Almost every back-end application has to communicate with external dependencies at some point. In this post, we'll learn how to properly test them using NestJS - and no, it's not simply "mocking out" stuff. Here's what we'll cover:
- Types of dependencies: Managed VS Unmanaged;
- Types of Test Doubles;
- When to use Mocks and when to replace them with Spies;
- When to use Stubs;
And as always, you can check out the final code at the public repository. Good reading!
Types of Dependencies
Vladimir Khorikov, in his exceptional book "Unit Testing: Principles, Practices and Patterns", categorizes dependencies (broadly) into two types:
- Managed Dependencies— Out-of-process dependencies our application has full control and are only visible to the external world through our system. Example: Database.
- Unmanaged Dependencies - Out-of-process dependencies that we don't exert full control over their state and are visible to the external world. Example: SMTP Server.
Put an image here illustrating these two types of dependencies
While managed dependencies shouldn't be mocked during tests to preserve the application's behavior, unmanaged dependencies have to be replaced with test doubles. For instance, when part of a use case flow consists of sending an email to a user, we don't want to send an actual email — and potentially fill up some spam inboxes.
So, what do we do instead? Let's start with an example of an authentication flow.
The Feature
Suppose we're tasked to implement a signup
endpoint - one of the very first steps when building a new application. The requirement is described using Gherkin for the sake of explicitness:
Feature: Sign up
Scenario: A user signs up successfully
When a user signs up with email "new-user@mail.com" and password "password"
Then they are authenticated
And they should receive an email with subject "Welcome".
Scenario: A user sign up fails due to leaked password
Given the password "weak" has leaked in a rainbow table
When a user tries to sign up with the email "new-user@mail.com" and password "weak"
Then they receive an error message: "Your password has leaked in a data breach. Please choose a different one."
Just by looking at the requirements, we can foresee we'll need an EmailService
to send that confirmation email and a PasswordService
that verifies whether the given password has leaked in a rainbow table (Consider we have an external API available that returns that information). Here's a chart illustrating how that flow should look like:
With that context in mind, let's move forward to the implementation.
Creating the test cases
A helpful tip when developing a new feature is to start by defining the acceptance test descriptions in our code (we already did the high-level one with the Gherkin syntax). Let's use the base end-to-end Nest example and write down the specifications:
describe('SignUp user (e2e)', () => {
let testingApp: INestApplication<App>;
beforeAll(async () => {
const testingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
testingApp = testingModule.createNestApplication();
await testingApp.init();
});
test.todo('creates a new user and sends a confirmation email');
test.todo('returns an error when password hash is in a rainbow table')
});
Now that we have both cases noted, we can pick the easiest one to define and implement. Let's do so and start with the success case:
test('create a new user and sends a confirmation email', async () => {
const server = testingApp.getHttpServer();
const response = await request(server).post('/auth/signup').send({
email: 'new-user@mail.com',
password: 'password',
});
const jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/;
expect(response.body).toHaveProperty(
'accessToken',
expect.stringMatching(jwtRegex),
);
// How do we test that the email was sent?
});
At this point, we can implement the simplest possible controller to make the test pass:
import { Controller, Post } from '@nestjs/common';
@Controller('auth')
export class AuthController {
@Post('signup')
signup() {
return {
accessToken: 'a.jwt.token',
};
}
}
The test is pretty simple. We expect the endpoint to accept an email
and a password
in the body and return a jwt
in the response. We don't have to verify the jwt's validity now, so we just check the expected syntax ({alpha}.{alpha}.{alpha}
). Then, the open question is how to verify that the email was sent. Well, mocks are designed precisely to do that.
Using mocks to verify the external dependency
So, let's define what we expect the EmailService
to look like and utilize jest's "spyOn()" method to create a spy that can observe and modify the service's behavior:
// file: src/email/email.service.ts
import { Injectable } from '@nestjs/common';
export type SendEmailInput = {
to: string;
subject: string;
content: string;
};
@Injectable()
export class EmailService {
public sendEmail(input: SendEmailInput) {
console.log('Sending email to %s with subject %s', input.email, input.subject);
}
}
And the updated test file:
test('create a new user and sends a confirmation email', async () => {
// Arrange
const server = testingApp.getHttpServer();
const emailService = testingApp.get(EmailService);
const spy = jest
.spyOn(emailService, 'sendEmail')
.mockImplementationOnce(() => {}) // We don't need any real implementation
.mockName('mockedSendEmail'); // this is used to display a custom function name instead of `jest.fn()` if this test fails.
// Act
const response = await request(server).post('/auth/signup').send({
email: 'new-user@mail.com',
password: 'password',
});
// Assert
const jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/;
expect(response.body).toHaveProperty(
'accessToken',
expect.stringMatching(jwtRegex),
);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith({
to: 'new-user@mail.com',
subject: 'Welcome!',
content: 'Welcome to our app!',
});
});
Executing this test will fail because the EmailService
isn't used in our dummy authentication controller. Let's fix this:
import { Body, Controller, Post } from '@nestjs/common';
import { EmailService } from '../email/email.service';
@Controller('auth')
export class AuthController {
constructor(private readonly emailService: EmailService) {}
@Post('signup')
signup(@Body() input: { email: string; password: string }) {
this.emailService.sendEmail({
to: input.email,
subject: 'Welcome!',
content: 'Welcome to our app!',
});
return {
accessToken: 'a.jwt.token',
};
}
}
ℹ️ Note
We haven't defined a type for our body nor its validation because it's out of the scope of this article.
Finally, our test case is green now. We asserted that an email is sent to the user by verifying the EmailService.sendEmail()
method. All good, right? Well, not so much. Let's think of a few drawbacks of that approach:
- To spy on
sendEmail()
, we must know its exact name beforehand:jest.spyOn(emailService, 'sendEmail')
. ' If we change that method's name or signature, our tests must also change. - Whenever we need to mock that EmailService on other test files for different use cases, we must repeat the code to spy on that same method again. If we have to change that method's name or signature as before, the amount of work is multiplied by the number of places using it.
- We must know the exact parameter names and schema in the assertion step:
expect(spy).toHaveBeenCalledWith({ to: 'to,' subject: 'subject,' content: '...content...' })
If that signature ever changes, all places using it break too.
In summary, using mocks by default might be helpful in the short term. Still, they increase the likelihood of breaking our test code — and a very relevant metric for good automated tests is their maintainability. We don't want the headache of changing a bunch of test files just because our code's internal structure has also changed. So, what's the alternative?
Spy, Don't Mock
A Spy is basically a handwritten mock. They serve the same purpose, replacing an outgoing interaction between our system and the external world, but creating a spy gives us more control over its interface and behavior while also fostering reusability. But just before creating our spy, we must create an interface that defines the observable (and "mockable") behavior of that EmailService
:
export type SendEmailInput = {
to: string;
subject: string;
content: string;
};
export abstract class IEmailService {
abstract sendEmail(input: SendEmailInput): void;
}
ℹ️ Note
We define the "interface" above as an abstract class because we can use an abstract class as an injection token in NestJS and also use the implements keyword - If we used an interface instead, we would have to create a symbol to represent that dependency, something likeconst EMAIL_SERVICE = Symbol.for('EmailService')
And the module has to be changed as well to provide this interface:
// File: src/email/email.module.ts
@Module({
providers: [
{
provide: IEmailService,
useClass: EmailService,
},
],
exports: [IEmailService],
})
export class EmailModule {}
Although I don't recommend creating an interface for dependencies before there are at least two different implementations, creating one to enable spies is a reasonable exception to the rule. We want our spy to implement the exact same "API" as our production server, no more and no less. Before checking out the spy implementation, let's take a look on how it's used in the test case:
test('create a new user and sends a confirmation email', async () => {
// Arrange
const server = testingApp.getHttpServer();
// Act
const response = await request(server).post('/auth/signup').send({
email: 'new-user@mail.com',
password: 'password',
});
// Assert
const jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/;
expect(response.body).toHaveProperty(
'accessToken',
expect.stringMatching(jwtRegex),
);
const emailServiceSpy = testingApp.get<EmailServiceSpy>(IEmailService); // The Spy is provided via the IEmailService token. However, we have to type it in the generics with `testingApp.get<EmailServiceSpy>` to have access to its helper methods below.
emailServiceSpy
.shouldHaveSentNumberOfEmails(1)
.toAddress('new-user@mail.com')
.withSubject('Welcome!')
.withContent('Welcome to our app!');
});
As you can notice from the code above, we build a spy that exposes a fluent-interface API we can use to build the assertions. Comparing to the use of mocks, it has the following benefits:
- Readability. The assertion methods names can be written using more meaningful words than the parameter names alone.
-
Encapsulation. We don't have to know the actual parameter names inside the
sendEmail()
method - this is abstracted by the helper methods - Customizability. We can add and modify these helper methods for more complex use cases
-
Reusability. Once our spy is defined, we can reuse it for all tests with the
IEmailService
as a dependency.
Finally, here's the EmailSpy implementation (intentionally postponed as I deem its utilization more meaningful than the implementation details):
// File: src/email/test/email.service.spy.ts
@Injectable({
scope: Scope.TRANSIENT, // Scope is transient to ensure we always provide a different instance for every class that depends on it.
})
export class EmailServiceSpy implements IEmailService {
private readonly emailsSent: SendEmailInput[] = [];
private readonly logger = new Logger(EmailServiceSpy.name);
sendEmail(input: SendEmailInput): void {
this.logger.debug(
`Sending email to ${input.to} with subject ${input.subject}`,
);
this.emailsSent.push(input);
}
shouldHaveSentNumberOfEmails(expected: number) {
expect(this.emailsSent.length).toBe(expected);
return this;
}
toAddress(email: string) {
const lastEmail = this.emailsSent.at(-1);
expect(lastEmail?.to).toEqual(email);
return this;
}
withSubject(subject: string) {
const lastEmail = this.emailsSent.at(-1);
expect(lastEmail?.subject).toEqual(subject);
return this;
}
withContent(content: string) {
const lastEmail = this.emailsSent.at(-1);
expect(lastEmail?.content).toEqual(expect.stringContaining(content));
return this;
}
}
In the next section we'll cover using Stubs for our second feature scenario: 'returns an error when password hash is in a rainbow table'
Using Stubs for incoming interactions
Now that we covered testing an outgoing interaction with a Spy, let's get back to the second feature and write down its test case:
test('returns an error when password hash is in a rainbow table', async () => {
// Arrange
const server = testingApp.getHttpServer();
// We should have a way to stub the response of a "PasswordService" we haven't created yet.
// Act
const response = await request(server).post('/auth/signup').send({
email: 'new-user@mail.com',
password: 'weak',
});
// Assert
expect(response.status).toBe(400);
expect(response.body).toEqual({
title: 'Bad Request',
detail:
'Your password has leaked in a data breach. Please choose a different one.',
});
});
The question now is how to fake the response of our yet-to-be-made PasswordService
to simulate it returned true
to the inquiry: "Is the password weak?". Again, let's use an interface to define what that service could look like:
// File: src/auth/password.service.interface.ts
export abstract class IPasswordService {
abstract isPasswordInRainbowTable(password: string): Promise<boolean>;
}
Pretty straightforward. It receives a password as an input and returns true
if found in a rainbow table and false
otherwise. This abstract service must have an implementation that will be used by our AuthController
as follows:
// File: src/auth/auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(
private readonly emailService: IEmailService,
private readonly passwordService: IPasswordService,
) {}
@Post('signup')
async signup(@Body() input: { email: string; password: string }) {
const isPasswordInRainbowTable =
await this.passwordService.isPasswordInRainbowTable(input.password);
if (isPasswordInRainbowTable) {
throw new BadRequestException({
title: 'Bad Request',
detail:
'Your password has leaked in a data breach. Please choose a different one.',
});
}
this.emailService.sendEmail({
to: input.email,
subject: 'Welcome!',
content: 'Welcome to our app!',
});
return {
accessToken: 'a.jwt.token',
};
}
}
And an example implementation:
// File: src/auth/password.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { IPasswordService } from './password.service.interface';
@Injectable()
export class PasswordService implements IPasswordService {
private readonly logger = new Logger(PasswordService.name);
isPasswordInRainbowTable(password: string): Promise<boolean> {
this.logger.debug(`Checking if password is in rainbow table: ${password}`);
return Promise.resolve(false);
}
}
And the Provider definition:
// File: src/auth.module.ts
import { Module } from '@nestjs/common';
import { EmailModule } from '../email/email.module';
import { AuthController } from './auth.controller';
import { IPasswordService } from './password.service.interface';
import { PasswordService } from './password.service';
@Module({
imports: [EmailModule],
providers: [{ provide: IPasswordService, useClass: PasswordService }],
controllers: [AuthController],
})
export class AuthModule {}
Up to this point, the e2e test we defined above fails because the PasswordService
always returns false
, so the password is always valid. Now, we have to replace the production implementation with the fake one in our tests, and for that, we'll leverage the jest-mock-extended library:
// File: src/auth/test/password.service.stub.ts
import { DeepMockProxy, mockDeep } from 'jest-mock-extended';
import { IPasswordService } from '../password.service.interface';
export function createPasswordServiceStub(): DeepMockProxy<IPasswordService> {
return mockDeep<IPasswordService>();
}
export type PasswordServiceStub = DeepMockProxy<IPasswordService>;
This library provides a powerful mockDeep<I>
function that can mock all methods (and deeply nested ones) of a given interface. With that stub defined, we can change the test case to make the isPasswordInRainbowTable(password)
method return true
:
test('returns an error when password hash is in a rainbow table', async () => {
// Arrange
const server = testingApp.getHttpServer();
const passwordServiceStub =
testingApp.get<PasswordServiceStub>(IPasswordService);
passwordServiceStub.isPasswordInRainbowTable.mockResolvedValueOnce(true);
// Act
const response = await request(server).post('/auth/signup').send({
email: 'new-user@mail.com',
password: 'weak',
});
// Assert
expect(response.status).toBe(400);
expect(response.body).toEqual({
title: 'Bad Request',
detail:
'Your password has leaked in a data breach. Please choose a different one.',
});
});
Note that when we use a stub, we don't verify whether its method was called or what parameters it used - this is actually an anti-pattern. When we replace an incoming interaction from an external system, we should only care about the data it returns, and how that external dependency is called is merely an implementation detail.
Revision and Conclusion
This article discussed how to test unmanaged dependencies in Nest, and here are some key takeaways for your convenience:
- We can define our system's dependencies as managed or unmanaged
- We don't have control over unmanaged dependencies states, which are visible to the external world. Example: SMTP server;
- We do have control over managed dependencies, which are hidden from the external world. Example: Our Database;
- Managed dependencies should be used as is in our integration tests, while unmanaged ones should be replaced with test doubles;
- Use Mocks when replacing outgoing interactions (our system sends data elsewhere), and Stubs when replacing incoming interactions (our system receives data from the external world);
- Prefer using Spies over Mocks as they provide higher maintainability and customizability.
And that's all for today's post, fellow reader 😉. Thank you for reading this far, and let me know if you have more questions about testing strategies in NestJS.
Top comments (0)