Test projections
Table of contents
Projections are pure functions: given a state and an event, they return a new state. That makes them straightforward to test — one interpretation at a time, without a replay of an entire event stream. Funes::ProjectionTestHelper ships a small set of methods for exactly that, so you can exercise each part of a projection in isolation.
Setup
Include Funes::ProjectionTestHelper in your test class and bind the projection under test with the projection macro:
# test/projections/outstanding_balance_projection_test.rb
class OutstandingBalanceProjectionTest < ActiveSupport::TestCase
include Funes::ProjectionTestHelper
projection OutstandingBalanceProjection
end
That gives you three methods: interpret, initial_state, and final_state. They all use the binding you declared, so you don’t have to repeat the projection class on every call.
The helper itself is a plain Ruby module — no test-framework-specific machinery — so the same include works in an RSpec example group:
# spec/projections/outstanding_balance_projection_spec.rb
RSpec.describe OutstandingBalanceProjection do
include Funes::ProjectionTestHelper
projection OutstandingBalanceProjection
end
The examples below use minitest assertions, but the helper calls themselves are identical under RSpec — swap in expect(...) matchers and you’re done.
Test event interpretations
interpret runs a single interpretation_for block in isolation. You pass an event instance and the state you want to start from (via the given: keyword). It returns the state that the interpretation produces:
test "issuing a debt sets the outstanding balance" do
event = Debt::Issued.new(amount: 5_000)
result = interpret(event, given: OutstandingBalance.new)
assert_instance_of OutstandingBalance, result
assert_equal 5_000, result.outstanding_balance
end
Because interpret returns the new state, you can fold a sequence of events with inject to simulate an event stream without the need to build one:
test "a payment reduces the outstanding balance" do
events = [ Debt::Issued.new(amount: 5_000),
Debt::PaymentReceived.new(amount: 1_000) ]
result = events.inject(OutstandingBalance.new) { |state, event| interpret(event, given: state) }
assert_equal 4_000, result.outstanding_balance
end
Test validation side effects
Because interpret returns the state object directly, you can assert on validations too:
test "balance cannot go negative" do
state = OutstandingBalance.new(outstanding_balance: 200)
event = Debt::PaymentReceived.new(amount: 500)
result = interpret(event, given: state)
assert_equal(-300, result.outstanding_balance)
refute result.valid?
end
The at: parameter
Every interpretation block receives a temporal reference — the at value — alongside the state and event. interpret defaults at: to Time.current, but you can supply a specific time when the interpretation logic depends on it:
test "records the payment date" do
payment_at = Time.new(2024, 3, 15, 12, 0, 0)
state = OutstandingBalance.new(outstanding_balance: 5_000)
event = Debt::PaymentReceived.new(amount: 1_000)
result = interpret(event, given: state, at: payment_at)
assert_equal payment_at, result.last_payment_at
end
You can also pass a Date instead of a Time — Funes coerces it to the beginning of that day.
Override the bound projection
When a single test needs to exercise a different projection, pass it explicitly with the projection: keyword:
result = interpret(event, given: state, projection: AlternateProjection)
initial_state and final_state accept the same keyword for the same reason.
Test the initial state
If your projection defines an initial_state block, use initial_state to test it in isolation:
test "initial state starts with a zero balance" do
assert_equal 0, initial_state.outstanding_balance
end
Pass an explicit at: when the initial state depends on the temporal reference:
test "initial state records the query time" do
at = Time.new(2024, 1, 1)
result = initial_state(at: at)
assert_equal at, result.recorded_as_of
end
Test the final state
If your projection defines a final_state block, use final_state to test it. Pass the state to finalize via given::
test "final state computes days since issuance" do
state = OutstandingBalance.new(since: Date.new(2024, 1, 1))
result = final_state(given: state, at: Time.new(2024, 3, 31))
assert_equal 90, result.days_in_effect
end
Put it all together
The helper hands you three primitives — interpret an event, build the initial state, apply the final state — and stays out of the way after that. How you organize your tests is entirely up to you and the conventions your team already follows. The example below mirrors the projection’s structure: one test per event type, plus tests for the initial and final state blocks. You are free to split tests across files, group them with describe blocks, or arrange them in the shape that reads best:
# test/projections/outstanding_balance_projection_test.rb
class OutstandingBalanceProjectionTest < ActiveSupport::TestCase
include Funes::ProjectionTestHelper
projection OutstandingBalanceProjection
test "initial state has zero balance" do
assert_equal 0, initial_state.outstanding_balance
end
test "Debt::Issued sets the balance" do
result = interpret(Debt::Issued.new(amount: 5_000), given: OutstandingBalance.new)
assert_equal 5_000, result.outstanding_balance
end
test "Debt::PaymentReceived reduces the balance" do
result = interpret(Debt::PaymentReceived.new(amount: 1_000),
given: OutstandingBalance.new(outstanding_balance: 5_000))
assert_equal 4_000, result.outstanding_balance
end
test "final state computes days in effect" do
state = OutstandingBalance.new(since: Date.new(2024, 1, 1))
result = final_state(given: state, at: Time.new(2024, 3, 31))
assert_equal 90, result.days_in_effect
end
end
To understand what a projection does and how to write one, see the Projection concept.