Writing unit tests with Prisma
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…
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. package.json { // ... "jest": { "testRegex": ".*\\.test\\.ts$", // ... } }Let’s create a basic test for our AuthenticationService class. authentication.service.test.ts import { AuthenticationService } from './authentication.service'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { UsersService } from '../users/users.service'; import { PrismaService } from '../prisma/prisma.service'; describe('The AuthenticationService', () => { let authenticationService: AuthenticationService; beforeEach(() => { authenticationService = new AuthenticationService( new UsersService(new PrismaService()), new JwtService({ secretOrPrivateKey: 'Secret key', }), new ConfigService(), ); }); describe('when calling the getCookieForLogOut method', () => { it('should return a correct string', () => { const result = authenticationService.getCookieForLogOut(); expect(result).toBe('Authentication=; HttpOnly; Path=/; Max-Age=0'); }); }); }); 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. authentication.service.test.ts import { AuthenticationService } from './authentication.service'; import { JwtModule } from '@nestjs/jwt'; import { ConfigModule } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { UsersModule } from '../users/users.module'; import { PrismaModule } from '../prisma/prisma.module'; describe('The AuthenticationService', () => { let authenticationService: AuthenticationService; beforeEach(async () => { const module = await Test.createTestingModule({ providers: [AuthenticationService], imports: [ UsersModule, ConfigModule.forRoot(), JwtModule.register({ secretOrPrivateKey: 'Secret key', }), PrismaModule, ], }).compile(); authenticationService = await module.get(AuthenticationService); }); describe('when calling the getCookieForLogOut method', () => { it('should return a correct string', () => { const result = authenticationService.getCookieForLogOut(); expect(result).toBe('Authentication=; HttpOnly; Path=/; Max-Age=0'); }); }); }); 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.import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { async onModuleInit() { await this.$connect(); } async onModuleDestroy() { await this.$disconnect(); } }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. authentication.service.ts import { BadRequestException, Injectable } from '@nestjs/common'; import { UsersService } from '../users/users.service'; @Injectable() export class AuthenticationService { constructor(private readonly usersService: UsersService) {} public async getAuthenticatedUser(email: string, plainTextPassword: string) { try { const user = await this.usersService.getByEmail(email); await this.verifyPassword(plainTextPassword, user.password); return user; } catch (error) { throw new BadRequestException(); } } // ... }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. authentication.service.test.ts import { AuthenticationService } from './authentication.service'; import { JwtModule } from '@nestjs/jwt'; import { ConfigModule } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { UsersService } from '../users/users.service'; import { User } from '@prisma/client'; import * as bcrypt from 'bcrypt'; describe('The AuthenticationService', () => { let userData: User; let authenticationService: AuthenticationService; let password: string; beforeEach(async () => { password = 'strongPassword123'; const hashedPassword = await bcrypt.hash(password, 10); userData = { id: 1, email: 'john@smith.com', name: 'John', password: hashedPassword, addressId: null, }; const module = await Test.createTestingModule({ providers: [ AuthenticationService, { provide: UsersService, useValue: { getByEmail: jest.fn().mockResolved(userData), }, }, ], imports: [ ConfigModule.forRoot(), JwtModule.register({ secretOrPrivateKey: 'Secret key', }), ], }).compile(); authenticationService = await module.get(AuthenticationService); }); describe('when calling the getCookieForLogOut method', () => { it('should return a correct string', () => { const result = authenticationService.getCookieForLogOut(); expect(result).toBe('Authentication=; HttpOnly; Path=/; Max-Age=0'); }); }); describe('when the getAuthenticatedUser method is called', () => { describe('and a valid email and password are provided', () => { it('should return the new user', async () => { const result = await authenticationService.getAuthenticatedUser( userData.email, password, ); expect(result).toBe(userData); }); }); }); });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. authentication.service.test.ts import { AuthenticationService } from './authentication.service'; import { JwtModule } from '@nestjs/jwt'; import { ConfigModule } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { UsersService } from '../users/users.service'; import { User } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { UserNotFoundException } from '../users/exceptions/userNotFound.exception'; import { BadRequestException } from '@nestjs/common'; describe('The AuthenticationService', () => { let authenticationService: AuthenticationService; let password: string; let getByEmailMock: jest.Mock; beforeEach(async () => { getByEmailMock = jest.fn(); const module = await Test.createTestingModule({ providers: [ AuthenticationService, { provide: UsersService, useValue: { getByEmail: getByEmailMock, }, }, ], imports: [ ConfigModule.forRoot(), JwtModule.register({ secretOrPrivateKey: 'Secret key', }), ], }).compile(); authenticationService = await module.get(AuthenticationService); }); describe('when calling the getCookieForLogOut method', () => { it('should return a correct string', () => { const result = authenticationService.getCookieForLogOut(); expect(result).toBe('Authentication=; HttpOnly; Path=/; Max-Age=0'); }); }); describe('when the getAuthenticatedUser method is called', () => { describe('and a valid email and password are provided', () => { let userData: User; beforeEach(async () => { password = 'strongPassword123'; const hashedPassword = await bcrypt.hash(password, 10); userData = { id: 1, email: 'john@smith.com', name: 'John', password: hashedPassword, addressId: null, }; getByEmailMock.mockResolvedValue(userData); // 👈 }); it('should return the new user', async () => { const result = await authenticationService.getAuthenticate