Skip to content

Commit 00c05ec

Browse files
committed
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.
1 parent a05cba9 commit 00c05ec

File tree

6 files changed

+353
-6
lines changed

6 files changed

+353
-6
lines changed

exp/gtest/README.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Testing Utilities
2+
3+
> This is an experimental package for testing utilities. **The API may change at any time.**
4+
5+
The `gtest` package will provide a suite of tools for easily testing
6+
event-sourcing patterns. Currently, you can:
7+
8+
- Test aggregate constructors.
9+
- Ensure aggregates produce the expected events.
10+
- Check for unexpected events from aggregates.
11+
12+
## Usage
13+
14+
Consider an aggregate `User`:
15+
16+
```go
17+
package auth
18+
19+
import (
20+
"github.com/google/uuid"
21+
"github.com/modernice/goes/aggregate"
22+
"github.com/modernice/goes/event"
23+
)
24+
25+
type User struct {
26+
*aggregate.Base
27+
28+
Username string
29+
Age int
30+
}
31+
32+
func NewUser(id uuid.UUID) *User {
33+
user := &User{Base: aggregate.New("auth.user", id)}
34+
35+
event.ApplyWith(user, user.created, "auth.user.created")
36+
37+
return user
38+
}
39+
40+
type UserCreation struct {
41+
Username string
42+
Age int
43+
}
44+
45+
func (u *User) Create(username string, age int) error {
46+
if username == "" {
47+
return fmt.Errorf("username cannot be empty")
48+
}
49+
50+
if age < 0 {
51+
return fmt.Errorf("age cannot be negative")
52+
}
53+
54+
aggregate.Next(u, "auth.user.created", UserCreation{
55+
Username: username,
56+
Age: age,
57+
})
58+
59+
return nil
60+
}
61+
62+
func (u *User) created(e event.Of[UserCreation]) {
63+
u.Username = e.Username
64+
u.Age = e.Age
65+
}
66+
```
67+
68+
Using `gtest`, you can efficiently test this aggregate.
69+
70+
### Testing Constructors
71+
72+
To ensure the `NewUser` constructor returns a valid `User` with the correct
73+
AggregateID:
74+
75+
```go
76+
func TestNewUser(t *testing.T) {
77+
gtest.Constructor(auth.NewUser).Run(t)
78+
}
79+
```
80+
81+
### Testing Aggregate Transitions
82+
83+
To ensure the `User` aggregate correctly transitions to the `auth.user.created`
84+
event with expected data:
85+
86+
```go
87+
func TestUser_Create(t *testing.T) {
88+
u := auth.NewUser(uuid.New())
89+
90+
if err := u.Create("Alice", 25); err != nil {
91+
t.Errorf("Create failed: %v", err)
92+
}
93+
94+
gtest.Transition(
95+
"auth.user.created",
96+
UserCreation{Username: "Alice", Age: 25},
97+
).Run(t, u)
98+
99+
if u.Username != "Alice" {
100+
t.Errorf("expected Username to be %q; got %q", "Alice", u.Username)
101+
}
102+
103+
if u.Age != 25 {
104+
t.Errorf("expected Age to be %d; got %d", 25, u.Age)
105+
}
106+
}
107+
```
108+
109+
### Testing Signals (events without data)
110+
111+
To ensure the `User` aggregate correctly transitions to the `auth.user.deleted`
112+
event:
113+
114+
```go
115+
func TestUser_Delete(t *testing.T) {
116+
u := auth.NewUser(uuid.New())
117+
118+
if err := u.Delete(); err != nil {
119+
t.Errorf("Delete failed: %v", err)
120+
}
121+
122+
gtest.Signal("auth.user.deleted").Run(t, u)
123+
}
124+
```
125+
126+
### Testing Non-Transitions
127+
128+
To ensure a specific event (e.g., `auth.user.created`) is not emitted by the
129+
`User` aggregate:
130+
131+
```go
132+
func TestUser_Create_negativeAge(t *testing.T) {
133+
u := auth.NewUser(uuid.New())
134+
135+
if err := u.Create("Alice", -3); err == nil {
136+
t.Errorf("Create should fail with negative age")
137+
}
138+
139+
gtest.NonTransition("auth.user.created").Run(t, u)
140+
}
141+
```

exp/gtest/aggregate.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package gtest
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/google/go-cmp/cmp"
8+
"github.com/google/uuid"
9+
"github.com/modernice/goes/aggregate"
10+
"github.com/modernice/goes/event"
11+
"github.com/modernice/goes/helper/pick"
12+
)
13+
14+
// ConstructorTest is a test for aggregate constructors. It checks if the
15+
// constructor properly sets the AggregateID and calls the OnCreated hook, if
16+
// provided, with the created aggregate.
17+
type ConstructorTest[A aggregate.Aggregate] struct {
18+
Constructor func(uuid.UUID) A
19+
OnCreated func(A) error
20+
}
21+
22+
// ConstructorTestOption is a function that modifies a ConstructorTest for an
23+
// Aggregate. It is used to customize the behavior of a ConstructorTest, such as
24+
// providing a custom OnCreated hook function.
25+
type ConstructorTestOption[A aggregate.Aggregate] func(*ConstructorTest[A])
26+
27+
// Created configures a ConstructorTest with a custom function to be called when
28+
// an Aggregate is created, allowing for additional validation or setup steps.
29+
// The provided function takes the created Aggregate as its argument and returns
30+
// an error if any issues are encountered during execution.
31+
func Created[A aggregate.Aggregate](fn func(A) error) ConstructorTestOption[A] {
32+
return func(test *ConstructorTest[A]) {
33+
test.OnCreated = fn
34+
}
35+
}
36+
37+
// Constructor creates a new ConstructorTest with the specified constructor
38+
// function and optional test options. It returns a pointer to the created
39+
// ConstructorTest.
40+
func Constructor[A aggregate.Aggregate](constructor func(uuid.UUID) A, opts ...ConstructorTestOption[A]) *ConstructorTest[A] {
41+
test := &ConstructorTest[A]{Constructor: constructor}
42+
for _, opt := range opts {
43+
opt(test)
44+
}
45+
return test
46+
}
47+
48+
// Run executes the ConstructorTest, ensuring that the constructed aggregate has
49+
// the correct UUID and, if provided, calls the OnCreated hook without errors.
50+
// If any of these checks fail, an error is reported to the given testing.T.
51+
func (test *ConstructorTest[A]) Run(t *testing.T) {
52+
t.Helper()
53+
54+
id := uuid.New()
55+
a := test.Constructor(id)
56+
57+
if pick.AggregateID(a) != id {
58+
t.Errorf("AggregateID should be %q; got %q", id, pick.AggregateID(a))
59+
}
60+
61+
if test.OnCreated != nil {
62+
if err := test.OnCreated(a); err != nil {
63+
t.Errorf("OnCreated hook failed with %q", err)
64+
}
65+
}
66+
}
67+
68+
// TransitionTest represents a test that checks whether an aggregate transitions
69+
// to a specific event with the specified data. It can be used to ensure that an
70+
// aggregate properly handles its internal state changes and produces the
71+
// correct events with the expected data.
72+
type TransitionTest[EventData comparable] struct {
73+
transitionTestConfig
74+
75+
Event string
76+
Data EventData
77+
}
78+
79+
type transitionTestConfig struct {
80+
MatchCount uint
81+
}
82+
83+
// TransitionTestOption is a function that modifies the behavior of a
84+
// TransitionTest, such as configuring the number of times an event should be
85+
// matched. It takes a transitionTestConfig struct and modifies its properties
86+
// based on the desired configuration.
87+
type TransitionTestOption func(*transitionTestConfig)
88+
89+
// Times is a TransitionTestOption that configures the number of times an event
90+
// should match the expected data in a TransitionTest. It takes an unsigned
91+
// integer argument representing the number of matches expected.
92+
func Times(times uint) TransitionTestOption {
93+
return func(cfg *transitionTestConfig) {
94+
cfg.MatchCount = times
95+
}
96+
}
97+
98+
// Once returns a TransitionTestOption that configures a TransitionTest to
99+
// expect the specified event and data exactly once.
100+
func Once() TransitionTestOption {
101+
return Times(1)
102+
}
103+
104+
// Transition creates a new TransitionTest with the specified event name and
105+
// data. It can be used to test if an aggregate transitions to the specified
106+
// event with the provided data when running the Run method on a *testing.T
107+
// instance.
108+
func Transition[EventData comparable](event string, data EventData, opts ...TransitionTestOption) *TransitionTest[EventData] {
109+
test := TransitionTest[EventData]{
110+
Event: event,
111+
Data: data,
112+
}
113+
114+
for _, opt := range opts {
115+
opt(&test.transitionTestConfig)
116+
}
117+
118+
return &test
119+
}
120+
121+
// Signal returns a new TransitionTest with the specified event name and no
122+
// event data. It is used to test aggregate transitions for events without data.
123+
func Signal(event string, opts ...TransitionTestOption) *TransitionTest[any] {
124+
return Transition[any](event, nil, opts...)
125+
}
126+
127+
// Run tests whether an aggregate transitions to the specified event with the
128+
// expected data. It reports an error if the aggregate does not transition to
129+
// the specified event, or if the event data does not match the expected data.
130+
func (test *TransitionTest[EventData]) Run(t *testing.T, a aggregate.Aggregate) {
131+
t.Helper()
132+
133+
var matches uint
134+
for _, evt := range a.AggregateChanges() {
135+
if evt.Name() != test.Event {
136+
continue
137+
}
138+
139+
if test.MatchCount == 0 {
140+
if err := test.testEquality(evt); err != nil {
141+
t.Errorf("Aggregate %q should transition to %q with %T; %s", pick.AggregateName(a), test.Event, test.Data, err)
142+
}
143+
return
144+
}
145+
146+
if test.testEquality(evt) == nil {
147+
matches++
148+
}
149+
}
150+
151+
if test.MatchCount == 0 {
152+
t.Errorf("Aggregate %q should transition to %q with %T", pick.AggregateName(a), test.Event, test.Data)
153+
return
154+
}
155+
156+
if matches != test.MatchCount {
157+
t.Errorf("Aggregate %q should transition to %q with %T %d times; got %d", pick.AggregateName(a), test.Event, test.Data, test.MatchCount, matches)
158+
}
159+
}
160+
161+
func (test *TransitionTest[EventData]) testEquality(evt event.Event) error {
162+
if evt.Name() != test.Event {
163+
return fmt.Errorf("event name %q does not match expected event name %q", evt.Name(), test.Event)
164+
}
165+
166+
var zero EventData
167+
if test.Data != zero {
168+
data := evt.Data()
169+
if test.Data != data {
170+
return fmt.Errorf("event data %T does not match expected event data %T\n%s", evt.Data(), test.Data, cmp.Diff(test.Data, data))
171+
}
172+
}
173+
174+
return nil
175+
}
176+
177+
// NonTransition represents an event that the aggregate should not transition
178+
// to. It's used in testing to ensure that a specific event does not occur
179+
// during the test run for a given aggregate.
180+
type NonTransition string
181+
182+
// Run checks if the given aggregate a does not transition to the event
183+
// specified by the NonTransition type. If it does, an error is reported with
184+
// testing.T.
185+
func (event NonTransition) Run(t *testing.T, a aggregate.Aggregate) {
186+
t.Helper()
187+
188+
for _, evt := range a.AggregateChanges() {
189+
if evt.Name() == string(event) {
190+
t.Errorf("Aggregate %q should not transition to %q", pick.AggregateName(a), string(event))
191+
}
192+
}
193+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/modernice/goes
22

3-
go 1.18
3+
go 1.20
44

55
require (
66
github.com/MakeNowJust/heredoc v1.0.0

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
3737
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
3838
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
3939
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
40+
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
4041
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
4142
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
4243
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
@@ -58,6 +59,7 @@ github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5W
5859
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
5960
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
6061
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
62+
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
6163
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
6264
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
6365
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
@@ -280,6 +282,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
280282
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
281283
google.golang.org/genproto/googleapis/rpc v0.0.0-20230913181813-007df8e322eb h1:Isk1sSH7bovx8Rti2wZK0UZF6oraBDK74uoyLEEVFN0=
282284
google.golang.org/genproto/googleapis/rpc v0.0.0-20230913181813-007df8e322eb/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M=
285+
google.golang.org/grpc v1.58.0 h1:32JY8YpPMSR45K+c3o6b8VL73V+rR8k+DeMIr4vRH8o=
286+
google.golang.org/grpc v1.58.0/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
283287
google.golang.org/grpc v1.58.1 h1:OL+Vz23DTtrrldqHK49FUOPHyY75rvFqJfXC84NYW58=
284288
google.golang.org/grpc v1.58.1/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
285289
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=

go.work

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
go 1.18
1+
go 1.20
2+
3+
toolchain go1.21.0
24

35
use (
46
.

0 commit comments

Comments
 (0)