Files
wenzi/docs/TESTING_BEST_PRACTICES.md

1618 lines
50 KiB
Markdown
Raw Normal View History

# 测试最佳实践检查清单
> 基于蚊子项目1210+测试的真实经验总结
>
> 本文档旨在帮助识别和规避AI生成测试中常见的陷阱提升测试质量和可维护性。
## 📋 目录
1. [AI测试陷阱识别清单](#1-ai测试陷阱识别清单)
2. [测试质量评估矩阵](#2-测试质量评估矩阵)
3. [可维护性检查清单](#3-可维护性检查清单)
4. [性能测试最佳实践](#4-性能测试最佳实践)
5. [并发测试策略](#5-并发测试策略)
6. [测试命名与文档规范](#6-测试命名与文档规范)
7. [Mock使用准则](#7-mock使用准则)
8. [断言最佳实践](#8-断言最佳实践)
9. [边界条件系统化方法](#9-边界条件系统化方法)
10. [持续改进检查表](#10-持续改进检查表)
---
## 1. AI测试陷阱识别清单
### 1.1 过度测试框架本身 🚫
#### 问题描述
AI常生成测试Lombok生成的getter/setter、构造函数或框架代码的测试这些测试毫无意义。
#### ❌ 错误示例
```java
@Test
void testGetterSetter() {
User user = new User();
user.setName("test");
assertEquals("test", user.getName()); // 测试Lombok生成的方法
}
@Test
void testConstructor() {
User user = new User("id", "name");
assertNotNull(user); // 测试对象能被创建
assertEquals("id", user.getId()); // 测试简单赋值
}
```
#### ✅ 正确示例
```java
@Test
void shouldDetectDuplicateUsers_whenRegisteringWithExistingEmail() {
// 测试业务逻辑,而非框架功能
userRepository.save(new User("existing@test.com"));
DuplicateException exception = assertThrows(
DuplicateException.class,
() -> userService.register(new RegistrationRequest("existing@test.com"))
);
assertEquals("USER_EXISTS", exception.getErrorCode());
}
```
#### 检查方法
- [ ] 是否测试了框架生成的代码?
- [ ] 是否测试了无业务逻辑的简单赋值?
- [ ] 是否测试了编译器会保证的行为?
#### 自动化规则
```bash
# 查找可疑的DTO测试
grep -r "testGetter\|testSetter\|testConstructor" src/test --include="*.java"
# 查找Lombok类的无意义测试
find src/main -name "*.java" -exec grep -l "@Data\|@Getter\|@Setter" {} \; | \
xargs -I {} basename {} .java | \
xargs -I {} find src/test -name "{}Test.java" -exec grep -l "get.*()\|set.*()" {} \;
```
---
### 1.2 虚假断言 🚫
#### 问题描述
断言永远不会失败,或只验证显而易见的事情,无法真正验证业务逻辑。
#### ❌ 错误示例
```java
@Test
void testCreateObject() {
Object obj = new Object();
assertNotNull(obj); // 永远不会失败
}
@Test
void testTrueIsTrue() {
assertTrue(true); // 无意义断言
}
@Test
void testServiceCalled() {
service.doSomething();
verify(service).doSomething(); // 只验证方法被调用
}
```
#### ✅ 正确示例
```java
@Test
void shouldCalculateCorrectReward_whenUserCompletesHighValueActivity() {
// Given
Activity activity = Activity.builder()
.rewardPoints(100)
.multiplier(2.0)
.build();
// When
Reward reward = rewardCalculator.calculate(activity, user);
// Then
assertEquals(200, reward.getPoints()); // 验证实际业务值
assertEquals("PREMIUM", reward.getTier()); // 验证业务状态
assertNotNull(reward.getAwardedAt()); // 验证副作用
}
```
#### 检查方法
- [ ] 断言是否可能失败?
- [ ] 是否验证了具体的业务值?
- [ ] 是否验证了状态变化?
#### 自动化规则
```bash
# 查找虚假断言
grep -r "assertNotNull(new\|assertTrue(true\|assertFalse(false)" src/test --include="*.java"
# 查找只验证调用的测试
grep -r "verify(.*)\.doSomething\|verify(.*)\.save\|verify(.*)\.find" src/test --include="*.java" | \
grep -v "assert\|verify.*times"
```
---
## 2. 测试质量评估矩阵
### 2.1 质量维度评分表
| 维度 | 权重 | 优秀(5) | 良好(4) | 合格(3) | 较差(2) | 不合格(1) |
|------|------|---------|---------|---------|---------|-----------|
| **业务价值** | 30% | 验证核心业务流程 | 验证重要业务规则 | 验证一般功能 | 验证边缘功能 | 无业务价值 |
| **断言质量** | 25% | 多维度精确验证 | 验证具体业务值 | 基本断言 | 模糊断言 | 虚假断言 |
| **边界覆盖** | 20% | 全面边界测试 | 主要边界覆盖 | 部分边界 | 极少边界 | 无边界测试 |
| **可读性** | 15% | 自文档化+注释 | 清晰命名结构 | 基本可读 | 难以理解 | 无法维护 |
| **独立性** | 10% | 完全独立 | 偶尔共享setup | 部分依赖 | 强依赖 | 相互依赖 |
### 2.2 测试分级标准
```
A级 (90-100分): 生产级测试,值得作为范例
B级 (75-89分): 良好测试,可以接受
C级 (60-74分): 及格测试,需要改进
D级 (40-59分): 问题测试,必须重构
F级 (<40分): 无效测试,应该删除
```
### 2.3 评估检查清单
- [ ] 测试是否明确验证了业务需求?
- [ ] 失败时能否快速定位问题?
- [ ] 是否覆盖了正常、异常、边界三种情况?
- [ ] 新开发者能否理解测试意图?
- [ ] 修改被测代码后,测试能否正确失败?
- [ ] 测试执行时间是否合理(<1秒
- [ ] 是否存在测试间的隐式依赖?
### 2.4 自动化评估脚本
```bash
#!/bin/bash
# test-quality-check.sh
echo "=== 测试质量快速评估 ==="
# 1. 检查虚假断言
echo "1. 虚假断言检查:"
grep -r "assertTrue(true)\|assertFalse(false)\|assertNotNull(new" src/test --include="*.java" | wc -l
echo " 发现疑似虚假断言数量"
# 2. 检查getter/setter测试
echo "2. Getter/Setter测试检查:"
grep -r "testGetter\|testSetter\|getId()\|setName" src/test --include="*.java" | wc -l
echo " 发现疑似框架测试数量"
# 3. 检查测试复杂度
echo "3. 测试复杂度检查:"
find src/test -name "*Test.java" -exec wc -l {} \; | sort -n | tail -5
echo " 最长的5个测试文件"
# 4. 检查断言密度
echo "4. 断言密度检查:"
for file in $(find src/test -name "*Test.java" | head -20); do
tests=$(grep -c "@Test" "$file" 2>/dev/null || echo 0)
asserts=$(grep -c "assert" "$file" 2>/dev/null || echo 0)
if [ "$tests" -gt 0 ]; then
density=$(echo "scale=2; $asserts / $tests" | bc)
echo " $file: $asserts assertions / $tests tests = $density"
fi
done
```
---
## 3. 可维护性检查清单
### 3.1 硬编码值问题
#### 问题描述
测试中使用魔法数字和字符串,导致测试脆弱且难以理解。
#### ❌ 错误示例
```java
@Test
void testCalculation() {
assertEquals(1000, service.calculate(500, 2)); // 魔法数字
assertEquals("ERROR_001", exception.getCode()); // 无意义错误码
}
```
#### ✅ 正确示例
```java
class RewardCalculationTest {
private static final int BASE_POINTS = 500;
private static final int MULTIPLIER = 2;
private static final int EXPECTED_REWARD = 1000;
private static final String ERROR_INVALID_MULTIPLIER = "ERR_MULTIPLIER_NEGATIVE";
@Test
void shouldCalculateReward_whenValidMultiplierProvided() {
int result = service.calculate(BASE_POINTS, MULTIPLIER);
assertEquals(EXPECTED_REWARD, result);
}
}
```
#### 检查方法
- [ ] 所有字面量是否有明确含义?
- [ ] 是否使用了有意义的常量名?
- [ ] 魔法数字是否分散在测试各处?
---
### 3.2 重复代码问题
#### 问题描述
多个测试中重复相同的setup和断言逻辑难以维护。
#### ❌ 错误示例
```java
@Test
void testCreateUser() {
User user = new User();
user.setName("test");
user.setEmail("test@test.com");
user.setCreatedAt(LocalDateTime.now());
// ... 重复10次
}
@Test
void testUpdateUser() {
User user = new User();
user.setName("test");
user.setEmail("test@test.com");
user.setCreatedAt(LocalDateTime.now());
// ... 同样的重复
}
```
#### ✅ 正确示例
```java
class UserServiceTest {
private User testUser;
@BeforeEach
void setUp() {
testUser = createDefaultUser();
}
private User createDefaultUser() {
return User.builder()
.name("Test User")
.email("test@test.com")
.createdAt(LocalDateTime.now())
.build();
}
private void assertUserHasDefaultValues(User user) {
assertAll("User default values",
() -> assertEquals("Test User", user.getName()),
() -> assertEquals("test@test.com", user.getEmail()),
() -> assertNotNull(user.getCreatedAt())
);
}
}
```
#### 检查方法
- [ ] 是否使用了@BeforeEach/@BeforeAll
- [ ] 是否有测试数据工厂类?
- [ ] 是否有共享的断言方法?
---
### 3.3 测试间依赖问题
#### 问题描述
测试之间存在隐式依赖,执行顺序影响结果,难以并行运行。
#### ❌ 错误示例
```java
@Test
@Order(1) // 强制顺序本身就是问题信号
void testCreate() {
userService.createUser("test");
}
@Test
@Order(2) // 依赖testCreate
void testRead() {
User user = userService.findByName("test"); // 依赖前面的测试数据
assertNotNull(user);
}
```
#### ✅ 正确示例
```java
@Test
void shouldCreateAndRetrieveUser_independently() {
// Given - 每个测试自己准备数据
String uniqueName = "test_" + UUID.randomUUID();
// When
userService.createUser(uniqueName);
User created = userService.findByName(uniqueName);
// Then
assertNotNull(created);
// Cleanup - 自己清理
userService.delete(created.getId());
}
```
#### 检查方法
- [ ] 是否使用了@Order注解?(危险信号)
- [ ] 测试能否独立运行?
- [ ] 是否共享可变状态?
- [ ] 能否并行执行(`mvn test -Dparallel`
---
## 4. 性能测试最佳实践
### 4.1 性能测试类型
```java
/**
* 1. 基线测试 - 记录正常性能指标
*/
@Test
void shouldCompleteWithinBaseline_whenProcessingStandardLoad() {
// Given
List<Data> standardData = DataFactory.create(100); // 标准数据量
// When
long start = System.currentTimeMillis();
Result result = processor.process(standardData);
long duration = System.currentTimeMillis() - start;
// Then
assertTrue(duration < 500, // 基线: <500ms
"Processing took " + duration + "ms, expected <500ms");
assertNotNull(result);
}
/**
* 2. 负载测试 - 测试预期峰值
*/
@Test
void shouldHandlePeakLoad_whenConcurrentRequestsArrive() {
int concurrentUsers = 100;
int requestsPerUser = 10;
ExecutorService executor = Executors.newFixedThreadPool(concurrentUsers);
CountDownLatch latch = new CountDownLatch(concurrentUsers * requestsPerUser);
AtomicInteger successCount = new AtomicInteger(0);
// When
for (int i = 0; i < concurrentUsers; i++) {
executor.submit(() -> {
for (int j = 0; j < requestsPerUser; j++) {
try {
Response response = api.call();
if (response.isSuccess()) {
successCount.incrementAndGet();
}
} finally {
latch.countDown();
}
}
});
}
// Then
assertTrue(latch.await(30, TimeUnit.SECONDS));
assertEquals(concurrentUsers * requestsPerUser, successCount.get());
executor.shutdown();
}
/**
* 3. 压力测试 - 测试系统极限
*/
@Test
void shouldDegradeGracefully_whenExceedingCapacity() {
// 逐步增加负载直到系统出现性能下降
for (int load : Arrays.asList(100, 500, 1000, 2000, 5000)) {
PerformanceResult result = runWithLoad(load);
if (result.getErrorRate() > 0.05) { // 5%错误率阈值
// 记录最大容量
log.info("System capacity limit: {} requests", load);
assertTrue(result.isGracefulDegradation(),
"System should degrade gracefully");
break;
}
}
}
/**
* 4. 稳定性测试 - 长时间运行
*/
@Test
void shouldMaintainPerformance_overExtendedPeriod() {
long testDuration = TimeUnit.MINUTES.toMillis(5);
long startTime = System.currentTimeMillis();
List<Long> responseTimes = new ArrayList<>();
while (System.currentTimeMillis() - startTime < testDuration) {
long callStart = System.currentTimeMillis();
api.call();
responseTimes.add(System.currentTimeMillis() - callStart);
Thread.sleep(100); // 模拟真实间隔
}
// 分析响应时间趋势
double avgResponseTime = responseTimes.stream()
.mapToLong(Long::longValue)
.average()
.orElse(0);
double p95ResponseTime = calculatePercentile(responseTimes, 95);
assertTrue(avgResponseTime < 200, "Average response time too high");
assertTrue(p95ResponseTime < 500, "P95 response time too high");
}
```
### 4.2 性能测试检查清单
- [ ] 是否建立了性能基线?
- [ ] 是否测试了预期峰值负载?
- [ ] 是否测试了系统极限(压力测试)?
- [ ] 是否进行了长时间稳定性测试?
- [ ] 是否测量了响应时间分布P50, P95, P99
- [ ] 是否监控了资源使用CPU, 内存, IO
- [ ] 是否有性能回归检测机制?
### 4.3 性能反模式
```java
// ❌ 在单元测试中测试性能
@Test
void testPerformance() { // 错误:单元测试不该测性能
for (int i = 0; i < 1000000; i++) {
service.doSomething();
}
}
// ❌ 不稳定的性能断言
@Test
void testResponseTime() {
long start = System.currentTimeMillis();
service.call();
long duration = System.currentTimeMillis() - start;
assertTrue(duration < 10); // 太严格,环境差异会导致失败
}
// ❌ 只测试吞吐量不测试延迟
@Test
void testThroughput() {
int count = 0;
long end = System.currentTimeMillis() + 1000;
while (System.currentTimeMillis() < end) {
service.call();
count++;
}
assertTrue(count > 1000); // 只关心数量不关心单个请求质量
}
```
---
## 5. 并发测试策略
### 5.1 并发测试基本原则
```java
/**
* 并发测试黄金法则:
* 1. 不要依赖Thread.sleep() - 使用CountDownLatch/CyclicBarrier
* 2. 不要假设执行顺序 - 使用同步原语控制
* 3. 测试竞争条件,而不仅仅是并发执行
* 4. 验证最终一致性和不变量
*/
```
### 5.2 常见并发测试模式
```java
/**
* 模式1: 测试竞态条件 - 库存扣减
*/
@Test
void shouldPreventOverselling_whenConcurrentPurchases() throws InterruptedException {
// Given
Product product = productRepository.save(
Product.builder().stock(10).build()
);
int concurrentBuyers = 15;
ExecutorService executor = Executors.newFixedThreadPool(concurrentBuyers);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch completeLatch = new CountDownLatch(concurrentBuyers);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);
// When - 所有线程同时开始
for (int i = 0; i < concurrentBuyers; i++) {
executor.submit(() -> {
try {
startLatch.await(); // 等待统一信号
try {
purchaseService.buy(product.getId(), 1);
successCount.incrementAndGet();
} catch (OutOfStockException e) {
failCount.incrementAndGet();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
completeLatch.countDown();
}
});
}
startLatch.countDown(); // 同时触发所有线程
completeLatch.await(10, TimeUnit.SECONDS);
// Then - 验证不变量
Product updated = productRepository.findById(product.getId()).orElseThrow();
assertEquals(10, successCount.get(), "只有10个应该成功");
assertEquals(5, failCount.get(), "5个应该失败");
assertEquals(0, updated.getStock(), "库存应该为0");
}
/**
* 模式2: 测试死锁
*/
@Test
void shouldNotDeadlock_whenNestedLockAcquisition() {
Account account1 = new Account("A1", 1000);
Account account2 = new Account("A2", 1000);
ExecutorService executor = Executors.newFixedThreadPool(2);
// 两个线程以不同顺序获取锁(经典的死锁场景)
Future<?> future1 = executor.submit(() ->
transferService.transfer(account1, account2, 100));
Future<?> future2 = executor.submit(() ->
transferService.transfer(account2, account1, 100));
// 验证不会死锁
assertDoesNotThrow(() -> {
future1.get(5, TimeUnit.SECONDS);
future2.get(5, TimeUnit.SECONDS);
});
}
/**
* 模式3: 测试可见性
*/
@Test
void shouldEnsureVisibility_whenMultipleThreadsReadWrite()
throws InterruptedException {
ConcurrentProcessor processor = new ConcurrentProcessor();
int writerCount = 5;
int readerCount = 10;
int iterations = 1000;
ExecutorService executor = Executors.newFixedThreadPool(writerCount + readerCount);
CountDownLatch latch = new CountDownLatch(writerCount + readerCount);
AtomicInteger visibilityViolations = new AtomicInteger(0);
// Writers
for (int i = 0; i < writerCount; i++) {
final int writerId = i;
executor.submit(() -> {
for (int j = 0; j < iterations; j++) {
processor.write(writerId, j);
}
latch.countDown();
});
}
// Readers
for (int i = 0; i < readerCount; i++) {
executor.submit(() -> {
for (int j = 0; j < iterations; j++) {
Data data = processor.read();
// 验证读取的数据一致性
if (!data.isConsistent()) {
visibilityViolations.incrementAndGet();
}
}
latch.countDown();
});
}
latch.await(30, TimeUnit.SECONDS);
assertEquals(0, visibilityViolations.get(),
"不应该出现可见性违规");
}
/**
* 模式4: 使用JCStress进行系统并发测试
*/
@JCStressTest
@Outcome(id = "1, 1", expect = Expect.ACCEPTABLE, desc = "两者都看到完整写入")
@Outcome(id = "0, 1", expect = Expect.ACCEPTABLE, desc = "一个看到写入")
@Outcome(id = "1, 0", expect = Expect.ACCEPTABLE, desc = "另一个看到写入")
@Outcome(id = "0, 0", expect = Expect.FORBIDDEN, desc = "都不看到写入是bug")
@State
public class VolatileVisibilityTest {
volatile int x;
volatile int y;
@Actor
public void actor1() {
x = 1;
y = 1;
}
@Actor
public void actor2(II_Result r) {
r.r1 = x;
r.r2 = y;
}
}
```
### 5.3 并发测试检查清单
- [ ] 是否测试了竞态条件?
- [ ] 是否验证了不变量(最终一致性)?
- [ ] 是否使用了同步原语而非Thread.sleep()
- [ ] 是否考虑了不同的线程交错?
- [ ] 是否测试了超时场景?
- [ ] 是否验证了资源清理?
- [ ] 是否使用JCStress等工具进行压力测试
---
## 6. 测试命名与文档规范
### 6.1 测试命名最佳实践
#### 命名格式对比
| 格式 | 示例 | 推荐度 |
|------|------|--------|
| **should_when** | `shouldThrowException_whenInvalidInput` | ⭐⭐⭐⭐⭐ |
| **Given_When_Then** | `GivenValidUser_WhenRegister_ThenSuccess` | ⭐⭐⭐⭐ |
| **method_condition_result** | `registerUser_withInvalidEmail_throwsException` | ⭐⭐⭐⭐ |
| **testMethod** | `testRegister` | ⭐⭐ 不推荐 |
#### ✅ 优秀命名示例
```java
// 业务场景清晰
@Test
void shouldSendNotification_whenUserCompletesMilestone()
// 边界条件明确
@Test
void shouldRejectRequest_whenEmailExceeds255Characters()
// 异常场景具体
@Test
void shouldThrowDuplicateKeyException_whenRegisteringExistingEmail()
// 状态转换明确
@Test
void shouldTransitionFromPendingToActive_whenPaymentConfirmed()
// 并发场景清晰
@Test
void shouldMaintainConsistency_whenConcurrentUpdatesToSameEntity()
```
#### ❌ 糟糕命名示例
```java
@Test
void test1() // 无意义
@Test
void testUser() // 过于笼统
@Test
void testRegisterUserSuccess() // 没有when部分
@Test
void test() // 完全无信息
```
### 6.2 Given-When-Then结构
```java
@Test
void shouldCalculateDiscountedPrice_whenPremiumMemberOnSale() {
// ==================== GIVEN ====================
// 准备测试数据
User premiumUser = User.builder()
.id("user-123")
.membershipTier(MembershipTier.PREMIUM)
.registrationDate(LocalDate.of(2020, 1, 1))
.build();
Product saleProduct = Product.builder()
.id("prod-456")
.basePrice(new BigDecimal("100.00"))
.saleDiscount(new BigDecimal("0.20")) // 8折
.build();
// 配置mock
when(userService.findById("user-123")).thenReturn(premiumUser);
when(productService.findById("prod-456")).thenReturn(saleProduct);
// ==================== WHEN ====================
// 执行被测操作
PriceCalculationResult result = priceCalculator.calculate(
"user-123",
"prod-456",
2 // 购买2件
);
// ==================== THEN ====================
// 验证结果
assertAll("价格计算验证",
// 验证业务值
() -> assertEquals(new BigDecimal("144.00"), result.getFinalPrice(),
"Premium用户应该享受折上折: 100*0.8*0.9*2"),
// 验证折扣明细
() -> assertEquals(2, result.getAppliedDiscounts().size()),
() -> assertTrue(result.getAppliedDiscounts().contains("SALE_20%")),
() -> assertTrue(result.getAppliedDiscounts().contains("PREMIUM_10%")),
// 验证行为
() -> verify(userService).findById("user-123"),
() -> verify(productService).findById("prod-456")
);
}
```
### 6.3 测试文档注释规范
```java
/**
* 测试场景: 高并发下的库存扣减
*
* 业务规则:
* - 每个商品有固定库存
* - 多用户同时购买时,不应该超卖
* - 库存为0时后续购买请求应该失败
*
* 测试策略:
* - 使用15个并发线程模拟15个用户同时购买
* - 商品初始库存为10
* - 验证最终只有10个购买成功
*
* 相关需求: REQ-001, REQ-045
* 关联缺陷: BUG-2024-1234
*
* @see InventoryService#deductStock(String, int)
* @since 1.2.0
*/
@Test
void shouldPreventOverselling_whenConcurrentPurchases() {
// ... 测试实现
}
```
### 6.4 检查清单
- [ ] 测试名是否描述了行为和条件?
- [ ] 是否使用了should_when格式
- [ ] 是否包含Given-When-Then结构注释
- [ ] 复杂测试是否有场景说明注释?
- [ ] 是否引用了相关需求和缺陷?
- [ ] 是否使用了assertAll组合相关断言
---
## 7. Mock使用准则
### 7.1 Mock决策树
```
是否使用Mock?
├── 是: 外部依赖数据库、API、消息队列
├── 是: 不可控组件(随机数、时间、网络)
├── 是: 尚未实现的依赖
├── 是: 测试需要特定异常场景
├── 否: 业务逻辑本身不应该Mock
├── 否: 简单值对象
└── 否: 可以实际初始化的轻量对象
```
### 7.2 Mock最佳实践
```java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private PaymentGateway paymentGateway; // ✅ 外部服务
@Mock
private InventoryService inventoryService; // ✅ 复杂依赖
@InjectMocks
private OrderService orderService; // 被测对象不Mock
// ❌ 不应该Mock
// @Mock
// private PriceCalculator priceCalculator; // 核心业务逻辑
@Test
void shouldCompleteOrder_whenPaymentSucceeds() {
// Given - 配置外部依赖
when(paymentGateway.charge(any(PaymentRequest.class)))
.thenReturn(PaymentResult.success("txn-123"));
when(inventoryService.reserve(anyString(), anyInt()))
.thenReturn(InventoryResult.success());
// When - 执行被测业务
OrderResult result = orderService.placeOrder(createValidOrder());
// Then - 验证业务结果和交互
assertEquals(OrderStatus.COMPLETED, result.getStatus());
verify(paymentGateway).charge(argThat(req ->
req.getAmount().compareTo(new BigDecimal("100.00")) == 0
));
verify(inventoryService).reserve("SKU-001", 2);
}
}
```
### 7.3 Mock反模式
```java
// ❌ 反模式1: Mock被测对象本身
@Test
void testOrderService() {
OrderService mockService = mock(OrderService.class);
when(mockService.placeOrder(any())).thenReturn(mockResult);
// 测试的是Mock不是真实逻辑
assertNotNull(mockService.placeOrder(new Order()));
}
// ❌ 反模式2: 过度使用ArgumentCaptor
@Test
void testWithExcessiveCaptors() {
ArgumentCaptor<PaymentRequest> captor1 = ArgumentCaptor.forClass(PaymentRequest.class);
ArgumentCaptor<String> captor2 = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Integer> captor3 = ArgumentCaptor.forClass(Integer.class);
// ... 测试代码 ...
// 过度验证实现细节,而非业务结果
verify(service).method(captor1.capture(), captor2.capture(), captor3.capture());
assertEquals("value1", captor1.getValue().getField1());
assertEquals("value2", captor2.getValue());
assertEquals(42, captor3.getValue());
}
// ❌ 反模式3: Mock Repository层应该是集成测试
@Test
void testWithMockedRepository() {
when(userRepository.findById("123")).thenReturn(Optional.of(mockUser));
// 测试的是Mock返回的数据不是真实数据库交互
User user = userService.findById("123");
assertEquals(mockUser.getName(), user.getName());
}
// ❌ 反模式4: 不验证Mock交互
@Test
void testWithoutVerification() {
when(paymentGateway.charge(any())).thenReturn(success());
orderService.placeOrder(order);
// 缺少verify不知道paymentGateway是否真的被调用
}
// ❌ 反模式5: 使用Mockito的一般匹配器测试具体值
@Test
void testWithGeneralMatchers() {
// 错误: 使用any()代替具体值
when(service.calculate(any(), any())).thenReturn(100);
// 无法验证是否正确传递了参数
assertEquals(100, service.calculate(5, 10)); // 实际应该验证5*10=50
assertEquals(100, service.calculate(999, 999)); // 错误值也返回100
}
```
### 7.4 Mock检查清单
- [ ] 只Mock外部依赖不Mock核心业务逻辑
- [ ] 配置Mock时使用具体的参数匹配
- [ ] 验证Mock的交互verify
- [ ] 使用ArgumentCaptor时保持简洁
- [ ] Repository层使用真实数据库集成测试
- [ ] 考虑使用@Spy部分Mock
- [ ] 使用BDDMockitogiven/willReturn/then/should提升可读性
---
## 8. 断言最佳实践
### 8.1 断言金字塔
```
/\
/ \
/ 单 \
/ 个 \
/ 断言 \ ← 简单场景
/--------\
/ 多个 \
/ 断言 \ ← 常见场景
/--------------\
/ 组合断言 \
/ (assertAll) \ ← 复杂场景
/--------------------\
/ 自定义断言 \ ← 领域特定
/------------------------\
```
### 8.2 断言选择指南
| 场景 | 推荐断言 | 示例 |
|------|---------|------|
| 相等性 | assertEquals | `assertEquals(expected, actual)` |
| 同一性 | assertSame | `assertSame(singleton, result)` |
| 非空 | assertNotNull | `assertNotNull(user.getId())` |
| 布尔 | assertTrue/False | `assertTrue(user.isActive())` |
| 异常 | assertThrows | `assertThrows(InvalidException.class, () -> ...)` |
| 集合 | assertIterableEquals | `assertIterableEquals(expected, actual)` |
| 多条件 | assertAll | `assertAll("user", () -> ..., () -> ...)` |
| 超时 | assertTimeout | `assertTimeout(Duration.ofSeconds(1), () -> ...)` |
| 浮点数 | assertEquals(delta) | `assertEquals(3.14, result, 0.01)` |
### 8.3 高质量断言示例
```java
@Test
void shouldCreateUser_withAllFieldsProperlySet() {
User user = userService.createUser(new CreateUserRequest(
"john.doe@example.com",
"John Doe",
LocalDate.of(1990, 5, 15)
));
// ✅ 组合断言 - 一次性验证所有相关属性
assertAll("用户创建验证",
// 验证ID生成
() -> assertNotNull(user.getId(), "用户ID应该被生成"),
() -> assertTrue(user.getId().startsWith("USR"), "ID应该以USR开头"),
// 验证基本字段
() -> assertEquals("john.doe@example.com", user.getEmail()),
() -> assertEquals("John Doe", user.getName()),
// 验证派生字段
() -> assertEquals(34, user.getAge(), "年龄应该根据生日计算"),
// 验证默认值
() -> assertEquals(UserStatus.PENDING, user.getStatus()),
() -> assertNotNull(user.getCreatedAt()),
() -> assertTrue(user.getCreatedAt().isBefore(LocalDateTime.now().plusSeconds(1))),
// 验证关联数据
() -> assertNotNull(user.getProfile()),
() -> assertTrue(user.getRoles().contains(Role.USER))
);
}
@Test
void shouldThrowValidationException_whenEmailIsMalformed() {
InvalidRequestException exception = assertThrows(
InvalidRequestException.class,
() -> userService.createUser(new CreateUserRequest(
"invalid-email",
"John Doe",
null
)),
"应该抛出验证异常"
);
// ✅ 验证异常详情
assertAll("异常验证",
() -> assertEquals("VALIDATION_ERROR", exception.getErrorCode()),
() -> assertTrue(exception.getMessage().contains("email")),
() -> assertEquals(1, exception.getFieldErrors().size()),
() -> assertEquals("email", exception.getFieldErrors().get(0).getField())
);
}
@Test
void shouldReturnPagedResults_withCorrectPaginationMetadata() {
Page<User> page = userService.findAll(PageRequest.of(2, 10)); // 第3页每页10条
// ✅ 验证复杂对象的所有方面
assertAll("分页结果验证",
// 内容验证
() -> assertEquals(10, page.getContent().size(), "应该返回10条记录"),
() -> assertTrue(page.getContent().stream().allMatch(u -> u.getId() != null)),
// 分页元数据验证
() -> assertEquals(2, page.getNumber(), "当前页码"),
() -> assertEquals(10, page.getSize(), "每页大小"),
() -> assertEquals(10, page.getNumberOfElements(), "当前页元素数"),
// 导航验证
() -> assertTrue(page.hasPrevious(), "应该有上一页"),
() -> assertTrue(page.hasNext(), "应该有下一页"),
() -> assertEquals(5, page.getTotalPages(), "总页数"),
() -> assertEquals(47, page.getTotalElements(), "总元素数"),
// 排序验证
() -> assertTrue(page.getSort().isSorted()),
() -> assertEquals("createdAt", page.getSort().iterator().next().getProperty())
);
}
```
### 8.4 断言反模式
```java
// ❌ 反模式1: 模糊消息
assertTrue(user.isValid(), "验证失败"); // 消息无用
assertEquals(expected, actual); // 无消息,失败时难调试
// ✅ 正确
assertTrue(user.isValid(), "用户应该有效,但实际状态: " + user.getStatus());
assertEquals(expected, actual, "订单总额应该匹配订单ID: " + orderId);
// ❌ 反模式2: 多个独立assert
@Test
void testUser() {
assertNotNull(user);
assertEquals("John", user.getName());
assertTrue(user.isActive());
// 第一个失败,后面的不会执行,不知道还有多少问题
}
// ✅ 正确 - 使用assertAll
@Test
void testUser() {
assertAll("用户验证",
() -> assertNotNull(user),
() -> assertEquals("John", user.getName()),
() -> assertTrue(user.isActive())
);
}
// ❌ 反模式3: 在测试中try-catch
@Test
void testWithTryCatch() {
try {
service.doSomething();
fail("应该抛出异常"); // 容易忘记
} catch (Exception e) {
assertEquals("error", e.getMessage());
}
}
// ✅ 正确 - 使用assertThrows
@Test
void testWithAssertThrows() {
Exception e = assertThrows(Exception.class, () -> service.doSomething());
assertEquals("error", e.getMessage());
}
// ❌ 反模式4: 浮点数精确比较
assertEquals(0.1 + 0.2, result); // 可能失败: 0.30000000000000004
// ✅ 正确 - 使用delta
assertEquals(0.3, result, 0.001);
```
### 8.5 断言检查清单
- [ ] 断言消息是否清晰描述了期望?
- [ ] 是否使用assertAll组合相关断言
- [ ] 异常测试是否使用了assertThrows
- [ ] 浮点数比较是否使用了delta
- [ ] 集合比较是否使用了适当的断言?
- [ ] 是否验证了所有重要的输出字段?
- [ ] 断言顺序是否合理(先验证前提条件)?
---
## 9. 边界条件系统化方法
### 9.1 边界值分析矩阵
```
输入类型 边界值 测试用例
─────────────────────────────────────────────────────────
数值范围 最小值-1, 最小值, -1, 0, 1, 99, 100, 101
最小值+1, 最大值-1, (范围0-100)
最大值, 最大值+1
字符串长度 空串, 1字符, "", "a", "ab",
最大长度-1, repeat("a", 254),
最大长度, 最大长度+1 repeat("a", 255),
repeat("a", 256)
集合/数组 空, 1元素, [], [1], [1,2],
最大容量-1, size=999, size=1000,
最大容量, 最大容量+1 size=1001
日期时间 最小日期, 最大日期, 1970-01-01,
闰年2月29日, 2038-01-19 (Y2K38)
时区边界 2024-02-29
枚举值 第一个, 最后一个, ENUM_FIRST,
随机中间值 ENUM_LAST,
random choice
布尔/状态 true, false, isActive=true,
无效状态值(如需要) isActive=false
```
### 9.2 系统化边界测试实现
```java
/**
* 边界测试策略实现
*/
class BoundaryTestStrategy {
/**
* 1. 数值边界测试
*/
@ParameterizedTest
@CsvSource({
"-1, false", // 低于最小值
"0, true", // 最小值
"1, true", // 最小值+1
"99, true", // 最大值-1
"100, true", // 最大值
"101, false", // 超过最大值
"2147483647, false", // Integer.MAX_VALUE (溢出风险)
})
void shouldValidateRange_forParticipantCount(int count, boolean expectedValid) {
ActivityRequest request = ActivityRequest.builder()
.participantCount(count)
.build();
Set<ConstraintViolation<ActivityRequest>> violations =
validator.validate(request);
boolean hasViolation = violations.stream()
.anyMatch(v -> v.getPropertyPath().toString().equals("participantCount"));
assertEquals(!expectedValid, hasViolation,
"参与者数量 " + count + " 应该" + (expectedValid ? "有效" : "无效"));
}
/**
* 2. 字符串边界测试
*/
@ParameterizedTest
@ValueSource(strings = {
"", // 空字符串
"a", // 1字符
"ab", // 2字符
"a".repeat(254), // 最大长度-1
"a".repeat(255), // 最大长度
"a".repeat(256), // 最大长度+1
" ", // 仅空白字符
"test@example.com", // 有效格式
"test", // 无效格式(无@
"test@", // 无效格式无domain
"@example.com", // 无效格式无local
})
void shouldValidateEmail_forVariousInputs(String email) {
UserRequest request = UserRequest.builder()
.email(email)
.build();
boolean isValid = email != null &&
email.length() >= 5 &&
email.length() <= 255 &&
email.contains("@") &&
email.indexOf("@") > 0 &&
email.indexOf("@") < email.length() - 1;
Set<ConstraintViolation<UserRequest>> violations =
validator.validate(request);
assertEquals(!isValid, !violations.isEmpty(),
"邮箱 '" + (email == null ? "null" : email.substring(0, Math.min(email.length(), 20))) +
"...' 验证结果应该匹配预期");
}
/**
* 3. 集合边界测试
*/
@Test
void shouldHandleCollectionBoundaries() {
// 空集合
assertThrows(IllegalArgumentException.class, () ->
batchProcessor.processBatch(Collections.emptyList())
);
// 单元素
List<Item> singleItem = Collections.singletonList(new Item("1"));
BatchResult result1 = batchProcessor.processBatch(singleItem);
assertEquals(1, result1.getProcessedCount());
// 最大容量
List<Item> maxItems = IntStream.range(0, 1000)
.mapToObj(i -> new Item(String.valueOf(i)))
.collect(Collectors.toList());
BatchResult resultMax = batchProcessor.processBatch(maxItems);
assertEquals(1000, resultMax.getProcessedCount());
// 超过最大容量
List<Item> overMaxItems = IntStream.range(0, 1001)
.mapToObj(i -> new Item(String.valueOf(i)))
.collect(Collectors.toList());
assertThrows(BatchSizeExceededException.class, () ->
batchProcessor.processBatch(overMaxItems)
);
}
/**
* 4. 时间边界测试
*/
@ParameterizedTest
@CsvSource({
"1970-01-01T00:00:00Z, true", // Epoch开始
"2024-02-29T12:00:00Z, true", // 闰年2月29日
"2023-02-29T12:00:00Z, false", // 非闰年2月29日无效
"2038-01-19T03:14:07Z, true", // Y2K38边界前
"2038-01-19T03:14:08Z, false", // Y2K38边界后32位时间戳溢出
"2100-01-01T00:00:00Z, true", // 远期日期
})
void shouldValidateDateTime_forBoundaryValues(
String dateTimeStr, boolean expectedValid) {
try {
Instant instant = Instant.parse(dateTimeStr);
boolean isValid = instant.isAfter(Instant.EPOCH) &&
instant.isBefore(Instant.parse("2100-01-01T00:00:00Z"));
assertEquals(expectedValid, isValid,
"日期时间 " + dateTimeStr + " 验证结果应该为 " + expectedValid);
} catch (DateTimeParseException e) {
assertFalse(expectedValid,
"无效的日期时间格式应该验证失败");
}
}
/**
* 5. null和特殊值测试
*/
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n", "null", "NULL", "undefined"})
void shouldHandleSpecialStringValues(String input) {
// 测试系统对各种特殊字符串值的处理
String sanitized = sanitizer.sanitize(input);
assertNotNull(sanitized, "不应该返回null");
assertFalse(sanitized.equals("null"), "不应该保留字符串'null'");
assertFalse(sanitized.equals("undefined"), "不应该保留字符串'undefined'");
}
}
/**
* 边界测试数据生成器
*/
class BoundaryTestDataGenerator {
static Stream<Arguments> provideIntegerBoundaries(int min, int max) {
return Stream.of(
Arguments.of(min - 1, false, "below-minimum"),
Arguments.of(min, true, "minimum"),
Arguments.of(min + 1, true, "minimum+1"),
Arguments.of((min + max) / 2, true, "middle"),
Arguments.of(max - 1, true, "maximum-1"),
Arguments.of(max, true, "maximum"),
Arguments.of(max + 1, false, "above-maximum")
);
}
static Stream<String> provideStringBoundaries(int maxLength) {
return Stream.of(
"", // 空
"a", // 1字符
"a".repeat(maxLength / 2), // 中等长度
"a".repeat(maxLength - 1), // 最大-1
"a".repeat(maxLength), // 最大
"a".repeat(maxLength + 1), // 最大+1
" ", // 仅空白
"\t\n\r", // 控制字符
"<script>alert('xss')</script>", // XSS尝试
"DROP TABLE users;--", // SQL注入尝试
"日本語テスト", // Unicode
"🔥🎉💯", // Emoji
new String(new char[10000]).replace('\0', 'a') // 超长
);
}
}
```
### 9.3 边界条件检查清单
- [ ] 是否测试了最小值和最大值?
- [ ] 是否测试了最小值-1和最大值+1
- [ ] 是否测试了空值null, 空串, 空集合)?
- [ ] 是否测试了0值除零风险
- [ ] 是否测试了负数(如果适用)?
- [ ] 是否测试了极大值(溢出风险)?
- [ ] 是否测试了特殊字符和Unicode
- [ ] 是否测试了日期边界(闰年、时区)?
- [ ] 是否使用了@ParameterizedTest系统化测试
- [ ] 是否测试了并发情况下的边界?
---
## 10. 持续改进检查表
### 10.1 定期审查计划
```
频率 审查内容 负责人
─────────────────────────────────────────────────────────
每日 新失败的测试分析 开发团队
每周 测试运行时间趋势分析 技术负责人
每月 测试质量评分和覆盖率审查 QA团队
每季度 全量测试审查和重构计划 架构师团队
每年 测试策略评估和工具升级 技术委员会
```
### 10.2 测试健康度指标
```java
/**
* 测试健康度评估脚本
*/
class TestHealthMetrics {
/**
* 1. 计算测试脆弱性指数
*/
void calculateTestFragilityIndex() {
Map<String, Integer> metrics = new HashMap<>();
// 不稳定测试比例
int flakyTests = countFlakyTests();
int totalTests = countTotalTests();
double flakyRatio = (double) flakyTests / totalTests;
// 平均修复时间
double avgFixTime = calculateAverageFixTime();
// 失败频率
double failureFrequency = calculateFailureFrequency();
// 脆弱性指数 = 不稳定比例 * 0.4 + 修复时间因子 * 0.3 + 失败频率 * 0.3
double fragilityIndex = flakyRatio * 0.4 +
(avgFixTime / 24) * 0.3 +
failureFrequency * 0.3;
System.out.println("测试脆弱性指数: " + String.format("%.2f", fragilityIndex));
System.out.println(" - 不稳定测试: " + flakyTests + "/" + totalTests);
System.out.println(" - 平均修复时间: " + avgFixTime + "小时");
System.out.println(" - 失败频率: " + String.format("%.2f", failureFrequency));
}
/**
* 2. 测试ROI计算
*/
void calculateTestROI() {
// 投入
double developmentTime = 120; // 小时
double maintenanceTimePerMonth = 8; // 小时
double infrastructureCost = 500; // 月度基础设施成本
// 产出
int bugsPrevented = 15; // 月度预防的缺陷数
double avgBugCost = 20; // 平均缺陷修复成本(小时)
int regressionBugsPrevented = 5;
double totalInvestment = developmentTime +
(maintenanceTimePerMonth * 12) +
(infrastructureCost * 12 / 100); // 折算为小时
double totalReturn = (bugsPrevented * avgBugCost * 12) +
(regressionBugsPrevented * avgBugCost * 2 * 12); // 回归缺陷成本更高
double roi = (totalReturn - totalInvestment) / totalInvestment * 100;
System.out.println("测试ROI: " + String.format("%.1f%%", roi));
}
}
```
### 10.3 改进行动清单
#### 立即行动(本周)
- [ ] 识别并标记所有不稳定测试
- [ ] 删除或重构虚假断言测试
- [ ] 修复最近的测试失败
- [ ] 更新测试文档
#### 短期行动(本月)
- [ ] 实施测试命名规范检查
- [ ] 添加边界条件测试覆盖率
- [ ] 优化慢速测试(>1秒
- [ ] 建立测试基线性能指标
- [ ] 审查Mock使用并修复过度Mock
#### 中期行动(本季度)
- [ ] 实施并行测试执行
- [ ] 建立测试质量评分流程
- [ ] 添加并发测试套件
- [ ] 实施测试数据工厂模式
- [ ] 建立性能测试基线
#### 长期行动(本年度)
- [ ] 评估并升级测试框架
- [ ] 实施契约测试
- [ ] 建立测试可视化仪表板
- [ ] 建立测试驱动开发文化
- [ ] 实施AI测试生成质量门禁
### 10.4 自动化质量门禁
```yaml
# .github/workflows/test-quality-gate.yml
name: Test Quality Gate
on: [push, pull_request]
jobs:
quality-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run Test Quality Checks
run: |
# 1. 检查虚假断言
FAKE_ASSERTIONS=$(grep -r "assertTrue(true)\|assertFalse(false)" src/test --include="*.java" | wc -l)
if [ $FAKE_ASSERTIONS -gt 0 ]; then
echo "❌ 发现 $FAKE_ASSERTIONS 个虚假断言"
exit 1
fi
# 2. 检查getter/setter测试
DTO_TESTS=$(grep -r "testGetter\|testSetter" src/test --include="*.java" | wc -l)
if [ $DTO_TESTS -gt 10 ]; then
echo "❌ 发现 $DTO_TESTS 个疑似DTO框架测试"
exit 1
fi
# 3. 检查测试覆盖率
mvn jacoco:report
COVERAGE=$(grep -o 'Total[^%]*%' target/site/jacoco/index.html | grep -o '[0-9]*%' | head -1)
echo "当前覆盖率: $COVERAGE"
# 4. 检查测试执行时间
SLOW_TESTS=$(mvn test 2>&1 | grep -c "Time elapsed.*> 1 sec")
if [ $SLOW_TESTS -gt 20 ]; then
echo "⚠️ 发现 $SLOW_TESTS 个慢速测试"
fi
echo "✅ 测试质量门禁通过"
```
### 10.5 测试改进度量表
| 指标 | 基线 | 目标 | 当前 | 差距 |
|------|------|------|------|------|
| 虚假断言数量 | 50 | 0 | ? | 待评估 |
| DTO框架测试数量 | 30 | 0 | ? | 待评估 |
| 平均测试执行时间 | 2.5s | <1s | ? | 待评估 |
| 不稳定测试比例 | 8% | <2% | ? | 待评估 |
| 边界条件覆盖率 | 45% | >80% | ? | 待评估 |
| 并发测试数量 | 0 | >20 | ? | 待评估 |
| 性能测试覆盖 | 0% | >50% | ? | 待评估 |
| 测试命名规范率 | 60% | >95% | ? | 待评估 |
---
## 附录A: 快速参考卡
### 测试反模式速查表
| 反模式 | 检测方法 | 修复建议 |
|--------|----------|----------|
| 虚假断言 | `grep -r "assertTrue(true)"` | 删除或添加有意义的验证 |
| 测试框架 | `grep -r "testGetter\|testSetter"` | 删除DTO/Entity的框架测试 |
| 过度Mock | 检查Mock核心业务类 | 使用真实对象或Spy |
| 硬编码 | 查找魔法数字 | 提取为命名常量 |
| 测试依赖 | 检查@Order注解 | 移除依赖,独立准备数据 |
| 缺少边界 | 检查参数化测试 | 添加@ParameterizedTest |
| 慢测试 | 执行时间>1秒 | 优化或使用@Tag("slow") |
| 无文档 | 检查注释和命名 | 添加Given-When-Then |
### 代码审查检查清单复制到PR模板
```markdown
## 测试代码审查检查单
### 质量
- [ ] 测试验证了有意义的业务逻辑(非框架代码)
- [ ] 断言具体且有意义非assertTrue(true)
- [ ] 使用了组合断言assertAll验证多个条件
- [ ] 异常测试使用了assertThrows
### 边界
- [ ] 测试了null输入
- [ ] 测试了空集合/字符串
- [ ] 测试了最大值/最小值
- [ ] 测试了无效/异常输入
### 可维护性
- [ ] 测试名使用should_when格式
- [ ] 使用了Given-When-Then结构
- [ ] 无硬编码魔法值
- [ ] 重复的setup抽取为方法
### Mock
- [ ] 只Mock外部依赖
- [ ] 没有Mock核心业务逻辑
- [ ] 配置了具体的参数匹配
- [ ] 验证了重要的Mock交互
### 并发
- [ ] 并发场景使用了CountDownLatch
- [ ] 验证了不变量和最终一致性
- [ ] 考虑了竞态条件
- [ ] 测试可以并行执行
```
---
## 附录B: 推荐阅读
1. **《xUnit Test Patterns》** - Gerard Meszaros
2. **《Test Driven Development: By Example》** - Kent Beck
3. **《Growing Object-Oriented Software, Guided by Tests》** - Freeman & Pryce
4. **JUnit 5官方文档**: https://junit.org/junit5/docs/current/user-guide/
5. **Mockito最佳实践**: https://site.mockito.org/
---
## 版本历史
| 版本 | 日期 | 修改内容 | 作者 |
|------|------|----------|------|
| 1.0 | 2026-02-03 | 初始版本基于1210个测试的经验总结 | AI Assistant |
---
**如何使用本文档:**
1. **新开发人员**: 阅读第1、6、8章了解基本规范
2. **代码审查**: 使用附录A的检查清单
3. **重构测试**: 参考第3、7、9章的具体方法
4. **建立规范**: 实施第10章的持续改进流程
5. **团队培训**: 分享第1章的AI陷阱识别避免生成低质量测试
---
*本文档基于蚊子项目真实测试代码的经验教训编制,旨在建立高质量、可维护的测试文化。*