Covering our NestJS application with unit tests can help us create a reliable product. In this article, we introduce the idea behind unit tests and implement them in an application working with Prisma.
In this article, we continue the code developed in API with NestJS #33. Managing PostgreSQL relationships with Prisma
Introducing unit tests
A unit test ensures that an individual piece of our code works properly. With them, we can make sure that various parts of our system function correctly in isolation.
When we run npm run test, Jest looks for files with a specific naming convention. By default, it includes files ending with .spec.ts. Another popular approach is to create files ending with .test.ts. We can look it up in our package.json file.
1{
2 // ...
3 "jest": {
4 "testRegex": ".*\\.test\\.ts$",
5 // ...
6 }
7}Let’s create a basic test for our AuthenticationService class.
1import { AuthenticationService } from './authentication.service';
2import { JwtService } from '@nestjs/jwt';
3import { ConfigService } from '@nestjs/config';
4import { UsersService } from '../users/users.service';
5import { PrismaService } from '../prisma/prisma.service';
6
7describe('The AuthenticationService', () => {
8 let authenticationService: AuthenticationService;
9 beforeEach(() => {
10 authenticationService = new AuthenticationService(
11 new UsersService(new PrismaService()),
12 new JwtService({
13 secretOrPrivateKey: 'Secret key',
14 }),
15 new ConfigService(),
16 );
17 });
18 describe('when calling the getCookieForLogOut method', () => {
19 it('should return a correct string', () => {
20 const result = authenticationService.getCookieForLogOut();
21 expect(result).toBe('Authentication=; HttpOnly; Path=/; Max-Age=0');
22 });
23 });
24});PASS src/authentication/tests/authentication.service.spec.ts The AuthenticationService when creating a cookie ✓ should return a string (12ms)
In the example above, we use the constructor of the AuthenticationService manually. While that’s a possible solution, we can depend on NestJS test utilities to do it for us. To do that, we need the Test.createTestingModule method from the @nestjs/testing library.
1import { AuthenticationService } from './authentication.service';
2import { JwtModule } from '@nestjs/jwt';
3import { ConfigModule } from '@nestjs/config';
4import { Test } from '@nestjs/testing';
5import { UsersModule } from '../users/users.module';
6import { PrismaModule } from '../prisma/prisma.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 PrismaModule,
20 ],
21 }).compile();
22
23 authenticationService = await module.get(AuthenticationService);
24 });
25 describe('when calling the getCookieForLogOut method', () => {
26 it('should return a correct string', () => {
27 const result = authenticationService.getCookieForLogOut();
28 expect(result).toBe('Authentication=; HttpOnly; Path=/; Max-Age=0');
29 });
30 });
31});Avoiding using a real database
When we take a look at our PrismaService, we can see that whenever we initialize it in our tests, we establish a connection with the database using the $connect() method.
1import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
2import { PrismaClient } from '@prisma/client';
3
4@Injectable()
5export class PrismaService
6 extends PrismaClient
7 implements OnModuleInit, OnModuleDestroy
8{
9 async onModuleInit() {
10 await this.$connect();
11 }
12
13 async onModuleDestroy() {
14 await this.$disconnect();
15 }
16}An essential thing about unit tests is that they should be independent. To depend on them, we need to ensure they are not affected by any possible issues with the database. The getCookieForLogOut method in our AuthenticationService does not require a database. However, a lot of other methods use the database. A good example is the getAuthenticatedUser method.
1import { BadRequestException, Injectable } from '@nestjs/common';
2import { UsersService } from '../users/users.service';
3
4@Injectable()
5export class AuthenticationService {
6 constructor(private readonly usersService: UsersService) {}
7
8 public async getAuthenticatedUser(email: string, plainTextPassword: string) {
9 try {
10 const user = await this.usersService.getByEmail(email);
11 await this.verifyPassword(plainTextPassword, user.password);
12 return user;
13 } catch (error) {
14 throw new BadRequestException();
15 }
16 }
17
18 // ...
19}To test the above logic in a unit test, we need to avoid making a request to the database.
When we look at our implementation of the AuthenticationService, we can see that it does not connect to the database directly. However, it uses the UsersService under the hood. We can provide a mocked instance of the UsersService that does not use our database.
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 { User } from '@prisma/client';
7import * as bcrypt from 'bcrypt';
8
9describe('The AuthenticationService', () => {
10 let userData: User;
11 let authenticationService: AuthenticationService;
12 let password: string;
13 beforeEach(async () => {
14 password = 'strongPassword123';
15 const hashedPassword = await bcrypt.hash(password, 10);
16 userData = {
17 id: 1,
18 email: 'john@smith.com',
19 name: 'John',
20 password: hashedPassword,
21 addressId: null,
22 };
23 const module = await Test.createTestingModule({
24 providers: [
25 AuthenticationService,
26 {
27 provide: UsersService,
28 useValue: {
29 getByEmail: jest.fn().mockResolved(userData),
30 },
31 },
32 ],
33 imports: [
34 ConfigModule.forRoot(),
35 JwtModule.register({
36 secretOrPrivateKey: 'Secret key',
37 }),
38 ],
39 }).compile();
40
41 authenticationService = await module.get(AuthenticationService);
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 the getAuthenticatedUser method is called', () => {
50 describe('and a valid email and password are provided', () => {
51 it('should return the new user', async () => {
52 const result = await authenticationService.getAuthenticatedUser(
53 userData.email,
54 password,
55 );
56 expect(result).toBe(userData);
57 });
58 });
59 });
60});Above, we provide a mocked version of the UsersService with the getByEmail method that always returns the data of a particular user. By doing that, we can know that our test won’t try to query users from the actual database.
Modifying our mock per test
In the above test, we assume that the getByEmail method returns a valid user. Unfortunately, that’s not always the case:
- when we provide an email of a user that exists in our database, it returns the user,
- if the user with that particular email does not exist, it throws the UserNotFoundException.
Let’s modify our mock before each test 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 { User } from '@prisma/client';
7import * as bcrypt from 'bcrypt';
8import { UserNotFoundException } from '../users/exceptions/userNotFound.exception';
9import { BadRequestException } from '@nestjs/common';
10
11describe('The AuthenticationService', () => {
12 let authenticationService: AuthenticationService;
13 let password: string;
14 let getByEmailMock: jest.Mock;
15 beforeEach(async () => {
16 getByEmailMock = jest.fn();
17 const module = await Test.createTestingModule({
18 providers: [
19 AuthenticationService,
20 {
21 provide: UsersService,
22 useValue: {
23 getByEmail: getByEmailMock,
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(AuthenticationService);
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 describe('when the getAuthenticatedUser method is called', () => {
44 describe('and a valid email and password are provided', () => {
45 let userData: User;
46 beforeEach(async () => {
47 password = 'strongPassword123';
48 const hashedPassword = await bcrypt.hash(password, 10);
49 userData = {
50 id: 1,
51 email: 'john@smith.com',
52 name: 'John',
53 password: hashedPassword,
54 addressId: null,
55 };
56 getByEmailMock.mockResolvedValue(userData); // 👈
57 });
58 it('should return the new user', async () => {
59 const result = await authenticationService.getAuthenticatedUser(
60 userData.email,
61 password,
62 );
63 expect(result).toBe(userData);
64 });
65 });
66 describe('and an invalid email is provided', () => {
67 beforeEach(() => {
68 getByEmailMock.mockRejectedValue(new UserNotFoundException()); // 👈
69 });
70 it('should throw the BadRequestException', () => {
71 return expect(async () => {
72 await authenticationService.getAuthenticatedUser(
73 'john@smith.com',
74 password,
75 );
76 }).rejects.toThrow(BadRequestException);
77 });
78 });
79 });
80});The AuthenticationService when the getAuthenticatedUser method is called and a valid email and password are provided ✓ should return the new user (106 ms) and an invalid email is provided ✓ should throw the BadRequestException (10 ms)
The essential thing to notice above is that we are not testing the UsersService itself. Focusing on a single class or a function is the essence of writing unit tests. We don’t check how classes work together but make sure they work as expected in isolation.
Mocking Prisma
So far, we’ve been able to avoid using our real database by mocking the UsersService. However, we should also test the UsersService class. Let’s take a quick look at the getByEmail method.
1import { Injectable } from '@nestjs/common';
2import { PrismaService } from '../prisma/prisma.service';
3import { UserNotFoundException } from './exceptions/userNotFound.exception';
4
5@Injectable()
6export class UsersService {
7 constructor(private readonly prismaService: PrismaService) {}
8
9 async getByEmail(email: string) {
10 const user = await this.prismaService.user.findUnique({
11 where: {
12 email,
13 },
14 });
15 if (!user) {
16 throw new UserNotFoundException();
17 }
18
19 return user;
20 }
21
22 // ...
23}There are two major cases above:
- if the findUnique method returns the user, getByEmail should return it too,
- if the findUnique method does not return the user, getByEmail should throw an error.
In order to test all of that, we need to mock the PrismaService.
1import { UsersService } from './users.service';
2import { PrismaService } from '../prisma/prisma.service';
3import { User } from '@prisma/client';
4import { Test } from '@nestjs/testing';
5import { UserNotFoundException } from './exceptions/userNotFound.exception';
6
7describe('The UsersService', () => {
8 let usersService: UsersService;
9 let findUniqueMock: jest.Mock;
10 beforeEach(async () => {
11 findUniqueMock = jest.fn();
12 const module = await Test.createTestingModule({
13 providers: [
14 UsersService,
15 {
16 provide: PrismaService,
17 useValue: {
18 user: {
19 findUnique: findUniqueMock,
20 },
21 },
22 },
23 ],
24 }).compile();
25
26 usersService = await module.get(UsersService);
27 });
28 describe('when the getByEmail function is called', () => {
29 describe('and the findUnique method returns the user', () => {
30 let user: User;
31 beforeEach(() => {
32 user = {
33 id: 1,
34 email: 'john@smith.com',
35 name: 'John',
36 password: 'strongPassword123',
37 addressId: null,
38 };
39 findUniqueMock.mockResolvedValue(user);
40 });
41 it('should return the user', async () => {
42 const result = await usersService.getByEmail(user.email);
43 expect(result).toBe(user);
44 });
45 });
46 describe('and the findUnique method does not return the user', () => {
47 beforeEach(() => {
48 findUniqueMock.mockResolvedValue(undefined);
49 });
50 it('should throw the UserNotFoundException', async () => {
51 return expect(async () => {
52 await usersService.getByEmail('john@smith.com');
53 }).rejects.toThrow(UserNotFoundException);
54 });
55 });
56 });
57});Summary
In this article, we’ve learned about writing unit tests with NestJS. As an example, we’ve used services that use Prisma to connect to our database. While we don’t always have to mock Prisma to avoid connecting to the actual database, we definitely need to know how to do that. There is still more to learn when it comes to testing with Prisma and NestJS, so stay tuned!