From 2494450368782bdf788b2466b6715392123ffe79 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Mon, 6 Jul 2026 13:44:25 +0200 Subject: [PATCH] test(nexus): migrate hand-rolled decide/react tests onto the fixtures (#238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route pure decide-only and react-only tests through `nexus::testing::AggregateFixture` / `SagaFixture` — the intended given/when/then surface — instead of hand-rolling `AggregateRoot::new -> handle/react -> assert`. The fixture drives the real replay path for `given`, so each migrated test now also proves the decision behaves correctly *after rehydration*, not just on a bare root. Adds fixture usage to four more test files: - aggregate_root_tests: the 5 `Handle` decide tests -> given/when/then. - saga_tests: the 3 `React` tests -> `SagaFixture` (and asserts the projected intent via `then_expect_commands`, which the hand-rolled version never checked). - integration_test / newtype_aggregate_tests: invariant-rejection and rehydrate->state assertions -> fixture, using `given(history)` to replace the `commit_persisted` setup. One `commit_persisted`+version lifecycle test is kept hand-rolled in each file — `when` folds state but deliberately does not advance the version, so version progression is not the fixture's job. `then_expect_events` requires `EventOf: PartialEq + Debug`, so `CounterEvent` (aggregate_root) and the `UserEvent` tree (integration) gain a `PartialEq` derive. Local error types have no `PartialEq`, so rejections assert with `then_expect_error_matching`. Premise corrections (from reading the code, not the issue's line numbers): the two listed store-side targets are not migrated. The fixture is a closed assertion DSL that never returns the `AggregateRoot`, so tests needing its `version()` / raw state cannot use it — `property_tests`' `replay_events`/`apply_events_to` feed proptest properties that inspect the root and exercise the replay contract itself, and `repository_qa_tests`' equivalents no longer exist. Adds a "When NOT to use the fixture" section to the `src/testing.rs` module docs — the sole production change — recording the boundary: `replay`/version-contract tests, version/`commit_persisted` progression, and store-owned load/replay lifecycle all stay hand-rolled. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/nexus/src/testing.rs | 21 +++++ crates/nexus/tests/kernel.rs | 3 +- .../kernel_tests/aggregate_root_tests.rs | 69 +++++++------- .../tests/kernel_tests/integration_test.rs | 94 +++++++++---------- .../kernel_tests/newtype_aggregate_tests.rs | 74 +++++++-------- crates/nexus/tests/kernel_tests/saga_tests.rs | 60 +++++------- 6 files changed, 162 insertions(+), 159 deletions(-) diff --git a/crates/nexus/src/testing.rs b/crates/nexus/src/testing.rs index ad1e05ac..3a9357b7 100644 --- a/crates/nexus/src/testing.rs +++ b/crates/nexus/src/testing.rs @@ -5,6 +5,27 @@ //! validation), then [`when`](Given::when) calls [`Handle::handle`]. //! No store, codec, or serialization. See //! `docs/plans/2026-06-17-aggregate-test-fixture-design.md`. +//! +//! # When NOT to use the fixture +//! +//! The fixture is the surface for **pure decide/react logic** — asserting the +//! events, error, or resulting state a command/event produces. It is a closed +//! assertion DSL that never hands back the [`AggregateRoot`], so it is the wrong +//! tool when a test needs the root itself. Keep these hand-rolled: +//! +//! - **`replay` / version-contract tests.** Version-gap rejection, duplicate +//! versions, `MAX_REHYDRATION_EVENTS`, and `restore` all test the very +//! machinery `given` is built on — routing them through the fixture would be +//! circular. +//! - **Version / `commit_persisted` progression.** [`when`](Given::when) folds +//! decided events into state via `apply_events` but deliberately does *not* +//! advance the version (a decision never branches on persistence position). A +//! test that asserts `version() == Some(n)` after a commit is exercising the +//! persistence seam, not decide logic — drive it through `commit_persisted` +//! directly. +//! - **Store-owned load/replay lifecycle.** Repository / store tests that own +//! the full load → decide → persist cycle assert against real persisted +//! state, which the store-free fixture cannot represent. use crate::aggregate::{Aggregate, AggregateRoot, EventOf, Handle}; use crate::event::DomainEvent; diff --git a/crates/nexus/tests/kernel.rs b/crates/nexus/tests/kernel.rs index 2ae32f06..7a6f8dd2 100644 --- a/crates/nexus/tests/kernel.rs +++ b/crates/nexus/tests/kernel.rs @@ -7,7 +7,8 @@ clippy::shadow_unrelated, clippy::as_conversions, clippy::str_to_string, - reason = "test harness — relaxed lints for test code" + unused_must_use, + reason = "test harness — relaxed lints for test code; terminal `then_expect_*` fixture assertions intentionally drop the returned fixture" )] #[path = "kernel_tests/aggregate_root_tests.rs"] diff --git a/crates/nexus/tests/kernel_tests/aggregate_root_tests.rs b/crates/nexus/tests/kernel_tests/aggregate_root_tests.rs index 5e7ef5e6..94c2f704 100644 --- a/crates/nexus/tests/kernel_tests/aggregate_root_tests.rs +++ b/crates/nexus/tests/kernel_tests/aggregate_root_tests.rs @@ -11,6 +11,7 @@ use nexus::KernelError; use nexus::Message; use nexus::Version; use nexus::events; +use nexus::testing::AggregateFixture; // --------------------------------------------------------------------------- // Self-contained test domain @@ -31,7 +32,9 @@ impl AsRef<[u8]> for TestId { } } -#[derive(Debug, Clone)] +// `PartialEq` lets the decide tests assert produced events via the fixture's +// `then_expect_events` (which requires `EventOf: PartialEq + Debug`). +#[derive(Debug, Clone, PartialEq)] enum CounterEvent { Incremented, Decremented, @@ -265,59 +268,57 @@ fn replay_does_not_mutate_state_on_version_gap() { } // --------------------------------------------------------------------------- -// Tests: Handle trait (decide pattern) +// Tests: Handle trait (decide pattern) — driven through `AggregateFixture`, +// the canonical given/when/then surface for pure decide logic. The fixture +// runs the real replay path for `given`, so these also prove decide behaves +// correctly *after rehydration*, not just on a bare `new()` root. +// +// `CounterError` has no `PartialEq`, so rejection assertions use +// `then_expect_error_matching` (predicate, no bound) rather than +// `then_expect_error`. // --------------------------------------------------------------------------- #[test] fn handle_increment_returns_event() { - let counter = AggregateRoot::::new(TestId("1".into())); - let decided = counter.handle(Increment).unwrap(); - assert_eq!(decided.len(), 1); - assert!(matches!( - decided.iter().next(), - Some(CounterEvent::Incremented) - )); + AggregateFixture::::with_id(TestId("1".into())) + .given([]) + .when(Increment) + .then_expect_events([CounterEvent::Incremented]); } #[test] fn handle_increment_by_returns_event_with_amount() { - let counter = AggregateRoot::::new(TestId("1".into())); - let decided = counter.handle(IncrementBy { amount: 42 }).unwrap(); - assert_eq!(decided.len(), 1); - assert!(matches!( - decided.iter().next(), - Some(CounterEvent::IncrementedBy(42)) - )); + AggregateFixture::::with_id(TestId("1".into())) + .given([]) + .when(IncrementBy { amount: 42 }) + .then_expect_events([CounterEvent::IncrementedBy(42)]); } #[test] fn handle_rejects_invalid_command() { - let counter = AggregateRoot::::new(TestId("1".into())); - let err = counter.handle(Decrement).unwrap_err(); - assert!(matches!(err, CounterError::WouldGoNegative)); + AggregateFixture::::with_id(TestId("1".into())) + .given([]) + .when(Decrement) + .then_expect_error_matching(|e| matches!(e, CounterError::WouldGoNegative)); } #[test] fn handle_rejects_zero_increment() { - let counter = AggregateRoot::::new(TestId("1".into())); - let err = counter.handle(IncrementBy { amount: 0 }).unwrap_err(); - assert!(matches!(err, CounterError::ZeroIncrement)); + AggregateFixture::::with_id(TestId("1".into())) + .given([]) + .when(IncrementBy { amount: 0 }) + .then_expect_error_matching(|e| matches!(e, CounterError::ZeroIncrement)); } #[test] fn handle_uses_current_state_for_decision() { - let mut counter = AggregateRoot::::new(TestId("1".into())); - // Replay an increment so decrement becomes valid. - counter - .replay(Version::new(1).unwrap(), &CounterEvent::Incremented) - .unwrap(); - - let decided = counter.handle(Decrement).unwrap(); - assert_eq!(decided.len(), 1); - assert!(matches!( - decided.iter().next(), - Some(CounterEvent::Decremented) - )); + // Prior history (a replayed increment) makes `Decrement` valid — the + // decision reads the rehydrated state, exactly what the fixture's real + // `given` -> `when` path exercises. + AggregateFixture::::with_id(TestId("1".into())) + .given([CounterEvent::Incremented]) + .when(Decrement) + .then_expect_events([CounterEvent::Decremented]); } // --------------------------------------------------------------------------- diff --git a/crates/nexus/tests/kernel_tests/integration_test.rs b/crates/nexus/tests/kernel_tests/integration_test.rs index e85a0de0..2d84c33f 100644 --- a/crates/nexus/tests/kernel_tests/integration_test.rs +++ b/crates/nexus/tests/kernel_tests/integration_test.rs @@ -2,6 +2,7 @@ //! Uses #[derive(DomainEvent)] macro + full aggregate lifecycle //! with the Handle/decide pattern. +use nexus::testing::AggregateFixture; use nexus::*; use std::fmt; @@ -25,15 +26,17 @@ impl AsRef<[u8]> for UserId { } // --- Events (using derive macro!) --- -#[derive(Debug, Clone)] +// `PartialEq` lets the fixture-driven tests assert produced events exactly via +// `then_expect_events` (which requires `EventOf: PartialEq + Debug`). +#[derive(Debug, Clone, PartialEq)] struct UserCreated { name: String, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] struct UserActivated; -#[derive(Debug, Clone, nexus_macros::DomainEvent)] +#[derive(Debug, Clone, PartialEq, nexus_macros::DomainEvent)] enum UserEvent { Created(UserCreated), Activated(UserActivated), @@ -133,59 +136,48 @@ fn full_aggregate_lifecycle_with_handle() { assert_eq!(user.version(), Some(v2)); } +// Decide-after-rehydration, driven through `AggregateFixture`: `given` replays +// the `Created` event (the real rehydration path), the chained `then_expect_state` +// proves the rehydrated state, then `when(ActivateUser)` decides on top of it. +// Version progression through `commit_persisted` is covered by the kept +// `full_aggregate_lifecycle_with_handle` test and `aggregate_root_tests`. #[test] fn rehydrate_then_decide() { - let mut user = AggregateRoot::::new(UserId::new(2)); - user.replay( - Version::new(1).unwrap(), - &UserEvent::Created(UserCreated { name: "Bob".into() }), - ) - .unwrap(); - - assert_eq!(user.version(), Some(Version::new(1).unwrap())); - assert_eq!(user.state().name, "Bob"); - - // Decide after rehydration - let events = user.handle(ActivateUser).unwrap(); - assert_eq!(events.len(), 1); - - // Persist and advance - let v2 = Version::new(2).unwrap(); - user.commit_persisted(v2, &events); - - assert!(user.state().active); - assert_eq!(user.version(), Some(v2)); -} - + AggregateFixture::::with_id(UserId::new(2)) + .given([UserEvent::Created(UserCreated { name: "Bob".into() })]) + .then_expect_state(|s| assert_eq!(s.name, "Bob")) + .when(ActivateUser) + .then_expect_events([UserEvent::Activated(UserActivated)]) + .then_expect_state(|s| assert!(s.active)); +} + +// Invariant rejections are pure decide logic: the prior history that makes a +// command illegal is supplied via `given`, replacing the hand-rolled +// create/commit setup. `UserError` has no `PartialEq`, so rejection is asserted +// with `then_expect_error_matching`. #[test] -fn invariant_violations_return_domain_errors() { - let mut user = AggregateRoot::::new(UserId::new(3)); - - // Create the user - let events = user - .handle(CreateUser { +fn duplicate_create_is_rejected() { + AggregateFixture::::with_id(UserId::new(3)) + .given([UserEvent::Created(UserCreated { name: "Charlie".into(), + })]) + .when(CreateUser { + name: "David".into(), }) - .unwrap(); - user.commit_persisted(Version::new(1).unwrap(), &events); - - // Duplicate creation rejected - assert!(matches!( - user.handle(CreateUser { - name: "David".into() - }), - Err(UserError::AlreadyExists) - )); - - // Activate the user - let events = user.handle(ActivateUser).unwrap(); - user.commit_persisted(Version::new(2).unwrap(), &events); - - // Duplicate activation rejected - assert!(matches!( - user.handle(ActivateUser), - Err(UserError::AlreadyActive) - )); + .then_expect_error_matching(|e| matches!(e, UserError::AlreadyExists)); +} + +#[test] +fn duplicate_activate_is_rejected() { + AggregateFixture::::with_id(UserId::new(3)) + .given([ + UserEvent::Created(UserCreated { + name: "Charlie".into(), + }), + UserEvent::Activated(UserActivated), + ]) + .when(ActivateUser) + .then_expect_error_matching(|e| matches!(e, UserError::AlreadyActive)); } #[test] diff --git a/crates/nexus/tests/kernel_tests/newtype_aggregate_tests.rs b/crates/nexus/tests/kernel_tests/newtype_aggregate_tests.rs index 772eaa51..641cb9ba 100644 --- a/crates/nexus/tests/kernel_tests/newtype_aggregate_tests.rs +++ b/crates/nexus/tests/kernel_tests/newtype_aggregate_tests.rs @@ -2,6 +2,7 @@ //! generates. The aggregate is a bare marker; command handlers are pure //! associated functions on the marker, dispatched via `AggregateRoot::handle`. +use nexus::testing::AggregateFixture; use nexus::*; use std::fmt; @@ -132,48 +133,47 @@ fn marker_aggregate_lifecycle() { assert_eq!(user.version(), Some(v2)); } +// Invariant rejections are pure decide logic: the history that makes each +// command illegal is supplied via `given` (replacing the hand-rolled +// create/commit setup), then `when` decides on top of it. `UserError` has no +// `PartialEq`, so rejection is asserted with `then_expect_error_matching`. #[test] -fn marker_aggregate_invariant_enforcement() { - let mut user = AggregateRoot::::new(UserId::new(2)); - - let events = user.handle(CreateUser { name: "Bob".into() }).unwrap(); - user.commit_persisted(Version::new(1).unwrap(), &events); - - assert!(matches!( - user.handle(CreateUser { - name: "Charlie".into() - }), - Err(UserError::AlreadyExists) - )); - - let events = user.handle(ActivateUser).unwrap(); - user.commit_persisted(Version::new(2).unwrap(), &events); - - assert!(matches!( - user.handle(ActivateUser), - Err(UserError::AlreadyActive) - )); +fn marker_aggregate_rejects_duplicate_create() { + AggregateFixture::::with_id(UserId::new(2)) + .given([UserEvent::Created(UserCreated { name: "Bob".into() })]) + .when(CreateUser { + name: "Charlie".into(), + }) + .then_expect_error_matching(|e| matches!(e, UserError::AlreadyExists)); } +#[test] +fn marker_aggregate_rejects_duplicate_activate() { + AggregateFixture::::with_id(UserId::new(2)) + .given([ + UserEvent::Created(UserCreated { name: "Bob".into() }), + UserEvent::Activated(UserActivated), + ]) + .when(ActivateUser) + .then_expect_error_matching(|e| matches!(e, UserError::AlreadyActive)); +} + +// Rehydration parity through the fixture: `given` replays the aggregate's own +// history (the real replay path) and `then_expect_state` proves the folded state. Version +// progression is covered by the kept `marker_aggregate_lifecycle` test. #[test] fn marker_aggregate_rehydrate() { - let mut user = AggregateRoot::::new(UserId::new(3)); - user.replay( - Version::new(1).unwrap(), - &UserEvent::Created(UserCreated { - name: "Dave".into(), - }), - ) - .unwrap(); - user.replay( - Version::new(2).unwrap(), - &UserEvent::Activated(UserActivated), - ) - .unwrap(); - - assert_eq!(user.state().name, "Dave"); - assert!(user.state().active); - assert_eq!(user.version(), Some(Version::new(2).unwrap())); + AggregateFixture::::with_id(UserId::new(3)) + .given([ + UserEvent::Created(UserCreated { + name: "Dave".into(), + }), + UserEvent::Activated(UserActivated), + ]) + .then_expect_state(|s| { + assert_eq!(s.name, "Dave"); + assert!(s.active); + }); } #[test] diff --git a/crates/nexus/tests/kernel_tests/saga_tests.rs b/crates/nexus/tests/kernel_tests/saga_tests.rs index d8f0681d..d041364a 100644 --- a/crates/nexus/tests/kernel_tests/saga_tests.rs +++ b/crates/nexus/tests/kernel_tests/saga_tests.rs @@ -4,10 +4,8 @@ //! not: `correlate` returning `None` (defensive boundary), and `react` producing //! more than one own-event (`N > 0`). -use nexus::{ - Aggregate, AggregateRoot, AggregateState, DomainEvent, Events, Message, React, Saga, Version, - events, -}; +use nexus::testing::SagaFixture; +use nexus::{Aggregate, AggregateState, DomainEvent, Events, Message, React, Saga, events}; // ── Saga identity ──────────────────────────────────────────────────────────── @@ -189,46 +187,36 @@ fn correlate_returns_none_for_unrouted_event() { ); } -/// `react` can produce more than one own-event when `N > 0`. -/// Both events must survive the `AggregateRoot::react` dispatch path unchanged. +/// `react` can produce more than one own-event when `N > 0`, and each produced +/// event projects through `intent_for` to the outgoing intent vocabulary. Driven +/// through `SagaFixture`, the canonical given/when/then surface for react logic: +/// `PaymentRequested` -> `TakePayment`, `OrderCompleted` -> `None`, so the two +/// events yield exactly one intent. #[test] fn react_produces_multiple_events_when_capacity_allows() { - let root = AggregateRoot::::new(OrderId::new(1)); - let produced = root - .react::(&OrderExpedited { id: 1 }) - .expect("react must succeed") - .expect("react must return Some events"); - assert_eq!( - produced.into_iter().collect::>(), - vec![SagaEvent::PaymentRequested, SagaEvent::OrderCompleted] - ); + SagaFixture::::with_id(OrderId::new(1)) + .given([]) + .when(&OrderExpedited { id: 1 }) + .then_expect_events([SagaEvent::PaymentRequested, SagaEvent::OrderCompleted]) + .then_expect_commands([Intent::TakePayment]); } -/// Smoke-test: the `react_ignores_when_no_op` path still works from the -/// external test surface (i.e., `Ok(None)` from `UnrelatedEvent::react`). +/// Smoke-test: the ignore path (`Ok(None)` from `UnrelatedEvent::react`) surfaces +/// as `then_expect_ignored` through the fixture. #[test] fn react_returns_none_for_unrouted_event() { - let root = AggregateRoot::::new(OrderId::new(1)); - let outcome = root - .react::(&UnrelatedEvent) - .expect("react must not error"); - assert!( - outcome.is_none(), - "react for an unrouted event must yield None" - ); + SagaFixture::::with_id(OrderId::new(1)) + .given([]) + .when(&UnrelatedEvent) + .then_expect_ignored(); } -/// Verify `Version` import is exercised: replaying a saga event advances the -/// root correctly before the multi-event react path. +/// React after prior history: the fixture's `given` replays the saga's own past +/// event before the multi-event react path, so both produced events survive. #[test] fn react_multi_event_after_replay() { - let mut root = AggregateRoot::::new(OrderId::new(2)); - root.replay(Version::INITIAL, &SagaEvent::PaymentRequested) - .expect("replay must succeed"); - // Even with prior history, OrderExpedited always produces two events. - let produced = root - .react::(&OrderExpedited { id: 2 }) - .expect("react must succeed") - .expect("react must return Some events"); - assert_eq!(produced.into_iter().count(), 2); + SagaFixture::::with_id(OrderId::new(2)) + .given([SagaEvent::PaymentRequested]) + .when(&OrderExpedited { id: 2 }) + .then_expect_events([SagaEvent::PaymentRequested, SagaEvent::OrderCompleted]); }