Docs / ArchitectureKit 2.0 / 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, use the architecturekittest package:

Changed in 2.0

import "github.com/thenativeweb/architecturekit-golang/v2/architecturekit/architecturekittest"

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

Call the Given function with a *testing.T, the decider, and the events that have happened so far. Then call the When function with the command, and check the decision:

func TestBorrowBook(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",
    })
}

Given returns a *Fixture, and When returns a *Decision, which is the decider's decision on the command: events, nothing, or a refusal. The functions that check the decision return the decision again, so they can be chained.

Like Execute, When refuses an event that is nil, a nil pointer included, an event that the state of the decider has no rule for, and an event whose data can not be encoded as JSON, for example because it holds a float NaN (see Executing Commands). As Execute does, it checks all events for nil first, then all of them for a rule, and encodes them last. It also 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). The decision then holds the same error of the category ErrPermanent that Execute returns, so ThenEvents and the other functions that expect events, or nothing, fail and name the cause, and ThenFailed(architecturekit.ErrPermanent) matches. Only an event ID of OnEventID that is not a revision is refused with the error that wraps ErrNotARevision instead, as Execute does, which ThenFailed(architecturekit.ErrNotARevision) matches.

Note that Given takes a testing.TB, as does every function of the package that can fail a test, so it works with the *testing.B of a benchmark as well.

Expecting Events#

To expect exactly the given events, in the given order, call the ThenEvents function, as shown above. To expect neither events nor an error, call the ThenNothing function:

architecturekittest.Given(t, returnBook, BookAcquired{}).
  When(ReturnBook{BookID: "42"}).
  ThenNothing()

To check the events with a function, call the ThenSomeEvent function to expect at least one matching event, the ThenEveryEvent function to expect only matching events, and at least one, or the ThenNoEvent function to expect no matching event:

isBookBorrowed := func(event architecturekit.Event) bool {
  _, ok := event.(BookBorrowed)
  return ok
}

architecturekittest.Given(t, borrowBook, BookAcquired{}).
  When(BorrowBook{BookID: "42", ReaderID: "23"}).
  ThenEveryEvent(isBookBorrowed)

Note that ThenSomeEvent, ThenEveryEvent, and ThenNoEvent fail the test for nil as the function, whatever the decision is, and say so. Otherwise, ThenNoEvent would pass for a decision without events, although it checks nothing.

Expecting Rejections#

To expect that a command is rejected, call the ThenFailed function with the error you expect. It matches with errors.Is, so it takes the error the decider returns, as well as every error that this error wraps. For example, acquireBook wraps ErrBookAlreadyAcquired to add the ID of the book (see Making Decisions), and ThenFailed still finds it:

architecturekittest.Given(t, acquireBook, BookAcquired{}).
  When(AcquireBook{BookID: "42"}).
  ThenFailed(ErrBookAlreadyAcquired)

To expect any error of a category instead, hand over the category, such as architecturekit.ErrDomain or architecturekit.ErrPermanent. For example, borrowBook rejects a book that does not exist with an error that it creates in place with NewDomainError, which belongs to the category ErrDomain:

architecturekittest.Given(t, borrowBook).
  When(BorrowBook{BookID: "42"}).
  ThenFailed(architecturekit.ErrDomain)

Note that ThenFailed does not compare messages, so rewording a rejection breaks no test. To tell a rejection apart from the others of the same category, create it once, as a variable, as ErrBookAlreadyAcquired is.

Expecting Preconditions#

To expect exactly the given preconditions, in the given order, call the ThenPreconditions function. Describe the preconditions with the functions of the same names, OnStateRead, Unconditionally, OnPristineSubject, OnPopulatedSubject, and OnEventID, and one made with Require from an EventQL query with the OnQuery function. 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):

architecturekittest.Given(t, borrowBook, BookAcquired{}).
  When(BorrowBook{BookID: "42", ReaderID: "23", ExpectedEventID: "0"}).
  ThenPreconditions(architecturekittest.OnEventID("/books/42", "0"))

ThenPreconditions describes a precondition by what the database checks, so OnPristineSubject, OnPopulatedSubject, and OnEventID also describe a precondition made with Require from the function of the client SDK that does the same, such as NewIsSubjectPristinePrecondition. 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):

architecturekittest.Given(t, acquireBook).
  When(AcquireBook{BookID: "42"}).
  ThenPreconditions(architecturekittest.OnPristineSubject("/books/42"))

For a pristine or a populated subject, the two are alike. For a revision, they are not: only OnEventID checks the event ID before anything is read, and refuses one that is not an event ID with an error that wraps ErrNotARevision, while Require with NewIsSubjectOnEventIDPrecondition leaves it to the database, which refuses it as a malformed request, so that Execute fails with an error of the category ErrPermanent (see Checking the Revision of the Caller). ThenPreconditions matches both, so a test tells them apart by what they do. For example, the following test passes for the BorrowBook that checks the revision of the caller with OnEventID, and fails for one that uses Require instead, since the test fixture then leaves the ID to the database, as Execute does, and the decider decides on the command:

architecturekittest.Given(t, borrowBook, BookAcquired{}).
  When(BorrowBook{BookID: "42", ReaderID: "23", ExpectedEventID: "abc"}).
  ThenFailed(architecturekit.ErrNotARevision)

Changed in 2.1To get the preconditions of a command directly, call the PreconditionsOf function. It returns a slice of Precondition, with the fields Subject, Pristine, Populated, EventID, Query, OnStateRead, and Unconditional:

preconditions := architecturekittest.PreconditionsOf(ReturnBook{BookID: "42"})

Inspecting State#

To check the state the command has been decided on, call the ThenState function with a function that receives the state:

architecturekittest.Given(t, returnBook, BookAcquired{}, BookBorrowed{}).
  When(ReturnBook{BookID: "42"}).
  ThenState(func(book Book) {
    if !book.IsBorrowed {
      t.Fatal("expected the book to be borrowed")
    }
  })

Note that ThenState fails the test for nil as the function, and says so.

Testing Upcasters#

To test an upcaster, call the GivenStored 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 bookState.UpcastWith(libraryUpcasters) (see Versioning Events). Without it, the state has no rule for the older event type, and GivenStored fails the test. To turn a typed event into a stored one, call the StoredEvent function with the subject, the event ID, and the event:

architecturekittest.GivenStored(t, borrowBook,
  architecturekittest.StoredEvent("/books/42", "0", BookAcquired{
    Title:  "2001 – A Space Odyssey",
    Author: "Arthur C. Clarke",
    ISBN:   "978-0756906788",
  }),
  eventsourcingdb.Event{
    Subject: "/books/42",
    Type:    "io.eventsourcingdb.library.book-lent",
    ID:      "1",
    Data:    json.RawMessage(`{"lentTo":"23","until":"2026-10-24"}`),
  },
).
  When(BorrowBook{BookID: "42", ReaderID: "17"}).
  ThenFailed(architecturekit.ErrDomain)

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

Replaying Events Directly#

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

book, err := architecturekit.Replay(bookState, BookAcquired{}, BookBorrowed{})
if err != nil {
  // ...
}