-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(gtest): add testing utilities for event-sourcing patterns
This commit introduces a new experimental package 'gtest' for testing utilities. The package provides a suite of tools for easily testing event-sourcing patterns. It includes functionalities to test aggregate constructors, ensure aggregates produce the expected events, and check for unexpected events from aggregates. chore: update Go version from 1.18 to 1.20 This commit updates the Go version used in the project from 1.18 to 1.20 to leverage the latest features and improvements in the language. chore: update go.work to use go1.21.0 toolchain This commit updates the go.work file to use the go1.21.0 toolchain for better compatibility and performance.
- Loading branch information
Showing
6 changed files
with
353 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,141 @@ | ||
# Testing Utilities | ||
|
||
> This is an experimental package for testing utilities. **The API may change at any time.** | ||
The `gtest` package will provide a suite of tools for easily testing | ||
event-sourcing patterns. Currently, you can: | ||
|
||
- Test aggregate constructors. | ||
- Ensure aggregates produce the expected events. | ||
- Check for unexpected events from aggregates. | ||
|
||
## Usage | ||
|
||
Consider an aggregate `User`: | ||
|
||
```go | ||
package auth | ||
|
||
import ( | ||
"github.com/google/uuid" | ||
"github.com/modernice/goes/aggregate" | ||
"github.com/modernice/goes/event" | ||
) | ||
|
||
type User struct { | ||
*aggregate.Base | ||
|
||
Username string | ||
Age int | ||
} | ||
|
||
func NewUser(id uuid.UUID) *User { | ||
user := &User{Base: aggregate.New("auth.user", id)} | ||
|
||
event.ApplyWith(user, user.created, "auth.user.created") | ||
|
||
return user | ||
} | ||
|
||
type UserCreation struct { | ||
Username string | ||
Age int | ||
} | ||
|
||
func (u *User) Create(username string, age int) error { | ||
if username == "" { | ||
return fmt.Errorf("username cannot be empty") | ||
} | ||
|
||
if age < 0 { | ||
return fmt.Errorf("age cannot be negative") | ||
} | ||
|
||
aggregate.Next(u, "auth.user.created", UserCreation{ | ||
Username: username, | ||
Age: age, | ||
}) | ||
|
||
return nil | ||
} | ||
|
||
func (u *User) created(e event.Of[UserCreation]) { | ||
u.Username = e.Username | ||
u.Age = e.Age | ||
} | ||
``` | ||
|
||
Using `gtest`, you can efficiently test this aggregate. | ||
|
||
### Testing Constructors | ||
|
||
To ensure the `NewUser` constructor returns a valid `User` with the correct | ||
AggregateID: | ||
|
||
```go | ||
func TestNewUser(t *testing.T) { | ||
gtest.Constructor(auth.NewUser).Run(t) | ||
} | ||
``` | ||
|
||
### Testing Aggregate Transitions | ||
|
||
To ensure the `User` aggregate correctly transitions to the `auth.user.created` | ||
event with expected data: | ||
|
||
```go | ||
func TestUser_Create(t *testing.T) { | ||
u := auth.NewUser(uuid.New()) | ||
|
||
if err := u.Create("Alice", 25); err != nil { | ||
t.Errorf("Create failed: %v", err) | ||
} | ||
|
||
gtest.Transition( | ||
"auth.user.created", | ||
UserCreation{Username: "Alice", Age: 25}, | ||
).Run(t, u) | ||
|
||
if u.Username != "Alice" { | ||
t.Errorf("expected Username to be %q; got %q", "Alice", u.Username) | ||
} | ||
|
||
if u.Age != 25 { | ||
t.Errorf("expected Age to be %d; got %d", 25, u.Age) | ||
} | ||
} | ||
``` | ||
|
||
### Testing Signals (events without data) | ||
|
||
To ensure the `User` aggregate correctly transitions to the `auth.user.deleted` | ||
event: | ||
|
||
```go | ||
func TestUser_Delete(t *testing.T) { | ||
u := auth.NewUser(uuid.New()) | ||
|
||
if err := u.Delete(); err != nil { | ||
t.Errorf("Delete failed: %v", err) | ||
} | ||
|
||
gtest.Signal("auth.user.deleted").Run(t, u) | ||
} | ||
``` | ||
|
||
### Testing Non-Transitions | ||
|
||
To ensure a specific event (e.g., `auth.user.created`) is not emitted by the | ||
`User` aggregate: | ||
|
||
```go | ||
func TestUser_Create_negativeAge(t *testing.T) { | ||
u := auth.NewUser(uuid.New()) | ||
|
||
if err := u.Create("Alice", -3); err == nil { | ||
t.Errorf("Create should fail with negative age") | ||
} | ||
|
||
gtest.NonTransition("auth.user.created").Run(t, u) | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,193 @@ | ||
package gtest | ||
|
||
import ( | ||
"fmt" | ||
"testing" | ||
|
||
"github.com/google/go-cmp/cmp" | ||
"github.com/google/uuid" | ||
"github.com/modernice/goes/aggregate" | ||
"github.com/modernice/goes/event" | ||
"github.com/modernice/goes/helper/pick" | ||
) | ||
|
||
// ConstructorTest is a test for aggregate constructors. It checks if the | ||
// constructor properly sets the AggregateID and calls the OnCreated hook, if | ||
// provided, with the created aggregate. | ||
type ConstructorTest[A aggregate.Aggregate] struct { | ||
Constructor func(uuid.UUID) A | ||
OnCreated func(A) error | ||
} | ||
|
||
// ConstructorTestOption is a function that modifies a ConstructorTest for an | ||
// Aggregate. It is used to customize the behavior of a ConstructorTest, such as | ||
// providing a custom OnCreated hook function. | ||
type ConstructorTestOption[A aggregate.Aggregate] func(*ConstructorTest[A]) | ||
|
||
// Created configures a ConstructorTest with a custom function to be called when | ||
// an Aggregate is created, allowing for additional validation or setup steps. | ||
// The provided function takes the created Aggregate as its argument and returns | ||
// an error if any issues are encountered during execution. | ||
func Created[A aggregate.Aggregate](fn func(A) error) ConstructorTestOption[A] { | ||
return func(test *ConstructorTest[A]) { | ||
test.OnCreated = fn | ||
} | ||
} | ||
|
||
// Constructor creates a new ConstructorTest with the specified constructor | ||
// function and optional test options. It returns a pointer to the created | ||
// ConstructorTest. | ||
func Constructor[A aggregate.Aggregate](constructor func(uuid.UUID) A, opts ...ConstructorTestOption[A]) *ConstructorTest[A] { | ||
test := &ConstructorTest[A]{Constructor: constructor} | ||
for _, opt := range opts { | ||
opt(test) | ||
} | ||
return test | ||
} | ||
|
||
// Run executes the ConstructorTest, ensuring that the constructed aggregate has | ||
// the correct UUID and, if provided, calls the OnCreated hook without errors. | ||
// If any of these checks fail, an error is reported to the given testing.T. | ||
func (test *ConstructorTest[A]) Run(t *testing.T) { | ||
t.Helper() | ||
|
||
id := uuid.New() | ||
a := test.Constructor(id) | ||
|
||
if pick.AggregateID(a) != id { | ||
t.Errorf("AggregateID should be %q; got %q", id, pick.AggregateID(a)) | ||
} | ||
|
||
if test.OnCreated != nil { | ||
if err := test.OnCreated(a); err != nil { | ||
t.Errorf("OnCreated hook failed with %q", err) | ||
} | ||
} | ||
} | ||
|
||
// TransitionTest represents a test that checks whether an aggregate transitions | ||
// to a specific event with the specified data. It can be used to ensure that an | ||
// aggregate properly handles its internal state changes and produces the | ||
// correct events with the expected data. | ||
type TransitionTest[EventData comparable] struct { | ||
transitionTestConfig | ||
|
||
Event string | ||
Data EventData | ||
} | ||
|
||
type transitionTestConfig struct { | ||
MatchCount uint | ||
} | ||
|
||
// TransitionTestOption is a function that modifies the behavior of a | ||
// TransitionTest, such as configuring the number of times an event should be | ||
// matched. It takes a transitionTestConfig struct and modifies its properties | ||
// based on the desired configuration. | ||
type TransitionTestOption func(*transitionTestConfig) | ||
|
||
// Times is a TransitionTestOption that configures the number of times an event | ||
// should match the expected data in a TransitionTest. It takes an unsigned | ||
// integer argument representing the number of matches expected. | ||
func Times(times uint) TransitionTestOption { | ||
return func(cfg *transitionTestConfig) { | ||
cfg.MatchCount = times | ||
} | ||
} | ||
|
||
// Once returns a TransitionTestOption that configures a TransitionTest to | ||
// expect the specified event and data exactly once. | ||
func Once() TransitionTestOption { | ||
return Times(1) | ||
} | ||
|
||
// Transition creates a new TransitionTest with the specified event name and | ||
// data. It can be used to test if an aggregate transitions to the specified | ||
// event with the provided data when running the Run method on a *testing.T | ||
// instance. | ||
func Transition[EventData comparable](event string, data EventData, opts ...TransitionTestOption) *TransitionTest[EventData] { | ||
test := TransitionTest[EventData]{ | ||
Event: event, | ||
Data: data, | ||
} | ||
|
||
for _, opt := range opts { | ||
opt(&test.transitionTestConfig) | ||
} | ||
|
||
return &test | ||
} | ||
|
||
// Signal returns a new TransitionTest with the specified event name and no | ||
// event data. It is used to test aggregate transitions for events without data. | ||
func Signal(event string, opts ...TransitionTestOption) *TransitionTest[any] { | ||
return Transition[any](event, nil, opts...) | ||
} | ||
|
||
// Run tests whether an aggregate transitions to the specified event with the | ||
// expected data. It reports an error if the aggregate does not transition to | ||
// the specified event, or if the event data does not match the expected data. | ||
func (test *TransitionTest[EventData]) Run(t *testing.T, a aggregate.Aggregate) { | ||
t.Helper() | ||
|
||
var matches uint | ||
for _, evt := range a.AggregateChanges() { | ||
if evt.Name() != test.Event { | ||
continue | ||
} | ||
|
||
if test.MatchCount == 0 { | ||
if err := test.testEquality(evt); err != nil { | ||
t.Errorf("Aggregate %q should transition to %q with %T; %s", pick.AggregateName(a), test.Event, test.Data, err) | ||
} | ||
return | ||
} | ||
|
||
if test.testEquality(evt) == nil { | ||
matches++ | ||
} | ||
} | ||
|
||
if test.MatchCount == 0 { | ||
t.Errorf("Aggregate %q should transition to %q with %T", pick.AggregateName(a), test.Event, test.Data) | ||
return | ||
} | ||
|
||
if matches != test.MatchCount { | ||
t.Errorf("Aggregate %q should transition to %q with %T %d times; got %d", pick.AggregateName(a), test.Event, test.Data, test.MatchCount, matches) | ||
} | ||
} | ||
|
||
func (test *TransitionTest[EventData]) testEquality(evt event.Event) error { | ||
if evt.Name() != test.Event { | ||
return fmt.Errorf("event name %q does not match expected event name %q", evt.Name(), test.Event) | ||
} | ||
|
||
var zero EventData | ||
if test.Data != zero { | ||
data := evt.Data() | ||
if test.Data != data { | ||
return fmt.Errorf("event data %T does not match expected event data %T\n%s", evt.Data(), test.Data, cmp.Diff(test.Data, data)) | ||
} | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// NonTransition represents an event that the aggregate should not transition | ||
// to. It's used in testing to ensure that a specific event does not occur | ||
// during the test run for a given aggregate. | ||
type NonTransition string | ||
|
||
// Run checks if the given aggregate a does not transition to the event | ||
// specified by the NonTransition type. If it does, an error is reported with | ||
// testing.T. | ||
func (event NonTransition) Run(t *testing.T, a aggregate.Aggregate) { | ||
t.Helper() | ||
|
||
for _, evt := range a.AggregateChanges() { | ||
if evt.Name() == string(event) { | ||
t.Errorf("Aggregate %q should not transition to %q", pick.AggregateName(a), string(event)) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,6 @@ | ||
go 1.18 | ||
go 1.20 | ||
|
||
toolchain go1.21.0 | ||
|
||
use ( | ||
. | ||
|
Oops, something went wrong.