测试框架

测试是保证代码质量的重要手段。掌握测试框架,能够帮助你编写可靠的代码,减少 bug,提高开发效率。

测试类型

类型 说明 工具示例
单元测试 测试最小可测试单元 Jest、Mocha
集成测试 测试模块间的交互 Jest、Testing Library
端到端测试 模拟用户真实操作 Cypress、Playwright
快照测试 对比 UI 变化 Jest
性能测试 测试性能指标 Lighthouse
安全测试 发现安全漏洞 OWASP ZAP

Jest 入门

Jest 是 Facebook 开发的 JavaScript 测试框架,功能全面、配置简单。

安装配置

# 安装 Jest
npm install -D jest

# 初始化配置
npx jest --init
// jest.config.js
module.exports = {
  // 测试环境
  testEnvironment: 'jsdom',
  
  // 根目录
  roots: ['<rootDir>/src'],
  
  // 测试文件匹配
  testMatch: [
    '**/__tests__/**/*.js',
    '**/*.test.js',
    '**/*.spec.js'
  ],
  
  // 覆盖率配置
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coveragePathIgnorePatterns: ['/node_modules/'],
  
  // 模块路径映射
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1'
  },
  
  // 设置文件
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  
  // 超时时间
  testTimeout: 5000
}

基本语法

// math.js
function add(a, b) {
  return a + b
}

function subtract(a, b) {
  return a - b
}

module.exports = { add, subtract }

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

describe('数学运算', () => {
  test('加法运算', () => {
    expect(add(1, 2)).toBe(3)
    expect(add(-1, 1)).toBe(0)
    expect(add(0.1, 0.2)).toBeCloseTo(0.3)
  })
  
  test('减法运算', () => {
    expect(subtract(5, 3)).toBe(2)
    expect(subtract(1, 1)).toBe(0)
  })
  
  // 别名
  it('应该返回正确结果', () => {
    expect(add(1, 2)).toBe(3)
  })
})

匹配器(Matchers)

describe('Jest 匹配器', () => {
  // 相等性
  test('toBe - 严格相等', () => {
    expect(1 + 1).toBe(2)
    expect('东巴文').toBe('东巴文')
  })
  
  test('toEqual - 深度相等', () => {
    expect({ name: '东巴文' }).toEqual({ name: '东巴文' })
    expect([1, 2, 3]).toEqual([1, 2, 3])
  })
  
  // 真值判断
  test('真值判断', () => {
    expect(true).toBeTruthy()
    expect(false).toBeFalsy()
    expect(null).toBeNull()
    expect(undefined).toBeUndefined()
    expect(1).toBeDefined()
  })
  
  // 数字比较
  test('数字比较', () => {
    expect(10).toBeGreaterThan(5)
    expect(10).toBeGreaterThanOrEqual(10)
    expect(5).toBeLessThan(10)
    expect(5).toBeLessThanOrEqual(5)
    expect(0.1 + 0.2).toBeCloseTo(0.3)
  })
  
  // 字符串匹配
  test('字符串匹配', () => {
    expect('东巴文教程').toMatch(/东巴文/)
    expect('hello world').toContain('world')
  })
  
  // 数组匹配
  test('数组匹配', () => {
    expect([1, 2, 3]).toContain(2)
    expect([1, 2, 3]).toHaveLength(3)
    expect([{ id: 1 }]).toContainEqual({ id: 1 })
  })
  
  // 对象匹配
  test('对象匹配', () => {
    expect({ name: '东巴文', age: 18 }).toMatchObject({ name: '东巴文' })
    expect({ name: '东巴文' }).toHaveProperty('name')
    expect({ name: '东巴文' }).toHaveProperty('name', '东巴文')
  })
  
  // 异常
  test('异常匹配', () => {
    const throwError = () => {
      throw new Error('东巴文错误')
    }
    
    expect(throwError).toThrow()
    expect(throwError).toThrow('东巴文错误')
    expect(throwError).toThrow(Error)
  })
  
  // 异步匹配
  test('异步匹配', async () => {
    const fetchData = () => Promise.resolve('东巴文')
    
    await expect(fetchData()).resolves.toBe('东巴文')
    
    const rejectPromise = () => Promise.reject(new Error('失败'))
    await expect(rejectPromise()).rejects.toThrow('失败')
  })
  
  // 取反
  test('取反匹配', () => {
    expect(1).not.toBe(2)
    expect([1, 2, 3]).not.toContain(4)
  })
})

异步测试

// 回调函数
test('回调函数测试', (done) => {
  function callback(data) {
    expect(data).toBe('东巴文')
    done()
  }
  
  fetchData(callback)
})

// Promise
test('Promise 测试', () => {
  return fetchData().then(data => {
    expect(data).toBe('东巴文')
  })
})

// async/await
test('async/await 测试', async () => {
  const data = await fetchData()
  expect(data).toBe('东巴文')
})

// resolves/rejects
test('resolves 测试', () => {
  return expect(fetchData()).resolves.toBe('东巴文')
})

test('rejects 测试', () => {
  return expect(fetchError()).rejects.toThrow('错误')
})

钩子函数

describe('钩子函数', () => {
  let data
  
  // 所有测试前执行一次
  beforeAll(() => {
    console.log('beforeAll')
  })
  
  // 所有测试后执行一次
  afterAll(() => {
    console.log('afterAll')
  })
  
  // 每个测试前执行
  beforeEach(() => {
    data = { name: '东巴文' }
    console.log('beforeEach')
  })
  
  // 每个测试后执行
  afterEach(() => {
    data = null
    console.log('afterEach')
  })
  
  test('测试 1', () => {
    expect(data.name).toBe('东巴文')
  })
  
  test('测试 2', () => {
    expect(data).toHaveProperty('name')
  })
})

Mock 函数

// 创建 Mock 函数
test('Mock 函数', () => {
  const mockFn = jest.fn()
  
  // 调用
  mockFn('东巴文')
  mockFn('测试')
  
  // 断言调用
  expect(mockFn).toHaveBeenCalled()
  expect(mockFn).toHaveBeenCalledTimes(2)
  expect(mockFn).toHaveBeenCalledWith('东巴文')
  expect(mockFn).toHaveBeenLastCalledWith('测试')
})

// Mock 返回值
test('Mock 返回值', () => {
  const mockFn = jest.fn()
  
  mockFn.mockReturnValue('默认值')
  expect(mockFn()).toBe('默认值')
  
  mockFn.mockReturnValueOnce('一次性返回')
  expect(mockFn()).toBe('一次性返回')
  expect(mockFn()).toBe('默认值')
})

// Mock 实现
test('Mock 实现', () => {
  const mockFn = jest.fn((x) => x * 2)
  
  expect(mockFn(5)).toBe(10)
})

// Mock 异步
test('Mock 异步', async () => {
  const mockFn = jest.fn()
  
  mockFn.mockResolvedValue('异步数据')
  await expect(mockFn()).resolves.toBe('异步数据')
  
  mockFn.mockImplementation(() => Promise.resolve('实现'))
  await expect(mockFn()).resolves.toBe('实现')
})

Mock 模块

// 模拟 axios
jest.mock('axios')

const axios = require('axios')

test('Mock axios', async () => {
  axios.get.mockResolvedValue({ data: { name: '东巴文' } })
  
  const result = await axios.get('/api/user')
  expect(result.data.name).toBe('东巴文')
})

// 模拟部分模块
jest.mock('./utils', () => ({
  ...jest.requireActual('./utils'),
  formatDate: jest.fn(() => '2024-01-01')
}))

// 模拟定时器
test('Mock 定时器', () => {
  jest.useFakeTimers()
  
  const callback = jest.fn()
  setTimeout(callback, 1000)
  
  jest.advanceTimersByTime(1000)
  expect(callback).toHaveBeenCalled()
  
  jest.useRealTimers()
})

快照测试

// 组件快照
test('组件快照', () => {
  const component = render(<Button>东巴文</Button>)
  expect(component).toMatchSnapshot()
})

// 内联快照
test('内联快照', () => {
  const data = { name: '东巴文' }
  expect(data).toMatchInlineSnapshot({
    name: '东巴文'
  })
})

// 属性匹配器
test('动态属性快照', () => {
  const data = {
    id: expect.any(Number),
    name: '东巴文',
    createdAt: expect.any(Date)
  }
  expect(data).toMatchSnapshot()
})

// 更新快照
// npx jest -u

测试覆盖率

// jest.config.js
module.exports = {
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coverageReporters: ['text', 'lcov', 'html'],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
}
# 运行覆盖率
npm test -- --coverage

# 查看报告
open coverage/lcov-report/index.html

Testing Library

Testing Library 是一套测试 DOM 的工具,专注于用户行为而非实现细节。

DOM 测试

const { render, screen, fireEvent, waitFor } = require('@testing-library/dom')

test('DOM 测试', () => {
  // 渲染
  document.body.innerHTML = `
    <div>
      <h1>东巴文</h1>
      <button id="btn">点击</button>
    </div>
  `
  
  // 查询元素
  expect(screen.getByText('东巴文')).toBeInTheDocument()
  expect(screen.getByRole('heading')).toHaveTextContent('东巴文')
  expect(screen.getByRole('button')).toBeInTheDocument()
  
  // 查询方法
  screen.getByText('东巴文')      // 精确文本
  screen.getByRole('button')     // ARIA 角色
  screen.getByLabelText('用户名') // 标签关联
  screen.getByPlaceholderText('请输入') // 占位符
  screen.getByTestId('custom-id') // data-testid
  
  // 模糊查询
  screen.getByText(/东巴/)
  
  // 查询变体
  screen.getAllByRole('button')   // 返回数组
  screen.queryByText('不存在')     // 不抛异常
  screen.findByText('异步内容')    // 返回 Promise
})

用户交互

const { render, screen, fireEvent, waitFor } = require('@testing-library/dom')
const userEvent = require('@testing-library/user-event').default

test('点击事件', async () => {
  const user = userEvent.setup()
  
  document.body.innerHTML = `
    <button id="btn">点击</button>
    <span id="result"></span>
  `
  
  const btn = document.getElementById('btn')
  const result = document.getElementById('result')
  
  btn.addEventListener('click', () => {
    result.textContent = '已点击'
  })
  
  await user.click(btn)
  expect(result).toHaveTextContent('已点击')
})

test('表单输入', async () => {
  const user = userEvent.setup()
  
  document.body.innerHTML = `
    <input type="text" id="name" />
    <input type="checkbox" id="agree" />
    <select id="city">
      <option value="">请选择</option>
      <option value="beijing">北京</option>
      <option value="shanghai">上海</option>
    </select>
  `
  
  const nameInput = document.getElementById('name')
  const agreeCheckbox = document.getElementById('agree')
  const citySelect = document.getElementById('city')
  
  // 文本输入
  await user.type(nameInput, '东巴文')
  expect(nameInput).toHaveValue('东巴文')
  
  // 复选框
  await user.click(agreeCheckbox)
  expect(agreeCheckbox).toBeChecked()
  
  // 下拉选择
  await user.selectOptions(citySelect, 'beijing')
  expect(citySelect).toHaveValue('beijing')
})

React 测试

const { render, screen, fireEvent, waitFor } = require('@testing-library/react')
const userEvent = require('@testing-library/user-event').default
const { Counter } = require('./Counter')

test('Counter 组件', async () => {
  const user = userEvent.setup()
  
  render(<Counter initialCount={0} />)
  
  // 初始状态
  expect(screen.getByText('0')).toBeInTheDocument()
  
  // 点击增加
  await user.click(screen.getByRole('button', { name: '+' }))
  expect(screen.getByText('1')).toBeInTheDocument()
  
  // 点击减少
  await user.click(screen.getByRole('button', { name: '-' }))
  expect(screen.getByText('0')).toBeInTheDocument()
})

test('异步组件', async () => {
  render(<UserProfile userId={1} />)
  
  // 加载状态
  expect(screen.getByText('加载中...')).toBeInTheDocument()
  
  // 等待数据加载
  await waitFor(() => {
    expect(screen.getByText('东巴文')).toBeInTheDocument()
  })
})

Vue 测试

const { mount } = require('@vue/test-utils')
const Counter = require('./Counter.vue')

test('Counter 组件', async () => {
  const wrapper = mount(Counter, {
    props: {
      initialCount: 0
    }
  })
  
  // 初始状态
  expect(wrapper.text()).toContain('0')
  
  // 点击增加
  await wrapper.find('button.increment').trigger('click')
  expect(wrapper.text()).toContain('1')
  
  // 触发事件
  await wrapper.find('button.reset').trigger('click')
  expect(wrapper.emitted('reset')).toBeTruthy()
})

test('Vue 组件快照', () => {
  const wrapper = mount(Button, {
    slots: {
      default: '东巴文按钮'
    }
  })
  
  expect(wrapper.html()).toMatchSnapshot()
})

E2E 测试

Cypress

// cypress/e2e/login.cy.js
describe('登录流程', () => {
  beforeEach(() => {
    cy.visit('/login')
  })
  
  it('应该显示登录表单', () => {
    cy.get('input[name="username"]').should('be.visible')
    cy.get('input[name="password"]').should('be.visible')
    cy.get('button[type="submit"]').should('be.visible')
  })
  
  it('应该成功登录', () => {
    cy.get('input[name="username"]').type('dongba')
    cy.get('input[name="password"]').type('password123')
    cy.get('button[type="submit"]').click()
    
    cy.url().should('include', '/dashboard')
    cy.contains('欢迎回来').should('be.visible')
  })
  
  it('应该显示错误信息', () => {
    cy.get('input[name="username"]').type('wrong')
    cy.get('input[name="password"]').type('wrong')
    cy.get('button[type="submit"]').click()
    
    cy.contains('用户名或密码错误').should('be.visible')
  })
})

Playwright

// tests/login.spec.js
const { test, expect } = require('@playwright/test')

test.describe('登录流程', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login')
  })
  
  test('应该成功登录', async ({ page }) => {
    await page.fill('input[name="username"]', 'dongba')
    await page.fill('input[name="password"]', 'password123')
    await page.click('button[type="submit"]')
    
    await expect(page).toHaveURL(/dashboard/)
    await expect(page.locator('.welcome')).toContainText('欢迎')
  })
  
  test('应该显示验证错误', async ({ page }) => {
    await page.click('button[type="submit"]')
    
    await expect(page.locator('.error')).toBeVisible()
  })
})

测试最佳实践

// 1. 测试行为而非实现
test('用户可以提交表单', async () => {
  render(<Form />)
  
  await userEvent.type(screen.getByLabelText('邮箱'), 'test@example.com')
  await userEvent.click(screen.getByRole('button', { name: '提交' }))
  
  expect(screen.getByText('提交成功')).toBeInTheDocument()
})

// 2. 使用描述性的测试名称
test('当用户输入无效邮箱时,应该显示错误提示', async () => {
  // ...
})

// 3. 一个测试只验证一个行为
test('表单验证 - 邮箱格式', async () => {
  // 只测试邮箱验证
})

test('表单验证 - 必填字段', async () => {
  // 只测试必填验证
})

// 4. 使用 beforeEach 重置状态
describe('购物车', () => {
  let cart
  
  beforeEach(() => {
    cart = new Cart()
  })
  
  test('添加商品', () => {
    cart.add({ id: 1, name: '东巴文' })
    expect(cart.items).toHaveLength(1)
  })
})

// 5. 避免测试中的逻辑
test('避免这样写', () => {
  const items = [1, 2, 3]
  items.forEach(item => {
    expect(item).toBeGreaterThan(0)
  })
})

test('应该这样写', () => {
  expect([1, 2, 3].every(item => item > 0)).toBe(true)
})

下一步

掌握测试框架后,你可以继续学习:


东巴文(db-w.cn)—— 让测试更简单