Integration tests with Prisma
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…
April 10, 2023
Previously, 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#
import { BadRequestException, Injectable } from "@nestjs/common"
import { UsersService } from "../users/users.service"
import * as bcrypt from "bcrypt"
import { UserNotFoundException } from "../users/exceptions/userNotFound.exception"
@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) {
if (error instanceof UserNotFoundException) {
throw new BadRequestException()
}
throw error
}
}
private async verifyPassword(plainTextPassword: string, hashedPassword: string) {
const isPasswordMatching = await bcrypt.compare(plainTextPassword, hashedPassword)
if (!isPasswordMatching) {
throw new BadRequestException("Wrong credentials provided")
}
} // ...
}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#
import { ConfigModule } from "@nestjs/config"
import { JwtModule } from "@nestjs/jwt"
import { Test } from "@nestjs/testing"
import { PrismaService } from "../prisma/prisma.service"
import { UsersService } from "../users/users.service"
import { AuthenticationService } from "./authentication.service"
describe("The AuthenticationService", () => {
let authenticationService: AuthenticationService
let password: string
let findUniqueMock: jest.Mock
beforeEach(async () => {
password = "strongPassword123"
findUniqueMock = jest.fn()
const module = await Test.createTestingModule({
providers: [
AuthenticationService,
UsersService,
{
provide: PrismaService,
useValue: {
user: {
findUnique: findUniqueMock,
},
},
},
],
imports: [
ConfigModule.forRoot(),
JwtModule.register({
secretOrPrivateKey: "Secret key",
}),
],
}).compile()
authenticationService = await module.get(AuthenticationService)
}) // ...
})First, we provide a Jest function in our PrismaService mock to be able to change the implementation per test.
{
provide: PrismaService,
useValue: {
user: {
findUnique: findUniqueMock,
},
},
},By doing that, we can alter the findUnique method to cover all of the cases:
- the
prismaService.user.findUniquemethod returns the requested user, - the
prismaService.user.findUniquemethod does not find the user.
authentication.service.test.ts#
import { BadRequestException } from "@nestjs/common"
import { User } from "@prisma/client"
import * as bcrypt from "bcrypt"
describe("The AuthenticationService", () => {
// ...
describe("when the getAuthenticatedUser method is called", () => {
describe("and the user can be found in the database", () => {
let user: User
beforeEach(async () => {
const hashedPassword = await bcrypt.hash(password, 10)
user = {
id: 1,
email: "john@smith.com",
name: "John",
password: hashedPassword,
addressId: null,
}
findUniqueMock.mockResolvedValue(user)
})
describe("and a correct password is provided", () => {
it("should return the new user", async () => {
const result = await authenticationService.getAuthenticatedUser(user.email, password)
expect(result).toBe(user)
})
})
describe("and an incorrect password is provided", () => {
it("should throw the BadRequestException", () => {
return expect(async () => {
await authenticationService.getAuthenticatedUser("john@smith.com", "wrongPassword")
}).rejects.toThrow(BadRequestException)
})
})
})
describe("and the user can not be found in the database", () => {
beforeEach(() => {
findUniqueMock.mockResolvedValue(undefined)
})
it("should throw the BadRequestException", () => {
return expect(async () => {
await authenticationService.getAuthenticatedUser("john@smith.com", password)
}).rejects.toThrow(BadRequestException)
})
})
})
})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.
npm install supertest @types/supertestLet’s start by testing the most basic case with registering the new user.
authentication.controller.test.ts#
import { INestApplication } from "@nestjs/common"
import { ConfigModule } from "@nestjs/config"
import { JwtModule } from "@nestjs/jwt"
import { Test } from "@nestjs/testing"
import { User } from "@prisma/client"
import * as request from "supertest"
import { PrismaService } from "../prisma/prisma.service"
import { UsersService } from "../users/users.service"
import { AuthenticationController } from "./authentication.controller"
import { AuthenticationService } from "./authentication.service"
describe("The AuthenticationController", () => {
let createUserMock: jest.Mock
let app: INestApplication
beforeEach(async () => {
createUserMock = jest.fn()
const module = await Test.createTestingModule({
providers: [
AuthenticationService,
UsersService,
{
provide: PrismaService,
useValue: {
user: {
create: createUserMock,
},
},
},
],
controllers: [AuthenticationController],
imports: [
ConfigModule.forRoot(),
JwtModule.register({
secretOrPrivateKey: "Secret key",
}),
],
}).compile()
app = module.createNestApplication()
await app.init()
})
describe("when the register endpoint is called", () => {
describe("and valid data is provided", () => {
let user: User
beforeEach(async () => {
user = {
id: 1,
email: "john@smith.com",
name: "John",
password: "strongPassword",
addressId: null,
}
})
describe("and the user is successfully created in the database", () => {
beforeEach(() => {
createUserMock.mockResolvedValue(user)
})
it("should return the new user without the password", async () => {
return request(app.getHttpServer())
.post("/authentication/register")
.send({
email: user.email,
name: user.name,
password: user.password,
})
.expect({
id: user.id,
name: user.name,
email: user.email,
addressId: null,
})
})
})
})
})
})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#
import { INestApplication } from "@nestjs/common"
import { Prisma, User } from "@prisma/client"
import * as request from "supertest"
import { PrismaError } from "../utils/prismaError"
describe("The AuthenticationController", () => {
let createUserMock: jest.Mock
let app: INestApplication // ...
describe("when the register endpoint is called", () => {
describe("and valid data is provided", () => {
let user: User
beforeEach(async () => {
user = {
id: 1,
email: "john@smith.com",
name: "John",
password: "strongPassword",
addressId: null,
}
}) // ...
describe("and the email is already taken", () => {
beforeEach(async () => {
createUserMock.mockImplementation(() => {
throw new Prisma.PrismaClientKnownRequestError("The user already exists", {
code: PrismaError.UniqueConstraintFailed,
clientVersion: "4.12.0",
})
})
})
it("should result in 400 Bad Request", async () => {
return request(app.getHttpServer())
.post("/authentication/register")
.send({
email: user.email,
name: user.name,
password: user.password,
})
.expect(400)
})
})
})
})
})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#
import { IsString, IsNotEmpty, IsEmail } from "class-validator"
class RegisterDto {
@IsString()
@IsNotEmpty()
@IsEmail()
email: string
@IsString()
@IsNotEmpty()
name: string
@IsString()
@IsNotEmpty()
password: string
}
export default RegisterDtoWhen testing the above, it is crucial to attach the ValidationPipe to our testing module.
import { INestApplication, ValidationPipe } from "@nestjs/common"
import { ConfigModule } from "@nestjs/config"
import { JwtModule } from "@nestjs/jwt"
import { Test } from "@nestjs/testing"
import * as request from "supertest"
import { PrismaService } from "../prisma/prisma.service"
import { UsersService } from "../users/users.service"
import { AuthenticationController } from "./authentication.controller"
import { AuthenticationService } from "./authentication.service"
describe("The AuthenticationController", () => {
let createUserMock: jest.Mock
let app: INestApplication
beforeEach(async () => {
createUserMock = jest.fn()
const module = await Test.createTestingModule({
providers: [
AuthenticationService,
UsersService,
{
provide: PrismaService,
useValue: {
user: {
create: createUserMock,
},
},
},
],
controllers: [AuthenticationController],
imports: [
ConfigModule.forRoot(),
JwtModule.register({
secretOrPrivateKey: "Secret key",
}),
],
}).compile()
app = module.createNestApplication()
app.useGlobalPipes(new ValidationPipe({ transform: true }))
await app.init()
})
describe("when the register endpoint is called", () => {
// ...
describe("and the email is missing", () => {
it("should result in 400 Bad Request", async () => {
return request(app.getHttpServer())
.post("/authentication/register")
.send({
name: "John",
password: "strongPassword",
})
.expect(400)
})
})
})
})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.