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.

Quellinformationen

Repository
piomin/claude-ai-spring-boot
Letzte Quellaktivität
27. Oktober 2026 um 19:09
Erkannte Sprache von SKILL.md
Englisch
Sterne
1.442
Forks
455

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
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); }
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen