Lesson 103 of 159
Written lesson · 19 min

Building a Testing Strategy

Deciding which layer proves which guarantee, before writing tests.

Preview

You are viewing a free preview lesson.

Throughout this course, we have already written tests. We tested Product invariants, Inventory behaviour and the Order lifecycle, use case orchestration, repository mappings, transaction rollback, HTTP contracts, authentication and authorization, external payment behaviour and concurrency. So Module 10 does not begin with "Now let's add tests": testing has been part of implementation from the beginning. Instead, this module asks a more mature question:

How do we build a testing strategy that gives us confidence in a production backend without making every test slow, fragile, expensive, or redundant?

A large test suite is not automatically a good one. A useful suite tells us what broke, where it broke, which guarantee failed, and whether the failure lies in business logic, persistence, integration or API behaviour. The goal of this lesson is:

Build a deliberate testing strategy where each important system guarantee is tested at the lowest layer capable of proving it, while using real infrastructure whenever the behavior being tested depends on real infrastructure.

Testing Is Part of Engineering

A common pattern is "build the feature, ship it, add tests later", or a testing ticket at the end of the project. That is not how we build this backend. Our backlog already established that testing belongs inside each feature's Definition of Done, and every feature so far was designed together with its tests:

  • Create Order: duplicate Product IDs, inactive Products, price capture, insufficient Inventory and transaction rollback.
  • Order cancellation: ownership, the lifecycle, Inventory restoration and concurrent cancellation.
  • Payment: provider success and failure, timeouts, duplicate payment execution, pay vs cancel and recovery after a restart.

Testing is not a cleanup phase. It is part of designing behaviour.

What Is a Testing Strategy?

A testing strategy answers questions such as:

  • Which behaviours are important, and which risks can break production?
  • At what layer should each behaviour be tested?
  • Which dependencies should be real, and which should be replaced?
  • Which tests need Spring, PostgreSQL or HTTP, and which should remain plain Java?
  • What should CI run, and what should a failing test tell us?

The strategy is not "write as many tests as possible", nor "achieve 100% line coverage". The goal is confidence, with a reasonable execution cost, clear failure diagnosis and maintainable test code.

So we start with guarantees, not frameworks: not "Should we use Mockito or Testcontainers?" but "What guarantee are we trying to prove?", followed by the smallest test capable of proving it. "An Order cannot move from PAID to CANCELLED" requires a plain domain test. It does not need Spring Boot, PostgreSQL, HTTP or Docker. But "Inventory changes roll back when later Order persistence fails" cannot be proven by a plain unit test: it requires a real transaction manager, real repository adapters and real PostgreSQL. Different guarantee, different test.

For this project, eight layers are useful: Domain, use case, repository integration, transaction integration, API integration, security integration, external-service integration and concurrency tests. Each proves something different and gets its own lesson in this module; the sections below say what each one proves and needs.

Domain Tests

Domain tests verify business invariants, state transitions, calculations and value-object rules: a Product price cannot be negative, the Order total is calculated correctly, UNPAID → PAID is allowed, PAID → CANCELLED is rejected, an Order cannot contain duplicate Product lines. These tests need no Spring, no database, no HTTP and no mocks. That is a major advantage of keeping our domain framework-light.

We already have such tests: OrderLifecycleTest from 7.11 proves that a PAID Order cannot be cancelled with nothing but the Order itself, and says nothing about persistence, the HTTP response, the transaction or authorization, nor should it. A good domain test runs in milliseconds with almost no setup, so we can have many. If testing order.cancel() required a Spring context, Docker and PostgreSQL, we would have coupled our domain too tightly to infrastructure.

Use Case Tests

Use case tests verify the application workflow: orchestration, validation ordering, which boundaries are called and which are not, and which business error is selected. CreateOrderUseCase should reject duplicate Product IDs before any Inventory mutation, use the server's Product price instead of a client price, and not save the Order after a known failure. None of these behaviours requires Hibernate.

Our architecture helps here. A use case depends on ports such as ProductRepository, InventoryRepository, OrderRepository and PaymentService, so a test can provide a fake, stub or mock and stay focused on application behaviour rather than JPA mechanics. To prove that an Order containing an inactive Product fails with PRODUCT_NOT_ORDERABLE and that Inventory consumption was never attempted, a stub ProductRepository plus a recording InventoryRepository is enough.

But a fake repository cannot prove that the SQL is correct, the mapping works, foreign keys, UUIDs and row locks behave, rollback happens or a migration is valid. This is one of the most important ideas in the module:

A test double proves your code's interaction with an abstraction. It does not prove the real implementation behind that abstraction.

Repository Integration Tests

Repository tests verify the boundary between the application and PostgreSQL: JPA mapping, SQL queries, database constraints, ordering, pagination and, where relevant, locking, all against the database technology we actually run. Useful guarantees for each repository:

  • Product: save, reload and update a Product; active and priceCents persist; browsing filters correctly and orders by name ASC, id ASC.
  • Inventory: the quantity persists, 0 is valid, a negative quantity is rejected, the Product foreign key prevents orphan Inventory, a conditional consume succeeds when enough stock exists and affects exactly one row, it fails when stock is insufficient, and restore affects exactly one row.
  • Order: an Order with several OrderItems persists and reloads; UUIDs, CustomerId, createdAt, the historical unitPriceCents and the status round-trip; history is filtered by CustomerId and ordered by createdAt DESC, id DESC.

These are persistence guarantees, and mocks cannot prove them.

Real PostgreSQL Through Testcontainers

Why not H2? Relational databases differ in data types, constraint behaviour, locking, transaction semantics and SQL. Our schema uses UUID, TIMESTAMPTZ and BIGINT, and concurrency behaviour matters, so a different database in tests can produce "tests pass" while production fails. Persistence integration tests use real PostgreSQL.

Nor should they depend on a developer running docker compose up. Integration tests own their infrastructure through Testcontainers, which starts a PostgreSQL container for the test run (10.05); Compose stays the environment for interactive development.

Transaction Integration Tests

It is easy to believe we have atomicity when we have only tested mocks. Suppose Product A has Inventory 10 and Product B has 1, and a request orders 2 × A and 5 × B: consuming A succeeds, consuming B fails, and afterwards A must still be 10, B still 1, with no new Order. A unit test can prove that the use case threw InsufficientInventory, but not that PostgreSQL rolled back the earlier successful UPDATE. Only a real transaction manager, real repository adapters and real PostgreSQL can, with assertions on the committed state read through a fresh boundary (10.06).

API Integration Tests

API tests verify what a client actually observes: the HTTP route, serialization, validation, authentication, the status code, the response shape and Problem Details. POST /api/v1/orders with insufficient Inventory must produce 409, application/problem+json and code = INSUFFICIENT_INVENTORY, which a use case test cannot fully prove. Other public contracts include:

  • a malformed UUID → 400 INVALID_REQUEST;
  • a missing token → 401 AUTHENTICATION_REQUIRED;
  • another customer's Order → 404 ORDER_NOT_FOUND;
  • cancelling a PAID Order → 409 ORDER_NOT_CANCELLABLE;
  • paying a CANCELLED Order → 409 ORDER_NOT_PAYABLE.

API tests should also verify what is not leaked. For an unexpected database or provider failure, the response must not contain SQL, a stack trace, the database host, provider credentials or Java exception class names; the client receives INTERNAL_ERROR or another deliberate application problem.

Security Integration Tests

Security rules are boundary behaviour: an unauthenticated request to a customer endpoint gets 401; Customer A reading, cancelling or paying Customer B's Order gets 404; a regular customer calling an admin Product endpoint gets 403; an administrator is allowed. These need the real Spring Security configuration participating.

A use case test can prove CustomerId-scoped ownership, but not that Spring Security blocks unauthenticated requests or that the roles claim maps to ROLE_ADMIN. Those require security-aware integration tests: a different guarantee needs a different test layer.

External Service Integration Tests

Payment introduced another boundary. We verify the provider request and response mapping, timeouts, technical failures, confirmed business failures and provider configuration without calling the real provider: the real ProviderPaymentClient talks to a controlled stub HTTP server (9.02 used the JDK's HttpServer). That tests serialization, headers, endpoint construction, timeouts and response interpretation while keeping tests deterministic, isolated and safe.

If the purpose is to prove that the HTTP request is correct, mocking ProviderPaymentClient would bypass the very behaviour under test. But when testing PayOrderUseCase, mocking or stubbing PaymentService is entirely reasonable, because that test concerns orchestration rather than HTTP. Same dependency, different testing layer.

Concurrency Tests

Some guarantees exist only under competing transactions, and one thread with a mock repository cannot prove them. A concurrency test uses real PostgreSQL, separate threads and separate transactions, because the behaviour depends on row locks, conditional UPDATEs, transaction visibility and commit ordering; mocking those mechanics mostly tests our imagination of how PostgreSQL works. With Inventory 5 and two concurrent Orders for 4 units each, one succeeds and the final Inventory is 1, never −3; two concurrent cancellations restore Inventory once; pay vs cancel never ends with both a charge and a restoration (10.10).

The Core Rule

The most important strategy rule is:

Test a behavior at the lowest layer that can actually prove the behavior.

For our Order Management Backend, that gives a practical matrix:

ConcernBest primary test
Product invariant, Order lifecycle, totalCents calculationDomain test
Use case validation ordering, ownership orchestrationUse case test
JPA mappingRepository integration test
Flyway schema, FK/CHECK constraints, conditional Inventory consumePostgreSQL integration test
RollbackTransaction integration test
HTTP status and Problem DetailsAPI integration test
Authentication and authorization (401/403/404)Security/API integration test
Provider HTTP mapping and timeoutsExternal-service integration test
Row-lock races, pay vs pay, pay vs cancelConcurrency integration test

This is more useful than "everything needs a unit test" or "everything needs an end-to-end test".

Neither Everything End-to-End Nor Everything Mocked

It might seem safer to write every test as HTTP → Spring → use case → JPA → PostgreSQL. But such tests are slower, harder to diagnose, more expensive to set up and more brittle. If a domain invariant fails, we want OrderTest to fail directly; we should not need an HTTP server, security and PostgreSQL to discover a broken state transition.

The opposite extreme is just as dangerous. A suite made entirely of mocks may report all green while production contains a broken Flyway migration, wrong SQL, an incorrect UUID mapping, a bad transaction boundary, a missing constraint or wrong lock behaviour. Unit tests prove logic; they do not prove infrastructure.

A balanced suite has many fast domain and use case tests, targeted repository and transaction tests, the important API and security tests, focused external-service tests and a few expensive concurrency tests. You may know this shape as the test pyramid. It is a useful heuristic, but not a numerical rule such as 70% unit, 20% integration and 10% end-to-end: our distribution follows architecture, risk and cost. In this backend, persistence and transactions matter significantly, so real PostgreSQL integration testing deserves meaningful coverage.

Test Risk, Not Lines

Twenty lines of conditional Inventory update logic may deserve far deeper testing than 100 lines of straightforward DTO mapping, and coverage percentage cannot tell us this. Focus on business risk, failure impact, complexity, boundaries, concurrency, money and security. In our backend, that means money calculations, Inventory concurrency, rollback, the Order lifecycle, customer ownership, administrator authorization, payment idempotency and timeouts, pay vs cancel, and a provider success followed by a local failure.

Production bugs often live in failure paths, not the happy path. A weak suite proves that valid Order creation works and stops. A stronger suite asks: what if the Product is missing or inactive? What if Product B fails after Product A was consumed? What if persistence fails, two requests race, the payment times out, or the provider succeeds but local persistence fails? Those tests also keep business failures (INSUFFICIENT_INVENTORY) and system failures (a safe INTERNAL_ERROR) distinct, as 7.13 does. Coverage percentages cannot rank any of this; 10.12 treats coverage as a signal, not a target.

Whatever the layer, tests assert observable behaviour (the Order and its OrderItems persisted, "a customer cannot cancel a PAID Order") rather than internal steps such as "method X is called before method Y", so that refactoring stays safe. The stubs, fakes and mocks that use case tests need are introduced in 10.03, and 10.11 settles what may be mocked: never the domain objects, nor EntityManager, row locks, rollback or constraints, which belong to PostgreSQL.

Boot Spring Only When It Matters

The reverse rule matters too. A Domain test should not use @SpringBootTest just because the project uses Spring Boot: starting Spring adds time, configuration and complexity without proving anything about a simple invariant. Use Spring only when Spring configuration is part of what is being tested.

For focused infrastructure tests, a narrower test slice such as @DataJpaTest starts only the JPA part of the application (10.05).

Use @SpringBootTest when you need a broad application context (API and security integration, full wiring, important vertical workflows), not for every Product method, mapper or use case. One high-value vertical test might send an authenticated request through OrderHandler, CreateOrderUseCase and the real repositories into PostgreSQL, and verify 201, the Location header, the OrderResponse, the Inventory reduction and the persisted Order. That proves a meaningful slice of the system; we do not push every domain edge case through this expensive path.

Flyway migrations are production code too: integration tests that start against a fresh database exercise every migration, and the constraints (price_cents >= 0, the foreign keys, the status CHECK) get direct persistence tests even though the domain normally prevents invalid state (10.04).

Layered Confidence, Not Duplication

Customer ownership appears in application query scoping and in HTTP semantics, so it deserves a use case test and an API test for the public 404; each proves a different contract. Likewise, cancelling a PAID Order appears in two tests: the domain test proves that Order.cancel() rejects PAID (the business invariant), the API test proves 409 ORDER_NOT_CANCELLABLE (the public translation).

What we avoid is repeating every internal scenario through domain, use case, repository and API without a reason. Twenty blank-name variants do not need full HTTP and PostgreSQL tests once the validation mapping is established. Use expensive tests for boundary confidence, not exhaustive repetition.

Trustworthy Tests

Every layer shares a few rules that 10.12 develops for the whole suite: tests are named after behaviour, deterministic (an injected Clock, latches instead of Thread.sleep, no Internet), independent of execution order, and never served by production code that exists only for them. When we need OrderRepository.save() to fail after the Inventory mutation, that failure belongs in test configuration, not in a weakened domain. And the whole suite, unit and integration tests alike, runs on a clean CI checkout without a manually started PostgreSQL (module 12).

Tests Reflect the Architecture

Our architecture (Handler → use case → repository, with PaymentService as the port to the external provider) naturally gives us testing seams: the domain in plain Java, use cases with their ports replaced, repository adapters against PostgreSQL, handlers through HTTP and Spring, the provider adapter against an HTTP stub. Architecture that is hard to test often has blurred boundaries: if OrderHandler queried JPA, checked ownership, called the provider, changed Inventory and built SQL errors, everything would be intertwined. Tests also validate the architecture. A Domain test that suddenly needs a Spring context signals framework coupling; use case tests that all need PostgreSQL signal persistence leaking upward; an API test that must understand the provider's response JSON signals integration leakage. The test suite exposes architectural drift.

Planning the Test Mix

For each feature, ask which guarantees belong in its Definition of Done. Create Order crosses several boundaries, so it may need domain calculation tests, use case business-failure tests, repository tests, a rollback test, an Inventory concurrency test and API contract tests. Payment needs a different mix: use case orchestration, provider HTTP, timeout, idempotency and recovery tests, API tests, and pay vs pay and pay vs cancel concurrency tests. It has fewer Product persistence concerns but much stronger external-boundary concerns: testing strategy follows architecture.

Before adding a test, ask:

  • What guarantee am I proving, and what is the lowest layer that can prove it?
  • Does this behaviour depend on Spring, PostgreSQL, real transaction semantics, HTTP serialization, the security configuration, an external protocol or concurrency?
  • Am I mocking the thing I actually need to prove?
  • Am I booting infrastructure that adds no value?
  • Will this test remain useful after internal refactoring?

Practice Exercise 1: Sort the Suite You Have

Solutions: lesson 10.14.

Before we add tests, find out what the existing ones prove. List the test classes under src/test/java in your project as it stands after module 9. For each class, write down three things: its layer among the eight above, what it needs to run (nothing, a Spring slice, the whole application with PostgreSQL, a stub HTTP server) and one bug it would catch. Then list the layers that have no test class yet, and for each one name the first guarantee from our project it should prove.

You are done when every class has one layer and a bug you could describe to a teammate. A class for which you cannot name a bug is a finding, not a failure of the exercise.

Practice Exercise 2: The Lowest Layer That Can Prove It

For each guarantee, name the lowest layer that can prove it and what a cheaper test would miss:

  1. An OrderItem of Desk Lamp × 3 at 3490 cents has a line total of 10470.
  2. CancelOrderUseCase restores nothing while the Order has a PENDING payment attempt.
  3. Ana's Order history never contains Bob's Orders and lists her newest Order first.
  4. GET /api/v1/orders/not-a-uuid returns 400 INVALID_REQUEST.
  5. When the second Inventory restoration of a cancellation fails, PostgreSQL still holds the Order as UNPAID.
  6. The provider's answer {"status": "declined"} becomes a declined PaymentResult, not an exception.
  7. A customer's token gets 403 on PUT /api/v1/inventory/{productId}.
  8. Two simultaneous cancellations of one Order restore its Inventory once.

Practice Exercise 3: Review a Test Plan

A teammate proposes this plan for the Order features:

  • Every scenario, the six lifecycle transitions included, goes through HTTP requests to the full application, "so we test what the customer sees".
  • Use case tests use mock(Order.class), so they need no real OrderItems.
  • The Create Order rollback is covered by a use case test in which consumeIfAvailable(B, 5) returns false and save(...) is never called.
  • Integration tests run against H2, because it starts faster than Docker.
  • The build fails below 90% line coverage.

Name the problem with each point and what you would do instead.

Knowledge Check

Question 1

OrderLifecycleTest and an API test both cover cancelling a PAID Order. Why is that not duplication?

Question 2

A use case test with a stubbed InventoryRepository passes. Why does it say nothing about whether the last unit in stock can be sold?

Question 3

Why does the strategy start from guarantees rather than from tools such as Mockito or Testcontainers?

Question 4

Why do failure paths, such as Product B failing after Product A was consumed, deserve more tests than the happy path?

Question 5

When is it right to replace PaymentService with a double, and when would replacing ProviderPaymentClient defeat the test?

Knowledge Check Answers

Answer 1

Each proves a different contract. The domain test proves the business rule in Order.cancel(); the API test proves its public translation into 409 ORDER_NOT_CANCELLABLE as Problem Details.

Answer 2

The stub answers true or false because we told it to. Whether the real UPDATE ... WHERE available_quantity >= :quantity accepts the exact remaining quantity is PostgreSQL behaviour, which only a repository test against PostgreSQL proves.

Answer 3

The guarantee decides what has to be real. Starting from a tool leads to mocking what we needed to prove or booting infrastructure that proves nothing.

Answer 4

Most production damage comes from partial work: consumed stock without an Order, a charge without PAID. The happy path usually works because everyone tries it; failure paths are where atomicity, ordering and error mapping break.

Answer 5

A PayOrderUseCase test is about orchestration, so a PaymentService double is right. A test of the provider adapter is about the HTTP request, timeouts and response mapping, so replacing ProviderPaymentClient would skip exactly the code under test.

Engineering Principle

Every test should have a clear job. Use the cheapest test capable of proving the guarantee, but use the real infrastructure when the guarantee itself depends on real infrastructure.

Domain, application workflow, persistence, HTTP, security, external services and concurrency fail differently, so they deserve different kinds of tests.

Summary

  • Testing has been part of every feature's Definition of Done; Module 10 deepens the strategy rather than introducing testing.
  • A strategy starts from system guarantees, not frameworks, and tests each behaviour at the lowest layer capable of proving it.
  • Domain and use case tests are fast and need no infrastructure; test doubles prove interaction with an abstraction, never the real implementation behind it.
  • Repository, transaction and concurrency guarantees need real PostgreSQL, started by Testcontainers rather than a manual Docker Compose, and rollback tests reload committed state.
  • API and security integration tests prove the public contract: status codes, Problem Details, 401/403/404 and what must not leak; provider tests use the real HTTP client against a stub server.
  • Neither all end-to-end nor all mocks: the distribution follows risk, failure paths and architecture, not a pyramid percentage or coverage target.
  • Good tests are deterministic, independent, named after behaviour and assert observable state; production invariants are never weakened for testability.

In the next lesson, Testing Domain Logic, we start with the smallest layer: plain Java tests for Product, Inventory, OrderItem, Order, the identity values, Order totals and the Order lifecycle, without Spring, repositories, mocks or database infrastructure.

Think first

Coming soon

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