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.

Builder style

import de.dwittkoetter.ddd.eventsourcing.test.upcasting.eventDowncasterTest

private val downcasting = eventDowncasterTest {
    downcasters(AccountOpenedDowncasters())
    events(AccountOpened::class)
}

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.

Builder style

import de.dwittkoetter.ddd.eventsourcing.test.upcasting.snapshotUpcasterTest

private val fixture = snapshotUpcasterTest {
    upcasters(BankAccountSnapshotUpcasters())
    aggregates(BankAccount::class)
}