Start Learning
Javaneer
Back to stage
Stage 3·Hexagonal & Clean Architecture

Testing a Hexagonal App

The real payoff: test the domain and use cases in memory with fake adapters, no Spring context or database - fast, focused tests that align with the testing pyramid.

14 min readAdvanced
On this page

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 InMemoryAccountRepository backed by a HashMap behaves like a repository - save then findById actually 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: JpaAccountRepository against 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.

Test-driving a car's controls on a bench

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.

Design the test strategy

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.
Was this lesson helpful?
Edit this page on GitHub