Unit Testing Services

Test service classes as plain Java with JUnit, AssertJ and Mockito, without starting Spring, and keep time and randomness under control.

Syntax@ExtendWith(MockitoExtension.class) class ServiceTest { @Mock Collaborator collaborator; given(collaborator.call(x)).willReturn(y); then(collaborator).should().call(x); }

A service built with constructor injection is an ordinary Java class. You can create it with new, hand it fakes or mocks for its collaborators, and test its rules without any Spring context. These tests run in milliseconds, so you can afford many of them, one per rule and edge case.

Mockito with JUnit

@ExtendWith(MockitoExtension.class) creates a mock for each @Mock field before every test. Build the service yourself in a @BeforeEach method, or let @InjectMocks do it. Building it by hand is clearer and fails at compile time when the constructor changes.

@Mock OrderRepository orders;
@Mock PaymentGateway payments;

@BeforeEach
void setUp() {
    service = new CheckoutService(orders, payments, clock);
}

The BDD style from org.mockito.BDDMockito reads well in tests: given(...).willReturn(...) to arrange, then(mock).should().save(...) to verify. Mockito's extension runs in strict stubs mode, so a stubbing your test never uses fails the test. That is a feature: it flags setup code that no longer matters.

What to assert

  • The result and the state change, with AssertJ: assertThat(order.status()).isEqualTo(PAID).
  • The exception for a broken rule: assertThatThrownBy(...) with its type and message.
  • What was saved or sent, with an ArgumentCaptor when the object is created inside the service.
  • What did not happen: then(payments).shouldHaveNoInteractions() after a validation failure, so a customer is never charged for a rejected order.

Time, IDs and randomness

A service that calls Instant.now() or UUID.randomUUID() cannot be tested for exact values. Inject a java.time.Clock as a bean and use Clock.fixed(...) in the test. The same idea applies to ID generators: inject a small interface.

Mistakes

Do not mock value objects, records or the class under test. Do not verify every call a method makes: tests that mirror the implementation line by line break on every refactor while still passing on wrong results. Mock at the boundary (repositories, remote clients, message senders) and assert on outcomes.

Annotating a service test with @SpringBootTest to get injection is the most common way a suite becomes slow. If the class under test needs no Spring features to do its job, it needs no Spring to be tested.

Example

Example · java
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;

import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

enum OrderStatus { NEW, PAID }

record Order(long id, String customerId, BigDecimal total, OrderStatus status, Instant paidAt) {

    Order markPaid(Instant when) {
        return new Order(id, customerId, total, OrderStatus.PAID, when);
    }
}

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

    Order save(Order order);
}

interface PaymentGateway {
    boolean charge(String customerId, BigDecimal amount);
}

class PaymentDeclinedException extends RuntimeException {
    PaymentDeclinedException(long orderId) {
        super("Payment declined for order " + orderId);
    }
}

class CheckoutService {

    private final OrderRepository orders;
    private final PaymentGateway payments;
    private final Clock clock;

    CheckoutService(OrderRepository orders, PaymentGateway payments, Clock clock) {
        this.orders = orders;
        this.payments = payments;
        this.clock = clock;
    }

    Order pay(long orderId) {
        Order order = orders.findById(orderId).orElseThrow();
        if (order.status() == OrderStatus.PAID) {
            return order;
        }
        if (!payments.charge(order.customerId(), order.total())) {
            throw new PaymentDeclinedException(orderId);
        }
        return orders.save(order.markPaid(clock.instant()));
    }
}

@ExtendWith(MockitoExtension.class)
class CheckoutServiceTest {

    private static final Instant NOW = Instant.parse("2026-03-01T10:15:00Z");

    @Mock
    OrderRepository orders;

    @Mock
    PaymentGateway payments;

    CheckoutService service;

    @BeforeEach
    void setUp() {
        service = new CheckoutService(orders, payments, Clock.fixed(NOW, ZoneOffset.UTC));
    }

    @Test
    void chargesTheCustomerAndStoresThePaidOrder() {
        Order order = new Order(7, "cus_1", new BigDecimal("30.00"), OrderStatus.NEW, null);
        given(orders.findById(7)).willReturn(Optional.of(order));
        given(payments.charge("cus_1", new BigDecimal("30.00"))).willReturn(true);
        given(orders.save(any())).willAnswer(call -> call.getArgument(0));

        Order paid = service.pay(7);

        assertThat(paid.status()).isEqualTo(OrderStatus.PAID);
        assertThat(paid.paidAt()).isEqualTo(NOW);

        ArgumentCaptor<Order> saved = ArgumentCaptor.forClass(Order.class);
        then(orders).should().save(saved.capture());
        assertThat(saved.getValue().id()).isEqualTo(7);
    }

    @Test
    void doesNotChargeAnOrderTwice() {
        Order order = new Order(8, "cus_1", BigDecimal.TEN, OrderStatus.PAID, NOW);
        given(orders.findById(8)).willReturn(Optional.of(order));

        service.pay(8);

        then(payments).shouldHaveNoInteractions();
    }

    @Test
    void leavesTheOrderUnpaidWhenTheCardIsDeclined() {
        Order order = new Order(9, "cus_2", BigDecimal.ONE, OrderStatus.NEW, null);
        given(orders.findById(9)).willReturn(Optional.of(order));
        given(payments.charge("cus_2", BigDecimal.ONE)).willReturn(false);

        assertThatThrownBy(() -> service.pay(9))
                .isInstanceOf(PaymentDeclinedException.class)
                .hasMessageContaining("order 9");
        then(orders).should().findById(9);
        then(orders).shouldHaveNoMoreInteractions();
    }
}

When to use it

  • Prove that a checkout service never charges a card twice for an order that is already paid.
  • Test a renewal reminder rule at exactly 23:30 UTC on the day the clocks change, without waiting for that date.
  • Check that a failed stock reservation publishes no event, so the warehouse never ships an item that was not reserved.

More examples

A hand-written fake instead of a mock

For a collaborator with one or two methods, a small fake is often clearer than a mock: no stubbing syntax, and the assertion reads like the requirement. Records give you <code>equals</code> for free, so <code>containsExactly</code> compares whole messages. Mocks are still the better fit when you need to make a collaborator fail or return different answers per call.

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

import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;

record WelcomeEmail(String to, String name) {
}

interface EmailSender {
    void send(WelcomeEmail email);
}

class RecordingEmailSender implements EmailSender {

    final List<WelcomeEmail> sent = new ArrayList<>();

    @Override
    public void send(WelcomeEmail email) {
        sent.add(email);
    }
}

class SignupService {

    private final EmailSender emails;

    SignupService(EmailSender emails) {
        this.emails = emails;
    }

    void register(String email, String name) {
        String normalised = email.trim().toLowerCase();
        emails.send(new WelcomeEmail(normalised, name.strip()));
    }
}

class SignupServiceTest {

    @Test
    void sendsTheWelcomeEmailToTheNormalisedAddress() {
        RecordingEmailSender sender = new RecordingEmailSender();
        SignupService service = new SignupService(sender);

        service.register("  [email protected] ", " Ada ");

        assertThat(sender.sent).containsExactly(new WelcomeEmail("[email protected]", "Ada"));
    }
}

Testing a rule that depends on the date

Passing a <code>Clock</code> makes the date part of the input, so the test pins down an exact moment, including the awkward one just before midnight UTC. In the application, register <code>Clock.systemUTC()</code> or a zoned clock as a <code>@Bean</code> and let constructor injection hand it to the service. Tests that read the real clock pass on most days and fail around midnight or a daylight saving change.

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

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import org.junit.jupiter.api.Test;

record Subscription(String customerId, LocalDate renewsOn) {
}

class RenewalReminderPolicy {

    private final Clock clock;

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

    boolean shouldRemind(Subscription subscription) {
        LocalDate today = LocalDate.now(clock);
        return !subscription.renewsOn().isBefore(today)
                && !subscription.renewsOn().isAfter(today.plusDays(7));
    }
}

class RenewalReminderPolicyTest {

    private final Clock clock = Clock.fixed(Instant.parse("2026-05-10T23:30:00Z"), ZoneId.of("Europe/London"));
    private final RenewalReminderPolicy policy = new RenewalReminderPolicy(clock);

    @Test
    void remindsWithinTheLastWeek() {
        assertThat(policy.shouldRemind(new Subscription("c1", LocalDate.of(2026, 5, 18)))).isTrue();
    }

    @Test
    void usesTheLocalDateNotTheUtcDate() {
        // 23:30 UTC on 10 May is already 11 May in London (BST):
        // 19 May is eight days away and 10 May is in the past.
        assertThat(policy.shouldRemind(new Subscription("c2", LocalDate.of(2026, 5, 19)))).isFalse();
        assertThat(policy.shouldRemind(new Subscription("c3", LocalDate.of(2026, 5, 10)))).isFalse();
    }
}

Checking that nothing is published after a failure

<code>@InjectMocks</code> builds the service through its constructor using the <code>@Mock</code> fields, which saves a setup method when the wiring is obvious. The interesting assertion is the negative one: after a rejected reservation, no stock was reserved and no event left the service. Those are the checks that stop a downstream system from shipping goods you never had.

Example · java
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

record StockReserved(String sku, int quantity) {
}

interface StockLevels {
    int available(String sku);

    void reserve(String sku, int quantity);
}

interface DomainEvents {
    void publish(Object event);
}

class OutOfStockException extends RuntimeException {
    OutOfStockException(String sku) {
        super("Not enough stock for " + sku);
    }
}

class ReservationService {

    private final StockLevels stock;
    private final DomainEvents events;

    ReservationService(StockLevels stock, DomainEvents events) {
        this.stock = stock;
        this.events = events;
    }

    void reserve(String sku, int quantity) {
        if (stock.available(sku) < quantity) {
            throw new OutOfStockException(sku);
        }
        stock.reserve(sku, quantity);
        events.publish(new StockReserved(sku, quantity));
    }
}

@ExtendWith(MockitoExtension.class)
class ReservationServiceTest {

    @Mock
    StockLevels stock;

    @Mock
    DomainEvents events;

    @InjectMocks
    ReservationService service;

    @Test
    void rejectsTheReservationAndPublishesNothing() {
        given(stock.available("SKU-1")).willReturn(2);

        assertThatThrownBy(() -> service.reserve("SKU-1", 3)).isInstanceOf(OutOfStockException.class);

        then(stock).should().available("SKU-1");
        then(stock).shouldHaveNoMoreInteractions();
        then(events).shouldHaveNoInteractions();
    }

    @Test
    void publishesAnEventAfterReserving() {
        given(stock.available("SKU-1")).willReturn(5);

        service.reserve("SKU-1", 3);

        then(stock).should().reserve("SKU-1", 3);
        then(events).should().publish(new StockReserved("SKU-1", 3));
    }
}

Discussion

  • Be the first to comment on this lesson.