测试与代码质量实战指南:从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"
    ]
  }
}

提交时自动修复 + 格式化 + 跑相关测试。

二、测试金字塔

graph TB A[E2E 测试 - 少量<br/>核心流程<br/>慢但真实] B[集成测试 - 中等<br/>模块交互<br/>中速] C[单元测试 - 大量<br/>核心逻辑<br/>快] C --> B --> A

推荐比例: 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 策略

三原则:

  1. 只 mock 真正需要隔离的外部依赖(API、数据库、第三方)
  2. 不要 mock 自己的模块
  3. 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:

  • waitForSelectorshould('be.visible') 稳定
  • 支持多浏览器并行
  • 自动等待机制更好

6.2 E2E 最佳实践

  1. 智能等待:用 waitForSelector,不要 cy.wait(1000)
  2. 测试隔离:每个测试独立,不互相影响
  3. 数据管理:每次跑完清理测试数据
  4. 失败诊断:截图录像
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 团队不接受

解决:循序渐进:

  1. 先在新功能中尝试
  2. 在重构中尝试
  3. 逐步扩展到整个项目

十一、测试策略 Checklist

项目初期

  • 配置 Linter 和 Prettier
  • 搭建测试框架
  • 定义测试规范
  • 集成到 CI/CD

开发阶段

  • 核心逻辑有单元测试
  • 复杂算法覆盖边界情况
  • API 有集成测试
  • 关键流程有 E2E 测试

上线前

  • 全量测试通过
  • 覆盖率达到目标
  • 性能测试通过
  • 没有引入新警告

上线后

  • 监控测试通过率
  • 监控测试执行时间
  • 定期更新测试用例
  • 关注生产缺陷反馈

十二、实际效果

某 React 项目上测试体系后的效果:

指标改造前改造后
Bug 数量基线减少 70%
代码审查时间基线减少 50%
发版信心
重构意愿

十三、写在最后

测试和代码质量不是技术问题,是文化和习惯问题

几条核心原则:

  1. 测试行为,不是测试实现
  2. 覆盖率只是参考,不是目标
  3. 测试金字塔比例 70/20/10
  4. Mock 要接近真实响应
  5. E2E 只测核心流程,别勉强
  6. Linter 平衡严格性和实用性
  7. CI 必须有质量门禁

实践之前先评估:

  • 项目类型(核心金融 vs 内部工具)
  • 团队接受度
  • 测试文化
  • 时间预算

不是所有项目都需要严格的测试体系,但基础规范不能少。比起半夜起来修生产 Bug,写测试的时间值得花。

写测试确实要多花时间,但这种投入会在三个月、半年后回来——回头的你会感谢当时写测试的自己。


本文整合了 4 篇测试与代码质量相关文章,涵盖 Linter 静态检查、TDD/ATDD、单元测试、集成测试、E2E 测试、性能测试、覆盖率、CI/CD 集成等核心技术。

版权声明: 本文首发于 指尖魔法屋-测试与代码质量实战指南:从Linter到E2E测试https://blog.thinkmoon.cn/post/testing-code-quality-comprehensive-guide/) 转载或引用必须申明原指尖魔法屋来源及源地址!