Writing tests is crucial when aiming to develop a solid and reliable application. In this article, we explain the idea behind unit tests and write them for our application that works with raw SQL queries.
The idea behind unit tests
The job of a unit test is to make sure that an individual part of our application works as expected. Every test should be isolated and independent.
1import { AuthenticationService } from './authentication.service';
2import UsersService from '../users/users.service';
3import UsersRepository from '../users/users.repository';
4import { JwtService } from '@nestjs/jwt';
5import { ConfigService } from '@nestjs/config';
6import DatabaseService from '../database/database.service';
7import { Pool } from 'pg';
8
9describe('The AuthenticationService', () => {
10 let authenticationService: AuthenticationService;
11 beforeEach(() => {
12 authenticationService = new AuthenticationService(
13 new UsersService(new UsersRepository(new DatabaseService(new Pool()))),
14 new JwtService({
15 secretOrPrivateKey: 'Secret key',
16 }),
17 new ConfigService(),
18 );
19 });
20 describe('when calling the getCookieForLogOut method', () => {
21 it('should return a correct string', () => {
22 const result = authenticationService.getCookieForLogOut();
23 expect(result).toBe('Authentication=; HttpOnly; Path=/; Max-Age=0');
24 });
25 });
26});PASS src/authentication/authentication.service.test.ts The AuthenticationService when calling the getCookieForLogOut method ✓ should return a correct string
Above, you can see that we use the constructor of the AuthenticationService class. While we can provide all necessary dependencies manually, as in the example above, NestJS provides some utilities to help us.
By using the Test.createTestingModule method, we create a testing module. By doing that, we mock the entire NestJS runtime. Then, when we run its compile() method, we bootstrap the module with its dependencies in a similar way that our main.ts file works.
1import { AuthenticationService } from './authentication.service';
2import { JwtModule } from '@nestjs/jwt';
3import { ConfigModule, ConfigService } from '@nestjs/config';
4import { Test } from '@nestjs/testing';
5import { UsersModule } from '../users/users.module';
6import DatabaseModule from '../database/database.module';
7
8describe('The AuthenticationService', () => {
9 let authenticationService: AuthenticationService;
10 beforeEach(async () => {
11 const module = await Test.createTestingModule({
12 providers: [AuthenticationService],
13 imports: [
14 UsersModule,
15 ConfigModule.forRoot(),
16 JwtModule.register({
17 secretOrPrivateKey: 'Secret key',
18 }),
19 DatabaseModule.forRootAsync({
20 imports: [ConfigModule],
21 inject: [ConfigService],
22 useFactory: (configService: ConfigService) => ({
23 host: configService.get('POSTGRES_HOST'),
24 port: configService.get('POSTGRES_PORT'),
25 user: configService.get('POSTGRES_USER'),
26 password: configService.get('POSTGRES_PASSWORD'),
27 database: configService.get('POSTGRES_DB'),
28 }),
29 }),
30 ],
31 }).compile();
32
33 authenticationService = await module.get(
34 AuthenticationService,
35 );
36 });
37 describe('when calling the getCookieForLogOut method', () => {
38 it('should return a correct string', () => {
39 const result = authenticationService.getCookieForLogOut();
40 expect(result).toBe('Authentication=; HttpOnly; Path=/; Max-Age=0');
41 });
42 });
43});Mocking the database connection
There is a very significant problem with the above test suite. Importing our DatabaseModule class causes our application to try to connect to an actual database. This is something we definitely want to avoid when writing unit tests.
Simply removing the DatabaseModule from the imports array causes the following error:
Error: Nest can’t resolve dependencies of the UsersRepository (?). Please make sure that the argument DatabaseService at index [0] is available in the UsersModule context.
We need to acknowledge that the UsersService uses the database under the hood. To solve this problem, we can provide a mocked version of the UsersService class that does not use a real database.
It might be a good idea to avoid mocking whole modules when writing unit tests. This is because e don’t want to test how modules and classes interact with each other just yet.
1import { AuthenticationService } from './authentication.service';
2import { JwtModule } from '@nestjs/jwt';
3import { ConfigModule } from '@nestjs/config';
4import { Test } from '@nestjs/testing';
5import UsersService from '../users/users.service';
6import RegisterDto from './dto/register.dto';
7
8describe('The AuthenticationService', () => {
9 let registrationData: RegisterDto;
10 let authenticationService: AuthenticationService;
11 beforeEach(async () => {
12 registrationData = {
13 email: 'john@smith.com',
14 name: 'John',
15 password: 'strongPassword123',
16 };
17 const module = await Test.createTestingModule({
18 providers: [
19 AuthenticationService,
20 {
21 provide: UsersService,
22 useValue: {
23 create: jest.fn().mockReturnValue(registrationData),
24 },
25 },
26 ],
27 imports: [
28 ConfigModule.forRoot(),
29 JwtModule.register({
30 secretOrPrivateKey: 'Secret key',
31 }),
32 ],
33 }).compile();
34
35 authenticationService = await module.get(
36 AuthenticationService,
37 );
38 });
39 describe('when calling the getCookieForLogOut method', () => {
40 it('should return a correct string', () => {
41 const result = authenticationService.getCookieForLogOut();
42 expect(result).toBe('Authentication=; HttpOnly; Path=/; Max-Age=0');
43 });
44 });
45 describe('when registering a new user', () => {
46 describe('and when the usersService returns the new user', () => {
47 it('should return the new user', async () => {
48 const result = await authenticationService.register(registrationData);
49 expect(result).toBe(registrationData);
50 });
51 });
52 });
53});PASS src/authentication/authentication.service.test.ts The AuthenticationService when calling the getCookieForLogOut method ✓ should return a correct string when registering a new user and when the usersService returns the new user ✓ should return the new user
Thanks to mocking the UsersService, we are confident our tests won’t need the real database.
Changing the mock per test
In the above test, we always assume that the create method of the AuthenticationService returns a valid user. However, this is not always the case.
There are two major cases for the AuthenticationService.register methods:
- it returns the created user if there weren’t any problems with the data,
- it throws an error if the user with a given email address already exists.
Fortunately, we can change our mock per test. However, to do that, we need to ensure that the mock is accessible through every test. We can achieve that by creating a variable that we modify through the beforeEach hook.
Thanks to using beforeEach we ensure that each test is independent and does not affect the other tests.
1import { AuthenticationService } from './authentication.service';
2import { JwtModule } from '@nestjs/jwt';
3import { ConfigModule } from '@nestjs/config';
4import { Test } from '@nestjs/testing';
5import UsersService from '../users/users.service';
6import RegisterDto from './dto/register.dto';
7
8describe('The AuthenticationService', () => {
9 let registrationData: RegisterDto;
10 let authenticationService: AuthenticationService;
11 let createUserMock: jest.Mock;
12 beforeEach(async () => {
13 registrationData = {
14 email: 'john@smith.com',
15 name: 'John',
16 password: 'strongPassword123',
17 };
18 createUserMock = jest.fn();
19 const module = await Test.createTestingModule({
20 providers: [
21 AuthenticationService,
22 {
23 provide: UsersService,
24 useValue: {
25 create: createUserMock,
26 },
27 },
28 ],
29 imports: [
30 ConfigModule.forRoot(),
31 JwtModule.register({
32 secretOrPrivateKey: 'Secret key',
33 }),
34 ],
35 }).compile();
36
37 authenticationService = await module.get(
38 AuthenticationService,
39 );
40 });
41
42 // ...
43});Thanks to creating the createUserMock variable accessible in the whole test suite, we now have full control over it. We can now manipulate it to cover both of the above cases.
1import { AuthenticationService } from './authentication.service';
2import { JwtModule } from '@nestjs/jwt';
3import { ConfigModule } from '@nestjs/config';
4import { Test } from '@nestjs/testing';
5import UsersService from '../users/users.service';
6import UserAlreadyExistsException from '../users/exceptions/userAlreadyExists.exception';
7import { BadRequestException } from '@nestjs/common';
8import RegisterDto from './dto/register.dto';
9
10describe('The AuthenticationService', () => {
11 let registrationData: RegisterDto;
12 let authenticationService: AuthenticationService;
13 let createUserMock: jest.Mock;
14 beforeEach(async () => {
15 registrationData = {
16 email: 'john@smith.com',
17 name: 'John',
18 password: 'strongPassword123',
19 };
20 createUserMock = jest.fn();
21 const module = await Test.createTestingModule({
22 providers: [
23 AuthenticationService,
24 {
25 provide: UsersService,
26 useValue: {
27 create: createUserMock,
28 },
29 },
30 ],
31 imports: [
32 ConfigModule.forRoot(),
33 JwtModule.register({
34 secretOrPrivateKey: 'Secret key',
35 }),
36 ],
37 }).compile();
38
39 authenticationService = await module.get(
40 AuthenticationService,
41 );
42 });
43 describe('when calling the getCookieForLogOut method', () => {
44 it('should return a correct string', () => {
45 const result = authenticationService.getCookieForLogOut();
46 expect(result).toBe('Authentication=; HttpOnly; Path=/; Max-Age=0');
47 });
48 });
49 describe('when registering a new user', () => {
50 describe('and when the usersService returns the new user', () => {
51 beforeEach(() => {
52 createUserMock.mockReturnValue(registrationData);
53 });
54 it('should return the new user', async () => {
55 const result = await authenticationService.register(registrationData);
56 expect(result).toBe(registrationData);
57 });
58 });
59 describe('and when the usersService throws the UserAlreadyExistsException', () => {
60 beforeEach(() => {
61 createUserMock.mockImplementation(() => {
62 throw new UserAlreadyExistsException(registrationData.email);
63 });
64 });
65 it('should throw the BadRequestException', () => {
66 return expect(() =>
67 authenticationService.register(registrationData),
68 ).rejects.toThrow(BadRequestException);
69 });
70 });
71 });
72});PASS src/authentication/authentication.service.test.ts The AuthenticationService when calling the getCookieForLogOut method ✓ should return a correct string when registering a new user and when the usersService returns the new user ✓ should return the new user and when the usersService throws the UserAlreadyExistsException ✓ should throw the BadRequestException
Above, we are covering two separate cases:
- when the usersService returns the new user,
- when the usersService throws an error.
The crucial thing to notice is that we are not testing the UsersService. Our tests focus solely on the methods of the AuthenticationService class, which is the essence of the unit tests. When writing unit tests, we don’t verify how classes work together but rather ensure they perform in isolation.
Testing the data repository
So far, in this article, we didn’t test a class that directly uses our DatabaseService. Let’s test the create method of our UsersRepository.
1import { CreateUserDto } from './dto/createUser.dto';
2import { Test } from '@nestjs/testing';
3import DatabaseService from '../database/database.service';
4import UsersRepository from './users.repository';
5import UserModel, { UserModelData } from './user.model';
6import DatabaseError from '../types/databaseError';
7import PostgresErrorCode from '../database/postgresErrorCode.enum';
8import UserAlreadyExistsException from './exceptions/userAlreadyExists.exception';
9
10describe('The UsersRepository class', () => {
11 let runQueryMock: jest.Mock;
12 let createUserData: CreateUserDto;
13 let usersRepository: UsersRepository;
14 beforeEach(async () => {
15 createUserData = {
16 name: 'John',
17 email: 'john@smith.com',
18 password: 'strongPassword123',
19 };
20 runQueryMock = jest.fn();
21 const module = await Test.createTestingModule({
22 providers: [
23 UsersRepository,
24 {
25 provide: DatabaseService,
26 useValue: {
27 runQuery: runQueryMock,
28 },
29 },
30 ],
31 }).compile();
32
33 usersRepository = await module.get(UsersRepository);
34 });
35 describe('when the create method is called', () => {
36 describe('and the database returns valid data', () => {
37 let userModelData: UserModelData;
38 beforeEach(() => {
39 userModelData = {
40 id: 1,
41 name: 'John',
42 email: 'john@smith.com',
43 password: 'strongPassword123',
44 address_id: null,
45 address_street: null,
46 address_city: null,
47 address_country: null,
48 };
49 runQueryMock.mockResolvedValue({
50 rows: [userModelData],
51 });
52 });
53 it('should return an instance of the UserModel', async () => {
54 const result = await usersRepository.create(createUserData);
55
56 expect(result instanceof UserModel).toBe(true);
57 });
58 it('should return the UserModel with correct properties', async () => {
59 const result = await usersRepository.create(createUserData);
60
61 expect(result.id).toBe(userModelData.id);
62 expect(result.email).toBe(userModelData.email);
63 expect(result.name).toBe(userModelData.name);
64 expect(result.password).toBe(userModelData.password);
65 expect(result.address).not.toBeDefined();
66 });
67 });
68 describe('and the database throws the UniqueViolation', () => {
69 beforeEach(() => {
70 const databaseError: DatabaseError = {
71 code: PostgresErrorCode.UniqueViolation,
72 table: 'users',
73 detail: 'Key (email)=(john@smith.com) already exists.',
74 };
75 runQueryMock.mockImplementation(() => {
76 throw databaseError;
77 });
78 });
79 it('should throw the UserAlreadyExistsException exception', () => {
80 return expect(() =>
81 usersRepository.create(createUserData),
82 ).rejects.toThrow(UserAlreadyExistsException);
83 });
84 });
85 });
86});PASS src/users/users.repository.test.ts The UsersRepository class when the create method is called and the database returns valid data ✓ should return an instance of the UserModel ✓ should return the UserModel with correct properties and the database throws the UniqueViolation ✓ should throw the UserAlreadyExistsException exception
Above, we are testing two crucial cases of the create method:
- the user is successfully added to the database,
- the database fails to add the user due to the unique violation error.
Feel free to take it a step further and test if the UsersRepository makes the right SQL query.
Summary
In this article, we’ve learned what unit tests are and how to implement them with NestJS. We’ve focused on testing the parts of our application that communicate with a database. Since unit tests shouldn’t use a real database connection, we’ve learned how to mock our services and repositories. There is still more to learn when it comes to testing NestJS when using SQL, so stay tuned!