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.
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.
Note that jest.config.ts requires ts-node on top of ts-jest — they do different jobs. ts-jest transforms your .test.ts files at test-run time; Jest's own config loader needs ts-node to parse jest.config.ts itself, before any tests run. Skip the ts-node install and Jest fails immediately trying to load its own config, before a single test executes.
Services contain the business logic — test them thoroughly. They take plain inputs and return plain outputs. No HTTP to set up.
// 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(); });
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 });
it('throws ConflictError when product is out of stock', async () => { mockUsersRepo.findById.mockResolvedValue(activeUser); mockProductsRepo.findById.mockResolvedValue({ ...product, stock: 1 });
it('throws NotFoundError when product does not exist', async () => { mockUsersRepo.findById.mockResolvedValue(activeUser); mockProductsRepo.findById.mockResolvedValue(null);
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.
Anything that reads Date.now() or new Date() — token expiry, rate-limit windows, "is this coupon still valid" checks — is nondeterministic in a test unless you control the clock. Jest's fake timers replace the global timer and date APIs so a test can move time forward on command instead of waiting in real time:
// src/services/__tests__/auth.service.test.ts import { generateAccessToken, verifyAccessToken } from '../auth.service.js';
This is exactly the JWT expiry logic from P-2's generateAccessToken/verifyAccessToken — short-lived access tokens, longer-lived refresh tokens. Without fake timers, testing "does a 15-minute token actually expire" means either a real setTimeout delay that makes the suite crawl, or skipping the test and hoping the expiry math is right. jest.useFakeTimers() plus jest.advanceTimersByTime() makes it deterministic and instant.
One gotcha: jsonwebtoken computes expiresIn from Date.now() at sign time, so fake timers must be installed before the token is signed, not just before it's verified — otherwise the token's embedded exp claim is based on the real clock and the test's time travel has no effect on it.
Integration tests send real HTTP requests to your Express app (in memory — no server port needed) and assert on the response.
// 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>;
For Supertest to work, your Express app must be importable without starting a server. Split app.ts from index.ts:
// 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';
Mocking is a stunt double — watching the stunt double convincingly fall off a building tells you nothing about whether the real actor can act. Mock two layers down and you're reviewing the stunt double's contract instead of the scene: the test passes, but it never actually exercised the code you meant to test.
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:
// 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:
beforeEach(() => { 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:
import * as emailService from '../services/email.service.js';
it('sends confirmation email after order creation', async () => { const spy = jest.spyOn(emailService, 'sendOrderConfirmationEmail') .mockResolvedValue(undefined);
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.
Production story: this is also where it can go wrong if it's the only strategy in use. An indexer team mocked the repository layer so thoroughly across their integration suite that a real Prisma migration silently renamed a column. The mocked repository kept returning whatever shape the test fixtures said it should — nothing in the suite ever ran a real query against the actual schema, so nothing could catch the mismatch. Every test stayed green for three weeks. The break only surfaced when a nightly reconciliation job ran against the live database and started throwing on the column that no longer existed. Option 1 is still the right default for speed, but a suite built entirely on it has zero coverage of the real schema — a handful of Option 2 tests per model closes exactly that gap.
Option 2: Test database with real Prisma
For testing repositories or critical data flows, use a separate test database:
// 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(), ]); }
// 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';
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 does support SQLite, but switching providers is not a drop-in env var swap: the provider field in schema.prisma (postgresql, sqlite, etc.) is fixed per schema file, not selected at runtime via DATABASE_URL alone. In practice, testing against SQLite means maintaining a second schema (e.g. prisma/schema.test.prisma with provider = "sqlite") or a schema-per-provider setup, running prisma generate against it before tests run, and watching for the type and feature differences Prisma has between providers (SQLite lacks native enums and arrays, for example, which can make a schema that's valid for Postgres invalid for SQLite without changes). Some teams decide the extra schema-management overhead isn't worth it for the tests it saves, and use Option 2 (a real Postgres test database) for anything beyond pure unit tests.
Tests that pass alone but fail — or hang — only when run alongside the rest of the suite are almost always caused by state leaking between test files. The most common culprit in a Node.js codebase is a module-level singleton:
// src/db/prisma.ts const prisma = new PrismaClient(); // one instance, imported everywhere export default prisma;
// src/cache/redis.ts const redis = new Redis(process.env.REDIS_URL); export default redis;
Every test file that imports these gets the same connection. If one file calls prisma.$disconnect() in afterAll while another file's tests are still running against that same shared instance in a different order, you get failures that depend on Jest's worker scheduling that particular run — flaky in the worst way, because they're not reproducible on demand.
Jest runs test files in parallel worker processes by default (maxWorkers, roughly one per CPU core). That parallelism is exactly what turns a shared-connection bug into a flaky one:
# Run everything in a single process, one file after another — slower, but # eliminates cross-worker races against a shared database/Redis connection jest --runInBand
# Or cap parallelism instead of forcing fully serial execution jest --maxWorkers=2
A common CI setup: run the bulk of the suite (mocked dependencies, no shared state) with full parallelism, and run the smaller set of database-backed integration tests with --runInBand as a separate step — speed where it's safe, determinism where it isn't.
Snapshot tests capture a value the first time a test runs — often a serialized object or response body — and fail if a later run produces something different:
it('matches the expected order shape', () => { expect(serializeOrder(orderFixture)).toMatchSnapshot(); });
They're cheap to write but easy to rubber-stamp: a failing snapshot usually just prompts a developer to run jest -u and commit the new snapshot without reading the diff, which defeats the point of having the test. Snapshots earn their keep for output that's large, stable, and rarely expected to change on purpose — a generated SQL query, a rendered email template — not for API response shapes that change often and deserve an explicit expect(res.body).toMatchObject(...) instead, where a reviewer has to actually read what changed.
Contract testing addresses a different gap: when your service depends on an external API you don't control, mocking that dependency only proves your code handles the response shape you assumed it would send. If the real API's response shape changes, your mocked tests stay green while production breaks. Tools like Pact let the consumer (your service) record the request/response shape it expects as a "contract," which the provider verifies its own test suite still satisfies before shipping a breaking change — catching the mismatch in the provider's CI, not in your production logs.
Coverage numbers are a proxy — what matters is whether your tests catch regressions. Some guidelines:
// 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.
Some things are expensive to test and rarely break:
External libraries — don't test that bcrypt.hash works. Trust the library.
Express internals — don't test that router.get registers routes correctly.
Simple getters/setters — user.name = 'Jatin' doesn't need a test.
Trivially thin code — a repository function that calls prisma.user.findUnique and returns the result doesn't need a unit test; the integration test covers it.
Test the code you wrote. Mock the code others wrote.
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.ts from index.ts — tests import the app without starting a listener.
jest.mock() replaces entire modules; jest.spyOn() intercepts specific functions while leaving others intact. Always jest.clearAllMocks() in beforeEach.
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 --noEmit before 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.
Knowledge Check
What is the recommended balance of test types for a Node.js API according to the testing pyramid?
Why should you separate your Express application (app.ts) from your server listener (index.ts)?
Which of the following is considered an anti-pattern (what NOT to test) when writing your test suite?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.