Node.js 73 🟢 Integration and End-to-End API Testing with Supertest
Supertest is a library that lets you make HTTP requests against your Express application and assert on the response, without starting a server on a fixed port and without writing manual socket code. It extends SuperAgent, an HTTP client, and adds a chainable .expect() method that validates status codes, headers, and body content in a single fluent expression . The library passes the Express app object directly to request(), binds it to an ephemeral port if it is not already listening, sends the request, and tears the server down when the test completes . This is the mechanism that makes it possible to test routes as integration tests—exercising the full middleware stack, routing, and serialization—without the operational overhead of a running server.
The distinction between integration and end-to-end testing matters for how you structure the suite. Integration tests verify that components work together: the route handler calls the service, the service calls the repository, and the response is correctly serialized. They run against an in-process application and a test database, and they complete in seconds . End-to-end tests verify complete user journeys, often including the frontend and external services, and they run against a deployed or staging environment . Supertest is the tool for the integration layer. It can be used for end-to-end tests against a running server, but its primary value is testing the application’s HTTP contract without leaving the process.
Key point: Supertest takes the Express app object, binds it to an ephemeral port, sends the request, asserts on the response, and cleans up—allowing integration tests to exercise the full HTTP stack without starting a persistent server; the critical pattern is separating app.js (the Express configuration) from server.js (app.listen()), because importing the server file in a test would bind a port and create conflicts.
Why the app/server separation is mandatory
The port conflict problem. If your test imports a file that calls app.listen(3000), the port is bound when the test runs. If another test file imports the same module, or if the test suite runs in parallel, the second import fails with EADDRINUSE . The fix is structural: app.js exports the Express application without calling listen(), and server.js imports the app and calls listen(). The test imports app.js, passes it to Supertest, and Supertest handles the ephemeral binding internally.
The middleware problem. An integration test should exercise the middleware that runs in production: body parsing, authentication, error handling, CORS. If the test creates a minimal Express app with only the route handler, it is testing the handler, not the integration. By importing the real app.js, the test runs against the same middleware stack that the server uses, which is the entire point of an integration test .
The cleanup problem. Supertest’s ephemeral binding means no persistent server is left running after the test. The request completes, the response is asserted, and the server is closed. There is no afterAll hook needed to close a server, and no risk of a leaked port . This is why the library is described as “zero-port” testing: the port exists only for the duration of the request.
a. The basic pattern: request, expect, and async/await
The simplest Supertest test makes a request and asserts on the status code and response body. The library supports callbacks, promises, and async/await. The async/await form is the clearest and the one that works with Jest, Mocha, and Vitest without additional configuration .
const request = require('supertest');
const app = require('./app');
test('GET /users returns a list', async () => {
const response = await request(app)
.get('/users')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.success).toBe(true);
expect(Array.isArray(response.body.data)).toBe(true);
});
The .expect(200) call is a Supertest assertion, not a Jest assertion. It checks the status code and throws if the response does not match. The .expect('Content-Type', /json/) checks the header against a regular expression. These can be chained before the await, and they run in the order they are defined . The response object contains status, headers, and body, and the Jest assertions (expect(response.body...)) run after the request completes.
The request(app) call does not require a URL. Supertest detects that the argument is an Express application and starts it on a random free port. If you pass a string (request('http://localhost:3000')), it sends the request to that running server instead .
b. Testing POST, PUT, and DELETE with bodies and headers
Supertest’s .send() method attaches a request body. For JSON bodies, Supertest sets the Content-Type to application/json automatically. For form-encoded bodies, it sets application/x-www-form-urlencoded . The .set() method adds headers, which is required for authentication tokens and custom content types.
test('POST /users creates a user', async () => {
const newUser = { name: 'Alice', email: 'alice@example.com' };
const response = await request(app)
.post('/users')
.send(newUser)
.set('Accept', 'application/json')
.expect(201);
expect(response.body.data.name).toBe('Alice');
expect(response.body.data.id).toBeDefined();
});
test('DELETE /users/:id removes a user', async () => {
await request(app).delete('/users/1').expect(200);
await request(app).get('/users/1').expect(404);
});
The DELETE test demonstrates the integration nature of the suite: the first request removes the user, and the second verifies that the user is gone. Both requests go through the same application instance, and the state change is visible because the in-memory store (or test database) persists between requests within the test .
For authenticated routes, .set('Authorization', 'Bearer token') adds the header. The request.agent(app) method creates a persistent agent that stores cookies across requests, which is necessary for testing login flows that rely on session cookies .
c. The end() callback and the assertion error trap
The .end() method is the callback-style termination of the request chain. It receives (err, res). The trap is that .expect() assertions that fail inside a .end() callback do not throw automatically—they pass the assertion error as the first argument to the callback . If the callback ignores the error, the test passes even though the assertion failed.
// WRONG: error is ignored
request(app)
.get('/users')
.expect(200)
.end((err, res) => {
// if err is set, the test still passes
done();
});
// CORRECT: error is propagated
request(app)
.get('/users')
.expect(200)
.end((err, res) => {
if (err) return done(err);
done();
});
The async/await pattern avoids this trap entirely because the .expect() calls throw when they fail, and the await propagates the error to the test framework . For new tests, use async/await. The .end() callback is only necessary when integrating with older test runners that do not support promises.
Complete Example Session
The following session builds an Express API with an in-memory user store and a test suite that covers GET, POST, DELETE, error handling, and an authenticated route.
// ============================================
// PART 1: THE APP (app.js)
// ============================================
const express = require('express');
const app = express();
app.use(express.json());
let users = [
{ id: 1, name: 'John', email: 'john@example.com' },
{ id: 2, name: 'Jane', email: 'jane@example.com' },
];
app.get('/users', (req, res) => {
res.json({ success: true, data: users });
});
app.get('/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ success: false, error: 'User not found' });
res.json({ success: true, data: user });
});
app.post('/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ success: false, error: 'Name and email required' });
}
const newUser = { id: users.length + 1, name, email };
users.push(newUser);
res.status(201).json({ success: true, data: newUser });
});
app.delete('/users/:id', (req, res) => {
const index = users.findIndex(u => u.id === parseInt(req.params.id));
if (index === -1) return res.status(404).json({ success: false, error: 'User not found' });
users.splice(index, 1);
res.json({ success: true, message: 'User deleted' });
});
module.exports = app;
// ============================================
// PART 2: THE SERVER (server.js)
// ============================================
const app = require('./app');
app.listen(3000, () => console.log('Server on :3000'));
// ============================================
// PART 3: THE TEST SUITE (app.test.js)
// ============================================
const request = require('supertest');
const app = require('./app');
describe('User API', () => {
// ============================================
// PART 4: GET TESTS
// ============================================
describe('GET /users', () => {
it('returns all users', async () => {
const response = await request(app)
.get('/users')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data.length).toBeGreaterThan(0);
});
});
describe('GET /users/:id', () => {
it('returns a user by id', async () => {
const response = await request(app).get('/users/1').expect(200);
expect(response.body.data).toHaveProperty('id', 1);
});
it('returns 404 for missing user', async () => {
const response = await request(app).get('/users/999').expect(404);
expect(response.body.error).toBe('User not found');
});
});
// ============================================
// PART 5: POST TESTS
// ============================================
describe('POST /users', () => {
it('creates a new user', async () => {
const newUser = { name: 'Bob', email: 'bob@example.com' };
const response = await request(app)
.post('/users')
.send(newUser)
.expect(201);
expect(response.body.data.name).toBe('Bob');
expect(response.body.data.id).toBeDefined();
});
it('rejects missing fields', async () => {
const response = await request(app)
.post('/users')
.send({ name: 'NoEmail' })
.expect(400);
expect(response.body.error).toContain('required');
});
});
// ============================================
// PART 6: DELETE TESTS
// ============================================
describe('DELETE /users/:id', () => {
it('removes a user', async () => {
await request(app).delete('/users/1').expect(200);
await request(app).get('/users/1').expect(404);
});
});
});
The six parts show the app with in-memory routes, the server file that is not imported by tests, and the test suite covering GET, POST, and DELETE with status codes and body assertions. What the session demonstrates is that Supertest tests the HTTP contract—the status codes, headers, and JSON shape—through the real Express middleware stack, and the app/server separation prevents port conflicts.
Quick Reference
Installation
| Command | Purpose |
|---|---|
npm install --save-dev supertest | Install Supertest |
npm install --save-dev jest | Install test runner (or Mocha/Vitest) |
Request Methods
| Method | Purpose |
|---|---|
request(app) | Target an Express app (ephemeral port) |
request('http://...') | Target a running server |
request.agent(app) | Persist cookies across requests |
HTTP Verbs
| Chain | Purpose |
|---|---|
.get(path) | GET request |
.post(path) | POST request |
.put(path) | PUT request |
.delete(path) | DELETE request |
Request Configuration
| Method | Purpose |
|---|---|
.send(body) | Attach JSON or form body |
.set(header, value) | Add request header |
.auth(user, pass) | HTTP basic auth |
.query(params) | Add query string |
Assertions
| Assertion | Purpose |
|---|---|
.expect(status) | Assert status code |
.expect(header, value) | Assert header |
.expect(body) | Assert exact body |
.expect(fn) | Custom assertion function |
Critical Rules
| Rule | Reason |
|---|---|
Separate app.js from server.js | Prevents port binding in tests |
Use async/await, not .end() | .end() swallows assertion errors |
Import the real app.js | Exercises the full middleware stack |
Clean up test database in beforeEach | Prevents state leakage between tests |
Best Practices
✅ Do This:
const app = require('./app'); // ✅ import app, not server
await request(app).get('/users').expect(200); // ✅ async/await
.expect('Content-Type', /json/) // ✅ header assertion
await request(app).post('/users').send(data).expect(201); // ✅ send JSON body
❌ Don’t Do This:
const server = require('./server'); // ❌ binds port
request(app).get('/').end((err, res) => done()); // ❌ ignores err
expect(response.status).toBe(200); // only Jest assertion // ❌ missing Supertest assert
// test against production database // ❌ pollutes real data
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
EADDRINUSE | Test imports server.js | Import app.js instead |
| Assertion error ignored | .end() callback does not check err | Use async/await |
| Body is empty | .send() not used | Attach body with .send() |
| Header assertion fails | Content-Type not JSON | Check route returns JSON |
| 404 for valid route | App not exported correctly | module.exports = app |
Real-World Examples
1. Basic GET test
await request(app).get('/health').expect(200);
2. POST with body
await request(app).post('/users').send({ name: 'A' }).expect(201);
3. Header assertion
.expect('Content-Type', /json/)
4. Auth header
.set('Authorization', 'Bearer token')
5. Cookie persistence
const agent = request.agent(app);
await agent.post('/login').send(creds);
await agent.get('/me').expect(200);
6. Error response
const res = await request(app).get('/missing').expect(404);
expect(res.body.error).toBe('Not found');
7. DELETE and verify
await request(app).delete('/users/1').expect(200);
await request(app).get('/users/1').expect(404);
8. Content-Type check
.expect('Content-Type', /json/)
9. Custom assertion function
.expect(res => { if (!res.body.id) throw new Error('No id'); })
10. Run tests
npx jest app.test.js
Visual
Supertest Request Flow
┌──────────────────────────────────────────────┐
│ Test: request(app).get('/users').expect(200)│
│ │ │
│ ▼ │
│ Supertest binds app to ephemeral port │
│ │ │
│ ▼ │
│ HTTP GET /users │
│ │ │
│ ▼ │
│ Express middleware stack runs │
│ │ │
│ ▼ │
│ Route handler returns response │
│ │ │
│ ▼ │
│ Supertest asserts status/headers/body │
│ │ │
│ ▼ │
│ Server closed, port released │
└──────────────────────────────────────────────┘
App/Server Separation
┌──────────────────────────────────────────────┐
│ app.js │
│ const app = express(); │
│ app.get('/users', ...); │
│ module.exports = app; │
│ │ │
│ ├── server.js imports app, listen() │
│ │ │
│ └── app.test.js imports app, │
│ passes to Supertest │
│ │
│ No listen() in app.js = no port conflict │
└──────────────────────────────────────────────┘
Integration vs E2E
┌──────────────────────────────────────────────┐
│ INTEGRATION (Supertest) │
│ - In-process app │
│ - Test database │
│ - Fast (seconds) │
│ - Full middleware stack │
│ │
│ E2E (deployed server) │
│ - Running server │
│ - Staging environment │
│ - Slow (minutes) │
│ - External services included │
└──────────────────────────────────────────────┘
Assertion Chain
┌──────────────────────────────────────────────┐
│ request(app) │
│ .get('/users') │
│ .expect('Content-Type', /json/) ← check │
│ .expect(200) ← check │
│ │ │
│ ▼ │
│ await → response object │
│ │ │
│ ▼ │
│ expect(response.body.data).toBeArray() │
│ (Jest assertion on the parsed body) │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Library | supertest |
| Base | SuperAgent |
| Primary use | Integration testing HTTP APIs |
| App binding | request(app) — ephemeral port |
| Running server | request('http://...') |
| Assertion | .expect(status), .expect(header, value) |
| Body | .send(data) |
| Headers | .set(name, value) |
| Cookies | request.agent(app) |
| Critical rule | Separate app.js from server.js |
Key takeaways:
- Supertest binds the app to an ephemeral port. It passes the Express
appobject, starts it on a random free port, sends the request, and cleans up. There is no persistent server and no port conflict . - The app/server separation is mandatory.
app.jsexports the Express application withoutlisten().server.jsimports the app and callslisten(). Tests importapp.js, notserver.js. - Use async/await, not
.end(). The.end()callback swallows assertion errors if the error argument is ignored. Async/await propagates the error to the test framework . .expect()is a Supertest assertion. It runs before the request completes and throws on failure. Jest assertions run after the response is received, on the parsedresponse.body..send()sets the Content-Type. JSON bodies getapplication/json, form bodies getapplication/x-www-form-urlencoded. Supertest handles the header automatically .- Integration tests exercise the full middleware stack. By importing the real
app.js, the test runs through body parsing, authentication, and error handling, not just the route handler . - End-to-end tests use Supertest against a running server. Pass a URL instead of the app object. The test is slower and requires a deployed environment, but it validates the complete system .
Remember: Supertest is the bridge between your Express application and the test runner. It sends HTTP requests through the real middleware stack, asserts on the response, and cleans up the ephemeral server. The pattern is always the same: export the app, pass it to request(), chain the HTTP verb and .send()/.set(), chain the .expect() assertions, and await the response. The critical discipline is the app/server separation and the async/await pattern—those two choices prevent the port conflicts and swallowed assertions that make API tests unreliable. Use Supertest for integration tests that verify the HTTP contract. Use a deployed environment and a URL for end-to-end tests that verify the complete user journey.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!