Back to main page Traveling Coderman

Node.js Microservice Architecture Testing Express.js endpoints with supertest

4555 views

Did you ever publish code without writing tests for it? Well, I certainly did in the last post! Let's fix that with writing some unit tests based on jest and supertest for the controllers.

Well-written unit tests enable you to have fast feedback loops. A failure of one of them gives you a clear indication of a bug in your code. None of them failing gives you a clear indication of your code working.

As a first aspect, we want to have isolated tests. A test for a module should test exactly that module. It should not fail due to bugs in another module.

As a second aspect, we want to have completeness. If there is a bug in the code, then one of the tests should fail.

As a third aspect, we want the tests to be minimal. For one bug in the code, there should be exactly one test that fails. That's the hardest aspect to achieve (but we can thrive for it).

As a fourth aspect, we want the tests to be correct. A test should verify the property you want it to verify.

Remember: Great unit tests are isolated, complete, minimal and correct.

For completeness, there are coverage tools. For correctness, it's best to strive for simplicity and obviousness in both tests and application code. For minimalism, you can reason about the different inputs and outputs a function can have.

For isolation, we need to consider internal and external dependencies. While external dependencies outside of our software are rather stable, internal dependencies within our software are a frequent point of failure. Hence, it's important to mock internal dependencies while external dependencies are not that crucial to be mocked.

For the API endpoints implemented by todo controllers, external dependencies are HTTP and Express. Internal dependencies are the todo type todo.type.ts and the todo DAO todo.dao.ts. We want to mock these two internal dependencies.

Todo stub 🔗

In types, you want to utilize the benefits of mandatory fields. If you define a field assignee of a todo to be mandatory instead of optional, then you don't need to deal with it being undefined in the logic.

// This is better...
export interface Todo {
id: TodoId;
name: string;
assignee: string;
dueDate: string;
}

// ...than this.
export interface Todo {
id?: TodoId;
name?: string;
assignee?: string;
dueDate?: string;
}

However, this means that every time you create a todo, you need to provide all the fields.

const todo: Todo = {
id: "123",
name: "Some Name",
assignee: "Yet another field?",
dueDate: "2022-02-07",
};

This is cumbersome in tests for several reasons.

  • It's a lot of code.
  • It distracts from what's actually under test.
  • It's tough to figure out if the seamingly random assigned values are relevant.
  • With each addition of a mandatory field in the Todo type, all occurrences need to be adjusted.

To avoid these problems, it is helpful to create a function that creates a stub of a todo for test cases.

import { v4 as uuid } from "uuid";
import { Todo } from "./todo.type";

export const createStubTodo = (): Todo => ({
id: uuid(),
name: "Name",
assignee: "Assignee",
dueDate: new Date().toISOString(),
});

This way, tests can refer to createStubTodo() and a change to the Todo type only requires a change to the stub.

it("...", async () => {
// ...
const todo = createStubTodo();
// ...
});

In some cases, you want to have a slightly different stub. You can achieve this with the omit function to remove fields.

it("...", async () => {
// ...
const todo = omit("id", createStubTodo());
// ...
});

You can use the spread syntax to add or overwrite fields.

it("...", async () => {
// ...
const todo = createStubTodo();
const createdTodo = {
...todo,
id: uuid(),
};
// ...
});

I recommend to not have a stub function configurable with parameters createStubTodo(options?: StubOptions). It might be tempting in some cases, but it's worth it to keep these stub functions as simple as possible.

Let's assume the type Todo would also have a list of tasks.

interface Task {
description: string;
}

interface Todo {
// ...
tasks: Task[];
}

In that case, I'd recommend to return the most minimal todo from the stub createStubTodo(): A todo without tasks.

export const createStubTodo = (): Todo => ({
// ...
tasks: [],
});

If a test requires a todo with tasks, they can use an additional stub createStubTask() and the spread syntax and compose the required todo.

const todo: Todo = {
...createStubTodo(),
tasks: [createStubTask(), createStubTask()],
};

Remember: Keep stubs simple stupid. If the implementation of stubs is not trivial, then they themselves are a possible source of failure, risking correctness of the tests.

Mocking data access 🔗

Beside the internal dependencies of the todo type todo.type.ts, we also want to mock the data access layer todo.dao.ts. Since the TypeScript file todo.dao.ts forms a module, it can be mocked at runtime with jest jest.mock('./todo.dao').

import { v4 as uuid } from "uuid";
import { createStubTodo } from "./todo.stub";

jest.mock("./todo.dao");

describe("getTodoController", () => {
// ...

it("returns the todo with the id", async () => {
const getTodo = require("./todo.dao").getTodo;
const todo = createStubTodo();
getTodo.mockResolvedValue(todo);
// ...
expect(getTodo).toHaveBeenCalledWith(todo.id);
});

it("returns a 404 if no todo with that id exists", async () => {
const getTodo = require("./todo.dao").getTodo;
const todoId = uuid();
getTodo.mockResolvedValue(undefined);
// ...
expect(getTodo).toHaveBeenCalledWith(todoId);
});
});

Note You need to use the import at runtime require("./todo.dao") such that you indeed get the mocked module. Remember that the controller module *.controller.ts is statically referring to the DAO module todo.dao.ts with import * from 'todo.dao'. Jest is overriding this at runtime.

Spinning up the HTTP server 🔗

We want to embrace and utilize the external dependency of HTTP and Express. There are be multiple approaches to test an API.

  • Call the controller functions directly.
  • Spin up the full API and send and receive HTTP requests and responses.
  • Spin up a minimal HTTP server and send and receive HTTP requests and responses.

Calling the controller functions directly looks first like a straightforward and isolated test approach without depending on HTTP and the runtime of Express. But the controller functions accept a request of the Express type Request and a response of the Express type Response as inputs. It is tough to properly mock and verify these input parameters to the controllers. Also, the generic type signatures of these types rather complicate the tests than that they are helping.

Note While we discard the option of testing controller functions directly, this is very much an option for testing of middleware functions in later posts.

Spinning up the full API is the approach closest to the production reality. However, in mature stages of the software with authentication and request logging, it leads to depending on these aspects which affects isolation of the controller tests. Also, tests become more complicated due to additional effort to pass access tokens.

The third way avoids that by spinning up a dedicated test HTTP server that only configures the required middleware.

import express, { Express } from "express";

export function server(configure: (express: Express) => void): Express {
const app = express();
app.use(express.json({ limit: "1mb" }));
configure(app);
return app;
}

This function can be used by each controller test to spin up the relevant endpoint.

import { server } from "../../test.functions";
import { putTodoController } from "./put-todo.controller";

describe("putTodoController", () => {
const route = "/todos/:id";

const app = server((app) => {
app.put(route, putTodoController);
});

// ...
});

In later posts, we will see how we can utilize this server function to add error handling middleware and to mock authentication middleware.

Sending HTTP requests 🔗

For sending the HTTP requests, we are using a library called supertest. Given an Express app app: Express, it allows to send requests and verify the responses.

const response = await request(app)
.put(`/todos/${todo.id}`)
.send(todo)
.expect(200);
expect(response).toHaveProperty("body", todo);

Controller test structure 🔗

With the todo stub, data access mocking, the server function and supertest, we have everything in place to write controller tests. We can define a general structure to all of these tests.

import request from "supertest";
import { server } from "../../test.functions";
import { createStubTodo } from "./todo.stub";
import { putTodoController } from "./put-todo.controller";

// Mock DAO module
jest.mock("./todo.dao");

describe("putTodoController", () => {
// Define route under test
const route = "/todos/:id";

// Spin up http server
const app = server((app) => {
// Setup endpoint
app.put(route, putTodoController);
});

it("updates a todo", async () => {
// Get data access function mock
const updateTodo = require("./todo.dao").updateTodo;
// Create stub (if necessary)
const todo = createStubTodo();
// Setup mocks
updateTodo.mockResolvedValue(todo);
// Send request and expect response
const response = await request(app)
.put(route.replace(":id", todo.id))
.send(todo)
.expect(200);
// Expect response body
expect(response).toHaveProperty("body", todo);
// Expect data access function to be called
expect(updateTodo).toHaveBeenCalledWith(todo.id, todo);
});
});

We mock the data access module and spin up a minimal server with only the required middleware and endpoint. A series of tests check for the expected properties of the endpoints. Each test gets the mock of the relevant data access function and sets it up. A request is send and a status code and response body expected. Additionally, it is verified that the controller called the data access function for the execution of a side effect.

Conclusion 🔗

We saw how we can build isolated controller unit tests with type stub, data access function mock and a minimal HTTP server. With these, we defined a general structure for controller tests. But at this point, all controllers accept all inputs without validation. In the next post, we are adding validation based on an OpenAPI specification.

Become a GitHub sponsor to access the code of this post as a GitHub repo.