Testing with Jest - The Heavy Lifter¶
Table of Contents¶
- Installing and Configuring Jest
- describe/it/test Blocks
- expect Matchers : toBe , toEqual , toHaveProperty , toThrow
- Mocks : jestfn , jestspyOn , jestmock
- Coverage Reports : --coverage
- Testing Async Code
Installing and Configuring Jest¶
npm install --save-dev jest
Add to package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
For ESM projects (type: module in package.json):
npm install --save-dev jest @jest/globals
// jest.config.js
export default {
testEnvironment: 'node',
transform: {},
testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'],
setupFilesAfterSetup: ['./test/setup.js'],
}
Jest finds files matching *.test.js or *.spec.js anywhere in the project. Put tests next to the code they test (user.test.js next to user.js) not in a separate tests directory. Proximity matters - when you delete a module you delete its tests too
describe/it/test Blocks¶
describe groups related tests. it or test defines an individual test case. They're aliases - use whichever reads better
const { calculateScore } = require('./scoring')
describe('calculateScore', () => {
it('returns 0 for empty frame', () => {
expect(calculateScore([])).toBe(0)
})
it('computes sum for normal frames', () => {
const rolls = [3, 4, 5, 2, 8, 1]
expect(calculateScore(rolls)).toBe(23)
})
it('handles spare bonus', () => {
// spare = 10 + next roll
const rolls = [5, 5, 3, 0]
expect(calculateScore(rolls)).toBe(16) // 10 + 3 + 3
})
it('handles strike bonus', () => {
// strike = 10 + next 2 rolls
const rolls = [10, 3, 4]
expect(calculateScore(rolls)).toBe(24) // 10 + 3 + 4 + 3 + 4
})
})
Nested describes for organizing complex test suites:
describe('OrderService', () => {
describe('createOrder', () => {
it('creates order with valid items')
it('rejects empty cart')
it('applies discount coupon')
})
describe('cancelOrder', () => {
it('cancels pending order')
it('refuses cancelled order')
})
})
expect Matchers : toBe , toEqual , toHaveProperty , toThrow¶
toBe uses Object.is() - strict equality for primitives. Never use toBe for objects or arrays because they compare references not values
expect(2 + 2).toBe(4)
expect('hello').toBe('hello')
expect(true).toBe(true)
expect(null).toBe(null)
toEqual deep-compares objects and arrays. This is what you want 90% of the time for non-primitives
expect({ a: 1, b: 2 }).toEqual({ a: 1, b: 2 })
expect([1, 2, 3]).toEqual([1, 2, 3])
// partial match
expect({ id: 1, name: 'ali', role: 'admin' }).toMatchObject({ name: 'ali' })
toHaveProperty for nested property access:
const user = { profile: { name: 'khaled', settings: { theme: 'dark' } } }
expect(user).toHaveProperty('profile.settings.theme', 'dark')
toThrow for error testing:
expect(() => JSON.parse('invalid')).toThrow()
expect(() => JSON.parse('invalid')).toThrow(SyntaxError)
expect(() => JSON.parse('invalid')).toThrow('Unexpected token')
// async version
await expect(Promise.reject(new Error('fail'))).rejects.toThrow('fail')
Other matchers you'll use daily:
expect([1, 2, 3]).toContain(2)
expect('hello world').toMatch(/world/)
expect([1, 2, 3]).toHaveLength(3)
expect(() => {}).not.toThrow()
expect(undefined).toBeUndefined()
expect(null).toBeNull()
expect(Promise.resolve(42)).resolves.toBe(42)
Mocks : jestfn , jestspyOn , jestmock¶
jest.fn() creates a standalone mock function:
const sendEmail = jest.fn()
sendEmail('user@example.com', 'Welcome!')
expect(sendEmail).toHaveBeenCalledTimes(1)
expect(sendEmail).toHaveBeenCalledWith('user@example.com', 'Welcome!')
// return a value
sendEmail.mockReturnValue(true)
// resolve a promise
sendEmail.mockResolvedValue({ id: 123 })
// implementation
sendEmail.mockImplementation((to, subject) => {
return `Sent ${subject} to ${to}`
})
jest.spyOn() wraps an existing method. Useful for tracking calls on real objects or temporarily replacing behavior
const db = require('./database')
const spy = jest.spyOn(db, 'findUser')
// mock the return
spy.mockResolvedValue({ id: 1, name: 'mohsen' })
// run your test
const user = await getUser('1')
expect(user.name).toBe('mohsen')
expect(db.findUser).toHaveBeenCalledWith('1')
// cleanup
spy.mockRestore()
jest.mock() auto-mocks entire modules. This is the big one - you mock the database module once and all your service tests use the mock
// __mocks__/database.js (manual mock)
const users = [
{ id: '1', name: 'omar', role: 'user' },
{ id: '2', name: 'ali', role: 'admin' },
]
module.exports = {
findUser: jest.fn((id) => users.find(u => u.id === id)),
findAll: jest.fn(() => users),
createUser: jest.fn((data) => ({ id: '3', ...data })),
}
// user-service.test.js
jest.mock('./database')
const db = require('./database')
const { getUser } = require('./user-service')
test('getUser returns user from database', async () => {
db.findUser.mockResolvedValue({ id: '1', name: 'ali' })
const user = await getUser('1')
expect(user.name).toBe('ali')
})
Coverage Reports : --coverage¶
jest --coverage
Generates a coverage report showing:
% Stmts- lines executed% Branch- if/else branches executed% Funcs- functions called% Lines- code lines executed
Configure thresholds in jest.config.js:
export default {
coverageThreshold: {
global: {
statements: 80,
branches: 70,
functions: 80,
lines: 80,
},
},
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js',
'!src/index.js',
],
}
Coverage is a useful metric but don't worship it. 100% coverage with meaningless tests is worse than 70% coverage with meaningful ones. Test the critical paths , edge cases , and known failure modes - not getters and setters
Testing Async Code¶
Jest handles async natively. Return a promise or use async/await - both work
// async/await style
test('fetches user data', async () => {
const data = await fetchUserData('1')
expect(data).toHaveProperty('id', '1')
})
// promise style
test('fetches user data (promise)', () => {
return fetchUserData('1').then(data => {
expect(data).toHaveProperty('id', '1')
})
})
// resolves matcher
test('fetches user data (resolves)', async () => {
await expect(fetchUserData('1')).resolves.toHaveProperty('id', '1')
})
// rejects matcher for errors
test('throws for invalid id', async () => {
await expect(fetchUserData(null)).rejects.toThrow('Invalid user ID')
})
Always return or await your async assertions. An unreturned promise produces a false positive - the test passes before the assertion runs
// BAD - this test never fails
test('this should fail but doesnt', () => {
expect(Promise.resolve('wrong')).resolves.toBe('correct')
})
// GOOD - await the assertion
test('this actually works', async () => {
await expect(Promise.resolve('correct')).resolves.toBe('correct')
})
prerequisites¶
test_01_testing.md - testing fundamentals , test types , AAA pattern
next -> test_03_mocking.md