Skip to content

Commit

Permalink
feat(gtest): add testing utilities for event-sourcing patterns
Browse files Browse the repository at this point in the history
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
bounoable committed Sep 15, 2023
1 parent a05cba9 commit 00c05ec
Show file tree
Hide file tree
Showing 6 changed files with 353 additions and 6 deletions.
141 changes: 141 additions & 0 deletions exp/gtest/README.md
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)
}
```
193 changes: 193 additions & 0 deletions exp/gtest/aggregate.go
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))
}
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/modernice/goes

go 1.18
go 1.20

require (
github.com/MakeNowJust/heredoc v1.0.0
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
Expand All @@ -58,6 +59,7 @@ github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5W
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
Expand Down Expand Up @@ -280,6 +282,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230913181813-007df8e322eb h1:Isk1sSH7bovx8Rti2wZK0UZF6oraBDK74uoyLEEVFN0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230913181813-007df8e322eb/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M=
google.golang.org/grpc v1.58.0 h1:32JY8YpPMSR45K+c3o6b8VL73V+rR8k+DeMIr4vRH8o=
google.golang.org/grpc v1.58.0/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
google.golang.org/grpc v1.58.1 h1:OL+Vz23DTtrrldqHK49FUOPHyY75rvFqJfXC84NYW58=
google.golang.org/grpc v1.58.1/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
Expand Down
4 changes: 3 additions & 1 deletion go.work
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 (
.
Expand Down
Loading

0 comments on commit 00c05ec

Please sign in to comment.