Eine hexagonale App testen
Der eigentliche Gewinn: Domäne und Use Cases im Speicher mit gefälschten Adaptern testen, ohne Spring-Kontext oder Datenbank - schnelle, fokussierte Tests, passend zur Testpyramide.
Deutsche Übersetzung in Arbeit
Diese Lektion ist noch nicht ins Deutsche übersetzt und wird daher auf Englisch angezeigt. Der Rest der Seite ist vollständig lokalisiert.
Auf dieser Seite
Here's where all the ceremony pays off. Because the domain and use cases depend only on ports - not on Spring,
JPA, or HTTP - you can test them in plain memory, with fake adapters, at unit-test speed. No database to spin
up, no @SpringBootTest context to load, no mocking framework gymnastics. This testability is the single most
compelling reason to adopt the architecture, and it aligns beautifully with the testing pyramid.
Testing the core with fakes
A use case depends on ports. To test it, supply fake implementations of those ports - usually simple in-memory ones - and exercise the real business logic:
class TransferMoneyUseCaseTest {
@Test
void transfers_money_between_accounts() {
// in-memory fake adapters - no DB, no Spring, no mocks
var accounts = new InMemoryAccountRepository();
accounts.save(new Account(AccountId.of("A"), Money.usd(100)));
accounts.save(new Account(AccountId.of("B"), Money.usd(0)));
var events = new RecordingPublisher();
var useCase = new TransferMoneyUseCase(accounts, events); // wire the real use case
var result = useCase.transfer(AccountId.of("A"), AccountId.of("B"), Money.usd(30));
assertThat(result.isSuccess()).isTrue();
assertThat(accounts.findById(AccountId.of("A")).get().balance()).isEqualTo(Money.usd(70));
assertThat(accounts.findById(AccountId.of("B")).get().balance()).isEqualTo(Money.usd(30));
assertThat(events.published()).contains(new MoneyTransferred("A", "B", Money.usd(30)));
}
}This test runs in milliseconds, tests real business logic (the use case and the Account entity's
invariants), and reads like a specification of the behavior. There's no database because the use case never knew
about one.
Fakes over mocks
Notice these are fakes (working in-memory implementations), not mocks (objects programmed to expect specific calls). Fakes are usually the better choice for ports:
- An
InMemoryAccountRepositorybacked by aHashMapbehaves like a repository -savethenfindByIdactually round-trips - so tests exercise realistic behavior, not a script of expected calls. - Mocks couple the test to how the use case calls the port (
verify(repo).save(...)), so they break on harmless refactors. Fakes couple only to outcomes. - One well-written fake is reused across dozens of tests; mock setups are re-specified each time.
Write a fake adapter for each driven port once, and your entire core becomes trivially testable.
Aligning with the testing pyramid
Recall the classic pyramid: many fast unit tests, fewer integration tests, a handful of end-to-end tests. Hexagonal architecture maps onto it cleanly:
- Unit tests (the wide base) - domain entities and use cases with fake adapters. Fast, numerous, where most of your business-logic coverage lives.
- Integration tests (the middle) - each real adapter against its real technology:
JpaAccountRepositoryagainst a Testcontainers Postgres, the REST adapter against a running context. You test that the adapter correctly translates, not the business rules (already covered by unit tests). - End-to-end (the tip) - a few full-stack flows through the real driving adapter, real DB, everything wired.
The architecture tells you what to test where: business rules at the unit level with fakes, translation
correctness at the integration level per adapter, and only a thin layer of full E2E. You stop writing slow,
brittle @SpringBootTests for logic that a millisecond unit test covers.
Contract tests keep fakes honest
A fake is only useful if it behaves like the real adapter. Guard that with a shared 'contract test' - an abstract test suite of the port's expected behavior that BOTH the InMemory fake and the real Jpa adapter must pass. Now your fast unit tests rest on a fake proven to match production behavior, and a divergence (the fake allows something the DB rejects) is caught automatically.
Because a car's steering, throttle, and brakes are connected through standard linkages (ports), an engineer can bench-test the control logic with simulated inputs and dummy loads - no need for a real road, engine, or weather. They confirm 'turn the wheel 30° → wheels angle correctly' in a lab, in seconds, hundreds of times. The real tires on real asphalt (integration tests) are checked separately, and only a few full test-drives on a track (E2E) are needed. A car welded into one inseparable lump could only ever be tested by driving it - slow, expensive, and you can't isolate which part failed. Hexagonal is the bench-testable car: the ports are the linkages that let you swap the road for a simulator.
For the hexagonal ledger-legacy's 'post a transaction' feature - a REST driving adapter, a PostTransaction use case with the Account entity's overdraft invariant, and a JpaTransactionRepository driven adapter - lay out which tests you'd write at each level of the pyramid, what each uses for its collaborators, and what each is actually verifying.
Why is a hexagonal/clean architecture app so easy to unit test?
Key takeaways
- The core depends only on ports, so you test domain and use cases in memory with fake adapters - no database, Spring context, or HTTP.
- Core tests run in milliseconds, exercise real business logic, and read like specifications of behavior.
- Prefer fakes (working in-memory implementations) over mocks: they test outcomes, not call sequences, so they survive refactors and are reusable.
- The architecture maps onto the testing pyramid: unit tests (business rules, fakes), integration tests (each real adapter vs real tech), a few E2E flows.
- Keep fakes honest with a shared contract test that both the fake and the real adapter must pass.