16.2 Jest + ts-jest — TypeScript Integration for Existing Projects
Jest + TypeScript Setup
npm install --save-dev jest ts-jest @types/jest
jest.config.ts
import type { Config } from 'jest'
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.{test,spec}.ts'],
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: {
strict: true,
},
}],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1', // Path alias mapping
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/index.ts',
],
coverageThreshold: {
global: { lines: 80, functions: 80 },
},
}
export default config
@types/jest and Type Safety
// Installing @types/jest provides auto-inference for the types below
import { describe, test, expect, jest, beforeEach, afterAll } from '@jest/globals'
// Or use without import when globals: true is configured
describe('Type-safe tests', () => {
test('jest.fn() type inference', () => {
// jest.fn<ReturnType, Args>()
const mockFn = jest.fn<string, [number, string]>()
mockFn.mockReturnValue('result')
mockFn.mockImplementation((n, s) => `${n}-${s}`)
const result = mockFn(1, 'hello')
expect(result).toBe('1-hello')
})
test('jest.spyOn type inference', () => {
const obj = {
greet(name: string): string {
return `Hello, ${name}!`
},
}
const spy = jest.spyOn(obj, 'greet')
spy.mockReturnValue('Mocked!')
expect(obj.greet('Alice')).toBe('Mocked!')
expect(spy).toHaveBeenCalledWith('Alice')
})
})
Automatic Module Mocking
// services/payment.service.ts
export class PaymentService {
async charge(amount: number, cardToken: string): Promise<{ transactionId: string }> {
// Actual payment API call
const response = await fetch('/api/charge', {
method: 'POST',
body: JSON.stringify({ amount, cardToken }),
})
return response.json()
}
}
// services/order.service.ts
import { PaymentService } from './payment.service'
export class OrderService {
constructor(private paymentService: PaymentService) {}
async createOrder(items: Array<{ price: number }>, cardToken: string) {
const total = items.reduce((sum, item) => sum + item.price, 0)
const { transactionId } = await this.paymentService.charge(total, cardToken)
return {
items,
total,
transactionId,
createdAt: new Date(),
}
}
}
// services/order.service.test.ts
import { OrderService } from './order.service'
import { PaymentService } from './payment.service'
// Auto-mock
jest.mock('./payment.service')
const MockedPaymentService = jest.mocked(PaymentService)
describe('OrderService', () => {
let orderService: OrderService
let mockPaymentService: jest.Mocked<PaymentService>
beforeEach(() => {
MockedPaymentService.mockClear()
mockPaymentService = new MockedPaymentService() as jest.Mocked<PaymentService>
mockPaymentService.charge.mockResolvedValue({ transactionId: 'txn-123' })
orderService = new OrderService(mockPaymentService)
})
test('processes payment when creating order', async () => {
const items = [{ price: 1000 }, { price: 2000 }]
const order = await orderService.createOrder(items, 'card-token')
expect(order.total).toBe(3000)
expect(order.transactionId).toBe('txn-123')
expect(mockPaymentService.charge).toHaveBeenCalledWith(3000, 'card-token')
})
})
Custom Matcher Type Extension
// jest.d.ts
import 'jest'
declare global {
namespace jest {
interface Matchers<R> {
toBeValidEmail(): R
toBeWithinRange(min: number, max: number): R
}
}
}
// jest.setup.ts
expect.extend({
toBeValidEmail(received: string) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const pass = emailRegex.test(received)
return {
pass,
message: () =>
pass
? `${received} should not be a valid email.`
: `${received} should be a valid email.`,
}
},
toBeWithinRange(received: number, min: number, max: number) {
const pass = received >= min && received <= max
return {
pass,
message: () =>
`${received} should ${pass ? 'not ' : ''}be within range ${min}–${max}.`,
}
},
})
// Usage
test('custom matchers', () => {
expect('alice@example.com').toBeValidEmail()
expect(50).toBeWithinRange(0, 100)
})
Async Test Patterns
describe('Async tests', () => {
// 1. async/await
test('async/await', async () => {
const result = await fetchData()
expect(result).toBeDefined()
})
// 2. Return Promise
test('return Promise', () => {
return fetchData().then(result => {
expect(result).toBeDefined()
})
})
// 3. resolves/rejects matchers
test('resolves matcher', async () => {
await expect(fetchData()).resolves.toBeDefined()
})
test('rejects matcher', async () => {
await expect(fetchError()).rejects.toThrow('Error message')
await expect(fetchError()).rejects.toBeInstanceOf(Error)
})
// 4. done callback (legacy)
test('done callback', (done) => {
fetchData().then(result => {
expect(result).toBeDefined()
done()
})
})
})
Pro Tips
Sync tsconfig paths with jest.config.ts
// jest.config.ts
export default {
preset: 'ts-jest',
moduleNameMapper: {
// Keep in sync with tsconfig paths
'^@/(.*)$': '<rootDir>/src/$1',
'^@auth/(.*)$': '<rootDir>/src/auth/$1',
'^@db/(.*)$': '<rootDir>/src/db/$1',
},
}
Vitest vs Jest Selection Guide
Choose Vitest:
- Vite-based projects
- Native ESM
- Speed is top priority
- Starting new projects
Choose Jest:
- Existing Jest test code
- CRA, Next.js default setup
- Needs large ecosystem
- Maintaining existing CI/CD