Express with TypeScript Testing Using ts-jest

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:

  1. Typed mock factories — jest.Mocked<ServiceInterface> ensures mocks implement the full interface contract
  2. Request/Response type augmentation — declare your custom req.user and req.requestId in express.d.ts once and use it everywhere
  3. Runtime type guards — validate that API responses actually conform to your TypeScript interfaces at test time, not just compile time
  4. isolatedModules: true — the single biggest performance improvement for large TypeScript test suites
  5. 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.

Read more

Start now free