Why and What to Test

Decide which parts of a Java service deserve tests, which kind of test fits each, and how to shape code so it can be tested at all.

Syntax@Test void describesTheBehaviour() { // given: known input and collaborators // when: call the code under test once // then: assert on the result or the effect }

A test is a small program that calls your code with known input and fails loudly when the result is wrong. Its real value shows up months later: when you change a pricing rule, upgrade a library or refactor a repository, the suite tells you within a minute whether behaviour you relied on still holds. A service without tests can only be changed carefully and slowly.

What pays off

  • Business rules: prices, discounts, permissions, state changes such as an order moving from PAID to SHIPPED. This is the code that loses money when it is wrong.
  • Boundaries and edge cases: zero, one, many, the last day of a coupon, an empty basket, a quantity of Integer.MAX_VALUE. Most bugs sit at the edges, not in the middle.
  • Error paths: what the service does with bad input, a missing row or a failed payment call. These paths run rarely in development and often in production.
  • Edges of the system: SQL queries, JSON mapping and HTTP status codes. These need tests against the real thing, because a mock cannot tell you that your SQL is wrong.

Skip tests for getters, record accessors, code the framework already tests, and private methods called directly. Test private logic through the public method that uses it; if that feels awkward, the logic probably wants to be its own class.

Kinds of test

Unit tests check one class or a few classes in memory. They run in milliseconds, so you can have thousands. Integration tests run your code against real infrastructure, such as PostgreSQL in a container, and catch the mistakes unit tests cannot. A handful of end-to-end tests call the running service over HTTP. Most of the suite should be fast unit tests, with enough integration tests to cover every query and every external boundary.

Testable code

Code is hard to test when it reaches out for its own dependencies: LocalDate.now() inside a method, a new HttpClient in a constructor, a static database helper. Pass those in instead. A Clock, a repository interface or a payment gateway interface given through the constructor can be replaced with a fixed or fake version in a test.

Common mistakes

  • Tests that depend on the current time, random values, map iteration order or another test running first. They pass on your machine and fail in CI one time in twenty, and soon nobody trusts the suite.
  • Chasing a coverage percentage. Coverage shows which lines never ran; it says nothing about whether the assertions were any good.
  • Only testing the happy path.

Example

Example · java
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.math.BigDecimal;
import org.junit.jupiter.api.Test;

class ShippingCalculatorTest {

    private final ShippingCalculator calculator = new ShippingCalculator(new BigDecimal("50.00"));

    @Test
    void chargesFlatRateBelowTheFreeShippingThreshold() {
        assertThat(calculator.shippingFor(new BigDecimal("49.99")))
                .isEqualByComparingTo("4.95");
    }

    @Test
    void shipsFreeAtExactlyTheThreshold() {
        // The boundary itself: is it "over 50" or "50 and over"?
        assertThat(calculator.shippingFor(new BigDecimal("50.00")))
                .isEqualByComparingTo("0");
    }

    @Test
    void rejectsANegativeBasketTotal() {
        assertThatThrownBy(() -> calculator.shippingFor(new BigDecimal("-1")))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("basket total must not be negative");
    }
}

// The class under test
class ShippingCalculator {
    private final BigDecimal freeFrom;

    ShippingCalculator(BigDecimal freeFrom) {
        this.freeFrom = freeFrom;
    }

    BigDecimal shippingFor(BigDecimal basketTotal) {
        if (basketTotal.signum() < 0) {
            throw new IllegalArgumentException("basket total must not be negative");
        }
        return basketTotal.compareTo(freeFrom) >= 0 ? BigDecimal.ZERO : new BigDecimal("4.95");
    }
}

When to use it

  • Pin down a discount rule so a later refactor of the pricing service cannot change what customers are charged.
  • Cover the 404 and 409 paths of an order API so error handling keeps returning the right status codes.
  • Replace LocalDate.now() with an injected Clock so subscription renewal logic can be tested on 29 February.

More examples

Injecting a Clock so time can be tested

Because the checker asks its Clock for the date instead of calling LocalDate.now(), a test can pin the time to the exact boundary it cares about. In production you pass Clock.systemUTC(), and nothing else changes.

Example · java
record Coupon(String code, LocalDate expiresOn) {}

class CouponChecker {
    private final Clock clock;

    CouponChecker(Clock clock) {
        this.clock = clock;
    }

    boolean isValid(Coupon coupon) {
        LocalDate today = LocalDate.now(clock);
        return !today.isAfter(coupon.expiresOn());
    }
}

void main() {
    Clock lastDay = Clock.fixed(Instant.parse("2026-03-31T23:30:00Z"), ZoneOffset.UTC);
    var coupon = new Coupon("SPRING", LocalDate.of(2026, 3, 31));

    // A boundary case: the expiry date itself still counts
    check("valid on its last day", new CouponChecker(lastDay).isValid(coupon));

    // One hour later it is 1 April in UTC
    Clock nextDay = Clock.offset(lastDay, Duration.ofHours(1));
    check("expired the day after", !new CouponChecker(nextDay).isValid(coupon));
}

void check(String name, boolean passed) {
    IO.println((passed ? "PASS " : "FAIL ") + name);
}

// Output:
// PASS valid on its last day
// PASS expired the day after

Testing an error path, not only the happy one

If OrderRepository is a functional interface, a lambda is a complete fake and no mocking library is needed. The test pins down the exception type that the web layer maps to a 404, so a refactor cannot quietly turn it into a 500.

Example · java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.Optional;
import org.junit.jupiter.api.Test;

class OrderServiceTest {

    // A hand-written fake: an in-memory repository with no orders in it
    private final OrderRepository emptyRepository = id -> Optional.empty();
    private final OrderService service = new OrderService(emptyRepository);

    @Test
    void cancellingAnUnknownOrderThrowsNotFound() {
        var e = assertThrows(OrderNotFoundException.class, () -> service.cancel("ord-404"));
        assertEquals("order ord-404 not found", e.getMessage());
    }
}

// The code under test
record Order(String id, String status) {}

interface OrderRepository {
    Optional<Order> findById(String id);
}

class OrderNotFoundException extends RuntimeException {
    OrderNotFoundException(String id) {
        super("order " + id + " not found");
    }
}

class OrderService {
    private final OrderRepository orders;

    OrderService(OrderRepository orders) {
        this.orders = orders;
    }

    void cancel(String id) {
        Order order = orders.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
        // ... change the status and save it
    }
}

Running tests from the command line

The same commands run in CI, so a pull request is blocked by exactly the suite you run locally. Running one class is the quick loop while you work; run the whole suite before you push.

Example · bash
# Maven: compile and run every unit test
./mvnw test

# One test class, or the matching methods in it
./mvnw test -Dtest=OrderServiceTest
./mvnw test -Dtest='OrderServiceTest#cancelling*'

# Unit tests plus integration tests (Failsafe runs *IT classes before verify)
./mvnw verify

# Gradle equivalents
./gradlew test
./gradlew test --tests 'com.example.shop.OrderServiceTest'

Discussion

  • Be the first to comment on this lesson.