Lesson 105 of 159
Written lesson · 15 min

Testing Use Cases with Test Doubles

Use case orchestration with fakes, stubs and mocks around real domain objects.

Preview

You are viewing a free preview lesson.

In the previous lesson, we tested domain logic directly: Product.changePrice(), Order.markPaid(), Order.cancel() and the rest, with no Spring, no PostgreSQL, no HTTP and no mocks. Now we move one layer outward. Our use cases (CreateProductUseCase, UpdateProductUseCase, CreateOrderUseCase, CancelOrderUseCase, PayOrderUseCase) coordinate business operations across boundaries: repositories, the external PaymentService and the Clock. The goal of this lesson is:

Test application orchestration with controlled test doubles while keeping infrastructure out of the test whenever infrastructure behavior is not what we are trying to prove.

What Is a Use Case Test?

A use case test asks questions about the workflow. Did it choose the correct business path and load the authoritative state it needed? Did it reject invalid current state before any side effect, and avoid calling a boundary after an earlier failure? Did it call the correct repository operation, and the external PaymentService only when appropriate? Did it use server-owned values rather than client-controlled ones, and propagate the right application failure?

It does not ask whether PostgreSQL really rolled back, whether the SQL locked the right row, whether the provider request contained the right JSON, whether Spring Security rejected the request or whether JPA mapped the entity correctly; those belong to the other layers of 10.01's strategy. So most use case tests need no @SpringBootTest, no PostgreSQL and no HTTP server. Our use cases receive their dependencies through the constructor, so a test simply calls new CreateOrderUseCase(productRepository, inventoryRepository, orderRepository, clock): production passes PostgreSQL-backed repositories, a test passes doubles, and the constructor is the testing seam. The domain objects stay real: a CancelOrderUseCase test uses a real Order, because cancel() throwing OrderNotCancellable is part of the workflow (10.11 explains why mocking it fails).

Stubs, Fakes and Mocks

We use three kinds of test double:

  • A stub gives controlled answers: ProductRepository.findById(...) returns a Product with priceCents = 8990, or consumeIfAvailable(...) returns false.
  • A fake is a lightweight working implementation. It helps when we want to inspect resulting state ("save the Product, then read it again").
  • A mock records interactions. verify(paymentService, never()).pay(any()) matters when a CANCELLED Order must not reach the payment provider: the interaction itself is the guarantee.

Here are two fakes. InMemoryProductRepository is the map from 7.02, now that ProductRepository also has the browse query from 7.04; SuccessfulPaymentService approves every payment:

<!-- file: src/test/java/com/tarkev/ordermanagement/product/application/InMemoryProductRepository.java -->
java
package com.tarkev.ordermanagement.product.application;

import com.tarkev.ordermanagement.product.domain.Product;
import com.tarkev.ordermanagement.product.domain.ProductId;
import com.tarkev.ordermanagement.shared.application.PageQuery;
import com.tarkev.ordermanagement.shared.application.PageResult;
import java.util.*;

class InMemoryProductRepository implements ProductRepository {

    private final Map<ProductId, Product> products = new HashMap<>();

    @Override
    public Optional<Product> findById(ProductId productId) {
        return Optional.ofNullable(products.get(productId));
    }

    @Override
    public void save(Product product) {
        products.put(product.id(), product);
    }

    @Override
    public PageResult<ProductSummary> findOrderable(PageQuery pageQuery) {
        throw new UnsupportedOperationException("Tested against PostgreSQL");
    }
}
<!-- file: src/test/java/com/tarkev/ordermanagement/order/application/SuccessfulPaymentService.java -->
java
package com.tarkev.ordermanagement.order.application;

import com.tarkev.ordermanagement.payment.application.*;

class SuccessfulPaymentService implements PaymentService {

    @Override
    public PaymentResult pay(PaymentRequest request) {
        return PaymentResult.succeeded("pay_" + request.orderId().value());
    }
}

The fake repository refuses findOrderable(...) on purpose: browsing is SQL joining, filtering and paging, and imitating it in a map would only test the fake. Whether a helper is technically a fake, stub, spy or mock matters less than knowing which behaviour it controls and which guarantee the test proves. Choose the simplest double that makes the test clear; with Mockito one object is often stub and mock at once (we stub findOwnedForUpdate(...) and verify save(...) on the same OrderRepository).

Product Workflows

CreateProductUseCase already has its test from 7.02: with the in-memory fake, it checks that command values become domain values, the Product is active and reached the persistence boundary. It does not prove that the UUID column, the BIGINT mapping or the Flyway migration work; those are repository integration concerns for the next lesson.

UpdateProductUseCase covers name only, price only, both, an empty update and a missing Product. The domain already rejects a blank name or a negative price, so the test looks at orchestration. With a mocked ProductRepository whose findById(...) returns Optional.empty(), execute(...) must throw ProductNotFound and verify(repository, never()).save(any()) must pass. The empty update new UpdateProductCommand(product.id(), null, null) is a no-op, not VALIDATION_ERROR, and gets the same never() check. Do not invent a failure just because an empty mutation feels unusual.

Testing CreateOrderUseCase

CreateOrderUseCase (7.06) rejects duplicate lines, loads every Product, rejects missing and inactive ones, captures the server-side price, consumes Inventory in ProductId order, builds the UNPAID Order for the command's CustomerId and saves it. The scenarios worth proving:

ScenarioExpected
Valid itemsUNPAID Order for the command's CustomerId, createdAt from the Clock, current Product priceCents in each OrderItem, Order saved
Duplicate Product lineDuplicateProductLine before any repository side effect
Missing or inactive ProductProductNotFound or ProductNotOrderable, no Inventory consumption, no save
Insufficient InventoryInsufficientInventory, Order not saved

Order creation reads Instant.now(clock), so the test passes Clock.fixed(...) and asserts the exact createdAt: no sleeps, no time ranges, no dependence on the machine clock. The repositories are mocks:

java
package com.tarkev.ordermanagement.order.application;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

import com.tarkev.ordermanagement.inventory.application.InventoryRepository;
import com.tarkev.ordermanagement.order.domain.*;
import com.tarkev.ordermanagement.product.application.ProductRepository;
import com.tarkev.ordermanagement.product.domain.Product;
import com.tarkev.ordermanagement.product.domain.ProductId;
import java.time.*;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;

class CreateOrderUseCaseTest {

    private static final Instant NOW = Instant.parse("2026-10-04T12:00:00Z");
    private static final CustomerId CUSTOMER = new CustomerId("customer-123");

    private final ProductRepository productRepository =
            mock(ProductRepository.class);
    private final InventoryRepository inventoryRepository =
            mock(InventoryRepository.class);
    private final OrderRepository orderRepository = mock(OrderRepository.class);
    private final CreateOrderUseCase useCase = new CreateOrderUseCase(
            productRepository, inventoryRepository, orderRepository,
            Clock.fixed(NOW, ZoneOffset.UTC));

    private final Product keyboard =
            Product.create(ProductId.newId(), "Mechanical Keyboard", 8990);

    @Test
    void createsUnpaidOrderWithServerSidePrice() {
        when(productRepository.findById(keyboard.id()))
                .thenReturn(Optional.of(keyboard));
        when(inventoryRepository.consumeIfAvailable(keyboard.id(), 2))
                .thenReturn(true);

        Order order = useCase.execute(new CreateOrderCommand(CUSTOMER,
                List.of(new CreateOrderCommand.Item(keyboard.id(), 2))));

        assertThat(order.customerId()).isEqualTo(CUSTOMER);
        assertThat(order.status()).isEqualTo(OrderStatus.UNPAID);
        assertThat(order.createdAt()).isEqualTo(NOW);
        assertThat(order.items()).containsExactly(
                new OrderItem(keyboard.id(), 2, 8990));
        verify(orderRepository).save(order);
    }

    @Test
    void duplicateProductLineIsRejectedBeforeAnySideEffect() {
        var line = new CreateOrderCommand.Item(keyboard.id(), 2);

        assertThatThrownBy(() -> useCase.execute(
                new CreateOrderCommand(CUSTOMER, List.of(line, line))))
                .isInstanceOf(DuplicateProductLine.class);
        verifyNoInteractions(inventoryRepository, orderRepository);
    }

    @Test
    void inactiveProductStopsTheOrderBeforeInventoryIsTouched() {
        Product mouse = Product.create(ProductId.newId(), "Mouse", 2990);
        mouse.deactivate();
        when(productRepository.findById(keyboard.id()))
                .thenReturn(Optional.of(keyboard));
        when(productRepository.findById(mouse.id()))
                .thenReturn(Optional.of(mouse));
        when(inventoryRepository.consumeIfAvailable(any(), anyInt()))
                .thenReturn(true);

        assertThatThrownBy(() -> useCase.execute(new CreateOrderCommand(
                CUSTOMER, List.of(new CreateOrderCommand.Item(keyboard.id(), 1),
                        new CreateOrderCommand.Item(mouse.id(), 1)))))
                .isInstanceOf(ProductNotOrderable.class);
        verifyNoInteractions(inventoryRepository, orderRepository);
    }
}

A fourth test stubs consumeIfAvailable(keyboard.id(), 5) to return false and expects InsufficientInventory plus verify(orderRepository, never()).save(any()). The assertions are AssertJ's, which spring-boot-starter-test brings along with JUnit and Mockito.

What the Happy Path Proves

The first test protects our authority boundary: CreateOrderCommand has no unitPriceCents or totalCents for a client to fill, so when the stubbed Product costs 8990, the OrderItem must say 8990. It also pins the CustomerId, which the handler takes from currentUser.customerId(); no CustomerRepository, JWT or security context is needed, and we do not add a boolean isAdmin to commands so that unit tests can simulate roles. The Order starts UNPAID without any PaymentService. If constructing CreateOrderUseCase ever needed mock(PaymentService.class), test setup would be revealing architectural drift.

Validation Ordering and Side Effects

The other tests are about what must not happen. A duplicate line is rejected before any Inventory consumption or persistence, so verifyNoInteractions(inventoryRepository, orderRepository) is part of the requirement. Our agreed strategy also validates all Product-level state before Inventory mutation: with an active keyboard and an inactive mouse, the result is PRODUCT_NOT_ORDERABLE and consumption must not even begin. A missing Product behaves the same way; the use case never manufactures a fake Product.

Is that ordering worth a test? Suppose a refactoring consumes each Product's stock as soon as it is loaded, inside toOrderItem(...). Every happy-path test stays green. This one does not:

text
CreateOrderUseCaseTest > inactiveProductStopsTheOrderBeforeInventoryIsTouched() FAILED
    org.mockito.exceptions.verification.NoInteractionsWanted:
    No interactions wanted here:
    -> at com.tarkev.ordermanagement.order.application.CreateOrderUseCaseTest.inactiveProductStopsTheOrderBeforeInventoryIsTouched(CreateOrderUseCaseTest.java:76)
    But found these interactions on mock 'inventoryRepository':
    -> at com.tarkev.ordermanagement.order.application.CreateOrderUseCase.toOrderItem(CreateOrderUseCase.java:57)
    Actually, above is the only interaction with this mock.

The test stubs consumeIfAvailable(...) to answer true on purpose: stock is available, so only the inactive Product can stop the Order. Without the stub, Mockito's default false would have turned the bug into an InsufficientInventory. Error precedence can be tested the same way (duplicate IDs are rejected before a missing Product is noticed), but only where the ordering was deliberately designed.

What Mocks Cannot Prove About Transactions

With two Products, the mock can answer true for A and false for B. The test proves that the use case throws InsufficientInventory and does not save the Order. It cannot prove that the real PostgreSQL UPDATE for A was rolled back, and a fake can even mislead: an in-memory Inventory that goes from 10 to 8 for A stays at 8 when B fails. A unit test proves application intent; 10.06 proves the actual transaction behaviour.

A Small Fake for Payment Attempts

Cancellation and payment depend on PaymentAttemptRepository (9.08). A fake is clearer than a mock here, because the tests inspect the resulting attempts and PayOrderUseCase reads back the attempt it saved:

<!-- file: src/test/java/com/tarkev/ordermanagement/order/application/InMemoryPaymentAttemptRepository.java -->
java
package com.tarkev.ordermanagement.order.application;

import com.tarkev.ordermanagement.order.domain.OrderId;
import com.tarkev.ordermanagement.payment.application.PaymentAttemptRepository;
import com.tarkev.ordermanagement.payment.domain.PaymentAttempt;
import com.tarkev.ordermanagement.payment.domain.PaymentAttemptId;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

class InMemoryPaymentAttemptRepository implements PaymentAttemptRepository {

    private final List<PaymentAttempt> attempts = new ArrayList<>();

    @Override
    public void save(PaymentAttempt attempt) {
        if (!attempts.contains(attempt)) {
            attempts.add(attempt);
        }
    }

    @Override
    public Optional<PaymentAttempt> findById(PaymentAttemptId attemptId) {
        return attempts.stream()
                .filter(attempt -> attempt.id().equals(attemptId)).findFirst();
    }

    @Override
    public Optional<PaymentAttempt> findUnresolvedByOrderId(OrderId orderId) {
        return attempts.stream()
                .filter(attempt -> attempt.orderId().equals(orderId))
                .filter(PaymentAttempt::isUnresolved).findFirst();
    }

    List<PaymentAttempt> all() {
        return List.copyOf(attempts);
    }
}

It does not pretend to be PostgreSQL: no partial unique index, no lock, no transaction. It holds the same PaymentAttempt objects the use case mutates, which is enough for orchestration.

Testing CancelOrderUseCase

Cancellation (7.12, with the attempt check from 9.09) coordinates the owned Order, its lifecycle, Inventory restoration and persistence. CancelOrderUseCaseTest builds the use case from a mocked OrderRepository and InventoryRepository plus the fake, and a real Order with a keyboard × 2 and a mouse × 3; ownedOrderIs(order) stubs findOwnedForUpdate(order.id(), CUSTOMER):

<!-- fragment -->
java
@Test
void cancelsOwnedUnpaidOrderAndRestoresExactQuantities() {
    ownedOrderIs(order);

    Order cancelled = useCase.execute(cancelCommand());

    assertThat(cancelled.status()).isEqualTo(OrderStatus.CANCELLED);
    verify(inventoryRepository).restore(keyboard, 2);
    verify(inventoryRepository).restore(mouse, 3);
    verify(orderRepository).save(order);
}

@Test
void paidOrderDoesNotRestoreInventory() {
    order.markPaid();
    ownedOrderIs(order);

    assertThatThrownBy(() -> useCase.execute(cancelCommand()))
            .isInstanceOf(OrderNotCancellable.class);
    verifyNoInteractions(inventoryRepository);
    verify(orderRepository, never()).save(any());
}

Two more tests complete the class. findOwnedForUpdate(...) returns Optional.empty() both for a missing Order and for another customer's Order, so one empty stub covers both: OrderNotFound, no restoration. And with a PENDING attempt saved in the fake (PaymentAttempt.start(...)), cancellation ends in PaymentInProgress and the Order stays UNPAID. An already CANCELLED Order fails like the PAID one: no second restoration. Restoration runs in ProductId order to reduce deadlock risk (7.12); because that ordering is part of the correctness strategy, a focused test may check it with Mockito's inOrder(...), but avoid asserting incidental iteration order anywhere else.

Testing PayOrderUseCase

Payment is the richest workflow: ownership, payability, a durable attempt, the PaymentService call and the local application of success, decline or uncertainty (9.09). We control PaymentService without contacting a provider: SuccessfulPaymentService above, or a mock that returns PaymentResult.succeeded(...) or PaymentResult.declined(...), or throws PaymentOutcomeUnknown. The use case also needs a TransactionTemplate; a real one around a mocked PlatformTransactionManager simply runs each callback, since the mock hands out no transaction and its commit(...) does nothing. The test stays plain Java and proves orchestration, not transaction boundaries.

java
package com.tarkev.ordermanagement.order.application;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

import com.tarkev.ordermanagement.order.domain.*;
import com.tarkev.ordermanagement.payment.application.*;
import com.tarkev.ordermanagement.payment.domain.PaymentAttemptStatus;
import com.tarkev.ordermanagement.product.domain.ProductId;
import java.time.*;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;

class PayOrderUseCaseTest {

    private static final Instant NOW = Instant.parse("2026-10-04T12:00:00Z");
    private static final CustomerId CUSTOMER = new CustomerId("customer-123");

    private final OrderRepository orderRepository = mock(OrderRepository.class);
    private final InMemoryPaymentAttemptRepository attempts =
            new InMemoryPaymentAttemptRepository();
    private final PaymentService paymentService = mock(PaymentService.class);
    private final PayOrderUseCase useCase = new PayOrderUseCase(
            orderRepository, attempts, paymentService,
            new TransactionTemplate(mock(PlatformTransactionManager.class)),
            Clock.fixed(NOW, ZoneOffset.UTC));

    private final Order order = Order.create(OrderId.newId(), CUSTOMER,
            List.of(new OrderItem(ProductId.newId(), 2, 8990),
                    new OrderItem(ProductId.newId(), 1, 6950)),
            NOW);

    @Test
    void chargesTheOrderTotalAndMarksTheOrderPaid() {
        ownedOrderIs(order);
        when(paymentService.pay(any()))
                .thenReturn(PaymentResult.succeeded("pay_123"));

        Order paid = useCase.execute(payCommand());

        verify(paymentService).pay(new PaymentRequest(order.id(), 24930));
        assertThat(paid.status()).isEqualTo(OrderStatus.PAID);
        assertThat(attempts.all()).singleElement().satisfies(attempt -> {
            assertThat(attempt.status()).isEqualTo(PaymentAttemptStatus.SUCCEEDED);
            assertThat(attempt.providerReference()).isEqualTo("pay_123");
        });
    }

    @Test
    void unknownOutcomeIsNotADeclineAndBlocksASecondCharge() {
        ownedOrderIs(order);
        when(paymentService.pay(any())).thenThrow(
                new PaymentOutcomeUnknown("read timeout", null));

        assertThatThrownBy(() -> useCase.execute(payCommand()))
                .isInstanceOf(PaymentOutcomeUnknown.class);
        assertThat(order.status()).isEqualTo(OrderStatus.UNPAID);
        assertThat(attempts.all()).singleElement().extracting("status")
                .isEqualTo(PaymentAttemptStatus.UNKNOWN);

        assertThatThrownBy(() -> useCase.execute(payCommand()))
                .isInstanceOf(PaymentInProgress.class);
        verify(paymentService).pay(any());
    }

    private void ownedOrderIs(Order owned) {
        when(orderRepository.findOwnedForUpdate(owned.id(), CUSTOMER))
                .thenReturn(Optional.of(owned));
    }

    private PayOrderCommand payCommand() {
        return new PayOrderCommand(CUSTOMER, order.id());
    }
}

What the Payment Tests Prove

The amount comes from the Order: 2 × 8990 + 6950 = 24930 cents. PaymentRequest is a record, so verify(paymentService).pay(new PaymentRequest(order.id(), 24930)) checks the exact request (an ArgumentCaptor works too for field-by-field assertions). No client-controlled amount reaches the provider.

The full class has three more tests. A CANCELLED Order (after order.cancel()) must end in OrderNotPayable with verifyNoInteractions(paymentService) and no attempt, and a non-owned Order (an empty lookup) in OrderNotFound the same way: "no external charge" is a financial guarantee. A decline (PaymentResult.declined("insufficient_funds")) ends in PaymentDeclined, an UNPAID Order and a DECLINED attempt, with no cancellation and no Inventory change. An unknown outcome is not a decline: the attempt is UNKNOWN, the Order stays UNPAID, and a second pay request ends in PaymentInProgress while verify(paymentService).pay(any()) confirms exactly one provider call. Once a recovery path exists, the next test is that a known success is applied locally without calling PaymentService again.

The assertions use the real PaymentAttemptStatus values from 9.08. Inventing TIMEOUT_RETRY_PENDING only because a test needs something to assert would be backwards: tests follow production semantics. The three order test classes need well under a second of test time (Gradle's test report); JVM and Gradle startup cost more than the tests:

text
Class                   Tests  Duration
CreateOrderUseCaseTest      4    0.516s
CancelOrderUseCaseTest      4    0.007s
PayOrderUseCaseTest         5    0.065s
13 tests, 0 failures, 0 skipped

Interaction Assertions

The verify(...) calls above all guard a side effect where a call means money or stock: no provider call for an ineligible Order, no consumption after failed Product validation, no save after insufficient Inventory, no restoration for a disallowed cancellation, exact quantities for a valid one. "findById called exactly once" or verifyNoMoreInteractions(...) merely because that is how the current code looks would only break harmless refactorings; 10.11 draws that line in detail.

Failures and Exceptions

Suppose productRepository.save(any()) throws new RuntimeException("database unavailable"). The use case must not catch Exception and turn it into ProductNotFound or InsufficientInventory; a use case test can verify that the unexpected failure propagates, and its conversion to 500 INTERNAL_ERROR belongs to API testing. In the other direction, use case tests assert application exceptions such as OrderNotPayable, never HTTP statuses: the API test asserts 409 ORDER_NOT_PAYABLE.

Double the Contracts the Use Case Owns

Prefer doubling OrderRepository over EntityManager, and PaymentService over RestClient: those are the dependencies the use case owns. If constructing CreateOrderUseCase required mocks for EntityManager, HttpServletRequest or SecurityContextHolder, that would signal boundary leakage. Keep fakes small, too: if a test only needs findOwnedForUpdate(...) to return empty, a stub beats a complete in-memory OrderRepository.

@Mock fields with @ExtendWith(MockitoExtension.class) work as well as mock(...); the extension's strict stubs fail a test whose stubbing is never used, so a deliberate "stock is available" stub like ours would need lenient().

Practice Exercise 1: Complete the Suites

Solutions: lesson 10.14.

Write the missing cases in two new plain JUnit classes, so the lesson's classes stay as they are. M10OrderUseCaseGapsTest in order.application uses a real Order of Standing Desk × 1 at 24900 cents and Desk Lamp × 3 at 3490 cents:

  • Create Order: a missing Product ends in ProductNotFound with no consumption and no save; two valid lines each carry their own Product's price, and each line's stock is consumed with its own quantity.
  • Cancel Order: an already CANCELLED Order restores nothing and is not saved; restoration follows ProductId order, checked with inOrder(...).
  • Pay Order: with SuccessfulPaymentService the Order ends PAID with one SUCCEEDED attempt for 35370 cents; a PAID Order never reaches the provider; a PaymentProviderUnavailable propagates and leaves the attempt UNKNOWN (9.09).

M10CatalogUseCaseTest in inventory.application covers DeactivateProductUseCase (a missing Product throws ProductNotFound and nothing is saved) and SetInventoryUseCase (a missing Product is rejected before Inventory is touched; the first quantity for a Product without Inventory creates it). For each test, decide what is stubbed, what is real and which interaction is verified.

Practice Exercise 2: Classify the Test

Which layer proves each guarantee: PaymentService is not called for a CANCELLED Order; a provider timeout is detected after the configured duration; the PAID status and the SUCCEEDED attempt commit together; POST /api/v1/orders/{id}/pay returns 409 ORDER_NOT_PAYABLE? And does a cancellation test whose mocked restore(B, ...) throws prove that PostgreSQL rolls back restoration A?

Knowledge Check

Question 1

Why does inactiveProductStopsTheOrderBeforeInventoryIsTouched stub consumeIfAvailable(...) to return true?

Question 2

Why does PayOrderUseCaseTest fake the payment attempts but mock PaymentService?

Question 3

Why would verifyNoMoreInteractions(...) on every mock make the suite worse?

Knowledge Check Answers

Answer 1

Mockito's default false would turn a consumption bug into InsufficientInventory. With stock available, only the inactive Product can stop the Order, so the test fails for the right reason.

Answer 2

The test reads back the resulting attempts, and the use case reads back the attempt it saved, which a small working fake supports. For PaymentService, the guarantee is the interaction itself: called once with the right amount, or never.

Answer 3

It pins down every incidental call, so harmless refactorings break tests. Verification belongs on calls that move money or stock.

Engineering Principle

Use case tests verify orchestration against application boundaries. Replace infrastructure with controlled doubles, but keep real domain objects so business behavior is still exercised.

A mock-based test can prove that the application asked a repository to do something; it cannot prove that the database did it atomically or correctly.

Summary

  • Use case tests check application workflow in plain Java: constructor injection lets us pass doubles, with no Spring context, PostgreSQL or HTTP server.
  • Domain objects stay real; stubs, fakes and mocks sit at the boundaries, and the simplest clear double wins.
  • CreateOrderUseCase tests prove server-side pricing, the command's CustomerId, UNPAID creation with a fixed Clock, and that invalid requests stop before later side effects.
  • CancelOrderUseCase tests prove ownership, lifecycle and unresolved-payment checks before restoration, and exact restored quantities.
  • PayOrderUseCase tests control PaymentService and fake the attempts: ineligible Orders never reach the provider, the amount is order.totalCents(), and an unknown outcome blocks a second charge.
  • Interaction assertions protect correctness-critical effects, especially negative ones; over-verification breaks harmless refactoring.
  • Use case tests assert application exceptions, not HTTP statuses, and let technical failures propagate.
  • Unit tests prove orchestration intent; rollback, locks, constraints, mappings and transaction boundaries need infrastructure tests.

In the next lesson, repository Integration Testing with PostgreSQL, we move below the application repository interfaces and test the real persistence adapters against PostgreSQL: Flyway migrations, JPA mappings, column types, constraints, Order and OrderItem persistence, customer-scoped queries, deterministic pagination and the atomic Inventory SQL.

Think first

Coming soon

Checkpoint questions that ask you to commit to an answer before the lesson shows it.