4.2. Testing
In one glance
- You will: Run the whole offline suite, see what it keeps real, and add your own table-driven case to a tool boundary.
- You need:
mise run installdone. No model, key, container, or network. - Time: about 24 minutes, hands-on.
Why one nondeterministic component does not make the agent untestable
A scripted model is a test double that implements ADK’s model.LLM interface and never calls a provider. Instead it returns known sequences of text, tool calls, tool results, and usage metadata. It serves one rule: replace the component that never gives you the same answer twice, and keep every other real thing real.
Without that rule, a team picks one of two losing options. Test nothing, on the grounds that it is all probabilistic anyway, and six months later nobody dares touch the confirmation flow without a live model and a good feeling. Test everything through the model, and your suite needs a GPU, takes twenty minutes, and goes red on a Tuesday because a sampler drifted.
The reference agent is twenty packages of ordinary Go — argument parsing, a SQLite transaction, an approval flow, an A2A task store — with one nondeterministic component in the middle of all of them. This page runs the whole offline suite, draws the line between what the scripted model replaces and what stays real, reads the coverage floor for what it gates, and leaves you one table case. Start with the run:
cd agents/go
mise run test✓ config (cached) (coverage: 91.0% of statements)
✓ tools (cached) (coverage: 98.5% of statements)
✓ policy (cached) (coverage: 90.9% of statements)
✓ compose (cached) (coverage: 91.7% of statements)
✓ a2aserver (cached) (coverage: 84.2% of statements)
✓ state (cached) (coverage: 82.7% of statements)
✓ domain (2.271s) (coverage: 99.4% of statements)
DONE 1815 tests, 1 skipped in 2.272sTrimmed to seven of the twenty-two package lines, but the summary is the real one. The whole agent — configuration, domain, tools, policy, sessions, A2A, MCP, state, telemetry — is decided under the race detector, with no service you have to start first and nothing spending a token. That detector is -race, which reports unsynchronized memory access. The suite opens plenty of loopback listeners of its own, and the A2A and MCP transport tests would prove nothing without them; what it never does is reach past your machine.
That run reused Go’s test cache, as the (cached) markers admit, so your first run takes longer. Either way, a red line here is a real failure rather than a missing piece of setup, worth reading instead of re-running.
The substitution stays narrow on purpose. Everything around the scripted model is the shipping code: the ADK runner, the plugins, the confirmation flow, sessions, tools, SQLite state, telemetry. So when a test proves that a guarded write pauses for approval, it proves it about the same code path a live turn walks — the only thing scripted was the sentence that asked.
The offline suite settles a longer list than most teams expect:
- Configuration parsing and cross-field failures.
- Domain normalization and strict protocol decoding.
- Read tools, guarded writes, audit transactions, and idempotent replay.
- Policy ordering, redaction, injection handling, budget, and compaction.
- Sessions, A2A tasks, MCP filtering, cancellation, and drain behavior.
- Telemetry field shape and content-capture defaults.
- Static binary configuration and executable startup boundaries.
What the scripted model replaces, and what stays real
The database is real. Tests copy the seed — the committed agents/data/incidents.db dataset — into a temporary directory and point every writable store there, so transactions, constraints, locks, and migrations are genuinely exercised. A mocked repository can assert which methods were called; it cannot prove SQLite locking, trigger behavior, atomicity, or crash-recovery journal semantics. When the guarded-action test claims that a state change and its audit row commit or roll back together, that claim was tested against SQLite doing the work.
The seed itself must come out of every run byte-identical:
cd agents/go
mise run test
cd ../..
test -z "$(git status --porcelain agents/data/incidents.db)"If that last command prints a path, stop and find out what wrote to it. A test that quietly mutates the committed dataset makes every later run a measurement of a different system.
Time is real, but controllable. Retries, circuit resets, and concurrent waits are exactly the behavior that wall-clock sleeps test badly: slow when they pass, flaky when they fail. A testing/synctest bubble is a group of goroutines with its own clock, which jumps to the next pending timer as soon as every goroutine in the bubble is durably blocked. The resilience tests run inside a bubble, advance timers deterministically, and call synctest.Wait when they need every runnable goroutine to become durably blocked. A thirty-second cooldown finishes instantly, and no fake clock has to be threaded through a production API to make it happen. Use a bubble for timer- and scheduling-driven behavior; leave pure functions to table-driven tests, where inputs and expected outputs sit in a slice, one subtest per row.
Concurrency is real. -race instruments memory access and reports unsynchronized reads and writes on the paths the suite actually exercises — session serialization, index creation, circuit state, restore locks, graceful shutdown. What it proves is bounded: no detected race on the interleavings this run happened to take, which is not the same as no race.
The evaluator is a stranger. The evals module drives the agent’s REST and A2A surfaces as a client and deliberately does not import agents/go. That is enforced rather than remembered: a test resolves the Go package graph and fails if an agent package appears anywhere in it. An evaluator that shares producer types stops being able to disagree with the producer.
One tool is deliberately absent. Rapid, the property-based library, would be the right instrument for invariants such as parser round trips, identifier normalization, pagination tokens, and event folding. It is not in go.mod, because the reviewed table and concurrency tests already cover the committed invariants. Add it when you have a property that ordinary examples cannot express, then pin it and keep the seed reproducible: a dependency decision, not a licence to write random tests whose failures nobody can replay.
Deeper: what one of those properties would actually say
A property is a claim quantified over every input, not an example. The clearest one available here is about domain.NormalizeIncidentID: for every string, normalization either returns an error or returns a value that normalizes to itself. That single sentence covers idempotence, and it catches the class of bug a table cannot — an input that normalizes to something which is itself invalid, or to something that keeps changing on each pass. A table test asserts "inc-002" becomes INC-002; the property asserts there is no string anywhere for which the function both succeeds and lies.
Two more in the same shape: folding an event stream twice must equal folding it once, and a redactor’s output must never contain a pattern the redactor matches. Each is one line of English before it is any code, and if you cannot write that line, the property is not ready to be a test.
The coverage floor is per package, and it gates reach only
mise run test does one more thing after the suite: it fails the module if any package sits below 80% line coverage. Three of its twenty per-package lines, and the verdict it ends with:
ok 90.9% agents/go/policy
ok 82.7% agents/go/state
ok 98.5% agents/go/tools
agents/go meets the 80% per-package coverage floorThe floor is checked per package rather than as one module total, because a total lets a large, well-tested package pay for a small, untested one — and the small one is usually the guardrail. cmd/ packages are excluded by kind: they are package main composition wiring — flag parsing, dependency construction, process lifecycle — that this project has chosen not to hold to the floor. Their coverage is measured like every other package’s and simply sits below it, so the exclusion buys a gate that stays meaningful rather than one everybody learns to override. Read it as a scope decision recorded in scripts/check-coverage.sh, not as a claim that something else covers that code.
Read the percentage as a regression gate on how much code the tests reach, and as nothing else: a test that calls a function and checks it did not panic raises the number and proves nothing. The suite as a whole is bounded the same way. It settles everything listed above and says nothing about a live model’s tool choice, a collector, a browser, or a draining cluster. 0.2. Evidence owns where that line falls; 4.4. Evaluations and Chapters 5 to 7 own the evidence beyond it.
Your turn: add one table-driven case for a tool edge
The get_incident test already covers four situations. Two of them are their own t.Run blocks — a known incident and a padded lower-case id — and two sit in a cases table below them, a well-formed unknown id and a traversal-shaped one. You are going to add a fifth to that table: the empty-string id, the edge nobody thought about. Predict which refusal it produces, invalid shape or not found, before you write the expectation.
- Mode:
keep. - Goal: extend the
get_incidenttable with the empty-string argument, predicting the exact refusal before you run it. - Files to touch:
agents/go/tools/read_test.goonly. Leave the tool implementation alone. - Preflight:
cd agents/go && go test ./tools -run TestGetIncidentReturnsTheFullRecordOrSaysWhyNot -count=1is green before you edit anything. - Steps: write down which of the two refusals an empty id produces — the invalid-shape one or the not-found one — then add a case to the
casesslice inTestGetIncidentReturnsTheFullRecordOrSaysWhyNotwith your answer aswant, and run the focused test. - Gate that proves completion:
cd agents/go && go test ./tools -run TestGetIncidentReturnsTheFullRecordOrSaysWhyNot -v -count=1lists your new subtest beside the four existing ones and reportsokfor the package. - Final state: keep the new case.
mise run checkandmise run testare green fromagents/go, and the committedagents/data/incidents.dbis unchanged.
If you predicted the not-found message, the table says so in one line:
--- FAIL: TestGetIncidentReturnsTheFullRecordOrSaysWhyNot (0.02s)
--- FAIL: TestGetIncidentReturnsTheFullRecordOrSaysWhyNot/empty_id (0.00s)
read_test.go:213: Error = "Invalid incident id \"\"; expected an id like INC-002.", want "No incident found with id \"\"."Your line number will differ with where you inserted the case, but the message will not. An empty string never becomes an id, so it is refused by shape before any lookup happens — the same branch that refused INC-2; DROP TABLE incidents in 4.0. Type Safety. Correct the expectation and your case joins the table:
--- PASS: TestGetIncidentReturnsTheFullRecordOrSaysWhyNot/empty_id (0.00s)What you can do now
- You can run the whole suite under
-racein seconds, and read a red line as a real failure, not missing setup. - You can say precisely what the scripted model replaces and what it leaves real — and why the SQLite transaction is not one of them.
- You can predict which refusal an empty id draws — invalid shape, before any lookup — and pin it as a table case.
- You can state what clearing the 80% floor does and does not license you to claim.
Continue to 4.3. Metrics, where a green suite becomes one row in a scorecard rather than the whole verdict.