Testing Deciders
In this step, you test the deciders from Making Decisions – without a database.
Given, When, Then#
A decider receives a command and a state, and the state is built from events. So every test of a decider can be told in three parts: given the events that have happened so far, when a command arrives, then the decider writes these events, or rejects the command. The module architecturekit::testing spells out tests exactly this way, and it builds the state from the given events itself, without EventSourcingDB.
The module needs the feature testing. Only the tests need it, not the program, so turn it on for the tests alone, as a dev dependency:
cargo add architecturekit --dev --features testing
Create a file tests/deciders.rs:
use architecturekit::testing::given;
use architecturekit::{Category, Precondition};
use library::book::BookEvent;
use library::commands::{BorrowBook, ReturnBook};
use library::deciders::{borrow_book, return_book};
use library::events::{BookAcquired, BookBorrowed, BookReturned};
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(),
})
}
mod borrow_book {
use super::*;
#[tokio::test]
async fn lends_an_available_book() {
given(&borrow_book(), [book_acquired()])
.when(BorrowBook {
book_id: "42".to_owned(),
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(),
})])
.then_preconditions([Precondition::on_state_read()]);
}
#[tokio::test]
async fn rejects_a_book_that_is_already_borrowed() {
given(
&borrow_book(),
[
book_acquired(),
BookEvent::Borrowed(BookBorrowed {
borrowed_by: "23".to_owned(),
borrowed_until: "2026-10-24".to_owned(),
}),
],
)
.when(BorrowBook {
book_id: "42".to_owned(),
reader_id: "17".to_owned(),
borrowed_until: "2026-10-31".to_owned(),
})
.await
.then_failed_in(Category::Domain);
}
#[tokio::test]
async fn rejects_a_book_that_does_not_exist() {
given(&borrow_book(), [])
.when(BorrowBook {
book_id: "42".to_owned(),
reader_id: "23".to_owned(),
borrowed_until: "2026-10-24".to_owned(),
})
.await
.then_failed_in(Category::Domain);
}
}
mod return_book {
use super::*;
#[tokio::test]
async fn returns_a_borrowed_book() {
given(
&return_book(),
[
book_acquired(),
BookEvent::Borrowed(BookBorrowed {
borrowed_by: "23".to_owned(),
borrowed_until: "2026-10-24".to_owned(),
}),
],
)
.when(ReturnBook { book_id: "42".to_owned() })
.await
.then_events([BookEvent::Returned(BookReturned {})]);
}
#[tokio::test]
async fn does_nothing_for_a_book_that_is_not_borrowed() {
given(&return_book(), [book_acquired()])
.when(ReturnBook { book_id: "42".to_owned() })
.await
.then_nothing();
}
}
Each test is a function marked with #[tokio::test], which runs it on Tokio, since a decider decides asynchronously. A module groups the tests of each decider, and use super::* brings in what the file imports, together with book_acquired, which builds the event that most tests start from.
given takes the decider and the events that have happened so far, and when takes the command. Once awaited, when returns the decision of the decider on the command: events, nothing, or a rejection. The functions that start with then_ check it, and since each of them returns the decision again, you can chain them:
then_eventsexpects exactly the given events, in the given order. It compares them by their type and their data as JSON, which is what would be stored, so the events need noPartialEq.then_failed_inexpects an error of the given category. Here, that isCategory::Domain, to which everyDomainErrorbelongs, whatever its message. To tell one rejection apart from the others, see Expecting Rejections.then_nothingexpects neither events nor an error.then_preconditionsexpects exactly the given preconditions, here theon_state_readprecondition from Making Decisions. The test names it withPrecondition::on_state_read, just as the command does, since two preconditions are equal if they check the same thing.
Running the Tests#
Run the tests. They need neither EventSourcingDB nor the API, so you can stop both first:
cargo test
Cargo runs the tests of every part of the package. The library crate and the program have no tests of their own, and neither has their documentation, so all five tests come from tests/deciders.rs, and pass. The tests run in parallel, so their order varies, as do the hashes in the file names and the durations:
Running unittests src/lib.rs (target/debug/deps/library-5a1c23d119c571f2)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/library-73631a88eff44574)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/deciders.rs (target/debug/deps/deciders-7ce093866d940d9f)
running 5 tests
test return_book::returns_a_borrowed_book ... ok
test borrow_book::rejects_a_book_that_does_not_exist ... ok
test borrow_book::lends_an_available_book ... ok
test borrow_book::rejects_a_book_that_is_already_borrowed ... ok
test return_book::does_nothing_for_a_book_that_is_not_borrowed ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests library
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
When a Test Fails#
To see that the tests actually guard the rules, remove the check for a borrowed book from borrow_book in src/deciders.rs:
if book.is_borrowed {
return Err(DomainError::new(format!("book {} is already borrowed", cmd.book_id)));
}
Run the tests again:
cargo test
The decider now lends a book that is already borrowed, and the test tells you so, and where. Cargo stops after the tests that failed, so the documentation is not tested. Again, the order, the hashes, the number of the thread, and the durations vary:
Running unittests src/lib.rs (target/debug/deps/library-5a1c23d119c571f2)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/library-73631a88eff44574)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/deciders.rs (target/debug/deps/deciders-7ce093866d940d9f)
running 5 tests
test return_book::returns_a_borrowed_book ... ok
test borrow_book::lends_an_available_book ... ok
test return_book::does_nothing_for_a_book_that_is_not_borrowed ... ok
test borrow_book::rejects_a_book_that_does_not_exist ... ok
test borrow_book::rejects_a_book_that_is_already_borrowed ... FAILED
failures:
---- borrow_book::rejects_a_book_that_is_already_borrowed stdout ----
thread 'borrow_book::rejects_a_book_that_is_already_borrowed' (75683273) panicked at tests/deciders.rs:53:6:
expected an error of the category Domain, got 1 event(s): ["io.eventsourcingdb.library.book-borrowed"]
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
borrow_book::rejects_a_book_that_is_already_borrowed
test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--test deciders`
Put the check back, and the tests pass again. For more ways to check a decision, see Testing Deciders, and for why tests like these matter, see Testing Event-Sourced Systems.
Shutting Down#
If they are still running, press Ctrl+C in the terminals of the API and of EventSourcingDB. Since the data directory of EventSourcingDB is temporary, all events are gone afterwards.
Next Steps#
You have built a small library with ArchitectureKit, from events to a tested HTTP API. To go further:
- Testing Projections and Testing Queries test the reading side without a database as well.
- Using Preconditions shows how to check the revision a caller has seen, and how to enforce rules across books, such as acquiring every ISBN only once.
- Setting Up an HTTP API and Handling Commands over HTTP add users, and refuse commands they are not allowed to send.
- Versioning Events changes the shape of an event without changing the events that are already stored.