Testland
Browse all skills & agents

event-sourcing-tests

Build event-sourcing + CQRS tests - the given-events / when-command / then-events aggregate test, replay determinism (same events produce the same state), event-versioning + upcasting, snapshot equivalence (replay-to-N vs snapshot-at-N must agree), retroactive event correction, and CQRS read-model projection tests (per-event projection deltas, idempotent + out-of-order apply, rebuild + zero-downtime swap, read-your-writes guard) with eventual-consistency convergence-window assertions in references/convergence-windows.md. Per martinfowler.com EventSourcing + CQRS references. Use when an event-sourced aggregate gains a new event type or a changed payload schema, when snapshots are introduced to shorten replay, when a read model is projected from the event stream, or when a documented convergence window needs a test.

Install with skills.sh (any agent)

npx skills add testland/qa --skill event-sourcing-tests
View source

event-sourcing-tests

Tests for an event-sourced system verify replay determinism, snapshot equivalence, and version-evolution correctness - without them, the event log silently drifts from the rebuilt state.

When to use

  • Domain model is event-sourced (orders, accounts, inventory).
  • Audit / compliance requirements demand event log as system of record.
  • Adding a new event type or changing payload schema - retro-compat tests are mandatory.

How to use

  1. Write the aggregate's given-events / when-command / then-events test first (see Worked example) - it is the base every other check builds on.
  2. Add a replay-determinism assertion: replay the same log twice and assert identical state; this catches time.now() / random-ID leaks at the source.
  3. When snapshots are introduced, assert snapshot-at-N equals replay-to-N - see references/snapshot-versioning-projections.md.
  4. When an event type or payload schema changes, add versioned upcasters and a mixed-version replay test - see the references (versioning + upcasting).
  5. For read models, add the projection tests below - determinism, idempotent + out-of-order apply, rebuild + swap - and assert the documented convergence window per references/convergence-windows.md.
  6. Gate appends on optimistic concurrency (expected version) and suppress external calls in replay mode - see the references (concurrency, replay mode).

Worked example - one event-sourced aggregate, end to end

The base test for any event-sourced aggregate is given-events / when-command / then-events: seed the aggregate with its prior events, handle one command, then assert the events it emits and the resulting state. Per Fowler - Event Sourcing (opens in new window), replay = "rebuild application state from scratch by replaying events in order."

def test_confirm_order_emits_order_confirmed():
    # GIVEN - the aggregate's prior event history
    history = [
        OrderCreated(order_id="o1", customer="c1"),
        ItemAdded(order_id="o1", sku="sku1", qty=2),
    ]
    order = OrderAggregate.replay(history)

    # WHEN - one command is handled
    new_events = order.handle(ConfirmOrder(order_id="o1"))

    # THEN - assert the emitted events, not only the final state
    assert new_events == [OrderConfirmed(order_id="o1")]

    # AND replay is deterministic: the same log rebuilds the same state
    state_a = OrderAggregate.replay(history + new_events)
    state_b = OrderAggregate.replay(history + new_events)
    assert state_a == state_b
    assert state_a.status == "confirmed"
    assert state_a.line_items == [("sku1", 2)]

If handle emits events derived from time.now() or random IDs, state_a == state_b fails - the test catches non-deterministic replay before it drifts the log from the rebuilt state.

Once this base test is green, layer on the operational checks - order independence within causality, snapshot equivalence, event versioning + upcasting, projection rebuild, retroactive correction, replay-mode side-effect suppression, and optimistic-concurrency appends - in references/snapshot-versioning-projections.md.

Projection rebuild (CQRS read models)

Per Fowler - CQRS (opens in new window), the read model is rebuilt from the write model's event stream; test it like the aggregate - deterministic, idempotent, rebuildable:

  1. Determinism - apply_all(events) twice yields the same materialized state; current-time / random-ID reads in apply break it.
  2. Per-event delta - each event type produces one well-defined change (parameterize: ProductPriceChanged{"sku1.price": 120}, etc.).
  3. Idempotent apply - the same event applied twice leaves state unchanged (track applied event IDs per projection).
  4. Out-of-order delivery - the projection buffers or versions when Updated arrives before Created; if it assumes in-order (Kafka per-partition), test that assumption end-to-end.
  5. Rebuild + zero-downtime swap - rebuild from a known event range and compare to a fixture; at the swap point assert new_proj.materialize() == old_proj.materialize() before switching reads. Full swap mechanic + the read-your-writes guard (202 Accepted → pending → active) are in references/rebuild-swap-and-read-your-writes.md.
  6. Convergence window - async projections lag; document the window ("read model converges within 5s of write") and assert it with the deadline + poll + assert pattern in references/convergence-windows.md, which also covers monotonic-read and bounded-staleness assertions.

Test each projection off one stream independently (search index, materialized SQL view, OLAP cube) so a flawed one doesn't mask the others.

Anti-patterns

Anti-patternWhy it failsFix
Replay calls time.now() / random IDsNon-deterministicMake replay deterministic (Worked example)
Skip snapshot equivalence testSnapshots silently divergeAssert snapshot-at-N == replay-to-N (references)
No upcasting plan; rewrite event store on schema changeAudit loss; downtimeVersioned upcasters (references)
Real email/HTTP calls during replayDuplicate side effectsReplay-mode flag (references)
Append without expected versionLost updates from concurrent writersOptimistic concurrency (references)
Skip the convergence-window test"I changed it but the UI shows the old value"Assert the window (references/convergence-windows.md)
Treat the projection as always-currentSubtle stale reads in prodDocument + assert the window
No rebuild test for a projectionSchema migration becomes riskyRebuild + swap tests (projection section)

Limitations

  • Event-store implementations vary widely (EventStoreDB, Kafka, Postgres). Test against the actual store.
  • Snapshot strategy choice (every N events, every X minutes) has performance implications outside this skill's scope.
  • Cross-aggregate transactions are not part of event sourcing - use sagas (saga-transaction-tests) for those.

References

Convergence-window tests

View source (opens in new window)

Convergence-window tests

"Eventually consistent" is untestable without a bound. These tests bind the window and assert convergence - for async projections, multi-region replication, and any read model that lags its write model. Per Fowler - CQRS (opens in new window), CQRS pairs naturally with "event-based systems and eventual consistency" - so the window is part of the contract, and the test asserts the contract.

Define the window per workflow

Document target windows first; each becomes one test:

WorkflowTarget windowSource
Read model after a command≤ 5sSLA
Cart update visibility across regions≤ 2s P95SLA
Search index update after product change≤ 30sProduct spec
Audit log replication to backup region≤ 60sCompliance

The canonical assertion - deadline + poll + assert

def test_projection_catches_up_within_5_seconds():
    """SLA: read model converges within 5s of write."""
    write_model.execute(ChangePriceCommand(sku="sku1", new=150))

    deadline = time.time() + 5.0
    while time.time() < deadline:
        if read_model.get_price("sku1") == 150:
            return  # converged in time
        time.sleep(0.1)

    pytest.fail("Read model did not converge within 5s")

The exact window is per-system; the pattern is always deadline + poll + assert. When the projection is synchronous (same DB transaction) no window exists - this test does not apply. On failure, check consumer lag / message-bus backlog before blaming the projection.

The same shape covers cross-region replication - write in one region, poll the other:

def test_cart_update_converges_within_2s_across_regions():
    cart_service_us.add_item(user_id="u1", sku="sku1")
    deadline = time.time() + 2.0
    while time.time() < deadline:
        if any(i.sku == "sku1" for i in cart_service_eu.get(user_id="u1").items):
            return
        time.sleep(0.05)
    pytest.fail("Cart did not converge across regions within 2s")

Monotonic-read test

A session must never see a value older than one it already read:

def test_monotonic_reads_per_session():
    session = client.connect(read_preference="monotonic")
    initial = session.get("counter")
    for _ in range(100):
        v = session.get("counter")
        assert v >= initial, f"Read regressed: {initial} -> {v}"

Bounded-staleness assertion

Distinct from the convergence window: "all reads no more than X seconds stale":

def test_bounded_staleness_under_2_seconds():
    leader.write("counter", time.time())
    time.sleep(2.5)  # exceed the bound
    for replica in replicas:
        staleness = time.time() - float(replica.read("counter"))
        assert staleness <= 2.0, f"Replica {replica} stale by {staleness:.2f}s"

Anti-patterns

Anti-patternWhy it failsFix
"Eventually consistent" with no time boundUntestable; can hangDefine + assert a window
Read-after-write expecting immediate freshnessDefeats async replicationTest the contracted window
Quiet-test-bench windows onlyConvergence degrades under loadTest under realistic concurrency
Single-region cluster in testsCross-region drift never surfacesMulti-region setup or simulation

Limitations

  • Real convergence depends on load, network, and clock drift; bench results don't predict prod.
  • Some stores offer strong-read modes that bypass eventual semantics - verify which mode the test exercises.

References

Projection rebuild/swap and read-your-writes tests

View source (opens in new window)

Projection rebuild/swap and read-your-writes tests

Deep variants for event-sourcing-tests' projection-rebuild section (zero-downtime swap and the read-your-writes guard). The SKILL.md spine keeps the minimal rebuild test inline; these are the longer worked tests.

Zero-downtime swap mechanic

Stand up the new projection in parallel, catch it up from the event log, verify it matches the old projection at the swap point, subscribe it to the live stream, then switch reads:

def test_zero_downtime_swap():
    # Stand up new projection in parallel
    new_proj = SearchIndexProjectionV2()
    catchup_from_event_log(new_proj, until=current_position)

    # Verify new matches old at the swap point
    assert new_proj.materialize() == old_proj.materialize()

    # Subscribe new to live event stream
    subscribe(new_proj)
    # Switch reads to new - verify no read returns stale state
    swap_query_target(old_proj, new_proj)

Read-your-writes guard

The UI either waits for the projection to catch up, or returns a synthetic "pending" state from the write model until the projection converges:

def test_post_command_returns_pending_until_projection_catches_up():
    response = api_client.post("/products", {"name": "Phone"})
    assert response.status == 202  # Accepted

    # Get returns "pending" until projection updates
    get1 = api_client.get(f"/products/{response.body['id']}")
    assert get1.body["status"] == "pending"

    wait_for_projection_to_catch_up(timeout=5)

    get2 = api_client.get(f"/products/{response.body['id']}")
    assert get2.body["status"] == "active"

Event-sourcing tests - snapshots, versioning, projections, concurrency

View source (opens in new window)

Event-sourcing tests - snapshots, versioning, projections, concurrency

Deep reference for the event-sourcing-tests SKILL.md. Consult after the base given-events / when-command / then-events test (in SKILL.md) is green, when adding snapshots, evolving event schemas, rebuilding projections, or hardening appends against concurrent writers.

Order independence within causality

Within a single aggregate, events ARE causally ordered. Across aggregates, only causal events are ordered. Test the boundary:

def test_unrelated_aggregates_replay_independently():
    # Two orders; events interleaved in the log
    log = [
        OrderCreated("o1", "c1"),
        OrderCreated("o2", "c2"),
        ItemAdded("o2", "sku2", 1),
        ItemAdded("o1", "sku1", 2),
        OrderConfirmed("o2"),
        OrderConfirmed("o1"),
    ]

    o1 = OrderAggregate.replay(filter(lambda e: e.order_id == "o1", log))
    o2 = OrderAggregate.replay(filter(lambda e: e.order_id == "o2", log))

    assert o1.line_items == [("sku1", 2)]
    assert o2.line_items == [("sku2", 1)]

Snapshot equivalence

Snapshots cache replayed state at version N. Per Fowler - Event Sourcing (opens in new window), "Most implementations cache the current application state, using snapshots to avoid replaying thousands of events."

Test snapshot at version N == replay-to-version N:

def test_snapshot_equivalent_to_full_replay():
    events = [...]  # 1000 events

    full_replay = OrderAggregate.replay(events)
    snapshot = OrderAggregate.snapshot_at(events, version=500)
    after_snapshot = OrderAggregate.from_snapshot(snapshot).apply_from(events[500:])

    assert full_replay == after_snapshot

If snapshot diverges, snapshot-creation logic is broken or events post-snapshot apply differently than they did during snapshot creation.

Event versioning + upcasting

Schema evolves: ItemAdded(sku, qty)ItemAdded(sku, qty, unit_price). Old events lack unit_price - upcast on read.

def test_upcasting_v1_to_v2():
    v1_event = {"type": "ItemAdded", "version": 1, "sku": "sku1", "qty": 2}
    v2_event = upcast_v1_v2(v1_event)

    assert v2_event["version"] == 2
    assert v2_event["unit_price"] is None  # or default per business rule
    assert v2_event["sku"] == "sku1"
    assert v2_event["qty"] == 2

Test that replaying with a mix of v1 + v2 events produces the same state as if all were v2:

def test_replay_handles_mixed_event_versions():
    mixed = [v1_event, v2_event, v1_event]
    upgraded = [upcast(e) for e in mixed]

    state_via_upcast = OrderAggregate.replay(upgraded)
    assert state_via_upcast.line_items_count == 3

Projection rebuild from the event log

Read models (projections) are derived from events. Rebuild from scratch must produce the same result:

def test_projection_rebuild_idempotent():
    events = load_events()
    projection_a = build_projection(events)
    projection_b = build_projection(events)
    assert projection_a == projection_b

    # And rebuild from cleared state matches incremental update
    projection_c = SearchProjection()
    for evt in events:
        projection_c.apply(evt)
    assert projection_c == projection_a

When projection logic changes, drop the materialized view and rebuild from events - test the rebuild matches expectations.

Retroactive event correction

Per Fowler - Event Sourcing (opens in new window), "Incorrect past events can be reversed and corrected, with downstream consequences automatically recalculated."

def test_retroactive_correction_recomputes():
    # Original state: order o1 has 2 items
    events_v1 = [OrderCreated("o1"), ItemAdded("o1", "sku1", 2)]
    state_v1 = OrderAggregate.replay(events_v1)
    assert state_v1.total_items == 2

    # Discover ItemAdded was wrong (qty 5, not 2). Two strategies:
    # (a) Append correction event:
    events_v1.append(ItemQtyCorrected("o1", "sku1", new_qty=5))
    state_corrected = OrderAggregate.replay(events_v1)
    assert state_corrected.total_items == 5

    # (b) Or replace the original event in the log + replay:
    events_v2 = [OrderCreated("o1"), ItemAdded("o1", "sku1", 5)]
    state_replayed = OrderAggregate.replay(events_v2)
    assert state_replayed.total_items == 5

Strategy (a) preserves audit trail (corrections visible). Strategy (b) requires careful migration but produces a clean log. Tests verify both yield the right final state.

External system integration during replay

Per Fowler - Event Sourcing (opens in new window): "Gateways must distinguish between real processing and replay modes to avoid sending duplicate notifications or using stale data."

def test_replay_mode_suppresses_external_calls():
    email_gateway = MockEmailGateway()

    handler = OrderConfirmedHandler(email_gateway, mode="replay")
    handler.handle(OrderConfirmed("o1"))

    assert email_gateway.sent_count == 0  # replay mode = no real calls

def test_live_mode_invokes_external_calls():
    email_gateway = MockEmailGateway()

    handler = OrderConfirmedHandler(email_gateway, mode="live")
    handler.handle(OrderConfirmed("o1"))

    assert email_gateway.sent_count == 1

Optimistic concurrency on append

Append must check expected version:

def test_concurrent_append_rejected():
    events = [OrderCreated("o1")]
    store.append("o1", events, expected_version=0)  # OK; new version = 1

    # Two concurrent commands both load version=1 + try to append
    new_events_a = [ItemAdded("o1", "sku1", 1)]
    new_events_b = [ItemAdded("o1", "sku2", 1)]

    store.append("o1", new_events_a, expected_version=1)  # OK; new version = 2

    with pytest.raises(ConcurrencyConflict):
        store.append("o1", new_events_b, expected_version=1)  # already at 2