CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / JHIPSTER / P-232 · JHipster 项目开发

JHipster 开发 17:测试全景——单元到端到端

“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 + MockitoVitest多(~70%)毫秒
切片@DataJpaTest / @WebMvcTest组件测试中(~20%)
集成@SpringBootTest + Testcontainers少(~8%)十秒级
E2ECypress(生成)同左极少(~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 套件覆盖了最关键的两条冒烟路径:登录实体 CRUDcypress/e2e/account.spec.tsentity/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 分钟跑完全量是可达到的目标。

系列导航

← 软件工程 017:常见企业级代码规范 目录 开发工具 017:Linux:Curl使用 →
← 返回文章列表