In this guide (20 items)
Testing Strategy
Testing Strategy
MANDATORY FOR ALL AI AGENTS: Every new feature MUST include tests at the appropriate layer. You MUST write tests BEFORE implementation when possible (TDD). Unit tests for domain and use cases are NON-NEGOTIABLE. Architecture tests (ArchUnit) MUST pass before any PR is merged. Do NOT skip testing under any circumstance.
This guide details the testing strategies and tools used to ensure quality in our hexagonal architecture, covering everything from isolated unit tests to end-to-end integration tests with Karate and Testcontainers.
1. Testing Strategy by Layer
Following the principles of hexagonal architecture, tests are divided according to the layer and its responsibility:
| Layer | Test Type | Tools | Focus |
|---|---|---|---|
| Domain | Unit Test | JUnit 5 | Pure business logic, entities, and Value Objects. No mocks in domain – use real instances. |
| Application | Unit Test | JUnit 5, MockK / Mockito | Use case orchestration. Mocking of outbound ports. |
| Infrastructure – Driven (outbound) Adapters | Integration Test | JUnit 5, Testcontainers | Verification of technical implementations (DB, Kafka, API Clients). |
| Infrastructure – Entry Points (driving/inbound adapters) | Functional / E2E Test | Karate DSL | Entry points (REST/GraphQL) are driving adapters inside infrastructure.entry.points (see §6 ArchUnit), not a separate layer. Full flows from entry points to persistence/messaging. |
2. TDD Workflow for Hexagonal Architecture
Test-Driven Development follows the Red-Green-Refactor cycle. In a hexagonal architecture, the workflow is:
Step 1: RED – Write a failing test for the port interface
Kotlin:
class CreateOrganizationUseCaseTest { private val repository = mockk<OrganizationRepositoryPort>() private val useCase = CreateOrganizationUseCase(repository)
@Test fun `should create organization with ACTIVE status`() { // Given val command = CreateOrganizationCommand( name = "Acme Corp", contactName = "John", contactLastName = "Doe", contactEmail = "john@acme.com", contactPhone = "+1234567890" ) every { repository.save(any()) } answers { firstArg() }
// When val result = useCase.execute(command)
// Then assertEquals("Acme Corp", result.name) assertEquals(OrganizationStatus.ACTIVE, result.status) verify(exactly = 1) { repository.save(any()) } }}Java:
class CreateOrganizationUseCaseTest { private final OrganizationRepositoryPort repository = mock(OrganizationRepositoryPort.class); private final CreateOrganizationUseCase useCase = new CreateOrganizationUseCase(repository);
@Test @DisplayName("should create organization with ACTIVE status") void shouldCreateOrganizationWithActiveStatus() { // Given var command = new CreateOrganizationCommand( "Acme Corp", "John", "Doe", "john@acme.com", "+1234567890" ); when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
// When var result = useCase.execute(command);
// Then assertEquals("Acme Corp", result.getName()); assertEquals(OrganizationStatus.ACTIVE, result.getStatus()); verify(repository, times(1)).save(any()); }}Step 2: GREEN – Implement the use case to pass the test
Write the minimal use case implementation that makes the test pass.
Step 3: REFACTOR – Clean up while keeping tests green
Improve code structure, naming, and remove duplication without changing behavior.
3. Unit Testing
Unit tests must be fast and should not depend on frameworks like Spring.
Domain Layer
Focus on business rules in entities and domain services.
- No mocks in domain: If an entity depends on another, use real instances.
- State: Verify that the entity state changes correctly.
Kotlin:
class OrganizationTest { @Test fun `should change status with withStatus`() { val org = Organization( id = UUID.randomUUID(), name = "Acme", contactName = "John", contactLastName = "Doe", contactEmail = "john@acme.com", contactPhone = "+1234567890", status = OrganizationStatus.ACTIVE )
val updated = org.withStatus(OrganizationStatus.INACTIVE)
assertEquals(OrganizationStatus.INACTIVE, updated.status) }
@Test fun `should throw when changing to same status`() { val org = Organization( id = UUID.randomUUID(), name = "Acme", contactName = "John", contactLastName = "Doe", contactEmail = "john@acme.com", contactPhone = "+1234567890", status = OrganizationStatus.ACTIVE )
assertThrows<IllegalStateException> { org.withStatus(OrganizationStatus.ACTIVE) } }}Java:
class OrganizationTest { @Test @DisplayName("should change status with withStatus") void shouldChangeStatusWithWithStatus() { var org = new Organization( UUID.randomUUID(), "Acme", "John", "Doe", "john@acme.com", "+1234567890", OrganizationStatus.ACTIVE );
var updated = org.withStatus(OrganizationStatus.INACTIVE);
assertEquals(OrganizationStatus.INACTIVE, updated.getStatus()); }
@Test @DisplayName("should throw when changing to same status") void shouldThrowWhenChangingToSameStatus() { var org = new Organization( UUID.randomUUID(), "Acme", "John", "Doe", "john@acme.com", "+1234567890", OrganizationStatus.ACTIVE );
assertThrows(IllegalStateException.class, () -> org.withStatus(OrganizationStatus.ACTIVE) ); }}Application Layer (Use Cases)
Test orchestration. Mocks are used for ports (interfaces).
Kotlin:
class CreateInvoiceUseCaseTest { private val transaction = mockk<TransactionPort<Invoice>>() private val repository = mockk<InvoiceRepositoryPort>() private val outbox = mockk<OutboxRepositoryPort>(relaxed = true) private val events = OutboxEventFactory("test-service", EventMessageFactory("test-service")) private val useCase = CreateInvoiceUseCase(transaction, repository, outbox, events)
@Test fun `should save aggregate and enqueue outbox event in same transaction`() { // Given val command = CreateInvoiceCommand(/* params */, traceId = "t-1") every { transaction.run(any()) } answers { firstArg<() -> Invoice>().invoke() } every { repository.save(any()) } returns mockInvoice
// When val result = useCase.execute(command)
// Then verify { repository.save(any()) } verify { outbox.save<OutboxMessage<EventMessage<InvoiceCreatedPayload>>>(match { it.topic == "platform.invoices" }) } assertNotNull(result) }}Java:
class CreateInvoiceUseCaseTest { private final TransactionPort<Invoice> transaction = mock(); private final InvoiceRepositoryPort repository = mock(); private final OutboxRepositoryPort outbox = mock(); private final OutboxEventFactory events = new OutboxEventFactory("test-service", new EventMessageFactory("test-service")); private final CreateInvoiceUseCase useCase = new CreateInvoiceUseCase(transaction, repository, outbox, events);
@Test @DisplayName("should save aggregate and enqueue outbox event in same transaction") void shouldEnqueueOutboxEvent() { var command = new CreateInvoiceCommand(/* params */); when(transaction.run(any())).thenAnswer(i -> ((Supplier<Invoice>) i.getArgument(0)).get()); when(repository.save(any())).thenReturn(mockInvoice);
var result = useCase.execute(command);
verify(repository).save(any()); verify(outbox).save(argThat(msg -> msg.getTopic().equals("platform.invoices"))); assertNotNull(result); }}4. Integration Testing with Testcontainers
Testcontainers is used for testing infrastructure adapters with real instances of databases, brokers, or external services, ensuring that our technical implementations work against the actual technology.
Database Integration Test (PostgreSQL)
Kotlin:
@Testcontainersclass InvoiceRepositoryAdapterTest {
companion object { @Container val postgres = PostgreSQLContainer("postgres:17-alpine") .withDatabaseName("testdb") .withUsername("user") .withPassword("password")
@JvmStatic @DynamicPropertySource fun properties(registry: DynamicPropertyRegistry) { registry.add("spring.datasource.url", postgres::getJdbcUrl) registry.add("spring.datasource.username", postgres::getUsername) registry.add("spring.datasource.password", postgres::getPassword) } }
@Test fun `should save and find invoice`() { // Test logic using the real database }}Java:
@Testcontainersclass InvoiceRepositoryAdapterTest {
@Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine") .withDatabaseName("testdb") .withUsername("user") .withPassword("password");
@DynamicPropertySource static void properties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); }
@Test @DisplayName("should save and find invoice") void shouldSaveAndFindInvoice() { // Test logic using the real database }}Messaging Integration Test (Kafka)
Kotlin:
@Testcontainersclass KafkaPublisherAdapterTest {
companion object { @Container val kafka = KafkaContainer("apache/kafka:3.8.0")
@JvmStatic @DynamicPropertySource fun registerKafkaProperties(registry: DynamicPropertyRegistry) { registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers) } }
// Test logic...}Java:
@Testcontainersclass KafkaPublisherAdapterTest {
@Container static KafkaContainer kafka = new KafkaContainer("apache/kafka:3.8.0");
@DynamicPropertySource static void registerKafkaProperties(DynamicPropertyRegistry registry) { registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers); }
// Test logic...}5. Functional Testing with Karate
Karate is used for black-box testing over entry-points (REST/GraphQL). These tests validate that the complete system works as expected.
Folder Structure
Karate tests usually reside in the bootstrap module or a dedicated acceptance-tests module.
src/test/resources/karate/ +-- features/ | +-- create-invoice.feature +-- karate-config.jsFeature Example (REST)
Feature: Invoice Management
Background: * url baseUrl * def payload = read('classpath:json/invoice-request.json')
Scenario: Create an invoice successfully Given path '/api/v1/invoices' And request payload When method post Then status 201 And match response.id == '#present' And match response.status == 'CREATED'6. Architecture Testing with ArchUnit
To ensure that hexagonal architecture rules are not violated (e.g., domain depending on infrastructure), we use ArchUnit. These tests run as part of the regular test suite and enforce architectural boundaries at build time.
Concrete Example (from this project)
The following tests are implemented and enforced in this project at bootstrap/src/test/kotlin/com/example/platform/architecture/HexagonalArchitectureTest.kt. Every new module MUST pass all 6 tests.
Kotlin (actual project implementation):
class HexagonalArchitectureTest { companion object { private lateinit var importedClasses: JavaClasses
@JvmStatic @BeforeAll fun setup() { importedClasses = ClassFileImporter() .withImportOption(ImportOption.DoNotIncludeTests()) .importPackages("com.example.platform") } }
@Test @DisplayName("Driven adapters (outbound) should not depend on domain commands") fun `driven adapters should not depend on domain commands`() { noClasses() .that() .resideInAPackage("..infrastructure.driven.adapters..") .should() .dependOnClassesThat() .resideInAPackage("..domain.command..") .because( "Commands are application-layer DTOs that should flow from entry points to use cases. " + "Driven adapters should only work with domain entities, not commands." ).check(importedClasses) }
@Test @DisplayName("Driven adapters should not access use case implementations directly") fun `driven adapters should not access use case implementations directly`() { noClasses() .that() .resideInAPackage("..infrastructure.driven.adapters..") .should() .accessClassesThat() .resideInAPackage("..usecase..") .andShould() .notBeInterfaces() .because( "Driven adapters should only implement port interfaces, " + "not access use case implementation classes directly." ).allowEmptyShould(true) .check(importedClasses) }
@Test @DisplayName("Domain should not depend on infrastructure") fun `domain should not depend on infrastructure`() { noClasses() .that() .resideInAPackage("..domain..") .should() .dependOnClassesThat() .resideInAPackage("..infrastructure..") .because( "The domain layer is the core of the application and should be completely independent. " + "It should not have any dependencies on infrastructure concerns." ).check(importedClasses) }
@Test @DisplayName("Domain should not depend on application use cases") fun `domain should not depend on application use cases`() { noClasses() .that() .resideInAPackage("..domain..") .should() .dependOnClassesThat() .resideInAPackage("..usecase..") .because( "The domain layer should not depend on application-specific use case orchestration. " + "Use cases orchestrate domain entities, not the other way around." ).check(importedClasses) }
@Test @DisplayName("Application use cases should not depend on infrastructure") fun `application use cases should not depend on infrastructure`() { noClasses() .that() .resideInAPackage("..usecase..") .should() .dependOnClassesThat() .resideInAPackage("..infrastructure..") .because( "Use cases should depend only on domain entities and port interfaces, " + "not on concrete infrastructure implementations." ).check(importedClasses) }
@Test @DisplayName("Entry points should not depend on driven adapters") fun `entry points should not depend on driven adapters`() { noClasses() .that() .resideInAPackage("..infrastructure.entry.points..") .should() .dependOnClassesThat() .resideInAPackage("..infrastructure.driven.adapters..") .because( "Entry points (driving adapters) should only depend on use case ports. " + "They should not have direct dependencies on driven adapters." ).check(importedClasses) }}Java equivalent:
class HexagonalArchitectureTest { private static JavaClasses importedClasses;
@BeforeAll static void setup() { importedClasses = new ClassFileImporter() .withImportOption(new ImportOption.DoNotIncludeTests()) .importPackages("com.example.platform"); }
@Test @DisplayName("Driven adapters should not depend on domain commands") void drivenAdaptersShouldNotDependOnDomainCommands() { noClasses() .that().resideInAPackage("..infrastructure.driven.adapters..") .should().dependOnClassesThat().resideInAPackage("..domain.command..") .because("Commands are application-layer DTOs that should flow from entry points to use cases.") .check(importedClasses); }
@Test @DisplayName("Domain should not depend on infrastructure") void domainShouldNotDependOnInfrastructure() { noClasses() .that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("..infrastructure..") .because("The domain layer should be completely independent of infrastructure.") .check(importedClasses); }
@Test @DisplayName("Domain should not depend on application use cases") void domainShouldNotDependOnUseCases() { noClasses() .that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("..usecase..") .because("Use cases orchestrate domain entities, not the other way around.") .check(importedClasses); }
@Test @DisplayName("Application use cases should not depend on infrastructure") void useCasesShouldNotDependOnInfrastructure() { noClasses() .that().resideInAPackage("..usecase..") .should().dependOnClassesThat().resideInAPackage("..infrastructure..") .because("Use cases should depend only on domain entities and port interfaces.") .check(importedClasses); }
@Test @DisplayName("Entry points should not depend on driven adapters") void entryPointsShouldNotDependOnDrivenAdapters() { noClasses() .that().resideInAPackage("..infrastructure.entry.points..") .should().dependOnClassesThat().resideInAPackage("..infrastructure.driven.adapters..") .because("Entry points should only depend on use case ports.") .check(importedClasses); }
@Test @DisplayName("Driven adapters should not access use case implementations directly") void drivenAdaptersShouldNotAccessUseCaseImplementations() { noClasses() .that().resideInAPackage("..infrastructure.driven.adapters..") .should().dependOnClassesThat().resideInAPackage("..usecase..") .because("Driven adapters implement output ports; they must not reach into use case implementations.") .check(importedClasses); }}Running ArchUnit Tests
# Run only architecture tests./gradlew :bootstrap:test --tests "com.example.platform.architecture.*"
# Run all bootstrap tests (includes architecture tests)./gradlew :bootstrap:testRules Enforced
| Rule | What It Prevents |
|---|---|
| Domain must not depend on Infrastructure | Database, Kafka, or HTTP imports in domain classes |
| Domain must not depend on Use Cases | Reverse dependency from domain to application layer |
| Use Cases must not depend on Infrastructure | Spring, JDBC, or Kafka imports in use case classes |
| Entry Points must not depend on Driven Adapters | REST controllers accessing repository implementations directly |
| Driven Adapters must not depend on Commands | Infrastructure working with application-layer DTOs |
| Driven Adapters must not access Use Cases | Infrastructure coupling to application logic |
7. Testing the Chain of Responsibility (Decorators)
When testing decorator adapters (cache, metrics, logging), test each decorator in isolation and verify delegation.
Kotlin:
class CreateUserMetricsAdapterTest { private val meterRegistry = SimpleMeterRegistry() private val delegate = mockk<CreateUserPort>() private val metricsAdapter = CreateUserMetricsAdapter(meterRegistry)
@BeforeEach fun setup() { metricsAdapter.setDelegate(delegate) }
@Test fun `should record metric and delegate execution`() { // Given val command = CreateUserCommand(/* params */) val expectedUser = User(/* params */) every { delegate.execute(command) } returns expectedUser
// When val result = metricsAdapter.execute(command)
// Then assertEquals(expectedUser, result) verify(exactly = 1) { delegate.execute(command) } // Verify metric was recorded val timer = meterRegistry.find("usecase.create.user").timer() assertNotNull(timer) assertEquals(1, timer?.count()) }}Java:
class CreateUserMetricsAdapterTest { private final MeterRegistry meterRegistry = new SimpleMeterRegistry(); private final CreateUserPort delegate = mock(CreateUserPort.class); private final CreateUserMetricsAdapter metricsAdapter = new CreateUserMetricsAdapter(meterRegistry);
@BeforeEach void setup() { metricsAdapter.setDelegate(delegate); }
@Test @DisplayName("should record metric and delegate execution") void shouldRecordMetricAndDelegateExecution() { // Given var command = new CreateUserCommand(/* params */); var expectedUser = new User(/* params */); when(delegate.execute(command)).thenReturn(expectedUser);
// When var result = metricsAdapter.execute(command);
// Then assertEquals(expectedUser, result); verify(delegate, times(1)).execute(command); // Verify metric was recorded var timer = meterRegistry.find("usecase.create.user").timer(); assertNotNull(timer); assertEquals(1, timer.count()); }}8. Best Practices
- Naming: Use descriptive names for tests (e.g.,
`should return error when stock is empty`in Kotlin,shouldReturnErrorWhenStockIsEmptyin Java). - Independence: Each test must be able to run on its own, without depending on the state left by another.
- Real Containers: For database or messaging adapters, use Testcontainers instead of H2 or mocks whenever possible to avoid false positives.
- Data Builders: Create utility classes or extension functions to build domain objects in tests to reduce boilerplate code.
- Given-When-Then: Structure every test with clear Given (setup), When (action), Then (assertions) sections.
- No Spring in Unit Tests: Domain and use case tests MUST NOT load the Spring context. Use plain JUnit 5 + MockK/Mockito.
JUnit 5 (Jupiter) only; JUnit 4 /
@RunWith/ vintage forbidden. - Test Coverage: Aim for high coverage in domain and application layers. Infrastructure adapter tests focus on correct integration, not exhaustive coverage.