Writing Useful Unit Tests for Spring Boot Services — Patterns That Catch Real Bugs

by Eric Hanson, Backend Developer at Clean Systems Consulting

What makes a test useful

A useful test fails when the production code has a bug and passes when it doesn't. This sounds obvious until you look at tests that verify method calls rather than outcomes, assert on implementation details rather than behavior, or test conditions that can't fail given the code structure.

Three questions for every test:

  1. What condition would cause this test to fail? If the answer is "a refactor that changes internal implementation without changing behavior," the test is fragile.
  2. What bug would this test catch? If the answer is "none, it just documents the happy path," the test provides coverage but not confidence.
  3. What is this test not checking? Edge cases, error conditions, boundary values — the omissions are where bugs live.

Testing service logic — state over interactions

The most common unit test anti-pattern: verifying that specific methods were called in a specific order, rather than verifying what the code produced.

// FRAGILE — tests implementation, not behavior
@Test
void processOrder_callsRepositoryAndPublisher() {
    orderService.processOrder(createRequest());

    verify(orderRepository).save(any(Order.class));
    verify(eventPublisher).publishEvent(any(OrderPlacedEvent.class));
    // Passes even if the saved order has wrong data
    // Passes even if the event has wrong data
    // Fails on every refactor that changes internal method calls
}

// USEFUL — tests what was produced
@Test
void processOrder_savesOrderWithCorrectStatus() {
    ArgumentCaptor<Order> orderCaptor = ArgumentCaptor.forClass(Order.class);

    orderService.processOrder(new CreateOrderRequest(
        "user-123", List.of(new LineItem("product-A", 2, Money.of(50, "USD")))));

    verify(orderRepository).save(orderCaptor.capture());
    Order saved = orderCaptor.getValue();

    assertThat(saved.getUserId()).isEqualTo("user-123");
    assertThat(saved.getStatus()).isEqualTo(OrderStatus.PENDING);
    assertThat(saved.getTotal()).isEqualByComparingTo(Money.of(100, "USD"));
    assertThat(saved.getLineItems()).hasSize(1);
}

The second test fails if the total is calculated wrong, the status is set incorrectly, or the user ID isn't set. The first test passes in all three cases — it only verifies that save() was called, not what was saved.

When testing methods with side effects (saves, publishes events, sends emails), use ArgumentCaptor to capture what was passed and assert on the captured values.

Testing domain logic in isolation

Business logic that lives in domain objects should be testable without any Spring infrastructure:

// Domain object with testable logic
public class Order {
    private final List<LineItem> lineItems;
    private OrderStatus status;
    private Instant confirmedAt;

    public void confirm() {
        if (status != OrderStatus.PENDING) {
            throw new IllegalStateException(
                "Cannot confirm order in status: " + status);
        }
        this.status = OrderStatus.CONFIRMED;
        this.confirmedAt = Instant.now();
    }

    public Money calculateTotal() {
        return lineItems.stream()
            .map(LineItem::subtotal)
            .reduce(Money.ZERO, Money::add);
    }

    public boolean isEligibleForDiscount() {
        return calculateTotal().isGreaterThan(Money.of(100, "USD"))
            && lineItems.size() >= 3;
    }
}
class OrderTest {

    @Test
    void confirm_transitionsStatusToConfirmed() {
        Order order = OrderFixture.pendingOrder();
        order.confirm();
        assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
    }

    @Test
    void confirm_setsConfirmedTimestamp() {
        Instant before = Instant.now();
        Order order = OrderFixture.pendingOrder();
        order.confirm();
        Instant after = Instant.now();

        assertThat(order.getConfirmedAt())
            .isAfterOrEqualTo(before)
            .isBeforeOrEqualTo(after);
    }

    @Test
    void confirm_throwsException_whenNotPending() {
        Order order = OrderFixture.confirmedOrder();

        assertThatThrownBy(() -> order.confirm())
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("CONFIRMED");
    }

    @Test
    void calculateTotal_sumsLineItemSubtotals() {
        Order order = new Order(List.of(
            new LineItem("A", 2, Money.of(10, "USD")),   // $20
            new LineItem("B", 1, Money.of(30, "USD"))    // $30
        ));

        assertThat(order.calculateTotal()).isEqualByComparingTo(Money.of(50, "USD"));
    }

    @Test
    void isEligibleForDiscount_requiresMinimumTotalAndItems() {
        // Under total threshold — not eligible
        Order smallOrder = new Order(List.of(
            new LineItem("A", 1, Money.of(10, "USD")),
            new LineItem("B", 1, Money.of(10, "USD")),
            new LineItem("C", 1, Money.of(10, "USD"))
        ));
        assertThat(smallOrder.isEligibleForDiscount()).isFalse();

        // Over total but too few items — not eligible
        Order richSmallOrder = new Order(List.of(
            new LineItem("A", 1, Money.of(200, "USD"))
        ));
        assertThat(richSmallOrder.isEligibleForDiscount()).isFalse();

        // Over total AND sufficient items — eligible
        Order qualifyingOrder = new Order(List.of(
            new LineItem("A", 1, Money.of(50, "USD")),
            new LineItem("B", 1, Money.of(30, "USD")),
            new LineItem("C", 1, Money.of(30, "USD"))
        ));
        assertThat(qualifyingOrder.isEligibleForDiscount()).isTrue();
    }
}

Domain object tests have no mocks, no Spring context, no infrastructure. They run in milliseconds and test the exact business rules. The isEligibleForDiscount test covers three distinct cases: fails on total, fails on item count, passes both conditions. This is the boundary value testing pattern — test the boundaries of each condition, not just the happy path.

The test doubles taxonomy — choosing the right one

Stub: returns predetermined values, no verification. Use for dependencies that need to return specific data:

when(userRepository.findById("user-123"))
    .thenReturn(Optional.of(new User("user-123", "alice@example.com")));

Mock: verifies interactions — what methods were called, with what arguments. Use sparingly, for behavior with side effects:

verify(emailService).sendOrderConfirmation(
    eq("alice@example.com"),
    argThat(order -> order.getId().equals("ord-456")));

Spy: wraps a real object, records calls, delegates to real methods by default. Use for partial mocking of complex objects:

OrderService spy = spy(new OrderService(realRepository));
doReturn(Optional.of(premiumUser)).when(spy).loadUser(any());

Fake: a working implementation with simplified behavior. Use for repositories and data stores in tests that need real logic:

public class InMemoryOrderRepository implements OrderRepository {
    private final Map<String, Order> store = new HashMap<>();

    @Override
    public Order save(Order order) {
        store.put(order.getId(), order);
        return order;
    }

    @Override
    public Optional<Order> findById(String id) {
        return Optional.ofNullable(store.get(id));
    }
}

A fake OrderRepository that stores in a HashMap enables service tests that exercise real query logic without a database. The InMemoryOrderRepository is a test artifact — it lives in the test source tree and is used across multiple tests.

The rule: prefer fakes for repositories, stubs for external services, mocks only for side-effectful operations where you need to verify the side effect occurred.

Testing error paths — where most bugs hide

Error paths are tested less frequently and break more often. For every service method, test at least one error condition:

class OrderServiceTest {

    private final OrderRepository orderRepository = mock(OrderRepository.class);
    private final PaymentGateway paymentGateway = mock(PaymentGateway.class);
    private final OrderService orderService = new OrderService(orderRepository, paymentGateway);

    @Test
    void confirmOrder_throwsOrderNotFound_whenOrderDoesNotExist() {
        when(orderRepository.findById("missing-order"))
            .thenReturn(Optional.empty());

        assertThatThrownBy(() -> orderService.confirmOrder("missing-order"))
            .isInstanceOf(OrderNotFoundException.class)
            .hasMessageContaining("missing-order");
    }

    @Test
    void confirmOrder_throwsPaymentException_whenPaymentDeclined() {
        Order pendingOrder = OrderFixture.pendingOrder("ord-123");
        when(orderRepository.findById("ord-123")).thenReturn(Optional.of(pendingOrder));
        when(paymentGateway.charge(any(), any()))
            .thenThrow(new PaymentDeclinedException("Insufficient funds"));

        assertThatThrownBy(() -> orderService.confirmOrder("ord-123"))
            .isInstanceOf(PaymentDeclinedException.class);

        // Verify the order was NOT saved in a bad state
        verify(orderRepository, never()).save(any());
    }

    @Test
    void confirmOrder_doesNotLeaveOrderInInconsistentState_onPaymentFailure() {
        Order pendingOrder = OrderFixture.pendingOrder("ord-123");
        when(orderRepository.findById("ord-123")).thenReturn(Optional.of(pendingOrder));
        when(paymentGateway.charge(any(), any()))
            .thenThrow(new PaymentDeclinedException("Declined"));

        assertThatThrownBy(() -> orderService.confirmOrder("ord-123"))
            .isInstanceOf(PaymentDeclinedException.class);

        // Order should still be PENDING — not partially confirmed
        assertThat(pendingOrder.getStatus()).isEqualTo(OrderStatus.PENDING);
    }
}

The third test — verifying that the order remains PENDING after a payment failure — catches a real bug class: partial state mutation that leaves the system in an inconsistent state. This test fails if confirmOrder mutates the order status before calling paymentGateway.charge() and doesn't roll back on failure.

Parameterized tests — eliminating test duplication

When the same logic needs verification across multiple inputs, parameterized tests avoid copy-paste:

@ParameterizedTest
@MethodSource("discountEligibilityTestCases")
void isEligibleForDiscount_returnsExpectedResult(
        List<LineItem> items, boolean expectedResult, String description) {

    Order order = new Order(items);
    assertThat(order.isEligibleForDiscount())
        .as(description)
        .isEqualTo(expectedResult);
}

static Stream<Arguments> discountEligibilityTestCases() {
    return Stream.of(
        Arguments.of(
            List.of(item("A", 50), item("B", 30), item("C", 30)),
            true, "over threshold with sufficient items"),
        Arguments.of(
            List.of(item("A", 200)),
            false, "over threshold but only 1 item"),
        Arguments.of(
            List.of(item("A", 10), item("B", 10), item("C", 10)),
            false, "3 items but under threshold"),
        Arguments.of(
            List.of(),
            false, "empty order"),
        Arguments.of(
            List.of(item("A", 100)),
            false, "exactly at threshold boundary — not over")
    );
}

The as(description) label appears in the test failure output — when a case fails, the description identifies which case without reading the arguments.

The boundary case "exactly at threshold boundary — not over" is the one most likely to reveal an off-by-one error in isGreaterThan vs isGreaterThanOrEqualTo.

Testing code with time dependencies

Code that calls Instant.now() or LocalDate.now() directly is hard to test — the current time is different on every test run:

// Hard to test — depends on real time
public boolean isExpired() {
    return expiresAt.isBefore(Instant.now());
}

// Testable — time is injected
public boolean isExpired(Clock clock) {
    return expiresAt.isBefore(clock.instant());
}

// Or via constructor injection
public class OrderExpiryService {
    private final Clock clock;

    public boolean isOrderExpired(Order order) {
        return order.getExpiresAt().isBefore(clock.instant());
    }
}
@Test
void isOrderExpired_returnsTrue_whenExpiryIsInThePast() {
    Clock fixedClock = Clock.fixed(
        Instant.parse("2026-04-17T12:00:00Z"), ZoneOffset.UTC);
    OrderExpiryService service = new OrderExpiryService(fixedClock);

    Order expiredOrder = OrderFixture.withExpiresAt(
        Instant.parse("2026-04-17T11:00:00Z")); // 1 hour before fixed clock

    assertThat(service.isOrderExpired(expiredOrder)).isTrue();
}

@Test
void isOrderExpired_returnsFalse_whenExpiryIsInTheFuture() {
    Clock fixedClock = Clock.fixed(
        Instant.parse("2026-04-17T12:00:00Z"), ZoneOffset.UTC);
    OrderExpiryService service = new OrderExpiryService(fixedClock);

    Order validOrder = OrderFixture.withExpiresAt(
        Instant.parse("2026-04-17T13:00:00Z")); // 1 hour after fixed clock

    assertThat(service.isOrderExpired(validOrder)).isFalse();
}

Clock.fixed() freezes time at a specific instant. Tests are deterministic regardless of when they run.

Test fixtures — keeping test data readable

Test fixtures provide pre-built test objects without duplicating construction code across tests:

// Test fixture class — lives in test source
public class OrderFixture {

    public static Order pendingOrder() {
        return new Order(
            "ord-" + UUID.randomUUID(),
            "user-123",
            OrderStatus.PENDING,
            List.of(defaultLineItem()),
            Instant.now()
        );
    }

    public static Order pendingOrder(String orderId) {
        return new Order(orderId, "user-123", OrderStatus.PENDING,
            List.of(defaultLineItem()), Instant.now());
    }

    public static Order withStatus(OrderStatus status) {
        return new Order("ord-456", "user-123", status,
            List.of(defaultLineItem()), Instant.now());
    }

    public static Order withExpiresAt(Instant expiresAt) {
        return new Order("ord-789", "user-123", OrderStatus.PENDING,
            List.of(defaultLineItem()), expiresAt);
    }

    private static LineItem defaultLineItem() {
        return new LineItem("product-A", 1, Money.of(50, "USD"));
    }
}

Test fixtures make tests readable — OrderFixture.pendingOrder() communicates intent without constructor argument noise. When the Order constructor signature changes, only the fixture needs updating, not every test that creates an order.

The tests that pay off vs the tests that don't

Worth testing:

  • Business rule implementations with branching logic (discount calculations, eligibility checks, state transitions)
  • Error conditions and exception paths
  • Boundary values (zero items, exactly at threshold, maximum allowed)
  • Data transformation between layers (entity to response, command to domain object)
  • Concurrency-sensitive logic (idempotency checks, optimistic lock retry)

Not worth the maintenance:

  • Tests that call save() and verify save() was called with no assertion on content
  • Tests that mock every dependency and verify every call in order — they're integration tests written badly
  • Tests that duplicate the production code logic to verify it (assertThat(result).isEqualTo(a + b) when the production code is literally return a + b)
  • Tests for Spring plumbing (@Autowired wiring, @Value binding) — test these at the slice or integration level

The signal that a test is worth keeping: when you change the test's assertion to the wrong value, the test fails. A test that passes regardless of what value you assert on is not testing anything.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why 9 Developers Cannot Deliver a Project 9 Months Faster

It sounds logical: if one developer takes 9 months, then 9 developers should take 1 month. But software projects don’t work like that.

Read more

Why Hiring a Backend Developer in Paris Costs More Than the Salary Suggests

You budgeted €65K for a backend hire. Then your accountant explained cotisations patronales and suddenly the number looked very different.

Read more

How to Recognize a Failing Software Project Early

Not all disasters happen overnight. Sometimes, projects fail slowly, and the warning signs are subtle. Spotting them early can save you money, time, and a lot of frustration.

Read more

Spring Boot Caching in Practice — @Cacheable, Cache Warming, and When Caching Makes Things Worse

Spring Boot's caching abstraction makes it easy to add caching to any method. What it doesn't tell you is when caching the wrong things causes stale data bugs, cache stampedes, and memory pressure that's harder to debug than the original performance problem.

Read more