The pattern
Every package that talks to the outside world defines a small interface for its dependency, takes that interface as a constructor parameter, and injects a real client in cli/*.go (production) or a fake in *_test.go (tests).
Examples in the tree:
| Package | Interface | Real | Fake for tests |
|---|---|---|---|
internal/transport/whatsapp |
WSConn |
whatsmeow's WebSocket | in-memory struct with a send channel |
internal/transport/email |
IMAPClient |
emersion/go-imap client |
scripted channel of messages |
internal/transport/whatsapp |
Sender |
direct whatsmeow send | in-memory slice for assertion |
internal/llm/* |
HTTPClient (indirect via http.Client) |
http.DefaultTransport |
httptest.NewServer |
internal/state/sqlite |
state.Store (interface owned by state) |
modernc.org/sqlite on-disk |
in-memory :memory: DSN |
internal/agent |
Provider, Approver, Compressor, RecallProvider |
concrete llm/* types |
struct implementations in _test.go |
The rule: interface with the consumer, implementation with the provider. Provider is defined in agent, not in llm/anthropic. Store is defined in state, not in state/sqlite.
Running the gate
make check
is equivalent to:
go vet ./...
golangci-lint run
go test -race -count=1 -covermode=atomic ./...
govulncheck ./...
CI runs the same command on ubuntu-latest and macos-latest. If it passes locally, it passes in CI — barring platform-specific bugs, which is why macOS is in the matrix.
Race detector
-race is non-negotiable. Every daemon in rousseau involves multiple goroutines (transport pump, agent loop, cron scheduler, session-store writer). A race in any one of them is a real bug.
If you find a test that only fails under -race, that is a bug in the code under test, not in the test. Do not disable -race.
Coverage floor
The current coverage floor is 75% total. Core packages (internal/agent, internal/tools, internal/state/sqlite) sit at 85–100% and are held there by the pre-existing test suite; new code in those packages should not lower them.
A CI job runs after go test -race -covermode=atomic ./... -coverprofile=coverage.out and inspects coverage.out. Failing the floor fails the build.
Fake generators
Rousseau does not use a mock-generation library. Fakes are hand-written struct types, small enough to read at a glance:
type fakeProvider struct {
responses []agent.Response
calls []agent.Request
}
func (f *fakeProvider) Complete(_ context.Context, req agent.Request) (agent.Response, error) {
f.calls = append(f.calls, req)
if len(f.responses) == 0 {
return agent.Response{}, errors.New("no more canned responses")
}
resp := f.responses[0]
f.responses = f.responses[1:]
return resp, nil
}
Two properties fall out:
- The fake is inspectable —
callscaptures every request, so assertions can check what the code under test emitted. - The fake is deterministic — canned responses are consumed in order.
httptest for HTTP-shaped providers
Every LLM adapter that talks HTTP uses httptest.NewServer for tests:
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"role": "assistant",
"content": []map[string]any{{"type": "text", "text": "hello"}},
"stop_reason":"end_turn",
})
}))
defer srv.Close()
p := anthropic.New(anthropic.Config{
APIKey: "test",
BaseURL: srv.URL,
Model: "test-model",
})
For SSE-style streaming, the same technique works — http.Flusher is available on the response writer.
Fuzz corpus
Every parser has a Fuzz* function. Run the full battery:
make fuzz
Under CI, fuzz runs for a bounded time (-fuzztime). Locally, run longer to seed the corpus.
Table-driven tests
Rousseau's tests lean heavily on table-driven form. Example shape:
func TestPatternApprover_Approve(t *testing.T) {
tests := []struct {
name string
approver *agent.PatternApprover
req agent.ApprovalRequest
want agent.Decision
}{
{
name: "allow read",
approver: &agent.PatternApprover{Allow: []agent.PatternRule{{ToolName: "read"}}},
req: agent.ApprovalRequest{ToolName: "read"},
want: agent.DecisionAllow,
},
{
name: "deny wins over allow",
approver: &agent.PatternApprover{
Allow: []agent.PatternRule{{ToolName: "bash"}},
Deny: []agent.PatternRule{{ToolName: "bash", Match: "rm"}},
},
req: agent.ApprovalRequest{ToolName: "bash", Input: json.RawMessage(`{"command":"rm -rf /"}`)},
want: agent.DecisionDeny,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, _ := tc.approver.Approve(context.Background(), tc.req)
require.Equal(t, tc.want, got)
})
}
}
This scales — every new rule shape becomes one table row.
Goroutine leaks
Tests that spawn goroutines must join them. Common patterns:
- Use
context.WithCancelandcancel()at the end of the test. - Use a
sync.WaitGroupandwg.Wait(). - Consume every channel to
close.
If a test leaks a goroutine, go test -race may catch it via a nil-receiver panic on the leaked goroutine after the test file's main has exited. Cheaper to be disciplined up front.
Deterministic time
For time-sensitive tests (cron, recall recency ranking), inject a time.Time provider:
type Clock interface {
Now() time.Time
}
Wire the real time.Now in cli/* and a fake time.Time in the test. The internal/cron/scheduler.go scheduler uses this pattern.
Testing the TUI
internal/tui/model_test.go uses bubbletea's TestModel helper. View() is a pure string function of the model, so most assertions become "run this update, expect this View output".
What not to test
- Third-party libraries. Rousseau does not shadow whatsmeow's or
signal-cli's upstream tests. - The Go standard library.
net/httpworks. - CLI flag registration by Cobra. Cobra's own tests cover that.
Instead, test the code you write: the wire-up, the branching, the error paths, the recovery paths.
Next
- Add a transport — the fake-injection pattern applied to a full transport.
- Add a provider —
httptestin action. - Contributing — the PR checklist.