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.

Syntax@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, if statements 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

Example · java
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;

@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class OrderCancellationTest {

    private final InMemoryOrderRepository orders = new InMemoryOrderRepository();
    private final OrderService service = new OrderService(orders);

    @Nested
    class a_paid_order {

        @Test
        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));
        }
    }

    @Nested
    class a_shipped_order {

        @Test
        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.

Example · java
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: true

A 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.

Example · text
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.json

Running *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.

Example · xml
<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

  • Be the first to comment on this lesson.