The Testing Pyramid in Node.js
A resilient testing strategy strikes a deliberate balance across three layers:
/ E2E Tests \ ▲ Slow, Expensive, High Confidence
/ Integration \ │
/ API Tests \ │
/───────────────────\ │
/ Unit Tests \ ▼ Fast, Cheap, Isolated
- Unit Tests: Verify individual, pure functions or isolated business logic classes in complete isolation. All side effects (I/O, network, database) are mocked or stubbed.
- Integration & API Tests: Validate that multiple components (routers, middleware, controllers, database models, caching layers) work together correctly using real or ephemeral test databases.
- End-to-End (E2E) Tests: Exercise the full deployed system from an external client’s perspective.
Unit & Integration Testing: Jest vs. Vitest
Both Jest and Vitest share nearly identical matchers and assertion APIs (describe, it, expect, beforeEach).
- Jest: The mature industry standard. However, it requires extra toolchains (
babel-jestorts-jest) to execute modern TypeScript or native ES Modules. - Vitest: Built on Vite. It natively understands ES Modules, TypeScript, and JSX with zero extra configuration, boots with multi-threaded worker pools, and shares the same configuration pipelines.
Setting Up Vitest for Node.js
Bash
npm install -D vitest
JavaScript
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true, // Exposes describe, it, expect globally
environment: 'node', // Uses Node runtime environment (not browser/jsdom)
coverage: {
provider: 'v8', // Native V8 code coverage
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/', 'tests/']
},
testTimeout: 10000 // 10s timeout for async operations
}
});
Add test scripts to package.json:
JSON
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
1. Unit Testing: Business Logic Isolation
Unit tests should test edge cases, parameter validations, and deterministic calculations without any network or database activity.
JavaScript
// src/utils/pricing.js
export function calculateOrderDiscount(subtotal, userTier, couponRate = 0) {
if (subtotal < 0) {
throw new Error('Subtotal cannot be negative');
}
let tierDiscount = 0;
if (userTier === 'GOLD') tierDiscount = 0.20;
else if (userTier === 'SILVER') tierDiscount = 0.10;
const totalDiscountRate = Math.min(tierDiscount + couponRate, 0.50); // Cap discount at 50%
const finalAmount = subtotal * (1 - totalDiscountRate);
return Math.round(finalAmount * 100) / 100;
}
JavaScript
// tests/unit/pricing.test.js
import { describe, it, expect } from 'vitest';
import { calculateOrderDiscount } from '../../src/utils/pricing.js';
describe('Pricing Utility: calculateOrderDiscount', () => {
it('should return the original amount if no tier or coupon is applied', () => {
const result = calculateOrderDiscount(100, 'STANDARD');
expect(result).toBe(100);
});
it('should apply the correct discount for GOLD tier users', () => {
const result = calculateOrderDiscount(200, 'GOLD');
expect(result).toBe(160); // 20% off of 200
});
it('should combine tier and coupon discounts accurately', () => {
const result = calculateOrderDiscount(100, 'SILVER', 0.15);
expect(result).toBe(75); // 10% + 15% = 25% off
});
it('should cap cumulative discounts at exactly 50%', () => {
const result = calculateOrderDiscount(100, 'GOLD', 0.40); // 20% + 40% = 60% -> capped at 50%
expect(result).toBe(50);
});
it('should throw an explicit error for negative amounts', () => {
expect(() => calculateOrderDiscount(-10, 'GOLD')).toThrow('Subtotal cannot be negative');
});
});
2. Integration Testing: Controller & Database Layer
Integration tests execute operations across the database without mocking the query engine. A common best practice is spinning up an ephemeral test database (such as an in-memory database or a dedicated Docker test instance) and wiping state in lifecycle hooks.
JavaScript
// tests/integration/userRepository.test.js
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { prisma } from '../../src/config/db.js';
import { userRepository } from '../../src/repositories/user.repository.js';
describe('Integration: User Repository with Database', () => {
beforeAll(async () => {
await prisma.$connect();
});
afterAll(async () => {
await prisma.$disconnect();
});
beforeEach(async () => {
// Truncate tables between tests to guarantee test isolation
await prisma.order.deleteMany();
await prisma.user.deleteMany();
});
it('should persist a new user and retrieve with relationships', async () => {
const createdUser = await userRepository.create({
email: 'test@domain.com',
name: 'Integration Tester'
});
expect(createdUser).toHaveProperty('id');
expect(createdUser.email).toBe('test@domain.com');
// Verify record directly via DB client
const fetched = await prisma.user.findUnique({
where: { id: createdUser.id }
});
expect(fetched).not.toBeNull();
expect(fetched?.name).toBe('Integration Tester');
});
it('should enforce unique constraint errors at database boundary', async () => {
await userRepository.create({ email: 'duplicate@domain.com', name: 'User 1' });
await expect(
userRepository.create({ email: 'duplicate@domain.com', name: 'User 2' })
).rejects.toThrow();
});
});
API Testing: Testing Endpoints with Supertest
Supertest binds to your Express app object and exercises real HTTP operations over an ephemeral local network port without needing you to manually boot the server on a live host port using app.listen().
Bash
npm install -D supertest
Structuring Express for Testability
Never invoke app.listen() directly inside your route definition file (app.js). Export the un-listened app instance, and create a separate entry point (server.js) that boots the listener:
src/
├── app.js ──▶ Configures middleware & routes (Exported for Supertest)
└── server.js ──▶ Imports app and calls app.listen(PORT) (Bootstraps production)
Writing End-to-End API Specs
JavaScript
// tests/api/auth.api.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app.js';
import { prisma } from '../../src/config/db.js';
describe('POST /api/v1/auth/register', () => {
beforeEach(async () => {
await prisma.user.deleteMany();
});
it('should return 201 Created and return a sanitized user entity', async () => {
const payload = {
email: 'alex@example.com',
password: 'StrongPassword123!',
name: 'Alex Rivera'
};
const res = await request(app)
.post('/api/v1/auth/register')
.send(payload)
.set('Accept', 'application/json');
expect(res.status).toBe(201);
expect(res.headers['content-type']).toMatch(/json/);
expect(res.body.status).toBe('success');
expect(res.body.data.user).toMatchObject({
email: 'alex@example.com',
name: 'Alex Rivera'
});
// Critical security check: Sensitive credentials must never be in responses
expect(res.body.data.user).not.toHaveProperty('password');
expect(res.body.data.user).not.toHaveProperty('passwordHash');
});
it('should return 422 Unprocessable Entity when validation fails', async () => {
const invalidPayload = {
email: 'not-an-email',
password: 'short'
};
const res = await request(app)
.post('/api/v1/auth/register')
.send(invalidPayload);
expect(res.status).toBe(422);
expect(res.body.status).toBe('fail');
expect(res.body.errors).toBeInstanceOf(Array);
expect(res.body.errors[0]).toHaveProperty('field');
});
it('should return 401 Unauthorized when accessing protected route without Bearer token', async () => {
const res = await request(app)
.get('/api/v1/users/profile');
expect(res.status).toBe(401);
expect(res.body.message).toContain('Authentication required');
});
it('should set an httpOnly Secure cookie when logging in', async () => {
// Seed user
await request(app).post('/api/v1/auth/register').send({
email: 'login@test.com',
password: 'Password123!',
name: 'Tester'
});
const res = await request(app)
.post('/api/v1/auth/login')
.send({ email: 'login@test.com', password: 'Password123!' });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('accessToken');
// Inspect Set-Cookie header
const cookies = res.headers['set-cookie'];
expect(cookies).toBeDefined();
expect(cookies[0]).toMatch(/refreshToken=/);
expect(cookies[0]).toMatch(/HttpOnly/i);
});
});
Mocking & Spying
Mocking replaces dependencies that are slow, nondeterministic, costly, or that produce external side effects (credit card processing, third-party emails, AWS buckets).
- Spy (
vi.spyOn): Observes calls, arguments, and return values of a real method while optionally leaving original behavior intact. - Stub / Mock (
vi.fn): Replaces the target implementation with a fake function returning pre-configured data. - Module Mock (
vi.mock): Replaces an entire file or npm module with a mocked interface.
1. Mocking Database Queries
When writing unit tests for your service layer, mock the repository or ORM client to avoid requiring a real database connection.
JavaScript
// tests/unit/userService.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { userService } from '../../src/services/user.service.js';
import { userRepository } from '../../src/repositories/user.repository.js';
import { AppError } from '../../src/errors/AppError.js';
// Automatically mock all methods on userRepository
vi.mock('../../src/repositories/user.repository.js');
describe('User Service Unit Tests', () => {
beforeEach(() => {
vi.clearAllMocks(); // Clear call counts and return values between tests
});
it('should throw an AppError if the user already exists', async () => {
// Arrange: Configure findByEmail to return an existing user
userRepository.findByEmail.mockResolvedValue({
id: 'usr_1',
email: 'exists@domain.com'
});
// Act & Assert
await expect(
userService.registerUser({ email: 'exists@domain.com', password: 'password123' })
).rejects.toThrow(AppError);
// Verify create was never triggered
expect(userRepository.create).not.toHaveBeenCalled();
expect(userRepository.findByEmail).toHaveBeenCalledTimes(1);
expect(userRepository.findByEmail).toHaveBeenCalledWith('exists@domain.com');
});
it('should successfully hash the password and persist a new user', async () => {
// Arrange
userRepository.findByEmail.mockResolvedValue(null);
userRepository.create.mockImplementation(async (data) => ({
id: 'usr_new',
...data,
createdAt: new Date()
}));
// Act
const result = await userService.registerUser({
email: 'new@domain.com',
password: 'PlainPassword',
name: 'New User'
});
// Assert
expect(result).toHaveProperty('id', 'usr_new');
expect(result.email).toBe('new@domain.com');
// Ensure repository received a hashed string, not the plain password
expect(userRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
email: 'new@domain.com',
password: expect.not.stringMatching('PlainPassword')
})
);
});
});
2. Mocking Network Calls (MSW & Fetch/Axios Mocks)
Third-party APIs should never be called during test execution. Mock Service Worker (MSW) intercepts outgoing HTTP requests at the network transport level, making it library-agnostic (works with native fetch, axios, and got).
Bash
npm install -D msw
JavaScript
// tests/mocks/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
// Intercept outgoing POST requests to third-party payment gateway
http.post('https://api.stripe.com/v1/payment_intents', async ({ request }) => {
const auth = request.headers.get('Authorization');
if (!auth || !auth.includes('sk_test_valid')) {
return new HttpResponse(null, { status: 401 });
}
return HttpResponse.json({
id: 'pi_test_12345',
status: 'succeeded',
amount: 5000,
currency: 'usd'
});
}),
// Intercept Currency Conversion API
http.get('https://api.exchangerates.io/v1/latest', () => {
return HttpResponse.json({
rates: { EUR: 0.92, GBP: 0.78 }
});
})
];
JavaScript
// tests/setupTests.js (Registered in vitest.config.js setupFiles)
import { beforeAll, afterAll, afterEach } from 'vitest';
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers.js';
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); // Throw if code calls an unmocked external API
afterEach(() => server.resetHandlers()); // Reset runtime overrides
afterAll(() => server.close());
3. Spying & Fake Timers
Spying on Methods and Built-ins
Use vi.spyOn to assert that internal utility methods or logging agents were notified with the expected metadata without altering their execution:
JavaScript
import { logger } from '../../src/utils/logger.js';
import { metrics } from '../../src/utils/metrics.js';
it('should increment metrics and log warning when cache misses occur', async () => {
const logSpy = vi.spyOn(logger, 'warn');
const metricSpy = vi.spyOn(metrics, 'increment').mockImplementation(() => {});
await getCachedData('key_missing');
expect(metricSpy).toHaveBeenCalledWith('cache.miss', 1);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Cache miss encountered'));
// Restore original non-mocked implementation
metricSpy.mockRestore();
});
Controlling Time with Fake Timers
When testing rate-limiters, TTL expirations, or recurring background queues, never use real delays like setTimeout in test assertions. Fake timers fast-forward system time synchronously.
JavaScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SessionManager } from '../../src/services/session.manager.js';
describe('Session TTL Expiration', () => {
beforeEach(() => {
vi.useFakeTimers(); // Intercept system Date, setTimeout, setInterval
});
afterEach(() => {
vi.useRealTimers(); // Restore real system clock
});
it('should invalidate active sessions after 15 minutes of inactivity', () => {
const sessionManager = new SessionManager();
sessionManager.createSession('session_1');
expect(sessionManager.isValid('session_1')).toBe(true);
// Fast-forward virtual system clock by 14 minutes
vi.advanceTimersByTime(14 * 60 * 1000);
expect(sessionManager.isValid('session_1')).toBe(true);
// Advance 2 more minutes (16 minutes total -> TTL expired)
vi.advanceTimersByTime(2 * 60 * 1000);
expect(sessionManager.isValid('session_1')).toBe(false);
});
});
Test Suite Execution Summary Matrix
| Tool | Primary Purpose | Scope | Key Methods / Mechanics |
| Vitest / Jest | Test runner & assertion harness | Unit, Integration | describe(), it(), expect(), toBe(), toMatchObject() |
| Supertest | HTTP assertion agent | REST API Endpoints | request(app).get(), .send(), .set(), .expect(200) |
| MSW (Mock Service Worker) | Outgoing HTTP interception | External Services | http.get(), http.post(), HttpResponse.json() |
vi.fn() / vi.mock() | Dependency replacement | Stubs & Fakes | mockResolvedValue(), mockImplementation(), toHaveBeenCalledWith() |
vi.useFakeTimers() | Clock manipulation | Timeouts & Expirations | vi.advanceTimersByTime(), vi.setSystemTime() |
