Allra 测试编写标准
Allra 后端团队的测试编写标准定义。包括测试助手选择、Fixture Monkey 数据生成、Given-When-Then 模式、AssertJ 验证等。
项目基本信息
本指南基于以下环境编写:
- Java: 17 以上
- Spring Boot: 3.2 以上
- 测试框架: JUnit 5
- 断言库: AssertJ
- 模拟: Mockito
- 测试数据: Fixture Monkey(可选)
- 容器: Testcontainers(可选)
参考: 项目使用的具体库或版本可能有所不同。根据项目进行调整使用。
测试助手选择指南
注意: 下面测试助手由 Allra 标准模板提供。如果项目中没有这些助手,直接使用 Spring Boot 基本测试注解(@SpringBootTest, @DataJpaTest, @WebMvcTest 等),但要遵循本指南的测试模式和原则。
| 助手 | 标签 | 用途 | 重量 | 何时? |
|---|---|---|---|---|
| IntegrationTest | Integration | 多个服务集成 | 🔴 重 | 整个工作流程 |
| RdbTest | RDB | Repository, QueryDSL | 🟡 中等 | 查询验证 |
| ControllerTest | Controller | API 端点 | 🟢 轻 | REST API 验证 |
| RedisTest | Redis | Redis 缓存 | 🟢 轻 | 缓存验证 |
| MockingUnitTest | MockingUnit | Service 单元 | 🟢 非常轻 | 业务逻辑 |
| PojoUnitTest | PojoUnit | 领域逻辑 | 🟢 非常轻 | 纯 Java |
选择流程
API 端点?→ ControllerTest
多个服务集成?→ IntegrationTest
Repository/QueryDSL?→ RdbTest
Redis 缓存?→ RedisTest
Service 逻辑(Mock)?→ MockingUnitTest
领域逻辑(POJO)?→ PojoUnitTest
🎯 Mock vs Integration 选择标准(重要!)
原则: 基本是 MockingUnitTest,仅在必要时使用 IntegrationTest
目标: 保持 IntegrationTest 比例在 5% 以下
决策流程图
┌─────────────────────────────────┐
│ 测试的目标是什么? │
└────────────┬────────────────────┘
│
┌────────▼────────┐
│ 仅领域逻辑? │ ──Yes──> PojoUnitTest
└────────┬────────┘
│ No
┌────────▼─────────────────────┐
│ Repository/QueryDSL 查询? │ ──Yes──> RdbTest
└────────┬─────────────────────┘
│ No
┌────────▼─────────────────────┐
│ API 端点响应/验证? │ ──Yes──> ControllerTest
└────────┬─────────────────────┘
│ No
┌────────▼─────────────────────────────┐
│ Service 业务逻辑验证? │
└────────┬─────────────────────────────┘
│
┌────────▼──────────────────────────────────────────┐
│ 下列情况是否适用? │
│ │
│ 1. 💰 金钱处理(存款/取款/转账/退款) │
│ 2. 🔄 事务回滚重要的工作流程 │
│ 3. 📊 多个表数据一致性验证 │
│ 4. 🔐 实际 DB 约束条件验证必须 │
│ 5. 📝 复杂的状态转移(3 阶段以上) │
│ 6. 🎯 事件发布/监听器集成验证 │
│ 7. 🤝 3 个以上服务必须合作 │
└────┬──────────────────────────────────────┬────────┘
│ Yes │ No
│ │
┌────▼────────────┐ ┌─────────▼──────────┐
│ IntegrationTest │ │ MockingUnitTest │
│ (最小化) │ │ (基本选择) │
└─────────────────┘ └────────────────────┘
需要 IntegrationTest 的具体案例
✅ 1. 金钱处理(存款/取款/转账/退款)
理由: 与金钱相关的逻辑必须验证实际 DB 事务操作
// 示例:众筹申请(FsData → FsPayment → PointUsage → UserAccount 联动)
@DisplayName("众筹申请时金额扣除及支付创建")
class ApplyServiceIntegrationTest extends IntegrationTest {
@Test
@Transactional
void apply_DecreasesAmount_Success() {
// given: 用户余额 100 万
User user = createUserWithBalance(1_000_000);
// when: 50 万众筹申请
applyService.apply(new ApplyRequest(user.getId(), 500_000));
// then: 实际 DB 中余额 50 万确认
User updated = userRepository.findById(user.getId()).get();
assertThat(updated.getBalance()).isEqualTo(500_000);
// then: FsPayment 创建确认
FsPayment payment = fsPaymentRepository.findByUserId(user.getId()).get();
assertThat(payment.getAmount()).isEqualTo(500_000);
}
}
✅ 2. 事务回滚重要的工作流程
理由: 失败时所有操作必须原子性回滚
// 示例:支付失败时全部回滚
@Test
@DisplayName("支付失败时申请数据也回滚")
void apply_PaymentFails_RollbackAll() {
// given
User user = createUser();
mockPaymentGateway_ToFail(); // 外部支付是 Mock
// when & then
assertThatThrownBy(() -> applyService.apply(request))
.isInstanceOf(PaymentException.class);
// then: DB 中没有任何数据保存
assertThat(fsDataRepository.findAll()).isEmpty();
assertThat(fsPaymentRepository.findAll()).isEmpty();
}
参考: 外部对接(支付网关,外部 API)用 @MockBean 处理
✅ 3. 多个表数据一致性验证
理由: 确认所有相关表的状态一致性
// 示例:创建合同时 UserAccount, Contract, FsData 都创建
@Test
@DisplayName("新建合同时相关表都创建")
void createContract_CreatesAllRelatedData() {
// when
contractService.createContract(userId, contractType);
// then: 3 个表都有数据存在
assertThat(userAccountRepository.findByUserId(userId)).isPresent();
assertThat(contractRepository.findByUserId(userId)).isPresent();
assertThat(fsDataRepository.findByUserId(userId)).isPresent();
}
✅ 4. 实际 DB 约束条件验证
理由: Unique, FK, Check 约束只能在实际 DB 中确认
// 示例:防止重复账户注册
@Test
@DisplayName("相同账户号重复注册时异常")
void registerAccount_Duplicate_ThrowsException() {
// given
userAccountRepository.save(new UserAccount(userId, "123-456-789"));
// when & then: Unique 约束违反
assertThatThrownBy(() ->
userAccountRepository.save(new UserAccount(userId, "123-456-789"))
).isInstanceOf(DataIntegrityViolationException.class);
}
✅ 5. 复杂的状态转移(3 阶段以上)
理由: 按实际场景验证状态变化流程
// 示例:合同状态转移(申请 → 审核 → 批准 → 完成)
@Test
@DisplayName("合同工作流程全部验证")
void contractWorkflow_FullCycle() {
// given: 申请
Contract contract = contractService.create(userId);
assertThat(contract.getStatus()).isEqualTo(ContractStatus.PENDING);
// when: 审核
contractService.review(contract.getId());
// then
Contract reviewed = contractRepository.findById(contract.getId()).get();
assertThat(reviewed.getStatus()).isEqualTo(ContractStatus.REVIEWED);
// when: 批准
contractService.approve(contract.getId());
// then
Contract approved = contractRepository.findById(contract.getId()).get();
assertThat(approved.getStatus()).isEqualTo(ContractStatus.APPROVED);
}
✅ 6. 事件发布/监听器集成验证
理由: 确认事件实际发布并且监听器工作
// 示例:合同完成事件 → 通知发送
@Test
@DisplayName("合同完成时通知事件发布")
void completeContract_PublishesEvent() {
// given
Contract contract = createContract(userId);
// when
contractService.complete(contract.getId());
// then: 实际通知是否发送?(外部通知是 @MockBean)
verify(notificationService).sendContractCompleteNotification(userId);
}
✅ 7. 3 个以上服务必须合作
理由: 在实际环境中验证服务间互动
// 示例:下单 → 库存减少 → 支付 → 通知
@Test
@DisplayName("下单工作流程")
void createOrder_FullWorkflow() {
// given
Product product = createProductWithStock(100);
// when
orderService.createOrder(userId, product.getId(), 10);
// then: 库存减少
Product updated = productRepository.findById(product.getId()).get();
assertThat(updated.getStock()).isEqualTo(90);
// then: 支付创建
Payment payment = paymentRepository.findByUserId(userId).get();
assertThat(payment.getStatus()).isEqualTo(PaymentStatus.COMPLETED);
}
MockingUnitTest 足够案例
✅ 大部分 Service 逻辑
- 简单查询(findById, findAll)
- 数据转换/计算
- 验证逻辑(validation)
- 单个实体 CRUD
- 业务规则验证
// 示例:折扣率计算逻辑(Mock 足够)
@ExtendWith(MockitoExtension.class)
class DiscountServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private DiscountService discountService;
@Test
@DisplayName("VIP 用户 10% 折扣计算")
void calculateDiscount_VipUser_10Percent() {
// given
User vipUser = User.builder().grade("VIP").build();
when(userRepository.findById(1L)).thenReturn(Optional.of(vipUser));
// when
BigDecimal discount = discountService.calculateDiscount(1L, new BigDecimal("10000"));
// then
assertThat(discount).isEqualByComparingTo(new BigDecimal("1000"));
}
}
外部对接处理原则
重要: IntegrationTest 中也用 @MockBean 处理外部系统
@SpringBootTest
class PaymentServiceIntegrationTest extends IntegrationTest {
@Autowired
private PaymentService paymentService;
@MockBean // 外部支付网关是 Mock
private ExternalPaymentGateway externalPaymentGateway;
@MockBean // 外部通知服务是 Mock
private ExternalNotificationService notificationService;
@Test
@DisplayName("支付成功时内部数据一致性验证")
void processPayment_Success() {
// given: 外部支付成功 Mock
when(externalPaymentGateway.charge(any()))
.thenReturn(new PaymentResult("SUCCESS", "tx-123"));
// when: 实际内部逻辑验证
paymentService.processPayment(userId, amount);
// then: 内部 DB 状态确认
Payment payment = paymentRepository.findByUserId(userId).get();
assertThat(payment.getStatus()).isEqualTo(PaymentStatus.COMPLETED);
assertThat(payment.getExternalTxId()).isEqualTo("tx-123");
}
}
测试策略总结
| 测试类型 | 目标比例 | 执行速度 | 主要用途 |
|---|---|---|---|
| PojoUnitTest | 30% | ⚡️ 0.01秒 | 领域逻辑,工具类 |
| MockingUnitTest | 50% | ⚡️ 0.1秒 | Service 业务逻辑 |
| ControllerTest | 10% | 🟡 0.5秒 | API 验证 |
| RdbTest | 5% | 🟡 1秒 | 复杂查询验证 |
| IntegrationTest | 5% | 🔴 5秒 | 金钱/事务/工作流程 |
快速判断检查列表
编写新测试时请确认以下内容:
□ 涉及金钱吗?(存款/取款/支付)
→ Yes: IntegrationTest
□ 失败时数据回滚重要吗?
→ Yes: IntegrationTest
□ 需要确认 3 个以上表的一致性吗?
→ Yes: IntegrationTest
□ DB 约束(Unique/FK)是核心吗?
→ Yes: IntegrationTest
□ 需要验证复杂的状态转移(3 阶段+)吗?
→ Yes: IntegrationTest
□ 需要验证事件发布/监听器吗?
→ Yes: IntegrationTest
□ 3 个以上服务合作吗?
→ Yes: IntegrationTest
全部 No → 使用 MockingUnitTest
测试助手结构
IntegrationTest - 集成测试
@Tag("Integration")
@SpringBootTest
public abstract class IntegrationTest {
// 完整 Spring Context, Testcontainers 利用
}
何时: 多个服务合作,需要实际 DB/外部系统
注意: 最重,外部 API 用 @MockBean 使用
RdbTest - Repository 测试
@Tag("RDB")
@DataJpaTest
public abstract class RdbTest {}
何时: Repository CRUD, QueryDSL 查询,N+1 问题验证
ControllerTest - API 测试
@Tag("Controller")
@WebMvcTest(TargetController.class)
public abstract class ControllerTest {
@Autowired
protected MockMvc mockMvc;
}
何时: API 端点,HTTP Status, 输入验证
注意: Service 用 @MockBean 必须处理
RedisTest - Redis 测试
@Tag("Redis")
@DataRedisTest
public abstract class RedisTest {}
何时: Redis 缓存,会话存储验证
MockingUnitTest - Service 单元测试
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
}
何时: Service 逻辑单元测试,快速测试
注意: 没有 Spring Context, @Autowired 不可用
PojoUnitTest - 领域逻辑测试
class UserTest {
@Test
void activate_Success() {
// 纯 Java 逻辑测试
}
}
何时: 领域实体,VO,工具类
Fixture Monkey - 测试数据生成
依赖设置
// Gradle
testImplementation 'com.navercorp.fixturemonkey:fixture-monkey-starter:1.0.13'
<!-- Maven -->
<dependency>
<groupId>com.navercorp.fixturemonkey</groupId>
<artifactId>fixture-monkey-starter</artifactId>
<version>1.0.13</version>
<scope>test</scope>
</dependency>
使用方法
import static {your.package}.fixture.FixtureFactory.FIXTURE_MONKEY;
// 简单生成
User user = FIXTURE_MONKEY.giveMeOne(User.class);
// 特定字段指定
User user = FIXTURE_MONKEY.giveMeBuilder(User.class)
.set("email", "test@example.com")
.set("active", true)
.sample();
// 多个生成
List<User> users = FIXTURE_MONKEY.giveMe(User.class, 10);
Given-When-Then 模式(必须)
所有测试必须遵循 Given-When-Then 模式
@Test
@DisplayName("用户创建 - 成功")
void createUser_Success() {
// given - 测试准备
UserRequest request = new UserRequest("test@example.com", "password");
User savedUser = FIXTURE_MONKEY.giveMeOne(User.class);
when(userRepository.save(any())).thenReturn(savedUser);
// when - 实际执行
UserResponse response = userService.createUser(request);
// then - 验证
assertThat(response).isNotNull();
verify(userRepository, times(1)).save(any());
}
AssertJ 验证模式
// 单个值
assertThat(response).isNotNull();
assertThat(response.userId()).isEqualTo(1L);
// 集合
assertThat(users).hasSize(3);
assertThat(users).extracting(User::getEmail)
.containsExactlyInAnyOrder("a@test.com", "b@test.com");
// Boolean
assertThat(user.isActive()).isTrue();
// 异常
assertThatThrownBy(() -> userService.findById(999L))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("USER_NOT_FOUND");
// Optional
assertThat(result).isPresent();
assertThat(result.get().getName()).isEqualTo("홍길동");
Mockito 模式
Mock 设置
// 返回值
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
// void 方法
doNothing().when(emailService).sendEmail(any());
// 异常发生
when(userRepository.findById(999L))
.thenThrow(new BusinessException(ErrorCode.USER_NOT_FOUND));
Mock 调用验证
// 调用次数
verify(userRepository, times(1)).findById(1L);
verify(userRepository, never()).delete(any());
// 参数验证
verify(userRepository).save(argThat(user ->
user.getEmail().equals("test@example.com")
));
测试命名规则
类
class ApplyServiceIntegrationTest extends IntegrationTest // Integration
class UserRepositoryTest extends RdbTest // Repository
class UserControllerTest extends ControllerTest // Controller
class UserServiceTest // Service Unit
class UserTest // Domain
方法
// 模式: {方法名}_{场景}_{预期结果}
@Test
@DisplayName("用户创建 - 成功")
void createUser_ValidRequest_Success()
@Test
@DisplayName("用户查询 - 用户不存在")
void findById_UserNotFound_ThrowsException()
测试示例
Controller 测试
@DisplayName("User -> UserController 测试")
@WebMvcTest(UserController.class)
class UserControllerTest extends ControllerTest {
@MockBean
private UserService userService;
@Test
@DisplayName("用户查询 API - 成功")
void getUser_Success() throws Exception {
// given
Long userId = 1L;
UserResponse response = new UserResponse(userId, "test@example.com");
when(userService.findById(userId)).thenReturn(response);
// when & then
mockMvc.perform(get("/api/v1/users/{id}", userId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value(userId));
}
}
Service 单元测试
@ExtendWith(MockitoExtension.class)
@DisplayName("User -> UserService 单元测试")
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
@DisplayName("用户查询 - 成功")
void findById_Success() {
// given
Long userId = 1L;
User user = FIXTURE_MONKEY.giveMeBuilder(User.class)
.set("id", userId)
.sample();
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
// when
UserResponse response = userService.findById(userId);
// then
assertThat(response).isNotNull();
assertThat(response.userId()).isEqualTo(userId);
verify(userRepository, times(1)).findById(userId);
}
}
Repository 测试
@DisplayName("User -> UserRepository 测试")
class UserRepositoryTest extends RdbTest {
@Autowired
private UserRepository userRepository;
@Test
@DisplayName("活跃用户查询 - 成功")
void findActiveUsers_Success() {
// given
User active = FIXTURE_MONKEY.giveMeBuilder(User.class)
.set("active", true)
.sample();
userRepository.save(active);
// when
List<UserDto> result = userRepository.findActiveUsers();
// then
assertThat(result).hasSize(1);
assertThat(result).extracting(UserDto::email)
.contains(active.getEmail());
}
}
何时使用此技能
此技能在以下情况下自动应用:
- 测试文件创建或修改
- 测试助手选择(IntegrationTest vs MockingUnitTest 判断)
- 测试数据生成(Fixture Monkey 使用)
- Given-When-Then 模式应用
- AssertJ 验证代码编写
- Mockito Mock 设置及验证
特别重要: 新 Service 测试编写时先确认 “Mock vs Integration 选择标准”!
检查列表
测试代码编写时确认事项:
所有测试共同
- [ ] 遵循 Given-When-Then 模式?
- [ ] 使用 @DisplayName 测试意图明确?
- [ ] 使用 AssertJ 验证?
- [ ] 方法名是
方法_场景_结果模式?
测试助手选择(首先确认!)
- [ ] 涉及金钱处理(存款/取款/支付)或事务回滚验证? → IntegrationTest
- [ ] 需要确认 3 个以上表一致性或 DB 约束验证? → IntegrationTest
- [ ] 需要验证复杂状态转移(3 阶段+)或事件发布/监听器验证? → IntegrationTest
- [ ] 3 个以上服务合作? → IntegrationTest
- [ ] 以上条件都不符合 → 使用 MockingUnitTest
IntegrationTest
- [ ] 符合上述选择标准之一以上?
- [ ] 外部 API 用 @MockBean 处理了?
- [ ] 真的需要 IntegrationTest 再次确认?
RdbTest
- [ ] 只包含 Repository/QueryDSL 测试?
- [ ] 验证 N+1 问题?
ControllerTest
- [ ] 明确 @WebMvcTest(TargetController.class)?
- [ ] Service 用 @MockBean 处理了?
- [ ] 验证 HTTP Status Code?
MockingUnitTest
- [ ] 用 @Mock 依赖,@InjectMocks 测试对象注入了?
- [ ] 用 verify() Mock 调用验证了?
PojoUnitTest
- [ ] 只测试领域逻辑?
- [ ] 没有外部依赖?
测试执行命令
Gradle
./gradlew test # 全部测试
./gradlew test --tests * -Dtest.tags=Integration # 标签执行
./gradlew test --tests UserServiceTest # 特定类
Maven
./mvnw test # 全部测试
./mvnw test -Dgroups=Integration # 标签执行
./mvnw test -Dtest=UserServiceTest # 特定类
测试质量标准
- 覆盖率: 核心业务逻辑 70% 以上
- 隔离性: 每个测试可以独立执行
- 速度: 单元测试 1 秒内,集成测试 5 秒内
- 明确性: 仅凭测试名称就能理解意图
- 可靠性: 相同输入总是相同结果