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:
text
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
bash
json
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.
json
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
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
Testing Time-Dependent Code
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:
typescript
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 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
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
typescript
Tests import app directly — no port, no network, no listen().
Test Helpers
Utilities that every test file needs:
typescript
typescript
typescript
json
Mocking Strategies
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:
text
Never mock things two layers away — that defeats the point of the test.
Module mocks with jest.mock:
typescript
Resetting mock state:
typescript
Spying on a function without replacing it:
typescript
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.
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:
bash
typescript
typescript
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.
Test Flakiness from Shared State
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:
typescript
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:
bash
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 Testing and Contract Testing
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:
typescript
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.
Test Coverage: What Actually Matters
Coverage numbers are a proxy — what matters is whether your tests catch regressions. Some guidelines:
typescript
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
What Not to Test
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.
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.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.
/\
/ \
/ E2E \ Few — slow, brittle, expensive
/--------\
/ \
/ Integration \ Some — test routes end-to-end in memory
/--------------\
/ \
/ Unit Tests \ Many — fast, isolated, test one thing
/--------------------\
npminstall-D jest ts-jest @types/jest ts-node supertest @types/supertest
// src/services/__tests__/orders.service.test.tsimport{ 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 versionsjest.mock('../../repositories/orders.repository.js');jest.mock('../../repositories/users.repository.js');jest.mock('../../repositories/products.repository.js');// Cast to jest mocks for TypeScriptconst 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 testsbeforeEach(()=>{ 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 =awaitcreateOrder({ 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);awaitexpect(createOrder({ userId:999, items })).rejects.toThrow(NotFoundError);});it('throws ForbiddenError when user is banned',async()=>{ mockUsersRepo.findById.mockResolvedValue({...activeUser, status:'banned'});awaitexpect(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});awaitexpect(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);awaitexpect(createOrder({ userId:1, items })).rejects.toThrow(NotFoundError);});});
// src/validators/__tests__/users.schema.test.tsimport{ 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);});});
// src/services/__tests__/auth.service.test.tsimport{ generateAccessToken, verifyAccessToken }from'../auth.service.js';describe('access token expiry',()=>{beforeEach(()=>{ jest.useFakeTimers();});afterEach(()=>{ jest.useRealTimers();});it('rejects a token after it expires',()=>{const token =generateAccessToken({ userId:1});// signed with a short expiry, per P-2 jest.advanceTimersByTime(16*60*1000);// fast-forward 16 minutes — no real waitingexpect(()=>verifyAccessToken(token)).toThrow('TokenExpiredError');});it('accepts a token just before it expires',()=>{const token =generateAccessToken({ userId:1}); jest.advanceTimersByTime(14*60*1000);expect(()=>verifyAccessToken(token)).not.toThrow();});});
// src/__tests__/users.routes.test.tsimport 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 realjest.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 =awaitrequest(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 =awaitrequest(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 }=awaitimport('../errors/AppError.js'); mockUsersService.create.mockRejectedValue(newConflictError('Email already registered'));const res =awaitrequest(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 =awaitrequest(app).get('/users/1').set('Authorization','Bearer '+generateTestToken(1,'user')).expect(200);expect(res.body).toMatchObject(user);});it('returns 401 without auth token',async()=>{awaitrequest(app).get('/users/1').expect(401);});it('returns 404 when user not found',async()=>{const{ NotFoundError }=awaitimport('../errors/AppError.js'); mockUsersService.findById.mockRejectedValue(newNotFoundError('User'));awaitrequest(app).get('/users/1').set('Authorization','Bearer '+generateTestToken(1,'user')).expect(404);});});
// src/index.ts — starts the serverimport app from'./app.js';constPORT= process.env.PORT??3000;app.listen(PORT,()=>{console.log(`Server running on port ${PORT}`);});
// jest.setup.ts — runs before every test fileprocess.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';
When testing: controllers/routes → mock: services
When testing: services → mock: repositories
When testing: repositories → use: a test database (or skip)
// Automock: replaces all exports with jest.fn() returning undefinedjest.mock('../repositories/users.repository.js');// Manual mock with specific implementationsjest.mock('../services/email.service.js',()=>({ sendOrderConfirmationEmail: jest.fn().mockResolvedValue(undefined), sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined),}));
beforeEach(()=>{ jest.clearAllMocks();// clear call counts and return values// jest.resetAllMocks(); // also reset implementations// jest.restoreAllMocks(); // restore original implementations (for jest.spyOn)});
import*as emailService from'../services/email.service.js';it('sends confirmation email after order creation',async()=>{const spy = jest.spyOn(emailService,'sendOrderConfirmationEmail').mockResolvedValue(undefined);awaitcreateOrder({ userId:1, items:[...]});expect(spy).toHaveBeenCalledTimes(1);expect(spy).toHaveBeenCalledWith('jatin@example.com', expect.objectContaining({ id: expect.any(Number)}),);});
// src/__tests__/setup/database.tsimport{ execSync }from'child_process';import prisma from'../../db/prisma.js';// Run before the entire test suiteexportasyncfunctionsetupTestDatabase(){execSync('npx prisma migrate reset --force --skip-seed',{ env:{...process.env,DATABASE_URL: process.env.DATABASE_URL},});}// Clean specific tables between testsexportasyncfunctionclearDatabase(){await prisma.$transaction([ prisma.orderItem.deleteMany(), prisma.order.deleteMany(), prisma.post.deleteMany(), prisma.user.deleteMany(),]);}
// src/repositories/__tests__/users.repository.test.tsimport prisma from'../../db/prisma.js';import{ clearDatabase }from'../setup/database.js';import{ create, findByEmail }from'../users.repository.js';beforeEach(async()=>awaitclearDatabase());afterAll(async()=>await prisma.$disconnect());it('creates a user and finds by email',async()=>{awaitcreate({ name:'Jatin', email:'j@test.com', passwordHash:'hash'});const found =awaitfindByEmail('j@test.com');expect(found?.name).toBe('Jatin');});
# Run everything in a single process, one file after another — slower, but# eliminates cross-worker races against a shared database/Redis connectionjest --runInBand# Or cap parallelism instead of forcing fully serial executionjest --maxWorkers=2
it('matches the expected order shape',()=>{expect(serializeOrder(orderFixture)).toMatchSnapshot();});
// jest.config.tscoverageThreshold:{ 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,},},
# .github/workflows/test.ymlname: Test
on:[push, pull_request]jobs:test:runs-on: ubuntu-latest
services:postgres:image: postgres:16env:POSTGRES_DB: myapp_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:- 5432:5432options:>---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