Skip to main content

Testing Strategies

This module covers comprehensive testing approaches for Axon Framework components within the EAF architecture, from unit tests to full integration scenarios.

๐Ÿ“š Learning Objectivesโ€‹

By the end of this module, you will be able to:

  • Write effective unit tests for aggregates using Axon Test
  • Create integration tests with EAF infrastructure
  • Design acceptance tests for complete workflows
  • Test multi-tenant scenarios and security
  • Mock external dependencies properly
  • Measure and optimize test performance

๐Ÿงช Testing Pyramid for Event-Sourced Systemsโ€‹

Testing Focus Areasโ€‹

  1. Aggregate Logic: Business rules and invariants
  2. Event Processing: Handlers and projections
  3. Infrastructure: Event store, NATS, multi-tenancy
  4. Security: Authorization and tenant isolation
  5. Performance: Load handling and scalability

๐ŸŽฏ Unit Testing with Axon Testโ€‹

Aggregate Testing Frameworkโ€‹

Axon provides excellent testing support through AggregateTestFixture:

// Test class setup
class UserAggregateTest {
private lateinit var fixture: AggregateTestFixture<User>

@BeforeEach
fun setUp() {
fixture = AggregateTestFixture(User::class.java)
}
}

Testing Aggregate Creationโ€‹

@Test
fun `should create user when valid command is given`() {
val command = CreateUserCommand(
userId = "user-123",
tenantId = "tenant-abc",
email = "john.doe@example.com",
firstName = "John",
lastName = "Doe",
roles = setOf("USER"),
department = "Engineering"
)

fixture.givenNoPriorActivity()
.`when`(command)
.expectEvents(
UserCreatedEvent(
userId = "user-123",
tenantId = "tenant-abc",
email = "john.doe@example.com",
firstName = "John",
lastName = "Doe",
roles = setOf("USER"),
department = "Engineering"
)
)
}

@Test
fun `should reject user creation with invalid email`() {
val command = CreateUserCommand(
userId = "user-123",
tenantId = "tenant-abc",
email = "invalid-email", // Invalid format
firstName = "John",
lastName = "Doe",
roles = setOf("USER"),
department = "Engineering"
)

fixture.givenNoPriorActivity()
.`when`(command)
.expectException(IllegalArgumentException::class.java)
.expectExceptionMessage("Invalid email format")
}

Testing State Transitionsโ€‹

@Test
fun `should update user email when user is active`() {
val createEvent = UserCreatedEvent(
userId = "user-123",
tenantId = "tenant-abc",
email = "john.doe@example.com",
firstName = "John",
lastName = "Doe",
roles = setOf("USER"),
department = "Engineering"
)

val updateCommand = ChangeUserEmailCommand(
userId = "user-123",
newEmail = "john.new@example.com",
reason = "Personal preference"
)

fixture.given(createEvent)
.`when`(updateCommand)
.expectEvents(
UserEmailChangedEvent(
userId = "user-123",
previousEmail = "john.doe@example.com",
newEmail = "john.new@example.com",
reason = "Personal preference"
)
)
}

@Test
fun `should reject email change when user is suspended`() {
val createEvent = UserCreatedEvent(
userId = "user-123",
tenantId = "tenant-abc",
email = "john.doe@example.com",
firstName = "John",
lastName = "Doe",
roles = setOf("USER"),
department = "Engineering"
)

val suspendEvent = UserSuspendedEvent(
userId = "user-123",
reason = "Policy violation",
suspendedBy = "admin-456"
)

val updateCommand = ChangeUserEmailCommand(
userId = "user-123",
newEmail = "john.new@example.com",
reason = "Personal preference"
)

fixture.given(createEvent, suspendEvent)
.`when`(updateCommand)
.expectException(IllegalStateException::class.java)
.expectExceptionMessage("Cannot change email of inactive user")
}

Testing Business Rulesโ€‹

@Test
fun `should enforce minimum role requirement`() {
val createEvent = UserCreatedEvent(
userId = "user-123",
tenantId = "tenant-abc",
email = "john.doe@example.com",
firstName = "John",
lastName = "Doe",
roles = setOf("USER"), // Only one role
department = "Engineering"
)

val removeRoleCommand = RemoveUserRoleCommand(
userId = "user-123",
role = "USER", // Trying to remove the only role
removedBy = "admin-456",
reason = "Role cleanup"
)

fixture.given(createEvent)
.`when`(removeRoleCommand)
.expectException(IllegalStateException::class.java)
.expectExceptionMessage("Cannot remove last role from user")
}

Parameterized Testingโ€‹

@ParameterizedTest
@ValueSource(strings = ["", " ", "invalid-email", "@example.com", "user@"])
fun `should reject invalid email formats`(invalidEmail: String) {
val command = CreateUserCommand(
userId = "user-123",
tenantId = "tenant-abc",
email = invalidEmail,
firstName = "John",
lastName = "Doe",
roles = setOf("USER"),
department = "Engineering"
)

fixture.givenNoPriorActivity()
.`when`(command)
.expectException(IllegalArgumentException::class.java)
}

๐Ÿ—๏ธ Integration Testing with EAF Infrastructureโ€‹

Test Configurationโ€‹

// Test application configuration
@TestConfiguration
class AxonTestConfiguration {

@Bean
@Primary
fun testEventStorageEngine(): EventStorageEngine {
return InMemoryEventStorageEngine()
}

@Bean
@Primary
fun testTokenStore(): TokenStore {
return InMemoryTokenStore()
}

@Bean
@Primary
fun testEventBus(): EventBus {
return SimpleEventBus.builder().build()
}
}

Event Store Integration Testsโ€‹

@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
@Import(AxonTestConfiguration::class)
class UserEventStoreIntegrationTest {

@Autowired
private lateinit var commandGateway: CommandGateway

@Autowired
private lateinit var eventStore: EventStore

@Container
companion object {
@JvmStatic
val postgres = PostgreSQLContainer("postgres:15-alpine")
.withDatabaseName("test_db")
.withUsername("test")
.withPassword("test")
}

@Test
fun `should persist and retrieve user events`() {
val userId = "user-${UUID.randomUUID()}"
val tenantId = "tenant-test"

// Send command
val command = CreateUserCommand(
userId = userId,
tenantId = tenantId,
email = "test@example.com",
firstName = "Test",
lastName = "User",
roles = setOf("USER"),
department = "Testing"
)

commandGateway.sendAndWait<Void>(command)

// Verify events were stored
val events = eventStore.readEvents(userId).asSequence().toList()

assertThat(events).hasSize(1)
assertThat(events[0].payload).isInstanceOf(UserCreatedEvent::class.java)

val event = events[0].payload as UserCreatedEvent
assertThat(event.userId).isEqualTo(userId)
assertThat(event.tenantId).isEqualTo(tenantId)
assertThat(event.email).isEqualTo("test@example.com")
}
}

Projection Integration Testsโ€‹

@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
@Transactional
class UserProjectionIntegrationTest {

@Autowired
private lateinit var commandGateway: CommandGateway

@Autowired
private lateinit var userProjectionRepository: UserProjectionRepository

@Autowired
private lateinit var eventProcessor: TrackingEventProcessor

@Test
fun `should create projection when user is created`() {
val userId = "user-${UUID.randomUUID()}"
val tenantId = "tenant-test"

// Send command
val command = CreateUserCommand(
userId = userId,
tenantId = tenantId,
email = "test@example.com",
firstName = "Test",
lastName = "User",
roles = setOf("USER"),
department = "Testing"
)

commandGateway.sendAndWait<Void>(command)

// Wait for projection to be updated
await().atMost(Duration.ofSeconds(5))
.until { userProjectionRepository.findById(userId).isPresent }

// Verify projection
val projection = userProjectionRepository.findById(userId).get()
assertThat(projection.userId).isEqualTo(userId)
assertThat(projection.tenantId).isEqualTo(tenantId)
assertThat(projection.email).isEqualTo("test@example.com")
assertThat(projection.status).isEqualTo(UserStatus.ACTIVE)
}

@Test
fun `should update projection when user email changes`() {
val userId = "user-${UUID.randomUUID()}"
val tenantId = "tenant-test"

// Create user
val createCommand = CreateUserCommand(
userId = userId,
tenantId = tenantId,
email = "old@example.com",
firstName = "Test",
lastName = "User",
roles = setOf("USER"),
department = "Testing"
)

commandGateway.sendAndWait<Void>(createCommand)

// Wait for initial projection
await().atMost(Duration.ofSeconds(5))
.until { userProjectionRepository.findById(userId).isPresent }

// Update email
val updateCommand = ChangeUserEmailCommand(
userId = userId,
newEmail = "new@example.com",
reason = "Testing"
)

commandGateway.sendAndWait<Void>(updateCommand)

// Wait for projection update
await().atMost(Duration.ofSeconds(5))
.until {
userProjectionRepository.findById(userId)
.map { it.email }
.orElse("") == "new@example.com"
}

// Verify updated projection
val projection = userProjectionRepository.findById(userId).get()
assertThat(projection.email).isEqualTo("new@example.com")
}
}

Multi-Tenant Testingโ€‹

@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class MultiTenantIntegrationTest {

@Autowired
private lateinit var commandGateway: CommandGateway

@Autowired
private lateinit var userProjectionRepository: UserProjectionRepository

@Autowired
private lateinit var tenantContextHolder: TenantContextHolder

@Test
fun `should isolate users by tenant`() {
val userId = "user-123"
val tenant1 = "tenant-1"
val tenant2 = "tenant-2"

// Create user in tenant 1
tenantContextHolder.setCurrentTenantId(tenant1)
val command1 = CreateUserCommand(
userId = userId,
tenantId = tenant1,
email = "user@tenant1.com",
firstName = "Tenant1",
lastName = "User",
roles = setOf("USER"),
department = "Testing"
)
commandGateway.sendAndWait<Void>(command1)

// Create user with same ID in tenant 2
tenantContextHolder.setCurrentTenantId(tenant2)
val command2 = CreateUserCommand(
userId = userId, // Same user ID, different tenant
tenantId = tenant2,
email = "user@tenant2.com",
firstName = "Tenant2",
lastName = "User",
roles = setOf("USER"),
department = "Testing"
)
commandGateway.sendAndWait<Void>(command2)

// Wait for projections
await().atMost(Duration.ofSeconds(5))
.until {
userProjectionRepository.findByTenantId(tenant1).size == 1 &&
userProjectionRepository.findByTenantId(tenant2).size == 1
}

// Verify tenant isolation
val tenant1Users = userProjectionRepository.findByTenantId(tenant1)
val tenant2Users = userProjectionRepository.findByTenantId(tenant2)

assertThat(tenant1Users).hasSize(1)
assertThat(tenant2Users).hasSize(1)
assertThat(tenant1Users[0].email).isEqualTo("user@tenant1.com")
assertThat(tenant2Users[0].email).isEqualTo("user@tenant2.com")
}
}

๐Ÿ”’ Security Testingโ€‹

Authorization Testingโ€‹

@SpringBootTest
@TestMethodOrder(OrderAnnotation::class)
class UserSecurityIntegrationTest {

@Autowired
private lateinit var commandGateway: CommandGateway

@Test
@WithMockUser(roles = ["USER_MANAGEMENT"])
fun `should allow user creation with proper role`() {
val command = CreateUserCommand(
userId = "user-123",
tenantId = "tenant-abc",
email = "test@example.com",
firstName = "Test",
lastName = "User",
roles = setOf("USER"),
department = "Testing"
)

assertDoesNotThrow {
commandGateway.sendAndWait<Void>(command)
}
}

@Test
@WithMockUser(roles = ["READ_ONLY"])
fun `should reject user creation without proper role`() {
val command = CreateUserCommand(
userId = "user-124",
tenantId = "tenant-abc",
email = "test2@example.com",
firstName = "Test2",
lastName = "User",
roles = setOf("USER"),
department = "Testing"
)

assertThrows<AccessDeniedException> {
commandGateway.sendAndWait<Void>(command)
}
}
}

Tenant Isolation Testingโ€‹

@Test
fun `should prevent cross-tenant access`() {
val userId = "user-123"
val authorizedTenant = "tenant-authorized"
val unauthorizedTenant = "tenant-unauthorized"

// Create user in authorized tenant
tenantContextHolder.setCurrentTenantId(authorizedTenant)
val createCommand = CreateUserCommand(
userId = userId,
tenantId = authorizedTenant,
email = "test@example.com",
firstName = "Test",
lastName = "User",
roles = setOf("USER"),
department = "Testing"
)
commandGateway.sendAndWait<Void>(createCommand)

// Try to access from unauthorized tenant
tenantContextHolder.setCurrentTenantId(unauthorizedTenant)
val updateCommand = ChangeUserEmailCommand(
userId = userId,
newEmail = "hacker@example.com",
reason = "Malicious attempt"
)

assertThrows<IllegalStateException> {
commandGateway.sendAndWait<Void>(updateCommand)
}
}

๐Ÿ“Š Performance Testingโ€‹

Load Testing Setupโ€‹

@SpringBootTest
@Testcontainers
@ActiveProfiles("performance-test")
class UserPerformanceTest {

@Autowired
private lateinit var commandGateway: CommandGateway

@Test
fun `should handle concurrent user creation`() {
val numUsers = 100
val tenantId = "tenant-load-test"
val latch = CountDownLatch(numUsers)
val errors = CopyOnWriteArrayList<Exception>()

val startTime = System.currentTimeMillis()

// Create users concurrently
repeat(numUsers) { index ->
CompletableFuture.runAsync {
try {
val command = CreateUserCommand(
userId = "user-$index",
tenantId = tenantId,
email = "user$index@example.com",
firstName = "User",
lastName = "$index",
roles = setOf("USER"),
department = "Load Testing"
)
commandGateway.sendAndWait<Void>(command)
} catch (e: Exception) {
errors.add(e)
} finally {
latch.countDown()
}
}
}

// Wait for completion
assertThat(latch.await(30, TimeUnit.SECONDS)).isTrue()

val duration = System.currentTimeMillis() - startTime

// Verify results
assertThat(errors).isEmpty()
assertThat(duration).isLessThan(10000) // Less than 10 seconds

println("Created $numUsers users in ${duration}ms")
println("Throughput: ${numUsers * 1000.0 / duration} users/second")
}
}

Memory and Resource Testingโ€‹

@Test
fun `should not leak memory during event processing`() {
val runtime = Runtime.getRuntime()
val initialMemory = runtime.totalMemory() - runtime.freeMemory()

// Process many events
repeat(1000) { index ->
val command = CreateUserCommand(
userId = "user-$index",
tenantId = "tenant-memory-test",
email = "user$index@example.com",
firstName = "User",
lastName = "$index",
roles = setOf("USER"),
department = "Memory Testing"
)
commandGateway.sendAndWait<Void>(command)
}

// Force garbage collection
System.gc()
Thread.sleep(1000)

val finalMemory = runtime.totalMemory() - runtime.freeMemory()
val memoryIncrease = finalMemory - initialMemory

// Memory increase should be reasonable (less than 100MB)
assertThat(memoryIncrease).isLessThan(100 * 1024 * 1024)

println("Memory increase: ${memoryIncrease / (1024 * 1024)}MB")
}

๐ŸŽญ Acceptance Testingโ€‹

End-to-End Workflow Testingโ€‹

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("acceptance-test")
class UserWorkflowAcceptanceTest {

@Autowired
private lateinit var restTemplate: TestRestTemplate

@LocalServerPort
private var port: Int = 0

@Test
fun `complete user lifecycle workflow`() {
val userId = "user-${UUID.randomUUID()}"
val tenantId = "tenant-acceptance"

// Step 1: Create user
val createRequest = CreateUserRequest(
userId = userId,
email = "workflow@example.com",
firstName = "Workflow",
lastName = "User",
roles = setOf("USER"),
department = "Acceptance Testing"
)

val createResponse = restTemplate.exchange(
"/api/users",
HttpMethod.POST,
HttpEntity(createRequest, createHeaders(tenantId)),
String::class.java
)

assertThat(createResponse.statusCode).isEqualTo(HttpStatus.CREATED)

// Step 2: Verify user exists
val getResponse = restTemplate.exchange(
"/api/users/$userId",
HttpMethod.GET,
HttpEntity.EMPTY,
UserResponse::class.java
)

assertThat(getResponse.statusCode).isEqualTo(HttpStatus.OK)
assertThat(getResponse.body?.email).isEqualTo("workflow@example.com")

// Step 3: Update user profile
val updateRequest = UpdateUserProfileRequest(
firstName = "Updated",
department = "New Department"
)

val updateResponse = restTemplate.exchange(
"/api/users/$userId/profile",
HttpMethod.PUT,
HttpEntity(updateRequest, createHeaders(tenantId)),
String::class.java
)

assertThat(updateResponse.statusCode).isEqualTo(HttpStatus.OK)

// Step 4: Assign additional role
val roleRequest = AssignRoleRequest(role = "MANAGER")

val roleResponse = restTemplate.exchange(
"/api/users/$userId/roles",
HttpMethod.POST,
HttpEntity(roleRequest, createHeaders(tenantId)),
String::class.java
)

assertThat(roleResponse.statusCode).isEqualTo(HttpStatus.OK)

// Step 5: Verify final state
val finalGetResponse = restTemplate.exchange(
"/api/users/$userId",
HttpMethod.GET,
HttpEntity(null, createHeaders(tenantId)),
UserResponse::class.java
)

val finalUser = finalGetResponse.body!!
assertThat(finalUser.firstName).isEqualTo("Updated")
assertThat(finalUser.department).isEqualTo("New Department")
assertThat(finalUser.roles).contains("USER", "MANAGER")
}

private fun createHeaders(tenantId: String): HttpHeaders {
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
headers.set("X-Tenant-ID", tenantId)
return headers
}
}

๐ŸŽฏ Testing Best Practicesโ€‹

1. Test Data Managementโ€‹

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserTestDataManager {

companion object {
fun createValidUser(
userId: String = "user-${UUID.randomUUID()}",
tenantId: String = "tenant-test",
email: String = "test@example.com"
) = CreateUserCommand(
userId = userId,
tenantId = tenantId,
email = email,
firstName = "Test",
lastName = "User",
roles = setOf("USER"),
department = "Testing"
)

fun createUserCreatedEvent(
userId: String = "user-${UUID.randomUUID()}",
tenantId: String = "tenant-test"
) = UserCreatedEvent(
userId = userId,
tenantId = tenantId,
email = "test@example.com",
firstName = "Test",
lastName = "User",
roles = setOf("USER"),
department = "Testing"
)
}
}

2. Custom Matchersโ€‹

// Custom assertion for events
fun assertThatEvent(event: Any): EventAssert {
return EventAssert(event)
}

class EventAssert(private val event: Any) {
fun hasEventType(expectedType: Class<*>): EventAssert {
assertThat(event).isInstanceOf(expectedType)
return this
}

fun hasUserId(expectedUserId: String): EventAssert {
when (event) {
is UserCreatedEvent -> assertThat(event.userId).isEqualTo(expectedUserId)
is UserEmailChangedEvent -> assertThat(event.userId).isEqualTo(expectedUserId)
else -> fail("Event type does not have userId field")
}
return this
}
}

3. Test Utilitiesโ€‹

object TestFixtures {

fun waitForProjectionUpdate(
repository: UserProjectionRepository,
userId: String,
condition: (UserProjection) -> Boolean,
timeout: Duration = Duration.ofSeconds(5)
) {
await().atMost(timeout)
.until {
repository.findById(userId)
.map(condition)
.orElse(false)
}
}

fun withTenantContext(tenantId: String, action: () -> Unit) {
val holder = ApplicationContextProvider.getBean(TenantContextHolder::class.java)
try {
holder.setCurrentTenantId(tenantId)
action()
} finally {
holder.clearContext()
}
}
}

๐Ÿ“ˆ Test Metrics and Reportingโ€‹

Coverage Configurationโ€‹

// build.gradle.kts
tasks.jacocoTestReport {
reports {
xml.required = true
html.required = true
}

executionData.setFrom(
fileTree(layout.buildDirectory.dir("jacoco")).include("**/*.exec")
)
}

tasks.jacocoTestCoverageVerification {
violationRules {
rule {
limit {
minimum = "0.80".toBigDecimal() // 80% coverage minimum
}
}

rule {
element = "CLASS"
excludes = listOf("*.configuration.*", "*.dto.*")

limit {
counter = "BRANCH"
minimum = "0.70".toBigDecimal()
}
}
}
}

๐ŸŽฏ Testing Checklistโ€‹

Before deploying your Axon components, ensure:

  • โœ… All aggregate business rules have unit tests
  • โœ… Command validation scenarios are covered
  • โœ… Event sourcing handlers work correctly
  • โœ… Projections update properly with events
  • โœ… Multi-tenant isolation is enforced
  • โœ… Security authorization works as expected
  • โœ… Error handling behaves correctly
  • โœ… Performance meets requirements
  • โœ… Integration with EAF infrastructure works
  • โœ… End-to-end workflows complete successfully

๐Ÿš€ Next Stepsโ€‹

You've mastered testing strategies for Axon Framework. Next, let's explore advanced operational topics:

Next Module: Performance & Operations โ†’

Topics covered next:

  • Monitoring and observability
  • Performance tuning and optimization
  • Deployment strategies
  • Troubleshooting common issues

๐Ÿ’ก Remember: Good tests are the foundation of reliable event-sourced systems. Invest in comprehensive testing to ensure confidence in your implementations!