con un clic
projection-test-builder
Knows how to generate test cases for commands and projections
Menú
Knows how to generate test cases for commands and projections
| name | projection test builder |
| description | Knows how to generate test cases for commands and projections |
This skill generates projection integration tests from STATE_VIEW slice specifications. It reads the specification's given/then structure and creates Kotlin tests that verify the read model projection behavior using the Axon Framework event sourcing pattern.
Use this skill when:
given (events) and then (expected read model state)The slice JSON contains specifications with this structure:
{
"specifications": [
{
"title": "spec: cart items with removed item",
"given": [
{
"title": "Cart Created",
"type": "SPEC_EVENT",
"fields": [{ "name": "aggregateId", "type": "UUID", "example": "" }]
},
{
"title": "Item Added",
"type": "SPEC_EVENT",
"fields": [
{ "name": "aggregateId", "type": "UUID" },
{ "name": "description", "type": "String" },
{ "name": "price", "type": "Double", "example": "9.99" }
]
}
],
"when": [],
"then": [
{
"title": "cart items",
"type": "SPEC_READMODEL",
"fields": [
{ "name": "aggregateId", "type": "UUID", "idAttribute": true },
{ "name": "totalPrice", "type": "Double", "example": "19.98" }
]
}
],
"comments": [{ "description": "Read Model should display an empty list" }]
}
]
}
Location Pattern: src/test/kotlin/de/alex/<module>/<readmodelname>/integration/<ReadModelName>ProjectionTest.kt
<module> from the readmodel's domain or context field (lowercase)<readmodelname> from the readmodel's title (convert to lowercase, no spaces)src/test/kotlin/de/alex/cart/cartitems/integration/CartItemsProjectionTest.ktTransform event titles to Kotlin event class names:
ItemAddedEventCartCreatedEventItemRemovedEventPattern: Remove spaces, append "Event" suffix.
Look at the aggregate or aggregateDependencies field in the readmodel to identify which aggregate to use:
"aggregate": "Cart",
"aggregateDependencies": ["Cart"]
This becomes CartAggregate for the repository.
Template:
/* (C)2024 */
package de.alex.<module>.<readmodelname>.integration
import de.alex.<module>.<readmodelname>.<ReadModelName>ReadModel
class <ReadModelName>ProjectionTest : BaseIntegrationTest() {
@Autowired private lateinit var commandGateway: CommandGateway
@Autowired private lateinit var queryGateway: QueryGateway
@Autowired private lateinit var repository: Repository<<Aggregate>Aggregate>
// Generate one test method per specification
}
For each specification in specifications[]:
@Test
fun `<spec title>`() {
// Generate UUIDs for id fields
val aggregateId = UUID.randomUUID()
// Generate additional UUIDs for other id fields (itemId, productId, etc.)
var fixture =
ProjectionFixtureConfiguration.aggregateInstance {
repository.newInstance { <Aggregate>Aggregate() }
}
// Apply events from given[] in index order
fixture.given(
*listOf(
// For each event in given[], sorted by index:
RandomData.newInstance<<EventName>Event> {
// Set fields from the spec, using example values or generated UUIDs
this.aggregateId = aggregateId
// Map other fields
},
).toTypedArray(),
)
fixture.apply()
// Verify read model state from then[]
awaitUntilAssserted {
var readModel =
queryGateway.query(
<ReadModelName>ReadModelQuery(aggregateId),
<ReadModelName>ReadModel::class.java
)
// Assert based on then[] specification
// If comments mention "empty list" → assertThat(readModel.get().data).isEmpty()
// Otherwise assert expected field values
}
}
idAttribute: true should use shared UUIDs across related eventsval aggregateId = UUID.randomUUID()itemId, productId), generate eachexample value (e.g., "example": "9.99"), use that value for assertions"9.99" for Double → 9.99"19.98" for Double → 19.98mapping field indicates the source field: "mapping": "productId" means map from productIdWhen comments contain "empty list" or the then[] result should be empty:
assertThat(readModel.get().data).isEmpty()
When multiple items expected:
assertThat(readModel.get().data).hasSize(2)
When then[] has specific field examples:
assertThat(readModel.get().data.totalPrice).isEqualTo(19.98)
Basic non-null check:
assertThat(readModel.get()).isNotNull
Input Spec:
{
"title": "spec: cart items with cleared cart",
"given": [
{ "title": "Cart Created", "index": 0, "fields": [{"name": "aggregateId", "type": "UUID"}] },
{ "title": "Item Added", "index": 1, "fields": [...] },
{ "title": "Cart Cleared", "index": 2, "fields": [{"name": "aggregateId", "type": "UUID"}] }
],
"then": [
{ "title": "cart items", "type": "SPEC_READMODEL", "fields": [...] }
],
"comments": [{ "description": "Read Model should display an empty list" }]
}
Generated Test:
@Test
fun `spec cart items with cleared cart`() {
val aggregateId = UUID.randomUUID()
val itemId = UUID.randomUUID()
val productId = UUID.randomUUID()
var fixture =
ProjectionFixtureConfiguration.aggregateInstance {
repository.newInstance { CartAggregate() }
}
fixture.given(
*listOf(
RandomData.newInstance<CartCreatedEvent> {
this.aggregateId = aggregateId
},
RandomData.newInstance<ItemAddedEvent> {
this.aggregateId = aggregateId
this.itemId = itemId
this.productId = productId
},
RandomData.newInstance<CartClearedEvent> {
this.aggregateId = aggregateId
},
).toTypedArray(),
)
fixture.apply()
awaitUntilAssserted {
var readModel =
queryGateway.query(
CartItemsReadModelQuery(aggregateId),
CartItemsReadModel::class.java
)
assertThat(readModel.get().data).isEmpty()
}
}
When a slice has multiple specifications, generate one test method per spec:
class CartItemsProjectionTest : BaseIntegrationTest() {
@Autowired private lateinit var commandGateway: CommandGateway
@Autowired private lateinit var queryGateway: QueryGateway
@Autowired private lateinit var repository: Repository<CartAggregate>
@Test
fun `spec cart items`() {
// Test for adding items - expects 2 items in list
}
@Test
fun `spec cart items with removed item`() {
// Test for removing item - expects empty list
}
@Test
fun `spec cart items with cleared cart`() {
// Test for clearing cart - expects empty list
}
@Test
fun `spec cart items with archived items`() {
// Test for archiving - expects item removed
}
}
When the given[] has multiple events with the same title (e.g., two "Item Added" events):
itemId for each)aggregateId to link themval itemId1 = UUID.randomUUID()
val itemId2 = UUID.randomUUID()
fixture.given(
*listOf(
RandomData.newInstance<CartCreatedEvent> { this.aggregateId = aggregateId },
RandomData.newInstance<ItemAddedEvent> {
this.aggregateId = aggregateId
this.itemId = itemId1
this.price = 9.99
},
RandomData.newInstance<ItemAddedEvent> {
this.aggregateId = aggregateId
this.itemId = itemId2
this.price = 9.99
},
).toTypedArray(),
)
When an event removes/archives an item, the subsequent event should reference the same ID:
// Item Added with itemId1
RandomData.newInstance<ItemAddedEvent> {
this.aggregateId = aggregateId
this.itemId = itemId1
},
// Item Removed referencing the same itemId1
RandomData.newInstance<ItemRemovedEvent> {
this.aggregateId = aggregateId
this.itemId = itemId1
},
Ensure these imports are available:
de.alex.common.support.BaseIntegrationTestde.alex.common.support.ProjectionFixtureConfigurationde.alex.common.support.RandomDatade.alex.common.support.awaitUntilAsssertedde.alex.events.*de.alex.domain.*