Testing Express APIs Like a Senior Engineer

Front
Back
Right
Left
Top
Bottom
WHY

Why Testing Matters Beyond "Best Practices"

"Untested code is not software — it's a time bomb." — common wisdom in every SRE war room I've been in.

Let me be straight with you. I’ve seen startups burn hours chasing production bugs that a 10-minute test could have caught. I’ve also seen teams over-test trivial code while critical logic goes unchecked. After years of working on Express APIs at scale, here is the testing strategy that actually works — for both the developer building features and the business depending on uptime.

Testing isn’t just a box to tick in your PR checklist. According to the State of JS 2024 survey, testing has become a first-class topic in the JavaScript ecosystem, with dedicated tracking for framework usage and developer satisfaction.

For business owners and decision-makers: a reliable test suite directly reduces the cost of bugs in production. IBM’s Systems Sciences Institute famously reported that fixing a bug in production costs 6× more than fixing it at the development stage. Tests are your cheapest insurance policy.

UNIT TEST
Test the Logic, Not the Framework

Unit Testing

What to unit test

Unit tests validate a single, isolated piece of logic — typically a middleware function or route handler — without touching the network or database.

Copy to clipboard
// middleware/validateUser.js
export function validateUser(req, res, next) {
  if (!req.body.email) {
    return res.status(400).json({ error: 'Email is required' });
  }
  next();
}
Copy to clipboard
// middleware/validateUser.test.js (Vitest)
import { describe, it, expect, vi } from 'vitest';
import { validateUser } from './validateUser.js';

describe('validateUser middleware', () => {
  it('returns 400 if email is missing', () => {
    const req = { body: {} };
    const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
    const next = vi.fn();

    validateUser(req, res, next);

    expect(res.status).toHaveBeenCalledWith(400);
    expect(next).not.toHaveBeenCalled();
  });

  it('calls next() when email is present', () => {
    const req = { body: { email: '[email protected]' } };
    const res = {};
    const next = vi.fn();

    validateUser(req, res, next);

    expect(next).toHaveBeenCalled();
  });
});

Why Vitest in 2027?

Vitest is a testing framework that excels in its speed, and it shares configuration with Vite — which most modern Node projects already use. To set it up with Express 5, install Vitest alongside Supertest: `npm install -D vitest vite-tsconfig-paths supertest @types/supertest @faker-js/faker`, then create a `vitest.config.ts` to configure the node environment.

HTTP LAYER
Test the Real HTTP Layer

Integration Testing with Supertest

Unit tests are great. But they don’t tell you if your routes, middleware, and business logic *work together*. That’s where Supertest comes in.

Supertest is lightweight and code-driven — no GUI or separate app required, just JavaScript. It works seamlessly with Express or any Node HTTP server, and is CI/CD friendly, easily running in automated pipelines with standard exit codes.

The golden rule: export your app without `.listen()`

If you try to listen in the same file you test, each test file starts a server on one port and you’ll get a `port in use` error. The fix: export `app` without calling `listen()`, and let Supertest manage the connection lifecycle.

Copy to clipboard
// app.js — export without listen
import express from 'express';
import userRoutes from './routes/users.js';

export const app = express();
app.use(express.json());
app.use('/api/users', userRoutes);

// server.js — listen separately
import { app } from './app.js';
app.listen(3000, () => console.log('Running on port 3000'));
Copy to clipboard
// users.test.js
import request from 'supertest';
import { describe, it, expect } from 'vitest';
import { app } from '../app.js';

describe('POST /api/users', () => {
  it('creates a user and returns 201', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: 'Dineth', email: '[email protected]' })
      .expect(201);

    expect(res.body).toHaveProperty('id');
    expect(res.body.email).toBe('[email protected]');
  });
});

Supertest is designed to test HTTP servers without opening a real network port, making endpoint tests faster and more deterministic in both local and CI environments.

MOCKING

Mocking Databases and External Services

In-memory databases for MongoDB

`mongodb-memory-server` runs an in-memory MongoDB instance for testing Mongoose models without needing a real MongoDB connection. Combined with Supertest, it makes HTTP-level tests very simple to configure.

Copy to clipboard
// tests/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { MongoMemoryServer } from 'mongodb-memory-server';
import mongoose from 'mongoose';

let mongod: MongoMemoryServer;

beforeAll(async () => {
  mongod = await MongoMemoryServer.create();
  await mongoose.connect(mongod.getUri());
});

afterEach(async () => {
  // Clean between tests
  const collections = mongoose.connection.collections;
  for (const key in collections) {
    await collections[key].deleteMany({});
  }
});

afterAll(async () => {
  await mongoose.disconnect();
  await mongod.stop();
});
Mocking external APIs with `vi.mock()`
Copy to clipboard
import { vi } from 'vitest';
import * as paymentService from '../services/payment.js';

vi.mock('../services/payment.js', () => ({
  chargeCard: vi.fn().mockResolvedValue({ success: true, chargeId: 'ch_mock123' }),
}));
This isolates your logic from third-party failures — a pattern every senior engineer uses to prevent flaky tests.
STRATEGY
What to Test, What to Skip

Test Coverage Strategy

Not everything needs a test. Here’s my personal guideline:

Test aggressively
Skip or deprioritize

The biggest gains come from balancing fast unit tests with focused integration and contract tests. Flaky tests usually indicate architecture or environment issues, not just “test instability.”

Aim for 70–80% coverage on business logic. 100% coverage sounds good on paper but often leads to testing implementation details rather than behavior — a trap I’ve seen junior devs fall into constantly.

CI PIPELINE
GitHub Actions

CI Pipeline

Every test suite needs to run automatically. Here’s a minimal but production-grade GitHub Actions workflow:

Copy to clipboard
# .github/workflows/test.yml
name: Run Tests

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests with coverage
        run: npm run test:coverage

      - name: Upload coverage report
        uses: codecov/codecov-action@v4
Add `”test:coverage”: “vitest run –coverage”` to your `package.json` scripts. Now every pull request is automatically verified — no more “it worked on my machine.”
Layer Tool Purpose
Unit Vitest + vi.mock() Isolate and test individual functions
Integration Supertest + Vitest Test full HTTP request/response cycle
DB Mocking mongodb-memory-server In-memory MongoDB for fast, isolated tests
External Services vi.mock() / MSW Prevent real API calls in tests
CI GitHub Actions Auto-run tests on every push/PR

Explore project snapshots or discuss custom web solutions.

Fast tests are a productivity feature; reliable tests are a business feature.

Testing Node.js APIs, Jest, Supertest, and Best Practices - 2025

Thank You for Spending Your Valuable Time

I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Front
Back
Right
Left
Top
Bottom
FAQ's

Frequently Asked Questions

Both work. Jest + Supertest remains a practical default stack for HTTP API testing across Express, Fastify, and Nest-based services. However, Vitest is faster for ESM projects and has better Vite integration. For a new project in 2027, I'd default to Vitest.

For MongoDB, `mongodb-memory-server` is the cleanest solution — no setup, no teardown. For PostgreSQL, consider `pg-mem` or a dedicated Docker container via `testcontainers-node`.

Generate a real JWT token in your `beforeAll` setup and attach it as a Bearer header: `request(app).get('/api/profile').set('Authorization', \`Bearer ${token}\`)`.

Unit tests isolate a single function with all dependencies mocked. Integration tests test multiple layers working together — usually hitting a real (or in-memory) database through the full HTTP stack.

Set `isolate: true` in your Vitest config and use `afterEach` to clear the database after every test. Avoid global mutable state shared across test files.

Rarely. It becomes a vanity metric that incentivizes testing trivial code. Focus coverage on your most critical paths — payment flows, auth logic, data transformations.

Comments are closed