Learn / Tutorials / First Steps with ArchitectureKit / 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 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/architecturekit"
  "github.com/thenativeweb/architecturekit-golang/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"}).
      ThenRejected("book 42 is already borrowed")
  })

  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. The functions that start with Then check the outcome, and since each of them returns the outcome again, you can chain them:

  • ThenEvents expects exactly the given events, in the given order.
  • ThenRejected expects a rejection with exactly the given message, and ThenFailed an error of the given category, whatever its message.
  • ThenNothing expects neither events nor an error.
  • ThenPreconditions expects exactly the given preconditions, here the OnStateRead precondition from Making Decisions.

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.322s

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 rejection "book 42 is already borrowed", got 1 event(s): [io.eventsourcingdb.library.book-borrowed]
FAIL
exit status 1
FAIL	library	0.362s

Put the check back, and the tests pass again. For more ways to check an outcome, 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: