测试是保证代码质量的重要手段。掌握测试框架,能够帮助你编写可靠的代码,减少 bug,提高开发效率。
| 类型 | 说明 | 工具示例 |
|---|---|---|
| 单元测试 | 测试最小可测试单元 | Jest、Mocha |
| 集成测试 | 测试模块间的交互 | Jest、Testing Library |
| 端到端测试 | 模拟用户真实操作 | Cypress、Playwright |
| 快照测试 | 对比 UI 变化 | Jest |
| 性能测试 | 测试性能指标 | Lighthouse |
| 安全测试 | 发现安全漏洞 | OWASP ZAP |
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)
})
})
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 函数
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('实现')
})
// 模拟 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 是一套测试 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')
})
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()
})
})
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()
})
// 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')
})
})
// 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)—— 让测试更简单