- Authors

- Name
- Youngju Kim
- @fjvbn20031
TypeScript Testing Complete Guide: Jest, Vitest, MSW, Mock and Stub in Practice
Testing is the most effective way to guarantee code quality. Writing proper tests in a TypeScript environment requires understanding test frameworks, mocking strategies, mock API servers, and more. This guide covers the complete testing stack used in production projects as of 2026.
1. Setting Up the TypeScript Testing Environment
1.1 Jest + ts-jest Setup
npm install -D 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: ['**/__tests__/**/*.ts', '**/*.test.ts', '**/*.spec.ts'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: 'tsconfig.json',
},
],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/*.stories.ts'],
coverageThreshold: {
global: {
branches: 70,
functions: 80,
lines: 80,
statements: 80,
},
},
setupFilesAfterFramework: ['<rootDir>/jest.setup.ts'],
}
export default config
// jest.setup.ts
import '@testing-library/jest-dom'
beforeEach(() => {
jest.clearAllMocks()
})
1.2 Vitest Setup (ESM Support)
Vitest is built on Vite and provides native ESM support with a Jest-compatible API.
npm install -D vitest @vitest/coverage-v8
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
globals: true, // use describe, it, expect globally
environment: 'node', // 'jsdom' for browser-like tests
setupFiles: ['./vitest.setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: ['node_modules', 'dist', '**/*.d.ts'],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
},
},
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
1.3 @types/jest vs @jest/globals
// Option 1: @types/jest (global type injection - requires "types": ["jest"] in tsconfig.json)
describe('example', () => {
it('works', () => {
expect(true).toBe(true)
})
})
// Option 2: @jest/globals (explicit imports - safer and more explicit)
import { describe, it, expect, jest } from '@jest/globals'
describe('example', () => {
it('works', () => {
expect(true).toBe(true)
})
})
// In Vitest with globals: true, no imports needed
// Or explicitly:
import { describe, it, expect, vi } from 'vitest'
2. Writing Unit Tests
2.1 Basic Structure
// src/utils/calculator.ts
export function add(a: number, b: number): number {
return a + b
}
export function divide(a: number, b: number): number {
if (b === 0) throw new Error('Cannot divide by zero')
return a / b
}
// src/utils/__tests__/calculator.test.ts
import { add, divide } from '../calculator'
describe('Calculator', () => {
describe('add', () => {
it('adds two positive numbers', () => {
expect(add(2, 3)).toBe(5)
})
it('adds negative numbers', () => {
expect(add(-1, -2)).toBe(-3)
})
it('adds zero', () => {
expect(add(5, 0)).toBe(5)
})
})
describe('divide', () => {
it('performs division', () => {
expect(divide(10, 2)).toBe(5)
})
it('throws when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Cannot divide by zero')
})
it('returns a decimal result', () => {
expect(divide(1, 3)).toBeCloseTo(0.333, 2)
})
})
})
2.2 Lifecycle Hooks
describe('Database Tests', () => {
let db: MockDatabase
beforeAll(async () => {
// Runs once before the entire test suite
db = await MockDatabase.connect('test-db')
await db.migrate()
})
afterAll(async () => {
// Runs once after the entire test suite
await db.disconnect()
})
beforeEach(async () => {
// Runs before each individual test
await db.seed()
})
afterEach(async () => {
// Runs after each individual test
await db.clean()
})
it('creates a user', async () => {
const user = await db.users.create({ name: 'Alice', email: 'alice@example.com' })
expect(user.id).toBeDefined()
expect(user.name).toBe('Alice')
})
})
2.3 Async Tests
// async/await (recommended)
it('fetches async data', async () => {
const data = await fetchData()
expect(data).toEqual({ id: 1, name: 'Alice' })
})
// Returning a Promise
it('returns a Promise', () => {
return fetchData().then((data) => {
expect(data).toEqual({ id: 1, name: 'Alice' })
})
})
// Testing errors
it('handles network errors', async () => {
await expect(fetchData()).rejects.toThrow('Network Error')
})
// Custom timeout
it('slow operation', async () => {
const result = await slowOperation()
expect(result).toBeDefined()
}, 10000) // 10 second timeout
// Modifiers
it.only('only run this test', () => {
/* ... */
})
it.skip('skip this test', () => {
/* ... */
})
it.todo('write this test later')
2.4 Using Matchers
describe('Matcher examples', () => {
it('equality checks', () => {
expect(1 + 1).toBe(2) // primitive equality (===)
expect({ a: 1 }).toEqual({ a: 1 }) // deep equality
expect({ a: 1, b: 2 }).toMatchObject({ a: 1 }) // partial match
})
it('truthiness checks', () => {
expect(true).toBeTruthy()
expect(null).toBeNull()
expect(undefined).toBeUndefined()
expect('value').toBeDefined()
})
it('number comparisons', () => {
expect(5).toBeGreaterThan(3)
expect(5).toBeLessThanOrEqual(5)
expect(0.1 + 0.2).toBeCloseTo(0.3, 5)
})
it('array / string checks', () => {
expect([1, 2, 3]).toContain(2)
expect([1, 2, 3]).toHaveLength(3)
expect('hello world').toMatch(/world/)
expect('hello world').toContain('hello')
})
it('error checks', () => {
expect(() => {
throw new Error('oops')
}).toThrow()
expect(() => {
throw new Error('oops')
}).toThrow('oops')
expect(() => {
throw new TypeError('type error')
}).toThrow(TypeError)
})
})
3. Jest Mocking Deep Dive
3.1 jest.fn() - Function Mocking
import { jest } from '@jest/globals'
// Basic mock function
const mockFn = jest.fn()
mockFn('hello')
expect(mockFn).toHaveBeenCalledWith('hello')
expect(mockFn).toHaveBeenCalledTimes(1)
// Setting return values
const mockAdd = jest
.fn()
.mockReturnValue(10) // always returns 10
.mockReturnValueOnce(5) // returns 5 on 1st call
.mockReturnValueOnce(7) // returns 7 on 2nd call
console.log(mockAdd()) // 5
console.log(mockAdd()) // 7
console.log(mockAdd()) // 10 (default from here on)
// Async return values
const mockFetch = jest
.fn()
.mockResolvedValue({ data: 'success' }) // Promise.resolve
.mockRejectedValueOnce(new Error('Network Error')) // Promise.reject (once)
// Replacing the implementation
const mockCalculate = jest.fn().mockImplementation((a: number, b: number) => {
return a * b
})
// Inspecting call arguments
const spy = jest.fn()
spy(1, 'hello', { key: 'value' })
expect(spy.mock.calls[0]).toEqual([1, 'hello', { key: 'value' }])
3.2 jest.spyOn() - Spy Pattern
// Watches calls while keeping the real implementation
const mathSpy = jest.spyOn(Math, 'random')
mathSpy.mockReturnValue(0.5)
Math.random() // returns 0.5
expect(mathSpy).toHaveBeenCalled()
// Restore the original implementation after the test
mathSpy.mockRestore()
// Spying on object methods
class UserService {
async getUser(id: number) {
return fetch(`/api/users/${id}`).then((r) => r.json())
}
}
const service = new UserService()
const spy = jest.spyOn(service, 'getUser').mockResolvedValue({ id: 1, name: 'Alice' })
const user = await service.getUser(1)
expect(user.name).toBe('Alice')
expect(spy).toHaveBeenCalledWith(1)
3.3 jest.mock() - Module Mocking
// Mock an entire module
jest.mock('../api/userApi')
import { fetchUser } from '../api/userApi'
const mockFetchUser = fetchUser as jest.MockedFunction<typeof fetchUser>
mockFetchUser.mockResolvedValue({ id: 1, name: 'Alice' })
// Mock with a factory function (more granular control)
jest.mock('../api/userApi', () => ({
fetchUser: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
createUser: jest.fn().mockResolvedValue({ id: 2, name: 'Bob' }),
}))
// Partial module mocking
jest.mock('../utils/date', () => ({
...jest.requireActual('../utils/date'), // keep the rest of the actual implementation
now: jest.fn().mockReturnValue(new Date('2026-01-01')),
}))
// Mocking ES module default exports
jest.mock('../services/emailService', () => ({
default: {
send: jest.fn().mockResolvedValue(true),
},
}))
3.4 Differences Between Mock Reset Methods
describe('Mock reset differences', () => {
const mockFn = jest.fn().mockReturnValue(42)
afterEach(() => {
// jest.clearAllMocks(): resets mock.calls, mock.instances, mock.results
// but keeps the mockReturnValue implementation
jest.clearAllMocks()
// jest.resetAllMocks(): clearAllMocks + also resets mockReturnValue etc.
// jest.resetAllMocks()
// jest.restoreAllMocks(): restores spies created with spyOn to their original implementations
// jest.restoreAllMocks()
})
it('implementation survives clearAllMocks', () => {
jest.clearAllMocks()
expect(mockFn()).toBe(42) // implementation intact
expect(mockFn).toHaveBeenCalledTimes(1) // call history reset
})
})
// When to use each:
// clearAllMocks: default for afterEach
// resetAllMocks: when you need to fully reset implementation too
// restoreAllMocks: after using spyOn and want the original back
3.5 Automatic Mocking (mocks Directory)
src/
services/
__mocks__/
emailService.ts <- automatic mock file
emailService.ts <- real file
// src/services/__mocks__/emailService.ts
export const emailService = {
send: jest.fn().mockResolvedValue({ success: true }),
verify: jest.fn().mockResolvedValue(true),
}
export default emailService
// In your test file: just add jest.mock('../services/emailService')
import { emailService } from '../services/emailService'
jest.mock('../services/emailService')
it('sends an email', async () => {
await emailService.send({ to: 'test@example.com', subject: 'Hello' })
expect(emailService.send).toHaveBeenCalledTimes(1)
})
4. Mock Service Worker (MSW) - Mock API Server
MSW intercepts HTTP requests using a service worker or a Node.js interceptor. It is the modern standard for API mocking.
4.1 Installation and Basic Setup
npm install -D msw
// src/mocks/handlers.ts
import { http, HttpResponse, delay } from 'msw'
export interface User {
id: number
name: string
email: string
}
const users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
]
export const handlers = [
// GET all
http.get('/api/users', () => {
return HttpResponse.json(users)
}),
// GET by ID with path parameter
http.get('/api/users/:id', ({ params }) => {
const { id } = params
const user = users.find((u) => u.id === Number(id))
if (!user) {
return HttpResponse.json({ error: 'User not found' }, { status: 404 })
}
return HttpResponse.json(user)
}),
// POST
http.post('/api/users', async ({ request }) => {
const body = (await request.json()) as Omit<User, 'id'>
const newUser: User = { id: Date.now(), ...body }
users.push(newUser)
return HttpResponse.json(newUser, { status: 201 })
}),
// PUT
http.put('/api/users/:id', async ({ params, request }) => {
const { id } = params
const body = (await request.json()) as Partial<User>
const index = users.findIndex((u) => u.id === Number(id))
if (index === -1) {
return HttpResponse.json({ error: 'User not found' }, { status: 404 })
}
users[index] = { ...users[index], ...body }
return HttpResponse.json(users[index])
}),
// DELETE
http.delete('/api/users/:id', ({ params }) => {
const { id } = params
const index = users.findIndex((u) => u.id === Number(id))
if (index === -1) {
return HttpResponse.json({ error: 'User not found' }, { status: 404 })
}
users.splice(index, 1)
return new HttpResponse(null, { status: 204 })
}),
]
4.2 Node.js Environment (Jest/Vitest)
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// In jest.setup.ts or vitest.setup.ts
import { server } from './src/mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
4.3 Simulating Response Delays
import { http, HttpResponse, delay } from 'msw'
export const handlers = [
http.get('/api/users', async () => {
await delay(500) // 500ms artificial delay
return HttpResponse.json(users)
}),
http.get('/api/slow-endpoint', async () => {
await delay('real') // simulate realistic network latency
return HttpResponse.json({ data: 'ok' })
}),
]
4.4 Mocking Error Responses
// Override to error response for a specific test
it('handles server errors', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ error: 'Internal Server Error' }, { status: 500 })
})
)
await expect(fetchUsers()).rejects.toThrow()
})
it('handles network errors', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.error() // simulate a network-level error
})
)
await expect(fetchUsers()).rejects.toThrow('Network Error')
})
4.5 Browser Environment (setupWorker)
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
// Activate MSW only in development (e.g., in main.tsx)
if (import.meta.env.DEV) {
const { worker } = await import('./mocks/browser')
await worker.start({
onUnhandledRequest: 'bypass',
})
}
4.6 GraphQL Mocking
import { graphql, HttpResponse } from 'msw'
export const handlers = [
graphql.query('GetUser', ({ variables }) => {
const { id } = variables
return HttpResponse.json({
data: {
user: { id, name: 'Alice', email: 'alice@example.com' },
},
})
}),
graphql.mutation('CreateUser', ({ variables }) => {
const { name, email } = variables
return HttpResponse.json({
data: {
createUser: { id: Date.now(), name, email },
},
})
}),
]
4.7 Complete Example: Next.js API Mocking
// src/services/userService.ts
export async function getUsers(): Promise<User[]> {
const response = await fetch('/api/users')
if (!response.ok) throw new Error('Failed to fetch users')
return response.json()
}
// src/services/__tests__/userService.test.ts
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { getUsers } from '../userService'
const server = setupServer(
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
])
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('UserService', () => {
it('fetches a list of users', async () => {
const users = await getUsers()
expect(users).toHaveLength(2)
expect(users[0].name).toBe('Alice')
})
it('throws on server error', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ error: 'Server Error' }, { status: 500 })
})
)
await expect(getUsers()).rejects.toThrow('Failed to fetch users')
})
})
5. Stub Patterns
5.1 Manual Stubs (Interface Implementation)
// Interface definition
interface EmailService {
send(to: string, subject: string, body: string): Promise<boolean>
verify(email: string): Promise<boolean>
}
// Test stub
class StubEmailService implements EmailService {
private sentEmails: Array<{ to: string; subject: string; body: string }> = []
async send(to: string, subject: string, body: string): Promise<boolean> {
this.sentEmails.push({ to, subject, body })
return true
}
async verify(_email: string): Promise<boolean> {
return true
}
getSentEmails() {
return this.sentEmails
}
}
// Usage
it('sends a welcome email on registration', async () => {
const emailStub = new StubEmailService()
const userService = new UserService(emailStub)
await userService.register({ name: 'Alice', email: 'alice@example.com' })
const sentEmails = emailStub.getSentEmails()
expect(sentEmails).toHaveLength(1)
expect(sentEmails[0].to).toBe('alice@example.com')
expect(sentEmails[0].subject).toContain('Welcome')
})
5.2 Date/Time Stubs
// Jest
describe('Date-related tests', () => {
beforeEach(() => {
jest.useFakeTimers()
jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
})
afterEach(() => {
jest.useRealTimers()
})
it('returns the correct current date', () => {
const result = getCurrentDate()
expect(result).toBe('2026-01-01')
})
it('advances timers', async () => {
const callback = jest.fn()
setTimeout(callback, 1000)
jest.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalled()
})
})
// Vitest
import { vi } from 'vitest'
describe('Date-related tests', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
})
afterEach(() => {
vi.useRealTimers()
})
})
5.3 Environment Variable Stubs
describe('Environment variable tests', () => {
const originalEnv = process.env
beforeEach(() => {
process.env = { ...originalEnv }
})
afterEach(() => {
process.env = originalEnv
})
it('behaves differently in production', () => {
process.env.NODE_ENV = 'production'
process.env.API_URL = 'https://api.example.com'
const config = loadConfig()
expect(config.apiUrl).toBe('https://api.example.com')
expect(config.debug).toBe(false)
})
it('enables debug mode in development', () => {
process.env.NODE_ENV = 'development'
const config = loadConfig()
expect(config.debug).toBe(true)
})
})
5.4 LocalStorage Stub
class LocalStorageStub {
private store: Map<string, string> = new Map()
getItem(key: string): string | null {
return this.store.get(key) ?? null
}
setItem(key: string, value: string): void {
this.store.set(key, value)
}
removeItem(key: string): void {
this.store.delete(key)
}
clear(): void {
this.store.clear()
}
get length(): number {
return this.store.size
}
key(index: number): string | null {
return Array.from(this.store.keys())[index] ?? null
}
}
// Usage in tests
beforeEach(() => {
const localStorageStub = new LocalStorageStub()
Object.defineProperty(window, 'localStorage', {
value: localStorageStub,
writable: true,
})
})
it('saves token to localStorage', () => {
authService.login('user@example.com', 'password')
expect(localStorage.getItem('auth_token')).toBeDefined()
})
6. Building a Mock Server
6.1 json-server Setup
npm install -D json-server
// db.json
{
"users": [
{ "id": 1, "name": "Alice", "email": "alice@example.com" },
{ "id": 2, "name": "Bob", "email": "bob@example.com" }
],
"posts": [{ "id": 1, "title": "Hello World", "userId": 1, "body": "First post" }]
}
// json-server.config.js
module.exports = {
port: 3001,
routes: {
'/api/*': '/$1', // /api/users -> /users
},
}
npx json-server db.json --config json-server.config.js
# GET /api/users
# GET /api/users/1
# POST /api/users
# PUT /api/users/1
# DELETE /api/users/1
6.2 TypeScript Express Mock Server
// mock-server/server.ts
import express, { Request, Response, NextFunction } from 'express'
import cors from 'cors'
const app = express()
app.use(express.json())
app.use(cors())
let users = [
{ id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' },
]
// Auth middleware stub
const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token || token !== 'valid-test-token') {
return res.status(401).json({ error: 'Unauthorized' })
}
next()
}
app.get('/api/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
app.get('/api/users', authMiddleware, (_req: Request, res: Response) => {
res.json({ data: users, total: users.length })
})
app.get('/api/users/:id', authMiddleware, (req: Request, res: Response) => {
const user = users.find((u) => u.id === parseInt(req.params.id))
if (!user) return res.status(404).json({ error: 'User not found' })
res.json(user)
})
app.post('/api/users', authMiddleware, (req: Request, res: Response) => {
const { name, email, role = 'user' } = req.body
if (!name || !email) {
return res.status(400).json({ error: 'name and email are required' })
}
const newUser = { id: Date.now(), name, email, role }
users.push(newUser)
res.status(201).json(newUser)
})
app.delete('/api/users/:id', authMiddleware, (req: Request, res: Response) => {
const id = parseInt(req.params.id)
const index = users.findIndex((u) => u.id === id)
if (index === -1) return res.status(404).json({ error: 'User not found' })
users.splice(index, 1)
res.status(204).send()
})
// Test reset endpoint
app.post('/api/__reset', (_req, res) => {
users = [
{ id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' },
]
res.json({ message: 'Data reset' })
})
export { app }
if (require.main === module) {
app.listen(3001, () => {
console.log('Mock server running on http://localhost:3001')
})
}
7. Integration Tests and E2E
7.1 Testing Express Apps with Supertest
import request from 'supertest'
import { app } from '../mock-server/server'
describe('User API Integration Tests', () => {
const authHeader = { Authorization: 'Bearer valid-test-token' }
beforeEach(async () => {
await request(app).post('/api/__reset')
})
it('GET /api/users - fetches user list', async () => {
const response = await request(app).get('/api/users').set(authHeader).expect(200)
expect(response.body.data).toHaveLength(2)
expect(response.body.total).toBe(2)
})
it('POST /api/users - creates a user', async () => {
const response = await request(app)
.post('/api/users')
.set(authHeader)
.send({ name: 'Charlie', email: 'charlie@example.com' })
.expect(201)
expect(response.body.name).toBe('Charlie')
expect(response.body.id).toBeDefined()
})
it('returns 401 without auth', async () => {
await request(app).get('/api/users').expect(401)
})
})
7.2 Testing Library (@testing-library/react)
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserList } from '../components/UserList'
const server = setupServer(
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice', email: 'alice@example.com' },
])
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('UserList Component', () => {
it('renders the user list', async () => {
render(<UserList />)
// Check loading state
expect(screen.getByText('Loading...')).toBeInTheDocument()
// Check data after load
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
})
})
it('navigates to detail page on user click', async () => {
const user = userEvent.setup()
render(<UserList />)
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
})
await user.click(screen.getByText('Alice'))
expect(screen.getByRole('heading', { name: 'User Detail' })).toBeInTheDocument()
})
})
8. TDD in Practice
8.1 The Red-Green-Refactor Cycle
The essence of TDD is to write tests first.
- Red: Write a failing test
- Green: Write the minimum code to make the test pass
- Refactor: Improve the code quality (tests must still pass)
8.2 Shopping Cart TDD - Complete Example
// Step 1 (Red): Write tests first
// src/domain/__tests__/shoppingCart.test.ts
import { describe, it, expect } from 'vitest'
import { ShoppingCart, CartItem } from '../shoppingCart'
describe('ShoppingCart', () => {
let cart: ShoppingCart
beforeEach(() => {
cart = new ShoppingCart()
})
describe('adding items', () => {
it('can add an item', () => {
cart.addItem({ id: 1, name: 'Apple', price: 1.0, quantity: 1 })
expect(cart.items).toHaveLength(1)
})
it('increases quantity when the same item is added', () => {
cart.addItem({ id: 1, name: 'Apple', price: 1.0, quantity: 1 })
cart.addItem({ id: 1, name: 'Apple', price: 1.0, quantity: 2 })
expect(cart.items[0].quantity).toBe(3)
})
})
describe('removing items', () => {
it('can remove an item', () => {
cart.addItem({ id: 1, name: 'Apple', price: 1.0, quantity: 1 })
cart.removeItem(1)
expect(cart.items).toHaveLength(0)
})
it('throws when removing a nonexistent item', () => {
expect(() => cart.removeItem(999)).toThrow('Item not found')
})
})
describe('calculating total', () => {
it('empty cart totals to 0', () => {
expect(cart.total).toBe(0)
})
it('calculates total for multiple items', () => {
cart.addItem({ id: 1, name: 'Apple', price: 1.0, quantity: 2 })
cart.addItem({ id: 2, name: 'Banana', price: 0.5, quantity: 3 })
expect(cart.total).toBe(3.5)
})
})
describe('applying discounts', () => {
it('applies a 10% discount', () => {
cart.addItem({ id: 1, name: 'Apple', price: 100, quantity: 1 })
cart.applyDiscount(10)
expect(cart.discountedTotal).toBe(90)
})
it('rejects discounts over 100%', () => {
expect(() => cart.applyDiscount(101)).toThrow('Discount must be between 0 and 100')
})
})
describe('applying coupons', () => {
it('applies a fixed amount coupon', () => {
cart.addItem({ id: 1, name: 'Apple', price: 100, quantity: 1 })
cart.applyCoupon({ type: 'fixed', amount: 20 })
expect(cart.discountedTotal).toBe(80)
})
it('applies a percentage coupon', () => {
cart.addItem({ id: 1, name: 'Apple', price: 100, quantity: 1 })
cart.applyCoupon({ type: 'percent', amount: 20 })
expect(cart.discountedTotal).toBe(80)
})
})
})
// Step 2 (Green): Implement to pass the tests
// src/domain/shoppingCart.ts
export interface CartItem {
id: number
name: string
price: number
quantity: number
}
type Coupon = { type: 'fixed'; amount: number } | { type: 'percent'; amount: number }
export class ShoppingCart {
private _items: CartItem[] = []
private _discountRate = 0
private _coupon?: Coupon
get items(): CartItem[] {
return [...this._items]
}
addItem(item: CartItem): void {
const existing = this._items.find((i) => i.id === item.id)
if (existing) {
existing.quantity += item.quantity
} else {
this._items.push({ ...item })
}
}
removeItem(id: number): void {
const index = this._items.findIndex((i) => i.id === id)
if (index === -1) throw new Error('Item not found')
this._items.splice(index, 1)
}
get total(): number {
return this._items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}
applyDiscount(rate: number): void {
if (rate < 0 || rate > 100) {
throw new Error('Discount must be between 0 and 100')
}
this._discountRate = rate
}
applyCoupon(coupon: Coupon): void {
this._coupon = coupon
}
get discountedTotal(): number {
let total = this.total * (1 - this._discountRate / 100)
if (this._coupon) {
if (this._coupon.type === 'fixed') {
total = Math.max(0, total - this._coupon.amount)
} else {
total = total * (1 - this._coupon.amount / 100)
}
}
return Math.round(total * 100) / 100
}
clear(): void {
this._items = []
this._discountRate = 0
this._coupon = undefined
}
}
9. Quiz
Quiz 1: jest.fn() call verification
Which assertion passes after this code runs?
const fn = jest
.fn()
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
.mockReturnValue('default')
fn('a')
fn('b', 'c')
fn()
Options: (A) expect(fn).toHaveBeenCalledTimes(2) (B) expect(fn.mock.calls[1]).toEqual(['b', 'c']) (C) expect(fn.mock.results[2].value).toBe('first') (D) expect(fn).toHaveBeenLastCalledWith('a')
Answer: (B)
Explanation: fn is called 3 times, so (A) fails. fn.mock.calls[1] is the arguments of the 2nd call ['b', 'c'], so (B) passes. fn.mock.results[2].value is the result of the 3rd call 'default', so (C) fails. The last call was fn() with no arguments, so (D) fails.
Quiz 2: clearAllMocks vs resetAllMocks
Explain the difference between calling jest.clearAllMocks() and jest.resetAllMocks() in the following code.
const mock = jest.fn().mockReturnValue(42)
mock('hello')
// After clearAllMocks or resetAllMocks...
console.log(mock()) // ?
expect(mock).toHaveBeenCalledTimes(???) // ?
Answer: After clearAllMocks: mock() returns 42, CalledTimes is 1. After resetAllMocks: mock() returns undefined, CalledTimes is 1.
Explanation: clearAllMocks only resets call history (mock.calls, mock.results) but keeps the mockReturnValue implementation. resetAllMocks also resets the implementation, so the mock returns undefined. In both cases, since we called mock() once after the reset, CalledTimes is 1.
Quiz 3: MSW request interception
What method should you use in MSW to temporarily return a different response for only one specific test?
Answer: server.use(...handlers)
Explanation: server.use() overrides existing handlers. Calling server.resetHandlers() in afterEach restores the original handlers after that test. This is the core MSW usage pattern.
Quiz 4: Stub vs Mock
Which statement correctly describes the difference between a Stub and a Mock?
(A) A Stub contains real logic while a Mock does not (B) A Stub returns predetermined responses; a Mock also verifies how it was called (C) Stubs are for classes and Mocks are for functions (D) Stub and Mock are synonyms
Answer: (B)
Explanation: A Stub provides the data the code under test needs (predetermined responses). A Mock adds call verification on top (e.g., toHaveBeenCalledWith). Modern test frameworks typically provide both capabilities through the same APIs.
Quiz 5: The TDD cycle
What is the correct principle for the Green phase of Red-Green-Refactor TDD?
(A) Write complete, extensible, production-quality code (B) Write only the minimum code needed to make the test pass (C) Handle all edge cases upfront (D) Finish the design document before writing code
Answer: (B)
Explanation: The Green phase is about "working code". Write the minimum code to pass the tests. Code quality improvements come in the Refactor phase. New features and edge cases come in new Red phases (writing new tests). Over-engineering is the opposite of TDD.
Summary
Testing is not a drag on development speed — it is an investment that speeds up development over the long term. Used alongside TypeScript, type safety and test coverage create powerful synergies.
Key takeaways:
- Unit tests must be fast and isolated — replace external dependencies with Mocks/Stubs
- MSW is the new standard for network request testing — test your real fetch/axios code unchanged
- TDD is a design tool — define the interface (tests) first, implement later
- Understand the difference between
clearAllMocksandresetAllMocks - Integration tests should focus on verifying that multiple modules work correctly together