Testing schema evolution
Upcasters and downcasters and
snapshot upcasters run on every aggregate load and projection
replay, so a silent bug in an upcaster body will surface far from its source.
The spring-ddd-eventsourcing-test fixture library provides dedicated test DSLs that drive the
real production upcaster chain and Jackson serialiser — no application context, no database, and
no re-implementation of the transformation logic.
Dependency
testImplementation("de.dwittkoetter:spring-ddd-starter-eventsourcing-test:0.0.1-SNAPSHOT") (1)
| 1 | Transitively brings spring-ddd-eventsourcing, the JUnit 5 API, and the Spring Boot BOM. |
Testing upcasters
Two entry styles produce the same EventUpcasting fixture.
Annotation style
@UpcasterTest registers upcaster classes by KClass reference.
The UpcasterTestExtension instantiates each class via its no-argument constructor and injects an
EventUpcasting parameter into each test method:
import de.dwittkoetter.ddd.eventsourcing.test.upcasting.EventUpcasting
import de.dwittkoetter.ddd.eventsourcing.test.upcasting.UpcasterTest
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
@UpcasterTest(
upcasters = [AccountOpenedUpcasters::class], (1)
events = [AccountOpened::class], (2)
)
class AccountOpenedUpcasterTest {
@Test
fun `upcasts a rev-0 payload to the current rev-2 shape`(upcasting: EventUpcasting) { (3)
upcasting
.given("banking.AccountOpened", 0, """{"accountId":"acc-1","ccy":"EUR"}""") (4)
.producesType("banking.AccountOpened") (5)
.appliesSteps(2) (6)
.upcastsTo<AccountOpened>() (7)
.satisfying { it.currency shouldBe "EUR" }
}
@Test
fun `upcaster chain is complete for AccountOpened`(upcasting: EventUpcasting) {
upcasting.assertChainComplete(AccountOpened::class) (8)
}
}
| 1 | One or more beans whose @EventUpcaster methods are discovered by reflection.
Each class must have a no-argument constructor; use the builder style when a collaborator is needed. |
| 2 | Event classes registered with the production serialiser so upcastsTo can deserialise the upcasted
payload.
For a renamed event, register the post-rename target class (the old class may no longer exist). |
| 3 | EventUpcasting is injected by the extension; the test needs no Spring context. |
| 4 | given feeds the stored event type, its stored revision, and the raw JSON payload into the production
chain.
The rev-0 payload carries ccy (the field name before the rev 1→2 rename) and has no owner yet. |
| 5 | Asserts the post-upcast event-type identifier; particularly useful for type-rename upcasters. |
| 6 | Asserts that exactly two upcaster steps applied: rev 0→1 (seeds owner) and rev 1→2 (renames ccy
to currency). |
| 7 | Asserts the upcasted type identifier resolves to AccountOpened and that the payload deserialises
successfully; returns a typed TypedEventUpcastCase<AccountOpened> for value assertions. |
| 8 | Delegates to the production EventUpcasterChainValidator: fails with a clear error if any revision
step between 0 and @EventRevision(2) is missing or duplicated. |
Builder style
When an upcaster needs constructor arguments, or when you prefer a plain Kotlin property, build the
fixture directly with eventUpcasterTest:
import de.dwittkoetter.ddd.eventsourcing.test.upcasting.eventUpcasterTest
private val upcasting = eventUpcasterTest {
upcasters(AccountOpenedUpcasters()) (1)
events(AccountOpened::class)
}
| 1 | Pass live instances rather than KClass references; inject collaborators through the constructor
before passing the bean. |
Split upcasters
Use givenSplit in place of given when the upcaster under test produces more than one event.
AccountOpenedSplitter on the Schema evolution page maps
a stored banking.AccountOpenedWithDeposit event into two separate events:
upcasting.givenSplit(
"banking.AccountOpenedWithDeposit", 0,
"""{"accountId":"acc-1","initialDeposit":"100"}""")
.producesCount(2)
.producesTypes("banking.AccountOpened", "banking.MoneyDeposited")
givenSplit also covers the drop case: a upcaster returning emptyList() is asserted with
.isDropped().
Testing downcasters
Downcasters are exercised with @DowncasterTest or eventDowncasterTest.
The fixture uses the production EventRevisionAligner, which applies downcasters when it encounters a
stored revision higher than the class’s current declared revision.
Annotation style
import de.dwittkoetter.ddd.eventsourcing.test.upcasting.DowncasterTest
import de.dwittkoetter.ddd.eventsourcing.test.upcasting.EventDowncasting
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
@DowncasterTest(
downcasters = [AccountOpenedDowncasters::class], (1)
events = [AccountOpened::class],
)
class AccountOpenedDowncasterTest {
@Test
fun `aligns a future rev-3 event down to current rev-2`(downcasting: EventDowncasting) {
downcasting
.given(
"banking.AccountOpened", 3,
"""{"accountId":"acc-1","owner":"unknown","currency":"EUR","extra":"x"}""" (2)
)
.producesType("banking.AccountOpened") (3)
.producesRevision(2) (4)
.downcastsTo<AccountOpened>() (5)
.satisfying { it.owner shouldBe "unknown" }
}
@Test
fun `unbridged future revision throws`(downcasting: EventDowncasting) {
downcasting.givenUnbridged("banking.AccountOpened", 9, """{}""") (6)
}
}
| 1 | Bean classes whose @EventDowncaster methods are discovered; no-argument constructor required. |
| 2 | The stored payload is at the future revision (3); the v3ToV2 downcaster drops the rev-3-only extra field, leaving a payload the current revision-2 AccountOpened can deserialise. |
| 3 | The post-downcast type identifier; set toEventType on the @EventDowncaster method annotation to assert a reverse rename. |
| 4 | Asserts the aligner reached the current declared revision of AccountOpened (@EventRevision(2)). |
| 5 | Asserts the downcast payload deserialises to AccountOpened. |
| 6 | Asserts that an unbridged future revision throws FutureEventRevisionException rather than succeeding
silently — verifies the two-phase deploy precondition. |
Testing snapshot upcasters
Snapshot upcasters use the same @UpcasterTest annotation, but the test method declares a
SnapshotUpcasting parameter instead of EventUpcasting — the extension selects the correct fixture
automatically based on the declared parameter type.
Annotation style
import de.dwittkoetter.ddd.eventsourcing.test.upcasting.SnapshotUpcasting
import de.dwittkoetter.ddd.eventsourcing.test.upcasting.UpcasterTest
import org.junit.jupiter.api.Test
@UpcasterTest(
upcasters = [BankAccountSnapshotUpcasters::class], (1)
aggregates = [BankAccount::class], (2)
)
class BankAccountSnapshotUpcasterTest {
@Test
fun `upcasts a rev-1 snapshot to the current rev-2 shape`(fixture: SnapshotUpcasting) { (3)
fixture
.given("banking.BankAccount", 1, """{"id":"acc-1","balance":"0"}""")
.upcast() (4)
.shouldHaveRevision(2) (5)
.shouldContainField("overdraftLimit") (6)
}
@Test
fun `snapshot upcaster chain is complete for BankAccount`(fixture: SnapshotUpcasting) {
fixture.assertChainComplete(BankAccount::class) (7)
}
}
| 1 | BankAccountSnapshotUpcasters declares the 1→2 step that seeds the overdraftLimit field. |
| 2 | Aggregate classes made available to the no-argument assertChainComplete() overload; unused when
each test calls assertChainComplete(BankAccount::class) directly. |
| 3 | Declaring SnapshotUpcasting (not EventUpcasting) tells the extension to build a snapshot
fixture from aggregates rather than an event fixture from events. |
| 4 | Runs the SnapshotUpcasterChain from the stored revision to the highest reachable revision. |
| 5 | Asserts the chain settled at revision 2, matching @SnapshotRevision(2) on BankAccount. |
| 6 | Asserts the seeded field is present in the upcasted JSON. |
| 7 | Delegates to SnapshotUpcasterChainValidator — fails if any step between 0 and @SnapshotRevision(2)
is missing or duplicated. |