Integration testing is one of the most important parts of building reliable software systems. While unit testing ensures that individual pieces of code work as expected, integration testing verifies that different parts of your application work together properly. In a Node.js application—especially one that exposes APIs through frameworks like Express—testing end-to-end behavior helps prevent bugs, ensure stability, and maintain confidence as your codebase evolves.
In this post, we will explore integration testing in detail. We’ll focus on using a popular testing tool called Supertest to test Node.js APIs. By the end, you will understand what integration testing is, why it’s essential, how Supertest works, and how to build and test an example Express API from start to finish.
Let’s get started.
What Is Integration Testing?
Integration testing verifies that different modules or services within an application interact correctly. While unit tests isolate small pieces of functionality (such as a single function), integration tests check the combined behavior of multiple modules.
For example, if you have a Node.js API with routes, controllers, and database access layers, integration testing ensures that:
- The API routes are connected correctly to controllers.
- Controllers handle requests and return proper responses.
- Middleware such as authentication, validation, or error handling works correctly together.
Integration tests simulate real scenarios—making actual HTTP requests to your API and verifying responses—without needing to start the production server.
Why Integration Testing Matters
Integration testing helps catch issues that unit testing might miss. It’s common for individual functions to work perfectly in isolation but fail when combined. For instance:
- A route may not return the expected response format.
- The API may return an incorrect status code.
- The database query may fail due to incorrect parameters.
- Middleware may block a legitimate request.
By performing integration tests, you can detect these problems early—before your code reaches production. This improves reliability, reduces debugging time, and increases developer confidence during deployment.
What Is Supertest?
Supertest is a Node.js library that simplifies testing HTTP servers. It’s built on top of the Superagent HTTP request library and integrates easily with test runners like Jest or Mocha.
With Supertest, you can simulate HTTP requests to your Express app without actually running it on a network port. This makes testing APIs fast, isolated, and repeatable.
Key features of Supertest:
- It allows you to make requests to your app (GET, POST, PUT, DELETE, etc.).
- It automatically handles assertions for status codes, response types, and response bodies.
- It integrates seamlessly with any Node.js testing framework.
Setting Up the Environment
Before we dive into writing tests, let’s set up a simple Express API project.
Step 1: Create a new Node.js project
In your terminal, run:
mkdir supertest-demo
cd supertest-demo
npm init -y
Step 2: Install required dependencies
npm install express
npm install --save-dev jest supertest
Step 3: Add a test script to package.json
Open your package.json
and add this line:
"scripts": {
"test": "jest"
}
Building a Simple Express API
Now, let’s build a small API that allows users to manage a list of books.
Create a file named app.js
and add the following code:
const express = require('express');
const app = express();
app.use(express.json());
let books = [
{ id: 1, title: 'Atomic Habits', author: 'James Clear' },
{ id: 2, title: 'The Alchemist', author: 'Paulo Coelho' }
];
// Get all books
app.get('/books', (req, res) => {
res.status(200).json(books);
});
// Get book by ID
app.get('/books/:id', (req, res) => {
const id = parseInt(req.params.id);
const book = books.find(b => b.id === id);
if (!book) return res.status(404).json({ message: 'Book not found' });
res.status(200).json(book);
});
// Add a new book
app.post('/books', (req, res) => {
const { title, author } = req.body;
if (!title || !author) {
return res.status(400).json({ message: 'Invalid input' });
}
const newBook = { id: books.length + 1, title, author };
books.push(newBook);
res.status(201).json(newBook);
});
// Delete a book
app.delete('/books/:id', (req, res) => {
const id = parseInt(req.params.id);
const index = books.findIndex(b => b.id === id);
if (index === -1) {
return res.status(404).json({ message: 'Book not found' });
}
books.splice(index, 1);
res.status(204).send();
});
module.exports = app;
This simple API supports four routes:
GET /books
– get all books.GET /books/:id
– get a single book.POST /books
– add a new book.DELETE /books/:id
– delete a book by ID.
Writing Integration Tests with Supertest
Now that we have a working API, let’s test it using Supertest and Jest.
Create a file named app.test.js
in the root directory and write the following tests:
const request = require('supertest');
const app = require('./app');
describe('Books API Integration Tests', () => {
test('GET /books should return all books', async () => {
const response = await request(app).get('/books');
expect(response.statusCode).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
});
test('GET /books/:id should return a single book', async () => {
const response = await request(app).get('/books/1');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('id', 1);
});
test('GET /books/:id should return 404 for invalid ID', async () => {
const response = await request(app).get('/books/999');
expect(response.statusCode).toBe(404);
expect(response.body).toHaveProperty('message', 'Book not found');
});
test('POST /books should create a new book', async () => {
const newBook = { title: 'Deep Work', author: 'Cal Newport' };
const response = await request(app)
.post('/books')
.send(newBook);
expect(response.statusCode).toBe(201);
expect(response.body).toMatchObject(newBook);
});
test('POST /books should return 400 for invalid input', async () => {
const response = await request(app)
.post('/books')
.send({});
expect(response.statusCode).toBe(400);
expect(response.body).toHaveProperty('message', 'Invalid input');
});
test('DELETE /books/:id should delete a book', async () => {
const response = await request(app).delete('/books/1');
expect(response.statusCode).toBe(204);
});
test('DELETE /books/:id should return 404 for non-existent book', async () => {
const response = await request(app).delete('/books/999');
expect(response.statusCode).toBe(404);
expect(response.body).toHaveProperty('message', 'Book not found');
});
});
Running the Tests
To execute the tests, simply run:
npm test
You’ll see output similar to:
PASS ./app.test.js
Books API Integration Tests
✓ GET /books should return all books
✓ GET /books/:id should return a single book
✓ GET /books/:id should return 404 for invalid ID
✓ POST /books should create a new book
✓ POST /books should return 400 for invalid input
✓ DELETE /books/:id should delete a book
✓ DELETE /books/:id should return 404 for non-existent book
All tests should pass if your API is implemented correctly.
How Supertest Works Internally
Supertest uses the HTTP interface exposed by your Express app to send simulated requests. Normally, when you start an Express server using app.listen()
, it listens on a specific port. But in testing, Supertest directly hooks into the app instance using Node.js’s http
module without actually starting a server.
This means your tests:
- Run faster (no server startup delay)
- Don’t require open network ports
- Remain fully isolated from production or development environments
Supertest sends the request, waits for a response, and then provides a chainable API for assertions, such as .expect(200)
or .expect('Content-Type', /json/)
.
Best Practices for Integration Testing with Supertest
Here are some tips to make your testing more effective and maintainable.
1. Keep test data isolated
If your API interacts with a real database, make sure to reset or seed the database before each test run. This ensures consistency and prevents flaky tests.
2. Test both success and failure paths
Don’t only test the happy paths. Verify how your API handles bad input, missing data, or unauthorized access.
3. Use descriptive test names
Make test descriptions clear and readable. Someone new to the project should understand what’s being tested without reading the test code.
4. Group related tests using describe blocks
Organize tests logically. For example, group all /books
route tests together.
5. Mock external dependencies when needed
If your routes call external services (like payment gateways or third-party APIs), mock them using libraries like nock
to avoid hitting real endpoints.
6. Keep tests fast and deterministic
Avoid writing tests that depend on network delays or random factors. A test should always produce the same result if the code hasn’t changed.
7. Run integration tests in CI/CD
Include integration tests in your continuous integration pipeline. This ensures that new code is always tested before merging or deployment.
Combining Unit, Integration, and End-to-End Tests
Each type of test serves a specific purpose in a full testing strategy.
- Unit tests: Verify individual functions or components.
- Integration tests: Verify that components work together correctly.
- End-to-end (E2E) tests: Simulate real user interactions across the system, often using browsers or real network calls.
In practice, most Node.js applications have more unit and integration tests, with fewer E2E tests since they are slower to run. Integration tests offer a good balance between speed and coverage.
Handling Authentication in Integration Tests
Many real-world APIs require authentication. You can simulate this easily in your Supertest tests by including tokens or headers.
Example:
test('GET /profile should return user profile when authenticated', async () => {
const token = 'mocked-jwt-token';
const response = await request(app)
.get('/profile')
.set('Authorization', Bearer ${token}
);
expect(response.statusCode).toBe(200);
});
You can also mock JWT verification or authentication middleware to isolate the functionality being tested.
Testing Error Handling and Edge Cases
Testing should also include cases where the application returns errors. This ensures that your API handles invalid input gracefully.
Examples:
- Missing required fields
- Invalid parameter types
- Server errors (thrown exceptions)
- Database connection failures
Supertest can verify not only the response code but also the message and structure.
test('POST /books should handle unexpected errors', async () => {
const response = await request(app)
.post('/books')
.send({ title: null });
expect(response.statusCode).toBeGreaterThanOrEqual(400);
});
Using beforeAll and afterAll Hooks
When writing multiple tests, you may want to set up data before running tests or clean up afterward. Jest provides beforeAll
, afterAll
, and beforeEach
hooks for this.
Example:
beforeAll(() => {
// Initialize test data or setup environment
});
afterAll(() => {
// Cleanup or reset data
});
These hooks make your tests consistent and easier to maintain.
Testing with Databases
If your API interacts with a database (like MongoDB or PostgreSQL), you can still use Supertest for integration testing. Instead of using in-memory data, connect to a test database and clean it between runs.
Example:
beforeEach(async () => {
await Book.deleteMany({});
await Book.insertMany([
{ title: 'Book 1', author: 'Author 1' },
{ title: 'Book 2', author: 'Author 2' }
]);
});
You can use tools like MongoDB Memory Server or SQLite in-memory mode to avoid affecting production data.
Advanced: Parameterized Tests
You can use parameterized testing to reduce code repetition. Jest supports test.each
for running the same test logic with multiple input values.
Example:
test.each([
['/books', 200],
['/books/1', 200],
['/books/999', 404]
])('GET %s should return %i', async (endpoint, expectedStatus) => {
const response = await request(app).get(endpoint);
expect(response.statusCode).toBe(expectedStatus);
});
This approach makes your test suite cleaner and easier to maintain.
Debugging Failed Tests
When tests fail, you can log the response for better debugging.
Example:
const response = await request(app).get('/books');
console.log(response.body);
expect(response.statusCode).toBe(200);
If your test runner suppresses logs, run Jest with the --verbose
flag to see more details:
npm test -- --verbose
Integration Testing vs. End-to-End Testing
While both test multiple components together, integration tests typically stay within the boundaries of your application—mocking external services if needed. End-to-end tests go a step further, involving UI, network, and external systems.
Integration tests are faster, cheaper, and easier to automate. They form the backbone of most API testing pipelines.
Leave a Reply