Learn / Tutorials / First Steps with ArchitectureKit / Rust / Testing Deciders
Step 6 of 6

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_events expects 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 no PartialEq.
  • then_failed_in expects an error of the given category. Here, that is Category::Domain, to which every DomainError belongs, whatever its message. To tell one rejection apart from the others, see Expecting Rejections.
  • then_nothing expects neither events nor an error.
  • then_preconditions expects exactly the given preconditions, here the on_state_read precondition from Making Decisions. The test names it with Precondition::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: