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 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.
// middleware/validateUser.js
export function validateUser(req, res, next) {
if (!req.body.email) {
return res.status(400).json({ error: 'Email is required' });
}
next();
}
// 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.
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.
// 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'));
// 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 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.
// 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()`
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' }),
}));
Test Coverage Strategy
Not everything needs a test. Here’s my personal guideline:
Test aggressively
- All middleware (auth, validation, error handling)
- Route handlers with business logic
- Edge cases (missing fields, invalid IDs, unauthorized access)
- Database interactions via integration tests
Skip or deprioritize
- Zod — runtime schema validation with TypeScript inference
- Third-party library internals
- Simple pass-through getters with zero logic
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
Every test suite needs to run automatically. Here’s a minimal but production-grade GitHub Actions workflow:
# .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
| 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.
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!
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