Test Structure and Naming
Lay out test files, name tests after behaviour, structure each one as arrange-act-assert, and build test data without copy and paste.
@Test
void refusesToCancelAShippedOrder() {
var order = anOrder().shipped().build(); // arrange
var result = service.cancel(order.id()); // act
assertThat(result).isEqualTo(CancelResult.REFUSED); // assert
}A test suite is read far more often than it is written: when a test fails in CI, someone who did not write it has to understand what broke. Consistent structure and names make that quick.
Where tests live
Maven and Gradle expect tests in src/test/java, mirroring the packages in src/main/java. A test in the same package can reach package-private classes and methods, so you do not have to make things public just to test them. Test resources such as SQL scripts and JSON fixtures go in src/test/resources.
Name the class after what it tests: OrderServiceTest for unit tests. Surefire picks up classes named Test*, *Test, *Tests or *TestCase by default, so a class called OrderServiceSpec is silently skipped. Use *IT for integration tests, which Failsafe runs in its integration-test phase when you build with ./mvnw verify.
Names that describe behaviour
A method name should say what the code does in a situation, not which method it calls. testCancel tells you nothing when it fails; refusesToCancelAShippedOrder tells you the rule that broke. Some teams use underscores (cancel_shippedOrder_isRefused) with @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) for readable reports. Pick one style per project.
Arrange, act, assert
Give each test three parts, separated by a blank line: set up the input and collaborators, call the code under test once, then check the result. Also known as given, when, then. If you find two acts in one test, it is two tests.
- One behaviour per test. Several assertions are fine when they describe one outcome.
- No logic in tests. Loops,
ifstatements and computed expectations can hide bugs of their own. Use literal expected values. - Show only what matters. If a test is about unverified customers, the customer's name and address are noise. A test data builder with sensible defaults lets each test set only the fields it cares about.
- No shared mutable state. Static fields that tests modify make results depend on run order and break once tests run in parallel.
Mistakes people make
Copying a 30-line setup into every test, so one model change means editing 50 files. Giant base classes that set up everything for everyone. Names like test1. Tests that assert on log output or private fields instead of observable behaviour.
Example
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
(DisplayNameGenerator.ReplaceUnderscores.class)
class OrderCancellationTest {
private final InMemoryOrderRepository orders = new InMemoryOrderRepository();
private final OrderService service = new OrderService(orders);
class a_paid_order {
void can_be_cancelled() {
// arrange
orders.save(OrderBuilder.anOrder().withId("ord-1").paid().build());
// act
service.cancel("ord-1");
// assert
assertThat(orders.findById("ord-1"))
.hasValueSatisfying(o -> assertThat(o.status()).isEqualTo(OrderStatus.CANCELLED));
}
}
class a_shipped_order {
void cannot_be_cancelled() {
orders.save(OrderBuilder.anOrder().withId("ord-2").shipped().build());
assertThatThrownBy(() -> service.cancel("ord-2"))
.isInstanceOf(OrderStateException.class)
.hasMessage("order ord-2 is SHIPPED and cannot be cancelled");
}
}
}
// In a project these live in src/main/java (the service) and src/test/java (the builder and fake)
enum OrderStatus { PAID, SHIPPED, CANCELLED }
record Order(String id, OrderStatus status) {
Order withStatus(OrderStatus newStatus) {
return new Order(id, newStatus);
}
}
class OrderStateException extends RuntimeException {
OrderStateException(String message) {
super(message);
}
}
interface OrderRepository {
void save(Order order);
Optional<Order> findById(String id);
}
class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> orders = new HashMap<>();
public void save(Order order) {
orders.put(order.id(), order);
}
public Optional<Order> findById(String id) {
return Optional.ofNullable(orders.get(id));
}
}
class OrderBuilder {
private String id = "ord-1";
private OrderStatus status = OrderStatus.PAID;
static OrderBuilder anOrder() {
return new OrderBuilder();
}
OrderBuilder withId(String id) {
this.id = id;
return this;
}
OrderBuilder paid() {
status = OrderStatus.PAID;
return this;
}
OrderBuilder shipped() {
status = OrderStatus.SHIPPED;
return this;
}
Order build() {
return new Order(id, status);
}
}
class OrderService {
private final OrderRepository orders;
OrderService(OrderRepository orders) {
this.orders = orders;
}
void cancel(String id) {
Order order = orders.findById(id).orElseThrow();
if (order.status() != OrderStatus.PAID) {
throw new OrderStateException("order " + id + " is " + order.status() + " and cannot be cancelled");
}
orders.save(order.withStatus(OrderStatus.CANCELLED));
}
}
When to use it
- Make a CI failure readable at a glance by naming tests after the business rule they protect.
- Keep order tests short with a builder after the Order record gains new required fields.
- Split a slow suite so container-based *IT tests run in verify while unit tests run on every save.
More examples
A test data builder with sensible defaults
The defaults describe a valid, unremarkable order, so each case only names the one thing it changes. When Order gains a field, only the builder needs updating, not every test that creates an order.
record Customer(String email, boolean verified) {}
record Order(String id, Customer customer, List<String> skus, BigDecimal total) {}
// In a project this is a top-level class in src/test/java; it is static
// here only because this demo is a single compact source file.
static class OrderBuilder {
private String id = "ord-1";
private Customer customer = new Customer("[email protected]", true);
private List<String> skus = List.of("SKU-1");
private BigDecimal total = new BigDecimal("19.99");
static OrderBuilder anOrder() {
return new OrderBuilder();
}
OrderBuilder withUnverifiedCustomer() {
customer = new Customer(customer.email(), false);
return this;
}
OrderBuilder withTotal(String amount) {
total = new BigDecimal(amount);
return this;
}
Order build() {
return new Order(id, customer, skus, total);
}
}
// The rule under test
boolean needsManualReview(Order order) {
return !order.customer().verified()
|| order.total().compareTo(new BigDecimal("1000")) >= 0;
}
void main() {
// Each case states only the detail that matters to it
IO.println("default order: " + needsManualReview(OrderBuilder.anOrder().build()));
IO.println("unverified customer: "
+ needsManualReview(OrderBuilder.anOrder().withUnverifiedCustomer().build()));
IO.println("total of 1000.00: "
+ needsManualReview(OrderBuilder.anOrder().withTotal("1000.00").build()));
}
// Output:
// default order: false
// unverified customer: true
// total of 1000.00: trueA project layout that mirrors main
Same package means the tests can use package-private constructors and helpers. Builders and fakes live under src/test, so they never ship in the production JAR.
src/
main/java/com/example/shop/order/
OrderService.java
OrderRepository.java
JdbcOrderRepository.java
test/java/com/example/shop/order/
OrderServiceTest.java unit test, run by Surefire in "test"
OrderBuilder.java test data builder, test code only
InMemoryOrderRepository.java fake used by unit tests
JdbcOrderRepositoryIT.java integration test, run by Failsafe in "verify"
test/resources/
db/schema.sql
fixtures/order-created.jsonRunning *IT classes with Maven Failsafe
With this in place, ./mvnw test runs only the fast unit tests and ./mvnw verify runs both kinds. Failsafe fails in the verify goal rather than immediately, so post-integration-test cleanup still runs.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.5.6</version>
<executions>
<execution>
<goals>
<!-- integration-test runs IT*, *IT and *ITCase classes;
verify fails the build afterwards if any of them failed -->
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
Discussion