“JHipster 生成的测试够用吗?“这个问题我们的答案是:生成的测试是底线而不是目标。它保证生成的代码在交付时是对的,但业务逻辑的正确性要靠你补的测试来守护。这一篇讲测试金字塔在 JHipster 工程里的完整落地——每一层用什么工具、生成器给了什么、我们往上加了什么,以及 CI 里怎么跑得又全又快。
金字塔总览
flowchart TD
E2E["E2E:Cypress / Playwright\n少量 · 全栈 · 慢"]
IT["集成测试:@SpringBootTest\n+ Testcontainers 真库\n中量 · 关键路径"]
SLICE["切片测试:@DataJpaTest / @WebMvcTest\n中量 · 快"]
UNIT["单元测试:JUnit 5 + Mockito / Vitest\n海量 · 毫秒级"]
UNIT --> SLICE --> IT --> E2E
| 层 | 后端 | 前端 | 数量比目标 | 单次耗时 |
|---|---|---|---|---|
| 单元 | JUnit 5 + Mockito | Vitest | 多(~70%) | 毫秒 |
| 切片 | @DataJpaTest / @WebMvcTest | 组件测试 | 中(~20%) | 秒 |
| 集成 | @SpringBootTest + Testcontainers | — | 少(~8%) | 十秒级 |
| E2E | Cypress(生成) | 同左 | 极少(~2%) | 分钟 |
比例是指导不是教条。我们的实盘:后端 600+ 单测、80 切片、30 集成、12 条 E2E,全量 CI 12 分钟。
Service 单测:Mockito 的正确姿势
生成的 CustomerServiceIT 是全栈集成测试(后文讲),Service 的纯单测要自己写,重点是隔离依赖:
@ExtendWith(MockitoExtension.class)
class PriceCalcServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private CouponClient couponClient;
@InjectMocks
private PriceCalcService service;
@Test
void shouldApplyVipDiscountBeforeCoupon() {
given(orderRepository.findYearTotal(customerId)).willReturn(new BigDecimal("12000"));
given(couponClient.query(couponCode)).willReturn(new Coupon("FULL100", new BigDecimal("20")));
BigDecimal finalPrice = service.calculate(orderOf(new BigDecimal("200"), VIP));
assertThat(finalPrice).isEqualByComparingTo("150"); // 200 * 0.85 - 20
}
@Test
void shouldRejectWhenCouponExpired() {
given(couponClient.query(code)).willReturn(Coupon.expired());
assertThatThrownBy(() -> service.calculate(order))
.isInstanceOf(BadRequestAlertException.class)
.extracting(ex -> ((BadRequestAlertException) ex).getErrorKey())
.isEqualTo("coupon.expired");
}
}
团队约定:
- 测行为不测实现——断言输出与副作用,不断言 mock 被调了几次(除非副作用本身就是契约);
- 单测里不碰 Spring 上下文,
@ExtendWith(MockitoExtension.class)而不是@SpringBootTest,否则毫秒变十秒; - 一个测试一个行为点,失败信息即文档;参数化(
@ParameterizedTest)用于边界值矩阵。
Repository 切片:@DataJpaTest + Testcontainers
派生查询方法(findByNameContainingAndStatus)不需要测——那是 Spring Data 的契约。要测的是手写的复杂查询:JPQL、native SQL、Specification 组合。@DataJpaTest 只装配 JPA 层,快且干净:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class CustomerRepositoryIT {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private CustomerRepository customerRepository;
@Test
void shouldFindActiveCustomersWithRecentOrder() {
customerRepository.save(activeCustomerWithOrder(30, DAYS));
customerRepository.save(inactiveCustomer());
List<Customer> result =
customerRepository.findActiveWithRecentOrder(OffsetDateTime.now().minusDays(90));
assertThat(result).hasSize(1);
}
}
@ServiceConnection(Spring Boot 3.1+)替代了手写 @DynamicPropertySource,容器起一次全类共享。必须用真库:H2 跑过的 native SQL 在 PostgreSQL 上翻车,是我们付过两次学费的教训——一次是函数行为差异,一次是锁语义差异。Testcontainers 让”真库测试”的成本降到了可以当默认。
Web 层:生成的 ResourceIT 与下沉策略
生成的 CustomerResourceIT 是金字塔中间最厚的一块模板:
@SpringBootTest(classes = MyAppApp.class)
@AutoConfigureMockMvc
@WithMockUser(authorities = AuthoritiesConstants.ADMIN)
class CustomerResourceIT {
@Autowired
private MockMvc restMockMvc;
@Test
void createCustomer() throws Exception {
CustomerDTO dto = new CustomerDTO();
dto.setName("新客户");
restMockMvc.perform(post("/api/customers").contentType(APPLICATION_JSON)
.content(TestUtil.convertObjectToJsonBytes(dto)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("新客户"));
}
@Test
void createCustomerWithExistingId() throws Exception {
restMockMvc.perform(post("/api/customers").param("id", "1") /* ... */)
.andExpect(status().isBadRequest());
}
}
它验证的是完整链路:序列化、路由、@Valid、安全、事务、真实数据库。每次实体再生成会同步更新——这是回归安全网。
我们的下沉策略:生成的 IT 保留不动,新增的业务端点优先写 @WebMvcTest 切片(只装配 MVC 层,mock Service,毫秒级启动):
@WebMvcTest(CustomerBatchResource.class)
class CustomerBatchResourceTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private CustomerService customerService;
@Test
void batchOver500ShouldFail() throws Exception {
List<CustomerDTO> oversized = nCustomers(501);
mockMvc.perform(post("/api/customers/batch").contentType(APPLICATION_JSON)
.content(toJson(oversized)))
.andExpect(status().isBadRequest());
}
}
全栈 IT 留给关键路径(权限组合、分页正确性、并发场景),装饰性重复的 CRUD 断言不值得十秒一次的价格。
前端:Vitest 组件测试
Angular 21 生成器为关键组件/服务配了 Vitest 的 spec 文件。手写业务组件的测试重点有二:FormService 纯逻辑与组件交互:
describe('CustomerFormService', () => {
const service = new CustomerFormService(new FormBuilder());
it('email 必须符合格式', () => {
const form = service.createForm();
form.patchValue({ name: '张三', email: 'not-an-email' });
expect(form.controls.email.valid).toBe(false);
});
});
describe('CustomerUpdateComponent', () => {
it('提交时调用 service 并广播保存事件', async () => {
const saveSpy = vi.fn().mockReturnValue(of(new HttpResponse({ body: {} })));
await render(CustomerUpdateComponent, { componentInputs: { customer: sample() } });
// 注入替身 -> 填表 -> 点击保存 -> 断言 saveSpy 与事件
});
});
前端测试的价值排序:FormService/工具函数(性价比之王)> 带状态组件 > 纯展示组件(快照测试,维护成本常大于收益,我们已弃用)。
E2E:生成的 Cypress 用例
JHipster 生成的 Cypress 套件覆盖了最关键的两条冒烟路径:登录与实体 CRUD(cypress/e2e/account.spec.ts、entity/customer.spec.ts),启动完整前后端跑真实浏览器。我们的增补原则:
- 只加跨层且高风险的流程(下单全链路、权限矩阵抽样),单页细节回归交给低层测试;
- 每条用例独立可重跑:自己造数据、自己清理,禁止依赖执行顺序;
- 生成器还提供 admin 操作的用例骨架,CI 里跑一个最小子集(登录 + 一个实体 CRUD + 一个管理页),全量 E2E 放夜间任务。
Playwright 可作为替代选项,多浏览器矩阵需求出现时再迁移,Cypress 对单浏览器的开发反馈回路依然更快。
CI 分层:又全又快的编排
GitHub Actions 里的分阶段策略:
jobs:
quick-gate: # 1 分钟内给结论
steps:
- run: ./mvnw test -Dtest='*Test' -DfailIfNoTests=false # 纯单测
- run: npm run test -- --changed # 前端受影响测试
backend: # 依赖 quick-gate
steps:
- run: ./mvnw verify # 切片 + 集成(Testcontainers,需要 docker)
frontend-build:
steps:
- run: npm run build
e2e: # 只在 main 与 release 分支
needs: [backend, frontend-build]
steps:
- run: npm run e2e
耗时优化的实战手段(按收益排序):
| 手段 | 收益 | 备注 |
|---|---|---|
| 分层门禁:单测先行快速失败 | 节省整条流水线的无效等待 | PR 反馈 <5 分钟 |
| Maven/Gradle 与 npm 依赖缓存 | 每次省 2-4 分钟 | actions/cache 两行配置 |
Testcontainers 复用(testcontainers.reuse.enable) | 集成层省 30-50% | 本地开发同样受益 |
| E2E 限分支限子集 | PR 不等 8 分钟 | 夜间全量兜底 |
| 并行 shard(大仓库) | 线性扩展 | 用到了第 3000 个测试才需要 |
小结
- 生成的测试是底线:ResourceIT 全栈兜底、随实体再生成同步演进,不要删;
- 单测不碰 Spring 上下文,切片按层裁剪装配,集成测试用 Testcontainers 真库——H2 与生产库的方言差异会收学费;
- 前端优先测 FormService 纯逻辑,快照测试弃用;
- E2E 只留跨层高风险路径,独立可重跑,PR 跑子集、夜间跑全量;
- CI 分层门禁 + 缓存 + 容器复用,12 分钟跑完全量是可达到的目标。
系列导航
- 上一篇:表单与表格的工程化
- 下一篇:CI/CD——从零到自动发布
- 相关阅读:开发工作流 · REST 层设计 · 升级策略