?

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:

  1. The fake is inspectable — calls captures every request, so assertions can check what the code under test emitted.
  2. 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.WithCancel and cancel() at the end of the test.
  • Use a sync.WaitGroup and wg.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/http works.
  • 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

Type to search 150+ pages. Ranking: BM25 with title/description boost.