Skip to content

Testing with Jest - The Heavy Lifter

Table of Contents


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