Integration Testing Node.js APIs with Jest and Supertest
Writing reliable and maintainable tests is a must when building APIs with Node.js. In this guide, you’ll learn how to write integration tests for Node.js and Express APIs using Jest and Supertest, with practical TypeScript examples you can apply immediately. While unit tests should make up the majority of your test suite, integration tests play a crucial role—especially when it comes to testing middleware, request validation, and error handling.
In this post, we’ll walk through how to write integration tests for a Node.js Express API using Jest and Supertest, based on a real-world project setup.
🚀 Complete JavaScript Guide (Beginner + Advanced)
🚀 NodeJS – The Complete Guide (MVC, REST APIs, GraphQL, Deno)
Why Integration Tests Matter
Integration tests exercise multiple parts of your application together:
- Routing
- Middleware (auth, validation, error handling)
- Controllers
- Use cases
Middleware in particular is difficult to test with unit tests alone. Integration tests allow you to send real HTTP requests into your API and assert against the responses—just like a real client would.
Installing Supertest
Supertest makes it easy to test HTTP endpoints without running a real server.
npm install supertest @types/supertest --save-dev
We’ll assume Jest is already set up in your project.
Providing an Express App to Supertest
Supertest needs an Express app instance. In this project, server.ts exposes a createServer function that returns an Express app:
// server.ts
import express from "express";
export function createServer() {
const app = express();
app.get("/health", (_req, res) => {
res.status(200).json({ ok: true });
});
return app;
}
This setup makes testing much easier.
Writing Your First Integration Test
Create a test file:
src/tests/integrationTests.test.ts
Then write a simple test for the /health endpoint:
import request from "supertest";
import { createServer } from "../server";
describe("Health endpoint tests", () => {
it("Health endpoint returns ok 200", async () => {
const app = createServer();
const response = await request(app).get("/health");
expect(response.status).toBe(200);
expect(response.body.ok).toBe(true);
});
});
Run Jest in watch mode:
npm run test -- --watch
If this passes, your integration testing setup is working.
Testing a Real Endpoint: Create Task
Now let’s test a more realistic scenario: creating a task via POST /v1/tasks.
The controller uses a CreateTaskUseCase class. We don’t want to hit the database during tests, so we’ll mock this dependency.
Mocking the Use Case
import request from "supertest";
import { createServer } from "../server";
import { CreateTaskUseCase } from "../routes/v1/tasks/createTask.usecase";
jest.mock("../routes/v1/tasks/createTask.usecase");
describe("Create task integration tests", () => {
const executeMock = jest.fn();
beforeEach(() => {
(CreateTaskUseCase as jest.Mock).mockImplementation(() => ({
execute: executeMock,
}));
});
afterEach(() => {
jest.clearAllMocks();
});
it("creates a new task", async () => {
executeMock.mockResolvedValue({ id: 1, name: "New task" });
const app = createServer();
const response = await request(app)
.post("/v1/tasks")
.send({ name: "New task" });
expect(response.status).toBe(201);
expect(response.body).toHaveProperty("task");
expect(response.body.task.name).toBe("New task");
});
});
Bypassing Authentication Middleware
If your route is protected by authentication middleware, you may see a 401 Unauthorized error.
For integration tests, you can mock the middleware to simply pass through:
jest.mock("../middleware/authenticate-user", () => ({
__esModule: true,
default: () => (_req: any, _res: any, next: any) => next(),
}));
This keeps your tests focused on behavior, not authentication.
Testing Error Handling
Error handling often lives in middleware, which makes it ideal for integration testing.
it("returns an error when task creation fails", async () => {
executeMock.mockRejectedValue(new Error("Error creating task"));
const app = createServer();
const response = await request(app)
.post("/v1/tasks")
.send({ name: "New task" });
expect(response.status).toBe(500);
expect(response.body).toHaveProperty("error");
});
This verifies that your global error handler works correctly.
Testing Request Validation
This project uses a validation middleware powered by Joi. The task name is required.
it("validates task payload by requiring name", async () => {
const app = createServer();
const response = await request(app)
.post("/v1/tasks")
.send({ description: "Missing name" });
expect(response.status).toBe(422);
expect(response.body.error.code).toBe("ERR_VALID");
});
This test exercises:
- Validation middleware
- Joi schema
- Error handler
All in one request.
Measuring Test Coverage
Run the coverage report:
npm run test -- --coverage
Even a small number of integration tests can significantly increase coverage because they touch multiple modules at once.
Best Practices
- Keep most of your tests unit tests
- Use integration tests selectively
- Focus integration tests on:
- Middleware
- Validation
- Error handling
- Redirect-heavy flows
Integration tests are powerful—but they should complement, not replace, unit tests.
Happy testing 🚀
Frequently Asked Questions (FAQ)
Should I use integration tests or unit tests for Node.js?
You should use both. Unit tests should make up the majority of your test suite, while integration tests are best for middleware, validation, error handling, and request/response flows.
Is Supertest good for Express APIs?
Yes. Supertest is one of the most popular libraries for testing Express APIs because it allows you to test endpoints without running a real HTTP server.
Do integration tests replace end-to-end tests?
No. Integration tests sit between unit tests and end-to-end tests. They test multiple modules together but usually mock external systems like databases and third-party services.