JavaScript/TypeScript Testing
Modern JavaScript and TypeScript testing with Jest and Vitest - the two most popular testing frameworks in the ecosystem.
Overview
JavaScript testing has evolved significantly. This guide covers both Jest (the established standard) and Vitest (the modern, faster alternative).
Quick Comparison
| Feature | Jest | Vitest |
|---|---|---|
| Speed | Good | Excellent (ESM native) |
| Configuration | More setup | Minimal (uses Vite config) |
| HMR | No | Yes (watch mode) |
| TypeScript | Needs setup | Built-in |
| Ecosystem | Mature | Growing |
| Best For | Large existing projects | New projects, Vite users |
Jest
Jest is a comprehensive testing framework created by Facebook, widely used in the React ecosystem.
Installation and Setup
# Install Jest
npm install --save-dev jest
# For TypeScript
npm install --save-dev @types/jest ts-jest
# For React
npm install --save-dev @testing-library/react @testing-library/jest-domConfiguration
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx',
],
coverageThresholds: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
}// jest.setup.js
import '@testing-library/jest-dom'// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}Writing Tests
// src/utils/math.ts
export function add(a: number, b: number): number {
return a + b
}
export function multiply(a: number, b: number): number {
return a * b
}
export function divide(a: number, b: number): number {
if (b === 0) throw new Error('Division by zero')
return a / b
}// src/utils/math.test.ts
import { add, multiply, divide } from './math'
describe('Math utilities', () => {
describe('add', () => {
it('should add two positive numbers', () => {
expect(add(2, 3)).toBe(5)
})
it('should handle negative numbers', () => {
expect(add(-5, 3)).toBe(-2)
})
it('should handle zero', () => {
expect(add(0, 5)).toBe(5)
})
})
describe('multiply', () => {
it('should multiply two numbers', () => {
expect(multiply(3, 4)).toBe(12)
})
it('should return 0 when multiplying by zero', () => {
expect(multiply(5, 0)).toBe(0)
})
})
describe('divide', () => {
it('should divide two numbers', () => {
expect(divide(10, 2)).toBe(5)
})
it('should throw error on division by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero')
})
it('should handle decimal results', () => {
expect(divide(10, 3)).toBeCloseTo(3.33, 2)
})
})
})Matchers and Assertions
Jest provides a rich set of matchers for different assertion types:
describe('Jest Matchers', () => {
// Equality
test('equality matchers', () => {
expect(2 + 2).toBe(4) // Strict equality (===)
expect({ name: 'John' }).toEqual({ name: 'John' }) // Deep equality
expect({ name: 'John' }).not.toBe({ name: 'John' }) // Different objects
})
// Truthiness
test('truthiness', () => {
expect(true).toBeTruthy()
expect(false).toBeFalsy()
expect(null).toBeNull()
expect(undefined).toBeUndefined()
expect('hello').toBeDefined()
})
// Numbers
test('numbers', () => {
expect(10).toBeGreaterThan(5)
expect(10).toBeGreaterThanOrEqual(10)
expect(5).toBeLessThan(10)
expect(0.1 + 0.2).toBeCloseTo(0.3) // Floating point
})
// Strings
test('strings', () => {
expect('hello world').toMatch(/world/)
expect('testing').toHaveLength(7)
expect('hello').toContain('ell')
})
// Arrays and Iterables
test('arrays', () => {
const list = ['apple', 'banana', 'cherry']
expect(list).toHaveLength(3)
expect(list).toContain('banana')
expect(list).toEqual(expect.arrayContaining(['apple', 'cherry']))
})
// Objects
test('objects', () => {
const user = { name: 'John', age: 30, email: 'john@example.com' }
expect(user).toHaveProperty('name')
expect(user).toHaveProperty('age', 30)
expect(user).toMatchObject({ name: 'John' })
})
// Exceptions
test('exceptions', () => {
const throwError = () => {
throw new Error('Something failed')
}
expect(throwError).toThrow()
expect(throwError).toThrow('Something failed')
expect(throwError).toThrow(Error)
})
// Async
test('async operations', async () => {
const data = await fetchData()
expect(data).toBeDefined()
await expect(fetchData()).resolves.toBeDefined()
await expect(fetchDataThatFails()).rejects.toThrow()
})
})Mocking Modules and Functions
// api.ts
export async function fetchUser(id: number) {
const response = await fetch(`/api/users/${id}`)
return response.json()
}
export function getConfig() {
return process.env.API_URL
}// user.service.test.ts
import { fetchUser } from './api'
// Mock the entire module
jest.mock('./api')
describe('UserService', () => {
test('should fetch user data', async () => {
// Type the mocked function
const mockFetchUser = fetchUser as jest.MockedFunction<typeof fetchUser>
// Set up mock return value
mockFetchUser.mockResolvedValue({
id: 1,
name: 'John Doe',
})
const user = await fetchUser(1)
expect(user).toEqual({ id: 1, name: 'John Doe' })
expect(mockFetchUser).toHaveBeenCalledWith(1)
})
})Partial mocking:
jest.mock('./api', () => ({
...jest.requireActual('./api'),
fetchUser: jest.fn(),
}))Mock functions:
describe('Mock functions', () => {
test('should track calls', () => {
const mockFn = jest.fn()
mockFn('arg1', 'arg2')
mockFn('arg3')
expect(mockFn).toHaveBeenCalledTimes(2)
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2')
expect(mockFn).toHaveBeenLastCalledWith('arg3')
})
test('should provide return values', () => {
const mockFn = jest.fn()
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
.mockReturnValue('default')
expect(mockFn()).toBe('first')
expect(mockFn()).toBe('second')
expect(mockFn()).toBe('default')
expect(mockFn()).toBe('default')
})
test('should mock implementation', () => {
const mockFn = jest.fn((x: number) => x * 2)
expect(mockFn(5)).toBe(10)
})
})Snapshot Testing
// Button.tsx
export function Button({ text, variant = 'primary' }: ButtonProps) {
return <button className={`btn btn-${variant}`}>{text}</button>
}// Button.test.tsx
import { render } from '@testing-library/react'
import { Button } from './Button'
test('Button matches snapshot', () => {
const { container } = render(<Button text="Click me" />)
expect(container.firstChild).toMatchSnapshot()
})
test('Button with secondary variant', () => {
const { container } = render(<Button text="Click me" variant="secondary" />)
expect(container.firstChild).toMatchSnapshot()
})Snapshots should be committed to version control. Review snapshot changes carefully – they should only change when you intend to update the UI.
Inline snapshots:
test('inline snapshot', () => {
const user = { name: 'John', age: 30 }
expect(user).toMatchInlineSnapshot(`
{
"age": 30,
"name": "John",
}
`)
})Code Coverage
# Run tests with coverage
jest --coverage
# Coverage report output:
# ----------------------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# ----------------------|---------|----------|---------|---------|
# All files | 85.71 | 83.33 | 88.89 | 85.71 |
# utils/math.ts | 100 | 100 | 100 | 100 |
# components/Button.tsx| 66.67 | 50 | 66.67 | 66.67 |
# ----------------------|---------|----------|---------|---------|Coverage thresholds in jest.config.js:
module.exports = {
coverageThresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
'./src/utils/': {
branches: 90,
functions: 90,
lines: 90,
statements: 90,
},
},
}Running Tests in CI/CD
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- run: npm test -- --coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3Vitest
Vitest is a blazing-fast testing framework powered by Vite, with a Jest-compatible API.
Why Vitest?
✅ Lightning Fast: Native ESM, instant HMR in watch mode
✅ Vite Integration: Uses your existing Vite config
✅ TypeScript: Built-in TypeScript support, no configuration
✅ Jest Compatible: Easy migration from Jest
✅ Modern: Built for modern web development
Setup and Configuration
# Install Vitest
npm install --save-dev vitest
# For React
npm install --save-dev @testing-library/react @testing-library/user-event jsdom// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
],
},
},
})// src/test/setup.ts
import { expect, afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
afterEach(() => {
cleanup()
})// package.json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}Writing Tests with Vitest
The API is nearly identical to Jest:
// counter.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { Counter } from './counter'
describe('Counter', () => {
let counter: Counter
beforeEach(() => {
counter = new Counter()
})
it('should start at zero', () => {
expect(counter.value).toBe(0)
})
it('should increment', () => {
counter.increment()
expect(counter.value).toBe(1)
})
it('should decrement', () => {
counter.decrement()
expect(counter.value).toBe(-1)
})
it('should reset', () => {
counter.increment()
counter.increment()
counter.reset()
expect(counter.value).toBe(0)
})
})Migration from Jest
Vitest is designed to be compatible with Jest, making migration straightforward:
- Install Vitest and remove Jest
- Update imports:
from 'jest'→from 'vitest' - Update config: Convert jest.config.js to vitest.config.ts
- Run tests: Most tests should work without changes
Key differences:
// Jest
import { jest } from '@jest/globals'
const mockFn = jest.fn()
// Vitest
import { vi } from 'vitest'
const mockFn = vi.fn()Auto-migration helper:
# Use globals option in vitest.config.ts
export default defineConfig({
test: {
globals: true, // No need to import describe, it, expect
},
})Vitest Features
Watch mode with HMR:
vitest
# Automatically reruns tests on file changes with instant feedbackUI Mode:
vitest --ui
# Opens a web-based UI for test resultsParallel execution:
// Tests run in parallel by default
// Use concurrent for test-level parallelism
describe.concurrent('parallel suite', () => {
it.concurrent('test 1', async () => {
// Runs in parallel with test 2
})
it.concurrent('test 2', async () => {
// Runs in parallel with test 1
})
})Vite Integration
Vitest uses your Vite configuration automatically:
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': '/src',
},
},
// This config is automatically used by Vitest
})Best Practices
Test Organization
src/
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx
│ │ └── index.ts
│ └── Card/
│ ├── Card.tsx
│ ├── Card.test.tsx
│ └── index.ts
├── utils/
│ ├── math.ts
│ └── math.test.ts
└── test/
├── setup.ts
├── helpers.ts
└── mocks/Naming Conventions
// ✅ Good: Descriptive test names
describe('UserService', () => {
it('should create user with valid data', () => {})
it('should throw error when email is invalid', () => {})
it('should return null when user not found', () => {})
})
// ❌ Bad: Vague test names
describe('UserService', () => {
it('works', () => {})
it('test 2', () => {})
it('error case', () => {})
})Test Structure
describe('Feature', () => {
// Setup
beforeEach(() => {
// Reset state
})
afterEach(() => {
// Cleanup
})
describe('specific scenario', () => {
it('should do something specific', () => {
// Arrange
const input = 'test'
// Act
const result = doSomething(input)
// Assert
expect(result).toBe('expected')
})
})
})Async Testing
// ✅ Good: Use async/await
test('fetches data', async () => {
const data = await fetchData()
expect(data).toBeDefined()
})
// ✅ Good: Use resolves/rejects
test('fetches data', () => {
return expect(fetchData()).resolves.toBeDefined()
})
// ❌ Bad: Missing return or await
test('fetches data', () => {
fetchData().then(data => {
expect(data).toBeDefined()
})
// Test completes before assertion runs!
})Choose Jest for established projects with existing Jest infrastructure. Choose Vitest for new projects, especially those using Vite, for faster testing experience.
Next Steps
- Learn React Testing with Testing Library
- Explore Testing Best Practices
- Set up CI/CD Integration for automated testing