Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions crates/nexus/src/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion crates/nexus/tests/kernel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
69 changes: 35 additions & 34 deletions crates/nexus/tests/kernel_tests/aggregate_root_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use nexus::KernelError;
use nexus::Message;
use nexus::Version;
use nexus::events;
use nexus::testing::AggregateFixture;

// ---------------------------------------------------------------------------
// Self-contained test domain
Expand All @@ -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<A>: PartialEq + Debug`).
#[derive(Debug, Clone, PartialEq)]
enum CounterEvent {
Incremented,
Decremented,
Expand Down Expand Up @@ -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::<Counter>::new(TestId("1".into()));
let decided = counter.handle(Increment).unwrap();
assert_eq!(decided.len(), 1);
assert!(matches!(
decided.iter().next(),
Some(CounterEvent::Incremented)
));
AggregateFixture::<Counter>::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::<Counter>::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::<Counter>::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::<Counter>::new(TestId("1".into()));
let err = counter.handle(Decrement).unwrap_err();
assert!(matches!(err, CounterError::WouldGoNegative));
AggregateFixture::<Counter>::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::<Counter>::new(TestId("1".into()));
let err = counter.handle(IncrementBy { amount: 0 }).unwrap_err();
assert!(matches!(err, CounterError::ZeroIncrement));
AggregateFixture::<Counter>::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::<Counter>::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::<Counter>::with_id(TestId("1".into()))
.given([CounterEvent::Incremented])
.when(Decrement)
.then_expect_events([CounterEvent::Decremented]);
}

// ---------------------------------------------------------------------------
Expand Down
94 changes: 43 additions & 51 deletions crates/nexus/tests/kernel_tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<A>: 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),
Expand Down Expand Up @@ -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::<User>::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::<User>::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::<User>::new(UserId::new(3));

// Create the user
let events = user
.handle(CreateUser {
fn duplicate_create_is_rejected() {
AggregateFixture::<User>::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::<User>::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]
Expand Down
74 changes: 37 additions & 37 deletions crates/nexus/tests/kernel_tests/newtype_aggregate_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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::<UserAggregate>::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::<UserAggregate>::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::<UserAggregate>::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::<UserAggregate>::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::<UserAggregate>::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]
Expand Down
Loading
Loading