Leveraging Mock Service Workers for NestJS e2e tests

In the previous article, we discussed how almost any modern web application has to communicate with external systems. It's also safe to assume most of that communication is made via HTTP calls. In today's post, we'll learn how to: Intercept and stub HTTP calls in NodeJS using Mock Service Workers and NestJS as the framework - No interfaces are needed this time. Create reusable API stubs for any e2e test. Simulate network errors. Bonus: How to transform the external data structure into domain objects using an anti-corruption layer (ACL). Leveraging a Result class to separate application and network layer error handling. As always, you can check out the reference repository for more details on the implementation. ℹ️ TL:DR -> We utilize MSW to intercept NodeJS's internal request calls using the setupServer() function and a server.use() call for each test. You can check out the final code below: import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { http, HttpResponse, passthrough } from 'msw'; import { setupServer, SetupServerApi } from 'msw/node'; import * as request from 'supertest'; import { stubGoogleAPIResponse } from '../src/address/application/test/google-geocode-mock.handler'; import { AppModule } from '../src/app.module'; describe('Get GeoCode Address', () => { let app: INestApplication; let mockServer: SetupServerApi; beforeAll(() => { // We must define a passthrough for localhost requests so MSW doesn't log a warning. mockServer = setupServer(http.all('http://127.0.0.1*', passthrough)); mockServer.listen(); }); afterAll(() => { mockServer.close(); }); beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); await app.init(); }); it('returns the coordinates for a valid address', () => { mockServer.use( stubGoogleAPIResponse({ results: [ { geometry: { location: { lat: 37.4224082, lng: -122.0856086, }, }, }, ], status: 'OK', }), ); return request(app.getHttpServer()) .get( '/addresses/geo-code?address=1600+Amphitheatre+Parkway,+Mountain+View,+CA', ) .expect({ latitude: 37.4224082, longitude: -122.0856086, }); }); it('returns a 404 error when the address is not found', async () => { mockServer.use( stubGoogleAPIResponse({ results: [], status: 'ZERO_RESULTS', }), ); return request(app.getHttpServer()) .get('/addresses/geo-code?address=invalid+address') .expect(404) .expect({ statusCode: 404, message: 'Address not found', }); }); it('returns a 424 error (code=01) when the API key is invalid', async () => { mockServer.use( stubGoogleAPIResponse({ results: [], status: 'REQUEST_DENIED', error_message: 'The provided API key is invalid.', }), ); return request(app.getHttpServer()) .get('/addresses/geo-code?address=invalid+address') .expect(424) .expect({ statusCode: 424, message: 'Failed to get coordinates', code: '01', }); }); it('returns a 424 error (code=02) when there is a network error', async () => { mockServer.use(stubGoogleAPIResponse(HttpResponse.error())); return request(app.getHttpServer()) .get('/addresses/geo-code?address=invalid+address') .expect(424) .expect({ statusCode: 424, message: 'Failed to get coordinates', code: '02', }); }); }); If you are interested in the process to get there, including the example use case, proceed with me to the following sections. Feature Let's consider a simple (but real-world-like) application to demonstrate MSW usage. That app returns the latitude and longitude of a given address the user inputs: The user types a location in the search bar The user clicks the button to "Get Coordinates" The location's coordinates are shown along with the location. The gherkin description below details each use case: Feature: As a user I want to be able to see an address' cooridnates on the map. A user can enter an address to view their location on the map. The system returns either the coordinates of that address or an error when not found. Scenario: Successfully retrieve address coordinates Given the address '1600 Amphitheatre Parkway, Mountain View, CA' is valid with coordinates 37.4224082, -122.0856086 When I search for that location's address Then I can see the address'coordinates Scenario: Handle non-existent address Given the address 'Invalid Address' is invalid When I search that location's address Then I receive an err

Mar 4, 2025 - 14:05
 0
Leveraging Mock Service Workers for NestJS e2e tests

In the previous article, we discussed how almost any modern web application has to communicate with external systems. It's also safe to assume most of that communication is made via HTTP calls. In today's post, we'll learn how to:

  • Intercept and stub HTTP calls in NodeJS using Mock Service Workers and NestJS as the framework - No interfaces are needed this time.
  • Create reusable API stubs for any e2e test.
  • Simulate network errors.

Bonus:

  • How to transform the external data structure into domain objects using an anti-corruption layer (ACL).
  • Leveraging a Result class to separate application and network layer error handling.

As always, you can check out the reference repository for more details on the implementation.

ℹ️ TL:DR -> We utilize MSW to intercept NodeJS's internal request calls using the setupServer() function and a server.use() call for each test. You can check out the final code below:

import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { http, HttpResponse, passthrough } from 'msw';
import { setupServer, SetupServerApi } from 'msw/node';
import * as request from 'supertest';
import { stubGoogleAPIResponse } from '../src/address/application/test/google-geocode-mock.handler';
import { AppModule } from '../src/app.module';

describe('Get GeoCode Address', () => {
  let app: INestApplication;
  let mockServer: SetupServerApi;

  beforeAll(() => {
    // We must define a passthrough for localhost requests so MSW doesn't log a warning.
    mockServer = setupServer(http.all('http://127.0.0.1*', passthrough));
    mockServer.listen();
  });

  afterAll(() => {
    mockServer.close();
  });

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('returns the coordinates for a valid address', () => {
    mockServer.use(
      stubGoogleAPIResponse({
        results: [
          {
            geometry: {
              location: {
                lat: 37.4224082,
                lng: -122.0856086,
              },
            },
          },
        ],
        status: 'OK',
      }),
    );

    return request(app.getHttpServer())
      .get(
        '/addresses/geo-code?address=1600+Amphitheatre+Parkway,+Mountain+View,+CA',
      )
      .expect({
        latitude: 37.4224082,
        longitude: -122.0856086,
      });
  });

  it('returns a 404 error when the address is not found', async () => {
    mockServer.use(
      stubGoogleAPIResponse({
        results: [],
        status: 'ZERO_RESULTS',
      }),
    );

    return request(app.getHttpServer())
      .get('/addresses/geo-code?address=invalid+address')
      .expect(404)
      .expect({
        statusCode: 404,
        message: 'Address not found',
      });
  });
  it('returns a 424 error (code=01) when the API key is invalid', async () => {
    mockServer.use(
      stubGoogleAPIResponse({
        results: [],
        status: 'REQUEST_DENIED',
        error_message: 'The provided API key is invalid.',
      }),
    );

    return request(app.getHttpServer())
      .get('/addresses/geo-code?address=invalid+address')
      .expect(424)
      .expect({
        statusCode: 424,
        message: 'Failed to get coordinates',
        code: '01',
      });
  });
  it('returns a 424 error (code=02) when there is a network error', async () => {
    mockServer.use(stubGoogleAPIResponse(HttpResponse.error()));

    return request(app.getHttpServer())
      .get('/addresses/geo-code?address=invalid+address')
      .expect(424)
      .expect({
        statusCode: 424,
        message: 'Failed to get coordinates',
        code: '02',
      });
  });
});

If you are interested in the process to get there, including the example use case, proceed with me to the following sections.

Feature

Let's consider a simple (but real-world-like) application to demonstrate MSW usage. That app returns the latitude and longitude of a given address the user inputs:

Three distinct frames showcasing an example

  • The user types a location in the search bar
  • The user clicks the button to "Get Coordinates"
  • The location's coordinates are shown along with the location.

The gherkin description below details each use case:

Feature: As a user I want to be able to see an address' cooridnates on the map.

  A user can enter an address to view their location on the map. The system
  returns either the coordinates of that address or an error when not found.

  Scenario: Successfully retrieve address coordinates
    Given the address '1600 Amphitheatre Parkway, Mountain View, CA' is valid with coordinates 37.4224082, -122.0856086
    When I search for that location's address
    Then I can see the address'coordinates

  Scenario: Handle non-existent address
    Given the address 'Invalid Address' is invalid
    When I search that location's address
    Then I receive an error message: 'Address not found'

  Scenario: Handle network error during coordinate retrieval
    Given a network error occurs
    When I search for the address 'Some Address'
    Then I see the error message: 'Failed to get coordinates'

We'll also use Google's Geocoding API to retrieve a location's coordinates. We just need to send a GET request to the API endpoint, passing the address string and the API key:

ADDRESS="Germany"
API_KEY="MY-APY-KEY" 

curl -X GET "https://maps.googleapis.com/maps/api/geocode/json?address=${ADDRESS}&key=${API_KEY}"

You'll need a Google Services API Key to fetch the real data, but fortunately, we don't even need to work with the API for our tests. We just need to know what its response looks like:

{
  "results": [
    {
      "address_components": [
        {
          "long_name": "Germany",
          "short_name": "DE",
          "types": [
            "country",
            "political"
          ]
        }
      ],
      "formatted_address": "Germany",
      "geometry": {
        "bounds": {
          "south": 47.270114,
          "west": 5.8663425,
          "north": 55.0815,
          "east": 15.0418962
        },
        "location": {
          "lat": 51.165691,
          "lng": 10.451526
        },
        "location_type": "APPROXIMATE",
        "viewport": {
          "south": 47.270114,
          "west": 5.8663425,
          "north": 55.0815,
          "east": 15.0418962
        }
      },
      "place_id": "ChIJa76xwh5ymkcRW-WRjmtd6HU",
      "types": [
        "country",
        "political"
      ]
    }
  ],
 "status": "OK"
}

ℹ️ Note: We'll consider the Geocode API always returns a single result for simplicity.

So, the data that really matters to us can be obtained from response.results[0].geometry.location. Let's keep that in mind when we start implementing the service. For now, let's note down a simplified type of that response:

export type GoogleGeocodeResponse = {
  results: {
    geometry: {
      location: {
        lat: number;
        lng: number;
      };
    };
  }[];
  status: string;
  error_message?: string; // This property exists when an error occurs.
};

The error_message property determines when an application error occurs —yes, they send a 200 status even when there is an error.

Meme showing two guys in a classroom, one with the label

Now that we have everything we need to start writing the test cases, let's proceed to the next section.

Defining the e2e test cases

The first step is to write the test scenario descriptions with the .todo() suffix as we won't implement them just yet:

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { http, HttpResponse } from 'msw';
import { setupServer, SetupServerApi } from 'msw/node';

describe('Get GeoCode Address', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it.todo('returns the coordinates for a valid address');
  it.todo('returns a 404 error for an invalid address');
  it.todo('returns a 424 (code=01) error when the API key is invalid');  // Extra test for a technical edge case
  it.todo('returns a 424 (code=02) error when there is a network error');
});

Notice that we have an extra test case above: returns a 424 (code=01) error when the API key is invalid. This is an unlikely scenario, but it was put there for a good reason - to simulate how we should handle unexpected errors.

If the API Key is invalid (or has expired), we want to associate a specific error code with it so the client side can report that via an automated channel. Moreover, we don't want to let the user know the underlying reason for failure, so the error message doesn't say anything about the API Key.

Writing the success test case

Let's start with the success scenario test case:

 it('returns the latitude/longitude for a valid address', () => {
    // How do we stub the geo-code API response?