Skip to main content
Back to Blog
Tutorial
2026-06-24

SuperTest Node API Testing Tutorial — Jest & Express Guide

A complete SuperTest tutorial for Node.js API testing. Learn SuperTest with Jest and Express, async/await assertions, auth, mocking, and CI in TypeScript.

SuperTest Node API Testing Tutorial: Jest and Express Guide

SuperTest is the de facto standard for HTTP assertion testing in the Node.js ecosystem. Built on top of the battle-tested superagent HTTP client, it gives you a fluent, chainable API for firing requests at your Express, Fastify, Koa, or NestJS application and asserting on the status code, headers, and JSON body -- all without manually starting a server on a fixed port. If you have an HTTP API written in JavaScript or TypeScript, supertest node testing is almost certainly the most direct, fastest-feedback way to verify it works.

What makes SuperTest so popular is how cleanly it composes with the rest of the Node testing stack. You pair it with a test runner -- Jest is the most common, though Mocha, Vitest, and the built-in node:test runner all work -- and you get end-to-end coverage of your routes in milliseconds per test, because SuperTest binds your Express app to an ephemeral port automatically. There is no Docker container to spin up, no separate server process to manage, and no flakiness from network ports already being in use.

This supertest api testing tutorial takes you from zero to a production-grade test suite. We will install and wire up supertest jest express, write our first GET and POST assertions with modern async/await, structure tests so the Express app is importable and reusable, validate complex JSON responses, handle authentication and cookies, mock the database and external services, measure coverage, and finally run the whole thing in a GitHub Actions CI pipeline. Every code sample is real, runnable Node.js. Let's get started.

Why SuperTest Is the Standard for Node API Testing

SuperTest solves a specific problem elegantly: testing an HTTP server without the ceremony of actually deploying it. You pass your application instance to request(app), and SuperTest handles binding it to an unused port, sending the request, and tearing it down. This makes integration tests as cheap as unit tests.

FeatureSuperTestAxios + manual serverPostman/NewmanPlaywright API
RuntimeNode.js (in-process)Node.jsStandalone CLINode.js
Starts server automaticallyYes (ephemeral port)No (manual)No (external URL)No (external URL)
Test runnerJest, Mocha, Vitest, node:testAnyBuilt-in collection runnerPlaywright runner
Assertion styleChainable .expect() + bodyManual assertionsUI + scriptsexpect()
TypeScript supportFirst-class via @typesNativeLimitedFirst-class
Best forUnit/integration of your own appCustom flowsBlack-box external APIsMixed UI + API
CI footprintTiny (no network)MediumMediumMedium

The key insight is the in-process model. Because SuperTest imports and runs your Express app inside the same Node process as your tests, there is no network hop to an external URL, no port conflicts, and no separate process lifecycle to babysit. That is why SuperTest tests are both fast and reliable. For a broader view of where SuperTest fits among other approaches, see the API testing complete guide.

Installing and Setting Up SuperTest with Jest

Start by installing SuperTest and Jest as dev dependencies. If you are writing TypeScript, add the type packages and ts-jest as well.

# JavaScript project
npm install --save-dev jest supertest

# TypeScript project
npm install --save-dev jest supertest ts-jest typescript \
  @types/jest @types/supertest @types/express

Add a test script and a minimal Jest config to package.json:

{
  "scripts": {
    "test": "jest --runInBand",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "preset": "ts-jest",
    "testEnvironment": "node",
    "testMatch": ["**/*.test.ts"]
  }
}

The --runInBand flag runs tests serially in a single process, which is the safest default when your tests share a database. Once you have isolated state per test, you can drop it for parallelism. For pure JavaScript, remove the preset and @types packages -- Jest works out of the box.

Structuring Your Express App for Testability

The single most important pattern for supertest jest express testing is to separate your Express app from the code that listens on a port. Export the configured app object so tests can import it, and start the server only in a separate entry file. This lets SuperTest bind the app to an ephemeral port without your real server ever calling listen.

Create src/app.ts:

import express, { Request, Response } from 'express';

export const app = express();
app.use(express.json());

const users = [
  { id: 1, name: 'Ada Lovelace', email: 'ada@example.com' },
  { id: 2, name: 'Alan Turing', email: 'alan@example.com' },
];

app.get('/api/users', (_req: Request, res: Response) => {
  res.status(200).json(users);
});

app.get('/api/users/:id', (req: Request, res: Response) => {
  const user = users.find((u) => u.id === Number(req.params.id));
  if (!user) return res.status(404).json({ error: 'User not found' });
  return res.status(200).json(user);
});

app.post('/api/users', (req: Request, res: Response) => {
  const { name, email } = req.body;
  if (!name || !email) {
    return res.status(400).json({ error: 'name and email are required' });
  }
  const user = { id: users.length + 1, name, email };
  users.push(user);
  return res.status(201).json(user);
});

Then a thin src/server.ts that only runs in production:

import { app } from './app';

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(\`Server listening on \${PORT}\`));

Now your tests import app, never server. This separation is the foundation of every clean SuperTest suite.

Writing Your First SuperTest GET Request Test

With the app exported, your first test is just a few lines. Import request from supertest, pass it your app, and chain HTTP verbs and expectations. Use modern async/await -- it is far more readable than the older callback or .end() style.

Create src/users.test.ts:

import request from 'supertest';
import { app } from './app';

describe('GET /api/users', () => {
  it('returns 200 and a list of users', async () => {
    const res = await request(app).get('/api/users');

    expect(res.status).toBe(200);
    expect(res.headers['content-type']).toMatch(/json/);
    expect(Array.isArray(res.body)).toBe(true);
    expect(res.body).toHaveLength(2);
    expect(res.body[0]).toEqual(
      expect.objectContaining({ name: 'Ada Lovelace' }),
    );
  });

  it('returns a single user by id', async () => {
    const res = await request(app).get('/api/users/1');
    expect(res.status).toBe(200);
    expect(res.body.email).toBe('ada@example.com');
  });

  it('returns 404 for a missing user', async () => {
    const res = await request(app).get('/api/users/999');
    expect(res.status).toBe(404);
    expect(res.body).toEqual({ error: 'User not found' });
  });
});

Run it with npm test. SuperTest started your app on a random port, made three real HTTP requests, and asserted on the responses -- all in a few milliseconds, with zero manual server management.

SuperTest's Built-in expect vs Jest's expect

SuperTest ships its own .expect() chain, which is separate from Jest's expect(). You can assert on status and headers inline, then fall back to Jest for deeper body validation. Mixing both is idiomatic.

AssertionSuperTest .expect()Jest expect()
Status code.expect(200)expect(res.status).toBe(200)
Content-Type.expect('Content-Type', /json/)expect(res.headers[...]).toMatch()
Header value.expect('X-Total-Count', '2')expect(res.headers['x-total-count'])
Body shape.expect((res) => { ... })expect(res.body).toEqual(...)
Failure throwsYes, on .then/awaitYes, immediately

Here is the same test written with SuperTest's fluent chain:

it('validates via the chainable SuperTest API', async () => {
  await request(app)
    .get('/api/users')
    .expect('Content-Type', /json/)
    .expect(200)
    .expect((res) => {
      if (res.body.length !== 2) throw new Error('expected 2 users');
    });
});

A practical rule of thumb: use SuperTest's .expect() for status and headers because it reads naturally in the request chain, and switch to Jest's expect(res.body) for rich object assertions where matchers like toEqual and objectContaining shine.

Testing POST, PUT, and DELETE with Request Bodies

Mutating endpoints need a request body and the right headers. Use .send() to attach a JSON payload -- SuperTest sets Content-Type: application/json automatically when you pass an object. Always assert the resource was actually created or changed, not just the status code.

describe('POST /api/users', () => {
  it('creates a user and returns 201', async () => {
    const payload = { name: 'Grace Hopper', email: 'grace@example.com' };

    const res = await request(app)
      .post('/api/users')
      .send(payload)
      .set('Accept', 'application/json');

    expect(res.status).toBe(201);
    expect(res.body).toMatchObject(payload);
    expect(res.body.id).toEqual(expect.any(Number));
  });

  it('rejects an incomplete payload with 400', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: 'No Email' });

    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/required/);
  });
});

For PUT and DELETE the pattern is identical -- swap the verb. Use .query({ page: 2 }) to add query-string parameters and .field() plus .attach() for multipart form uploads. The toMatchObject matcher is ideal here because it asserts the payload was echoed back without requiring you to specify the server-generated id.

Validating JSON Responses and Headers in Depth

Real APIs return nested objects, arrays, pagination metadata, and dynamic fields like timestamps and IDs. Jest's asymmetric matchers let you assert on structure while tolerating volatile values, which keeps tests stable.

it('returns a well-formed, paginated response', async () => {
  const res = await request(app).get('/api/orders?page=1');

  expect(res.status).toBe(200);
  expect(res.body).toEqual(
    expect.objectContaining({
      page: 1,
      total: expect.any(Number),
      data: expect.arrayContaining([
        expect.objectContaining({
          id: expect.any(Number),
          createdAt: expect.any(String),
          status: expect.stringMatching(/pending|shipped|delivered/),
        }),
      ]),
    }),
  );

  // assert on headers too
  expect(res.headers['cache-control']).toBeDefined();
  expect(Number(res.headers['x-total-count'])).toBeGreaterThan(0);
});

The matchers expect.any(), arrayContaining, objectContaining, and stringMatching let you validate the shape of a payload without hardcoding values that change between runs. This is the JavaScript equivalent of fuzzy matching and is essential for assertions against live or seeded data.

Authentication, Cookies, and Session Testing

Most real endpoints sit behind auth. SuperTest handles bearer tokens, basic auth, and cookies cleanly. For token auth, chain .set('Authorization', ...). For session-cookie flows, use a SuperTest agent, which persists cookies across requests just like a browser.

import request from 'supertest';
import { app } from './app';

describe('Authenticated routes', () => {
  it('rejects requests without a token', async () => {
    const res = await request(app).get('/api/profile');
    expect(res.status).toBe(401);
  });

  it('accepts a valid bearer token', async () => {
    const res = await request(app)
      .get('/api/profile')
      .set('Authorization', 'Bearer test-token-123');
    expect(res.status).toBe(200);
  });

  it('persists session cookies with an agent', async () => {
    const agent = request.agent(app);

    // login sets a session cookie
    await agent
      .post('/api/login')
      .send({ username: 'ada', password: 'secret' })
      .expect(200);

    // the agent reuses the cookie automatically
    const res = await agent.get('/api/profile');
    expect(res.status).toBe(200);
    expect(res.body.username).toBe('ada');
  });
});

The request.agent(app) pattern is the canonical way to test multi-step authenticated flows -- login, then access a protected resource -- because the agent stores the Set-Cookie from the first response and sends it on every subsequent request. This mirrors how a browser maintains a session.

Mocking Databases and External Services

Integration tests should exercise your routing, validation, and serialization logic without hitting a real production database or third-party API. Two complementary techniques cover almost every case: an in-memory or test database for your own data, and Jest mocks for external HTTP calls.

import request from 'supertest';
import { app } from './app';
import * as paymentClient from './payment-client';

// mock an outbound HTTP dependency
jest.mock('./payment-client');

describe('POST /api/checkout', () => {
  beforeEach(() => jest.clearAllMocks());

  it('charges the card and returns 201', async () => {
    (paymentClient.charge as jest.Mock).mockResolvedValue({
      id: 'ch_123',
      status: 'succeeded',
    });

    const res = await request(app)
      .post('/api/checkout')
      .send({ amount: 4999, token: 'tok_visa' });

    expect(res.status).toBe(201);
    expect(paymentClient.charge).toHaveBeenCalledWith(4999, 'tok_visa');
    expect(res.body.status).toBe('succeeded');
  });

  it('returns 502 when the gateway fails', async () => {
    (paymentClient.charge as jest.Mock).mockRejectedValue(
      new Error('gateway timeout'),
    );

    const res = await request(app)
      .post('/api/checkout')
      .send({ amount: 4999, token: 'tok_visa' });

    expect(res.status).toBe(502);
  });
});

For your own database, prefer a dedicated test database (e.g. an in-memory SQLite or a disposable Postgres schema) seeded in beforeEach and truncated in afterEach. This keeps tests deterministic. Mock only what you do not own -- payment gateways, email providers, third-party APIs -- and let SuperTest exercise the rest of your stack for real.

Lifecycle Hooks, Coverage, and CI Integration

Robust suites manage shared resources with Jest lifecycle hooks and gate merges on coverage. Open database connections in beforeAll, seed in beforeEach, clean in afterEach, and close connections in afterAll so Jest exits cleanly instead of hanging on open handles.

import { db } from './db';

beforeAll(async () => {
  await db.connect();
  await db.migrate();
});

beforeEach(async () => {
  await db.seed();
});

afterEach(async () => {
  await db.truncateAll();
});

afterAll(async () => {
  await db.disconnect();
});

Add a coverage threshold to package.json so the suite fails when coverage drops:

{
  "jest": {
    "collectCoverageFrom": ["src/**/*.ts", "!src/server.ts"],
    "coverageThreshold": {
      "global": { "branches": 80, "lines": 85, "functions": 85 }
    }
  }
}

Finally, run everything in GitHub Actions:

name: API Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm
      - run: npm ci
      - run: npm run test:coverage
      - name: Upload coverage
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

If you forget to disconnect in afterAll, Jest warns that it could not exit and may hang in CI -- closing handles is the most common SuperTest CI gotcha.

Testing Error Handling and Validation Middleware

Production APIs spend as much code on rejecting bad input as on serving good requests, so your tests must cover the unhappy paths thoroughly. SuperTest makes negative testing as easy as the happy path -- you simply assert on 4xx and 5xx status codes and the shape of the error body. A common mistake is to test only the success case; a route that returns 500 on malformed JSON is just as broken as one that returns the wrong data.

Suppose your Express app uses a validation middleware that returns structured errors. Test that each rule fires with the correct message and status:

describe('Validation and error handling', () => {
  it('returns 400 with field-level errors for invalid input', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: '', email: 'not-an-email' });

    expect(res.status).toBe(400);
    expect(res.body).toEqual(
      expect.objectContaining({
        errors: expect.arrayContaining([
          expect.objectContaining({ field: 'name' }),
          expect.objectContaining({ field: 'email' }),
        ]),
      }),
    );
  });

  it('returns 415 for an unsupported content type', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Content-Type', 'text/plain')
      .send('name=Ada');

    expect([400, 415]).toContain(res.status);
  });

  it('maps unexpected errors to a 500 without leaking internals', async () => {
    const res = await request(app).get('/api/boom');
    expect(res.status).toBe(500);
    expect(res.body).not.toHaveProperty('stack');
    expect(res.body.error).toBeDefined();
  });
});

The last test is especially valuable: it verifies your global error handler never leaks a stack trace to clients, a real security concern. Assert that sensitive fields like stack are absent from error responses. Testing malformed JSON, missing headers, oversized payloads, and rate-limit responses (429) rounds out a defensive suite that catches regressions in middleware long before they reach production.

SuperTest Best Practices and Common Pitfalls

A handful of practices separate flaky SuperTest suites from rock-solid ones. First, always export your Express app separately from listen -- importing a file that calls listen will leave a real server open and cause port and teardown problems. Second, prefer async/await over chained .end(done) callbacks; await surfaces assertion errors cleanly and avoids forgotten done() calls that cause timeouts.

Third, isolate state between tests. Seed and truncate the database around every test so order does not matter and parallel runs are safe. Fourth, do not share a single SuperTest agent across unrelated tests unless you intend to share its cookie jar. Fifth, assert on response content, not just status -- a 200 with the wrong body is still a bug. Sixth, mock only external dependencies you do not own; over-mocking your own code turns an integration test into a unit test that proves nothing about your routing. For teams running broader API strategies across languages, compare this Node-first approach with the JVM workflow in the REST Assured Java API testing guide, and browse ready-to-use testing skills at /skills.

Frequently Asked Questions

What is SuperTest used for in Node.js?

SuperTest is used for HTTP integration and end-to-end testing of Node.js web applications. You pass your Express, Koa, Fastify, or NestJS app to request(app), and SuperTest binds it to an ephemeral port, sends real HTTP requests, and asserts on the status, headers, and JSON body. It is the standard way to verify API routes without manually starting or deploying a server.

How do you use SuperTest with Jest?

Install both with npm install --save-dev jest supertest, then import request from supertest and your exported Express app in a .test.js or .test.ts file. Inside a Jest it block, call await request(app).get('/route') and assert with Jest's expect() on res.status and res.body. Run the suite with the jest command via an npm test script.

Do I need to start my Express server to use SuperTest?

No. That is SuperTest's main advantage. When you pass your Express app to request(app), SuperTest automatically binds it to an unused ephemeral port for the duration of the request, then tears it down. You should export the app separately from the file that calls app.listen(), so tests use the app without ever starting your production server.

What is the difference between SuperTest and Axios for testing?

Axios is a general-purpose HTTP client that requires a running server at a known URL, so you must start and stop your server manually. SuperTest is purpose-built for testing: it starts your app in-process on an ephemeral port, offers a chainable .expect() assertion API, and integrates with test runners. SuperTest is faster and simpler for testing your own application's routes.

How do I test authenticated routes with SuperTest?

For token auth, chain .set('Authorization', 'Bearer ' + token) onto the request. For session-cookie flows, create a persistent client with request.agent(app), log in once so the agent stores the Set-Cookie header, then make follow-up requests through the same agent -- it automatically resends the cookie, exactly like a browser maintaining a session across requests.

Can SuperTest test TypeScript APIs?

Yes. Install @types/supertest, @types/jest, and ts-jest, then set the ts-jest preset in your Jest config. Write tests in .test.ts files importing your typed Express app. SuperTest has first-class TypeScript support, and the type definitions give you autocomplete on the request builder and full type safety on your test assertions.

Why does Jest hang after my SuperTest tests finish?

Jest hangs when there are open handles -- usually a database connection or server that was never closed. Close every resource in an afterAll hook: disconnect the database, stop any timers, and avoid calling app.listen() in code your tests import. Running Jest with --detectOpenHandles pinpoints the leaking resource so you can release it properly.

Conclusion

SuperTest is the most direct, fastest-feedback way to test a Node.js HTTP API. By exporting your Express app separately from listen, pairing supertest jest express with modern async/await, and leaning on Jest's asymmetric matchers for flexible JSON validation, you get integration coverage that runs in milliseconds and stays stable as your codebase grows. Add session-aware authentication tests with request.agent, mock only the external services you do not own, manage resources with lifecycle hooks, and gate merges on coverage in CI -- and you have a professional-grade suite.

The best next step is to take one existing route, export your app, and write a single request(app).get(...) test. The moment you see it pass without starting a server, the supertest node workflow clicks. From there, expand to POST validation, auth flows, and mocked dependencies until your critical paths are covered.

Ready to equip your team and AI coding agents with battle-tested API testing skills? Explore curated, ready-to-install QA skills at /skills and start shipping reliable Node.js APIs today.

SuperTest Node API Testing Tutorial — Jest & Express Guide | QASkills.sh