Skip to main content

testing-patterns

Testing patterns for Spring Boot - JUnit 5, Mockito, MockMvc, Testcontainers, positive and negative test cases. Use when user asks about testing, writing tests, test coverage, or test failures.

Informações da origem

Repositório
piomin/claude-ai-spring-boot
Última atividade na origem
18 de outubro de 2026 às 09:39
Idioma detectado do SKILL.md
inglês
Estrelas
1.430
Forks
448

Opções de instalação

Por padrão, está selecionado o prompt que primeiro revisa a origem. Você pode mudar para um comando direto ou baixar uma cópia local.

Revise os arquivos de origem

Leia o SKILL.md e os arquivos complementares exibidos pelo SkillsMP antes de decidir se vai instalar.

Exibindo SKILL.md

SKILL.md
Instruções da origem · Visualização somente leitura
name
testing-patterns
description
Testing patterns for Spring Boot - JUnit 5, Mockito, MockMvc, Testcontainers, positive and negative test cases. Use when user asks about testing, writing tests, test coverage, or test failures.
# Testing Patterns Skill Testing strategies for Spring Boot applications. Always write both positive and negative test cases. ## When to Use - "write tests" / "add test coverage" / "test this" - Test failures or debugging test issues - Choosing between unit, slice, and integration tests --- ## Test Pyramid | Level | Annotation | Speed | Scope | When to Use | |-------|-----------|-------|-------|-------------| | Unit | `@ExtendWith(MockitoExtension.class)` | Fast | Single class | Business logic in services | | Slice | `@WebMvcTest`, `@DataJpaTest` | Medium | One layer | Controllers, repositories | | Integration | `@SpringBootTest` | Slow | Full app | End-to-end flows, cross-layer | --- ## Unit Tests (Service Layer) ```java @ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private OrderRepository orderRepository; @Mock private ProductService productService; @InjectMocks private OrderService orderService; // ✅ Positive test @Test @DisplayName("Should create order with valid items") void shouldCreateOrder() { // Given var request = new OrderRequest(List.of( new OrderItem(1L, 2) )); var product = new Product(); product.setId(1L); product.setPrice(new BigDecimal("10.00")); when(productService.findById(1L)).thenReturn(product); when(orderRepository.save(any(Order.class))).thenAnswer(inv -> { Order o = inv.getArgument(0); o.setId(1L); return o; }); // When Order result = orderService.create(request); // Then assertThat(result.getId()).isEqualTo(1L); verify(orderRepository).save(any(Order.class)); } // ✅ Negative test @Test @DisplayName("Should throw when product not found") void shouldThrowWhenProductNotFound() { // Given var request = new OrderRequest(List.of( new OrderItem(999L, 1) )); when(productService.findById(999L)) .thenThrow(new EntityNotFoundException("Product not found")); // When & Then assertThatThrownBy(() -> orderService.create(request)) .isInstanceOf(EntityNotFoundException.class) .hasMessageContaining("Product not found"); verify(orderRepository, never()).save(any()); } // ✅ Negative test - empty input @Test @DisplayName("Should throw when order has no items") void shouldThrowWhenNoItems() { var request = new OrderRequest(List.of()); assertThatThrownBy(() -> orderService.create(request)) .isInstanceOf(IllegalArgumentException.class); } } ``` --- ## Controller Slice Tests (@WebMvcTest) ```java @WebMvcTest(ProductController.class) class ProductControllerTest { @Autowired private MockMvc mockMvc; @MockitoBean private ProductService productService; @Autowired private ObjectMapper objectMapper; // ✅ Positive - successful creation @Test @DisplayName("POST /api/v1/products - returns 201") void createProduct_validRequest_returns201() throws Exception { var request = new ProductRequest("Widget", new BigDecimal("10.00")); var product = new Product(); product.setId(1L); product.setName("Widget"); when(productService.create(any())).thenReturn(product); mockMvc.perform(post("/api/v1/products") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.name").value("Widget")); } // ✅ Negative - validation failure @Test @DisplayName("POST /api/v1/products - blank name returns 400") void createProduct_blankName_returns400() throws Exception { var request = new ProductRequest("", new BigDecimal("10.00")); mockMvc.perform(post("/api/v1/products") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.errors[0].field").value("name")) .andExpect(jsonPath("$.errors[0].code").value("required")); verify(productService, never()).create(any()); } // ✅ Negative - not found @Test @DisplayName("GET /api/v1/products/999 - returns 404") void getProduct_notFound_returns404() throws Exception { when(productService.findById(999L)) .thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "Product 999 not found")); mockMvc.perform(get("/api/v1/products/999")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.status").value(404)); } // ✅ Negative - stale version @Test @DisplayName("PUT /api/v1/products/1 - stale version returns 409 without errors") void updateProduct_staleVersion_returns409() throws Exception { when(productService.update(eq(1L), any())) .thenThrow(new ObjectOptimisticLockingFailureException(Product.class, 1L)); mockMvc.perform(put("/api/v1/products/1") .contentType(MediaType.APPLICATION_JSON) .content(""" {"name":"Widget","price":10.0,"version":0} """)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.errors").doesNotExist()); } } ``` - Assert error bodies as Problem Details (`$.status`, `$.errors[*].field`, `$.errors[*].code`), never on `message` texts. - `@WebMvcTest` creates no repositories or services: every bean the controller injects needs a `@MockitoBean`, including a repository newly added to an existing controller. --- ## Repository Slice Tests (@DataJpaTest) ```java @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @Testcontainers class ProductRepositoryTest { @Container @ServiceConnection static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine"); @Autowired private ProductRepository productRepository; @Autowired private TestEntityManager entityManager; @Test @DisplayName("Should find products by name containing") void shouldFindByNameContaining() { // Given var product = new Product(); product.setName("Spring Widget"); product.setPrice(new BigDecimal("10.00")); entityManager.persistAndFlush(product); // When List<Product> results = productRepository .findByNameContainingIgnoreCase("widget"); // Then assertThat(results).hasSize(1); assertThat(results.get(0).getName()).isEqualTo("Spring Widget"); } @Test @DisplayName("Should return empty list when no match") void shouldReturnEmptyWhenNoMatch() { List<Product> results = productRepository .findByNameContainingIgnoreCase("nonexistent"); assertThat(results).isEmpty(); } } ``` --- ## Integration Tests (@SpringBootTest) ```java @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureRestTestClient @Testcontainers(disabledWithoutDocker = true) @ActiveProfiles("test") class ProductIntegrationTest { @Container @ServiceConnection static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine"); @Autowired private RestTestClient restTestClient; @Autowired private ProductRepository productRepository; @Autowired private Flyway flyway; @Autowired private JdbcTemplate jdbc; @Autowired private BuildProperties buildProperties; @Test @DisplayName("Should apply all Flyway migrations") void migrationsApplied() { assertThat(flyway.info().current()).isNotNull(); assertThat(flyway.info().pending()).isEmpty(); } @Test @DisplayName("Should reject row violating DB constraint") void databaseRejectsNegativePrice() { // Migration defines CONSTRAINT ck_products_price CHECK (price >= 0) assertThatThrownBy(() -> jdbc.update( "INSERT INTO products (name, price, version) VALUES ('Widget', -1, 0)")) .isInstanceOf(DataIntegrityViolationException.class); } @BeforeEach void setUp() { productRepository.deleteAll(); } @Test @DisplayName("Full CRUD flow works end-to-end") void crudFlow() { // Create var request = new ProductRequest("Widget", new BigDecimal("10.00")); var created = restTestClient.post() .uri("/api/v1/products") .contentType(MediaType.APPLICATION_JSON) .body(request) .exchange() .expectStatus().isCreated() .expectBody(ProductResponse.class) .returnResult() .getResponseBody(); Long id = created.id(); // Read restTestClient.get() .uri("/api/v1/products/{id}", id) .exchange() .expectStatus().isOk() .expectBody(ProductResponse.class) .value(product -> assertThat(product.name()).isEqualTo("Widget")); // Delete restTestClient.delete() .uri("/api/v1/products/{id}", id) .exchange() .expectStatus().isNoContent(); restTestClient.get() .uri("/api/v1/products/{id}", id) .exchange() .expectStatus().isNotFound(); } // Only when the API is built for a UI (public /actuator/info, see conventions) @Test @DisplayName("Health and info are public; other Actuator endpoints are not") void statusEndpointsArePublic() { restTestClient.get().uri("/actuator/health").exchange() .expectStatus().isOk() .expectBody().jsonPath("$.components.db.status").isEqualTo("UP"); restTestClient.get().uri("/actuator/info").exchange() .expectStatus().isOk() .expectBody().jsonPath("$.build.version").isEqualTo(buildProperties.getVersion()); restTestClient.get().uri("/actuator/env").exchange() .expectStatus().isUnauthorized(); } } ``` What the integration tests cover besides CRUD (the last two only when the API is built for a UI, see `conventions`): - **Relations:** none → A, A → B and A → none for every `@ManyToOne` the API lets a client change, against the real database. A → B is the case that fails with `LazyInitializationException` when the write is not `@Transactional`; a mocked `@WebMvcTest` cannot see it. - **Referenced delete:** deleting a record that is still referenced returns 409, and the row is still there. - **Stale version:** two updates with the same `version`; the second returns 409. - **Contract export:** the test that writes `contract/openapi.json` (`spring-boot` skill, `references/ui-contract.md`). - **Status endpoints:** `health` and `info` answer without a token, everything else under `/actuator` does not. Run them with `./mvnw clean verify`. With `@Testcontainers(disabledWithoutDocker = true)` they are skipped silently when Docker is not available, so check the test report shows them as run, not skipped. With Podman instead of Docker: ```bash # macOS (podman machine); on Linux: unix:///run/user/$(id -u)/podman/podman.sock export DOCKER_HOST=unix://$(podman machine inspect --format '{{.ConnectionInfo.PodmanSocket.Path}}') export TESTCONTAINERS_RYUK_DISABLED=true ./mvnw clean verify ``` --- ## Testcontainers Reusable Config ```java @TestConfiguration(proxyBeanMethods = false) public class TestcontainersConfig { @Bean @ServiceConnection public PostgreSQLContainer<?> postgresContainer() { return new PostgreSQLContainer<>("postgres:16-alpine"); } } // Use with @Import @SpringBootTest @Import(TestcontainersConfig.class) class AnyIntegrationTest { // ... } ``` --- ## Parameterized Tests ```java @ParameterizedTest @ValueSource(strings = {"", " ", "ab"}) @DisplayName("Should reject invalid usernames") void shouldRejectInvalidUsername(String username) { var request = new UserRequest(username, "valid@email.com"); assertThatThrownBy(() -> userService.create(request)) .isInstanceOf(ConstraintViolationException.class); }
Ver no GitHub
Este SKILL.md e muito grande, entao o SkillsMP mostra aqui apenas a primeira secao. Ver no GitHub