Skip to content

JavaScript Testing

Modern JavaScript testing with Vitest, Jest, and Testing Library.

Installation

npm install -D vitest

Configuration

// vitest.config.js
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,
    environment: 'node', // or 'jsdom' for browser
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
    },
  },
})

Running Tests

# Run tests
npx vitest

# Watch mode
npx vitest --watch

# Run once
npx vitest run

# With coverage
npx vitest --coverage

# Specific file
npx vitest src/utils.test.js

Basic Tests

// math.test.js
import { describe, it, expect } from 'vitest'
import { add, multiply } from './math'

describe('math functions', () => {
  it('adds two numbers', () => {
    expect(add(2, 3)).toBe(5)
  })

  it('multiplies two numbers', () => {
    expect(multiply(2, 3)).toBe(6)
  })
})

Async Tests

import { describe, it, expect } from 'vitest'

describe('async operations', () => {
  it('fetches data', async () => {
    const data = await fetchData()
    expect(data).toHaveProperty('id')
  })

  it('handles promise rejection', async () => {
    await expect(failingOperation()).rejects.toThrow('Error')
  })
})

Mocking

import { describe, it, expect, vi } from 'vitest'

// Mock a module
vi.mock('./api', () => ({
  fetchUser: vi.fn(() => Promise.resolve({ id: 1, name: 'Test' }))
}))

describe('user service', () => {
  it('fetches user', async () => {
    const user = await getUser(1)
    expect(user.name).toBe('Test')
  })
})

// Mock functions
describe('callbacks', () => {
  it('calls callback with result', () => {
    const callback = vi.fn()
    processData('input', callback)
    expect(callback).toHaveBeenCalledWith('processed')
  })
})

// Spy on methods
describe('spy', () => {
  it('tracks method calls', () => {
    const obj = { method: () => 'original' }
    const spy = vi.spyOn(obj, 'method')

    obj.method()

    expect(spy).toHaveBeenCalled()
  })
})

Timers

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

describe('timers', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  it('delays execution', () => {
    const callback = vi.fn()
    setTimeout(callback, 1000)

    expect(callback).not.toHaveBeenCalled()

    vi.advanceTimersByTime(1000)

    expect(callback).toHaveBeenCalled()
  })
})

Jest

Installation

npm install -D jest

Configuration

// jest.config.js
module.exports = {
  testEnvironment: 'node',
  collectCoverageFrom: ['src/**/*.js'],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
    },
  },
}

Basic Tests

// math.test.js
const { add, multiply } = require('./math')

describe('math functions', () => {
  test('adds two numbers', () => {
    expect(add(2, 3)).toBe(5)
  })

  test('multiplies two numbers', () => {
    expect(multiply(2, 3)).toBe(6)
  })
})

Jest Mocking

// Mock entire module
jest.mock('./api')

// Mock implementation
const api = require('./api')
api.fetchData.mockResolvedValue({ data: 'test' })

// Mock return value
jest.fn().mockReturnValue('mocked')
jest.fn().mockResolvedValue('async mocked')

Testing Library

For testing React, Vue, and DOM interactions.

Installation

# React
npm install -D @testing-library/react @testing-library/jest-dom

# Vue
npm install -D @testing-library/vue

# DOM
npm install -D @testing-library/dom

React Testing

import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'

describe('Button', () => {
  it('renders with text', () => {
    render(<Button>Click me</Button>)
    expect(screen.getByText('Click me')).toBeInTheDocument()
  })

  it('calls onClick when clicked', async () => {
    const handleClick = vi.fn()
    render(<Button onClick={handleClick}>Click me</Button>)

    await userEvent.click(screen.getByText('Click me'))

    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  it('shows loading state', () => {
    render(<Button loading>Submit</Button>)
    expect(screen.getByRole('button')).toBeDisabled()
    expect(screen.getByText('Loading...')).toBeInTheDocument()
  })
})

Queries

// By role (preferred)
screen.getByRole('button')
screen.getByRole('heading', { level: 1 })
screen.getByRole('textbox', { name: 'Email' })

// By text
screen.getByText('Hello')
screen.getByText(/hello/i)

// By label
screen.getByLabelText('Email')

// By placeholder
screen.getByPlaceholderText('Enter email')

// By test id
screen.getByTestId('submit-button')

// Query variants
screen.queryByText('Optional')  // Returns null if not found
screen.findByText('Async')      // Returns promise, waits for element
screen.getAllByRole('listitem') // Returns array

Async Testing

import { render, screen, waitFor } from '@testing-library/react'

describe('async component', () => {
  it('loads data', async () => {
    render(<UserProfile userId={1} />)

    // Wait for element to appear
    expect(await screen.findByText('John Doe')).toBeInTheDocument()
  })

  it('shows loading then content', async () => {
    render(<DataFetcher />)

    expect(screen.getByText('Loading...')).toBeInTheDocument()

    await waitFor(() => {
      expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
    })

    expect(screen.getByText('Data loaded')).toBeInTheDocument()
  })
})

Matchers

Common Matchers

// Equality
expect(value).toBe(expected)
expect(value).toEqual(expected)
expect(value).toStrictEqual(expected)

// Truthiness
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeUndefined()
expect(value).toBeDefined()

// Numbers
expect(value).toBeGreaterThan(3)
expect(value).toBeLessThanOrEqual(10)
expect(value).toBeCloseTo(0.3, 5)

// Strings
expect(value).toMatch(/pattern/)
expect(value).toContain('substring')

// Arrays
expect(array).toContain(item)
expect(array).toHaveLength(3)

// Objects
expect(obj).toHaveProperty('key')
expect(obj).toMatchObject({ key: 'value' })

// Errors
expect(() => fn()).toThrow()
expect(() => fn()).toThrow('error message')
expect(() => fn()).toThrow(ErrorClass)

Jest DOM Matchers

import '@testing-library/jest-dom'

expect(element).toBeInTheDocument()
expect(element).toBeVisible()
expect(element).toBeEnabled()
expect(element).toBeDisabled()
expect(element).toHaveClass('active')
expect(element).toHaveAttribute('href', '/path')
expect(element).toHaveTextContent('Hello')
expect(element).toHaveValue('input value')
expect(element).toBeChecked()
expect(element).toHaveFocus()

Test Organization

Describe Blocks

describe('Calculator', () => {
  describe('addition', () => {
    it('adds positive numbers', () => {})
    it('adds negative numbers', () => {})
  })

  describe('subtraction', () => {
    it('subtracts numbers', () => {})
  })
})

Setup and Teardown

describe('database tests', () => {
  beforeAll(async () => {
    await db.connect()
  })

  afterAll(async () => {
    await db.disconnect()
  })

  beforeEach(async () => {
    await db.clear()
  })

  afterEach(() => {
    vi.clearAllMocks()
  })

  it('creates record', async () => {})
})

Snapshot Testing

describe('Component', () => {
  it('matches snapshot', () => {
    const { container } = render(<MyComponent />)
    expect(container).toMatchSnapshot()
  })

  it('matches inline snapshot', () => {
    expect(formatDate(new Date('2024-01-01'))).toMatchInlineSnapshot(`"January 1, 2024"`)
  })
})

Update snapshots:

npx vitest -u

Coverage Configuration

// vitest.config.js
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: [
        'node_modules/',
        'tests/',
        '**/*.d.ts',
        '**/*.config.*',
      ],
      thresholds: {
        lines: 80,
        branches: 80,
        functions: 80,
        statements: 80,
      },
    },
  },
})

Package.json Scripts

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest --coverage",
    "test:ui": "vitest --ui"
  }
}

See Also