Backend Testing in Practice

Building a Testing Strategy

ReadingPreview

আপনি একটি free preview lesson দেখছেন।

Building a Testing Strategy

Throughout this course, we have already written and discussed tests.

We tested:

Product invariants

Inventory behavior

Order lifecycle

UseCase orchestration

Repository mappings

transaction rollback

HTTP contracts

authentication

authorization

external Payment behavior

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?

That distinction matters.

A large test suite is not automatically a good test suite.

A useful test suite should tell us:

what broke

where it broke

which guarantee failed

whether the failure represents
business logic,
persistence,
integration,
or API behavior

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 development pattern is:

build feature
    ↓
ship feature
    ↓
later add tests

or:

finish whole project
    ↓
create "testing ticket"

That is not how we are designing this backend.

Our backlog already established:

Testing belongs inside each feature's Definition of Done.

When we implemented:

Create Order

we also considered tests for:

duplicate Product IDs

inactive Products

price capture

Inventory failure

transaction rollback

When we implemented:

Order Cancellation

we considered:

ownership

lifecycle

Inventory restoration

concurrent cancellation

When we designed:

Payment

we considered:

provider success

provider failure

timeout

duplicate payment execution

Pay vs Cancel

restart recovery

Testing is not a cleanup phase.

It is part of designing behavior.


What Is a Testing Strategy?

A testing strategy answers questions such as:

What behaviors are important?

Which risks can break production?

At what layer should each behavior be tested?

Which dependencies should be real?

Which dependencies should be replaced?

Which tests need Spring?

Which tests need PostgreSQL?

Which tests need HTTP?

Which tests should remain plain Java?

What should CI run?

What should a failing test tell us?

The strategy is not:

write as many tests as possible

Nor is it:

achieve 100% line coverage

The goal is:

confidence

with:

reasonable execution cost
+
clear failure diagnosis
+
maintainable test code

Start With Guarantees, Not Frameworks

Bad starting point:

Should we use Mockito?

Should we use @SpringBootTest?

Should we use Testcontainers?

Better starting point:

What guarantee
are we trying to prove?

Then choose the smallest test capable of proving it.

For example:

Order cannot move
PAID → CANCELLED

requires:

plain Domain test

It does not require:

Spring Boot

PostgreSQL

HTTP

Docker

But:

Inventory changes roll back
when later Order persistence fails

cannot be proven by a plain unit test.

That requires:

real transaction manager

real Repository adapters

real PostgreSQL

Different guarantee.

Different test.


Our Testing Layers

For this project, the useful layers are:

Domain tests

UseCase tests

Repository integration tests

Transaction integration tests

API integration tests

Security integration tests

External-service integration tests

Concurrency tests

Each exists because it proves something different.


Layer 1 — Domain Tests

Domain tests verify:

business invariants

state transitions

calculations

value-object rules

Examples:

Product price cannot be negative
Order total is calculated correctly
UNPAID → PAID is allowed
PAID → CANCELLED is rejected
Order cannot contain
duplicate Product lines

These tests need:

no Spring
no database
no HTTP
no mocks

That is a major advantage of keeping our Domain framework-light.


Example Domain Test

class OrderTest {

    @Test
    void unpaidOrderCanBeMarkedPaid() {
        Order order =
                createUnpaidOrder();

        order.markPaid();

        assertEquals(
                OrderStatus.PAID,
                order.status()
        );
    }
}

This test proves:

Domain transition rule

It does not prove:

Order persistence

HTTP response

database transaction

authorization

Nor should it.


Domain Tests Should Be Fast

A good Domain test usually runs in:

milliseconds

and requires almost no setup.

That means we can have many of them.

If testing:

order.cancel();

requires:

Spring context

Docker

PostgreSQL

security configuration

we have probably coupled our Domain too tightly to infrastructure.


Layer 2 — UseCase Tests

UseCase tests verify:

application workflow

orchestration

validation ordering

which boundaries are called

which boundaries are not called

which business error is selected

For example:

CreateOrderUseCase

should:

reject duplicate Product IDs
before Inventory mutation

or:

use server Product price
instead of client price

or:

not save Order
after known failure

These behaviors do not require Hibernate.


UseCase Tests Use Application Boundaries

Our architecture helps here.

A UseCase depends on abstractions such as:

ProductRepository

InventoryRepository

OrderRepository

PaymentService

So a test can provide:

fake

stub

mock

implementations depending on what is useful.

The test remains focused on:

application behavior

rather than:

JPA mechanics

Example UseCase Test

Imagine:

Order contains Product A

but Product A is inactive.

We want to prove:

CreateOrderUseCase
→ PRODUCT_NOT_ORDERABLE

and:

Inventory consumption
was never attempted

A fake/stub ProductRepository plus a recording InventoryRepository can prove that quickly.

No real database required.


What a UseCase Test Cannot Prove

A fake Repository cannot prove:

SQL is correct

JPA mapping works

FOREIGN KEY works

PostgreSQL UUID works

row locking works

transaction rollback works

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.


Layer 3 — Repository Integration Tests

Repository tests verify the boundary between:

application/domain

and:

PostgreSQL

They should exercise:

JPA mapping

SQL queries

database constraints

ordering

pagination

locking behavior where relevant

against the actual database technology we use.

For this project:

PostgreSQL

Example Product Repository Tests

Useful tests include:

save Product

reload Product

update Product

active state persists

priceCents persists

Product browse filters correctly

ordering is name ASC, id ASC

These are persistence guarantees.


Example Inventory Repository Tests

Useful tests:

quantity persists

quantity 0 is valid

negative quantity rejected

Product FK prevents orphan Inventory

conditional consume succeeds
when enough quantity exists

conditional consume affects
exactly one row

consume fails when quantity
is insufficient

restore affects exactly one row

These tests need real PostgreSQL behavior.


Example Order Repository Tests

Test:

persist Order

persist multiple OrderItems

reload Order

UUIDs round-trip

CustomerId preserved

createdAt preserved

historical unitPriceCents preserved

Order status preserved

history filtered by CustomerId

history ordered
createdAt DESC, id DESC

Mocks cannot prove those things.


Why PostgreSQL Instead of H2?

Our production database is:

PostgreSQL

Relational databases differ in:

data types

constraint behavior

locking

transaction semantics

SQL syntax

query behavior

For example, our schema uses:

UUID

TIMESTAMPTZ

BIGINT

and concurrency behavior matters.

Using a different database for integration tests can create:

tests pass

while:

production fails

So persistence integration should use:

real PostgreSQL

Testcontainers

Our automated tests should not depend on:

developer manually running
docker compose up

That would make tests:

environment-dependent

fragile in CI

easy to forget

Instead, integration tests own their infrastructure through:

Testcontainers

Conceptually:

JUnit
    ↓
Testcontainers
    ↓
PostgreSQL container

The test controls:

startup

configuration

lifecycle

Docker Compose vs Testcontainers

Our local development workflow remains:

docker compose up -d

for interactive application development.

Testing uses:

Testcontainers

for automated infrastructure.

Different purpose:

Docker Compose
→ developer environment
Testcontainers
→ automated integration test environment

Do not make:

./gradlew test

depend on a developer remembering to start Compose.


Layer 4 — Transaction Integration Tests

Transaction behavior deserves a distinct category because it is easy to believe we have atomicity when we have only tested mocks.

Suppose:

Product A Inventory = 10

Product B Inventory = 1

Request:

A × 2

B × 5

Inside CreateOrderUseCase:

consume A
→ succeeds

consume B
→ fails

Application throws:

INSUFFICIENT_INVENTORY

What must be true afterward?

A Inventory = 10

B Inventory = 1

no Order

no OrderItems

That is a transaction guarantee.


Mockito Cannot Prove Rollback

A unit test can prove:

InventoryRepository.consumeIfAvailable(...)
was called

or:

UseCase threw
InsufficientInventoryException

It cannot prove:

PostgreSQL rolled back
the earlier successful UPDATE

Only a test with:

real transaction manager

real Repository adapter

real PostgreSQL

can prove that.


Reload State After Rollback

A rollback test should not merely inspect:

in-memory objects

after failure.

What matters is:

committed database state

So after the failed UseCase:

query again
through a fresh persistence boundary

and assert the persisted state.


The Most Valuable Rollback Test

For our project, one of the strongest transaction tests is:

Inventory A = 10

Inventory B = 1

Request:

A × 2

B × 5

Execute real:

CreateOrderUseCase

Expected:

INSUFFICIENT_INVENTORY

Then reload:

A = 10

B = 1

orders unchanged

order_items unchanged

This tells us:

The transaction boundary actually works.


Persistence Failure Test

Another valuable case:

Inventory mutation succeeds

Order persistence fails

Expected:

Inventory mutation rolls back

The failure should be introduced through controlled test infrastructure.

Do not modify production Domain rules merely to manufacture a test scenario.


Layer 5 — API Integration Tests

API tests verify:

HTTP route

serialization

validation

authentication

status code

response shape

Problem Details

They test what a client actually observes.

For example:

POST /api/v1/orders

with insufficient Inventory should result in:

409

application/problem+json

code = INSUFFICIENT_INVENTORY

That behavior cannot be fully proven by a UseCase test.


API Tests Should Verify Contracts

Examples:

malformed UUID
→ 400 INVALID_REQUEST
missing authentication
→ 401 AUTHENTICATION_REQUIRED
non-owned Order
→ 404 ORDER_NOT_FOUND
cancel PAID Order
→ 409 ORDER_NOT_CANCELLABLE
pay CANCELLED Order
→ 409 ORDER_NOT_PAYABLE

These are public contracts.


API Tests Should Also Verify What Is Not Leaked

For an unexpected database or provider failure, verify that the response does not contain:

SQL

stack trace

database host

provider credentials

Java exception class

The client should receive:

INTERNAL_ERROR

or another deliberate application problem.


Layer 6 — Security Integration Tests

Security rules are boundary behavior.

Important tests include:

unauthenticated Customer endpoint
→ 401
Customer A reads Customer B's Order
→ 404
Customer A cancels Customer B's Order
→ 404
Customer A pays Customer B's Order
→ 404
regular Customer calls admin Product endpoint
→ 403
Administrator calls admin Product endpoint
→ allowed

These need the real Spring Security configuration participating.


Why Security Should Not Be Tested Only With UseCase Doubles

The UseCase can test:

CustomerId-scoped ownership

But that does not prove:

Spring Security blocks unauthenticated requests

or:

ADMIN authority mapping works

Those require security-aware integration tests.

Again:

different guarantee
→ different test layer

Layer 7 — External Service Integration Tests

Payment introduced another test boundary.

We need to verify:

provider HTTP request mapping

provider response mapping

timeout behavior

technical failures

confirmed business failure

provider configuration

without calling:

real production provider

in automated tests.


Controlled HTTP Stub

Conceptually:

ProviderPaymentService
    ↓
real HTTP client
    ↓
controlled stub server

This allows us to test:

serialization

headers

endpoint construction

timeouts

response interpretation

while keeping tests:

deterministic

isolated

safe

What Not to Mock in Provider Tests

If the purpose is to prove:

HTTP request is correct

then mocking:

ProviderPaymentClient

would bypass the very behavior under test.

Instead use:

real ProviderPaymentClient
+
stub HTTP server

But when testing:

PayOrderUseCase

mocking or stubbing:

PaymentService

is entirely reasonable because the test concerns orchestration rather than HTTP.

Same dependency.

Different testing layer.


Layer 8 — Concurrency Tests

Some guarantees exist only under competing transactions.

Examples:

two Orders consume
limited Inventory concurrently
two cancellation requests
race
Pay vs Pay
Pay vs Cancel

These cannot be meaningfully proven with:

one thread
+
mock Repository

Real Concurrency Requires Real Transactions

A concurrency test should use:

real PostgreSQL

separate threads

separate transactions

because the behavior depends on:

row locks

conditional UPDATE

transaction visibility

commit ordering

Mocking those mechanics mostly tests our imagination of how PostgreSQL works.


Example Inventory Concurrency Test

Initial:

Inventory = 5

Two concurrent Order operations request:

4 units

4 units

Expected:

one succeeds

one fails

final Inventory = 1

Not:

both succeed

Inventory = -3

This proves the conditional SQL strategy under real competition.


Example Cancellation Concurrency Test

Two concurrent cancellation attempts target:

same UNPAID Order

Expected:

one cancellation succeeds

Inventory restored once

The other sees the new lifecycle state and fails appropriately.


Example Pay vs Cancel Test

If payment start wins:

unresolved Payment attempt exists

cancellation cannot restore Inventory

If cancellation wins:

Order = CANCELLED

Inventory restored

Payment Provider not invoked

This is a real concurrency guarantee.


The Core Rule

The most important strategy rule is:

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

Examples:

Order transition
→ Domain test
UseCase validation order
→ UseCase test
JPA mapping
→ Repository integration test
PostgreSQL rollback
→ Transaction integration test
401/403/404 contract
→ API/Security integration test
HTTP provider mapping
→ External-service integration test
row-lock race
→ Concurrency integration test

Why Not Test Everything End-to-End?

It might seem safer to write every test as:

HTTP
→ Spring
→ UseCase
→ JPA
→ PostgreSQL

But that creates problems.

Such tests are:

slower

harder to diagnose

more expensive to set up

more brittle

If a Domain invariant fails, we want:

OrderTest

to fail directly.

We should not need:

HTTP server
+
security
+
PostgreSQL

to discover a broken state transition.


Why Not Test Everything With Unit Tests?

The opposite extreme is also dangerous.

A suite made entirely from mocks may report:

all green

while production contains:

broken Flyway migration

wrong SQL

incorrect UUID mapping

bad transaction boundary

missing constraint

wrong lock behavior

Unit tests prove logic.

They do not prove infrastructure.


A Balanced Strategy

Our project should look conceptually like:

many fast Domain tests

many focused UseCase tests

targeted Repository integration tests

targeted Transaction tests

important API integration tests

important Security integration tests

focused external-service tests

small number of expensive concurrency tests

Not every behavior needs every layer.


Do We Need a Test Pyramid?

You may have seen:

many unit tests

fewer integration tests

few end-to-end tests

as a testing pyramid.

That can be useful as a heuristic.

But do not turn it into a numerical rule such as:

70% unit

20% integration

10% end-to-end

Our actual distribution should follow:

system architecture

risk

cost

behavior

For this backend, persistence and transactions matter significantly.

So real PostgreSQL integration testing deserves meaningful coverage.


Test Risk, Not Lines

Suppose a class contains:

100 lines

of straightforward DTO mapping.

Another contains:

20 lines

of conditional Inventory update logic.

The second may be far more important to test deeply.

Coverage percentage alone cannot tell us this.

Focus on:

business risk

failure impact

complexity

boundary behavior

concurrency

money

security

High-Risk Areas in Our Backend

Particularly valuable coverage exists around:

money calculations

Inventory concurrency

transaction rollback

Order lifecycle

Customer ownership

Administrator authorization

Payment idempotency

Payment timeout

Pay vs Cancel

provider success + local failure

These deserve stronger testing than trivial getters.


Testing Failure Paths

Production bugs often live in:

failure paths

not the happy path.

A weak test suite may prove:

valid Order creation works

and stop.

A stronger suite asks:

What if Product is missing?

What if Product is inactive?

What if Inventory is insufficient?

What if Product B fails
after Product A was consumed?

What if persistence fails?

What if two requests race?

What if Payment times out?

What if provider succeeds
but local persistence fails?

Failure-path tests are core backend engineering.


Test Business Failures Separately From System Failures

We made this distinction in Module 7.

Tests should preserve it.

Business failure:

INSUFFICIENT_INVENTORY

System failure:

database unavailable

Do not design a test suite that makes them look equivalent.

At API level:

business conflict
→ stable application response

while:

unexpected infrastructure failure
→ safe INTERNAL_ERROR

Test State, Not Only Calls

Mock-heavy tests often look like:

verify(repository)
        .save(order);

That can be useful in some orchestration tests.

But real integration tests should assert:

actual resulting state

For example:

Order persisted

Inventory quantity correct

OrderItems correct

not just:

save() invoked once

Production cares about state, not mock invocation count.


Test Observable Behavior

Prefer tests that say:

Customer cannot cancel
a PAID Order

over tests tightly coupled to internal implementation like:

method X called before method Y

unless that ordering itself is a correctness requirement.

Tests should allow safe refactoring.


Avoid Testing Private Methods Directly

If a private method implements part of:

public behavior

test that behavior through the appropriate boundary.

If a private method becomes so complex that it seems to require its own test suite, that may indicate:

missing Domain concept

missing collaborator

too much responsibility

Do not solve architecture issues with reflection-based private-method tests.


Fakes, Stubs, and Mocks

These words are often used loosely.

A useful practical distinction:

Stub

Returns predefined data.

Example:

ProductRepository
→ returns active Product

Useful when the test needs:

controlled input from a dependency

Fake

A lightweight working implementation.

Example:

InMemoryProductRepository

Useful for simple workflow tests.

Mock

Records interactions and supports verification.

Example:

verify PaymentService
was never called

Useful when interaction itself matters.

Choose based on what the test needs.


Do Not Mock Everything

If a UseCase has:

three simple dependencies

a test with:

30 Mockito stubs
+
20 verify statements

may be harder to understand than the production code.

Sometimes a small fake is clearer.

Testing style should optimize:

clarity

not maximum mocking.


Do Not Mock Domain Objects

Avoid:

mock(Order.class)

to test:

Order lifecycle

Use the real Domain object.

Domain objects are:

fast

deterministic

framework-light

There is little benefit in replacing them.


Do Not Mock Hibernate

Likewise, avoid pretending to test persistence by mocking:

EntityManager

Hibernate Session

JpaRepository

line by line.

That proves only:

we called Hibernate API
the way our mock expected

It does not prove:

mapping works in PostgreSQL

Use real PostgreSQL integration tests instead.


Do Not Mock PostgreSQL Semantics

Do not simulate:

SELECT FOR UPDATE

conditional UPDATE

rollback

FOREIGN KEY

unique constraint

with Java mocks and call that persistence confidence.

Those behaviors belong to PostgreSQL.

Test PostgreSQL.


Do Not Boot Spring Unnecessarily

The reverse rule also matters.

A Domain test should not use:

@SpringBootTest

just because the project uses Spring Boot.

Starting Spring adds:

time

configuration

test complexity

without proving anything relevant to a simple invariant.

Use Spring only when:

Spring configuration

is part of what needs to be tested.


Test Slices

For focused infrastructure testing, narrower Spring contexts can be useful.

For example:

@DataJpaTest

can provide JPA-focused testing.

But our database should still be:

PostgreSQL Testcontainer

rather than silently replacing it with an unrelated embedded database.

The exact annotation is less important than:

what real behavior
the test proves

Full Spring Boot Tests

Use:

@SpringBootTest

when you need a broad application context for something such as:

API integration

security integration

full wiring

important vertical workflows

Do not use it for:

every Product method

every mapper

every UseCase

A Useful Vertical Test

One high-value integration test might exercise:

authenticated HTTP request
    ↓
OrderHandler
    ↓
CreateOrderUseCase
    ↓
real Repositories
    ↓
PostgreSQL

and verify:

201

Location header

OrderResponse

Inventory reduction

persisted Order state

This proves a meaningful slice of the system.

But we do not need to reproduce every Domain edge case through this expensive path.


Testing Migrations

Flyway migrations are production code.

If the application starts with a fresh PostgreSQL container and Flyway cannot construct the schema:

the build should fail

Integration tests naturally exercise migration validity when they start against:

fresh PostgreSQL

This gives us confidence that:

Java expectations

match:

database schema

Testing Database Constraints

Some constraints should be tested directly at persistence level.

For example:

price_cents >= 0
available_quantity >= 0
quantity > 0
Order status CHECK
foreign keys

Domain normally prevents invalid state.

But database constraints exist as defense.

A persistence integration test can verify that defense independently.


Do Not Force Every Constraint Through the UseCase

Suppose Domain makes:

negative unitPriceCents

impossible.

We do not need to corrupt:

CreateOrderUseCase

just to make it produce invalid data for a test.

Instead test the constraint at:

persistence/schema boundary

where that risk belongs.


Test Ownership at More Than One Relevant Boundary

Customer ownership appears in:

application query scoping

HTTP security semantics

So it deserves:

UseCase test

for application behavior and:

API integration test

for public 404 behavior.

This is not redundant because each proves a different contract.


Duplication vs Layered Confidence

Two tests may cover the same scenario but prove different things.

Example:

Order = PAID
Customer calls cancel

Domain test proves:

Order.cancel()
rejects PAID

API test proves:

HTTP 409
ORDER_NOT_CANCELLABLE

Both are valuable.

One tests:

business invariant

The other tests:

public translation

Avoid Exact Duplicate Tests Across Layers

What we should avoid is repeating every internal scenario through:

Domain

UseCase

Repository

API

without a reason.

For example, twenty different blank-name variants do not need full HTTP/PostgreSQL tests if the relevant validation mapping is already established.

Use expensive tests for:

boundary confidence

not exhaustive repetition.


Test Naming

A test name should communicate behavior.

Good:

rejectsPaymentForCancelledOrder
rollsBackConsumedInventoryWhenLaterItemFails
returnsNotFoundForAnotherCustomersOrder

Bad:

test1
executeWorks
payTest

The test suite becomes living documentation.


Arrange — Act — Assert

A simple structure remains useful:

Arrange
→ establish state

Act
→ perform behavior

Assert
→ verify outcome

For example:

Arrange:
Order UNPAID

Act:
order.cancel()

Assert:
Order CANCELLED

Do not turn this into ceremony with comments everywhere.

The goal is readable test flow.


Prefer Deterministic Tests

Tests should avoid relying on:

current wall clock

random sleep

network Internet

external production service

unordered collections

when deterministic alternatives exist.

We already use:

Clock

for application-generated timestamps.

That makes time-sensitive behavior testable deterministically.


Avoid Thread.sleep() for Concurrency Coordination

Concurrency tests that rely on:

Thread.sleep(500);

can become flaky.

Prefer explicit synchronization constructs such as:

barriers

latches

controlled transaction points

so the race being tested is intentional and reproducible.


Flaky Tests Are Production Problems

A flaky test teaches developers:

red build
might not matter

That destroys trust in CI.

When a test flakes:

investigate it

Do not normalize:

rerun until green

as the standard workflow.


Tests Should Be Independent

One test should not depend on:

another test running first

Each test should establish its own state.

Especially with database tests:

test ordering

must not become part of hidden correctness.


Database Cleanup

Integration tests need reliable isolation.

Possible strategies include:

transaction rollback

clean schema

truncate/reset between tests

fresh container where justified

The exact strategy can vary.

The requirement is:

one test's state
must not make another pass or fail

Test Data Builders

As Domain objects become richer, repeated setup can make tests noisy.

A small test builder can help.

Conceptually:

Order order =
        anUnpaidOrder()
                .withCustomer(
                        customerId
                )
                .build();

But avoid creating:

giant generic test framework

that hides meaningful state.

A reader should still understand:

what makes this scenario important

Production Code Should Not Exist Only for Tests

Avoid changing production APIs solely to expose internals for testing.

Examples:

public setter
only so test can mutate state

special production endpoint
only for tests

disable constraint
to force a scenario

Good architecture usually improves testability naturally.

Do not weaken invariants to make testing easier.


Testing Private Infrastructure Failure

Sometimes we need to force:

OrderRepository.save()

to fail after Inventory mutation.

That failure mechanism belongs in:

test configuration

controlled test double

test-specific database condition

not production business behavior.


CI Expectations

Eventually our CI should be able to run:

clean checkout

build

unit tests

integration tests

without:

manual local PostgreSQL

developer-specific state

private machine configuration

Testcontainers helps provide that reproducibility.

Module 12 will connect this into the final CI pipeline.


Fast Feedback vs Deep Confidence

We want both.

Fast feedback:

Domain tests

UseCase tests

Deep confidence:

PostgreSQL integration

API integration

external-service integration

concurrency tests

The suite should allow engineers to detect simple failures quickly while still protecting infrastructure-dependent behavior before merge.


A Practical Testing Matrix

For our Order Management Backend:

ConcernBest primary test
Product invariantDomain test
Order lifecycleDomain test
totalCents calculationDomain test
UseCase validation orderingUseCase test
ownership orchestrationUseCase test
JPA mappingRepository integration test
Flyway schemaPostgreSQL integration test
FK/CHECK constraintsPostgreSQL integration test
rollbackTransaction integration test
conditional Inventory consumePostgreSQL integration test
HTTP status / Problem DetailsAPI integration test
authenticationSecurity/API integration test
authorizationSecurity/API integration test
provider HTTP mappingExternal-service integration test
provider timeoutExternal-service integration test
Pay vs PayConcurrency integration test
Pay vs CancelConcurrency integration test

This is more useful than saying:

everything needs a unit test

or:

everything needs an end-to-end test

Testing Our Existing Architecture

Our architecture:

Handler
    ↓
UseCase
    ↓
Repository

with:

Service

reserved for external integrations naturally gives us useful testing seams.

Domain

Test directly.

UseCase

Replace Repository/Service boundaries.

Repository adapter

Test with PostgreSQL.

Handler/API

Test through HTTP/Spring.

External Service adapter

Test against controlled HTTP provider stub.

This is one practical benefit of clear responsibilities.


Architecture That Is Difficult to Test Often Has Blurred Boundaries

Suppose:

OrderHandler

directly:

queries JPA

checks ownership

calls provider

changes Inventory

builds SQL errors

Testing becomes difficult because everything is intertwined.

Our architecture instead separates:

transport

workflow

domain

persistence

external integration

Testing becomes a reflection of those boundaries.


Tests Also Validate Architecture

If a Domain test suddenly requires:

Spring context

that may signal Domain/framework coupling.

If every UseCase test requires:

PostgreSQL

that may signal persistence concerns leaking upward.

If an API test has to understand:

provider response JSON

that may signal integration leakage.

The test suite can expose architectural drift.


Coverage Metrics

Coverage tools can be useful for detecting:

large untested areas

But do not make:

100% coverage

the primary engineering objective.

You can achieve high coverage with tests that assert almost nothing.

And low-value getters can inflate numbers.

Ask:

Are important guarantees protected?

before:

What is the percentage?

Mutation Testing?

Techniques such as mutation testing can evaluate whether tests actually detect changed behavior.

That can be useful in mature codebases.

It is not required for our current course scope.

We first need:

clear test boundaries

meaningful scenarios

reliable integration tests

before adding more sophisticated test tooling.


What Makes a Good Backend Test Suite?

A strong suite should be:

reliable

deterministic

readable

fast enough for regular use

realistic where infrastructure matters

focused where infrastructure does not matter

clear about failure cause

It should protect:

business behavior

persistent state

transactions

security

API contracts

external boundaries

concurrency

without testing the same thing expensively everywhere.


What Makes a Bad Backend Test Suite?

Warning signs include:

every test uses @SpringBootTest

every dependency is mocked

no real PostgreSQL tests

H2 used despite PostgreSQL-specific behavior

transaction rollback tested only with Mockito

security only tested through method mocks

provider integration calls production

concurrency "tested" sequentially

tests depend on execution order

Thread.sleep used everywhere

large number of verify(...) calls
with little state assertion

test failures frequently ignored as flaky

These create apparent coverage without reliable confidence.


Definition of Done for Testing

For each feature, ask which guarantees belong in its Definition of Done.

Example:

Create Order

may require:

Domain calculation tests

UseCase business failure tests

Repository persistence tests

transaction rollback test

Inventory concurrency test

API contract tests

Not because every feature needs every test layer, but because this particular workflow crosses several important boundaries.


Payment Requires a Different Test Mix

Payment requires:

UseCase orchestration tests

external-provider HTTP tests

timeout tests

idempotency tests

recovery tests

API tests

Pay-vs-Pay concurrency tests

Pay-vs-Cancel concurrency tests

It has fewer meaningful Product persistence concerns but much stronger external-boundary concerns.

Testing strategy follows architecture.


Testing Strategy Review Checklist

Before adding a test, ask:

What guarantee am I proving?

What is the lowest layer
that can prove it?

Does this behavior depend
on Spring?

Does it depend on PostgreSQL?

Does it depend on real transaction semantics?

Does it depend on HTTP serialization?

Does it depend on Security configuration?

Does it depend on an external protocol?

Does it depend on 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?

Common Mistakes

Avoid:

"testing later"

Avoid:

100% line coverage
as the strategy

Avoid:

@SpringBootTest
for every test

Avoid:

mock everything

Avoid:

real database
for simple Domain rules

Avoid:

mock database
for transaction guarantees

Avoid:

H2
to prove PostgreSQL locking behavior

Avoid:

manual Docker Compose dependency
for automated tests

Avoid:

mocking EntityManager
to prove persistence

Avoid:

testing concurrency
with sequential method calls

Avoid:

calling production external services
from automated tests

Avoid:

forcing production code changes
only to create test hooks

Avoid:

asserting implementation details
instead of observable behavior

Engineering Principle

The core principle is:

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.

Another:

Mocks can prove orchestration against an abstraction. They cannot prove PostgreSQL, Spring Security, HTTP serialization, transaction rollback, locking, or an external protocol.

And:

Testing strategy should follow system boundaries. Domain, application workflow, persistence, HTTP, security, external services, and concurrency fail differently, so they deserve different kinds of tests.


Summary

In this lesson, we established that:

  • Testing has been part of feature implementation throughout the course.
  • Module 10 deepens testing strategy rather than introducing testing for the first time.
  • A testing strategy begins with system guarantees, not frameworks.
  • Each behavior should be tested at the lowest layer capable of proving it.
  • Domain tests verify business invariants and calculations.
  • Domain tests require no Spring, PostgreSQL, or HTTP.
  • UseCase tests verify application orchestration and business flow.
  • UseCase tests may use fakes, stubs, or mocks at Repository/Service boundaries.
  • UseCase mocks cannot prove persistence behavior.
  • Repository integration tests verify real JPA/PostgreSQL behavior.
  • PostgreSQL Testcontainers is our default persistence integration direction.
  • H2 is not our substitute for PostgreSQL-specific confidence.
  • Automated tests should not depend on manually started Docker Compose.
  • Transaction tests require a real transaction manager and PostgreSQL.
  • Mockito cannot prove rollback.
  • Rollback tests should reload committed database state after failure.
  • API integration tests verify HTTP status, DTOs, validation, and Problem Details.
  • Security integration tests verify authentication, authorization, and ownership behavior.
  • External Service integration tests should use real HTTP clients against controlled stub servers.
  • We mock PaymentService in UseCase tests but do not mock away the HTTP client in provider integration tests.
  • Concurrency tests require real competing transactions.
  • Inventory races, duplicate cancellation, Pay vs Pay, and Pay vs Cancel deserve concurrency tests.
  • We should not test everything end-to-end.
  • We should not test everything with mocks either.
  • The suite should contain many fast focused tests and targeted infrastructure-heavy tests.
  • Test distribution should follow risk rather than arbitrary percentages.
  • High-risk areas deserve deeper coverage.
  • Failure-path tests are as important as happy-path tests.
  • Business failures and system failures should remain distinguishable in tests.
  • Tests should prefer observable state and behavior over unnecessary implementation verification.
  • Private methods should generally be tested through public behavior.
  • Domain objects usually should not be mocked.
  • Hibernate/PostgreSQL behavior should not be mocked as a substitute for integration testing.
  • Spring should not be booted when it is irrelevant.
  • Full Spring Boot tests are appropriate for meaningful API/security/wiring scenarios.
  • Flyway migrations should execute against real PostgreSQL in integration testing.
  • Database constraints should be tested at the persistence boundary.
  • Layered tests can overlap scenarios when they prove different guarantees.
  • Tests should be deterministic and independent.
  • Concurrency tests should avoid timing based on arbitrary Thread.sleep.
  • Flaky tests should be treated as defects.
  • Production invariants should not be weakened merely for testability.
  • CI should be able to run tests from a clean environment.
  • Architecture and testing strategy reinforce one another.
  • Coverage percentage is a supporting metric, not the goal.

Next lesson:

Testing Domain Logic

We will focus specifically on plain Java tests for:

Product

Inventory

Order

OrderItem

ProductId / OrderId / CustomerId

Order totals

Order lifecycle

and establish how to test invariants thoroughly without Spring, repositories, mocks, or database infrastructure.