Docs / ArchitectureKit / Rust / Guides / Testing Deciders

Testing Deciders

Deciders can be tested without a database, from the events that have happened so far, the command, and the expected decision.

To test deciders without a database, turn on the feature testing for the tests, which adds the module architecturekit::testing:

cargo add architecturekit --dev --features testing

The examples use the commands as Defining Commands declares them, with on_state_read, apart from those under Expecting Preconditions, which use the BorrowBook that checks the revision of the caller with on_event_id (see Checking the Revision of the Caller) and the AcquireBook that prevents duplicates with on_pristine_subject (see Preventing Duplicates).

Call the given function with the decider and the events that have happened so far. Then call the when method with the command, wait for the decision, and check it. A decider decides asynchronously, so a test runs on Tokio, as #[tokio::test] does:

use architecturekit::testing::given;

#[tokio::test]
async fn borrows_a_book() {
  given(
    &borrow_book(),
    [BookEvent::Acquired(BookAcquired {
      title: "2001 – A Space Odyssey".to_owned(),
      author: "Arthur C. Clarke".to_owned(),
      isbn: "978-0756906788".to_owned(),
    })],
  )
  .when(BorrowBook {
    book: Subject::new("/books/42"),
    reader_id: "23".to_owned(),
    borrowed_until: "2026-10-24".to_owned(),
  })
  .await
  .then_events([BookEvent::Borrowed(BookBorrowed {
    borrowed_by: "23".to_owned(),
    borrowed_until: "2026-10-24".to_owned(),
  })]);
}

given returns a Fixture, and when returns a Decision, which is the decider's decision on the command: events, nothing, or a refusal. The methods that check the decision return the decision again, so they can be chained. A check that does not hold panics with a message that says what was expected and what was decided, as assert_eq! does, which fails the test.

The events are variants of the enum of the state, so the compiler checks that the state has a rule for each of them. given builds the state by writing every event as JSON and reading it back before the state evolves by it, exactly as executing a command and reading the state again would, so a field that does not survive that, such as one that serde skips, shows up in the test. It fails the test if an event can not be encoded. Like execute, when checks the preconditions of the command before the decider decides, so that a command execute refuses, such as one that combines unconditionally with others, is refused here as well (see Using Preconditions), and it refuses the events if the data of one of them can not be encoded as JSON. The decision then holds the same error that execute returns, ExecuteError::Permanent, so then_events and the other methods that expect events, or nothing, fail and name the cause, and then_failed_in(Category::Permanent) matches.

The examples below start from the event of the first example, which a function of the test returns:

fn book_acquired() -> BookEvent {
  BookEvent::Acquired(BookAcquired {
    title: "2001 – A Space Odyssey".to_owned(),
    author: "Arthur C. Clarke".to_owned(),
    isbn: "978-0756906788".to_owned(),
  })
}

Expecting Events#

To expect exactly the given events, in the given order, call the then_events method, as shown above. It compares the events by their type and the JSON of their data, which is what executing the command would write, so the events need no PartialEq. To expect neither events nor an error, call the then_nothing method:

given(&return_book(), [book_acquired()])
  .when(ReturnBook { book: Subject::new("/books/42") })
  .await
  .then_nothing();

To check the events with a function, call the then_some_event method to expect at least one matching event, the then_every_event method to expect only matching events, and at least one, or the then_no_event method to expect no matching event:

given(&borrow_book(), [book_acquired()])
  .when(BorrowBook {
    book: Subject::new("/books/42"),
    reader_id: "23".to_owned(),
    borrowed_until: "2026-10-24".to_owned(),
  })
  .await
  .then_every_event(|event| matches!(event, BookEvent::Borrowed(_)));

Note that then_every_event fails the test for a decision without events, while then_no_event passes for one, since no event matches.

Expecting Rejections#

To expect that a command is rejected, call the then_failed method with the error you expect. It compares the error of the decider with ==, so the error type implements PartialEq and Debug, as DomainError does. For example, acquire_book refuses a book that has been acquired already (see Making Decisions):

given(&acquire_book(), [book_acquired()])
  .when(AcquireBook {
    book: Subject::new("/books/42"),
    title: "2001 – A Space Odyssey".to_owned(),
    author: "Arthur C. Clarke".to_owned(),
    isbn: "978-0756906788".to_owned(),
  })
  .await
  .then_failed(DomainError::new("/books/42 has already been acquired"));

To expect any error of a category instead, call the then_failed_in method with the category, such as Category::Domain or Category::Permanent. It matches an error of the decider as well as one of the kit. For example, borrow_book rejects a book that does not exist with a DomainError, which belongs to the category Category::Domain:

given(&borrow_book(), [])
  .when(BorrowBook {
    book: Subject::new("/books/42"),
    reader_id: "23".to_owned(),
    borrowed_until: "2026-10-24".to_owned(),
  })
  .await
  .then_failed_in(Category::Domain);

For a failure that neither its value nor its category tells apart, such as a refusal of the kit, which may have the same category as an error of the decider, call the then_failed_matching method with a function that receives the ExecuteError, as in then_failed_matching(|failure| matches!(failure, ExecuteError::Permanent(_))).

Note that then_failed compares the whole error, which for a DomainError is its message, so rewording a rejection breaks a test that expects it. then_failed_in does not compare messages. To tell a rejection apart from the others of the same category without its message, give the decider an error type of its own, such as an enum with a variant per rejection, which implements Categorized, and expect the variant.

Expecting Preconditions#

To expect exactly the given preconditions, in the given order, call the then_preconditions method. Describe the preconditions as the command does, with Precondition::on_state_read, unconditionally, on_pristine_subject, on_populated_subject, and on_event_id, and one made with require from a precondition of the client SDK, such as an EventQL query. For example, the BorrowBook that checks the revision of the caller requires its subject to be on the event ID the command carries (see Checking the Revision of the Caller):

given(&borrow_book(), [book_acquired()])
  .when(BorrowBook {
    book: Subject::new("/books/42"),
    reader_id: "23".to_owned(),
    borrowed_until: "2026-10-24".to_owned(),
    expected_event_id: Revision::from(0),
  })
  .await
  .then_preconditions([Precondition::on_event_id(Subject::new("/books/42"), Revision::from(0))]);

then_preconditions compares preconditions by their kind and by what the database checks for them. on_pristine_subject and on_populated_subject are the same as require with is_subject_pristine and is_subject_populated of the client SDK, so each matches the other. A test that expects a pristine subject fails for a command that declares a populated one, and the failure names both. For example, the AcquireBook that prevents duplicates requires a pristine subject (see Preventing Duplicates):

given(&acquire_book(), [])
  .when(AcquireBook {
    book: Subject::new("/books/42"),
    title: "2001 – A Space Odyssey".to_owned(),
    author: "Arthur C. Clarke".to_owned(),
    isbn: "978-0756906788".to_owned(),
  })
  .await
  .then_preconditions([Precondition::on_pristine_subject(Subject::new("/books/42"))]);

For a pristine or a populated subject, the two are alike. For a revision, they are not: on_event_id takes a Revision, which the caller reads from its text first, so that a value that is not one is refused before anything is read, while require with is_subject_on_event_id takes any text, and leaves it to the database, which refuses one that is not an event ID as a malformed request, so that execute fails with an error of the category Category::Permanent (see Checking the Revision of the Caller). So then_preconditions tells them apart: a test that expects on_event_id fails for a command that uses require instead, and the failure shows which function made each of them.

A precondition that checks the subject together with every subject below it, made with require from is_subject_pristine, is_subject_populated, or is_subject_on_event_id with PreconditionOptions that set recursive to true (see Using Preconditions), has no function of its own in the kit. Describe it the same way in the test. It checks something else than the precondition of the subject alone, so a test that expects the one fails for a command that declares the other, and the failure names both, the recursive one with its options. PreconditionOptions that set recursive to false mean the same as none, so on_pristine_subject and on_populated_subject describe such a precondition. For example, an AcquireBook that requires neither its subject nor any subject below it to have events yet passes the following test, while the one that prevents duplicates with on_pristine_subject fails it:

given(&acquire_book(), [])
  .when(AcquireBook {
    book: Subject::new("/books/42"),
    title: "2001 – A Space Odyssey".to_owned(),
    author: "Arthur C. Clarke".to_owned(),
    isbn: "978-0756906788".to_owned(),
  })
  .await
  .then_preconditions([Precondition::require(eventsourcingdb::Precondition::is_subject_pristine(
    "/books/42",
    Some(eventsourcingdb::PreconditionOptions { recursive: true }),
  ))]);

To get the preconditions of a command directly, call its preconditions method, which the Command trait declares. It returns a Vec<Precondition>, which a test compares with assert_eq!, since Precondition implements PartialEq and Debug:

use architecturekit::Command;

let preconditions = ReturnBook { book: Subject::new("/books/42") }.preconditions();

Inspecting State#

To check the state the command has been decided on, call the then_state method with a function that receives the state, and panics, as assert! does, if the state is not what you expect:

given(
  &return_book(),
  [
    book_acquired(),
    BookEvent::Borrowed(BookBorrowed {
      borrowed_by: "23".to_owned(),
      borrowed_until: "2026-10-24".to_owned(),
    }),
  ],
)
.when(ReturnBook { book: Subject::new("/books/42") })
.await
.then_state(|book| assert!(book.is_borrowed, "expected the book to be borrowed"));

Testing Upcasters#

To test an upcaster, call the given_stored function instead of given, and hand over the events as they are stored. They run through the upcasters of the decider's state, as they do when reading from the database, so the example below needs a Book whose upcasters returns the upcasters of the library (see Versioning Events). Without them, the state has no rule for the older event type, and given_stored fails the test. To turn a typed event into a stored one, create a StoredEvent with the subject, the event ID, and the event, and for an event that no type describes, such as one of an older type, with StoredEvent::raw and its type and data, as a Value of the serde_json crate, which you add with cargo add serde_json --dev:

use architecturekit::testing::{StoredEvent, given_stored};
use serde_json::json;

given_stored(
  &borrow_book(),
  [
    StoredEvent::new(Subject::new("/books/42"), 0, &book_acquired()),
    StoredEvent::raw(
      Subject::new("/books/42"),
      1,
      "io.eventsourcingdb.library.book-lent",
      json!({ "lentTo": "23", "until": "2026-10-24" }),
    ),
  ],
)
.when(BorrowBook {
  book: Subject::new("/books/42"),
  reader_id: "17".to_owned(),
  borrowed_until: "2026-10-31".to_owned(),
})
.await
.then_failed_in(Category::Domain);

The upcaster turns the older event into a BookBorrowed event, so the book is borrowed already, and borrow_book rejects the command.

Replaying Events Directly#

To evolve a state from events without a decider, call the replay function with typed events, or the replay_stored function with stored events. To check the state after each event, advance it one event at a time with the step function (see Stepping Through States):

use architecturekit::replay;

let book: Book = replay([
  book_acquired(),
  BookEvent::Borrowed(BookBorrowed {
    borrowed_by: "23".to_owned(),
    borrowed_until: "2026-10-24".to_owned(),
  }),
])?;