Maciej Soliński
Back to the blog

Tests That Help You Change Code

How to test business behaviour, choose useful boundaries and avoid tests that break after every harmless refactoring.

Tests That Help You Change Code

A test suite is supposed to make changes safer. Yet some suites fail whenever a private helper moves, while others stay green after an important business rule breaks.

The difference is often what the tests describe. A useful test protects a decision or an observable contract. A fragile test repeats the current implementation so closely that changing the implementation also requires rewriting the test.

Write down the rule before the assertion

Consider a shipping policy with three requirements:

  • Standard delivery costs 4.99 below an order subtotal of 100.00.
  • Standard delivery is free from 100.00, including the threshold itself.
  • Express delivery always costs 9.99, regardless of the subtotal.

These amounts are illustrative and use a single assumed currency. The policy rejects a negative subtotal. This gives us a small, explicit contract:

import java.math.BigDecimal;
import java.util.Objects;

public final class ShippingPolicy {
    private static final BigDecimal FREE_FROM = new BigDecimal("100.00");
    private static final BigDecimal STANDARD_FEE = new BigDecimal("4.99");
    private static final BigDecimal EXPRESS_FEE = new BigDecimal("9.99");

    public BigDecimal fee(BigDecimal subtotal, boolean express) {
        Objects.requireNonNull(subtotal);
        if (subtotal.signum() < 0) {
            throw new IllegalArgumentException("Negative subtotal");
        }
        if (express) {
            return EXPRESS_FEE;
        }
        return subtotal.compareTo(FREE_FROM) >= 0
                ? new BigDecimal("0.00") : STANDARD_FEE;
    }
}

There is no reason to start a Spring context for this class. It has no framework dependencies. A plain JUnit Jupiter test is enough for these rules:

import static org.junit.jupiter.api.Assertions.*;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;

class ShippingPolicyTest {
    private final ShippingPolicy policy = new ShippingPolicy();

    @Test
    void chargesStandardFeeBelowTheThreshold() {
        assertEquals(new BigDecimal("4.99"),
                policy.fee(new BigDecimal("99.99"), false));
    }

    @Test
    void includesTheThresholdInFreeStandardDelivery() {
        assertEquals(new BigDecimal("0.00"),
                policy.fee(new BigDecimal("100.00"), false));
    }

    @Test
    void doesNotApplyFreeDeliveryToExpressOrders() {
        assertEquals(new BigDecimal("9.99"),
                policy.fee(new BigDecimal("150.00"), true));
    }

    @Test
    void rejectsNegativeSubtotals() {
        assertThrows(IllegalArgumentException.class,
                () -> policy.fee(new BigDecimal("-0.01"), false));
    }
}

The test names explain the decisions. In particular, the exact threshold catches a common boundary error, while the express case protects the priority of that rule. Assertions are part of the Jupiter API documented in the JUnit user guide.

assertEquals also checks the representation of each BigDecimal, including its scale. That is intentional here: the example returns fees with two decimal places. If representation were irrelevant, the comparison should express numerical equality instead.

Choose the boundary that can expose the failure

A unit test can prove the shipping rule, but it cannot prove that the controller accepts the expected JSON or that a transaction rolls back after a database failure.

Question Useful test boundary
Does the shipping rule include the threshold? Plain unit test
Does the endpoint reject invalid input? MVC test with request and response assertions
Does the query work on the target database? Database integration test
Does the packaged application start correctly? Application startup or smoke test

Spring Boot provides focused testing support as well as full application tests. Its testing documentation is the right place to check the modules for your version. In Boot 4, focused test starters can affect the dependencies and imports needed by a particular test slice.

Mock only what needs isolation

A mock is useful when a dependency is slow, unavailable or difficult to put into a specific failure state. It is less useful when it replaces a simple domain object or turns every internal call into an expectation.

Verifying that a notification is sent once may protect an important side effect. Verifying the order of five harmless helper calls may just lock in the current design. The question is whether the interaction itself belongs to the contract.

Keep expected results independent of the implementation. A test that calculates its expected shipping fee by calling the same policy again cannot detect a mistake in that policy.

Make failure messages useful

Small tests, explicit input and clear names reduce the time needed to understand a failure. Avoid hidden dependencies on execution order, the current date or leftover database records. Use controlled clocks and isolated fixtures where those concerns matter.

Coverage is helpful for finding unexercised code, but it does not show whether assertions protect the right behaviour. A useful final question is: which incorrect change would make this test fail? If there is no clear answer, the test may be adding maintenance work without adding confidence.