TestingJavaScript/TypeScript

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

FeatureJestVitest
SpeedGoodExcellent (ESM native)
ConfigurationMore setupMinimal (uses Vite config)
HMRNoYes (watch mode)
TypeScriptNeeds setupBuilt-in
EcosystemMatureGrowing
Best ForLarge existing projectsNew 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-dom

Configuration

// 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@v3

Vitest

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:

  1. Install Vitest and remove Jest
  2. Update imports: from 'jest'from 'vitest'
  3. Update config: Convert jest.config.js to vitest.config.ts
  4. 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 feedback

UI Mode:

vitest --ui
# Opens a web-based UI for test results

Parallel 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