Express testing p2: integration and unit
/ 9 min read
Integration and unit tests
In the previous post I explained how to setup the environment for testing. In this post I will show you how to test your express app with integration and unit tests.
The idea is to do a couple of examples to test the API endpoints with integration tests to see if all works correctly with the DB and also unit test for the error handler and auth middlewares.
Unit testing
Usually this kind of tests are used for specific functions, the idea in general is to have more integration tests than unit tests, that’s why we are going to reserve unit testing for key parts. In our case we want to verify that the auth and the error handler middlewares are working correctly.
Auth middleware
Let’s start with auth by adding to the middlewares folder a new test folder and file /middlewares/__tests__/auth.test.ts
.
Let’s see the code of the middleware.
import { checkToken } from "@lib/helpers";
import { NextFunction, Request, Response } from "express";
const userAuthorizationMiddleware = async (req: Request, _: Response, next: NextFunction) => {
try {
const authCookies = req.headers.cookie;
if (!authCookies) {
throw new Error("Authorization header/cookie is required");
}
const token = authCookies.split(" ")[1];
if (!token) {
throw new Error("Missing token in headers");
}
const { userId } = checkToken(token) as DecodedToken;
req.userId = userId as number;
next();
} catch (e) {
next(e);
}
};
export default userAuthorizationMiddleware;
In this middleware we get the cookies and evaluate if the token provided is valid or not. In first place we need to create a token (we have a helper function that builds the JWT for us). The auth middleware takes in a http request, check if the token provided is ok and attach the user_id
to the request to make it available for in thecontrollers. We want to keep it simple so we are going to test 2 cases, when the token is ok and when it’s not.
The middleware is just a function that accepts a request and a response and a next function and it’s going to modify for the next middleware. In this case what we need to do is mock all the inputs in order to create the unit test.
A mock is a placeholder for a value that we want to use in a unit test. It will behave exactly like the real value but we can alter it to test different scenarios.
This is the code of the unit test:
import { generateToken } from "@lib/helpers";
describe("Auth middleware", () => {
let mockRequest: Partial<authRequest>;
let mockResponse: Partial<Response>;
let nextFunction: NextFunction = jest.fn();
//helper function to generate a token
const token = generateToken(1);
beforeEach(() => {
mockRequest = {
headers: {
cookie: `AUTHORIZATION=BEARER ${token}`,
},
userId: undefined,
};
mockResponse = {};
});
});
We mock the 3 parameters used in the middleware and in this case we can alter the properties and methods used. For example in the request we know that at some point the userId
is going to be defined and the cookies will be used to validate the token. We also need to mock the next
function that will be called after the middleware and finally use the beforeEach
to reset the mock on each test.
As you can see we we are not assigning express Request
to the mock but a authRequest
, that’s because in the original one we don’t have the userId
, add an interface at the top of the file. Another detail to look is that we are only mocking the request, with all the properties needed, and that’s because the only that will be testested is the request.
interface authRequest extends Request {
userId: number;
}
The first test is for valid token, the idea is to get the userId
in the request and to be like the one generated at the beginning of the file. In the second the token will be invalid (we’ll face this situation) so the userId
will remain undefined
and the nextFunction
will be called with an error. So the test’s will show like:
test("Should pass for a valid token", async () => {
userAuthorizationMiddleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockRequest.userId).toBe(1);
expect(nextFunction).toHaveBeenCalled();
});
test("Should not pass for a invalid token", async () => {
if (mockRequest.headers) {
mockRequest.headers.cookie = `AUTHORIZATION=BEARER ${token}123`;
}
userAuthorizationMiddleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockRequest.userId).toBe(undefined);
expect(nextFunction).toHaveBeenCalledWith(expect.any(Error));
});
Error handler middleware
Let’s move on to the error handler, add to in the same folder error.test.ts
file. In this we have to follow the structure but with different logic. In this middleware the idea is to insert an Error and see if the error handler process it correctly.
Here is the middleware:
function errorHandler(error: Error, _: Request, res: Response, _next: NextFunction): void {
if (error.constructor === PrismaClientKnownRequestError) {
const prismaError = error as PrismaClientKnownRequestError;
res.status(400).json(prismaCustomError(prismaError));
return;
} else if (error.message) {
res.status(400).json({ error: error.message });
return;
}
Logger.error(error);
res.status(500);
return;
}
function prismaCustomError(error: PrismaClientKnownRequestError): PrismaErrorMessage {
let message = "";
switch (error.code) {
case "P2002": {
const target: string[] = error.meta?.target as string[];
message = `${target.join("")} already exists`;
break;
}
default: {
console.error(error);
break;
}
}
return { error: message };
}
export default errorHandler;
And here the the test setup
describe("Error handler middleware", () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let mockNext: Partial<NextFunction>;
beforeEach(() => {
mockRequest = {};
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
mockNext = jest.fn();
});
});
Create an error and pass it to the function, after it we can expect that the error handler will process it correctly.
test("handle error when error includes statusCode", async () => {
const error = new Error("Test");
errorHandler(error, mockRequest as Request, mockResponse as Response, mockNext as NextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.json).toHaveBeenCalledWith({ error: error.message });
});
Integration tests
The integration testing are going to be the core of your tests, when you are creating the server the idea is to test all the endpoints of you app but we are going to test only 2 endpoints. The idea is to start the server and use a package called supertest
to test the endpoints. Add it with pnpm add -D supertest
and also add the types with pnpm add -D @types/supertest
I’m going only to do a cuople examples of integration testing, but once an example the rest is going to be similar.
Setup app and test
We need to understand supertest is going to create an instance of the app and when we are done with them we are going to close down the server. For that we need to be sure that the express app is not exported directly but instead in a wrapper function that will be triggered in the index.ts
or api.tests.ts
like this:
function startServer(): Server {
const server = app.listen(port, () => {
Logger.info(`[server]: Server is running at http://${HOST}:${PORT}`);
});
process.on("SIGINT", () => {
server.close();
});
return server;
}
export default startServer;
Now we can create the src/__tests__/api.test.ts
file with the following setup code:
describe("API tests", () => {
let app: Server;
beforeAll(() => {
app = startServer();
});
afterAll(() => {
app.close();
});
});
After the test this will close the server and exit the process
Test first endpoint
The idea is test POST/users
which is calling this controller
const UsersController = {
async createUser(req: Request, res: Response, next: NextFunction) {
try {
const { name, password, email } = req.body;
if (!name || !password || !email) {
throw new Error("Name, email and password are required");
}
const hashedPassword = await hashPassword(password);
const user = await User.create(name, hashedPassword, email);
res.status(201).json(user);
} catch (e) {
next(e);
}
},
};
As you can see we need to pass a body with some content and the given result is going to be a user
with id
, name
and createdAt
. In this case we can use supertest to make it easy to
create the tests, in this case we won’t need to mock the Request
or Response
. The first endpoint is not protected but in the second one we’ll need to add a valid token in the headers.
The final result should look like:
describe("POST /users", () => {
test("Should create user", async () => {
const response = await request(app).post("/users").send({
name: "test",
email: "test",
password: "test",
});
expect(response.status).toBe(201);
expect(response.body).toEqual({
id: 1,
name: "test",
createdAt: expect.any(String),
});
});
test("Should return error when not passed all required fields", async () => {
const response = await request(app).post("/users").send({
name: "test",
email: "test",
});
expect(response.status).toBe(400);
expect(response.body).toEqual({
error: "Name, email and password are required",
});
});
});
describe("POST /posts", () => {
test("Should create post", async () => {
const token = generateToken(1);
const response = await request(app)
.post("/posts")
.send({
title: "test",
text: "test",
})
.set("Cookie", [`AUTHORIZATION=BEARER ${token}`]);
expect(response.status).toBe(201);
expect(response.body).toEqual({
id: expect.any(Number),
title: "test",
text: "test",
creaedAt: expect.any(String),
publish: false,
authorId: expect.any(Number),
});
});
});
Extra: Mock functions
Is not our case but if we imagine that you want to call the some functions in the integration testing, generally you want to do it when you are connected with external APIs that have a cost, in those cases we’ll mock the function and resolve the mock with the value that we expect from that function. Let’s see an example.
In this case here we have a controller that is using the User
model.
const UsersController = {
async getUsers(_: Request, res: Response, next: NextFunction) {
try {
const allUsers = await User.findAll();
res.status(200).json(allUsers);
} catch (e) {
next(e);
}
},
};
We need to import the controller and the model, with jest.mock()
we intercept the module a we can be sure that when we use the controller the model won’t be used and return the expected value (empty array in this case) with the mockResolvedValue()
, jest.spyOn()
is the same as mock but with the ability to spy on the method.
import UsersController from "@users.controller";
import User from "@models/Users";
import { jest, test, expect, afterAll } from "@jest/globals";
//mock the model
jest.mock("@models/Users");
test("Get all users", async () => {
//place a spy on the specific method
const mockModel = jest.spyOn(User, "findAll");
//tell the mock what is going to be the value returned by "findAll"
mockModel.mockResolvedValue([]);
const response = await request(app).get("/users");
expect(mockModel).toHaveBeenCalledTimes(1);
expect(response.status).toEqual(200);
expect(response.body).toEqual([]);
});
And that’s it, I hope this guide is helpful for you and now it’s up to you to test your code.