Skip to content
My Guidelines
Guides
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:

@Testcontainers
class 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:

@Testcontainers
class 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:

@Testcontainers
class 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:

@Testcontainers
class 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.js

Feature 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

Terminal window
# Run only architecture tests
./gradlew :bootstrap:test --tests "com.example.platform.architecture.*"
# Run all bootstrap tests (includes architecture tests)
./gradlew :bootstrap:test

Rules 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

  1. Naming: Use descriptive names for tests (e.g., `should return error when stock is empty` in Kotlin, shouldReturnErrorWhenStockIsEmpty in Java).
  2. Independence: Each test must be able to run on its own, without depending on the state left by another.
  3. Real Containers: For database or messaging adapters, use Testcontainers instead of H2 or mocks whenever possible to avoid false positives.
  4. Data Builders: Create utility classes or extension functions to build domain objects in tests to reduce boilerplate code.
  5. Given-When-Then: Structure every test with clear Given (setup), When (action), Then (assertions) sections.
  6. 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.

  7. Test Coverage: Aim for high coverage in domain and application layers. Infrastructure adapter tests focus on correct integration, not exhaustive coverage.