JavaScript API Testing
Required Software
Why use JavaScript for API Testing?
Great question! When considering overwhelming community support that JavaScript has, along with libraries that are specifically created for API testing JavaScript is worth considering for API testing. Supertest, one of the most prominent libraries for API testing in JavaScript, provides a simple interface making requests as well as asserting their results. Besides, it's fairly simple to create custom assertions for supertest which we will dive into in the post below.
Setting up the API
We'll start by creating a project and installing the dependencies we need. We are going to use express to create our API, and Jest coupled with supertest to create our API Testing framework.
// create your project directory
mdkir javascript-api-testing
cd javascript-api-testing
// create a default package.json and install dependencies
npm init -y
npm install express
npm install -D supertest jest
2
3
4
5
6
7
8
// ./package.json
{
...,
"scripts": {
"start": "node index.js",
"test": "jest"
},
...
}
2
3
4
5
6
7
8
9
Awesome now that we have all of our dependencies for creating our API and API Tests we can start by creating a basic API to test against. As you can see below there is a todo endpoint that exposes GET and POST methods, and a health endpoint that exposes a GET method.
// index.js
const express = require("express");
const todoRouter = express.Router();
const healthRouter = express.Router();
const app = express();
let todoId = 0;
const todos = [];
todoRouter.get("/", (req, res) => {
res.send(todos);
});
todoRouter.post("/", (req, res) => {
if (!req.body.title) {
return res.status(400).send("Todo must have a title");
}
todos.push({ id: todoId++, title: req.body.title });
return res.sendStatus(200);
});
app.use("/todo", todoRouter);
healthRouter.get("/", (req, res) => {
res.send({
status: "ok"
});
});
app.use("/health", healthRouter);
app.listen(process.env.PORT || 3000, () => console.log("API Started"));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Great, we've got something to write API tests against. Before we begin to write our tests we need to start our API, and we do this by running the command below in a terminal.
npm start
Now that the API is running we can construct our tests for it. Let's start by creating our first test file and filling it in with our tests for the GET /todo endpoint.
Setting up the API tests
// ./todo.spec.js
// here we require the supertest module
const supertest = require("supertest");
// create a variable for the endpoint that we would like to execute against
const endpoint = "http://localhost:3000";
// create our describe blocks for our tests
describe("/todo", () => {
describe("GET /", () => {
it("should return a 200 when requesting todos", async () => {
// ensure the function is async since we are making an API request
await supertest(endpoint) // set the base url for supertest
.get("/todo") // GET request against the /todo endpoint
.expect(200); // expect a 200 back
});
});
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
After creating this test run it with the following command:
npm test
As you can see it's pretty simple to create an API test using these libraries, but let's take things a bit further. In my experience with supertest I've noticed on test failures the error message returned to the user is not the most informative.
To see an example of this change the expect(200) in the test above to expect(201) and run your tests again.
expected 201 "Created", got 200 "OK"
at Test.Object.<anonymous>.Test._assertStatus (node_modules/supertest/lib/test.js:268:12)
at Test.Object.<anonymous>.Test._assertFunction (node_modules/supertest/lib/test.js:283:11)
at Test.Object.<anonymous>.Test.assert (node_modules/supertest/lib/test.js:173:18)
at localAssert (node_modules/supertest/lib/test.js:131:12)
at node_modules/supertest/lib/test.js:128:5
2
3
4
5
6
While knowing the status code was incorrect is good, it would be amazing if we could get the full detail of the request and response. Let's take a look at enhancing the supertest library by extending supertest and adding additional assertions.
Customizing Supertest
For now, we will add this logic above our original test, but don't you worry it won't be there for long.
// ./todo.spec.js
const supertest = require("supertest");
const http = require("http");
const endpoint = "http://localhost:3000";
const Test = supertest.Test;
Test.prototype.expectStatus = function (expectedStatus) {
return this.expect((response) => {
const {status} = response;
if(expectedStatus !== status) {
const message = `
Request: ${this.method} ${this.url}
Headers: ${JSON.stringify(this.header)}
Body: ${JSON.stringify(this._data)}
Response:
Headers: ${JSON.stringify(this.header)}
Body: ${JSON.stringify(response.body)}
Status: ${response.status}
Expected ${expectedStatus} "${http.STATUS_CODES[expectedStatus]}", got ${status} "${http.STATUS_CODES[status]}`
throw new Error(message);
}
})
}
describe("/todo", () => {
...
.expectStatus(201);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Now if you run your test again, you'll notice that we have a very informative message of both the request sent and the response is given back.
Request: GET http://localhost:3000/todo
Headers: {"User-Agent":"node-superagent/3.8.3"}
Body: undefined
Response:
Headers: {"User-Agent":"node-superagent/3.8.3"}
Body: []
Status: 200
Expected 201 "Created", got 200 "OK
2
3
4
5
6
7
8
9
Now you may be thinking how I'm I supposed to use these prototype changes across all of my API Tests? Well, we will rely on jest to preconfigure supertest for us through our jest.config.js.
// ./test/supertest-extended.js
const http = require("http");
const Test = supertest.Test;
Test.prototype.expectStatus = function(expectedStatus) {
return this.expect(response => {
const { status } = response;
if (expectedStatus !== status) {
// Craft the the error message that you have been longing for!
const message = `
Request: ${this.method} ${this.url}
Headers: ${JSON.stringify(this.header)}
Body: ${JSON.stringify(this._data)}
Response:
Headers: ${JSON.stringify(this.header)}
Body: ${JSON.stringify(response.body)}
Status: ${response.status}
Expected ${expectedStatus} "${
http.STATUS_CODES[expectedStatus]
}", got ${status} "${http.STATUS_CODES[status]}`;
throw new Error(message);
}
});
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ./jest.config.js
module.exports = {
setupFiles: ["./test/supertest-extended.js"]
};
2
3
4
5
Now we can make as many tests as we like across as many files as we would like.
// ./health.spec.js
const supertest = require("supertest");
const endpoint = "http://localhost:3000";
describe("/health", () => {
describe("GET /", () => {
it("should return a 200 when requesting health", async () => {
await supertest(endpoint)
.get("/health")
.expectStatus(201);
});
});
});
2
3
4
5
6
7
8
9
10
11
12
13
14
Intellisense on Custom Assertions
That covers API test creation and customization and we've covered a lot of ground with only a few files. Something thing that you may notice if you are in an editor like Visual Studio Code is that IntelliSense will not recognize the new method that you have added to the Test class, let's fix that with a typings declaration.
// ./test/supertest-extended.d.ts
import * as supertest from "supertest";
declare module "supertest" {
interface Test {
expectStatus(code: number): Test;
}
}
2
3
4
5
6
7
8
9
Simple enough right? We extend the existing typings for supertest within our project and now we have typings on our custom supertest assertions.
Whew! If you have managed to follow along through this entire blog post you've done something pretty incredible. You've created an extensible API Testing Framework that has full IntelliSense. Congratulations on making it through and test on fellow developers!
Source code can be found here.
Resources