Testing
Three doubles, and picking the wrong one is the usual reason a test passes against behavior no provider would ever produce.
Contents
- Which one do I want
- ScriptedDriver
- MockDriver
- The global registry
- Why elelemtest imports no test framework
Which one do I want
One question: does the test need the model to say something?
elelemtest.ScriptedDriver | elelemtest/mocks.MockDriver | |
|---|---|---|
| It is a fake… | model | neighbour |
| Use when testing… | code that consumes model output: the turn loop, tool calls, streaming, history | code that wraps or calls a Driver: decorators, retry, metrics, registries |
| You assert on… | what came out of your code | what went into the Driver, and that it was called |
| Multi-turn conversation | yes, one Turn per driver call | no |
| “called twice with X” | no | yes: matchers, .Once(), AssertExpectations |
Honors the Driver contract | yes | no |
That last row is the one that matters. ScriptedDriver can’t be made to emit a shape no real provider emits, so a test built on it can’t pass against fiction. MockDriver will return whatever you tell it to, including impossible things. That is the point when you’re testing the thing that wraps a driver, and a trap when you’re not.
ScriptedDriver
Plays a sequence of turns, one per driver call. No HTTP.
driver := elelemtest.NewScriptedDriver(
elelemtest.ToolCall("call-1", "lookup_incidents", `{"service":"api"}`),
elelemtest.Text("the answer"),
)
client := elelem.New(driver)
Turns are constructor arguments. One is consumed per driver call, so the list above is a two-round conversation: the model asks for a tool, then answers.
Turn constructors:
| Constructor | Emits |
|---|---|
Text(s) | one text delta, finish reason stop |
Thinking(reasoning, answer) | a reasoning delta then a text delta |
ToolCall(id, name, arguments) | a tool-call turn |
Turn{Deltas, Usage, Err} | whatever you build by hand |
Turn.WithUsage(u) attaches usage to any of them. Setting Turn.Err makes that turn fail, which is how you test the failure path without a network.
It records what it was asked for, so you can assert on the request your code actually built:
driver.Calls() // how many times the driver was called
driver.Requests() // every DriverRequest, in order
driver.LastRequest() // (DriverRequest, bool)
driver.Streamed() // one bool per call: true if it came in via Stream
Streamed() is what a test asserts on when the thing under test is the transport choice itself. WithStreaming(false) should move a call from Stream to Complete and change nothing else. Both paths replay the same turn through the same delta callback, so every other assertion in the test holds either way; that is the property, and a double that quietly reshaped the output on one path would hide a break in it.
Programmable self-reporting, because a double that always claims full support can’t exercise a capability gate:
driver.
WithModels("model-a", "model-b").
WithCapabilities(elelem.Capabilities{SupportsSeed: true}). // replaces wholesale
WithTokenCounter(myCounter)
WithCapabilities replaces the whole struct. It doesn’t merge over the permissive defaults. That’s deliberate: a partial override that silently kept defaults would make a gate test pass for the wrong reason.
MockDriver
Generated by mockery. Regenerate with make generate after changing Driver; don’t hand-edit driver.gen.go.
driver := mocks.NewMockDriver(t) // AssertExpectations runs on t.Cleanup
driver.EXPECT().
ListModels(context.Background()).
Return([]string{"model-a"}, nil).
Once()
Matching gotcha: the retry decorator hands the driver its own delta callback so it can tell whether output has started, so it is never the callback you passed in. Match it with mock.Anything:
driver.EXPECT().
Stream(context.Background(), elelem.DriverRequest{}, mock.Anything).
RunAndReturn(func(
context.Context,
elelem.DriverRequest,
func(elelem.Delta) error,
) (elelem.Usage, error) {
return elelem.Usage{}, io.EOF
}).
Times(3)
MockDriver lives in elelemtest/mocks, which imports elelem, so a test in package elelem that imports it is an import cycle. Use the external test package (package elelem_test) for mock-based tests of this library itself.
The global registry
An application builds its Driver deep inside its own wiring, so a test that wants to program it never holds the reference. The registry closes that gap:
func TestSomething(t *testing.T) {
t.Cleanup(elelemtest.ResetGlobalScriptedDriver)
driver := elelemtest.NewScriptedDriver(elelemtest.Text("hi"))
elelemtest.SetGlobalScriptedDriver(driver)
// the app's own wiring picks this up under `go test`
}
GlobalScriptedDriver() returns nil when none is set. Always reset in a cleanup. A leaked driver hands the next test someone else’s script.
The alternative would be handing tests a constructor hook, which means the test exercises a wiring path production never takes. The registry is process-wide state, so tests touching it must not be t.Parallel.
Why elelemtest imports no test framework
elelemtest imports nothing but elelem and ctxerrors. No testing, no testify. That’s a hard constraint, not an accident: production code reaches this package. An app can resolve every upstream to the scripted driver under go test so a suite can’t dial a real provider by accident, and that import must not drag a test framework into the shipped binary.
elelemtest/conformance is a separate package for exactly that reason. It needs testing and testify, so it can’t live alongside the doubles. See drivers.md.