340 lines
7.5 KiB
Markdown
340 lines
7.5 KiB
Markdown
---
|
|
title: Testing
|
|
description: Test naming conventions, test helpers, and patterns for Core applications.
|
|
---
|
|
|
|
# Testing
|
|
|
|
Core uses `github.com/stretchr/testify` for assertions and follows a structured test naming convention. This page covers the patterns used in the framework itself and recommended for services built on it.
|
|
|
|
## Naming Convention
|
|
|
|
Tests use a `_Good`, `_Bad`, `_Ugly` suffix pattern:
|
|
|
|
| Suffix | Purpose | Example |
|
|
|--------|---------|---------|
|
|
| `_Good` | Happy path -- expected behaviour | `TestCore_New_Good` |
|
|
| `_Bad` | Expected error conditions | `TestCore_WithService_Bad` |
|
|
| `_Ugly` | Panics, edge cases, degenerate input | `TestCore_MustServiceFor_Ugly` |
|
|
|
|
The format is `Test{Component}_{Method}_{Suffix}`:
|
|
|
|
```go
|
|
func TestCore_New_Good(t *testing.T) {
|
|
c, err := New()
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, c)
|
|
}
|
|
|
|
func TestCore_WithService_Bad(t *testing.T) {
|
|
factory := func(c *Core) (any, error) {
|
|
return nil, assert.AnError
|
|
}
|
|
_, err := New(WithService(factory))
|
|
assert.Error(t, err)
|
|
assert.ErrorIs(t, err, assert.AnError)
|
|
}
|
|
|
|
func TestCore_MustServiceFor_Ugly(t *testing.T) {
|
|
c, _ := New()
|
|
assert.Panics(t, func() {
|
|
MustServiceFor[*MockService](c, "nonexistent")
|
|
})
|
|
}
|
|
```
|
|
|
|
## Creating a Test Core
|
|
|
|
For unit tests, create a minimal Core with only the services needed:
|
|
|
|
```go
|
|
func TestMyFeature(t *testing.T) {
|
|
c, err := core.New()
|
|
assert.NoError(t, err)
|
|
|
|
// Register only what the test needs
|
|
err = c.RegisterService("my-service", &MyService{})
|
|
assert.NoError(t, err)
|
|
}
|
|
```
|
|
|
|
## Mock Services
|
|
|
|
Define mock services as test-local structs. Core's interface-based design makes this straightforward:
|
|
|
|
```go
|
|
// Mock a Startable service
|
|
type MockStartable struct {
|
|
started bool
|
|
err error
|
|
}
|
|
|
|
func (m *MockStartable) OnStartup(ctx context.Context) error {
|
|
m.started = true
|
|
return m.err
|
|
}
|
|
|
|
// Mock a Stoppable service
|
|
type MockStoppable struct {
|
|
stopped bool
|
|
err error
|
|
}
|
|
|
|
func (m *MockStoppable) OnShutdown(ctx context.Context) error {
|
|
m.stopped = true
|
|
return m.err
|
|
}
|
|
```
|
|
|
|
For services implementing both lifecycle interfaces:
|
|
|
|
```go
|
|
type MockLifecycle struct {
|
|
MockStartable
|
|
MockStoppable
|
|
}
|
|
```
|
|
|
|
## Testing Lifecycle
|
|
|
|
Verify that startup and shutdown are called in the correct order:
|
|
|
|
```go
|
|
func TestLifecycleOrder(t *testing.T) {
|
|
c, _ := core.New()
|
|
var callOrder []string
|
|
|
|
s1 := &OrderTracker{id: "1", log: &callOrder}
|
|
s2 := &OrderTracker{id: "2", log: &callOrder}
|
|
|
|
_ = c.RegisterService("s1", s1)
|
|
_ = c.RegisterService("s2", s2)
|
|
|
|
_ = c.ServiceStartup(context.Background(), nil)
|
|
assert.Equal(t, []string{"start-1", "start-2"}, callOrder)
|
|
|
|
callOrder = nil
|
|
_ = c.ServiceShutdown(context.Background())
|
|
assert.Equal(t, []string{"stop-2", "stop-1"}, callOrder) // reverse order
|
|
}
|
|
```
|
|
|
|
## Testing Message Handlers
|
|
|
|
### Actions
|
|
|
|
Register an action handler and verify it receives the expected message:
|
|
|
|
```go
|
|
func TestAction(t *testing.T) {
|
|
c, _ := core.New()
|
|
var received core.Message
|
|
|
|
c.RegisterAction(func(c *core.Core, msg core.Message) error {
|
|
received = msg
|
|
return nil
|
|
})
|
|
|
|
_ = c.ACTION(MyEvent{Data: "test"})
|
|
event, ok := received.(MyEvent)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "test", event.Data)
|
|
}
|
|
```
|
|
|
|
### Queries
|
|
|
|
```go
|
|
func TestQuery(t *testing.T) {
|
|
c, _ := core.New()
|
|
|
|
c.RegisterQuery(func(c *core.Core, q core.Query) (any, bool, error) {
|
|
if _, ok := q.(GetStatus); ok {
|
|
return "healthy", true, nil
|
|
}
|
|
return nil, false, nil
|
|
})
|
|
|
|
result, handled, err := c.QUERY(GetStatus{})
|
|
assert.NoError(t, err)
|
|
assert.True(t, handled)
|
|
assert.Equal(t, "healthy", result)
|
|
}
|
|
```
|
|
|
|
### Tasks
|
|
|
|
```go
|
|
func TestTask(t *testing.T) {
|
|
c, _ := core.New()
|
|
|
|
c.RegisterTask(func(c *core.Core, t core.Task) (any, bool, error) {
|
|
if m, ok := t.(ProcessItem); ok {
|
|
return "processed-" + m.ID, true, nil
|
|
}
|
|
return nil, false, nil
|
|
})
|
|
|
|
result, handled, err := c.PERFORM(ProcessItem{ID: "42"})
|
|
assert.NoError(t, err)
|
|
assert.True(t, handled)
|
|
assert.Equal(t, "processed-42", result)
|
|
}
|
|
```
|
|
|
|
### Async Tasks
|
|
|
|
Use `assert.Eventually` to wait for background task completion:
|
|
|
|
```go
|
|
func TestAsyncTask(t *testing.T) {
|
|
c, _ := core.New()
|
|
|
|
var completed atomic.Bool
|
|
var resultReceived any
|
|
|
|
c.RegisterAction(func(c *core.Core, msg core.Message) error {
|
|
if tc, ok := msg.(core.ActionTaskCompleted); ok {
|
|
resultReceived = tc.Result
|
|
completed.Store(true)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
c.RegisterTask(func(c *core.Core, task core.Task) (any, bool, error) {
|
|
return "async-result", true, nil
|
|
})
|
|
|
|
taskID := c.PerformAsync(MyTask{})
|
|
assert.NotEmpty(t, taskID)
|
|
|
|
assert.Eventually(t, func() bool {
|
|
return completed.Load()
|
|
}, 1*time.Second, 10*time.Millisecond)
|
|
|
|
assert.Equal(t, "async-result", resultReceived)
|
|
}
|
|
```
|
|
|
|
## Testing with Context Cancellation
|
|
|
|
Verify that lifecycle methods respect context cancellation:
|
|
|
|
```go
|
|
func TestStartupCancellation(t *testing.T) {
|
|
c, _ := core.New()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel immediately
|
|
|
|
s := &MockStartable{}
|
|
_ = c.RegisterService("s1", s)
|
|
|
|
err := c.ServiceStartup(ctx, nil)
|
|
assert.Error(t, err)
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
assert.False(t, s.started)
|
|
}
|
|
```
|
|
|
|
## Global Instance in Tests
|
|
|
|
If your code under test uses `core.App()` or `core.GetInstance()`, save and restore the global instance:
|
|
|
|
```go
|
|
func TestWithGlobalInstance(t *testing.T) {
|
|
original := core.GetInstance()
|
|
defer core.SetInstance(original)
|
|
|
|
c, _ := core.New(core.WithApp(&mockApp{}))
|
|
core.SetInstance(c)
|
|
|
|
// Test code that calls core.App()
|
|
assert.NotNil(t, core.App())
|
|
}
|
|
```
|
|
|
|
Or use `ClearInstance()` to ensure a clean state:
|
|
|
|
```go
|
|
func TestAppPanicsWhenNotSet(t *testing.T) {
|
|
original := core.GetInstance()
|
|
core.ClearInstance()
|
|
defer core.SetInstance(original)
|
|
|
|
assert.Panics(t, func() {
|
|
core.App()
|
|
})
|
|
}
|
|
```
|
|
|
|
## Fuzz Testing
|
|
|
|
Core includes fuzz tests for critical paths. The pattern is to exercise constructors and registries with arbitrary input:
|
|
|
|
```go
|
|
func FuzzE(f *testing.F) {
|
|
f.Add("svc.Method", "something broke", true)
|
|
f.Add("", "", false)
|
|
|
|
f.Fuzz(func(t *testing.T, op, msg string, withErr bool) {
|
|
var underlying error
|
|
if withErr {
|
|
underlying = errors.New("wrapped")
|
|
}
|
|
e := core.E(op, msg, underlying)
|
|
if e == nil {
|
|
t.Fatal("E() returned nil")
|
|
}
|
|
})
|
|
}
|
|
```
|
|
|
|
Run fuzz tests with:
|
|
|
|
```bash
|
|
core go test --run Fuzz --fuzz FuzzE
|
|
```
|
|
|
|
Or directly with `go test`:
|
|
|
|
```bash
|
|
go test -fuzz FuzzE ./pkg/core/
|
|
```
|
|
|
|
## Benchmarks
|
|
|
|
Core includes benchmarks for the message bus. Run them with:
|
|
|
|
```bash
|
|
go test -bench . ./pkg/core/
|
|
```
|
|
|
|
Available benchmarks:
|
|
|
|
- `BenchmarkMessageBus_Action` -- ACTION dispatch throughput
|
|
- `BenchmarkMessageBus_Query` -- QUERY dispatch throughput
|
|
- `BenchmarkMessageBus_Perform` -- PERFORM dispatch throughput
|
|
|
|
## Running Tests
|
|
|
|
```bash
|
|
# All tests
|
|
core go test
|
|
|
|
# Single test
|
|
core go test --run TestCore_New_Good
|
|
|
|
# With race detector
|
|
go test -race ./pkg/core/
|
|
|
|
# Coverage
|
|
core go cov
|
|
core go cov --open # opens HTML report in browser
|
|
```
|
|
|
|
## Related Pages
|
|
|
|
- [Services](services.md) -- what you are testing
|
|
- [Lifecycle](lifecycle.md) -- startup/shutdown behaviour
|
|
- [Messaging](messaging.md) -- ACTION/QUERY/PERFORM
|
|
- [Errors](errors.md) -- the `E()` helper used in tests
|