Build CQRS read-model projection tests - write-model + read-model consistency tests, projection-replay determinism, projection-versioning + zero-downtime swap, eventual-consistency-window assertions. Per martinfowler.com CQRS reference. Use when a read model is derived from a write-model event stream - adding a projection, migrating a projection schema, or chasing a "I changed it but the UI shows the old value" report.
79
99%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Per Fowler - CQRS, CQRS "splits commands and queries into distinct models." Read-model (projection) is rebuilt from the write-model's events. Tests verify the projection is correct + reproducible + consistent within the documented eventual-consistency window.
Per Fowler - CQRS, the read model is "optimized for reading and displaying information." Test that rebuilding from the same events yields the same projection state:
def test_projection_deterministic():
events = load_test_events()
proj_a = ProductCatalogProjection().apply_all(events)
proj_b = ProductCatalogProjection().apply_all(events)
assert proj_a.materialize() == proj_b.materialize()If apply_all references current time / random IDs, projection
isn't deterministic - fix.
Each event should produce one well-defined change in the read model:
@pytest.mark.parametrize("event,expected_delta", [
(ProductPriceChanged(sku="sku1", new=120), {"sku1.price": 120}),
(ProductDescUpdated(sku="sku1", desc="new"), {"sku1.desc": "new"}),
(ProductRetired(sku="sku1"), {"sku1.active": False}),
])
def test_event_updates_projection(event, expected_delta):
proj = ProductCatalogProjection({"sku1": {"price": 100, "desc": "old", "active": True}})
proj.apply(event)
materialized = proj.materialize()
for path, value in expected_delta.items():
sku, field = path.split(".")
assert materialized[sku][field] == valuePer Fowler - CQRS, CQRS pairs naturally with "event-based systems and eventual consistency." Document the window + test:
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")When the projection is async (via message bus), this is the canonical SLA test. If sync (in same DB transaction), no consistency window exists - different test pattern.
Verify: assert the read model converges within the documented window; if it does not, check consumer lag / message-bus backlog, then re-run.
CQRS often has many projections (search index, materialized SQL view, OLAP cube) per event stream. Test each independently:
def test_search_index_projection():
events = [...]
search = SearchIndexProjection().apply_all(events)
assert search.find("description LIKE '%phone%'") == [...]
def test_inventory_summary_projection():
events = [...]
summary = InventorySummaryProjection().apply_all(events)
assert summary.total_skus == 1234A flawed projection doesn't affect the others - test in isolation.
Schema migration of a projection ≈ rebuild from event log + swap. Test the rebuild produces correct state for a known event range:
def test_projection_rebuild_matches_known_state():
historical_events = load_events(date_range=(start, end))
rebuilt = ProductCatalogProjectionV2().apply_all(historical_events)
expected = json.loads(Path("tests/fixtures/catalog_at_end.json").read_text())
assert rebuilt.materialize() == expectedVerify: at the swap point, assert new_proj.materialize() == old_proj.materialize() before switching reads; if they diverge, replay
the events missing from the new projection, then re-run.
The zero-downtime swap mechanic (stand up in parallel, catch up, subscribe, switch reads) is in references/rebuild-swap-and-read-your-writes.md.
Distributed projections may receive duplicates. Apply must be idempotent (same final state when applied twice):
def test_event_idempotent_on_projection():
proj = ProductCatalogProjection()
proj.apply(ProductCreated("sku1", "Phone"))
proj.apply(ProductCreated("sku1", "Phone")) # duplicate
assert proj.materialize()["sku1"]["name"] == "Phone"
assert len(proj.materialize()) == 1 # not 2Track event IDs already applied per projection.
Async event delivery may reorder events. Test that the projection either handles reordering or correctly waits/buffers:
def test_projection_handles_out_of_order():
proj = ProductCatalogProjection()
# Out-of-order: Updated arrives before Created
proj.apply(ProductUpdated("sku1", new_name="Phone v2", expected_version=1))
proj.apply(ProductCreated("sku1", name="Phone v1", version=0))
# If projection requires in-order, it should buffer + apply correctly
assert proj.materialize()["sku1"]["name"] == "Phone v2"If your projection assumes in-order (e.g., Kafka per-partition), test the assumption holds end-to-end.
CQRS often breaks "read your own write" expectations. Tests verify the UI either:
Full worked test (202 Accepted -> pending -> active) is in references/rebuild-swap-and-read-your-writes.md.
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Skip eventual-consistency window test | Customer reports "I just changed the price; UI shows old" | Step 3 |
| Treat projection as "always current" with the write model | Subtle stale reads in prod | Document + assert window (Step 3) |
| Couple projection update to write transaction | Defeats CQRS scaling promise | Async projection (Step 3) |
| No rebuild test for projection | Schema migration becomes risky | Step 5 |
| Skip out-of-order test | Real systems reorder | Step 7 |
event-sourcing-tests - pairs
with CQRS for event-sourced write modelssaga-transaction-tests -
cross-aggregate writes feeding the same projectioneventual-consistency-tests -
window assertions across aggregates