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 architecturekittest package spells out tests exactly this way, and it builds the state from the given events itself, without EventSourcingDB.
Create a file deciders_test.go:
package main
import (
"testing"
"github.com/thenativeweb/architecturekit-golang/v2/architecturekit"
"github.com/thenativeweb/architecturekit-golang/v2/architecturekit/architecturekittest"
)
func TestBorrowBook(t *testing.T) {
t.Run("lends an available book", func(t *testing.T) {
architecturekittest.Given(t, borrowBook,
BookAcquired{
Title: "2001 – A Space Odyssey",
Author: "Arthur C. Clarke",
ISBN: "978-0756906788",
},
).
When(BorrowBook{BookID: "42", ReaderID: "23", BorrowedUntil: "2026-10-24"}).
ThenEvents(BookBorrowed{BorrowedBy: "23", BorrowedUntil: "2026-10-24"}).
ThenPreconditions(architecturekittest.OnStateRead())
})
t.Run("rejects a book that is already borrowed", func(t *testing.T) {
architecturekittest.Given(t, borrowBook,
BookAcquired{
Title: "2001 – A Space Odyssey",
Author: "Arthur C. Clarke",
ISBN: "978-0756906788",
},
BookBorrowed{BorrowedBy: "23", BorrowedUntil: "2026-10-24"},
).
When(BorrowBook{BookID: "42", ReaderID: "17", BorrowedUntil: "2026-10-31"}).
ThenFailed(architecturekit.ErrDomain)
})
t.Run("rejects a book that does not exist", func(t *testing.T) {
architecturekittest.Given(t, borrowBook).
When(BorrowBook{BookID: "42", ReaderID: "23", BorrowedUntil: "2026-10-24"}).
ThenFailed(architecturekit.ErrDomain)
})
}
func TestReturnBook(t *testing.T) {
t.Run("returns a borrowed book", func(t *testing.T) {
architecturekittest.Given(t, returnBook,
BookAcquired{
Title: "2001 – A Space Odyssey",
Author: "Arthur C. Clarke",
ISBN: "978-0756906788",
},
BookBorrowed{BorrowedBy: "23", BorrowedUntil: "2026-10-24"},
).
When(ReturnBook{BookID: "42"}).
ThenEvents(BookReturned{})
})
t.Run("does nothing for a book that is not borrowed", func(t *testing.T) {
architecturekittest.Given(t, returnBook,
BookAcquired{
Title: "2001 – A Space Odyssey",
Author: "Arthur C. Clarke",
ISBN: "978-0756906788",
},
).
When(ReturnBook{BookID: "42"}).
ThenNothing()
})
}
Given takes the test, the decider, and the events that have happened so far, and When takes the command. 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:
ThenEventsexpects exactly the given events, in the given order.ThenFailedexpects an error, which it looks for witherrors.Is. Here, that is the categoryErrDomain, to which every rejection created withNewDomainErrorbelongs, whatever its message. To tell one rejection apart from the others, see Expecting Rejections.ThenNothingexpects neither events nor an error.ThenPreconditionsexpects exactly the given preconditions, here theOnStateReadprecondition from Making Decisions. The test describes it witharchitecturekittest.OnStateRead, the counterpart ofarchitecturekit.OnStateReadin the test package. Such a counterpart describes what a precondition checks, so that the test can compare it with the precondition the command declares.
Running the Tests#
Run the tests. They need neither EventSourcingDB nor the API, so you can stop both first:
go test -v
All five tests pass. The durations vary:
=== RUN TestBorrowBook
=== RUN TestBorrowBook/lends_an_available_book
=== RUN TestBorrowBook/rejects_a_book_that_is_already_borrowed
=== RUN TestBorrowBook/rejects_a_book_that_does_not_exist
--- PASS: TestBorrowBook (0.00s)
--- PASS: TestBorrowBook/lends_an_available_book (0.00s)
--- PASS: TestBorrowBook/rejects_a_book_that_is_already_borrowed (0.00s)
--- PASS: TestBorrowBook/rejects_a_book_that_does_not_exist (0.00s)
=== RUN TestReturnBook
=== RUN TestReturnBook/returns_a_borrowed_book
=== RUN TestReturnBook/does_nothing_for_a_book_that_is_not_borrowed
--- PASS: TestReturnBook (0.00s)
--- PASS: TestReturnBook/returns_a_borrowed_book (0.00s)
--- PASS: TestReturnBook/does_nothing_for_a_book_that_is_not_borrowed (0.00s)
PASS
ok library 0.008s
When a Test Fails#
To see that the tests actually guard the rules, remove the check for a borrowed book from borrowBook in deciders.go:
if book.IsBorrowed {
return nil, architecturekit.NewDomainError("book %s is already borrowed", cmd.BookID)
}
Run the tests again, without -v:
go test
The decider now lends a book that is already borrowed, and the test tells you so. Again, the durations vary:
--- FAIL: TestBorrowBook (0.00s)
--- FAIL: TestBorrowBook/rejects_a_book_that_is_already_borrowed (0.00s)
deciders_test.go:34: expected an error matching domain rule violated, got 1 event(s): [io.eventsourcingdb.library.book-borrowed]
FAIL
exit status 1
FAIL library 0.004s
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.