How to Write Tests for Node.js
So why are tests needed in modern app development practices?
Here are the reasons why, I think, conducting tests are essential:
- When tests are correctly written, developers detect issues more quickly.
- Tests help development teams come up with edge cases for their code. Tests should contain all possible scenarios. Because of that, they require developers to predict what kind of data is necessary to get into code.
- Tests make you more confident about your code. Every scenario that you test confirms that the code works properly.
- Previous tests ensure that the new code doesn't destroy the existing one.
In this guide, I’ll walk you through a basic introduction of writing tests for Node.js, including some hands-on tips when testing for your projects.
Types of automated testing
There are two main types of testing: automated and manual. In this guide, we’ll be focusing on automated tests, which can be split into three groups:
- Unit tests: These are low-level tests focused on a small portion of code. Unit tests are typically easy to write and fast to execute. For beginners, you can interpret them as writing separate tests for every function, class, or method. While writing this type of test, you should also focus on isolation of your code. For instance, when testing a method, developers shouldn’t rely on any other code. However, if there’s some kind of connection, usage of mocks or stubs would probably be needed.
- Integration tests: This type of testing validates behavior between many components. They can be used for testing the whole feature route — from calling the right endpoint to saving proper data to the database.
- End-to-end (e2e) tests: This is considered as a more advanced version of integration tests, which is why it’s sometimes referred to as broad integration tests. Some developers say that if an integration test includes authentication or authorization, then it’s a e2e test. In this type of testing, any mocks should be avoided.
Useful tools
Test doubles is a common name for tools used for creating fake objects that will, in some way, copy the behavior of the original object. This can be categorized to several types such as dummies, fakes, stubs, spies, and mocks. I’ll be focusing on the differences between the main categories, particularly stubs and mocks:
Stubs | Mocks |
Asserts state | Asserts behavior |
Possible to use in many tests (e.g. usage of factories) | Needs to be created for every test |
Due to multiple usage change in stub’s interface, it may case multiple errors in related specs | Changes to the code can cause errors only in related specs |
So why are mocks and stubs needed? In many cases, mocks are an inherent part of creating unit tests. Mocks can separate code that should be tested from parts that it relies on. Also, your code should never rely on external services. Obviously, there’s always a risk that your test code can break something (e.g. sending too many requests to external sources).
Further, some connections require secret API keys that shouldn’t be shared to test files. This is why connecting with external services should be replaced with some kind of mock or stub instance.
This way, you’ll only test your code without checking data that’s not from your site. Checking how well external services were tested by the provider should be an inherent part of picking sources for your project. Even though you won’t have control over this part, you have control over who delivers it.
In-memory databases
For testing purposes, in-memory databases can function as a mock connection with real databases. This solution will allow you to check if data was properly written onto or read from the database without using a production instance.
This approach will separate real records from tested ones. But you should remember that even though in-memory instances are quick and easy to implement, there’s always a risk that some tool provided by a real database won't be accessible in them.
Here is simple implementation (via usage of Nest.js, TypeScript, and Jest) of Mongo memory server for testing with UserSchema:
let service: UsersService;
let userModel: Model;
let mongod: MongoMemoryServer;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UsersService],
imports: [
MongooseModule.forRootAsync({
useFactory: async () => {
mongod = await MongoMemoryServer.create();
const uri = mongod.getUri();
return {
uri: uri,
};
},
}),
MongooseModule.forFeature([{ name: 'User', schema: UserSchema }]),
],
}).compile();
userModel = module.get<model>(getModelToken(User.name));
service = module.get(UsersService);
});
afterAll(async () => {
if (mongod) await mongod.stop();
});
Test coverage
Test coverage is one of the quality determinants for tests. As the name suggests, it refers to the proportion of the code that will be tested. There are many approaches to interpret test coverage, some programmers suggest that high percentage of coverage requires testing code that does not need any tests (like static objects) but it’s a bad practice. At the same time, if the percentage of test coverage is really low, there is a high possibility that some important code logic was not tested at all.
In my experience as a developer, I would recommend checking code coverage regularly but you don’t have to meet the required percentage of coverage. Arbitrary numbers should never determine how much code is tested but it can sometimes be a convenient reference point, which can show you what might have been skipped in the process of writing tests.
The Node package Jest allows you to set which files should be included in test coverage and which ones can be skipped. For instance, it’s possible to set it in package.json.
Applying such filters can be helpful to determine the optimal test coverage.
“jest”: {
“moduleFileExtensions”: [
“js”,
“json”,
“ts”
],
“rootDir”: “src”,
“testRegex”: “.*\\.spec\\.ts$“,
“transform”: {
“^.+\\.(t|j)s$“: “ts-jest”
},
“collectCoverageFrom”: [
“**/*.(t|j)s”
],
“coverageDirectory”: “../coverage”,
“testEnvironment”: “node”,
“coveragePathIgnorePatterns”: [
“src/app.module.ts”,
“src/main.ts”
]
}
To create Jest coverage, you should use the command: jest --coverage.
It will create the separate folder coverage
with an index.html
file, which will present a graphic with the sum of your coverage. By clicking on file link, you can see exactly which files or lines aren’t tested:
Hands-on
With the theoretical introductions above, you should have enough knowledge to write your own tests. In this section, we’ll be testing code written in TypeScript by using the Nest.js framework and Jest library.
How should your test files look?
First, make sure that the names of files, tested services, classes, and others should be almost identical to the tested file with .spec
suffix, such as:
- Files that should be tested:
users.service.ts
- Tests for the file:
users.service.spec.ts
There are different approaches to storing tests. Some programmers prefer keeping all tests in the test directory with several subdirectories (e.g. tests/unit
and tests/integration
). As an option, you can recreate the project’s structure in tests directory to traverse the tests suite, which can be easier. Personally, I would recommend splitting tests between two parts:
- Unit test: This one should be next to the file that will be tested.
- Integration or e2e tests: These tests should be in one directory (
tests
), separated from other parts of code.
Construction of code inside the test file
You can split the test file into a few segments. The first one could be the main description. This will contain information about what service, class, controller will be tested. Inside of it, there will be a beforeEach
hook, which should create an instance of a tested object, which ensures that all needed dependencies will be present. Thanks to the beforeEach
hook, you’ll have a clean set of fresh services that you can test. This will help you avoid interference between test cases.
Below the beforeEach segment, you should have describe()
for every method that will be tested. This can result in different outcomes. That’s why in your describe()
, you also need it()
segments, which will cover all possible scenarios for the tested method.
Other handy tools
Here are a couple of tools that you can also use.
AfterEach()
— Having this inside of adescribe()
segment allows you to take an action after any nestedit()
ordescribe()
. This is a convenient tool for cleaning databases or mocks.BeforeAll()
andAfterAll()
— This code will be executed once per description. It can be useful to create or end a connection with a database.
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
describe('UsersService', () => {
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UsersService],
}).compile();
service = module.get(UsersService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('method one', () => {
it('first possible scenario', () => {});
it('second possible scenario', () => {});
});
describe('method two', () => {
it('first possible scenario', () => {});
it('possible asynchronous scenario', async () => {});
});
});
How to create a mock object via Jest library
Here are some of ways to create a mock object via Jest:
- Creating mock object:
const ourMock = jest.fn()
. - By providing mock as a required service. I use the examples below frequently.
describe('UsersService', () => {
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{ provide: HttpService, useValue: { get: jest.fn() } },
{ provide: HttpService, useValue: jest.fn() },
{ provide: HttpService, useValue: ourMock },
],
}).compile();
service = module.get(UsersService);
});
- By using
spyOn:
const spy = jest.spyOn(httpService, ‘get’);
Here are a few things that you can do with your mock.
- Replacing a function with a new one:
jest.spyOn(users, “getIds”).mockImplementation(() => {
foo: ‘bar’;
});
- Returning
Promise.resolved
orpromise.rejected
:
jest.spyOn(users, “getIds”).mockResolvedValue([‘1’, ‘2’]);
- Returning value from a non-asynchronous function:
jest.spyOn(httpService, ‘get’).mockReturnValue(response);
- Changing if mock will return only one value every time until the description in which it was created will execute.
jest.spyOn(usersRepository, ‘find’).mockResolvedValueOnce(users);
If you want to clean your mocks in order to remove the previously set returned data, you can use:
jest.clearAllMocks();
What can you check in your tests?
Here are some of the things you can check as a result of testing.
- Checking if error was thrown:
it(‘Should throw Error’, async () => {
await expect(service.method()).rejects.toBeInstanceOf(Error);
});
- Checking if method was called:
expect(service.method).toBeCalled();
- Checking if method was called with the right arguments:
expect(service.method).toBeCalledWith(correctValue);
- Checking every calling of the method:
expect(service.method).toHaveBeenNthCalledWith(1, {});
expect(service.method).toHaveBeenNthCalledWith(2, correctValue);
- Checking returned http status and response message from called endpoint:
return request(app.getHttpServer())
.get(‘/’)
.expect(200)
.expect(‘Hello World!’);
});
Checking if returned value is an undefined or expected type:
expect(user.password).toBeUndefined();
Examples of unit test
In the code below, there are two possible scenarios for the method findOne. If there’s a user, the method should return all of their data (except the password). If the user doesn’t exist, then the method should throw an error.
describe(‘find user by login’, () => {
it(‘should return user data’, async () => {
const testUser = new userModel({
login: ‘testLogin’,
password: ‘password’,
importantData: ‘some data’,
});
await testUser.save();
const user = await service.findOne(‘testLogin’);
expect(user.login).toEqual(‘testLogin’);
expect(user.password).toBeUndefined();
expect(user.importantData).toEqual(‘some data’);
});
it(‘should throw error if user was not found’, async () => {
await expect(
service.findOne(‘thisUserWillNotExist’),
).rejects.toThrowError(‘Not found exception’);
});
});
In another unit test below, let’s check if a user exists in the database. Here we also have two possible scenarios. Either the user was found and the function returns true or the user wasn’t found (error was thrown) so the function return false. However, this time, it doesn’t rely on real UserService
but this mock:
describe(‘validateUser’, () => {
it(‘should return true, if user was found in userService’, async () => {
jest.spyOn(userService, ‘findOne’).mockImplementationOnce(async () => {
return Promise.resolve({ login: ‘login’ } as User & UserDocument);
});
const output = await service.validateUser(‘login’, ‘password’);
expect(userService.findOne).toBeCalledWith(‘login’);
expect(output).toBeTruthy();
});
it(‘should return false, if user was not found in userService’, async () => {
jest.spyOn(userService, ‘findOne’).mockImplementationOnce(async () => {
throw new Error();
});
const output = await service.validateUser(‘login’, ‘password’);
expect(output).toBeFalsy();
});
});
The following is an example of writing integration tests, which is simple validation step checking whether the appropriate status was returned after executing the query depending on whether the user's data is in the database:
it('/auth/login (POST) - invalid user', async () => {
const server = app.getHttpServer();
const response = await request(server)
.post('/auth/login')
.send({ username: 'username', password: 'password' });
expect(response.status).toEqual(401);
});
it('/auth/login (POST) - valid user', async () => {
const testUser = new userModel({
login: 'testLogin',
password: 'password',
importantData: 'some data',
});
await testUser.save();
const server = app.getHttpServer();
const response = await request(server)
.post('/auth/login')
.send({ username: 'testLogin', password: 'password' });
expect(response.status).toEqual(201);
});
Conclusion
Tests should be an inherent part of any project. As you can see, there are many possible ways to check your code and what’s occurring at every step in it. You should avoid sticking to only one possible testing method and try different approaches.
Ultimately, testing is a necessary tool in taking care of the quality of your code and shouldn’t be seen as an annoying duty.