TL;DR: Use JUnit 5 as the testing framework with Mockito for mocking and Java Faker for test data generation. Tag tests with
@Tag("unit")or@Tag("integration"). All tests must follow the BDD pattern with// given,// when,// thencomment blocks. Use@DisplayNamefor human-readable test descriptions. Unit tests run in parallel. Integration tests use Spring Boot test slices with setup/teardown and are NOT parallel.
JUnit 5 discovers test files automatically via classpath scanning. This document defines the conventions for organizing and writing tests across all Java projects.
src/test/java/.../
<module>/
domain/
builders/ test data builders
commands/
InsertItemCommandTest.java command unit tests
infrastructure/
builders/ JPA entity builders
controllers/
InsertItemControllerTest.java controller unit tests
repositories/
JpaItemsRepositoryTest.java repository integration tests
services/
JpaSendItemServiceTest.java service unit tests
listeners/
ProcessItemListenerTest.java listener tests
doubles/
SendItemServiceStub.java stubs
DummyItemService.java dummies
InMemoryItemsRepository.java in-memory implementations
src/test/resources/
db/ test database migrations
http/ HTTP test data (REST requests)
queue/ message test data
- Test tagging is mandatory. Every test class must have
@Tag("unit")or@Tag("integration"). - BDD structure. Every test must use
// given,// when,// thencomment blocks to separate preconditions, actions, and assertions. - Display names. Use
@DisplayNameon every test method to provide a human-readable description of the scenario. - Testing framework. Use JUnit 5 with Mockito for mocking and AssertJ or JUnit assertions for verification.
- File naming. Test classes use the
Testsuffix (e.g.,InsertItemCommandTest.java). - File placement. Test classes mirror the source structure under
src/test/java/. - Parallel unit tests. Unit tests are configured for parallel class-level execution via JUnit properties.
- Sequential integration tests. Integration tests share database state and must NOT run in parallel.
Unit tests must be lightweight, fast, and isolated. They are configured for parallel execution at the class level.
@Tag("unit")
@NoArgsConstructor(access = AccessLevel.PRIVATE)
class InsertItemCommandTest {
@Test
@DisplayName("should call onSuccess when the item is inserted")
void shouldCallOnSuccess() {
// given
final var event = new ItemEventBuilder().build();
final var service = new SendItemServiceStub().withOnSuccess();
final var command = new InsertItemCommand(service);
final var onSuccess = mock(Runnable.class);
// when
final var listeners = new InsertItemCommand.Listeners(
onSuccess,
(e) -> fail("onError should not be called"));
command.execute(event, listeners);
// then
verify(onSuccess, times(1)).run();
}
@Test
@DisplayName("should call onError when the service throws an exception")
void shouldCallOnError() {
// given
final var event = new ItemEventBuilder().build();
final var service = new SendItemServiceStub().withOnError(new RuntimeException("test"));
final var command = new InsertItemCommand(service);
final var errorRef = new AtomicReference<Exception>();
// when
final var listeners = new InsertItemCommand.Listeners(
() -> fail("onSuccess should not be called"),
errorRef::set);
command.execute(event, listeners);
// then
assertNotNull(errorRef.get());
assertEquals("test", errorRef.get().getMessage());
}
}Key points:
@Tag("unit")marks the class for unit test execution.@NoArgsConstructor(access = AccessLevel.PRIVATE)prevents instantiation outside JUnit.- Each test is self-contained -- it creates its own doubles, command, and listeners.
- The Listeners pattern reflects all possible outcomes:
onSuccess,onError.
@Tag("unit")
@NoArgsConstructor(access = AccessLevel.PRIVATE)
class ListItemsControllerTest {
@Test
@DisplayName("should respond 200 (OK) when items are listed successfully")
void shouldRespondOk() {
// given
final var command = new ListItemsCommandStub().withOnSuccess();
final var controller = new ListItemsController(command);
// when
final var response = controller.execute(1L, PageRequest.of(0, 10));
// then
assertEquals(HttpStatus.OK.value(), response.getStatusCode().value());
}
@Test
@DisplayName("should respond 500 (Internal Server Error) when command fails")
void shouldRespondInternalServerError() {
// given
final var command = new ListItemsCommandStub().withOnError();
final var controller = new ListItemsController(command);
// when
final var response = controller.execute(1L, PageRequest.of(0, 10));
// then
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), response.getStatusCode().value());
}
}@Tag("unit")
@NoArgsConstructor(access = AccessLevel.PRIVATE)
class JpaSendItemServiceTest {
@Test
@DisplayName("should save the mapped item when event is valid")
void shouldSaveItem() {
// given
final var event = new ItemEventBuilder().build();
final var repository = new InMemoryItemsRepository();
final var mapper = ItemMapper.INSTANCE;
final var service = new JpaSendItemService(repository, mapper);
// when
service.send(event);
// then
assertEquals(1, repository.count());
}
@Test
@DisplayName("should throw when repository fails to save")
void shouldThrowWhenRepositoryFails() {
// given
final var event = new ItemEventBuilder().build();
final var repository = new InMemoryItemsRepository().withOnError(new RuntimeException("db error"));
final var mapper = ItemMapper.INSTANCE;
final var service = new JpaSendItemService(repository, mapper);
// when & then
assertThrows(RuntimeException.class, () -> service.send(event));
}
}Integration tests verify the full stack (database, HTTP, messaging) using real infrastructure. They are NOT parallel due to shared mutable state.
@Tag("integration")
@SpringBootTest
@ActiveProfiles("test")
class JpaItemsRepositoryTest {
@Autowired
private JpaItemsRepository repository;
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
void setUp() {
// Seed test data
jdbcTemplate.execute("INSERT INTO item (id, name, created_at) VALUES (1, 'test-item', NOW())");
}
@AfterEach
void tearDown() {
jdbcTemplate.execute("DELETE FROM item");
}
@Test
@DisplayName("should find item by ID successfully")
void shouldFindById() {
// given
final var itemId = 1L;
// when
final var result = repository.findById(itemId);
// then
assertTrue(result.isPresent());
assertEquals("test-item", result.get().getName());
}
@Test
@DisplayName("should return empty when item does not exist")
void shouldReturnEmpty() {
// given
final var nonExistentId = 99999L;
// when
final var result = repository.findById(nonExistentId);
// then
assertTrue(result.isEmpty());
}
@Test
@DisplayName("should save a new item successfully")
void shouldSaveItem() {
// given
final var item = JpaItem.builder()
.name("new-item")
.createdAt(LocalDateTime.now())
.build();
// when
final var saved = repository.save(item);
// then
assertNotNull(saved.getId());
assertEquals("new-item", saved.getName());
}
@Test
@DisplayName("should delete an item successfully")
void shouldDeleteItem() {
// given
final var itemId = 1L;
// when
repository.deleteById(itemId);
// then
assertTrue(repository.findById(itemId).isEmpty());
}
}Key points:
@Tag("integration")marks the class for integration test execution.@SpringBootTestloads the full application context.@ActiveProfiles("test")activates the test profile (application-test.yaml).@BeforeEach/@AfterEachmanage test data lifecycle.- Tests are grouped by outcome: success, error, edge cases.
Follow the Martin Fowler taxonomy for test doubles:
| Type | Purpose | Example |
|---|---|---|
| Stub | Returns canned answers, no logic | SendItemServiceStub |
| Dummy | Minimal implementation, ready-made answers | DummyItemService |
| In-Memory | In-memory logic without external modules | InMemoryItemsRepository |
| Faker | External library generating realistic data | ItemEventBuilder (using Java Faker) |
| Mock | Mimics and verifies method calls | Mockito mock() -- avoid when possible |
public class SendItemServiceStub implements SendItemService {
private Exception error;
public SendItemServiceStub withOnSuccess() {
this.error = null;
return this;
}
public SendItemServiceStub withOnError(final Exception error) {
this.error = error;
return this;
}
@Override
public void send(final ItemEvent event) {
if (error != null) {
throw new RuntimeException(error);
}
}
}public class InMemoryItemsRepository implements ItemsRepository {
private final List<JpaItem> items = new ArrayList<>();
private Exception error;
public InMemoryItemsRepository withOnError(final Exception error) {
this.error = error;
return this;
}
public long count() {
return items.size();
}
@Override
public JpaItem save(final JpaItem item) {
if (error != null) {
throw new RuntimeException(error);
}
items.add(item);
return item;
}
}Use the Builder Design Pattern to construct complex test objects step by step. Builders keep test setup readable and reusable across test suites.
@NoArgsConstructor
public final class ItemEventBuilder {
private Long organizationId;
private String referenceGuid;
private ItemCode code;
private ItemCategory category;
public ItemEventBuilder withOrganizationId(final Long organizationId) {
this.organizationId = organizationId;
return this;
}
public ItemEventBuilder withCode(final ItemCode code) {
this.code = code;
return this;
}
public ItemEvent build() {
final var faker = new Faker();
return new ItemEvent(
organizationId != null ? organizationId : faker.number().randomNumber(),
referenceGuid != null ? referenceGuid : faker.internet().uuid(),
code != null ? code : ItemCode.values()[faker.number().numberBetween(0, ItemCode.values().length)],
category != null ? category : ItemCategory.DEFAULT);
}
}Usage in tests:
// Default values (randomized via Faker)
final var event = new ItemEventBuilder().build();
// Custom values
final var event = new ItemEventBuilder()
.withOrganizationId(42L)
.withCode(ItemCode.CRITICAL)
.build();Configure parallel execution in build.gradle:
test {
useJUnitPlatform()
systemProperty 'junit.jupiter.execution.parallel.enabled', 'true'
systemProperty 'junit.jupiter.execution.parallel.mode.classes.default', 'concurrent'
}This runs test classes concurrently while methods within the same class run in the same thread, preventing shared-state conflicts within a test class.
Seed files populate test databases with known data:
- The seed file name must match the table name (e.g.,
item.sql). - For multiple seeds targeting the same table, use numbered suffixes:
item_01.sql,item_02.sql. - Constants must be named according to the file name.