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-testsevent-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
How to use
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:
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-pattern | Why it fails | Fix |
|---|---|---|
Replay calls time.now() / random IDs | Non-deterministic | Make replay deterministic (Worked example) |
| Skip snapshot equivalence test | Snapshots silently diverge | Assert snapshot-at-N == replay-to-N (references) |
| No upcasting plan; rewrite event store on schema change | Audit loss; downtime | Versioned upcasters (references) |
| Real email/HTTP calls during replay | Duplicate side effects | Replay-mode flag (references) |
| Append without expected version | Lost updates from concurrent writers | Optimistic 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-current | Subtle stale reads in prod | Document + assert the window |
| No rebuild test for a projection | Schema migration becomes risky | Rebuild + swap tests (projection section) |
Limitations
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:
| Workflow | Target window | Source |
|---|---|---|
| Read model after a command | ≤ 5s | SLA |
| Cart update visibility across regions | ≤ 2s P95 | SLA |
| Search index update after product change | ≤ 30s | Product spec |
| Audit log replication to backup region | ≤ 60s | Compliance |
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-pattern | Why it fails | Fix |
|---|---|---|
| "Eventually consistent" with no time bound | Untestable; can hang | Define + assert a window |
| Read-after-write expecting immediate freshness | Defeats async replication | Test the contracted window |
| Quiet-test-bench windows only | Convergence degrades under load | Test under realistic concurrency |
| Single-region cluster in tests | Cross-region drift never surfaces | Multi-region setup or simulation |
Limitations
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_snapshotIf 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"] == 2Test 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 == 3Projection 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_aWhen 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 == 5Strategy (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 == 1Optimistic 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 2Related skills
outbox-pattern-test-author
Authors tests for the transactional outbox pattern: atomic DB-write-plus-event-insert in one transaction, relay/poller publishing with at-least-once delivery and consumer deduplication, insertion-order preservation, idempotent consumers, and relay failure/retry. Use when adding outbox infrastructure, changing the relay or poller, or auditing whether dual-write atomicity and at-least-once delivery guarantees hold under failure.
saga-transaction-tests
Build saga transaction tests - orchestration vs choreography variants, per-step compensating-action verification, partial-failure scenarios (Step 3 fails → Steps 1+2 must compensate), idempotency of compensations, outbox pattern for atomic DB-update + message-publish. Per microservices.io/saga; tests guard against ACD-without-Isolation anomalies. Use when a business transaction spans two or more services and the compensating paths for a mid-sequence failure have no coverage, or right after compensation logic changes.