Hexagonal Testing

Hexagonal Architecture Concepts

Domain

<Data>
Demand

Domain

Not an original Hexagonal concept

Just POJOs

No dependencies (but utils)

No framework-specific code

Knows only itself,
but is known to all

Domain

<Data>
Demand

Application

Application

Does orchestration

Domain

<Service>
DemandService

<Data>
Demand

Application

Application

Does orchestration

Knows the domain

Framework-specific
@Service, @Component, @Configuration

Domain

<Service>
DemandService

<Data>
Demand

Application

Application

Does orchestration

Knows the domain

Framework-specific
@Service, @Component, @Configuration

Implementation of in ports

Knows nothing about Kafka Consumers, HTTP-APIs, etc.

<InPort>
ToImportDemands

Application

Does orchestration

Knows the domain

Framework-specific
@Service, @Component, @Configuration

Implementation of in ports

Knows nothing about Kafka Consumers, HTTP-APIs, etc.

Uses out ports

Knows nothing about Postgres, Kafka Producers, etc.

Domain

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Application

Driving Adapters

Configures/implements provided interfaces, e.g. Kafka

May use the domain,
might have its own data classes

Uses in ports

Domain

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Application

Demand
Consumer

<Data>
Demand
Message

<Data>
Demand
Message

Driven Adapters

Configures/implements outer dependencies, e.g. Postgres

May use the domain,
might have its own data classes

Implements out ports

Domain

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Application

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

Domain

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Application

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

Fronten Service

<Data>
Demand
Message

Application

Driving

Adapter

Driven

Adapter

Fronten Service

<Service>
Service

<InPort>
ToImport

<OutPort>
ToStore

Consumer

Store

<Data>
Entity

Repository

<Data>
Message

Controller

<Data>
Dto

<InPort>
ToQuery

KafkaProducer

<Data>
Message

<OutPort>
ToPublish

<Service>
Service

Hexagonal Unit Tests

Domain

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Application

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

<Data>
Demand

Domain

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Application

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Demand
Store

Demand
Repo.

Demand
Entity

Demand
Repo.Stub

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Demand
Store

class DemandRepositoryStub :
  CrudRepositoryStub<DemandEntity, String>(),
  DemandRepository {

  fun findByOrderId(orderId: String) =
    data.values.find {
      entity -> demand.orderId == orderId
    }
}

Demand
Repo.Stub

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Demand
Store

Demand
Repo.Stub

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Demand
Store

class DemandImporterTest {

  private val demandStore : ToStoreDemands =
    DemandStore(DemandRepositoryStub())
  
  private val demandImporter : ToImportDemands =
    DemandService(demandStore)
}
 

Demand
Repo.Stub

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Demand
Store

class DemandImporterTest {

  private val demandStore : ToStoreDemands =
    DemandStore(DemandRepositoryStub())
  
  private val demandImporter : ToImportDemands =
    DemandService(demandStore)
    
  fun `importDemand`() {
    val result = demandImporter.save(aDemand().build())
    
    assertThat(result.isSuccessful()).isTrue()
    assertThat(demandStore.findById(preStoredDemand.id))
        .isNotNull()
  }
}
 

Demand
Repo.Stub

<Service>
DemandService

<Data>
Demand

<OutPort>
ToStoreDemands

Demand
Store

<InPort>
ToImportDemands

class DemandImporterTest {

  private val demandStore : ToStoreDemands =
    DemandStore(DemandRepositoryStub())
  
  private val demandImporter : ToImportDemands =
    DemandService(demandStore)
    
  fun `importDemand`() {
    val result = demandImporter.save(aDemand().build())
    
    assertThat(result.isSuccessful()).isTrue()
    assertThat(demandStore.findById(preStoredDemand.id))
        .isNotNull()
  }
  
  fun `importDemand conflict`() {
    val preStoredDemand = aDemand().build()
    demandStore.save(preStoredDemand)
    
    val result = demandImporter.save(preStoredDemand)
    
    assertThat(result.isConflict()).isTrue()
  }
}
 

Demand
Repo.Stub

<Service>
DemandService

<Data>
Demand

<OutPort>
ToStoreDemands

Demand
Store

<InPort>
ToImportDemands

class DemandImporterTest {

  private val demandStore : ToStoreDemands =
    DemandStore(DemandRepositoryStub())
  
  private val demandImporter : ToImportDemands =
    DemandService(demandStore)
    
  fun `importDemand`() {
    val result = demandImporter.save(aDemand().build())
    
    assertThat(result.isSuccessful()).isTrue()
    assertThat(demandStore.findById(preStoredDemand.id))
        .isNotNull()
  }
  
  fun `importDemand conflict`() {
    val preStoredDemand = aDemand().build()
    demandStore.save(preStoredDemand)
    
    val result = demandImporter.save(preStoredDemand)
    
    assertThat(result.isConflict()).isTrue()
  }
  
  fun `deleteDemand`() {
    val preStoredDemand = aDemand().build()
    demandStore.save(preStoredDemand))
    
    val result = demandImporter.delete(preStoredDemand)
    
    assertThat(result.isSuccessful()).isTrue()
    assertThat(demandStore.findById(preStoredDemand.id))
        .isNull()
  }
}
 

Demand
Repo.Stub

<Service>
DemandService

<Data>
Demand

<OutPort>
ToStoreDemands

Demand
Store

<InPort>
ToImportDemands

class DemandImporterTest {

  private val demandStore : ToStoreDemands =
    DemandStore(DemandRepositoryStub())
  
  private val demandImporter : ToImportDemands =
    DemandService(demandStore)
    
  fun `importDemand`() {
    val result = demandImporter.save(aDemand().build())
    
    assertThat(result.isSuccessful()).isTrue()
    assertThat(demandStore.findById(preStoredDemand.id))
        .isNotNull()
  }
  
  fun `importDemand conflict`() {
    val preStoredDemand = aDemand().build()
    demandStore.save(preStoredDemand)
    
    val result = demandImporter.save(preStoredDemand)
    
    assertThat(result.isConflict()).isTrue()
  }
  
  fun `deleteDemand`() {
    val preStoredDemand = aDemand().build()
    demandStore.save(preStoredDemand))
    
    val result = demandImporter.delete(preStoredDemand)
    
    assertThat(result.isSuccessful()).isTrue()
    assertThat(demandStore.findById(preStoredDemand.id))
        .isNull()
  }
}
 

Demand
Repo.Stub

<Service>
DemandService

<Data>
Demand

<OutPort>
ToStoreDemands

Demand
Store

<InPort>
ToImportDemands

class DemandImporterTest {

  private val demandStore : ToStoreDemands =
    DemandStore(DemandRepositoryStub())
  
  private val demandImporter : ToImportDemands =
    DemandService(demandStore)
    
  fun `importDemand`() {
    val result = demandImporter.save(aDemand().build())
    
    assertThat(result.isSuccessful()).isTrue()
    assertThat(demandStore.findById(preStoredDemand.id))
        .isNotNull()
  }
  
  fun `importDemand conflict`() {
    val preStoredDemand = aDemand().build()
    demandStore.save(preStoredDemand)
    
    val result = demandImporter.save(preStoredDemand)
    
    assertThat(result.isConflict()).isTrue()
  }
  
  fun `deleteDemand`() {
    val preStoredDemand = aDemand().build()
    demandStore.save(preStoredDemand))
    
    val result = demandImporter.delete(preStoredDemand)
    
    assertThat(result.isSuccessful()).isTrue()
    assertThat(demandStore.findById(preStoredDemand.id))
        .isNull()
  }
}
 
class DemandBuilder private constructor {

  private var id: String = UUID.randomUUID().toString()
  private var version: Long = 1
  private var referenceId: String = randomReferenceId()
  // …

  companion object {
    fun aDemand() = DemandBuilder()
  }
  
  fun id(id: String) = apply { this.id = id }
  fun version(version: Long) = apply { this.version = version }
  fun referenceId(referenceId: String) = apply {
    this.referenceId = referenceId }
  // …
  
  fun build() = Demand(
    id = id,
    version = version,
    referenceId = referenceId,
    // …
  )
}
 

Demand
Repo.Stub

<Service>
DemandService

<Data>
Demand

<OutPort>
ToStoreDemands

Demand
Store

<InPort>
ToImportDemands

Domain

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Application

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

Demand
Entity

<Data>
Demand
Message

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

Hexagonal Integration Tests

Domain

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Application

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

Domain

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Application

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

Domain

<Service>
DemandService

<Data>
Demand

<OutPort>
ToStoreDemands

Application

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

@SpringBootTest
@EmbeddedKafka
class DemandCosumptionTest() : AbstractServiceTest {
}
 

<InPort>
ToImportDemands

Domain

<Service>
DemandService

<Data>
Demand

<OutPort>
ToStoreDemands

Application

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

@SpringBootTest
@EmbeddedKafka
class DemandCosumptionTest(
  @Autowired private val demandProducer: DemandProducer,
) : AbstractServiceTest {
}
 

<InPort>
ToImportDemands

@Component
class DemandProducer(
  @Autowired kafkaTemplate: KafkaTemplate<String, String>,
  @Value("\${kafka.topics.demand.name}") topicName: String
) {

  fun send() {
    val result: CompletableFuture<SendResult<String, String>> =
      kafkaTemplate.send(topic, key, data)
    kafkaTemplate.flush()
    result.get(10, SECONDS)
  }
}

Domain

<Service>
DemandService

<Data>
Demand

<OutPort>
ToStoreDemands

Application

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

@SpringBootTest
@EmbeddedKafka
class DemandCosumptionTest(
  @Autowired private val demandProducer: DemandProducer,
  @Autowired private val demandStore: ToStoreDemands
) : AbstractServiceTest {
}
 

<InPort>
ToImportDemands

Domain

<Service>
DemandService

<Data>
Demand

<OutPort>
ToStoreDemands

Application

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

@SpringBootTest
@EmbeddedKafka
class DemandCosumptionTest(
  @Autowired private val demandProducer: DemandProducer,
  @Autowired private val demandStore: ToStoreDemands
) : AbstractServiceTest {

  fun `consume demand`() {
    val demandMessage = aDemandMessage().build()
    demandProducer.send(demandMessage)
    
    await().untilAsserted {
      assertThat(demandStore.findById(demandMessage.id))
        .isNotNull()
    }
  }
}
 

<InPort>
ToImportDemands

Domain

<Service>
DemandService

<Data>
Demand

<OutPort>
ToStoreDemands

Application

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

@SpringBootTest
@EmbeddedKafka
class DemandCosumptionTest(
  @Autowired private val demandProducer: DemandProducer,
  @Autowired private val demandStore: ToStoreDemands
) : AbstractServiceTest {

  fun `consume demand`() {
    val demandMessage = aDemandMessage().build()
    demandProducer.send(demandMessage)
    
    await().untilAsserted {
      assertThat(demandStore.findById(demandMessage.id))
        .isNotNull()
    }
  }
  
  fun `consume tombstone`() {
    val preStoredDemand = aDemand().build()
    demandStore.save(preStoredDemand)
    
    demandProducer.sendTombstone(preStoredDemand.id)
    
    await().untilAsserted {
      assertThat(demandStore.findById(demandMessage.id))
        .isNull()
    }
  }
}
 

<InPort>
ToImportDemands

Domain

<Service>
DemandService

<Data>
Demand

<InPort>
ToImportDemands

<OutPort>
ToStoreDemands

Application

Demand
Consumer

<Data>
Demand
Message

Demand
Store

Demand
Repo.

Demand
Entity

<Data>
Demand
Message

Hexagonal Testing

Domain

Application

Domain

Very well and easily covered by simple Unit Tests

Application

Testing port-to-port

Requires stubbing

Test data builders

Still fast and simple unit tests

Adapters

Mostly about infrastructure

Unit Tests rather pointless

Focussed Integration Tests or Service Tests necessary

@SpringBootTest

Testcontainers

ports

application

adapters

SpringBootApplication

driven

driving

domain

driven

driving

Hexagonal Testing

By Michael Kutz

Hexagonal Testing

Are you tired of brittle test suites, that keep breaking for no good reasons at all? Does maintaining your test suite feels like doubling your efforts on a service? Do you avoid big refactoring because you're afraid of your own tests? Well, I've been there… In this talk I demonstrate a concept of how to achieve a great test coverage on hexagonal services written in Spring Boot with minimal test code, no mocks and great expression of intent. Never done a hexagonal service? No problem! I'll explain all core concepts in the first part of the talk to make sure we're all on the same page. After hearing this, you should have a good idea of how to refactor your tests and know some new helpful libraries and design patterns for tests.

  • 233