mcpField
T
testing-pyramid

testing-pyramid

github.com/rrezartprebreza测试与质量328130 次安装1 周前更新
代码仓库

关于 testing-pyramid

testing-pyramid 是面向 Spring 应用开发者的测试技能,帮助组织服务、控制器、仓库和集成测试。它介绍单元、切片和集成测试的分层,并提供安全配置、Testcontainers、命名及常见问题的示例与指导。

主要功能

  1. 01单元、切片和集成测试结构
  2. 02Spring MVC 与 JPA 切片示例
  3. 03Testcontainers 数据库配置
  4. 04测试命名约定
  5. 05安全与框架常见问题

适用场景

  1. 01为 Spring 应用各层选择测试类型
  2. 02使用容器化数据库测试仓库
  3. 03编写受安全机制保护的控制器测试
  4. 04避免常见 Spring 测试配置错误

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/rrezartprebreza/spring-boot-skills --skill testing-pyramid

安装后在 Claude Code 中输入/testing-pyramid确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

agents/
openai.yaml
examples/
good-controller-test.java
bad-controller-test.java

详细介绍

测试金字塔

结构

Unit Tests         — fast, no Spring context, mock dependencies    (70%)
Slice Tests        — partial Spring context (@WebMvcTest, @DataJpaTest) (20%)
Integration Tests  — full context + real DB via Testcontainers     (10%)

单元测试 — 服务

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock private OrderRepository orderRepository;
    @Mock private InventoryService inventoryService;

    @InjectMocks private OrderService orderService;

    @Test
    void createOrder_whenItemsAvailable_shouldSaveAndReturnOrder() {
        // Given
        var request = new CreateOrderRequest("[email protected]", List.of(new OrderItemRequest(UUID.randomUUID(), 2)));
        var savedOrder = Order.create("[email protected]");
        when(orderRepository.save(any(Order.class))).thenReturn(savedOrder);
        doNothing().when(inventoryService).reserve(any());

        // When
        Order result = orderService.createOrder(request);

        // Then
        assertThat(result).isNotNull();
        assertThat(result.getCustomerEmail()).isEqualTo("[email protected]");
        verify(inventoryService).reserve(request.items());
        verify(orderRepository).save(any(Order.class));
    }

    @Test
    void createOrder_whenInventoryUnavailable_shouldThrowException() {
        // Given
        var request = new CreateOrderRequest("[email protected]", List.of());
        doThrow(new InsufficientInventoryException("Out of stock"))
            .when(inventoryService).reserve(any());

        // When / Then
        assertThatThrownBy(() -> orderService.createOrder(request))
            .isInstanceOf(InsufficientInventoryException.class)
            .hasMessage("Out of stock");
    }
}

切片测试 — 控制器(@WebMvcTest)

// POSTs need .with(csrf()): @WebMvcTest applies Boot's default security (CSRF on);
// @WithMockUser only authenticates. Add @Import(SecurityConfig.class) to test your real rules.
// import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mockMvc;
    @Autowired ObjectMapper objectMapper;
    @MockitoBean OrderService orderService; // Spring Boot 3.4+: @MockBean is deprecated

    @Test
    @WithMockUser(roles = "USER")
    void createOrder_withValidRequest_shouldReturn201() throws Exception {
        // Given
        var request = new CreateOrderRequest("[email protected]",
            List.of(new OrderItemRequest(UUID.randomUUID(), 1))); // items are @NotEmpty
        var order = Order.create("[email protected]");
        when(orderService.createOrder(any())).thenReturn(order);

        // When / Then
        mockMvc.perform(post("/api/v1/orders").with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.success").value(true))
            .andExpect(jsonPath("$.data.customerEmail").value("[email protected]"));
    }

    @Test
    @WithMockUser
    void createOrder_withInvalidRequest_shouldReturn400() throws Exception {
        mockMvc.perform(post("/api/v1/orders").with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content("{}")) // missing required fields
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.success").value(false))
            .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED"));
    }
}

切片测试 — 仓库(@DataJpaTest)

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // use real DB (Testcontainers)
@Import(TestcontainersConfig.class)
class OrderRepositoryTest {

    @Autowired OrderRepository orderRepository;

    @Test
    void findByStatus_shouldReturnMatchingOrders() {
        // Given
        var order1 = orderRepository.save(Order.create("[email protected]"));
        var order2 = orderRepository.save(Order.create("[email protected]"));
        order2.ship(); // change status
        orderRepository.save(order2);

        // When
        List<Order> pending = orderRepository.findByStatus(OrderStatus.PENDING, Pageable.unpaged()).getContent();

        // Then
        assertThat(pending).hasSize(1);
        assertThat(pending.get(0).getCustomerEmail()).isEqualTo("[email protected]");
    }
}

集成测试 — Testcontainers

此应用级示例假设使用相关技能中的一方 JWT 设置和订单 API。请为 你的应用架构提供 src/test/resources/fixtures/order-user-and-product.sql:一个已启用的 USER,名称为 [email protected],以及一个有库存、ID 为 00000000-0000-0000-0000-000000000001 的商品。仅在一次性 测试数据库上运行此脚本。请根据你的应用调整测试数据和响应 DTO。

签署令牌前,先加载实际持久化的用户。@WithMockUser 无法对发送到随机端口服务器的 HTTP 请求进行身份验证。对于 OAuth2 资源服务器,请使用测试颁发者,而不是引入此应用的 JWT 服务。

// Shared config — reuse container across tests
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }
}

// Full integration test
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(TestcontainersConfig.class)
class OrderIntegrationTest {

    @Autowired TestRestTemplate restTemplate;
    @Autowired OrderRepository orderRepository;
    @Autowired JwtService jwtService;
    @Autowired UserDetailsService userDetailsService;

    private static final UUID PRODUCT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
    private static final ParameterizedTypeReference<ApiResponse<OrderResponse>> RESPONSE_TYPE =
        new ParameterizedTypeReference<>() {};

    @Test
    @Sql("/fixtures/order-user-and-product.sql")
    void createAndRetrieveOrder_endToEnd() {
        var user = userDetailsService.loadUserByUsername("[email protected]");
        String token = jwtService.generateAccessToken(user);
        var createRequest = new CreateOrderRequest("[email protected]",
            List.of(new CreateOrderRequest.OrderItemRequest(PRODUCT_ID, 1)));
        var headers = new HttpHeaders();
        headers.setBearerAuth(token);
        headers.setContentType(MediaType.APPLICATION_JSON);
        var createResponse = restTemplate.exchange("/api/v1/orders", HttpMethod.POST,
            new HttpEntity<>(createRequest, headers), RESPONSE_TYPE);
        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        var created = createResponse.getBody();
        assertThat(created).isNotNull();
        assertThat(created.success()).isTrue();
        assertThat(created.data()).isNotNull();
        UUID id = created.data().id();
        assertThat(id).isNotNull();
        assertThat(orderRepository.findById(id)).isPresent();

        var getResponse = restTemplate.exchange("/api/v1/orders/{id}", HttpMethod.GET,
            new HttpEntity<>(headers), RESPONSE_TYPE, id);
        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        var retrieved = getResponse.getBody();
        assertThat(retrieved).isNotNull();
        assertThat(retrieved.success()).isTrue();
        assertThat(retrieved.data()).isNotNull();
        assertThat(retrieved.data().id()).isEqualTo(id);
        assertThat(retrieved.data().status()).isEqualTo(created.data().status());
        assertThat(retrieved.data().items()).hasSize(1);
        assertThat(retrieved.data().items().get(0).productId()).isEqualTo(PRODUCT_ID);
        assertThat(retrieved.data().items().get(0).quantity()).isEqualTo(1);
    }
}

命名约定

// Method name: methodName_condition_expectedBehavior
createOrder_whenItemsAvailable_shouldSaveOrder()
findById_whenOrderNotFound_shouldThrowNotFoundException()
login_withInvalidCredentials_shouldReturn401()

Testcontainers 依赖项

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>

常见问题

  • Agent 对所有测试都使用 @SpringBootTest——使用切片测试以提升速度
  • Agent 在 @DataJpaTest 中使用 H2 内存数据库——使用 Testcontainers 以确保准确性
  • Agent 使用 @MockBean——自 Spring Boot 3.4 起已弃用;请使用 @MockitoBean(间谍对象请使用 @MockitoSpyBean)
  • Agent 使用 Mockito.mock() 而不是 @Mock——请结合 @ExtendWith(MockitoExtension.class) 使用注解
  • Agent 忘记在控制器测试中添加 @WithMockUser——安全过滤器会阻止所有请求并返回 401
  • Agent 在 @WebMvcTest 中发送 POST 请求时未使用 .with(csrf())——Boot 的默认安全配置会启用 CSRF,因此即使使用 @WithMockUser,每个写入请求也会返回 403
  • Agent 使用 JUnit 的 assertEquals——请使用 AssertJ(assertThat(...).isEqualTo(...))
  • Agent 将测试命名为 test_createOrder()——请使用 createOrder_condition_expected() 模式

评论

支持 Markdown