A well-organised test project is just as important as well-organised production code. Poor test structure leads to duplication, brittle tests, and test suites that are hard to navigate and maintain. This guide shows you exactly how to structure a JUnit 6 project β from package layout to naming conventions, test base classes, shared utilities, and the practices that keep test suites healthy at scale.
Whether you are starting fresh or cleaning up an existing project, the patterns here apply to any Java application β monolith, microservice, or library.
The Standard Maven/Gradle Directory Layout
JUnit 6 follows the standard Maven directory convention, which Gradle also adopts by default:
my-app/
βββ src/
β βββ main/
β β βββ java/
β β βββ com/example/
β β βββ service/
β β β βββ OrderService.java
β β βββ repository/
β β β βββ OrderRepository.java
β β βββ model/
β β βββ Order.java
β βββ test/
β βββ java/
β β βββ com/example/ <-- mirrors main package structure
β β βββ service/
β β β βββ OrderServiceTest.java
β β βββ repository/
β β β βββ OrderRepositoryIT.java <-- IT suffix = integration test
β β βββ common/
β β βββ BaseTest.java <-- shared test base class
β βββ resources/
β βββ junit-platform.properties <-- JUnit 6 config
βββ pom.xml
βββ README.md
Key principles of this layout:
- Test packages mirror production packages so you can immediately find the test for any class
- Unit tests end with
Test; integration tests end withIT - Shared test infrastructure lives in a
commonsub-package - JUnit 6 configuration goes in
src/test/resources/junit-platform.properties
Naming Conventions That Actually Help
Consistent naming makes a test suite searchable and self-documenting. Use these conventions:
| What to name | Convention | Example |
|---|---|---|
| Unit test class | ClassUnderTestTest | OrderServiceTest |
| Integration test class | ClassUnderTestIT | OrderRepositoryIT |
| Test method | shouldExpectedBehaviourWhenCondition | shouldThrowWhenOrderIsNull |
| Test method (alt) | givenCondition_whenAction_thenResult | givenEmptyCart_whenCheckout_thenThrowException |
| Test data builder | ClassNameBuilder or ClassNameMother | OrderBuilder, CustomerMother |
| Test constants class | TestConstants or Fixtures | OrderTestFixtures |
Test Method Naming: Readable and Searchable
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("OrderService β order creation and validation")
class OrderServiceTest {
// Good: describes what happens and under what condition
@Test
void shouldSetStatusToPendingWhenNewOrderIsCreated() { }
// Good: Given/When/Then style makes test intent crystal clear
@Test
void givenNullCustomer_whenCreatingOrder_thenThrowIllegalArgumentException() { }
// Bad: no information about what is being tested
@Test
void test1() { }
// Bad: too vague
@Test
void createOrderWorks() { }
}
Using a Shared Base Test Class
A base test class centralises setup that is common to many tests β logging configuration, shared mocks, common assertions, or test utilities. Inherit from it in your concrete test classes:
package com.example.common;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Base class for all unit tests in this project.
* Provides shared logging, timing, and helper assertions.
*/
public abstract class BaseTest {
// Each concrete test class gets its own logger automatically
protected final Logger log = LoggerFactory.getLogger(getClass());
private long testStartTimeMillis;
@BeforeEach
void logTestStart() {
testStartTimeMillis = System.currentTimeMillis();
log.info("Starting test: {}", getClass().getSimpleName());
}
@AfterEach
void logTestDuration() {
long durationMs = System.currentTimeMillis() - testStartTimeMillis;
log.info("Test completed in {} ms", durationMs);
}
}
// Concrete test class inherits lifecycle methods from BaseTest
class OrderServiceTest extends BaseTest {
@Test
void shouldReturnEmptyListWhenNoOrdersExist() {
// log, timing etc. inherited automatically
assertTrue(true);
}
}
Test Data Builders (Object Mother Pattern)
Hard-coding test data inline in every test leads to duplication and fragile tests. Use a builder or Object Mother to create reusable test objects:
package com.example.common;
import com.example.model.Order;
import com.example.model.OrderStatus;
/**
* Object Mother for Order test data.
* Provides pre-built Order instances for common test scenarios.
*/
public class OrderMother {
// Returns a valid, fully-populated Order ready for testing
public static Order validPendingOrder() {
return Order.builder()
.id(1L)
.customerEmail("[email protected]")
.totalAmount(99.99)
.status(OrderStatus.PENDING)
.build();
}
// Returns an Order in COMPLETED state
public static Order completedOrder() {
return validPendingOrder().toBuilder()
.status(OrderStatus.COMPLETED)
.build();
}
}
// Usage in a test
class OrderServiceTest {
@Test
void completingAnOrderShouldSendConfirmationEmail() {
// Reusable test data β no duplication
Order order = OrderMother.validPendingOrder();
// ... test logic
assertNotNull(order);
}
}
The junit-platform.properties Configuration File
Place this file at src/test/resources/junit-platform.properties to configure JUnit 6 behaviour globally:
# Display names for test classes and methods
junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores
# Parallel execution (disabled by default)
junit.jupiter.execution.parallel.enabled=false
junit.jupiter.execution.parallel.mode.default=concurrent
# Timeout for all tests (optional safety net)
junit.jupiter.execution.timeout.default=30s
# Lifecycle: create a new instance per method (default) or per class
junit.jupiter.testinstance.lifecycle.default=per_method
Project Structure Anti-Patterns to Avoid
- β Putting all tests in one package β impossible to navigate in large projects
- β Naming methods
test1(),test2()β tells you nothing about what is being tested - β One giant test class per module β makes tests hard to find and slow to run
- β Sharing mutable state between tests β leads to order-dependent, flaky tests
- β Testing implementation details β tests should verify behaviour, not internal method calls
- β No separation of unit and integration tests β makes CI pipelines slow and brittle
Frequently Asked Questions (FAQs)
Q1: Should test classes be in the same package as production classes?
Yes β by convention and for a practical reason. When a test class is in the same package as the class it tests (even though in a different source folder), it gains access to package-private methods and fields without needing to make them public. This is the standard practice in Java projects and is supported by the Maven/Gradle source set design.
Q2: How many test methods should a single test class have?
There is no hard limit, but a good rule of thumb is to keep test classes focused on one production class and use @Nested inner classes to group related tests within that class. If a test class grows beyond 200β300 lines, consider splitting it by scenario or feature area. See JUnit 6 Nested Tests for grouping strategies.
Q3: Where should I put test helper utilities like builders and mothers?
Place them in a dedicated sub-package under your test root, such as com.example.common or com.example.testutil. This keeps them separate from actual test classes and makes it easy to import them across the test suite. Never place them under src/main/java β they should only exist on the test classpath.
Q4: What is the difference between @BeforeAll in a base class vs. in the test class itself?
When @BeforeAll is declared in a base class, it runs once before all tests in the entire class hierarchy that extends it. When declared in the test class itself, it only runs once for that specific class. Base class lifecycle methods are inherited and compose naturally in JUnit 6 β both the parentβs and the childβs @BeforeAll methods will run.
Q5: Should I use a flat or hierarchical test package structure?
Always mirror your production package structure. A hierarchical structure (e.g., com.example.service, com.example.repository) scales far better than a flat structure because you can immediately locate the test for any production class. In large projects with hundreds of test classes, a flat structure becomes unnavigable.
See Also
- JUnit 6 Tutorial: Complete Series Index
- JUnit 6 with Maven and Gradle: Complete Setup Guide
- JUnit 6 Test Lifecycle Explained (BeforeEach, AfterAll, and More)
- Structuring Tests with JUnit 6 Nested Tests
- Writing Maintainable Tests in JUnit 6 (Clean Code Principles)
Conclusion
A clean project structure is the foundation of a maintainable test suite. Mirror production packages, separate unit and integration tests, use base classes for shared setup, and apply the Object Mother pattern for reusable test data. These patterns scale from 10 tests to 10,000 tests without becoming a maintenance burden.
Next: JUnit 6 vs JUnit 5: Key Differences, Features, and Migration Guide β understand what changed and how to migrate existing test suites.