Skip to content
← All posts
0911 min read

Testing Node.js + TypeScript APIs the Right Way (Vitest + Supertest)

  • typescript
  • backend-development
  • software-testing
Testing Node.js + TypeScript APIs the Right Way (Vitest + Supertest) — by Chirag Mehta
Testing Node.js + TypeScript APIs the Right Way (Vitest + Supertest)

Your API works on your machine. You hit it with Postman, the response looks right, you push the branch, and you move on. But “I tried it and it looked fine” isn’t the same thing as “I know this works” — and the gap between those two shows up at the worst possible time, usually three weeks later when someone refactors your auth middleware and nobody notices the /orders endpoint started leaking other users' data.

I’ve shipped Node APIs without proper tests and I’ve shipped them with a real test suite, and the difference isn’t philosophical — it’s how fast I sleep at night after a deploy. This is the setup I actually use: Vitest for speed and TypeScript ergonomics, Supertest for hitting real route handlers, and a mocking strategy for your database layer that doesn’t turn your tests into a maintenance burden. By the end you’ll have a CI pipeline that blocks merges when coverage drops or tests fail — not a “we should really add tests someday” README badge.

Unit vs Integration Tests for an Express/TypeScript API

Before writing a single test, decide what you’re actually testing, because conflating these two is where most test suites go to die.

Unit tests verify a single function or module in isolation — no HTTP, no database, no network. Think: a JWT helper that signs and verifies tokens, a Zod schema that validates a payload, a pure function that calculates order totals.

Integration tests verify that your route handler, middleware stack, and (mocked or real) database layer work together the way a real request would exercise them. This is where Supertest comes in — it lets you send an actual HTTP request into your Express app without binding to a port.

Here’s how I split a typical project:

src/
routes/
users.routes.ts
users.controller.ts
lib/
jwt.ts
hash.ts
db/
prisma.ts
tests/
unit/
jwt.test.ts
hash.test.ts
integration/
users.routes.test.ts
auth.routes.test.ts

The rule of thumb: if a test needs to spin up your Express app and simulate a request/response cycle, it’s integration. If it just calls a function and checks the return value, it’s unit. You want a lot of the former for cheap logic (validation, pure utils) and a focused set of the latter for your actual API surface, because that’s what your consumers — frontend, mobile, other services — actually interact with. Route handlers are the contract; test the contract.

Setting Up Vitest in a TypeScript Project (vs Jest — Why/When)

I used Jest for years. I switched to Vitest on every new Node + TypeScript project for three concrete reasons: it uses esbuild/vite under the hood so it's dramatically faster (especially with ts-jest's notoriously slow type-checking transform), it has native ESM support without the --experimental-vm-modules dance, and its API is a near drop-in for Jest's (describe, it, expect, vi.fn() instead of jest.fn()), so migration is trivial.

When do I still reach for Jest? If you’re in a large legacy codebase already deeply wired into jest.config.js with custom transforms, snapshot serializers, or CRA, ripping that out isn't worth it just for speed. But for a fresh Node/TypeScript API, Vitest is the better default in 2026.

Install it:

npm install -D vitest @vitest/coverage-v8 supertest @types/supertest

Here’s the config I use in every project:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true, // lets you use describe/it/expect without importing them
environment: 'node',
setupFiles: ['./tests/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.ts'],
exclude: [
'src/**/*.d.ts',
'src/server.ts', // entrypoint, not worth unit testing
'src/db/prisma.ts', // generated client wrapper
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
testTimeout: 10_000,
},
});

A minimal setup file to reset mocks between tests and load env vars:

// tests/setup.ts
import { config } from 'dotenv';
import { beforeEach, vi } from 'vitest';
config({ path: '.env.test' });
beforeEach(() => {
vi.clearAllMocks();
});

Add scripts to package.json:

{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}

vitest run executes once and exits — that's what you want in CI. vitest (no run) starts watch mode, which is what you want locally while writing tests.

Testing Route Handlers with Supertest

Supertest wraps your Express app instance and lets you fire real requests at it in-process — no listening on a port, no flaky network calls. The critical setup detail: your app.ts must export the configured Express app without calling app.listen(), and your server.ts entrypoint is the only place that calls listen.

// src/app.ts
import express from 'express';
import usersRouter from './routes/users.routes';
export const app = express();
app.use(express.json());
app.use('/api/users', usersRouter);
// centralized error handler
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
});
// src/server.ts
import { app } from './app';
const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => console.log(`Listening on ${PORT}`));

Now a real integration test against a route:

// tests/integration/users.routes.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';
describe('GET /api/users/:id', () => {
it('returns 200 and the user payload for a valid id', async () => {
const res = await request(app).get('/api/users/1');
    expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: 1,
email: expect.any(String),
});
});
  it('returns 404 for a non-existent user', async () => {
const res = await request(app).get('/api/users/999999');
    expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'User not found' });
});
  it('returns 400 when id is not numeric', async () => {
const res = await request(app).get('/api/users/not-a-number');
    expect(res.status).toBe(400);
});
});
describe('POST /api/users', () => {
it('creates a user with a valid payload', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'Test User' })
.set('Content-Type', 'application/json');
    expect(res.status).toBe(201);
expect(res.body.email).toBe('test@example.com');
});
  it('rejects an invalid email with 422', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'not-an-email', name: 'Test User' });
    expect(res.status).toBe(422);
expect(res.body.error).toMatch(/email/i);
});
});

Notice these tests describe behavior, not implementation — status codes, response shape, error messages. That’s deliberate. You can refactor the controller internals completely and these tests keep passing as long as the contract holds. That’s the entire point of integration testing a route.

Mocking Your Database Layer (Prisma/Mongoose) Cleanly

Mocking Your Database Layer (Prisma/Mongoose) Cleanly
Mocking Your Database Layer (Prisma/Mongoose) Cleanly

This is where most people get it wrong. Two bad patterns I see constantly: hitting a real database in every test (slow, flaky, needs cleanup logic), or manually stubbing prisma.user.findUnique = vi.fn() scattered across every test file with no type safety.

For Prisma, I use vitest-mock-extended, which gives you a fully typed deep mock of the Prisma Client — autocomplete included, and it breaks at compile time if your schema changes.

npm install -D vitest-mock-extended
// tests/mocks/prisma.mock.ts
import { PrismaClient } from '@prisma/client';
import { mockDeep, mockReset, DeepMockProxy } from 'vitest-mock-extended';
import { beforeEach, vi } from 'vitest';
import { prisma } from '../../src/db/prisma';
vi.mock('../../src/db/prisma', () => ({
prisma: mockDeep<PrismaClient>(),
}));
export const prismaMock = prisma as unknown as DeepMockProxy<PrismaClient>;
beforeEach(() => {
mockReset(prismaMock);
});

Now the controller under test never touches a real database — it just calls prisma.user.findUnique(...), and in tests, that call resolves whatever you tell it to.

// tests/integration/users.controller.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';
import { prismaMock } from '../mocks/prisma.mock';
describe('GET /api/users/:id with mocked Prisma', () => {
it('returns the user Prisma resolves', async () => {
prismaMock.user.findUnique.mockResolvedValue({
id: 1,
email: 'chirag@example.com',
name: 'Chirag Mehta',
createdAt: new Date('2026-01-01'),
});
    const res = await request(app).get('/api/users/1');
    expect(res.status).toBe(200);
expect(res.body.email).toBe('chirag@example.com');
expect(prismaMock.user.findUnique).toHaveBeenCalledWith({
where: { id: 1 },
});
});
  it('returns 404 when Prisma resolves null', async () => {
prismaMock.user.findUnique.mockResolvedValue(null);
    const res = await request(app).get('/api/users/1');
    expect(res.status).toBe(404);
});
  it('returns 500 when the database throws', async () => {
prismaMock.user.findUnique.mockRejectedValue(new Error('connection lost'));
    const res = await request(app).get('/api/users/1');
    expect(res.status).toBe(500);
});
});

If you’re on Mongoose instead, the pattern is similar but you mock the model directly since Mongoose doesn’t have a single injectable client:

// tests/mocks/user.model.mock.ts
import { vi } from 'vitest';
import { User } from '../../src/models/user.model';
vi.mock('../../src/models/user.model', () => ({
User: {
findById: vi.fn(),
create: vi.fn(),
findByIdAndUpdate: vi.fn(),
},
}));
export const UserModelMock = vi.mocked(User);
// usage in a test
UserModelMock.findById.mockResolvedValue({
_id: '64f1a2b3c4d5e6f7a8b9c0d1',
email: 'chirag@example.com',
});

Either way, the key discipline is: mock at the data-access boundary, not deep inside your business logic. Your controller/service still runs for real; only the actual network call to Postgres or MongoDB gets intercepted. That gives you fast, deterministic tests that still exercise your real code paths.

Testing JWT-Protected Routes

Auth middleware is the thing that breaks silently and expensively, so it deserves explicit tests — both the “valid token gets you in” case and every rejection path.

First, a small helper to generate test tokens so you’re not hand-rolling JWTs in every test file:

// tests/helpers/auth.helper.ts
import jwt from 'jsonwebtoken';
const TEST_JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret';
export function generateTestToken(
payload: { userId: number; role?: string } = { userId: 1, role: 'user' },
options: jwt.SignOptions = { expiresIn: '15m' },
): string {
return jwt.sign(payload, TEST_JWT_SECRET, options);
}
export function generateExpiredToken(payload: { userId: number } = { userId: 1 }): string {
return jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: '-1s' });
}
export function authHeader(token: string) {
return { Authorization: `Bearer ${token}` };
}

Your middleware, for reference:

// src/middleware/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export interface AuthRequest extends Request {
userId?: number;
}
export function requireAuth(req: AuthRequest, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or malformed token' });
}
  const token = header.slice(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: number };
req.userId = decoded.userId;
next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}

And the tests covering every branch:

// tests/integration/auth.routes.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';
import { generateTestToken, generateExpiredToken, authHeader } from '../helpers/auth.helper';
describe('GET /api/orders (protected route)', () => {
it('rejects a request with no Authorization header', async () => {
const res = await request(app).get('/api/orders');
expect(res.status).toBe(401);
});
  it('rejects a malformed Authorization header', async () => {
const res = await request(app)
.get('/api/orders')
.set('Authorization', 'NotBearer abc123');
    expect(res.status).toBe(401);
});
  it('rejects an expired token', async () => {
const token = generateExpiredToken();
const res = await request(app).get('/api/orders').set(authHeader(token));
    expect(res.status).toBe(401);
expect(res.body.error).toMatch(/invalid or expired/i);
});
  it('allows access with a valid token and scopes data to that user', async () => {
const token = generateTestToken({ userId: 42 });
const res = await request(app).get('/api/orders').set(authHeader(token));
    expect(res.status).toBe(200);
// this is the assertion that actually matters — did auth correctly
// scope the query, not just let the request through
expect(res.body.every((order: { userId: number }) => order.userId === 42)).toBe(true);
});
});

That last test is the one people skip and shouldn’t — asserting a 200 tells you the token was accepted, but not that the handler used req.userId correctly to scope the query. That's exactly the class of bug ("user A can see user B's orders") that ships silently without this test.

Test Coverage Thresholds and CI Integration

Coverage numbers are a floor, not a goal — chasing 100% gets you brittle tests around trivial getters. But a threshold that fails the build when it drops is genuinely useful: it catches the PR that adds a new route with zero tests.

The thresholds block in the vitest.config.ts above already does this — vitest run --coverage exits non-zero if any metric falls below its threshold. Run it locally to check:

npm run test:coverage
% Coverage report from v8
--------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files | 84.21 | 78.57 | 85.71 | 84.21 |
controllers | 91.30 | 83.33 | 90.00 | 91.30 |
middleware | 88.88 | 75.00 | 100.00 | 88.88 |
lib | 72.00 | 70.00 | 77.77 | 72.00 |
--------------------|---------|----------|---------|---------|
ERROR: Coverage for branches (78.57%) does not meet threshold (75%)

Wait — that example actually passes (78.57 > 75), which is the point: when a number is genuinely under threshold, the process exits with a non-zero code and --coverage will fail the CI step automatically. No extra scripting needed.

Keep thresholds realistic per project maturity. I start new projects at 70% and ratchet up as the codebase stabilizes — setting 90% on day one just trains people to write tests that pad the number instead of testing behavior.

Wiring Tests into a GitHub Actions Pipeline

Here’s the workflow I drop into every Node/TypeScript API repo. It runs on every push and PR, installs dependencies with caching, type-checks, runs the full test suite with coverage enforcement, and uploads the coverage report as an artifact so you can inspect it without re-running locally.

Wiring Tests into a GitHub Actions Pipeline
Wiring Tests into a GitHub Actions Pipeline
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
    services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
    env:
DATABASE_URL: postgresql://test:test@localhost:5432/test_db
JWT_SECRET: test-secret
    steps:
- name: Checkout code
uses: actions/checkout@v4
      - name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
      - name: Install dependencies
run: npm ci
      - name: Type check
run: npx tsc --noEmit
      - name: Run Prisma migrations
run: npx prisma migrate deploy
      - name: Run tests with coverage
run: npm run test:coverage
      - name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/

A few things worth calling out: the Postgres service block only matters if you have integration tests that hit a real test database rather than mocked Prisma calls (some teams do both — mocked unit-style integration tests plus a smaller set of true end-to-end tests against a real DB in CI). The tsc --noEmit step catches type errors that Vitest's transform layer (esbuild) won't — esbuild strips types without checking them, so this step is not optional if you're using Vitest with TypeScript. And if: always() on the artifact upload means you get the coverage report even when tests fail, which is exactly when you want to see it.

Set this workflow as a required status check on your main branch protection rules, and you've turned "we should really add tests" into "the merge button is physically disabled until you do."

Wrapping Up

You don’t need to retrofit your entire API with tests this week. Start with the routes that would hurt the most if they silently broke — auth, payments, anything touching another user’s data — and write integration tests for those first using the Supertest pattern above. Add the Prisma or Mongoose mock once, reuse it everywhere. Wire the GitHub Actions workflow in on day one, even with a low coverage threshold, because a build that can fail is infinitely more valuable than a build that can’t.

Next step I’d actually take: pick your riskiest untested endpoint right now, write three tests for it — happy path, one auth failure, one bad-input case — and get it into a PR today. That’s a better use of an afternoon than reading one more article about testing strategy.

Author card for Chirag Mehta, Full-Stack Developer specializing in React, Next.js and Node.js. Links: github.com/chiragmehta900, linkedin.com/in/chiragmehta900, medium.com/@chiragmehta900.
Chirag Mehta

Originally published on Medium

Clap, comment or follow along there

Read on Medium →