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 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.
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? 🤔
const expectedCoordinates = {
latitude: 37.4224082,
longitude: -122.0856086,
}
return request(app.getHttpServer())
.get(
'/addresses/geo-code?address=Germany',
)
.expect(200)
.expect(expectedCoordinates);
});
The test is straightforward. We send a request to the GET /addresses/geocode
endpoint and expect to get Germany's coordinates as a response. But since we won't connect to the real API, we must stub this response with MSW. To implement this, we must use MSW's NodeJS integration.
ℹ️ Note: MSW default mode works in the browser with Service Workers, so we need a slightly different route to make it work with NodeJS
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;
let mockServer: SetupServerApi;
const handlers = [
// Intercept "GET 'https://maps.googleapis.com/maps/api/geocode" requests...
http.get(
'https://maps.googleapis.com/maps/api/geocode/json',
() => {
// ...and respond to them using this JSON response.
return HttpResponse.json(
{
results: [
{
geometry: {
location: {
lat: 37.4224082,
lng: -122.0856086,
},
},
},
],
status: 'OK'
},
);
},
),
];
beforeAll(() => {
mockServer = setupServer(...handlers);
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', () => {
const expectedCoordinates = {
latitude: 37.4224082,
longitude: -122.0856086,
}
return request(app.getHttpServer())
.get(
'/addresses/geo-code?address=Germany',
)
.expect(200)
.expect(expectedCoordinates);
});
});
The key steps to setup MSW above are:
- Define the request handler array containing the handler that should respond to HTTP calls made to the geocode API
- Setup the server with the list of handlers.
- Make the server start listening
- Ensure we close it in the
afterAll
block
Now, we must implement the controller and the service that should handle this request. We'll communicate with our Geocode API using NestJS HttpModule.
Implementing the first version of Controller and Service
We can use NestJS CLI to create an 'AddressModule', an 'AddressController', and an 'AddressService' to scaffold their basic structure:
nest g module address
nest g controller address
nest g service address
Then, implement the communication with the GeoCode API at the service layer:
import { Injectable } from '@nestjs/common';
import { Coordinates } from '../domain/coordinates';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
export type GoogleGeocodeResponse = {
results: {
geometry: {
location: {
lat: number;
lng: number;
};
};
}[];
};
@Injectable()
export class AddressService {
constructor(private readonly httpService: HttpService) {}
async getGeoCode(address: string): Promise<Coordinates> {
const response = await firstValueFrom(
this.httpService.get<GoogleGeocodeResponse>(
`https://maps.googleapis.com/maps/api/geocode/json?key=test&address=${address}`,
),
);
const { lat, lng } = response.data.results[0].geometry.location;
return new Coordinates(lat, lng);
}
}
Note that we're returning a domain object from that service: Coordinates
. It represents a coordinate Value Object and should be responsible for validating itself. This centralizes the logic that defines what is a valid coordinate, making it reusable and safe to be used by other services:
export class Coordinates {
public readonly latitude: number;
public readonly longitude: number;
constructor(lat: number, lng: number) {
if (!Coordinates.isValid(lat, lng)) {
throw new Error(`Invalid coordinates: lat=${lat}, lng=${lng}`);
}
this.latitude = lat;
this.longitude = lng;
}
static isValid(lat: number, lng: number): boolean {
return (
typeof lat === 'number' &&
typeof lng === 'number' &&
lat >= -90 &&
lat <= 90 &&
lng >= -180 &&
lng <= 180
);
}
}
Now we must inject this AddressService
into our AddressController
:
import { Controller, Get, Query } from '@nestjs/common';
import { Coordinates } from './domain/coordinates';
import { AddressService } from './application/address.service';
@Controller('addresses')
export class AddressController {
constructor(private readonly addressService: AddressService) {}
@Get('geo-code')
async getGeoCode(@Query('address') address: string): Promise<Coordinates> {
return this.addressService.getGeoCode(address);
}
}
Lastly, let's update the AddressModule
to import NestJS's HttpModule
to provide the HttpService
:
import { Module } from '@nestjs/common';
import { AddressController } from './address.controller';
import { AddressService } from './application/address.service';
import { HttpModule } from '@nestjs/axios';
@Module({
imports: [HttpModule],
controllers: [AddressController],
providers: [AddressService],
})
export class AddressModule {}
With all the pieces fitted, we can run the test case:
$ pnpm run test:e2e
PASS test/get-address-coordinates.e2e-spec.ts
And then the first cycle is completed! Well, almost. If you execute the test command above, you'll also notice it logs a warning:
[MSW] Warning: intercepted a request without a matching request handler:
• GET http://127.0.0.1:36245/addresses/geo-code?address=Germany
So, how do we get rid of it?
Fixing MSW "intercepted request without a matching request handler" warning
The WARN happened because MSW detected we sent a request to a local server (hence the 127.0.0.1
IP address) but didn't register any handler. To solve this, we must define a passthrough handler because we don't want to change or stub responses from our own server:
const handlers = [
http.get('https://maps.googleapis.com/maps/api/geocode/json', () => HttpResponse.json(geocodeResponse),
http.get('http://127.0.0.1*', passthrough),
];
The message is gone now when running the e2e test cases:
PASS test/app.e2e-spec.ts
PASS test/get-address-coordinates.e2e-spec.ts
Test Suites: 2 passed, 2 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 4.713 s, estimated 5 s
Ran all test suites.
------------------------
Refactoring MSW response stubs to allow response per test case
Before implementing the subsequent test cases, which should handle error scenarios, we must refactor the existing code slightly because they are globally defined. That means any request to the maps/api/geocode/json
endpoint produces the same success response.
Each test case sends a request to MSW Server but gets the same response.
The alternative that allows us to define per-use-case response is MSW's use method. This method dynamically attaches request handlers to MSW's server, which can later be removed by calling resetHandlers(). Therefore, we'll implement three main changes to our test code:
- Remove the specific request handlers from the server setup.
- Add test-case-specific request handlers with
mockServer.use()
- Extract the code that creates a request handler to a file next to the
AddressSevice
so we encapsulate its details.
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { http, 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=Germany')
.expect({
latitude: 37.4224082,
longitude: -122.0856086,
});
});
});
And this is the stubGoogleAPIResponse()
implementation code:
import { http, HttpResponse } from 'msw';
import { GoogleGeocodeResponse } from '../address.service';
export function stubGoogleAPIResponse(
response: GoogleGeocodeResponse | Response,
) {
return http.get(
'https://maps.googleapis.com/maps/api/geocode/json',
() =>
response instanceof Response ? response : HttpResponse.json(response),
{
// Ensure this stubbed response is only used once.
once: true,
},
);
}
ℹ️ Note: The stub accepts an instance of
Response
as a possible input to simulate errors.
And that's all we needed to refactor at this point. In the next section we'll implement the error cases.
Implementing the error cases
There are three error cases we have to simulate now:
- Address not found. Response:
{ results: [], status: 'ZERO_RESULTS' }
- The API Key is invalid. Response:
{ results: [], status: 'REQUEST_DENIED' }
- There is a network error. In this case, the request is aborted and we don't receive a response from the server.
Let's write down each of these error cases:
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',
});
});
The first two test cases are similar - they only need to specify the JSON response. The third test, however, has to respond with HttpResponse.error()
to simulate a network error.
ℹ️ Note: You can also easily simulate responses with error status by passing a second argument to the
HttpResponse.json()
method in the stub handler. For instance:HttpResponse.json(response, { status: 404 })
Handling errors at the service and controller layer.
The last piece of the puzzle is handling each of these errors. To do so, we'll separate error handling into two layers:
-
Application Layer - The service takes the API response and verifies whether this is a success or an error result. Then, it returns that result to the layer above instead of throwing an error. We use the
Result
class for that purpose. - Network Layer - The controller takes the response from the service and decides how to response to each type of error - formatting the messages accordingly and specifying the response status.
The diagram below depicts the error-handling flow:
So let's start with the Service. We'll define a constant to hold all known error messages that Google's API responds so we can map them to specific Error classes:
export const GeoLocationErrorCode = {
AddressNotFound: 'ADDRESS_NOT_FOUND',
InvalidAPIKey: 'INVALID_API_KEY',
UnknownException: 'UNKNOWN',
NetworkException: 'NETWORK',
InvalidRequest: 'INVALID_REQUEST',
} as const;
export class AddressNotFoundError extends ApplicationError {
constructor() {
super('Address not found', GeoLocationErrorCode.AddressNotFound);
}
}
export class InvalidAPIKeyError extends ApplicationError {
constructor() {
super('Failed to get coordinates', GeoLocationErrorCode.InvalidAPIKey);
}
}
export class GeoLocationNetworkError extends ApplicationError {
constructor() {
super('Failed to get coordinates', GeoLocationErrorCode.NetworkException);
}
}
export class InvalidInputError extends ApplicationError {
constructor() {
super('Invalid input', GeoLocationErrorCode.InvalidRequest);
}
}
export class UnknownGeolocationError extends ApplicationError {
constructor() {
super('Unknown error', GeoLocationErrorCode.UnknownException);
}
}
Then, we'll capture each possible error and map it to our application-level errors:
@Injectable()
export class AddressService {
private readonly logger = new Logger(AddressService.name);
constructor(private readonly httpService: HttpService) {}
async getGeoCode(
address: string,
): Promise<Result<Coordinates, ApplicationError | AxiosError>> {
const response = await firstValueFrom(
this.httpService
.get<GoogleGeocodeResponse>(
`https://maps.googleapis.com/maps/api/geocode/json?key=test&address=${address}`,
)
.pipe(
catchError((error: AxiosError) => {
if (error.response) {
return of(error);
}
if (error.request) {
return of(new GeoLocationNetworkError());
}
return of(new UnknownGeolocationError());
}),
),
);
if (response instanceof Error) {
return Result.fail(response);
}
const data = response.data;
if (data.status !== 'OK') {
this.logger.error(`Failed to get coordinates: ${data.error_message}`);
switch (data.status) {
case 'ZERO_RESULTS':
return Result.fail(new AddressNotFoundError());
case 'REQUEST_DENIED':
return Result.fail(new InvalidAPIKeyError());
case 'INVALID_REQUEST':
return Result.fail(new InvalidInputError());
default:
return Result.fail(new UnknownGeolocationError());
}
}
const { lat, lng } = data.results[0].geometry.location;
return Result.ok(new Coordinates(lat, lng));
}
}
ℹ️ Note: You can check out the implementation details of the Result class in the repository code.
Finally, the AddressController
decides how to map each type of error to a specific network exception:
import {
Controller,
Get,
HttpException,
NotFoundException,
Query,
} from '@nestjs/common';
import { Coordinates } from './domain/coordinates';
import {
AddressService,
GeoLocationErrorCode,
} from './application/address.service';
@Controller('addresses')
export class AddressController {
constructor(private readonly addressService: AddressService) {}
@Get('geo-code')
async getGeoCode(@Query('address') address: string): Promise<Coordinates> {
const result = await this.addressService.getGeoCode(address);
if (result.isOk()) {
return result.value;
}
switch (result.error.code) {
case GeoLocationErrorCode.AddressNotFound:
throw new NotFoundException('Address not found', {
cause: result.error,
});
case GeoLocationErrorCode.InvalidAPIKey:
throw new HttpException(
{
statusCode: 424,
message: 'Failed to get coordinates',
code: '01',
},
424,
{
cause: result.error,
},
);
case GeoLocationErrorCode.NetworkException:
throw new HttpException(
{
statusCode: 424,
message: 'Failed to get coordinates',
code: '02',
},
424,
{
cause: result.error,
},
);
case GeoLocationErrorCode.InvalidRequest:
throw new HttpException(
{
statusCode: 424,
message: 'Failed to get coordinates',
code: '03',
},
424,
{
cause: result.error,
},
);
default:
throw result.error;
}
}
}
This finalizes our last cycle. Each error scenario is handled by the AddressService
, turned into a Result
object, and mapped to an HttpException
at the AddressController
. Running our e2e tests is the final litmus test that everythingworks as intended:
$ pnpm run test:e2e
PASS test/get-address-coordinates.e2e-spec.ts
Test Suites: 2 passed, 2 total
Tests: 6 passed, 6 total
Snapshots: 0 total
Time: 5.061 s
Ran all test suites.
Top comments (0)