Skip to main content
Advertisement

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
Advertisement