软件测试完整知识总结与使用教程
覆盖:单元测试 · 集成测试 · E2E测试(Selenium / Cypress / Playwright)· 性能测试(JMeter)· Fuzzing · 契约测试
目录
- 测试基础与测试金字塔
- [单元测试(Unit Testing)](#单元测试(Unit Testing))
- [集成测试(Integration Testing)](#集成测试(Integration Testing))
- [E2E测试(End-to-End Testing)](#E2E测试(End-to-End Testing))
- 4.1 Selenium
- 4.2 Cypress
- 4.3 Playwright
- 4.4 三者横向对比
- [性能测试 ------ Apache JMeter](#性能测试 —— Apache JMeter)
- 模糊测试(Fuzzing)
- [契约测试(Contract Testing)](#契约测试(Contract Testing))
- [CI/CD 中的测试策略](#CI/CD 中的测试策略)
- 测试最佳实践总结
1. 测试基础与测试金字塔
1.1 为什么需要软件测试
软件测试是贯穿整个软件生命周期的质量保障活动,而非仅在交付前"找 bug"。其核心价值体现在:
- 越早发现缺陷,修复成本越低:在需求阶段发现的 bug 修复成本远低于在生产环境中发现的。
- 保证持续交付的可靠性:在 CI/CD 流水线中,自动化测试是每次代码提交的安全网。
- 提高代码质量:测试驱动开发(TDD)促使开发者写出更简洁、可维护的代码。
- 防止回归:任何代码修改都可能引入新问题,回归测试确保已有功能不被破坏。
1.2 测试金字塔(Testing Pyramid)
┌────────────┐
│ E2E 测试 │ ← 少量、慢、成本高,但最贴近用户
├────────────┤
│ 集成测试 │ ← 适量,验证模块间协作
├────────────┤
│ 单元测试 │ ← 大量、快、成本低,覆盖最广
└────────────┘
测试金字塔的核心思想:
| 层次 | 数量 | 速度 | 成本 | 隔离度 |
|---|---|---|---|---|
| 单元测试 | 最多(70%+) | 最快(毫秒级) | 最低 | 完全隔离 |
| 集成测试 | 适中(20%) | 中等(秒级) | 中等 | 部分隔离 |
| E2E 测试 | 最少(10%) | 最慢(分钟级) | 最高 | 无隔离 |
1.3 各测试阶段与开发阶段的对应关系(V 模型)
需求分析 ←→ 验收测试(UAT)
系统设计 ←→ 系统测试(System Test)
概要设计 ←→ 集成测试(Integration Test)
详细设计 ←→ 单元测试(Unit Test)
编码
1.4 测试分类速查
| 测试类型 | 关注点 | 执行者 | 典型工具 |
|---|---|---|---|
| 单元测试 | 最小代码单元 | 开发者 | JUnit, pytest, Jest |
| 集成测试 | 模块间接口 | 开发者 / QA | Testcontainers, Spring Test |
| E2E 测试 | 完整用户流程 | QA / 自动化工程师 | Selenium, Cypress, Playwright |
| 性能测试 | 响应时间、吞吐量 | 性能工程师 | JMeter, Gatling, Locust |
| Fuzzing | 安全漏洞、稳定性 | 安全工程师 | AFL++, libFuzzer |
| 契约测试 | 微服务接口兼容性 | 全栈 / 平台团队 | Pact |
2. 单元测试(Unit Testing)
2.1 定义与核心概念
单元测试 是针对软件中最小可测试单元(通常是一个函数、方法或类)在与程序其他部分完全隔离的情况下进行验证的测试活动。
核心特征(FIRST 原则):
| 原则 | 说明 |
|---|---|
| Fast(快速) | 单元测试应在毫秒内完成,整个测试套件应在几秒内跑完 |
| Isolated(隔离) | 不依赖数据库、网络、文件系统等外部依赖 |
| Repeatable(可重复) | 在任何环境、任何时间运行结果一致 |
| Self-validating(自验证) | 测试结果明确为 Pass 或 Fail,无需人工判断 |
| Timely(及时) | 在编写被测代码前或同时编写(TDD) |
2.2 测试替身(Test Doubles)
由于单元测试需要完全隔离,通常使用以下替身技术替换外部依赖:
| 类型 | 说明 | 使用场景 |
|---|---|---|
| Dummy(哑对象) | 传递给方法但从不使用的对象 | 填充参数列表 |
| Stub(桩) | 为调用提供固定返回值 | 替代数据库查询 |
| Mock(模拟) | 可验证调用行为的替身 | 验证方法是否被正确调用 |
| Fake(伪造) | 有简化实现的工作替身 | 内存数据库替代真实数据库 |
| Spy(间谍) | 包装真实对象并记录调用信息 | 部分模拟,保留真实逻辑 |
2.3 测试内容
单元测试应覆盖以下方面:
- 模块接口:参数传递、返回值是否正确
- 局部数据结构:内部数据结构是否按预期维护
- 独立执行路径:所有分支逻辑(if/else/switch)
- 错误处理路径:异常、边界条件、非法输入
- 边界条件:最大值、最小值、空值、零值
2.4 测试驱动开发(TDD)
TDD 遵循红绿重构循环:
① 红(Red) → 先写一个失败的测试
② 绿(Green)→ 写最少量代码让测试通过
③ 重构(Refactor)→ 在保持测试通过的前提下改善代码
TDD 的优势:
- 迫使开发者思考 API 设计
- 保证测试覆盖率
- 代码变更有即时反馈
2.5 主流单元测试框架
Java ------ JUnit 5
xml
<!-- pom.xml -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class CalculatorTest {
private Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@Test
@DisplayName("两个正整数相加应返回正确结果")
void testAdd() {
int result = calculator.add(3, 5);
assertEquals(8, result, "3 + 5 应该等于 8");
}
@Test
@DisplayName("除以零应抛出 ArithmeticException")
void testDivideByZero() {
assertThrows(ArithmeticException.class, () -> calculator.divide(10, 0));
}
@ParameterizedTest
@CsvSource({"1, 1, 2", "2, 3, 5", "10, 20, 30"})
void testAddParameterized(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
@Test
void testWithMock() {
// 使用 Mockito 模拟依赖
UserRepository mockRepo = mock(UserRepository.class);
when(mockRepo.findById(1L)).thenReturn(new User("Alice"));
UserService service = new UserService(mockRepo);
User user = service.getUser(1L);
assertEquals("Alice", user.getName());
verify(mockRepo, times(1)).findById(1L);
}
}
Python ------ pytest
bash
pip install pytest pytest-mock pytest-cov
python
# test_calculator.py
import pytest
from unittest.mock import Mock, patch
from calculator import Calculator, UserService
class TestCalculator:
def setup_method(self):
self.calc = Calculator()
def test_add_positive_numbers(self):
assert self.calc.add(3, 5) == 8
def test_divide_by_zero_raises(self):
with pytest.raises(ZeroDivisionError):
self.calc.divide(10, 0)
@pytest.mark.parametrize("a,b,expected", [
(1, 1, 2),
(2, 3, 5),
(-1, 1, 0),
])
def test_add_parametrized(self, a, b, expected):
assert self.calc.add(a, b) == expected
def test_user_service_with_mock():
mock_repo = Mock()
mock_repo.find_by_id.return_value = {"name": "Alice"}
service = UserService(mock_repo)
user = service.get_user(1)
assert user["name"] == "Alice"
mock_repo.find_by_id.assert_called_once_with(1)
bash
# 运行测试并生成覆盖率报告
pytest --cov=. --cov-report=html tests/
JavaScript/TypeScript ------ Jest
bash
npm install --save-dev jest @types/jest ts-jest
typescript
// calculator.test.ts
import { Calculator } from './calculator';
describe('Calculator', () => {
let calc: Calculator;
beforeEach(() => {
calc = new Calculator();
});
test('两数相加', () => {
expect(calc.add(3, 5)).toBe(8);
});
test('除以零抛出异常', () => {
expect(() => calc.divide(10, 0)).toThrow('Division by zero');
});
test('使用 Jest Mock 模拟依赖', () => {
const mockFetch = jest.fn().mockResolvedValue({ data: 'result' });
const service = new ApiService(mockFetch);
// 断言 Mock 行为
expect(mockFetch).toHaveBeenCalledTimes(0);
});
});
Go ------ testing 标准库
go
// calculator_test.go
package calculator
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestAdd(t *testing.T) {
result := Add(3, 5)
assert.Equal(t, 8, result)
}
func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
assert.Error(t, err)
assert.EqualError(t, err, "division by zero")
}
// 表格驱动测试(Go 惯用法)
func TestAddTable(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"正数相加", 1, 2, 3},
{"负数相加", -1, -2, -3},
{"零值", 0, 5, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, Add(tt.a, tt.b))
})
}
}
2.6 代码覆盖率指标
| 覆盖率类型 | 说明 | 推荐目标 |
|---|---|---|
| 语句覆盖率(Statement) | 每条语句至少执行一次 | ≥ 80% |
| 分支覆盖率(Branch) | 每个 if/else 分支均覆盖 | ≥ 70% |
| 函数覆盖率(Function) | 每个函数至少调用一次 | ≥ 80% |
| 行覆盖率(Line) | 每行代码至少执行一次 | ≥ 80% |
⚠️ 覆盖率不等于质量,100% 覆盖率也可能存在逻辑缺陷,应关注断言的质量而非仅追求覆盖率数字。
2.7 常见反模式与最佳实践
反模式(Bad Smells):
- 测试之间相互依赖(共享可变状态)
- 在单元测试中访问真实数据库或网络
- 测试过多私有方法
- 过度 Mock,导致测试脆弱
最佳实践:
- 遵循 AAA 模式:Arrange(准备)→ Act(执行)→ Assert(断言)
- 每个测试只验证一件事
- 测试名称清晰描述场景和预期行为
- 保持测试代码与生产代码同等质量
3. 集成测试(Integration Testing)
3.1 定义与目的
集成测试(也称联合测试、组装测试)是在多个已通过单元测试的模块组合后,验证模块间接口和协作是否正确的测试活动。
核心思想:单元测试用于验证"零件"的正确性,集成测试用于验证"组装"的正确性。
模块虽然能单独工作,但组装后不一定能正常协同工作。集成测试重点验证:
- 模块间的数据传输是否正确
- 模块间的接口定义是否匹配
- API 调用、数据库交互、网络通信是否符合预期
- 全局数据结构是否正确维护
3.2 集成策略
大爆炸集成(Big Bang Integration)
一次性将所有模块组合,整体测试。
- 优点:简单,适用于小型系统
- 缺点:错误定位困难,调试代价大
自顶向下集成(Top-Down Integration)
从最高层模块开始,逐步向下集成,用桩(Stub)替代未完成的下层模块。
主模块
├── 子模块A(实际)
│ └── 子子模块A1(Stub)
└── 子模块B(Stub)
自底向上集成(Bottom-Up Integration)
从最底层模块开始,逐步向上集成,用驱动程序(Driver)调用下层模块。
子子模块A1(实际)
└── 子模块A(实际)
└── 主模块(实际)
三明治集成(Sandwich Integration)
结合自顶向下和自底向上,从中间层开始向两端扩展,适合大型复杂系统。
3.3 典型集成测试场景
数据库集成测试:验证数据能否正确存储和检索
java
// Spring Boot 集成测试示例
@SpringBootTest
@Transactional
class UserRepositoryIntegrationTest {
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndRetrieveUser() {
// Arrange
User user = new User("alice@example.com", "Alice");
// Act
User saved = userRepository.save(user);
User retrieved = userRepository.findById(saved.getId()).orElseThrow();
// Assert
assertEquals("Alice", retrieved.getName());
assertEquals("alice@example.com", retrieved.getEmail());
}
}
API 集成测试:验证 HTTP 请求/响应是否正确
java
// Spring Boot MockMvc 集成测试
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldReturnUserById() throws Exception {
mockMvc.perform(get("/api/users/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Alice"))
.andExpect(jsonPath("$.email").exists());
}
@Test
void shouldCreateUser() throws Exception {
String requestBody = """
{"name": "Bob", "email": "bob@example.com"}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"));
}
}
使用 Testcontainers 进行真实数据库集成测试:
java
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private OrderService orderService;
@Test
void shouldProcessOrderSuccessfully() {
Order order = orderService.createOrder(new OrderRequest("product-1", 2));
assertNotNull(order.getId());
assertEquals(OrderStatus.PENDING, order.getStatus());
}
}
3.4 集成测试 vs 单元测试
| 维度 | 单元测试 | 集成测试 |
|---|---|---|
| 测试粒度 | 单个函数/类 | 多个模块协作 |
| 外部依赖 | 全部 Mock | 部分真实(数据库、API) |
| 执行速度 | 毫秒级 | 秒级到分钟级 |
| 错误定位 | 精确到行 | 需要在多个组件中查找 |
| 数量 | 多 | 少 |
| 主要发现的问题 | 逻辑错误 | 接口不匹配、数据流错误 |
3.5 集成测试最佳实践
- 使用真实的(或轻量的)外部依赖:能用真实数据库就用,避免过度 Mock
- 利用 Testcontainers:通过 Docker 启动真实服务(数据库、消息队列等)
- 测试覆盖边界情况:空响应、超时、错误 HTTP 状态码
- 保证测试隔离:每次测试前后重置数据库状态(使用 @Transactional 或数据清理脚本)
- 聚焦高价值测试:集中测试跨越系统边界的关键交互路径
4. E2E测试(End-to-End Testing)
4.1 Selenium
简介与架构
Selenium 是最古老、应用最广的浏览器自动化框架,诞生于 2004 年,目前版本为 Selenium 4.x。
架构原理:客户端 → WebDriver → 浏览器驱动 → 浏览器
Test Code
↓
Selenium Client Library (Java/Python/JS/C#...)
↓
WebDriver Protocol (HTTP)
↓
Browser Driver (ChromeDriver/GeckoDriver/...)
↓
Real Browser (Chrome/Firefox/Safari/Edge/...)
Selenium 4 引入了 W3C WebDriver 标准 和 BiDi(双向通信)协议,大幅改善了稳定性。
支持的语言和浏览器
| 支持语言 | Java, Python, JavaScript, C#, Ruby, PHP, Kotlin |
|---|---|
| 支持浏览器 | Chrome, Firefox, Safari, Edge, IE |
| 支持平台 | Windows, macOS, Linux |
安装与配置
bash
# Python
pip install selenium
# Node.js
npm install selenium-webdriver
# Java (Maven)
# 在 pom.xml 中添加依赖
Selenium 4.6+ 自动管理驱动(无需手动下载 ChromeDriver):
python
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
# 自动下载并管理驱动
driver = webdriver.Chrome()
driver.implicitly_wait(10)
try:
driver.get("https://example.com/login")
# 定位元素
username = driver.find_element(By.ID, "username")
password = driver.find_element(By.NAME, "password")
submit = driver.find_element(By.CSS_SELECTOR, "button[type='submit']")
username.send_keys("testuser")
password.send_keys("password123")
submit.click()
# 显式等待元素出现
wait = WebDriverWait(driver, 10)
dashboard = wait.until(
EC.presence_of_element_located((By.ID, "dashboard"))
)
assert "Welcome" in dashboard.text
print("✓ 登录测试通过")
finally:
driver.quit()
Java 完整示例
java
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.*;
import org.junit.jupiter.api.*;
import java.time.Duration;
class LoginTest {
private WebDriver driver;
private WebDriverWait wait;
@BeforeEach
void setUp() {
driver = new ChromeDriver();
wait = new WebDriverWait(driver, Duration.ofSeconds(10));
driver.manage().window().maximize();
}
@Test
void testSuccessfulLogin() {
driver.get("https://example.com/login");
driver.findElement(By.id("username")).sendKeys("testuser");
driver.findElement(By.id("password")).sendKeys("password123");
driver.findElement(By.cssSelector("button[type='submit']")).click();
WebElement welcomeMsg = wait.until(
ExpectedConditions.visibilityOfElementLocated(By.id("welcome-message"))
);
Assertions.assertTrue(welcomeMsg.getText().contains("Welcome"));
}
@AfterEach
void tearDown() {
if (driver != null) driver.quit();
}
}
Page Object Model(POM)设计模式
POM 是 Selenium 中最重要的设计模式,将页面的元素定位和操作封装在独立的类中:
python
# pages/login_page.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class LoginPage:
URL = "https://example.com/login"
# 元素定位器(与测试逻辑分离)
USERNAME_INPUT = (By.ID, "username")
PASSWORD_INPUT = (By.NAME, "password")
SUBMIT_BUTTON = (By.CSS_SELECTOR, "button[type='submit']")
ERROR_MESSAGE = (By.CLASS_NAME, "error-msg")
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, 10)
def open(self):
self.driver.get(self.URL)
return self
def login(self, username, password):
self.wait.until(EC.element_to_be_clickable(self.USERNAME_INPUT)).send_keys(username)
self.driver.find_element(*self.PASSWORD_INPUT).send_keys(password)
self.driver.find_element(*self.SUBMIT_BUTTON).click()
return DashboardPage(self.driver)
def get_error_message(self):
return self.wait.until(EC.visibility_of_element_located(self.ERROR_MESSAGE)).text
# tests/test_login.py
def test_valid_login(driver):
dashboard = LoginPage(driver).open().login("user@test.com", "password")
assert dashboard.get_welcome_text() == "Welcome, User!"
def test_invalid_password(driver):
login_page = LoginPage(driver).open()
login_page.login("user@test.com", "wrongpassword")
assert "Invalid credentials" in login_page.get_error_message()
Selenium Grid(分布式并行测试)
java
// 连接到 Selenium Grid Hub 执行远程测试
ChromeOptions options = new ChromeOptions();
WebDriver driver = new RemoteWebDriver(
new URL("http://selenium-hub:4444/wd/hub"),
options
);
yaml
# docker-compose.yml - 搭建 Selenium Grid
version: "3"
services:
selenium-hub:
image: selenium/hub:4.15.0
ports:
- "4444:4444"
chrome:
image: selenium/node-chrome:4.15.0
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_NODE_MAX_SESSIONS=3
deploy:
replicas: 3
4.2 Cypress
简介与架构
Cypress 由 Cypress.io 于 2017 年发布,是专为现代 Web 开发者设计的 E2E 测试框架。
核心架构特点 :Cypress 与其他框架的最大区别在于测试代码直接在浏览器内运行(而非通过 WebDriver 远程控制),与应用共享同一个 JavaScript 事件循环。
┌─────────────────────────────────────┐
│ Browser │
│ ┌─────────────┐ ┌───────────────┐ │
│ │ App (iframe)│ │ Cypress Runner│ │
│ │ │ │ (Test Code) │ │
│ └─────────────┘ └───────────────┘ │
│ ↕ 直接DOM访问 │
└─────────────────────────────────────┘
↕ 文件/网络代理
Node.js Server Process
安装与配置
bash
npm install --save-dev cypress
npx cypress open # 打开图形界面
npx cypress run # 无头模式运行
javascript
// cypress.config.js
const { defineConfig } = require('cypress')
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
defaultCommandTimeout: 4000,
setupNodeEvents(on, config) {
// 插件配置
},
},
})
核心 API 与基础用法
javascript
// cypress/e2e/login.cy.js
describe('用户登录功能', () => {
beforeEach(() => {
cy.visit('/login')
})
it('使用有效凭证成功登录', () => {
cy.get('[data-cy="username"]').type('testuser@example.com')
cy.get('[data-cy="password"]').type('password123')
cy.get('[data-cy="submit-btn"]').click()
// Cypress 自动等待断言条件满足
cy.url().should('include', '/dashboard')
cy.get('[data-cy="welcome-message"]').should('contain', 'Welcome')
})
it('使用错误密码显示错误信息', () => {
cy.get('[data-cy="username"]').type('testuser@example.com')
cy.get('[data-cy="password"]').type('wrongpassword')
cy.get('[data-cy="submit-btn"]').click()
cy.get('[data-cy="error-message"]')
.should('be.visible')
.and('contain', 'Invalid credentials')
})
it('支持键盘操作', () => {
cy.get('[data-cy="username"]').type('testuser@example.com')
cy.get('[data-cy="password"]').type('password123{enter}') // 按回车提交
cy.url().should('include', '/dashboard')
})
})
网络请求拦截(cy.intercept)
javascript
describe('商品列表', () => {
it('成功加载商品', () => {
// 拦截并模拟 API 响应
cy.intercept('GET', '/api/products', {
statusCode: 200,
body: [
{ id: 1, name: '商品A', price: 100 },
{ id: 2, name: '商品B', price: 200 }
]
}).as('getProducts')
cy.visit('/products')
cy.wait('@getProducts')
cy.get('[data-cy="product-card"]').should('have.length', 2)
cy.get('[data-cy="product-card"]').first().should('contain', '商品A')
})
it('处理 API 错误', () => {
cy.intercept('GET', '/api/products', {
statusCode: 500,
body: { error: 'Internal Server Error' }
}).as('getProductsError')
cy.visit('/products')
cy.wait('@getProductsError')
cy.get('[data-cy="error-banner"]').should('be.visible')
})
})
自定义命令
javascript
// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
cy.session([email, password], () => {
cy.visit('/login')
cy.get('[data-cy="username"]').type(email)
cy.get('[data-cy="password"]').type(password)
cy.get('[data-cy="submit-btn"]').click()
cy.url().should('include', '/dashboard')
})
})
Cypress.Commands.add('loginByApi', (email, password) => {
cy.request('POST', '/api/auth/login', { email, password })
.then((response) => {
window.localStorage.setItem('authToken', response.body.token)
})
})
// 在测试中使用
describe('Dashboard', () => {
beforeEach(() => {
cy.login('user@example.com', 'password123')
})
it('显示用户统计数据', () => {
cy.visit('/dashboard')
cy.get('[data-cy="stats-widget"]').should('be.visible')
})
})
组件测试(Cypress Component Testing)
javascript
// cypress/component/Button.cy.jsx
import React from 'react'
import Button from '../../src/components/Button'
describe('Button 组件', () => {
it('点击时触发 onClick 回调', () => {
const onClickSpy = cy.spy().as('onClickSpy')
cy.mount(<Button onClick={onClickSpy}>点击我</Button>)
cy.get('button').click()
cy.get('@onClickSpy').should('have.been.calledOnce')
})
it('禁用状态不可点击', () => {
cy.mount(<Button disabled>禁用按钮</Button>)
cy.get('button').should('be.disabled')
})
})
4.3 Playwright
简介与架构
Playwright 由微软(原 Google Puppeteer 团队成员)于 2020 年发布,是目前最受欢迎的现代 E2E 测试框架。
架构特点:
- 通过 WebSocket + DevTools Protocol 与浏览器通信(无 HTTP 开销)
- 测试代码运行在浏览器外部(Node.js 进程)
- 使用浏览器上下文(Browser Context) 实现轻量级隔离(类似无痕窗口)
- 内置支持 Chromium、Firefox、WebKit(Safari)
安装与配置
bash
npm init playwright@latest
# 或手动安装
npm install --save-dev @playwright/test
npx playwright install # 下载所有浏览器
typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true, // 完全并行执行
forbidOnly: !!process.env.CI, // CI 中禁止 test.only
retries: process.env.CI ? 2 : 0, // CI 中失败重试
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // 失败时录制 trace
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
],
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
基础 API 与用法
typescript
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('用户认证流程', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('使用有效凭证登录', async ({ page }) => {
await page.fill('[data-testid="username"]', 'testuser@example.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="submit-btn"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('[data-testid="welcome"]')).toContainText('Welcome');
});
test('错误凭证显示错误提示', async ({ page }) => {
await page.fill('[data-testid="username"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'wrongpassword');
await page.click('[data-testid="submit-btn"]');
await expect(page.locator('[data-testid="error"]')).toBeVisible();
await expect(page.locator('[data-testid="error"]')).toHaveText('Invalid credentials');
});
});
Playwright Locators(推荐定位策略)
typescript
// Playwright 推荐使用语义化定位器(更稳定,贴近用户视角)
test('推荐的定位方式', async ({ page }) => {
// 1. getByRole - 基于 ARIA 角色(推荐)
await page.getByRole('button', { name: '登录' }).click();
// 2. getByText - 基于可见文本
await page.getByText('欢迎回来').isVisible();
// 3. getByLabel - 基于表单标签
await page.getByLabel('用户名').fill('testuser');
// 4. getByPlaceholder - 基于占位符
await page.getByPlaceholder('请输入密码').fill('password123');
// 5. getByTestId - 基于 data-testid(兜底方案)
await page.getByTestId('submit-btn').click();
// 6. CSS 选择器(最后考虑)
await page.locator('.error-message').isVisible();
});
网络拦截与 Mock
typescript
test('拦截 API 请求', async ({ page }) => {
// 拦截并返回 Mock 数据
await page.route('/api/users/**', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 1, name: 'Alice', role: 'admin' }),
});
});
await page.goto('/profile');
await expect(page.getByText('Alice')).toBeVisible();
});
test('模拟网络错误', async ({ page }) => {
await page.route('/api/data', route => route.abort('failed'));
await page.goto('/data-page');
await expect(page.getByText('加载失败,请重试')).toBeVisible();
});
并行测试与 Fixtures
typescript
// 使用 fixture 实现认证状态复用
import { test as base, expect } from '@playwright/test';
type TestFixtures = {
authenticatedPage: Page;
};
const test = base.extend<TestFixtures>({
authenticatedPage: async ({ page }, use) => {
// 通过 API 登录,跳过 UI 流程
const response = await page.request.post('/api/auth/login', {
data: { email: 'user@test.com', password: 'password' },
});
const { token } = await response.json();
await page.context().addCookies([{ name: 'auth', value: token, domain: 'localhost' }]);
await use(page);
},
});
test('已登录用户访问 Dashboard', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard');
await expect(authenticatedPage.getByText('Dashboard')).toBeVisible();
});
Playwright 特有功能
typescript
// 多标签页测试
test('新标签页打开', async ({ context }) => {
const page1 = await context.newPage();
await page1.goto('/');
const [page2] = await Promise.all([
context.waitForEvent('page'),
page1.click('[target="_blank"]'),
]);
await expect(page2).toHaveURL(/external\.com/);
});
// 文件下载
test('下载文件', async ({ page }) => {
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('[data-testid="download-btn"]'),
]);
const path = await download.path();
expect(path).toBeTruthy();
});
// 截图和视觉回归测试
test('视觉快照对比', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard.png', { maxDiffPixels: 100 });
});
// API 测试(无浏览器)
test('直接测试 API', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: 'Alice', email: 'alice@test.com' },
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.id).toBeTruthy();
});
4.4 三者横向对比
架构对比
| 维度 | Selenium | Cypress | Playwright |
|---|---|---|---|
| 发布年份 | 2004 | 2017 | 2020 |
| 架构模式 | Client-Server (WebDriver) | In-Browser | Out-of-Process (WebSocket) |
| 开发厂商 | 开源社区 | Cypress.io | Microsoft |
| 协议 | W3C WebDriver / BiDi | 浏览器内部 | CDP + WebSocket |
语言支持
| 语言 | Selenium | Cypress | Playwright |
|---|---|---|---|
| JavaScript/TypeScript | ✅ | ✅ | ✅ |
| Python | ✅ | ❌ | ✅ |
| Java | ✅ | ❌ | ✅ |
| C# / .NET | ✅ | ❌ | ✅ |
| Ruby | ✅ | ❌ | ❌ |
| Go | ❌ | ❌ | ❌ |
浏览器支持
| 浏览器 | Selenium | Cypress | Playwright |
|---|---|---|---|
| Chrome / Chromium | ✅ | ✅ | ✅ |
| Firefox | ✅ | ✅(实验性) | ✅ |
| Safari / WebKit | ✅ | ❌ | ✅ |
| Edge | ✅ | ✅ | ✅ |
| IE 11 | ✅ | ❌ | ❌ |
性能对比(2026 年数据)
| 指标 | Playwright | Cypress | Selenium |
|---|---|---|---|
| 执行速度 | 最快(基准) | 慢约 23% | 慢约 42% |
| Flaky 测试率 | 最低(~6%) | 中等(~20%) | 最高(~28%) |
| 内存占用 | 最低(共享浏览器进程) | 中等 | 最高(每会话一个浏览器) |
| 并行支持 | 内置免费 | 需付费 Cypress Cloud | 需配置 Selenium Grid |
| 开发者满意度 | 94%(最高留存率) | 下降中 | 22% 市场份额(下降) |
功能特性对比
| 特性 | Selenium | Cypress | Playwright |
|---|---|---|---|
| 自动等待 | ❌(需手动配置) | ✅ | ✅ |
| 网络拦截 | 有限(通过插件) | ✅ cy.intercept | ✅ page.route |
| 多标签页/窗口 | ✅ | ❌(不原生支持) | ✅ |
| iframe 支持 | ✅ | 有限 | ✅ |
| 移动设备模拟 | 通过 Appium | ❌(仅视口模拟) | ✅(视口+UA) |
| 组件测试 | ❌ | ✅ | 实验性 |
| 截图/视频录制 | 通过插件 | ✅ | ✅ |
| 视觉回归测试 | 通过插件 | 通过插件 | ✅ 内置 |
| API 测试 | ❌ | ✅ cy.request | ✅ request fixture |
| Trace 调试 | ❌ | Time Travel | ✅ Trace Viewer |
选型建议
如果你需要... 选择...
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
跨语言团队(Java/Python/C#) Selenium
遗留浏览器(IE11)支持 Selenium
现有大型 Selenium 测试套件 Selenium(迁移成本高)
前端开发者,SPA 应用,快速上手 Cypress
丰富的 UI 调试体验 Cypress
组件级测试 Cypress
现代 Web 应用,需要跨浏览器 Playwright
需要测试 Safari/WebKit Playwright
需要内置并行和移动设备模拟 Playwright
全新项目(首选) Playwright ← 2025-2026 推荐
5. 性能测试 ------ Apache JMeter
5.1 简介
Apache JMeter 是 Apache 组织开发的基于 Java 的开源性能测试工具,支持多种协议和场景。
- 官网:https://jmeter.apache.org/
- 最新版本:JMeter 5.6.x(需要 JDK 8 或更高)
- 协议支持:HTTP/HTTPS、SOAP/REST、FTP、JDBC、LDAP、SMTP、WebSocket
5.2 性能测试类型
| 类型 | 目的 | 说明 |
|---|---|---|
| 负载测试(Load Test) | 验证正常负载下的性能 | 模拟预期的用户并发数 |
| 压力测试(Stress Test) | 找到系统极限 | 持续增加负载直到系统崩溃 |
| 峰值测试(Spike Test) | 测试突发流量响应 | 短时间内大幅增加用户数 |
| 浸泡测试(Soak Test) | 发现内存泄漏 | 长时间(数小时/数天)中等负载 |
| 容量测试(Capacity Test) | 规划扩容 | 确定系统最大承载容量 |
| 并发测试(Concurrency Test) | 测试同时访问的能力 | 多用户同时触发相同操作 |
5.3 核心组件说明
测试计划(Test Plan)
是 JMeter 测试的根节点,包含所有测试组件。
线程组(Thread Group)
模拟虚拟用户的核心组件:
| 参数 | 说明 | 示例 |
|---|---|---|
| 线程数(Number of Threads) | 模拟的虚拟用户数量 | 100 |
| Ramp-Up 时间 | 达到线程数所需时间(秒) | 10(每秒启动 10 个线程) |
| 循环次数(Loop Count) | 每个线程执行测试的次数 | 5 |
| 持续时间(Duration) | 测试持续时间(秒) | 300 |
取样器(Samplers)
| 取样器类型 | 用途 |
|---|---|
| HTTP 请求 | 测试 Web API |
| JDBC 请求 | 直接测试数据库 |
| WebSocket | 测试 WebSocket 服务 |
| Java 请求 | 执行 Java 代码 |
| TCP 取样器 | 测试 TCP 服务 |
监听器(Listeners)
| 监听器 | 作用 |
|---|---|
| 察看结果树 | 查看每个请求的详细信息 |
| 汇总报告 | 显示统计数据(平均、最大、最小响应时间) |
| 聚合报告 | 显示百分位数(90th、95th、99th) |
| 图形结果 | 以图表展示性能趋势 |
| 响应时间图 | 显示响应时间随时间的变化 |
5.4 安装与启动
bash
# 1. 前提:安装 JDK 8+
java -version
# 2. 下载 JMeter(官网下载 .zip 或 .tgz)
# https://jmeter.apache.org/download_jmeter.cgi
# 3. 解压并启动 GUI 模式(用于脚本开发)
cd apache-jmeter-5.6.2/bin
./jmeter.sh # Linux/macOS
jmeter.bat # Windows
# 4. 切换中文界面
# Options → Choose Language → Chinese(Simplified)
# 5. 命令行模式(推荐用于实际压测)
./jmeter -n -t test_plan.jmx -l results.jtl -e -o report/
5.5 创建第一个性能测试
步骤一:创建测试计划结构
测试计划
├── 线程组
│ ├── HTTP 请求默认值(配置)
│ ├── HTTP Cookie 管理器(配置)
│ ├── HTTP 请求取样器(登录)
│ ├── HTTP 请求取样器(查询商品列表)
│ ├── HTTP 请求取样器(下单)
│ ├── 响应断言
│ └── 查看结果树(调试用,压测时移除)
└── 聚合报告
步骤二:配置 HTTP 请求默认值
在线程组上右键 → 添加 → 配置元件 → HTTP 请求默认值
协议:https
服务器名称:api.example.com
端口:443
编码:UTF-8
步骤三:添加 HTTP 请求
方法:POST
路径:/api/auth/login
请求体:
{
"username": "${username}",
"password": "${password}"
}
步骤四:参数化测试数据(CSV Data Set Config)
csv
# test_data/users.csv
username,password
user1@test.com,pass123
user2@test.com,pass456
user3@test.com,pass789
添加 → 配置元件 → CSV Data Set Config
文件名:${__P(testdata_dir)}/users.csv
变量名称:username,password
分隔符:,
是否循环:True
步骤五:关联(提取响应中的 Token)
添加 → 后置处理器 → JSON 提取器
变量名:authToken
JSON路径:$.token
在后续请求中使用:
Header: Authorization: Bearer ${authToken}
5.6 关键性能指标
性能测试报告关键指标:
┌──────────────────────────────────────────────┐
│ 吞吐量(Throughput) │
│ → 每秒处理的请求数(RPS / TPS) │
│ → 越高越好 │
│ │
│ 响应时间(Response Time) │
│ → 平均响应时间:< 200ms(良好) │
│ → 90th 百分位:< 500ms(可接受) │
│ → 99th 百分位:< 2000ms(最大容忍) │
│ │
│ 错误率(Error Rate) │
│ → 生产环境应 < 0.1% │
│ │
│ 并发用户数(Concurrent Users) │
│ → 系统能稳定支持的最大并发数 │
└──────────────────────────────────────────────┘
5.7 完整 JMeter 脚本示例(命令行)
bash
# 完整的命令行压测示例
# -n: 无 GUI 模式
# -t: 测试计划文件
# -l: 结果日志文件
# -e: 测试结束后生成报告
# -o: 报告输出目录
# -Jusers=100: 覆盖线程数
# -Jrampup=30: 覆盖 Ramp-Up 时间
./jmeter -n \
-t test_plans/api_load_test.jmx \
-l results/$(date +%Y%m%d_%H%M%S).jtl \
-e \
-o reports/html_report \
-Jusers=100 \
-Jrampup=30 \
-Jduration=300 \
-Jbase_url=https://api.staging.example.com
5.8 分布式压测(Distributed Testing)
当单台机器性能不足以模拟足够多的虚拟用户时,使用分布式压测:
Controller(主控机)
├── Worker 1(负载机,运行 jmeter-server)
├── Worker 2
└── Worker 3
bash
# 在各负载机上启动 JMeter Server
./jmeter-server -Djava.rmi.server.hostname=192.168.1.101
# 在主控机上执行分布式压测
./jmeter -n \
-t test_plan.jmx \
-R 192.168.1.101,192.168.1.102,192.168.1.103 \
-l results.jtl
5.9 性能测试插件推荐
| 插件 | 功能 |
|---|---|
| Throughput Shaping Timer | 精确控制 RPS/TPS |
| Concurrency Thread Group | 更精准的并发控制 |
| Ultimate Thread Group | 自定义负载曲线 |
| PerfMon | 服务器资源监控(CPU/内存/IO) |
| Response Times Over Time | 响应时间趋势图 |
bash
# 安装插件管理器
# 下载 jmeter-plugins-manager-*.jar 放入 lib/ext 目录
# 然后在 Options → Plugins Manager 中安装
5.10 性能测试流程
1. 确定测试目标 → 吞吐量目标、响应时间目标、并发用户数
2. 分析业务场景 → 哪些接口/功能需要压测
3. 准备测试数据 → 参数化用户、商品、订单等数据
4. 编写测试脚本 → JMeter 脚本录制或手动编写
5. 基线测试 → 1 个用户,验证脚本正确性
6. 负载测试 → 逐步增加用户数,观察系统表现
7. 压力测试 → 找到系统破坏点
8. 服务器监控 → CPU、内存、网络、磁盘 IO
9. 分析瓶颈 → 数据库慢查询?连接池耗尽?GC?
10. 优化并重测 → 验证优化效果
6. 模糊测试(Fuzzing)
6.1 什么是 Fuzzing
模糊测试(Fuzzing / Fuzz Testing) 是一种通过向目标程序注入大量随机的、意外的或格式错误的输入来发现安全漏洞、稳定性问题和内存错误的自动化测试技术。
它能高效发现:
- 缓冲区溢出(Buffer Overflow)
- 内存越界读写(Out-of-bounds Read/Write)
- 格式字符串漏洞
- 整数溢出
- 空指针解引用
- 拒绝服务(DoS)漏洞
- Use-After-Free
6.2 Fuzzing 的分类
按测试对象的可见度分类
| 类型 | 说明 | 代表工具 |
|---|---|---|
| 黑盒 Fuzzing | 不了解内部实现,纯粹基于输入/输出 | Radamsa, zzuf |
| 灰盒 Fuzzing | 有代码覆盖率等运行时反馈,引导变异 | AFL++, LibFuzzer |
| 白盒 Fuzzing | 基于源码/符号执行,精确生成输入 | KLEE, SAGE |
按输入生成策略分类
| 策略 | 说明 |
|---|---|
| 基于变异(Mutation-based) | 从已知有效输入进行随机变异 |
| 基于生成(Generation-based) | 根据协议或格式的语法规则生成输入 |
| 覆盖引导(Coverage-guided) | 根据代码覆盖率反馈,优先探索新路径 |
6.3 覆盖引导模糊测试工作原理
┌──────────────────────────────────────────────┐
│ Fuzzing 工作循环 │
│ │
│ ┌─────────┐ 变异 ┌──────────────┐ │
│ │ 语料库 │ ─────────→ │ 生成测试用例 │ │
│ │(Corpus) │ └──────┬───────┘ │
│ └────┬────┘ │ 执行 │
│ │ 更新 ↓ │
│ │ ┌──────────────┐ │
│ └──────────────│ 目标程序 │ │
│ └──────┬───────┘ │
│ │ 观察 │
│ ↓ │
│ ┌──────────────────┐ │
│ │ 覆盖率/崩溃 反馈 │ │
│ └──────────────────┘ │
└──────────────────────────────────────────────┘
当发现新代码路径时,将触发该路径的输入保存到语料库,用于后续变异。
6.4 AFL++ 使用教程
AFL++ 是 AFL(American Fuzzy Lop)的增强版,目前最主流的覆盖引导 Fuzzer。
安装
bash
# Ubuntu/Debian
sudo apt install afl++
# 从源码编译(推荐获取最新功能)
git clone https://github.com/AFLplusplus/AFLplusplus
cd AFLplusplus
make distrib
sudo make install
基本使用流程
bash
# 步骤 1:使用 AFL 编译器插桩目标程序
export CC=afl-clang-fast
export CXX=afl-clang-fast++
./configure --prefix=/tmp/xml_install
make && make install
# 步骤 2:准备初始语料库(越小越好,建议 < 1KB)
mkdir -p fuzz_input
echo '{"key": "value"}' > fuzz_input/sample.json
# 步骤 3:运行 AFL++
afl-fuzz -i fuzz_input -o fuzz_output -- /tmp/xml_install/bin/xmllint --debug @@
# @@ 会被 AFL 替换为测试用例文件路径
# 步骤 4:并行运行(利用多核)
# 主 Fuzzer(-M = master)
afl-fuzz -i fuzz_input -o fuzz_sync -M fuzzer1 -- ./target @@
# 次 Fuzzer(-S = secondary,在不同终端)
afl-fuzz -i fuzz_input -o fuzz_sync -S fuzzer2 -- ./target @@
afl-fuzz -i fuzz_input -o fuzz_sync -S fuzzer3 -- ./target @@
AFL++ 输出界面解读
american fuzzy lop ++4.09c (fuzzer1)
┌─ process timing ─────────────────────────────────┐
│ run time : 0 days, 0 hrs, 5 min, 23 sec │
│ last new find : 0 days, 0 hrs, 0 min, 5 sec │
│ last uniq crash : none seen yet │
│ last uniq hang : none seen yet │
├─ overall results ────────────────────────────────┤
│ cycles done : 0 │ ← 语料库已遍历次数
│ corpus count : 47 │ ← 当前语料库数量
│found crashes : 0 │ ← 发现的崩溃数
│ exec speed : 5.23k/sec │ ← 每秒执行次数
└──────────────────────────────────────────────────┘
语料库优化工具
bash
# afl-cmin:去除覆盖相同代码的重复样本
afl-cmin -i large_corpus/ -o small_corpus/ -- ./target @@
# afl-tmin:最小化单个崩溃样本(便于分析)
afl-tmin -i crash_sample -o minimized_crash -- ./target @@
6.5 libFuzzer 使用教程
libFuzzer 是 LLVM 项目的一部分,为进程内覆盖引导 Fuzzer,适合对单个函数/库进行 Fuzzing。
编写 Fuzz Target
cpp
// fuzz_json.cpp
#include <stdint.h>
#include <stddef.h>
#include "my_json_parser.h"
// libFuzzer 要求函数签名固定
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
// 防止崩溃影响 Fuzzer 进程
try {
std::string input(reinterpret_cast<const char*>(data), size);
JsonParser parser;
parser.parse(input); // 测试目标函数
} catch (const std::exception &e) {
// 捕获受控异常,不作为 bug
}
return 0; // 返回 0 表示该输入不需要进入语料库
}
编译与运行
bash
# 使用 AddressSanitizer 和 libFuzzer 编译
clang++ -g -O1 \
-fsanitize=fuzzer,address,undefined \
fuzz_json.cpp my_json_parser.cpp \
-o fuzz_json
# 运行(不提供初始语料库时,从随机数据开始)
./fuzz_json -max_len=1024 -timeout=10
# 提供初始语料库
mkdir corpus
./fuzz_json corpus/ seed_corpus/
# 最小化语料库
./fuzz_json -merge=1 small_corpus/ large_corpus/
与 Sanitizer 结合
bash
# AddressSanitizer (ASan) - 检测内存错误
clang -fsanitize=fuzzer,address -g -O1 target.c -o target_asan
# UndefinedBehaviorSanitizer (UBSan) - 检测未定义行为
clang -fsanitize=fuzzer,undefined -g -O1 target.c -o target_ubsan
# MemorySanitizer (MSan) - 检测未初始化内存读取
clang -fsanitize=fuzzer,memory -g -O1 target.c -o target_msan
6.6 Go Fuzzing(内置支持)
Go 1.18+ 原生支持 Fuzzing:
go
// fuzz_test.go
package mypackage
import (
"testing"
"unicode/utf8"
)
func FuzzReverse(f *testing.F) {
// 提供初始种子
f.Add("hello")
f.Add("world")
f.Add("")
f.Fuzz(func(t *testing.T, s string) {
reversed := Reverse(s)
// 验证属性:双重反转应等于原字符串
doubleReversed := Reverse(reversed)
if s != doubleReversed {
t.Errorf("反转两次后不等于原字符串: %q -> %q -> %q", s, reversed, doubleReversed)
}
// 验证属性:如果输入是有效 UTF-8,反转后也应该是
if utf8.ValidString(s) && !utf8.ValidString(reversed) {
t.Errorf("有效 UTF-8 字符串反转后变为无效: %q -> %q", s, reversed)
}
})
}
bash
# 运行 Fuzz 测试
go test -fuzz=FuzzReverse -fuzztime=30s
# 运行已发现的 Fuzz 语料库(回归测试)
go test -run=FuzzReverse
6.7 常见 Fuzzing 工具对比
| 工具 | 类型 | 语言 | 特点 |
|---|---|---|---|
| AFL++ | 灰盒 | C/C++ | 最广泛使用,支持闭源(QEMU 模式) |
| libFuzzer | 灰盒 | C/C++ | 进程内,速度快,LLVM 生态 |
| HonggFuzz | 灰盒 | C/C++ | 多线程,速度更快,硬件覆盖支持 |
| OSS-Fuzz | 持续集成 | 多语言 | Google 提供的开源项目持续 Fuzzing 平台 |
| go-fuzz | 灰盒 | Go | Go 语言,1.18 前的方案 |
| Atheris | 覆盖引导 | Python | libFuzzer 的 Python 绑定 |
| jazzer | 覆盖引导 | Java/JVM | JVM 语言 Fuzzing |
| syzkaller | 覆盖引导 | C(内核) | Linux 内核 Fuzzing,Google 维护 |
6.8 Fuzzing 发现的真实漏洞案例
- Heartbleed(CVE-2014-0160):OpenSSL 内存越界读,Fuzzing 可发现此类问题
- ImageMagick 系列漏洞:处理图片时的内存错误
- 各浏览器引擎漏洞:Google Project Zero 使用 Fuzzing 大量发现 V8、WebKit 漏洞
- Linux 内核漏洞:syzkaller 每年发现数百个内核 bug
7. 契约测试(Contract Testing)
7.1 为什么需要契约测试
在微服务架构中,服务之间通过 API 通信。传统的验证方式------E2E 集成测试------存在明显缺陷:
| 问题 | 说明 |
|---|---|
| 速度慢 | 需要启动所有服务,测试耗时长 |
| 维护成本高 | 任何一个服务变更都可能导致测试失败 |
| 反馈周期长 | 问题往往在 CI 流水线后期才暴露 |
| 环境依赖 | 需要完整的测试环境 |
| 调试困难 | 多服务链路中定位问题根源困难 |
契约测试通过让消费者和提供者各自独立验证一份"契约",解决了上述问题。
7.2 契约测试核心概念
消费者(Consumer)
↓ 定义期望
契约文件(.pact)
↓ 共享给
提供者(Provider)
↓ 验证契约
验证结果
关键术语:
| 术语 | 说明 |
|---|---|
| Consumer(消费者) | 使用另一个服务 API 的服务(通常是客户端) |
| Provider(提供者) | 提供 API 的服务(通常是服务端) |
| Pact 文件(契约) | JSON 格式,描述消费者对提供者的所有期望交互 |
| Pact Broker | 存储和共享契约文件的中央仓库 |
| PactFlow | Pact 官方托管版 Pact Broker |
| Verification(验证) | 提供者用实际代码验证能否满足契约中的期望 |
7.3 消费者驱动的契约测试(CDCT)工作流
步骤 1:消费者编写契约测试
Consumer Test Code
↓ 运行测试(使用 Pact Mock Server)
生成 .pact 文件(JSON 格式)
步骤 2:共享契约
.pact 文件 → 上传到 Pact Broker
步骤 3:提供者验证契约
Pact Verifier 工具
↓ 下载 .pact 文件
↓ 回放所有交互请求
↓ 对比实际响应与期望
验证通过/失败
步骤 4:CI/CD 集成
can-i-deploy 检查
↓ 确认所有契约已验证
↓ 安全部署
7.4 Pact 安装与使用(JavaScript/TypeScript)
消费者侧测试
bash
npm install --save-dev @pact-foundation/pact
typescript
// consumer/src/api.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { UserApiClient } from './user-api-client';
import path from 'path';
const { like, string, integer, eachLike } = MatchersV3;
// 定义 Pact(消费者和提供者的身份)
const provider = new PactV3({
consumer: 'UserDashboard',
provider: 'UserService',
dir: path.resolve(__dirname, '../pacts'),
port: 4000,
});
describe('UserApiClient', () => {
describe('获取用户信息', () => {
test('返回指定 ID 的用户', async () => {
// Arrange:定义期望的交互
await provider
.given('用户 ID=1 存在')
.uponReceiving('GET /users/1 请求')
.withRequest({
method: 'GET',
path: '/api/users/1',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: integer(1),
name: string('Alice'),
email: string('alice@example.com'),
role: string('admin'),
},
})
.executeTest(async (mockServer) => {
// Act:调用真实的 API 客户端(指向 Mock Server)
const client = new UserApiClient(mockServer.url);
const user = await client.getUser(1);
// Assert:验证响应是否符合消费者预期
expect(user.id).toBe(1);
expect(user.name).toBe('Alice');
expect(user.email).toBe('alice@example.com');
});
});
test('用户不存在时返回 404', async () => {
await provider
.given('用户 ID=999 不存在')
.uponReceiving('GET /users/999 请求')
.withRequest({
method: 'GET',
path: '/api/users/999',
})
.willRespondWith({
status: 404,
body: { error: like('User not found') },
})
.executeTest(async (mockServer) => {
const client = new UserApiClient(mockServer.url);
await expect(client.getUser(999)).rejects.toThrow('User not found');
});
});
test('获取用户列表', async () => {
await provider
.given('存在多个用户')
.uponReceiving('GET /users 请求')
.withRequest({
method: 'GET',
path: '/api/users',
})
.willRespondWith({
status: 200,
body: eachLike({
id: integer(1),
name: string('Alice'),
email: string('alice@example.com'),
}),
})
.executeTest(async (mockServer) => {
const client = new UserApiClient(mockServer.url);
const users = await client.getUsers();
expect(users.length).toBeGreaterThan(0);
});
});
});
});
发布契约到 Pact Broker
bash
# 使用 Pact CLI 发布
npx pact-broker publish ./pacts \
--broker-base-url https://your-pact-broker.pactflow.io \
--broker-token YOUR_BROKER_TOKEN \
--consumer-app-version $(git rev-parse HEAD) \
--branch $(git rev-parse --abbrev-ref HEAD)
提供者侧验证
typescript
// provider/src/contract-verification.test.ts
import { Verifier } from '@pact-foundation/pact';
import path from 'path';
import { app } from './app';
import http from 'http';
describe('契约验证 - UserService', () => {
let server: http.Server;
let port: number;
beforeAll(async () => {
server = app.listen(0);
port = (server.address() as any).port;
});
afterAll(() => server.close());
test('验证所有消费者契约', async () => {
const verifier = new Verifier({
provider: 'UserService',
providerBaseUrl: `http://localhost:${port}`,
// 从 Pact Broker 获取契约
pactBrokerUrl: 'https://your-pact-broker.pactflow.io',
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
// 或使用本地文件(开发阶段)
// pactUrls: [path.resolve(__dirname, '../pacts/UserDashboard-UserService.json')],
// Provider State 处理器:设置测试数据
stateHandlers: {
'用户 ID=1 存在': async () => {
await db.users.create({ id: 1, name: 'Alice', email: 'alice@example.com' });
},
'用户 ID=999 不存在': async () => {
await db.users.deleteWhere({ id: 999 });
},
'存在多个用户': async () => {
await db.users.createMany([
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
]);
},
},
publishVerificationResult: process.env.CI === 'true',
providerVersion: process.env.GIT_COMMIT,
providerVersionBranch: process.env.GIT_BRANCH,
});
await verifier.verifyProvider();
});
});
7.5 Java(Spring Boot)版本示例
java
// 消费者侧(JUnit 5 + Pact)
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "UserService", port = "8080")
class UserApiClientTest {
@Pact(consumer = "UserDashboard")
public RequestResponsePact getUserPact(PactDslWithProvider builder) {
return builder
.given("用户 ID=1 存在")
.uponReceiving("获取用户信息请求")
.path("/api/users/1")
.method("GET")
.willRespondWith()
.status(200)
.header("Content-Type", "application/json")
.body(new PactDslJsonBody()
.integerType("id", 1)
.stringType("name", "Alice")
.stringType("email", "alice@example.com"))
.toPact();
}
@Test
@PactTestFor(pactMethod = "getUserPact")
void testGetUser(MockServer mockServer) {
UserApiClient client = new UserApiClient(mockServer.getUrl());
User user = client.getUser(1L);
assertNotNull(user);
assertEquals(1L, user.getId());
assertEquals("Alice", user.getName());
}
}
7.6 契约类型对比
| 类型 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| 消费者驱动契约(CDC) | 消费者定义期望,提供者验证 | 确保提供者满足所有消费者需求 | 需要消费者团队主动参与 |
| 提供者驱动契约 | 提供者定义契约(如 OpenAPI) | 单方维护 | 无法保证满足所有消费者 |
| 双向契约测试 | 双方各自验证 | 最全面 | 实现复杂 |
7.7 契约测试 vs 集成测试
| 维度 | 集成测试 | 契约测试 |
|---|---|---|
| 运行环境 | 需要所有服务 | 各服务独立运行 |
| 速度 | 慢(分钟级) | 快(秒级) |
| 发现的问题 | 实际运行时问题 | API 不兼容问题 |
| 维护成本 | 高(依赖多) | 低(各自维护) |
| 适合场景 | 验证端到端流程 | 验证服务接口兼容性 |
7.8 Pact Broker 集成
bash
# 使用 Docker 自托管 Pact Broker
docker run -d \
--name pact-broker \
-p 9292:9292 \
-e PACT_BROKER_DATABASE_URL="sqlite:////tmp/pact_broker.sqlite3" \
pactfoundation/pact-broker
# 访问 http://localhost:9292
7.9 CI/CD 集成示例
yaml
# .github/workflows/consumer.yml
name: Consumer CI
on: [push]
jobs:
test-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 运行消费者契约测试
run: npm test
- name: 发布契约到 Pact Broker
run: |
npx pact-broker publish ./pacts \
--broker-base-url ${{ secrets.PACT_BROKER_URL }} \
--broker-token ${{ secrets.PACT_BROKER_TOKEN }} \
--consumer-app-version ${{ github.sha }} \
--branch ${{ github.ref_name }}
- name: 检查是否可以部署
run: |
npx pact-broker can-i-deploy \
--pacticipant UserDashboard \
--broker-base-url ${{ secrets.PACT_BROKER_URL }} \
--broker-token ${{ secrets.PACT_BROKER_TOKEN }} \
--version ${{ github.sha }} \
--to-environment production
7.10 契约测试的适用与不适用场景
适用场景:
- 微服务架构中服务间 HTTP/消息通信
- 有明确消费者和提供者关系的场景
- 需要独立部署、独立测试的服务
- 多个消费者依赖同一个提供者
不适用场景:
- 验证业务逻辑正确性(不是契约测试的职责)
- 性能测试
- 第三方不使用 Pact 的情况(摩擦大)
- 消费者和提供者由同一团队维护且强耦合
8. CI/CD 中的测试策略
8.1 测试流水线设计
yaml
# 典型的 GitHub Actions 测试流水线
name: Test Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
# 阶段 1:单元测试(快速反馈,< 3 分钟)
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run Unit Tests
run: |
npm install
npm run test:unit -- --coverage
- name: Upload Coverage
uses: codecov/codecov-action@v3
# 阶段 2:集成测试(中等速度,< 10 分钟)
integration-test:
needs: unit-test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
steps:
- uses: actions/checkout@v3
- name: Run Integration Tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://postgres:postgres@localhost/testdb
# 阶段 3:契约测试
contract-test:
needs: unit-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run Contract Tests
run: npm run test:contract
- name: Publish Pact
run: npm run pact:publish
# 阶段 4:E2E 测试(较慢,仅在 PR 到 main 时运行)
e2e-test:
needs: [integration-test, contract-test]
if: github.base_ref == 'main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E Tests
run: npx playwright test
- name: Upload Playwright Report
uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
# 阶段 5:性能测试(定时或手动触发)
performance-test:
needs: e2e-test
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run JMeter Tests
run: |
jmeter -n -t tests/performance/load_test.jmx \
-l results/result.jtl \
-e -o results/report
8.2 测试环境策略
| 环境 | 运行的测试 | 触发时机 |
|---|---|---|
| 开发(Local) | 单元测试 | 文件保存时 / 提交前 |
| PR(Pull Request) | 单元 + 集成 + 契约 | PR 创建/更新 |
| Staging | 全部(包含 E2E) | 合并到 main 分支 |
| Production | 冒烟测试 | 生产部署后 |
| 定时 | 性能测试 + 安全扫描 | 每日 / 每周 |
9. 测试最佳实践总结
9.1 通用原则
1. 测试金字塔原则
- 单元测试 > 集成测试 > E2E 测试(在数量上)
- 越底层的测试,ROI(投入产出比)越高
2. 测试应该是独立的(Independent)
- 每个测试不依赖其他测试的执行顺序
- 每个测试有自己的数据准备和清理
3. 测试应该是确定的(Deterministic)
- 相同的代码,多次运行结果相同
- 避免依赖时间、随机数、外部服务
4. 早测试,持续测试(Shift-Left Testing)
- 测试越早介入,发现问题的成本越低
- 将测试纳入 CI/CD 流水线
5. 测试即文档(Tests as Documentation)
- 好的测试名称描述了业务行为
- 测试用例是最好的 API 文档
9.2 各类型测试的选择指南
决策树:选择合适的测试类型
═══════════════════════════════════════════
Q1: 是否需要验证单个函数/类的逻辑?
YES → 单元测试(Unit Test)
Q2: 是否需要验证两个或多个模块的协作?
YES → 集成测试(Integration Test)
Q3: 是否需要从用户角度验证完整业务流程?
YES → E2E 测试(选 Playwright > Cypress > Selenium)
Q4: 是否需要验证系统在高并发下的性能?
YES → 性能测试(JMeter / Gatling / Locust)
Q5: 是否需要发现安全漏洞和内存错误?
YES → Fuzzing(AFL++ / libFuzzer)
Q6: 是否需要验证微服务 API 接口兼容性?
YES → 契约测试(Pact)
9.3 工具技术栈推荐
前端 Web 应用
单元/组件测试 → Jest + React Testing Library / Vitest
E2E 测试 → Playwright(首选)/ Cypress
视觉回归 → Playwright Screenshots / Percy / Chromatic
性能测试 → Lighthouse CI / k6
后端服务(Java)
单元测试 → JUnit 5 + Mockito
集成测试 → Spring Boot Test + Testcontainers
API 测试 → REST Assured
契约测试 → Pact JVM
性能测试 → JMeter / Gatling
后端服务(Python)
单元测试 → pytest + unittest.mock
集成测试 → pytest + SQLAlchemy + testcontainers-python
API 测试 → pytest + requests / httpx
契约测试 → pact-python
性能测试 → Locust / JMeter
Fuzzing → Atheris(libFuzzer)
后端服务(Go)
单元测试 → testing 标准库 + testify
集成测试 → testing + testcontainers-go
API 测试 → net/http/httptest
性能测试 → go test -bench / k6
Fuzzing → go test -fuzz(内置)
9.4 测试覆盖率目标建议
| 项目阶段 | 单元测试覆盖率 | 集成测试关键路径 |
|---|---|---|
| 初创/MVP | ≥ 50% | 核心流程 100% |
| 成长期 | ≥ 70% | 主要 API 100% |
- | 成熟期 | ≥ 80% | 全部 API 100% |
| 关键系统 | ≥ 90% | 含异常路径 |
9.5 常见陷阱与解决方案
| 问题 | 症状 | 解决方案 |
|---|---|---|
| 脆弱测试(Flaky Tests) | 测试时通时失败 | 消除随机性,固定测试数据,使用 Playwright 等自带稳定性的框架 |
| 测试速度过慢 | CI 超过 30 分钟 | 并行化、只运行受影响的测试(Test Impact Analysis) |
| 过度 Mock | 单元测试通过但集成失败 | 减少 Mock,增加真实集成测试 |
| 测试覆盖不均衡 | 只有 E2E,没有单元测试 | 构建测试金字塔,补充底层测试 |
| 测试维护成本高 | 需求变化后大量测试失败 | 避免测试实现细节,测试行为而非实现 |
| 环境不一致 | 本地通过,CI 失败 | 使用 Docker/Testcontainers 统一环境 |
附录 A:快速参考
测试框架速查表
| 语言 | 单元测试框架 | Mock 库 | E2E 框架 |
|---|---|---|---|
| Java | JUnit 5, TestNG | Mockito, EasyMock | Selenium, Playwright |
| Python | pytest, unittest | unittest.mock, pytest-mock | Playwright, Selenium |
| JavaScript | Jest, Mocha, Vitest | Jest Built-in | Playwright, Cypress |
| TypeScript | Jest, Vitest | Jest Built-in | Playwright, Cypress |
| Go | testing | gomock, testify | Playwright (via PW-Go) |
| C# | NUnit, xUnit, MSTest | Moq, NSubstitute | Selenium, Playwright |
| Ruby | RSpec, Minitest | Mocha, RSpec Mocks | Selenium, Capybara |
常用测试断言速查
javascript
// Jest / Playwright
expect(value).toBe(42)
expect(value).toEqual({ a: 1 })
expect(value).toBeTruthy()
expect(value).toBeNull()
expect(arr).toHaveLength(3)
expect(str).toContain('hello')
expect(fn).toThrow('error')
expect(mock).toHaveBeenCalledTimes(1)
expect(mock).toHaveBeenCalledWith('arg')
python
# pytest
assert value == 42
assert value is None
assert 'hello' in string
assert len(arr) == 3
with pytest.raises(ValueError):
risky_function()
mock.assert_called_once_with('arg')
java
// JUnit 5
assertEquals(42, value);
assertNotNull(value);
assertTrue(condition);
assertThrows(Exception.class, () -> riskyMethod());
assertThat(list).hasSize(3); // AssertJ
verify(mock).method(anyString()); // Mockito
附录 B:学习资源
官方文档
| 工具 | 官网 |
|---|---|
| Playwright | https://playwright.dev |
| Cypress | https://docs.cypress.io |
| Selenium | https://www.selenium.dev |
| JMeter | https://jmeter.apache.org |
| Pact | https://docs.pact.io |
| AFL++ | https://github.com/AFLplusplus/AFLplusplus |
| libFuzzer | https://llvm.org/docs/LibFuzzer.html |
| OSS-Fuzz | https://google.github.io/oss-fuzz |
延伸阅读
- 《测试驱动开发》------ Kent Beck
- 《Google 软件测试之道》------ James Whittaker
- 《The Art of Software Testing》------ Glenford Myers
- 《Growing Object-Oriented Software, Guided by Tests》------ Freeman & Pryce
- 《The Fuzzing Book》------ https://www.fuzzingbook.org
- Martin Fowler 测试文章集:https://martinfowler.com/testing/