测试与代码质量实战指南:从Linter到E2E测试
前言:测试不是负担,是安心
改代码时心里没底、发版时整夜不睡、Bug 修复后又引出新 Bug——这些都是没有好测试体系的表现。
测试的核心价值:
- 快速反馈:几秒内知道改动有没有破坏什么
- 重构信心:有测试托底才敢大改
- 文档作用:测试用例就是活的文档
- 设计引导:难测试的代码通常设计有问题
一、代码质量的第一道防线:Linter
1.1 ESLint 配置
// .eslintrc.js
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'prettier', // 关闭与 Prettier 冲突的规则
],
parser: '@typescript-eslint/parser',
plugins: ['react', '@typescript-eslint'],
rules: {
'no-console': 'warn',
'no-unused-vars': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
},
};
1.2 Prettier 格式化
// .prettierrc.js
module.exports = {
semi: true,
trailingComma: 'es5',
singleQuote: true,
printWidth: 100,
tabWidth: 2,
};
1.3 Go 的 golangci-lint
# .golangci.yml
linters:
disable-all: true
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gofmt
- goimports
- misspell
1.4 Husky + lint-staged
{
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write",
"jest --bail --findRelatedTests"
]
}
}
提交时自动修复 + 格式化 + 跑相关测试。
二、测试金字塔
推荐比例: 70% 单元测试 + 20% 集成测试 + 10% E2E 测试
| 测试类型 | 速度 | 成本 | 覆盖率 | 反馈 |
|---|---|---|---|---|
| 单元测试 | 快 | 低 | 高 | 即时 |
| 集成测试 | 中 | 中 | 中 | 分钟级 |
| E2E 测试 | 慢 | 高 | 低 | 几十分钟 |
三、TDD:测试驱动开发
3.1 Red-Green-Refactor 循环
// 1. Red:写一个失败的测试
test('empty cart should throw on checkout', () => {
expect(() => checkout([])).toThrow('Cart is empty');
});
// 2. Green:让测试通过(最简实现)
function checkout(items) {
if (items.length === 0) throw new Error('Cart is empty');
// ...
}
// 3. Refactor:重构,保持测试通过
3.2 实际案例:用户注册
// Red 1:基础功能
test('should create user with valid data', () => {
const user = userService.register({
name: 'Alice',
email: '[email protected]',
password: 'secure123'
});
expect(user.id).toBeDefined();
});
// Green 1
class UserService {
register(data) {
return { id: Date.now(), ...data };
}
}
// Red 2:邮箱验证
test('should throw for invalid email', () => {
expect(() => userService.register({
name: 'Alice',
email: 'invalid',
password: 'secure123'
})).toThrow('Invalid email');
});
// Green 2:加验证逻辑
class UserService {
register(data) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
throw new Error('Invalid email');
}
return { id: Date.now(), ...data };
}
}
3.3 ATDD:验收测试驱动开发
Feature: 用户注册
Scenario: 有效数据注册成功
Given 准备有效的注册数据
When 用户提交注册
Then 用户创建成功
Scenario: 无效邮箱注册失败
Given 邮箱格式不正确
When 用户提交注册
Then 提示"邮箱格式错误"
Given('a user with valid registration data', function () {
this.userData = { name: 'Alice', email: '[email protected]', password: 'secure12345' };
});
When('the user attempts to register', function () {
this.result = userService.register(this.userData);
});
Then('the user should be created successfully', function () {
expect(this.result.id).toBeDefined();
});
四、单元测试
4.1 测试行为,不是测试实现
// utils/discount.ts
export function calculateDiscount(price: number, rule: { type: 'percentage' | 'fixed', value: number }) {
if (price <= 0) throw new Error('Price must be positive');
if (rule.type === 'percentage') {
if (rule.value < 0 || rule.value > 100) throw new Error('Invalid percentage');
return price * (1 - rule.value / 100);
} else {
return Math.max(0, price - rule.value);
}
}
// 好的测试:覆盖正常和异常路径
describe('calculateDiscount', () => {
describe('正常情况', () => {
it('百分比折扣', () => {
expect(calculateDiscount(100, { type: 'percentage', value: 20 })).toBe(80);
});
it('固定金额折扣', () => {
expect(calculateDiscount(100, { type: 'fixed', value: 30 })).toBe(70);
});
it('折扣不超过原价', () => {
expect(calculateDiscount(50, { type: 'fixed', value: 60 })).toBe(0);
});
});
describe('异常情况', () => {
it('负价格抛错', () => {
expect(() => calculateDiscount(-10, { type: 'percentage', value: 20 }))
.toThrow('Price must be positive');
});
});
});
4.2 测试替身(Test Doubles)
| 类型 | 用途 | 示例 |
|---|---|---|
| Mock | 模拟依赖行为 | 模拟数据库 |
| Stub | 提供预设返回值 | 模拟 API |
| Spy | 记录调用 | 验证函数被调用 |
| Fake | 简化实现 | 内存数据库 |
// Mock 数据库
const mockDb = {
query: jest.fn().mockResolvedValue([{ id: 1, name: 'Alice' }])
};
const userService = new UserService(mockDb);
const user = await userService.getUser(1);
expect(user).toEqual({ id: 1, name: 'Alice' });
expect(mockDb.query).toHaveBeenCalledWith('SELECT * FROM users WHERE id = ?', [1]);
4.3 Jest vs Vitest
// Vitest 更快,API 类似 Jest
import { vi } from 'vitest';
vi.mock('../api', () => ({
fetchUser: vi.fn(() => Promise.resolve({ id: 1, name: 'Test' }))
}));
坑: vi.mock 必须在 import 之前,否则无效。
五、集成测试
5.1 Mock 策略
三原则:
- 只 mock 真正需要隔离的外部依赖(API、数据库、第三方)
- 不要 mock 自己的模块
- mock 数据要接近真实响应
// 好:接近真实响应
vi.mock('../api', () => ({
fetchOrders: vi.fn(() => Promise.resolve({
data: [],
total: 0,
code: 200,
message: 'Success'
}))
}));
// 差:太简单
vi.mock('../api', () => ({
fetchOrders: vi.fn(() => Promise.resolve([]))
}));
5.2 API 集成测试(Supertest)
const request = require('supertest');
const app = require('./app');
describe('User API', () => {
test('GET /api/users should return users', async () => {
const response = await request(app)
.get('/api/users')
.expect('Content-Type', /json/)
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
test('POST /api/users should create user', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: '[email protected]' })
.expect(201);
expect(response.body).toHaveProperty('id');
});
});
5.3 测试容器(Testcontainers)
@Testcontainers
class IntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14");
@Test
void shouldConnectToDatabase() {
// 自动启动容器,测试完自动清理
}
}
六、E2E 测试
6.1 Cypress vs Playwright
// Cypress:开发体验好,但异步处理弱
describe('订单流程', () => {
it('从登录到下单', () => {
cy.visit('/login');
cy.get('[data-testid="username"]').type('testuser');
cy.get('[data-testid="submit"]').click();
cy.url().should('include', '/dashboard');
});
});
// Playwright:稳定性更好,支持多浏览器
import { test, expect } from '@playwright/test';
test('从登录到下单', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('username').fill('testuser');
await page.getByTestId('submit').click();
await expect(page).toHaveURL(/\/dashboard/);
});
推荐 Playwright:
waitForSelector比should('be.visible')稳定- 支持多浏览器并行
- 自动等待机制更好
6.2 E2E 最佳实践
- 智能等待:用
waitForSelector,不要cy.wait(1000) - 测试隔离:每个测试独立,不互相影响
- 数据管理:每次跑完清理测试数据
- 失败诊断:截图录像
test.beforeEach(async ({ page }) => {
await page.request.post('/api/test/reset-database');
});
test('登录流程', async ({ page }) => {
await page.goto('/login');
// ...
});
6.3 E2E 的坑
坑:测试数据库污染
用同一个数据库跑 E2E,生产环境出现一堆测试订单。解决:专门测试数据库 + 每次清理。
坑:异步加载超时
// 错误:硬等
await page.waitForTimeout(1000);
// 正确:等条件
await page.waitForSelector('[data-testid="product-list"]', { timeout: 10000 });
七、覆盖率
7.1 覆盖率不是目标
// Jest 配置
module.exports = {
collectCoverage: true,
collectCoverageFrom: ['src/**/*.js', '!src/**/*.test.js'],
coverageThreshold: {
global: { branches: 80, functions: 80, lines: 80 }
}
};
覆盖率只是参考,不是目标。 为了冲覆盖率写无意义测试得不偿失。
7.2 分层覆盖率目标
| 代码类型 | 推荐覆盖率 |
|---|---|
| 核心业务逻辑 | 80-90% |
| 公共服务模块 | 70-80% |
| 工具函数 | 60-70% |
| 配置和常量 | 40-50% |
7.3 CI 覆盖率门禁
# .github/workflows/test.yml
- name: Check coverage
run: |
COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage below 80%"
exit 1
fi
建议: 先设 60%,稳定后慢慢提到 80%。
八、CI/CD 集成
8.1 GitHub Actions
name: Test
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
- uses: codecov/codecov-action@v3
8.2 GitLab CI/CD
stages:
- test
- quality
lint:
stage: test
script: npm run lint
test:
stage: test
script: npm test -- --coverage
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
九、性能测试
9.1 性能测试类型
| 类型 | 目的 |
|---|---|
| 负载测试 | 正常负载下的表现 |
| 压力测试 | 极限承载能力 |
| 容量测试 | 系统容量上限 |
| 稳定性测试 | 长期运行稳定性 |
9.2 关键指标
- 响应时间:P50、P95、P99
- 吞吐量:QPS、TPS
- 错误率
- 资源利用率:CPU、内存、IO
十、踩坑总结
坑一:为了覆盖率写无意义测试
// 坏测试
test('true should be true', () => {
expect(true).toBe(true);
});
// 好测试
test('empty cart should throw', () => {
expect(() => checkout([])).toThrow('Cart is empty');
});
坑二:测试代码比业务代码还多
解决:简化测试,只测关键路径。
坑三:异步测试不稳定
// 错误:没等异步完成
it('loads data', () => {
loadData();
expect(screen.getByText('Success')).toBeInTheDocument();
});
// 正确:async/await + waitFor
it('loads data', async () => {
await loadData();
await waitFor(() => expect(screen.getByText('Success')).toBeInTheDocument());
});
坑四:Mock 失效
// 错误:import 在前
import { fetchUser } from '../api';
vi.mock('../api');
// 正确:mock 在前
vi.mock('../api');
import { fetchUser } from '../api';
坑五:定时器导致测试卡死
it('delay test', async () => {
vi.useFakeTimers();
// ... 测试逻辑
vi.advanceTimersByTime(1000);
// ...
vi.useRealTimers(); // 恢复,避免影响其他测试
});
坑六:Linter 太严格
规则太严影响开发效率。平衡严格性和实用性:
error:必须遵守(如no-unused-vars)warn:建议遵守(如no-console)off:关闭(如react/prop-types)
坑七:环境变量未加载
// vitest.config.ts
import dotenv from 'dotenv';
dotenv.config({ path: '.env.test' });
坑八:TDD 团队不接受
解决:循序渐进:
- 先在新功能中尝试
- 在重构中尝试
- 逐步扩展到整个项目
十一、测试策略 Checklist
项目初期
- 配置 Linter 和 Prettier
- 搭建测试框架
- 定义测试规范
- 集成到 CI/CD
开发阶段
- 核心逻辑有单元测试
- 复杂算法覆盖边界情况
- API 有集成测试
- 关键流程有 E2E 测试
上线前
- 全量测试通过
- 覆盖率达到目标
- 性能测试通过
- 没有引入新警告
上线后
- 监控测试通过率
- 监控测试执行时间
- 定期更新测试用例
- 关注生产缺陷反馈
十二、实际效果
某 React 项目上测试体系后的效果:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| Bug 数量 | 基线 | 减少 70% |
| 代码审查时间 | 基线 | 减少 50% |
| 发版信心 | 低 | 高 |
| 重构意愿 | 低 | 高 |
十三、写在最后
测试和代码质量不是技术问题,是文化和习惯问题。
几条核心原则:
- 测试行为,不是测试实现
- 覆盖率只是参考,不是目标
- 测试金字塔比例 70/20/10
- Mock 要接近真实响应
- E2E 只测核心流程,别勉强
- Linter 平衡严格性和实用性
- CI 必须有质量门禁
实践之前先评估:
- 项目类型(核心金融 vs 内部工具)
- 团队接受度
- 测试文化
- 时间预算
不是所有项目都需要严格的测试体系,但基础规范不能少。比起半夜起来修生产 Bug,写测试的时间值得花。
写测试确实要多花时间,但这种投入会在三个月、半年后回来——回头的你会感谢当时写测试的自己。
本文整合了 4 篇测试与代码质量相关文章,涵盖 Linter 静态检查、TDD/ATDD、单元测试、集成测试、E2E 测试、性能测试、覆盖率、CI/CD 集成等核心技术。
版权声明: 本文首发于 指尖魔法屋-测试与代码质量实战指南:从Linter到E2E测试(https://blog.thinkmoon.cn/post/testing-code-quality-comprehensive-guide/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。