DEV Community

Cover image for How to test external dependencies with Nest
Thiago Valentim
Thiago Valentim

Posted on

How to test external dependencies with Nest

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."
Enter fullscreen mode Exit fullscreen mode

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:

Image description

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')
});
Enter fullscreen mode Exit fullscreen mode

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?
  });
Enter fullscreen mode Exit fullscreen mode

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',
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}

Enter fullscreen mode Exit fullscreen mode

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!',
    });
  });
Enter fullscreen mode Exit fullscreen mode

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',
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

ℹ️ 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:

  1. 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.
  2. 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.
  3. 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;
}
Enter fullscreen mode Exit fullscreen mode

ℹ️ 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 like const 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 {}
Enter fullscreen mode Exit fullscreen mode

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!');
  });
Enter fullscreen mode Exit fullscreen mode

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;
  }
}

Enter fullscreen mode Exit fullscreen mode

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.',
    });
  });
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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',
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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.',
    });
  });
Enter fullscreen mode Exit fullscreen mode

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)