Testing NodeJS SDKs with nock

07 Jul 2023 in Tech

I recently had a chat with an old colleague about the best way to test JavaScript SDKs and I recommended nock as my go-to solution for testing that our SDKs behave correctly.

nock isn’t the only option out there - you can also stub your request module using something like jest or sinon and assert that the correct method calls are made.

The primary reason I prefer nock is that no matter which underlying HTTP client you’re using, the same HTTP request should be made. nock allows you to decouple your tests from your HTTP client. When someone new joins the project, they can immediately read the test mocks and understand what’s going on rather than having to learn your HTTP client’s interface.

It’s easier to explain how nock works through examples, so let’s write some tests for a mini-SDK that I put together specifically for this post.

The SDK

Here’s a small SDK that talks to the OpenAPI example Pet Store API. It supports two endpoints - a GET and a POST. It uses the got NPM module to make the API calls and returns JSON.

Note: nock will work if you're using node-fetch but not if you're using the native Node 18+ fetch implementation. There's an open issue to add support.

js
import got from "got";
async function findByStatus(status) {
return await got
.get(
`https://petstore3.swagger.io/api/v3/pet/findByStatus?status=${status}`
)
.json();
}
async function create(id, name) {
return await got
.post("https://petstore3.swagger.io/api/v3/pet", {
json: {
id,
name,
status: "available",
},
})
.json();
}
export default {
findByStatus,
create,
};

In this post we’re going to write tests for these two endpoints, covering both successful and invalid responses.

Writing Tests

When testing an SDK, I’m looking to validate the following behaviour:

  • The correct number of HTTP requests are sent
  • The structure of all HTTP requests are correct. This includes the request URI, headers and body payload
  • Success responses are handled correctly
  • Error responses are handled correctly
  • Unexpected errors are caught

Nock allows us to assert all of the above Let’s build a mini-SDK for the example OpenAPI Pet Store API that allows us to showcase all of Nock’s features.

It(“Sends the correct number of HTTP requests”)

The first thing I configure in every test I write is that we’re only making the expected HTTP requests. This involves disabling network connectivity using nock.disableNetConnect() to ensure that an error is thrown if a request without a matching stub is made.

In addition, I add an afterEach hook that throws an error if we don’t use all of our expected mocks. This ensures that we make all of the HTTP requests that we expected to make.

js
nock.disableNetConnect();
afterEach(() => {
if (!nock.isDone()) {
throw new Error(
`Not all nock interceptors were used: ${JSON.stringify(
nock.pendingMocks()
)}`
);
}
nock.cleanAll();
});

Once this configuration is in place, the test case itself is very small. It looks odd as there’s no expect call because the assertions are built in to nock. All we need to do is create a stub then make the HTTP request itself:

js
it("Sends the correct number of HTTP requests", async function () {
nock("https://petstore3.swagger.io")
.get("/api/v3/pet/findByStatus?status=available")
.reply(200);
await api.findByStatus("available");
});

If we change the the call to api.findByStatus("banana")then our test will fail as there is not a valid mock:

json
RequestError: Nock: No match for request {
"method": "GET",
"url": "https://petstore3.swagger.io/api/v3/pet/findByStatus?status=banana",
"headers": {
"user-agent": "got (https://github.com/sindresorhus/got)",
"accept": "application/json",
"accept-encoding": "gzip, deflate, br"
}

Finally, if I remove the api.findByStatus call from the test it will fail as not all HTTP requests have been made:

json
Not all nock interceptors were used: ["GET https://petstore3.swagger.io:443/api/v3/pet/findByStatus"]

It(“Sends well formatted request bodies”)

Nock allows you to assert that a provided request body matches what you’re expecting. If the mock receives a payload other than the one specified the test will fail. Here we can see how the mock asserts against both user provided data (id and name) and a default value in the SDK (status):

js
it("Sends well formatted request bodies", async function () {
nock("https://petstore3.swagger.io")
.post("/api/v3/pet", {
id: 123,
name: "Fido",
status: "available",
})
.reply(200);
await api.create(123, "Fido");
});

Sometimes you don’t know all of the data in advance (e.g. when the id is auto-generated by the SDK). In this instance you can provide regular expressions to match the shape of the data rather than explicit values. Here’s an example that accepts any number in the id field:

js
it("Sends well formatted request bodies", async function () {
nock("https://petstore3.swagger.io")
.post("/api/v3/pet", {
id: new RegExp("\\d+"),
name: "Fido",
status: "available",
})
.reply(200);
await api.create(123, "Fido");
});

It(“Handles well formatted success responses”)

Now that we’ve built assertions for the requests, it’s time to ensure that the API response is returned to the user through the SDK. The .reply function in nock accepts both a HTTP response code and a response body.

In the test below, we define a pet that is returned by the server. We then use this in an expect().toEqual() call to ensure that the whole API response is returned via the SDK.

js
it("Handles well formatted success responses", async function () {
const pet = {
id: 124,
name: "Fido",
photoUrls: [],
tags: [{ id: 0 }],
status: "available",
};
nock("https://petstore3.swagger.io").post("/api/v3/pet").reply(200, pet);
const response = await api.create(124, "Fido");
expect(response).toEqual(pet);
});

It(“Handles well formatted error responses”)

This test actually helped me catch a bug whilst writing this post! The first time I ran it I got the following error as I wasn’t using a try/catch in my SDK:

js
Expected substring: "There was an error processing your request. It has been logged (ID: d02467c768c0b977)"
Received message: "HTTPError: Response code 500 (Internal Server Error)"

Instead, got was returning a standard error message when the server returned a HTTP 500. After my update (see the conclusion for the final SDK code) the error message from the server was propagated as expected and the expect call passed.

js
it("Handles well formatted error responses", async function () {
const message = "Invalid body: missing status";
nock("https://petstore3.swagger.io").post("/api/v3/pet").reply(400, {
code: 400,
message,
});
return expect(async () => {
await api.create(123, "Fido");
}).rejects.toThrow(message);
});

It(“Handles unexpected error responses”)

Then our final test - how does our SDK handle unexpected error responses? This is another one where I needed to edit my SDK code to return a useful error message rather than an empty string:

js
it("Handles unexpected error responses", async function () {
nock("https://petstore3.swagger.io").post("/api/v3/pet").reply(500);
return expect(async () => {
await api.create(123, "Fido");
}).rejects.toThrow("[500] Unknown error. No error provided by the server");
});

Putting it all together

That’s all! We’ve tested all of the different ways that our code should work and/or fail. Here’s the complete test code for your reference:

js
import api from "./main";
import nock from "nock";
nock.disableNetConnect();
afterEach(() => {
if (!nock.isDone()) {
throw new Error(
`Not all nock interceptors were used: ${JSON.stringify(
nock.pendingMocks()
)}`
);
}
nock.cleanAll();
});
it("Sends the correct number of HTTP requests", async function () {
nock("https://petstore3.swagger.io")
.get("/api/v3/pet/findByStatus?status=available")
.reply(200);
await api.findByStatus("available");
});
it("Sends well formatted request bodies", async function () {
nock("https://petstore3.swagger.io")
.post("/api/v3/pet", {
id: new RegExp("\\d+"),
name: "Fido",
status: "available",
})
.reply(200);
await api.create(123, "Fido");
});
it("Handles well formatted success responses", async function () {
const pet = {
id: 124,
name: "Fido",
photoUrls: [],
tags: [{ id: 0 }],
status: "available",
};
nock("https://petstore3.swagger.io").post("/api/v3/pet").reply(200, pet);
const response = await api.create(124, "Fido");
expect(response).toEqual(pet);
});
it("Handles well formatted error responses", async function () {
const message = "Invalid body: missing status";
nock("https://petstore3.swagger.io").post("/api/v3/pet").reply(400, {
code: 400,
message,
});
return expect(async () => {
await api.create(123, "Fido");
}).rejects.toThrow(message);
});
it("Handles unexpected error responses", async function () {
nock("https://petstore3.swagger.io").post("/api/v3/pet").reply(500);
return expect(async () => {
await api.create(123, "Fido");
}).rejects.toThrow("[500] Unknown error. No error provided by the server");
});

We’ve got twice as many lines of code in our tests than in our actual SDK. Speaking of the SDK, I promised you the updated version with error handling. It’s not pretty, but it works!

js
import got from "got";
async function findByStatus(status) {
return await got
.get(
`https://petstore3.swagger.io/api/v3/pet/findByStatus?status=${status}`
)
.json();
}
async function create(id, name) {
try {
return await got
.post("https://petstore3.swagger.io/api/v3/pet", {
json: {
id,
name,
status: "available",
},
})
.json();
} catch (error) {
let e;
try {
e = JSON.parse(error.response.body);
} catch (err) {
throw new Error(
`[${error.response.statusCode}] Unknown error. No error provided by the server`
);
}
throw new Error(e.message);
}
}
export default {
findByStatus,
create,
};

Of course, now that we’ve got tests we can refactor until we’re happy with the code.

So, Nock is great!

Nock is a key part of my testing toolkit. I use it heavily when developing octokit plugins, building GitHub Actions, testing libraries and writing CLI tools.

Mocking the HTTP call directly means that I don’t need to worry about mocking dependencies. I can just call a method and know that if I return the correct response, everything will act as though it was a real HTTP request.

It’s possible to mock method calls rather than the actual HTTP call itself, but I’ find that you then couple your tests to the HTTP library that you’re using. As a contributor, you need to know how the library works rather than the underlying API.

Nock is simple, and simple is good. Give it a try the next time you need to test that a HTTP call has been made in your node project.