| |

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

CommandPurpose
npm install --save-dev supertestInstall Supertest
npm install --save-dev jestInstall test runner (or Mocha/Vitest)

Request Methods

MethodPurpose
request(app)Target an Express app (ephemeral port)
request('http://...')Target a running server
request.agent(app)Persist cookies across requests

HTTP Verbs

ChainPurpose
.get(path)GET request
.post(path)POST request
.put(path)PUT request
.delete(path)DELETE request

Request Configuration

MethodPurpose
.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

AssertionPurpose
.expect(status)Assert status code
.expect(header, value)Assert header
.expect(body)Assert exact body
.expect(fn)Custom assertion function

Critical Rules

RuleReason
Separate app.js from server.jsPrevents port binding in tests
Use async/await, not .end().end() swallows assertion errors
Import the real app.jsExercises the full middleware stack
Clean up test database in beforeEachPrevents 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

PitfallWhy It HappensFix
EADDRINUSETest imports server.jsImport app.js instead
Assertion error ignored.end() callback does not check errUse async/await
Body is empty.send() not usedAttach body with .send()
Header assertion failsContent-Type not JSONCheck route returns JSON
404 for valid routeApp not exported correctlymodule.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

ItemValue
Librarysupertest
BaseSuperAgent
Primary useIntegration testing HTTP APIs
App bindingrequest(app) — ephemeral port
Running serverrequest('http://...')
Assertion.expect(status), .expect(header, value)
Body.send(data)
Headers.set(name, value)
Cookiesrequest.agent(app)
Critical ruleSeparate app.js from server.js

Key takeaways:

  • Supertest binds the app to an ephemeral port. It passes the Express app object, 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.js exports the Express application without listen(). server.js imports the app and calls listen(). Tests import app.js, not server.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 parsed response.body .
  • .send() sets the Content-Type. JSON bodies get application/json, form bodies get application/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!