This skill should be used when writing Java tests, creating JUnit 5 test fixtures, using Mockito, testing Spring Boot applications, or improving test coverage.
This skill defines comprehensive testing patterns for Java, covering JUnit 5 lifecycle, Mockito mocking, Spring Boot test slices, Testcontainers, ArchUnit, and coverage standards.
Use @BeforeEach for per-test setup and @AfterEach for cleanup. Avoid @BeforeAll unless expensive setup is shared safely.
// CORRECT: @BeforeEach for per-test setup
class OrderServiceTest {
private OrderRepository repository;
private OrderService service;
@BeforeEach
void setUp() {
repository = new InMemoryOrderRepository();
service = new OrderService(repository);
}
@AfterEach
void tearDown() {
repository.clear();
}
@Test
void shouldCreateOrder() {
var request = new OrderRequest("product-1", 2);
var order = service.createOrder(request);
assertThat(order.productId()).isEqualTo("product-1");
assertThat(order.quantity()).isEqualTo(2);
}
}
// WRONG: Shared mutable state without proper setup
class OrderServiceTest {
// Shared state leads to flaky tests
private final OrderRepository repository = new InMemoryOrderRepository();
private final OrderService service = new OrderService(repository);
@Test
void shouldCreateOrder() {
// Previous test data leaks into this test
var order = service.createOrder(new OrderRequest("product-1", 2));
assertThat(order.productId()).isEqualTo("product-1");
}
}
Use @BeforeAll only for truly expensive, immutable setup like database containers.
// CORRECT: @BeforeAll for expensive, shared, immutable setup
class DatabaseIntegrationTest {
private static PostgreSQLContainer<?> postgres;
@BeforeAll
static void startContainer() {
postgres = new PostgreSQLContainer<>("postgres:16-alpine");
postgres.start();
}
@AfterAll
static void stopContainer() {
postgres.stop();
}
@BeforeEach
void setUp() {
// Per-test setup with clean state
cleanDatabase();
}
}
Use @Nested classes to group related tests by method or behavior.
// CORRECT: @Nested for logical grouping
class UserServiceTest {
private UserService service;
private UserRepository repository;
@BeforeEach
void setUp() {
repository = mock(UserRepository.class);
service = new UserService(repository);
}
@Nested
@DisplayName("findById")
class FindById {
@Test
@DisplayName("should return user when found")
void shouldReturnUserWhenFound() {
var user = new User(1L, "[email protected]");
when(repository.findById(1L)).thenReturn(Optional.of(user));
var result = service.findById(1L);
assertThat(result).isPresent();
assertThat(result.get().email()).isEqualTo("[email protected]");
}
@Test
@DisplayName("should return empty when not found")
void shouldReturnEmptyWhenNotFound() {
when(repository.findById(99L)).thenReturn(Optional.empty());
var result = service.findById(99L);
assertThat(result).isEmpty();
}
}
@Nested
@DisplayName("createUser")
class CreateUser {
@Test
@DisplayName("should create user with valid email")
void shouldCreateUserWithValidEmail() {
var request = new CreateUserRequest("[email protected]", "Bob");
when(repository.save(any(User.class))).thenAnswer(inv -> {
User u = inv.getArgument(0);
return new User(1L, u.email());
});
var result = service.createUser(request);
assertThat(result.email()).isEqualTo("[email protected]");
verify(repository).save(any(User.class));
}
@Test
@DisplayName("should reject duplicate email")
void shouldRejectDuplicateEmail() {
var request = new CreateUserRequest("[email protected]", "Existing");
when(repository.existsByEmail("[email protected]")).thenReturn(true);
assertThatThrownBy(() -> service.createUser(request))
.isInstanceOf(DuplicateEmailException.class)
.hasMessageContaining("[email protected]");
}
}
}
// WRONG: Flat test structure without grouping
class UserServiceTest {
@Test
void testFindByIdFound() { /* ... */ }
@Test
void testFindByIdNotFound() { /* ... */ }
@Test
void testCreateUser() { /* ... */ }
@Test
void testCreateUserDuplicate() { /* ... */ }
// Hard to see which tests belong to which method
}
Name tests using the shouldDoSomethingWhenCondition pattern for clarity.
// CORRECT: should...when naming pattern
@Test
void shouldReturnDiscountedPrice_whenCustomerIsPremium() {
var customer = new Customer("premium");
var price = pricingService.calculate(customer, new BigDecimal("100"));
assertThat(price).isEqualByComparingTo(new BigDecimal("85.00"));
}
@Test
void shouldThrowException_whenOrderIsEmpty() {
assertThatThrownBy(() -> orderService.submit(new Order()))
.isInstanceOf(EmptyOrderException.class);
}
@Test
void shouldReturnPagedResults_whenPageSizeIsSpecified() {
var page = userService.findAll(PageRequest.of(0, 10));
assertThat(page.getContent()).hasSize(10);
}
// WRONG: Vague or inconsistent naming
@Test
void test1() { /* ... */ }
@Test
void testOrder() { /* ... */ }
@Test
void orderCreation() { /* ... */ }
@Test
void itWorks() { /* ... */ }
Use @DisplayName to provide human-readable test descriptions in reports.
// CORRECT: @DisplayName for readable reports
@Test
@DisplayName("should apply 15% discount for gold tier customers")
void shouldApplyGoldDiscount() {
// ...
}
@Nested
@DisplayName("Payment processing")
class PaymentProcessing {
@Test
@DisplayName("should process valid credit card payment")
void shouldProcessValidCreditCard() {
// ...
}
@Test
@DisplayName("should reject expired credit cards")
void shouldRejectExpiredCreditCard() {
// ...
}
}
Use @CsvSource for simple parameterized tests with inline data.
// CORRECT: @CsvSource for inline parameterized data
@ParameterizedTest(name = "calculate({0}, {1}) = {2}")
@CsvSource({
"0, 0, 0",
"1, 1, 2",
"10, 20, 30",
"-5, 5, 0",
"100, -50, 50"
})
void shouldAddNumbers(int a, int b, int expected) {
assertThat(calculator.add(a, b)).isEqualTo(expected);
}
@ParameterizedTest(name = "validate email: {0} -> valid={1}")
@CsvSource({
"[email protected], true",
"[email protected], true",
"invalid, false",
"'', false",
"no-at-sign.com, false"
})
void shouldValidateEmail(String email, boolean expectedValid) {
assertThat(validator.isValidEmail(email)).isEqualTo(expectedValid);
}
// WRONG: Separate tests for each data point
@Test
void shouldAddPositiveNumbers() {
assertThat(calculator.add(1, 1)).isEqualTo(2);
}
@Test
void shouldAddNegativeNumbers() {
assertThat(calculator.add(-5, 5)).isEqualTo(0);
}
// Many duplicated tests...
Use @MethodSource when test data is too complex for @CsvSource.
// CORRECT: @MethodSource for complex data
@ParameterizedTest(name = "{0}")
@MethodSource("orderScenarios")
void shouldCalculateOrderTotal(String scenario, List<OrderItem> items, BigDecimal expectedTotal) {
var order = new Order(items);
assertThat(order.total()).isEqualByComparingTo(expectedTotal);
}
static Stream<Arguments> orderScenarios() {
return Stream.of(
Arguments.of(
"single item order",
List.of(new OrderItem("A", 2, new BigDecimal("10.00"))),
new BigDecimal("20.00")
),
Arguments.of(
"multi-item order",
List.of(
new OrderItem("A", 1, new BigDecimal("10.00")),
new OrderItem("B", 3, new BigDecimal("5.00"))
),
new BigDecimal("25.00")
),
Arguments.of(
"empty order",
List.of(),
BigDecimal.ZERO
)
);
}
Use @EnumSource for testing behavior across all or specific enum values.
// CORRECT: @EnumSource for enum coverage
@ParameterizedTest(name = "tier {0} should have a positive discount rate")
@EnumSource(CustomerTier.class)
void shouldHavePositiveDiscountRate(CustomerTier tier) {
assertThat(tier.discountRate()).isGreaterThanOrEqualTo(BigDecimal.ZERO);
}
@ParameterizedTest(name = "premium tier {0} should have discount >= 10%")
@EnumSource(value = CustomerTier.class, names = {"GOLD", "PLATINUM"})
void shouldHavePremiumDiscount(CustomerTier tier) {
assertThat(tier.discountRate()).isGreaterThanOrEqualTo(new BigDecimal("0.10"));
}
@ParameterizedTest(name = "non-premium tier {0} should have discount < 10%")
@EnumSource(value = CustomerTier.class, mode = EnumSource.Mode.EXCLUDE, names = {"GOLD", "PLATINUM"})
void shouldHaveStandardDiscount(CustomerTier tier) {
assertThat(tier.discountRate()).isLessThan(new BigDecimal("0.10"));
}
Use AssertJ for fluent, readable assertions with better error messages.
// CORRECT: AssertJ fluent assertions
import static org.assertj.core.api.Assertions.*;
@Test
void shouldFilterActiveUsers() {
var users = userService.findActive();
assertThat(users)
.hasSize(3)
.extracting(User::email)
.containsExactlyInAnyOrder(
"[email protected]",
"[email protected]",
"[email protected]"
);
}
@Test
void shouldCreateOrderWithCorrectDetails() {
var order = orderService.create(request);
assertThat(order)
.isNotNull()
.satisfies(o -> {
assertThat(o.status()).isEqualTo(OrderStatus.PENDING);
assertThat(o.total()).isEqualByComparingTo(new BigDecimal("99.99"));
assertThat(o.items()).hasSize(2);
assertThat(o.createdAt()).isCloseTo(Instant.now(), within(1, ChronoUnit.SECONDS));
});
}
// WRONG: JUnit assertions (less readable, worse error messages)
import static org.junit.jupiter.api.Assertions.*;
@Test
void shouldFilterActiveUsers() {
var users = userService.findActive();
assertEquals(3, users.size());
assertTrue(users.stream().anyMatch(u -> u.email().equals("[email protected]")));
assertTrue(users.stream().anyMatch(u -> u.email().equals("[email protected]")));
}
Use AssertJ assertThatThrownBy or assertThatCode for exception testing.
// CORRECT: AssertJ exception assertions
@Test
void shouldThrowOnInvalidInput() {
assertThatThrownBy(() -> service.process(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Input must not be null")
.hasNoCause();
}
@Test
void shouldThrowWithCause() {
assertThatThrownBy(() -> service.connect())
.isInstanceOf(ServiceException.class)
.hasMessageContaining("connection failed")
.hasCauseInstanceOf(IOException.class);
}
@Test
void shouldNotThrowForValidInput() {
assertThatCode(() -> service.process(validInput))
.doesNotThrowAnyException();
}
// WRONG: JUnit assertThrows (less fluent)
@Test
void shouldThrowOnInvalidInput() {
var exception = assertThrows(IllegalArgumentException.class,
() -> service.process(null));
assertEquals("Input must not be null", exception.getMessage());
}
Use AssertJ's rich collection assertions for expressive tests.
// CORRECT: AssertJ collection assertions
@Test
void shouldReturnSortedActiveUsers() {
var users = userService.findActiveSorted();
assertThat(users)
.isNotEmpty()
.hasSize(5)
.isSortedAccordingTo(Comparator.comparing(User::name))
.allSatisfy(user -> {
assertThat(user.isActive()).isTrue();
assertThat(user.email()).contains("@");
})
.noneSatisfy(user ->
assertThat(user.isActive()).isFalse()
);
}
@Test
void shouldReturnOrdersWithExpectedStatuses() {
var orders = orderService.findByCustomer(customerId);
assertThat(orders)
.extracting(Order::status)
.containsOnly(OrderStatus.PENDING, OrderStatus.CONFIRMED)
.doesNotContain(OrderStatus.CANCELLED);
}
Use @ExtendWith(MockitoExtension.class) to initialize mocks automatically.
// CORRECT: MockitoExtension for automatic mock initialization
@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {
@Mock
private PaymentGateway paymentGateway;
@Mock
private OrderRepository orderRepository;
@InjectMocks
private PaymentService paymentService;
@Test
void shouldProcessPaymentSuccessfully() {
var order = new Order(1L, new BigDecimal("99.99"));
when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
when(paymentGateway.charge(any(PaymentRequest.class)))
.thenReturn(new PaymentResponse("txn-123", PaymentStatus.SUCCESS));
var result = paymentService.processPayment(1L);
assertThat(result.status()).isEqualTo(PaymentStatus.SUCCESS);
assertThat(result.transactionId()).isEqualTo("txn-123");
}
}
// WRONG: Manual mock initialization
class PaymentServiceTest {
private PaymentGateway paymentGateway;
private PaymentService paymentService;
@BeforeEach
void setUp() {
paymentGateway = Mockito.mock(PaymentGateway.class); // Verbose
paymentService = new PaymentService(paymentGateway);
}
}
Use verify to confirm expected interactions occurred.
// CORRECT: Verify interactions
@Test
void shouldSendNotificationAfterOrderCreation() {
var request = new CreateOrderRequest("product-1", 2);
when(orderRepository.save(any())).thenReturn(new Order(1L));
orderService.createOrder(request);
verify(orderRepository).save(any(Order.class));
verify(notificationService).sendOrderConfirmation(eq(1L), any(Order.class));
verifyNoMoreInteractions(notificationService);
}
@Test
void shouldNotSendNotificationOnFailure() {
when(orderRepository.save(any())).thenThrow(new DataAccessException("DB error") {});
assertThatThrownBy(() -> orderService.createOrder(request))
.isInstanceOf(DataAccessException.class);
verify(notificationService, never()).sendOrderConfirmation(anyLong(), any());
}
Use ArgumentCaptor to capture and assert arguments passed to mocked methods.
// CORRECT: ArgumentCaptor for detailed argument assertions
@Test
void shouldCreateAuditLogWithCorrectDetails() {
var request = new TransferRequest(1L, 2L, new BigDecimal("500.00"));
transferService.executeTransfer(request);
var captor = ArgumentCaptor.forClass(AuditLog.class);
verify(auditLogRepository).save(captor.capture());
var auditLog = captor.getValue();
assertThat(auditLog.action()).isEqualTo("TRANSFER");
assertThat(auditLog.amount()).isEqualByComparingTo(new BigDecimal("500.00"));
assertThat(auditLog.fromAccountId()).isEqualTo(1L);
assertThat(auditLog.toAccountId()).isEqualTo(2L);
assertThat(auditLog.timestamp()).isCloseTo(Instant.now(), within(1, ChronoUnit.SECONDS));
}
Use doNothing, doThrow, or doAnswer for void methods.
// CORRECT: Stubbing void methods
@Test
void shouldHandleNotificationFailureGracefully() {
doThrow(new NotificationException("SMTP down"))
.when(notificationService).sendEmail(any());
// Service should handle notification failure without throwing
assertThatCode(() -> orderService.createOrder(request))
.doesNotThrowAnyException();
verify(notificationService).sendEmail(any());
}
@Test
void shouldCallDeleteSuccessfully() {
doNothing().when(repository).deleteById(1L);
service.removeUser(1L);
verify(repository).deleteById(1L);
}
Use @WebMvcTest for testing Spring MVC controllers in isolation.
// CORRECT: @WebMvcTest for controller layer
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void shouldReturnUserById() throws Exception {
var user = new UserResponse(1L, "alice", "[email protected]");
when(userService.findById(1L)).thenReturn(Optional.of(user));
mockMvc.perform(get("/api/users/1")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.username").value("alice"))
.andExpect(jsonPath("$.email").value("[email protected]"));
}
@Test
void shouldReturn404WhenUserNotFound() throws Exception {
when(userService.findById(99L)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/users/99")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
void shouldCreateUser() throws Exception {
var request = """
{
"username": "bob",
"email": "[email protected]"
}
""";
var response = new UserResponse(2L, "bob", "[email protected]");
when(userService.createUser(any())).thenReturn(response);
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(request))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(2))
.andExpect(jsonPath("$.username").value("bob"));
}
@Test
void shouldReturn400ForInvalidRequest() throws Exception {
var invalidRequest = """
{
"username": "",
"email": "not-an-email"
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidRequest))
.andExpect(status().isBadRequest());
}
}
// WRONG: @SpringBootTest for controller-only testing (loads full context)
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {
// Loads entire application context unnecessarily
}
Use @DataJpaTest for testing JPA repositories with an embedded database.
// CORRECT: @DataJpaTest for repository layer
@DataJpaTest
class UserRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private UserRepository userRepository;
@Test
void shouldFindUserByEmail() {
var user = new User("alice", "[email protected]");
entityManager.persistAndFlush(user);
var found = userRepository.findByEmail("[email protected]");
assertThat(found).isPresent();
assertThat(found.get().getUsername()).isEqualTo("alice");
}
@Test
void shouldReturnEmptyForNonExistentEmail() {
var found = userRepository.findByEmail("[email protected]");
assertThat(found).isEmpty();
}
@Test
void shouldFindActiveUsersByRole() {
entityManager.persist(new User("alice", "[email protected]", Role.ADMIN, true));
entityManager.persist(new User("bob", "[email protected]", Role.USER, true));
entityManager.persist(new User("carol", "[email protected]", Role.ADMIN, false));
entityManager.flush();
var activeAdmins = userRepository.findByRoleAndActiveTrue(Role.ADMIN);
assertThat(activeAdmins)
.hasSize(1)
.extracting(User::getUsername)
.containsExactly("alice");
}
}
Use @WebFluxTest for testing reactive WebFlux controllers.
// CORRECT: @WebFluxTest for reactive controller testing
@WebFluxTest(UserController.class)
class UserControllerWebFluxTest {
@Autowired
private WebTestClient webTestClient;
@MockBean
private UserService userService;
@Test
void shouldReturnUserById() {
var user = new UserResponse(1L, "alice", "[email protected]");
when(userService.findById(1L)).thenReturn(Mono.just(user));
webTestClient.get()
.uri("/api/users/1")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody(UserResponse.class)
.value(response -> {
assertThat(response.id()).isEqualTo(1L);
assertThat(response.username()).isEqualTo("alice");
});
}
@Test
void shouldStreamUsers() {
var users = Flux.just(
new UserResponse(1L, "alice", "[email protected]"),
new UserResponse(2L, "bob", "[email protected]")
);
when(userService.streamAll()).thenReturn(users);
webTestClient.get()
.uri("/api/users/stream")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.expectBodyList(UserResponse.class)
.hasSize(2);
}
}
Use Testcontainers with static lifecycle for shared containers across tests.
// CORRECT: Testcontainers with static shared container
@SpringBootTest
@Testcontainers
class OrderIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void setUp() {
orderRepository.deleteAll();
}
@Test
void shouldCreateAndRetrieveOrder() {
var request = new CreateOrderRequest("product-1", 3, new BigDecimal("29.99"));
var created = orderService.createOrder(request);
var retrieved = orderService.findById(created.id());
assertThat(retrieved).isPresent();
assertThat(retrieved.get().productId()).isEqualTo("product-1");
assertThat(retrieved.get().total()).isEqualByComparingTo(new BigDecimal("89.97"));
}
}
// WRONG: Non-static container (restarted per test, very slow)
@SpringBootTest
@Testcontainers
class OrderIntegrationTest {
@Container // Non-static: new container per test method
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
// This is extremely slow
}
Use multiple containers for integration tests requiring multiple services.
// CORRECT: Multiple containers for full integration test
@SpringBootTest
@Testcontainers
class FullIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
}
Create a base class for shared container configuration.
// CORRECT: Reusable base class for Testcontainers
public abstract class AbstractIntegrationTest {
@Container
protected static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
// Tests extend the base class
@SpringBootTest
@Testcontainers
class UserIntegrationTest extends AbstractIntegrationTest {
@Autowired
private UserService userService;
@Test
void shouldCreateUser() {
var user = userService.create(new CreateUserRequest("[email protected]"));
assertThat(user.id()).isNotNull();
}
}
Use ArchUnit to enforce architectural boundaries between layers.
// CORRECT: ArchUnit layer dependency rules
@AnalyzeClasses(packages = "com.example.myapp")
class ArchitectureTest {
@ArchTest
static final ArchRule layerDependencies = layeredArchitecture()
.consideringAllDependencies()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Repository").definedBy("..repository..")
.layer("Domain").definedBy("..domain..")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
.whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Service", "Repository");
}
Use ArchUnit to enforce consistent naming patterns across the codebase.
// CORRECT: ArchUnit naming conventions
@AnalyzeClasses(packages = "com.example.myapp")
class NamingConventionTest {
@ArchTest
static final ArchRule controllersShouldEndWithController =
classes()
.that().resideInAPackage("..controller..")
.and().areAnnotatedWith(RestController.class)
.should().haveSimpleNameEndingWith("Controller");
@ArchTest
static final ArchRule servicesShouldEndWithService =
classes()
.that().resideInAPackage("..service..")
.and().areAnnotatedWith(Service.class)
.should().haveSimpleNameEndingWith("Service");
@ArchTest
static final ArchRule repositoriesShouldEndWithRepository =
classes()
.that().resideInAPackage("..repository..")
.should().haveSimpleNameEndingWith("Repository");
@ArchTest
static final ArchRule noFieldInjection =
noFields()
.should().beAnnotatedWith(Autowired.class)
.because("Field injection is not allowed; use constructor injection");
}
Prevent circular dependencies and enforce package boundaries.
// CORRECT: No circular dependencies
@AnalyzeClasses(packages = "com.example.myapp")
class PackageDependencyTest {
@ArchTest
static final ArchRule noCyclicDependencies =
slices().matching("com.example.myapp.(*)..")
.should().beFreeOfCycles();
@ArchTest
static final ArchRule domainShouldNotDependOnInfrastructure =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("..infrastructure..", "..controller..");
}
Aim for at least 90% line coverage on business logic. Use JaCoCo for measurement.
<!-- CORRECT: JaCoCo Maven plugin configuration -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
Exclude generated code, configuration, and DTOs from coverage requirements.
<!-- CORRECT: JaCoCo exclusions in Maven -->
<configuration>
<excludes>
<exclude>**/config/**</exclude>
<exclude>**/dto/**</exclude>
<exclude>**/*Application.*</exclude>
<exclude>**/*Config.*</exclude>
<exclude>**/*Properties.*</exclude>
</excludes>
</configuration>
// CORRECT: JaCoCo exclusions in Gradle Kotlin DSL
tasks.jacocoTestCoverageVerification {
violationRules {
rule {
limit {
counter = "LINE"
value = "COVEREDRATIO"
minimum = "0.90".toBigDecimal()
}
}
}
afterEvaluate {
classDirectories.setFrom(files(classDirectories.files.map {
fileTree(it) {
exclude(
"**/config/**",
"**/dto/**",
"**/*Application*",
"**/*Config*",
"**/*Properties*"
)
}
}))
}
}
A complete example demonstrating all testing conventions together.
// CORRECT: Complete test class following all conventions
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private InventoryService inventoryService;
@Mock
private NotificationService notificationService;
@InjectMocks
private OrderService orderService;
@Nested
@DisplayName("createOrder")
class CreateOrder {
@Test
@DisplayName("should create order when inventory is available")
void shouldCreateOrder_whenInventoryAvailable() {
var request = new CreateOrderRequest("SKU-001", 5);
when(inventoryService.checkAvailability("SKU-001", 5)).thenReturn(true);
when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> {
Order order = invocation.getArgument(0);
return order.withId(42L);
});
var result = orderService.createOrder(request);
assertThat(result.id()).isEqualTo(42L);
assertThat(result.sku()).isEqualTo("SKU-001");
assertThat(result.quantity()).isEqualTo(5);
assertThat(result.status()).isEqualTo(OrderStatus.PENDING);
verify(inventoryService).reserve("SKU-001", 5);
verify(notificationService).sendOrderCreated(eq(42L));
}
@Test
@DisplayName("should throw when inventory is insufficient")
void shouldThrow_whenInventoryInsufficient() {
var request = new CreateOrderRequest("SKU-001", 100);
when(inventoryService.checkAvailability("SKU-001", 100)).thenReturn(false);
assertThatThrownBy(() -> orderService.createOrder(request))
.isInstanceOf(InsufficientInventoryException.class)
.hasMessageContaining("SKU-001");
verify(orderRepository, never()).save(any());
verify(notificationService, never()).sendOrderCreated(anyLong());
}
}
@Nested
@DisplayName("cancelOrder")
class CancelOrder {
@Test
@DisplayName("should cancel pending order")
void shouldCancelPendingOrder() {
var order = new Order(1L, "SKU-001", 5, OrderStatus.PENDING);
when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
orderService.cancelOrder(1L);
var captor = ArgumentCaptor.forClass(Order.class);
verify(orderRepository).save(captor.capture());
assertThat(captor.getValue().status()).isEqualTo(OrderStatus.CANCELLED);
verify(inventoryService).release("SKU-001", 5);
}
@Test
@DisplayName("should throw when cancelling shipped order")
void shouldThrow_whenCancellingShippedOrder() {
var order = new Order(1L, "SKU-001", 5, OrderStatus.SHIPPED);
when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
assertThatThrownBy(() -> orderService.cancelOrder(1L))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Cannot cancel order in SHIPPED status");
}
}
@Nested
@DisplayName("calculateDiscount")
class CalculateDiscount {
@ParameterizedTest(name = "tier {0} with amount {1} should get discount {2}")
@CsvSource({
"BRONZE, 100.00, 5.00",
"SILVER, 100.00, 10.00",
"GOLD, 100.00, 15.00",
"PLATINUM,100.00, 20.00"
})
@DisplayName("should apply correct discount per tier")
void shouldApplyCorrectDiscount(
CustomerTier tier, BigDecimal amount, BigDecimal expectedDiscount) {
var discount = orderService.calculateDiscount(tier, amount);
assertThat(discount).isEqualByComparingTo(expectedDiscount);
}
}
}
When contributing to existing repositories, follow their established test conventions.
// Check existing test patterns before writing tests:
// - Look at existing test classes for structure
// - Check for testify/AssertJ/Hamcrest usage
// - Check for Mockito vs other mocking frameworks
// - Look for shared test fixtures or base classes
// - Check for Testcontainers configuration
// - Follow existing naming conventions
# Discover test conventions
find src/test -name "*Test.java" | head -10 # Test file structure
grep -r "import static" src/test/ | head -5 # Assertion library
grep -r "@ExtendWith" src/test/ | head -5 # Extensions used
grep -r "Testcontainers" src/test/ | head -5 # Container usage
This skill ensures comprehensive, maintainable test suites following JUnit 5, AssertJ, Mockito, Spring Boot, and Testcontainers best practices. Apply these patterns consistently to maintain high-quality, reliable test coverage.