Skip to content

Testing a projection

A projection carries its fold in a View, so the smallest test asks the view to evolve a state and asserts the result. No store, no subscription:

View<Boolean, AccountEvent> view = isUsernameClaimed("bob").view();
Boolean state = view.evolve(view.initialState(), new AccountRegistered("1", "bob"));
assertThat(state).isTrue();
state = view.evolve(state, new UsernameChanged("1", "alice"));
assertThat(state).isFalse();

Cover the type without a handler too. A projection returns the state unchanged for an event it does not handle, and a fold that accidentally resets instead is easy to write and invisible without that test.

Above that level, run the projection through a ProjectionRunner into a repository and assert what landed there. The subscription is asynchronous, so the assertion waits:

Map<String, String> store = new ConcurrentHashMap<>();
ViewStateRepository<String, String> repository = ViewStateRepository.create(store::get, store::put);
ProjectionRunner.agnostic(subscriptionModel, converter).project("current-name", currentName, repository);
eventStore.write("johan", converter.toCloudEvents(List.of(nameDefined("johan", "Johan Haleby"))));
await().untilAsserted(() -> assertThat(store.get("johan")).isEqualTo("Johan Haleby"));

currentName() and nameDefined(...) are local test factories over your own projection and event types, not part of Occurrent. The snippets on this page come from DocumentedProjectionTest and DocumentedProjectionKotlinTest in dsl/projection-dsl/blocking/src/test, which run in CI, so you can read the full setup there.

Read after write, for a synchronous projection

Section titled “Read after write, for a synchronous projection”

A projection in SYNCHRONOUS mode updates its read model inside execute(...), which is the whole reason the mode exists, so the test that proves it is the one with no waiting at all. Register the projection against the synchronous subscription model and read the store on the line after the write:

ProjectionRunner.agnostic(synchronousSubscriptions, converter).project("current-name", currentName, repository);
applicationService.execute("johan", state -> List.of(nameDefined("johan", "Johan Haleby")));
// No await: a synchronous projection is already updated when execute returns
assertThat(store.get("johan")).isEqualTo("Johan Haleby");

Reaching for await here would hide the bug you are testing for, because a waiting assertion passes whether the update was synchronous or merely fast.

A projection fed from RabbitMQ or Kafka is driven by acceptOrDefer(...), and your test can call that itself. Register against the PushSubscriptionModel, then hand it the events your listener would have handed it. No broker, no container:

ProjectionRunner.agnostic(pushModel, converter).project("order-status", projection, repository);
// Stand in for the listener
pushModel.acceptOrDefer(converter.toCloudEvent(new OrderPlaced("order-1", "The Pragmatic Programmer")));
pushModel.acceptOrDefer(converter.toCloudEvent(new OrderShipped("order-1")));
assertThat(store.get("order-1")).isEqualTo(new OrderStatusView("order-1", "The Pragmatic Programmer", "SHIPPED"));

The catch-up in front of a push feed is worth its own test, because that is where a rebuilt projection either replays its history or silently starts empty. Write history before registering, then assert the projection ends up with the state the whole history implies rather than only the events you pushed afterwards.

This one is a property rather than a snippet, and it is the strongest test in the set. The same projection descriptor can be run two ways, pushed through a subscription into a stored read model, or run over a query on demand. Both should give the same answer, so assert that instead of hand-writing the expected value twice:

// Push: subscription-fed into a store
ProjectionRunner.agnostic(subscriptionModel, converter).project("current-name", currentName(), repository);
write("johan", nameDefined("johan", "Johan"), nameWasChanged("johan", "Johan Haleby"));
// A second instance, so the pull side actually has to scope to one of them
write("eve", nameDefined("eve", "Eve"));
await().untilAsserted(() -> assertThat(store.get("johan")).isEqualTo("Johan Haleby"));
// Pull: the same descriptor run over a query, right now
DomainEventQueries<DomainEvent> queries = new DomainEventQueries<>(eventStore, converter);
assertThat(Projections.project(currentName(), queries, "johan")).isEqualTo(store.get("johan"));

A disagreement is a bug in one of the two paths, and this catches classes of mistake a single expected value never will, a filter that selects different events on replay than live, or an id derivation that only works when metadata is present.

Write a second instance, as above. With only one instance in the store the pull side’s scoping is a no-op, so the test cannot tell a correctly scoped fold from one that applies everything and happens to agree.

One thing to know. In Kotlin the on-demand fold is an extension, so it needs import org.occurrent.dsl.projection.blocking.project unless your test happens to sit in that package. From Java it’s the static Projections.project(projection, queries, instanceId), described under Reading on demand.

For a DCB projection the same pair is dcbSubscriptions.project(id, projection, repository) and dcbQueries.project(projection), and note that a single-instance projection stores its one slot under the subscription id rather than under any value from the events. Reading the wrong key gives a test that looks reasonable and always sees null. The projection-dsl example does this pairing for every one of its vignettes, in Java and Kotlin, for stream and DCB.