Nest.js Tutorial

Integration tests with Prisma

Marcin Wanago
NestJS

In the previous part of this series, we learned how to write unit tests in a NestJS project with Prisma. Unit tests help verify if individual components of our system work as expected on their own. However, while useful, they don’t guarantee that our API functions correctly as a whole. In this article, we deal with it by implementing integration tests.

You can find all of the code from this article in this repository.

Introducing integration tests

An integration test verifies if multiple parts of our application work together. We can do that by testing the integration of two or more pieces of our system.

Let’s investigate the getAuthenticatedUser method in our AuthenticationService.

authentication.service.ts
1import { BadRequestException, Injectable } from '@nestjs/common';
2import { UsersService } from '../users/users.service';
3import * as bcrypt from 'bcrypt';
4import { UserNotFoundException } from '../users/exceptions/userNotFound.exception';
5 
6@Injectable()
7export class AuthenticationService {
8  constructor(private readonly usersService: UsersService) {}
9 
10  public async getAuthenticatedUser(email: string, plainTextPassword: string) {
11    try {
12      const user = await this.usersService.getByEmail(email);
13      await this.verifyPassword(plainTextPassword, user.password);
14      return user;
15    } catch (error) {
16      if (error instanceof UserNotFoundException) {
17        throw new BadRequestException();
18      }
19      throw error;
20    }
21  }
22 
23  private async verifyPassword(
24    plainTextPassword: string,
25    hashedPassword: string,
26  ) {
27    const isPasswordMatching = await bcrypt.compare(
28      plainTextPassword,
29      hashedPassword,
30    );
31    if (!isPasswordMatching) {
32      throw new BadRequestException('Wrong credentials provided');
33    }
34  }
35 
36  // ...
37}

When we look closely, we can distinguish a few cases. The getAuthenticatedUser method:

  • should throw an error if the user can’t be found,
  • should throw an error if the provided password is not valid,
  • should return the user if the user is found and the provided password is correct.

In the previous article, we tested the AuthenticationService in isolation and mocked the UsersService. This time, let’s test how they work together.

Even though we are writing an integration test, it does not necessarily mean we want to include every part of our system. For example, in this particular case, we still want to mock our PrismaService class to avoid making actual database calls.

authentication.service.test.ts
1import { AuthenticationService } from './authentication.service';
2import { JwtModule } from '@nestjs/jwt';
3import { ConfigModule } from '@nestjs/config';
4import { Test } from '@nestjs/testing';
5import { PrismaService } from '../prisma/prisma.service';
6import { UsersService } from '../users/users.service';
7 
8describe('The AuthenticationService', () => {
9  let authenticationService: AuthenticationService;
10  let password: string;
11  let findUniqueMock: jest.Mock;
12  beforeEach(async () => {
13    password = 'strongPassword123';
14    findUniqueMock = jest.fn();
15    const module = await Test.createTestingModule({
16      providers: [
17        AuthenticationService,
18        UsersService,
19        {
20          provide: PrismaService,
21          useValue: {
22            user: {
23              findUnique: findUniqueMock,
24            },
25          },
26        },
27      ],
28      imports: [
29        ConfigModule.forRoot(),
30        JwtModule.register({
31          secretOrPrivateKey: 'Secret key',
32        }),
33      ],
34    }).compile();
35 
36    authenticationService = await module.get(AuthenticationService);
37  });
38 
39  // ...
40});

First, we provide a Jest function in our PrismaService mock to be able to change the implementation per test.

1{
2  provide: PrismaService,
3  useValue: {
4    user: {
5      findUnique: findUniqueMock,
6    },
7  },
8},

By doing that, we can alter the findUnique method to cover all of the cases:

  • the prismaService.user.findUnique method returns the requested user,
  • the prismaService.user.findUnique method does not find the user.
authentication.service.test.ts
1import { User } from '@prisma/client';
2import { BadRequestException } from '@nestjs/common';
3import * as bcrypt from 'bcrypt';
4 
5describe('The AuthenticationService', () => {
6  // ...
7  
8  describe('when the getAuthenticatedUser method is called', () => {
9    describe('and the user can be found in the database', () => {
10      let user: User;
11      beforeEach(async () => {
12        const hashedPassword = await bcrypt.hash(password, 10);
13        user = {
14          id: 1,
15          email: 'john@smith.com',
16          name: 'John',
17          password: hashedPassword,
18          addressId: null,
19        };
20        findUniqueMock.mockResolvedValue(user);
21      });
22      describe('and a correct password is provided', () => {
23        it('should return the new user', async () => {
24          const result = await authenticationService.getAuthenticatedUser(
25            user.email,
26            password,
27          );
28          expect(result).toBe(user);
29        });
30      });
31      describe('and an incorrect password is provided', () => {
32        it('should throw the BadRequestException', () => {
33          return expect(async () => {
34            await authenticationService.getAuthenticatedUser(
35              'john@smith.com',
36              'wrongPassword',
37            );
38          }).rejects.toThrow(BadRequestException);
39        });
40      });
41    });
42    describe('and the user can not be found in the database', () => {
43      beforeEach(() => {
44        findUniqueMock.mockResolvedValue(undefined);
45      });
46      it('should throw the BadRequestException', () => {
47        return expect(async () => {
48          await authenticationService.getAuthenticatedUser(
49            'john@smith.com',
50            password,
51          );
52        }).rejects.toThrow(BadRequestException);
53      });
54    });
55  });
56});
The AuthenticationService when the getAuthenticatedUser method is called and the user can be found in the database and a correct password is provided ✓ should return the new user and an incorrect password is provided ✓ should throw the BadRequestException and the user can not be found in the database ✓ should throw the BadRequestException

By not mocking the UsersService in the above tests, we ensured that it integrates as expected with the AuthenticationService. Whenever we call the authenticationService.getAuthenticatedUser method in our test, it uses the actual usersService.getByEmail method under the hood.

Testing controllers using API calls

Another approach we could take to our integration testing is to perform HTTP requests to our API. This allows us to test multiple application layers, starting with the controllers.

To perform the tests, we need the SuperTest library.

1npm install supertest @types/supertest

Let’s start by testing the most basic case with registering the new user.

authentication.controller.test.ts
1import { AuthenticationService } from './authentication.service';
2import { JwtModule } from '@nestjs/jwt';
3import { ConfigModule } from '@nestjs/config';
4import { Test } from '@nestjs/testing';
5import { User } from '@prisma/client';
6import { PrismaService } from '../prisma/prisma.service';
7import { UsersService } from '../users/users.service';
8import { INestApplication } from '@nestjs/common';
9import * as request from 'supertest';
10import { AuthenticationController } from './authentication.controller';
11 
12describe('The AuthenticationController', () => {
13  let createUserMock: jest.Mock;
14  let app: INestApplication;
15  beforeEach(async () => {
16    createUserMock = jest.fn();
17    const module = await Test.createTestingModule({
18      providers: [
19        AuthenticationService,
20        UsersService,
21        {
22          provide: PrismaService,
23          useValue: {
24            user: {
25              create: createUserMock,
26            },
27          },
28        },
29      ],
30      controllers: [AuthenticationController],
31      imports: [
32        ConfigModule.forRoot(),
33        JwtModule.register({
34          secretOrPrivateKey: 'Secret key',
35        }),
36      ],
37    }).compile();
38 
39    app = module.createNestApplication();
40    await app.init();
41  });
42  describe('when the register endpoint is called', () => {
43    describe('and valid data is provided', () => {
44      let user: User;
45      beforeEach(async () => {
46        user = {
47          id: 1,
48          email: 'john@smith.com',
49          name: 'John',
50          password: 'strongPassword',
51          addressId: null,
52        };
53      });
54      describe('and the user is successfully created in the database', () => {
55        beforeEach(() => {
56          createUserMock.mockResolvedValue(user);
57        });
58        it('should return the new user without the password', async () => {
59          return request(app.getHttpServer())
60            .post('/authentication/register')
61            .send({
62              email: user.email,
63              name: user.name,
64              password: user.password,
65            })
66            .expect({
67              id: user.id,
68              name: user.name,
69              email: user.email,
70              addressId: null,
71            });
72        });
73      });
74    });
75  });
76});
The AuthenticationController when the register endpoint is called and valid data is provided and the user is successfully created in the database ✓ should return the new user without the password

In the above code, we create a testing module that includes the AuthenticationController. This way, we can make an HTTP request to the /register endpoint.

Besides the most basic case, we can also test what happens if the email is already taken. To do that, we must ensure our mocked method throws the PrismaClientKnownRequestError.

authentication.controller.test.ts
1import { Prisma, User } from '@prisma/client';
2import { INestApplication } from '@nestjs/common';
3import * as request from 'supertest';
4import { PrismaError } from '../utils/prismaError';
5 
6describe('The AuthenticationController', () => {
7  let createUserMock: jest.Mock;
8  let app: INestApplication;
9  // ...
10  describe('when the register endpoint is called', () => {
11    describe('and valid data is provided', () => {
12      let user: User;
13      beforeEach(async () => {
14        user = {
15          id: 1,
16          email: 'john@smith.com',
17          name: 'John',
18          password: 'strongPassword',
19          addressId: null,
20        };
21      });
22      // ...
23      describe('and the email is already taken', () => {
24        beforeEach(async () => {
25          createUserMock.mockImplementation(() => {
26            throw new Prisma.PrismaClientKnownRequestError(
27              'The user already exists',
28              {
29                code: PrismaError.UniqueConstraintFailed,
30                clientVersion: '4.12.0',
31              },
32            );
33          });
34        });
35        it('should result in 400 Bad Request', async () => {
36          return request(app.getHttpServer())
37            .post('/authentication/register')
38            .send({
39              email: user.email,
40              name: user.name,
41              password: user.password,
42            })
43            .expect(400);
44        });
45      });
46    });
47  });
48});
The AuthenticationController when the register endpoint is called and valid data is provided and the user is successfully created in the database ✓ should return the new user without the password and the email is already taken ✓ should result in 400 Bad Request

Testing the validation

Our NestJS application validates the data sent when making POST requests. For example, we check if the provided registration data contains a valid email, name, and password.

register.dto.ts
1import { IsString, IsNotEmpty, IsEmail } from 'class-validator';
2 
3class RegisterDto {
4  @IsString()
5  @IsNotEmpty()
6  @IsEmail()
7  email: string;
8 
9  @IsString()
10  @IsNotEmpty()
11  name: string;
12 
13  @IsString()
14  @IsNotEmpty()
15  password: string;
16}
17 
18export default RegisterDto;

When testing the above, it is crucial to attach the ValidationPipe to our testing module.

1import { AuthenticationService } from './authentication.service';
2import { JwtModule } from '@nestjs/jwt';
3import { ConfigModule } from '@nestjs/config';
4import { Test } from '@nestjs/testing';
5import { PrismaService } from '../prisma/prisma.service';
6import { UsersService } from '../users/users.service';
7import { INestApplication, ValidationPipe } from '@nestjs/common';
8import * as request from 'supertest';
9import { AuthenticationController } from './authentication.controller';
10 
11describe('The AuthenticationController', () => {
12  let createUserMock: jest.Mock;
13  let app: INestApplication;
14  beforeEach(async () => {
15    createUserMock = jest.fn();
16    const module = await Test.createTestingModule({
17      providers: [
18        AuthenticationService,
19        UsersService,
20        {
21          provide: PrismaService,
22          useValue: {
23            user: {
24              create: createUserMock,
25            },
26          },
27        },
28      ],
29      controllers: [AuthenticationController],
30      imports: [
31        ConfigModule.forRoot(),
32        JwtModule.register({
33          secretOrPrivateKey: 'Secret key',
34        }),
35      ],
36    }).compile();
37 
38    app = module.createNestApplication();
39    app.useGlobalPipes(new ValidationPipe({ transform: true }));
40    await app.init();
41  });
42  describe('when the register endpoint is called', () => {
43    // ...
44    describe('and the email is missing', () => {
45      it('should result in 400 Bad Request', async () => {
46        return request(app.getHttpServer())
47          .post('/authentication/register')
48          .send({
49            name: 'John',
50            password: 'strongPassword',
51          })
52          .expect(400);
53      });
54    });
55  });
56});
The AuthenticationController when the register endpoint is called and valid data is provided and the user is successfully created in the database ✓ should return the new user without the password and the email is already taken ✓ should result in 400 Bad Request and the email is missing ✓ should result in 400 Bad Request

Summary

In this article, we’ve gone through the idea of writing integration tests. As an example, we’ve used a NestJS application using Prisma. When doing so, we had to learn how to mock Prisma properly, including throwing errors. All of the above will definitely help us ensure that our app works as expected.