Express with TypeScript Testing Using ts-jest
Testing an Express application written in TypeScript introduces both advantages and challenges. The advantages: your mocks can be type-checked, your request/response extensions are documented in the type system, and the compiler catches whole categories of test bugs before they run. The challenges: ts-jest configuration, typing custom middleware, and creating type-safe mocks without losing the flexibility you need in tests. This post covers the patterns that make TypeScript Express testing productive rather than frustrating.
Setting Up ts-jest
Start with the minimal correct configuration. Over-engineering this step is the most common source of early friction:
npm install --save-dev jest ts-jest @types/jest @types/supertest supertest typescript// jest.config.js
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.ts', '**/*.spec.ts'],
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: {
// Use a looser config for tests — don't need strict null checks in test utilities
strict: true,
esModuleInterop: true,
},
}],
},
// Map path aliases from tsconfig so they work in tests
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@middleware/(.*)$': '<rootDir>/src/middleware/$1',
'^@routes/(.*)$': '<rootDir>/src/routes/$1',
},
coverageDirectory: 'coverage',
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/__tests__/**'],
};// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@middleware/*": ["src/middleware/*"],
"@routes/*": ["src/routes/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Typing Your Express Application
The foundation of TypeScript Express testing is correct typing of your request object. Define augmentations once and use them everywhere:
// src/types/express.d.ts
import { User } from '../models/User';
import { Logger } from 'winston';
declare global {
namespace Express {
interface Request {
user?: AuthenticatedUser;
requestId: string;
startTime: number;
}
interface Locals {
logger: Logger;
}
}
}
export interface AuthenticatedUser {
id: string;
email: string;
role: 'admin' | 'member' | 'guest';
}
export interface ApiError {
error: string;
code: string;
errors?: FieldError[];
}
export interface FieldError {
field: string;
message: string;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
total: number;
page: number;
per_page: number;
total_pages: number;
next_page: number | null;
prev_page: number | null;
};
}// src/app.ts
import express, { Application } from 'express';
import { UserService } from './services/UserService';
import { Logger } from 'winston';
interface AppDependencies {
userService?: UserService;
logger?: Logger;
}
export function createApp(deps: AppDependencies = {}): Application {
const app = express();
app.use(express.json());
if (deps.userService) app.locals.userService = deps.userService;
if (deps.logger) app.locals.logger = deps.logger;
// Routes
app.use('/api/users', require('./routes/users').default);
return app;
}Typed Middleware Testing
Testing middleware with TypeScript requires typed mock objects. The trick is partial mocking — you don't need a full Request object, just the properties your middleware touches:
// src/middleware/requestId.ts
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
export function requestIdMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
req.requestId = (req.headers['x-request-id'] as string) || uuidv4();
res.setHeader('X-Request-Id', req.requestId);
next();
}// src/middleware/__tests__/requestId.test.ts
import { requestIdMiddleware } from '../requestId';
import { Request, Response, NextFunction } from 'express';
// Helper to create typed partial mocks
function mockRequest(overrides: Partial<Request> = {}): Request {
return {
headers: {},
requestId: '',
...overrides,
} as unknown as Request;
}
function mockResponse(): jest.Mocked<Pick<Response, 'setHeader'>> & { _headers: Record<string, string> } {
const headers: Record<string, string> = {};
return {
_headers: headers,
setHeader: jest.fn((name: string, value: string) => {
headers[name] = value;
}),
};
}
describe('requestIdMiddleware', () => {
let next: jest.MockedFunction<NextFunction>;
beforeEach(() => {
next = jest.fn();
});
it('generates a UUID when no request ID header is present', () => {
const req = mockRequest({ headers: {} });
const res = mockResponse();
requestIdMiddleware(req, res as unknown as Response, next);
expect(req.requestId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
expect(res.setHeader).toHaveBeenCalledWith('X-Request-Id', req.requestId);
expect(next).toHaveBeenCalledWith();
});
it('uses the incoming request ID when header is present', () => {
const req = mockRequest({ headers: { 'x-request-id': 'trace-abc-123' } });
const res = mockResponse();
requestIdMiddleware(req, res as unknown as Response, next);
expect(req.requestId).toBe('trace-abc-123');
});
});Type-Safe Mocks with jest.Mocked
TypeScript's jest.Mocked<T> utility creates a type that mirrors your interface but wraps all methods in jest.MockedFunction. This is the right way to mock services:
// src/services/UserService.ts
export interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'member';
createdAt: Date;
}
export interface CreateUserInput {
email: string;
name: string;
password: string;
}
export interface UserService {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
create(input: CreateUserInput): Promise<User>;
update(id: string, data: Partial<User>): Promise<User>;
delete(id: string): Promise<void>;
}// src/routes/__tests__/users.test.ts
import request from 'supertest';
import express from 'express';
import { createApp } from '../../app';
import { UserService, User } from '../../services/UserService';
// Factory for typed mock — TypeScript ensures we implement all interface methods
function createMockUserService(): jest.Mocked<UserService> {
return {
findById: jest.fn(),
findByEmail: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
}
// Factory for test user data
function buildUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
email: 'alice@example.com',
name: 'Alice Smith',
role: 'member',
createdAt: new Date('2026-01-01'),
...overrides,
};
}
describe('Users Routes', () => {
let userService: jest.Mocked<UserService>;
let app: express.Application;
beforeEach(() => {
userService = createMockUserService();
app = createApp({ userService });
});
describe('GET /api/users/:id', () => {
it('returns 200 with user when found', async () => {
const user = buildUser({ id: 'user-42' });
userService.findById.mockResolvedValue(user);
const response = await request(app).get('/api/users/user-42');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
id: 'user-42',
email: 'alice@example.com',
name: 'Alice Smith',
});
// TypeScript guarantees this mock was called with a string
expect(userService.findById).toHaveBeenCalledWith('user-42');
});
it('returns 404 when user is not found', async () => {
userService.findById.mockResolvedValue(null);
const response = await request(app).get('/api/users/missing');
expect(response.status).toBe(404);
});
});
describe('POST /api/users', () => {
it('creates a user and returns 201', async () => {
const createdUser = buildUser({ id: 'new-user-id' });
userService.findByEmail.mockResolvedValue(null);
userService.create.mockResolvedValue(createdUser);
const response = await request(app)
.post('/api/users')
.send({ email: 'bob@example.com', name: 'Bob Jones', password: 'secret123' });
expect(response.status).toBe(201);
// TypeScript checks: create was called with the right shape
const createCall = userService.create.mock.calls[0][0];
expect(createCall.email).toBe('bob@example.com');
expect(createCall.name).toBe('Bob Jones');
expect(createCall.password).toBeDefined(); // should be hashed
});
});
});Testing Typed Route Handlers
TypeScript-specific testing value comes from testing the full type contract — not just "does it return 200" but "does it return the right shape that TypeScript would accept":
// src/routes/users.ts
import { Router, Request, Response, NextFunction } from 'express';
import { AuthenticatedUser, PaginatedResponse, ApiError } from '../types/express';
import { User, UserService } from '../services/UserService';
export function createUsersRouter(): Router {
const router = Router();
router.get(
'/',
async (
req: Request<{}, PaginatedResponse<User> | ApiError, {}, { page?: string; per_page?: string }>,
res: Response<PaginatedResponse<User> | ApiError>,
next: NextFunction
) => {
try {
const page = parseInt(req.query.page || '1', 10);
const perPage = Math.min(parseInt(req.query.per_page || '10', 10), 100);
const userService = req.app.locals.userService as UserService;
const { data, total } = await userService.paginate({ page, perPage });
const totalPages = Math.ceil(total / perPage);
res.json({
data,
pagination: {
total,
page,
per_page: perPage,
total_pages: totalPages,
next_page: page < totalPages ? page + 1 : null,
prev_page: page > 1 ? page - 1 : null,
},
});
} catch (err) {
next(err);
}
}
);
return router;
}// src/routes/__tests__/users.pagination.test.ts
import request from 'supertest';
import { PaginatedResponse } from '../../types/express';
import { User } from '../../services/UserService';
// Type guard for runtime response validation
function isPaginatedResponse<T>(value: unknown): value is PaginatedResponse<T> {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
return (
Array.isArray(obj.data) &&
typeof obj.pagination === 'object' &&
obj.pagination !== null &&
typeof (obj.pagination as Record<string, unknown>).total === 'number'
);
}
describe('Users Pagination', () => {
it('returns a correctly typed paginated response', async () => {
const mockUsers: User[] = Array.from({ length: 15 }, (_, i) =>
buildUser({ id: `user-${i}`, email: `user${i}@example.com` })
);
userService.paginate = jest.fn().mockResolvedValue({
data: mockUsers.slice(0, 10),
total: 15,
});
const response = await request(app).get('/api/users?page=1&per_page=10');
expect(response.status).toBe(200);
// Runtime type check — validates the response shape matches our TypeScript interface
expect(isPaginatedResponse(response.body)).toBe(true);
const body: PaginatedResponse<User> = response.body;
expect(body.data).toHaveLength(10);
expect(body.pagination.total).toBe(15);
expect(body.pagination.total_pages).toBe(2);
expect(body.pagination.next_page).toBe(2);
expect(body.pagination.prev_page).toBeNull();
});
});Interface Testing with Discriminated Unions
TypeScript discriminated unions let you model response types precisely and test them with full type safety:
// src/types/responses.ts
export type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string; code: string };
export type UserResponse = ApiResponse<{
id: string;
email: string;
name: string;
role: 'admin' | 'member';
}>;// src/routes/__tests__/typed-responses.test.ts
import { ApiResponse, UserResponse } from '../../types/responses';
describe('Typed Response Contracts', () => {
it('success response contains expected user fields', async () => {
userService.findById.mockResolvedValue(buildUser());
const response = await request(app).get('/api/users/user-1');
expect(response.status).toBe(200);
// Cast to typed response — TypeScript narrows based on `success`
const body: UserResponse = response.body;
if (body.success) {
// TypeScript knows body.data exists here
expect(body.data.id).toBeDefined();
expect(body.data.email).toBeDefined();
expect(['admin', 'member']).toContain(body.data.role);
} else {
// If we reach here, the test should fail
fail(`Expected success response, got error: ${body.error}`);
}
});
it('error response contains error and code', async () => {
userService.findById.mockResolvedValue(null);
const response = await request(app).get('/api/users/missing');
expect(response.status).toBe(404);
const body: ApiResponse<never> = response.body;
if (!body.success) {
expect(typeof body.error).toBe('string');
expect(typeof body.code).toBe('string');
} else {
fail('Expected error response');
}
});
});Mocking TypeScript Modules Correctly
Module mocking in TypeScript requires special handling to keep type safety:
// src/middleware/__tests__/auth.test.ts
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { createJwtMiddleware } from '../auth';
// Mock jwt module with types preserved
jest.mock('jsonwebtoken');
const mockedJwt = jest.mocked(jwt);
describe('JWT Middleware — Unit (mocked jwt)', () => {
const TEST_SECRET = 'test-secret';
beforeEach(() => {
jest.clearAllMocks();
});
it('calls jwt.verify with correct parameters', () => {
const middleware = createJwtMiddleware({ secret: TEST_SECRET });
const req = mockRequest({ headers: { authorization: 'Bearer test-token' } });
const res = mockResponse();
const next = jest.fn();
// Make verify return a valid payload
mockedJwt.verify.mockReturnValue({ sub: 'user-1', role: 'member' } as never);
middleware(req as Request, res as Response, next);
expect(mockedJwt.verify).toHaveBeenCalledWith(
'test-token',
TEST_SECRET,
{ algorithms: ['HS256'] }
);
});
it('calls next with UnauthorizedError when verify throws TokenExpiredError', () => {
const middleware = createJwtMiddleware({ secret: TEST_SECRET });
const req = mockRequest({ headers: { authorization: 'Bearer expired-token' } });
const res = mockResponse();
const next = jest.fn();
const expiredError = new Error('jwt expired');
expiredError.name = 'TokenExpiredError';
mockedJwt.verify.mockImplementation(() => { throw expiredError; });
middleware(req as Request, res as Response, next);
// next() should be called with an error
expect(next).toHaveBeenCalledWith(expect.objectContaining({
status: 401,
message: expect.stringContaining('expired'),
}));
});
});TypeScript-Specific Test Utilities
Build a typed test utility library to avoid repeating boilerplate:
// src/test/utils.ts
import express, { Application, RequestHandler } from 'express';
import request, { SuperTest, Test } from 'supertest';
export interface TestAppOptions {
middleware?: RequestHandler[];
routes?: Array<{
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
path: string;
handler: RequestHandler;
}>;
errorHandler?: express.ErrorRequestHandler;
}
export function buildTestApp(options: TestAppOptions = {}): Application {
const app = express();
app.use(express.json());
options.middleware?.forEach(mw => app.use(mw));
options.routes?.forEach(({ method, path, handler }) => {
app[method](path, handler);
});
if (options.errorHandler) {
app.use(options.errorHandler);
} else {
app.use(((err: Error & { status?: number; code?: string }, req, res, next) => {
res.status(err.status || 500).json({
error: err.message,
code: err.code || 'INTERNAL_ERROR',
});
}) as express.ErrorRequestHandler);
}
return app;
}
export function makeRequest(app: Application): SuperTest<Test> {
return request(app);
}
// Assertion helper for type-safe response checking
export function assertResponseShape<T>(
body: unknown,
validator: (data: unknown) => data is T
): asserts body is T {
if (!validator(body)) {
throw new Error(`Response body does not match expected shape: ${JSON.stringify(body, null, 2)}`);
}
}ts-jest Performance Optimization
For large TypeScript test suites, ts-jest can be slow. These configurations help:
// jest.config.js (performance-optimized)
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': ['ts-jest', {
// Disable type checking during tests for speed
// Type checking happens in CI as a separate step (tsc --noEmit)
isolatedModules: true,
tsconfig: {
strict: false, // Loosen for tests only
},
}],
},
// Cache transpiled modules
cacheDirectory: '<rootDir>/.jest-cache',
// Run tests in workers for parallelism
maxWorkers: '50%',
globals: {
'ts-jest': {
diagnostics: {
// Only report TS errors in source files, not test files
pathRegex: /src\/(?!__tests__).*\.ts$/,
},
},
},
};Separating type checking from test execution is the key insight: tsc --noEmit in CI catches type errors, while isolatedModules: true in ts-jest makes individual test files transpile in milliseconds instead of seconds.
Key Takeaways
TypeScript + Express testing delivers the most value through:
- Typed mock factories —
jest.Mocked<ServiceInterface>ensures mocks implement the full interface contract - Request/Response type augmentation — declare your custom
req.userandreq.requestIdinexpress.d.tsonce and use it everywhere - Runtime type guards — validate that API responses actually conform to your TypeScript interfaces at test time, not just compile time
isolatedModules: true— the single biggest performance improvement for large TypeScript test suites- Discriminated unions for response types — forces tests to handle both success and error cases explicitly
The compile-time guarantees TypeScript provides don't replace runtime testing — they eliminate one class of bugs so your tests can focus on business logic and integration correctness.