go/docs/testing.md
Snider 89d189dd95 docs: add human-friendly documentation for Core Go framework
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:02:37 +00:00

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