Unit testing with Jest, integration testing with Supertest, mocking modules, database testing strategies, meaningful coverage, and CI integration.
Module P-5 — Testing Node.js Applications
What this module covers: Untested code is a liability — you cannot refactor it safely, you cannot ship it confidently, and you cannot onboard a new engineer without fear. This module covers the testing pyramid for Node.js: unit tests with Jest that test one function in isolation, integration tests with Supertest that hit real Express routes, the correct way to mock dependencies so tests stay fast and deterministic, database testing strategies, and wiring tests into CI. By the end you will have a test suite that actually catches bugs and runs in seconds.
The Testing Pyramid
Three levels of tests, each with a different trade-off:
/\
/ \
/ E2E \ Few — slow, brittle, expensive
/--------\
/ \
/ Integration \ Some — test routes end-to-end in memory
/--------------\
/ \
/ Unit Tests \ Many — fast, isolated, test one thing
/--------------------\
- Unit tests: Test a single function — a service method, a validator, a utility. No HTTP, no database, no network. Run in milliseconds.
- Integration tests: Test an Express route from HTTP request to HTTP response. Real validation middleware, real service logic, but the database layer is mocked or uses a test database.
- End-to-end tests: Spin up the full stack against a real database. Slow and brittle. Write few, keep them for critical paths only.
For a Node.js API, the sweet spot is: many unit tests for service/business logic, integration tests for every route, minimal E2E tests.
Setup: Jest and Supertest
bashnpm install -D jest ts-jest @types/jest supertest @types/supertest
json// jest.config.ts (or jest.config.js) import type { Config } from 'jest'; const config: Config = { preset: 'ts-jest', testEnvironment: 'node', testMatch: ['**/__tests__/**/*.test.ts', '**/*.test.ts'], collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'], coverageThreshold: { global: { branches: 70, functions: 80, lines: 80, }, }, }; export default config;
json// package.json { "scripts": { "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:ci": "jest --ci --coverage" } }
Unit Testing: Services
Services contain the business logic — test them thoroughly. They take plain inputs and return plain outputs. No HTTP to set up.
typescript// src/services/__tests__/orders.service.test.ts import { createOrder } from '../orders.service.js'; import * as ordersRepo from '../../repositories/orders.repository.js'; import * as usersRepo from '../../repositories/users.repository.js'; import * as productsRepo from '../../repositories/products.repository.js'; import { NotFoundError, ConflictError, ForbiddenError } from '../../errors/AppError.js'; // Mock entire modules — imports in the service get the mocked versions jest.mock('../../repositories/orders.repository.js'); jest.mock('../../repositories/users.repository.js'); jest.mock('../../repositories/products.repository.js'); // Cast to jest mocks for TypeScript const mockUsersRepo = usersRepo as jest.Mocked<typeof usersRepo>; const mockProductsRepo = productsRepo as jest.Mocked<typeof productsRepo>; const mockOrdersRepo = ordersRepo as jest.Mocked<typeof ordersRepo>; describe('ordersService.createOrder', () => { // Reset all mocks before each test — prevents state leaking between tests beforeEach(() => { jest.clearAllMocks(); }); const activeUser = { id: 1, name: 'Jatin', status: 'active', role: 'user' }; const product = { id: 10, name: 'Widget', price: 29.99, stock: 100 }; const items = [{ productId: 10, quantity: 2 }]; it('creates an order for an active user', async () => { mockUsersRepo.findById.mockResolvedValue(activeUser); mockProductsRepo.findById.mockResolvedValue(product); mockOrdersRepo.create.mockResolvedValue({ id: 1, userId: 1, total: 59.98, items: [] }); const order = await createOrder({ userId: 1, items }); expect(order.total).toBe(59.98); expect(mockOrdersRepo.create).toHaveBeenCalledWith({ userId: 1, total: 59.98, items: [{ productId: 10, quantity: 2, price: 29.99 }], }); }); it('throws NotFoundError when user does not exist', async () => { mockUsersRepo.findById.mockResolvedValue(null); await expect(createOrder({ userId: 999, items })) .rejects.toThrow(NotFoundError); }); it('throws ForbiddenError when user is banned', async () => { mockUsersRepo.findById.mockResolvedValue({ ...activeUser, status: 'banned' }); await expect(createOrder({ userId: 1, items })) .rejects.toThrow(ForbiddenError); }); it('throws ConflictError when product is out of stock', async () => { mockUsersRepo.findById.mockResolvedValue(activeUser); mockProductsRepo.findById.mockResolvedValue({ ...product, stock: 1 }); await expect(createOrder({ userId: 1, items: [{ productId: 10, quantity: 5 }] })) .rejects.toThrow(ConflictError); }); it('throws NotFoundError when product does not exist', async () => { mockUsersRepo.findById.mockResolvedValue(activeUser); mockProductsRepo.findById.mockResolvedValue(null); await expect(createOrder({ userId: 1, items })) .rejects.toThrow(NotFoundError); }); });
Notice the pattern: set up mocks, call the function, assert on the output or thrown error. Each test describes one behaviour. The test names read like a specification.
Unit Testing: Validators
Schemas are pure functions — trivial to test:
typescript// src/validators/__tests__/users.schema.test.ts import { createUserSchema, updateUserSchema } from '../users.schema.js'; describe('createUserSchema', () => { const validInput = { name: 'Jatin Saraf', email: 'jatin@example.com', password: 'securepassword', }; it('parses valid input', () => { const result = createUserSchema.safeParse(validInput); expect(result.success).toBe(true); if (result.success) { expect(result.data.role).toBe('user'); // default applied } }); it('lowercases email', () => { const result = createUserSchema.safeParse({ ...validInput, email: 'JATIN@EXAMPLE.COM' }); expect(result.success && result.data.email).toBe('jatin@example.com'); }); it('rejects invalid email', () => { const result = createUserSchema.safeParse({ ...validInput, email: 'not-an-email' }); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues[0].path).toEqual(['email']); } }); it('rejects password shorter than 8 characters', () => { const result = createUserSchema.safeParse({ ...validInput, password: 'short' }); expect(result.success).toBe(false); }); }); describe('updateUserSchema', () => { it('accepts partial updates', () => { const result = updateUserSchema.safeParse({ name: 'New Name' }); expect(result.success).toBe(true); }); it('rejects empty update object', () => { const result = updateUserSchema.safeParse({}); expect(result.success).toBe(false); }); });
Integration Testing: Routes with Supertest
Integration tests send real HTTP requests to your Express app (in memory — no server port needed) and assert on the response.
typescript// src/__tests__/users.routes.test.ts import request from 'supertest'; import app from '../app.js'; // export your express app separately from listen() import * as usersService from '../services/users.service.js'; // Mock the service layer — the HTTP → validation → controller chain runs for real jest.mock('../services/users.service.js'); const mockUsersService = usersService as jest.Mocked<typeof usersService>; describe('POST /users', () => { beforeEach(() => jest.clearAllMocks()); const validBody = { name: 'Jatin Saraf', email: 'jatin@example.com', password: 'securepassword', }; it('returns 201 and the created user', async () => { const createdUser = { id: 1, name: 'Jatin Saraf', email: 'jatin@example.com', role: 'user' }; mockUsersService.create.mockResolvedValue(createdUser); const res = await request(app) .post('/users') .send(validBody) .expect(201); expect(res.body).toMatchObject({ id: 1, name: 'Jatin Saraf' }); expect(res.body.passwordHash).toBeUndefined(); // never exposed }); it('returns 400 for missing required fields', async () => { const res = await request(app) .post('/users') .send({ email: 'jatin@example.com' }) // missing name, password .expect(400); expect(res.body.error).toBe('Validation failed'); expect(res.body.issues).toBeInstanceOf(Array); }); it('returns 409 when email already exists', async () => { const { ConflictError } = await import('../errors/AppError.js'); mockUsersService.create.mockRejectedValue(new ConflictError('Email already registered')); const res = await request(app) .post('/users') .send(validBody) .expect(409); expect(res.body.error).toBe('Email already registered'); }); }); describe('GET /users/:id', () => { it('returns 200 and the user', async () => { const user = { id: 1, name: 'Jatin', email: 'j@example.com' }; mockUsersService.findById.mockResolvedValue(user); const res = await request(app) .get('/users/1') .set('Authorization', 'Bearer ' + generateTestToken(1, 'user')) .expect(200); expect(res.body).toMatchObject(user); }); it('returns 401 without auth token', async () => { await request(app).get('/users/1').expect(401); }); it('returns 404 when user not found', async () => { const { NotFoundError } = await import('../errors/AppError.js'); mockUsersService.findById.mockRejectedValue(new NotFoundError('User')); await request(app) .get('/users/1') .set('Authorization', 'Bearer ' + generateTestToken(1, 'user')) .expect(404); }); });
Separating the App from the Server
For Supertest to work, your Express app must be importable without starting a server. Split app.ts from index.ts:
typescript// src/app.ts — pure Express application, no .listen() import express from 'express'; import { errorHandler } from './middleware/errorHandler.js'; import { requestId } from './middleware/requestId.js'; import usersRouter from './routes/users.routes.js'; import postsRouter from './routes/posts.routes.js'; import ordersRouter from './routes/orders.routes.js'; const app = express(); app.use(express.json()); app.use(requestId); app.use('/users', usersRouter); app.use('/posts', postsRouter); app.use('/orders', ordersRouter); app.use(errorHandler); export default app;
typescript// src/index.ts — starts the server import app from './app.js'; const PORT = process.env.PORT ?? 3000; app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); });
Tests import app directly — no port, no network, no listen().
Test Helpers
Utilities that every test file needs:
typescript// src/__tests__/helpers/auth.ts import jwt from 'jsonwebtoken'; export function generateTestToken(userId: number, role: 'user' | 'admin' = 'user'): string { return jwt.sign( { sub: userId, role }, process.env.JWT_ACCESS_SECRET ?? 'test-secret', { expiresIn: '1h' } ); } export const adminToken = generateTestToken(1, 'admin'); export const userToken = generateTestToken(2, 'user');
typescript// src/__tests__/helpers/fixtures.ts export const userFixture = { id: 1, name: 'Test User', email: 'test@example.com', role: 'user' as const, createdAt: new Date('2024-01-01'), }; export const adminFixture = { ...userFixture, id: 2, role: 'admin' as const, }; export const postFixture = { id: 1, title: 'Test Post', content: 'Test content', published: false, authorId: 1, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), };
typescript// jest.setup.ts — runs before every test file process.env.JWT_ACCESS_SECRET = 'test-secret-at-least-32-chars-long'; process.env.JWT_REFRESH_SECRET = 'test-refresh-secret-also-32-chars'; process.env.NODE_ENV = 'test';
json// jest.config.ts { setupFiles: ['./jest.setup.ts'], }
Mocking Strategies
Mock at the right level. Mock the layer just below the layer under test:
When testing: controllers/routes → mock: services
When testing: services → mock: repositories
When testing: repositories → use: a test database (or skip)
Never mock things two layers away — that defeats the point of the test.
Module mocks with jest.mock:
typescript// Automock: replaces all exports with jest.fn() returning undefined jest.mock('../repositories/users.repository.js'); // Manual mock with specific implementations jest.mock('../services/email.service.js', () => ({ sendOrderConfirmationEmail: jest.fn().mockResolvedValue(undefined), sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined), }));
Resetting mock state:
typescriptbeforeEach(() => { jest.clearAllMocks(); // clear call counts and return values // jest.resetAllMocks(); // also reset implementations // jest.restoreAllMocks(); // restore original implementations (for jest.spyOn) });
Spying on a function without replacing it:
typescriptimport * as emailService from '../services/email.service.js'; it('sends confirmation email after order creation', async () => { const spy = jest.spyOn(emailService, 'sendOrderConfirmationEmail') .mockResolvedValue(undefined); await createOrder({ userId: 1, items: [...] }); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith( 'jatin@example.com', expect.objectContaining({ id: expect.any(Number) }), ); });
Database Testing Strategies
Three options, in order of preference:
Option 1: Mock repositories entirely (preferred for unit/integration tests)
Repositories are mocked in service tests. The service never touches a real database. Fast, hermetic, no setup.
Option 2: Test database with real Prisma
For testing repositories or critical data flows, use a separate test database:
bash# .env.test DATABASE_URL="postgresql://localhost:5432/myapp_test"
typescript// src/__tests__/setup/database.ts import { execSync } from 'child_process'; import prisma from '../../db/prisma.js'; // Run before the entire test suite export async function setupTestDatabase() { execSync('npx prisma migrate reset --force --skip-seed', { env: { ...process.env, DATABASE_URL: process.env.DATABASE_URL }, }); } // Clean specific tables between tests export async function clearDatabase() { await prisma.$transaction([ prisma.orderItem.deleteMany(), prisma.order.deleteMany(), prisma.post.deleteMany(), prisma.user.deleteMany(), ]); }
typescript// src/repositories/__tests__/users.repository.test.ts import prisma from '../../db/prisma.js'; import { clearDatabase } from '../setup/database.js'; import { create, findByEmail } from '../users.repository.js'; beforeEach(async () => await clearDatabase()); afterAll(async () => await prisma.$disconnect()); it('creates a user and finds by email', async () => { await create({ name: 'Jatin', email: 'j@test.com', passwordHash: 'hash' }); const found = await findByEmail('j@test.com'); expect(found?.name).toBe('Jatin'); });
Option 3: In-memory database (SQLite for fast repo tests)
Use an in-memory SQLite database for fast, zero-setup repository tests. Prisma supports this with the sqlite provider.
Test Coverage: What Actually Matters
Coverage numbers are a proxy — what matters is whether your tests catch regressions. Some guidelines:
typescript// jest.config.ts coverageThreshold: { global: { branches: 70, // most if/else paths functions: 80, // most functions called lines: 80, // most lines executed }, // You can also set thresholds per file/directory: './src/services/': { branches: 85, // business logic deserves higher coverage functions: 90, }, },
Focus coverage where bugs are expensive: services (business logic), validators (input contracts), error handling paths.
Don't chase 100%: boilerplate routes, configuration files, and type declaration files don't need tests. Use /* istanbul ignore next */ for legitimately untestable branches.
Running Tests in CI
yaml# .github/workflows/test.yml name: Test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_DB: myapp_test POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '22' cache: 'npm' - run: npm ci - name: Type check run: npx tsc --noEmit - name: Run tests run: npm run test:ci env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/myapp_test JWT_ACCESS_SECRET: ci-test-secret-at-least-32-characters JWT_REFRESH_SECRET: ci-refresh-secret-at-least-32-characters NODE_ENV: test
What Not to Test
Some things are expensive to test and rarely break:
- External libraries — don't test that
bcrypt.hashworks. Trust the library. - Express internals — don't test that
router.getregisters routes correctly. - Simple getters/setters —
user.name = 'Jatin'doesn't need a test. - Trivially thin code — a repository function that calls
prisma.user.findUniqueand returns the result doesn't need a unit test; the integration test covers it.
Test the code you wrote. Mock the code others wrote.
Summary
- Unit tests target services and validators — fast, no database, mock dependencies one layer down.
- Integration tests target Express routes via Supertest — real validation middleware runs, services are mocked.
- Separate
app.tsfromindex.ts— tests import the app without starting a listener. jest.mock()replaces entire modules;jest.spyOn()intercepts specific functions while leaving others intact. Alwaysjest.clearAllMocks()inbeforeEach.- Mock at the right level — service tests mock repositories, route tests mock services. Never skip a layer.
- Coverage thresholds enforce a floor on test quality in CI — set them for the service layer first, where bugs are most expensive.
- CI runs
tsc --noEmitbefore tests — catch type errors before runtime errors.
Next: configuration, environment management, and security hardening — environment variables, secrets management, rate limiting, CORS, and the security headers that prevent the most common API vulnerabilities.