Backend Testing in Practice
Building a Testing Strategy
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:
| Concern | Best primary test |
|---|---|
| Product invariant | Domain test |
| Order lifecycle | Domain test |
| totalCents calculation | Domain test |
| UseCase validation ordering | UseCase test |
| ownership orchestration | UseCase test |
| JPA mapping | Repository integration test |
| Flyway schema | PostgreSQL integration test |
| FK/CHECK constraints | PostgreSQL integration test |
| rollback | Transaction integration test |
| conditional Inventory consume | PostgreSQL integration test |
| HTTP status / Problem Details | API integration test |
| authentication | Security/API integration test |
| authorization | Security/API integration test |
| provider HTTP mapping | External-service integration test |
| provider timeout | External-service integration test |
| Pay vs Pay | Concurrency integration test |
| Pay vs Cancel | Concurrency 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
PaymentServicein 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.
