Buildkite Agent — Testing#
Test metrics#
- Test files: 120
- Source files (non-test): ~249
- Ratio (test files / source files): ~0.48 (roughly 1:2)
- Test frameworks:
testing(stdlib),github.com/stretchr/testify(assert/require),gotest.tools/v3/assert,github.com/buildkite/bintest/v3,net/http/httptest
Test organization#
- Placement: Primarily same-package (white-box) tests; ~27 files use external
_testpackages (e.g.api_test,agent_test) for black-box testing of exported APIs - Helper packages:
internal/job/integration/—ExecutorTesterhelper that spawns the actual bootstrap subprocess viaexec.Cmdand usesbintestto intercept external binary calls; includesgitRepositoryhelper for in-process git reposinternal/e2e/— end-to-end framework that connects to a real Buildkite API;testcase.gowraps agent lifecycle, job triggering, and result fetchinginternal/replacer/testdata/— fixture files for the streaming redactor tests
- Fixtures: Minimal; mostly generated inline (shell scripts written to
os.MkdirTemp);testdata/only present for the replacer package; e2e test cases reference YAML fixture files
Test patterns#
Table-driven tests#
- Prevalence: Heavy — 150 instances of
t.Run,testCases,tt.Run, ortc.namepatterns across 120 test files - Style: Named anonymous struct slices —
for _, test := range []struct{ name string; ... }{ {...}, {...} }followed byt.Run(test.name, func(t *testing.T) { ... }) - Example:
api/secrets_test.go:18— table of success/failure scenarios forGetSecret, each with distinct HTTP status codes, tokens, and expected errors;agent/integration/job_runner_integration_test.go:25— table-driven pre-bootstrap hook test with platform-specific cases appended at runtime
Mocking approach#
- Strategy: Layered, with different tools at different abstraction levels:
- Interface fakes (unit level): Each package defines its own narrow
APIClientinterface and tests supply a hand-written struct implementing only the needed methods (e.g.internal/artifact,internal/secrets). No mock generation framework. httptest.NewServer(API level): 38 usages. Tests for theapi/package spin up a real HTTP server with a custom handler; theapi.Clientunder test hits the real network stack against127.0.0.1. This catches serialization bugs and HTTP header issues that interface mocks would miss.bintest(subprocess level): Integration tests usegithub.com/buildkite/bintest/v3— Buildkite’s own binary proxy library.tester.MustMock(t, "git")creates a real executable on$PATHthat intercepts OS-level subprocess invocations and feeds responses from within the test process. SupportsExpect().Once(),WithAnyArguments(),AndCallFunc(...), andPassthroughToLocalCommand().
- Interface fakes (unit level): Each package defines its own narrow
- Example:
internal/job/integration/hooks_integration_test.go:50:This lets the real git command run while also asserting that specific environment variables were propagated to the subprocess.git := tester.MustMock(t, "git").PassthroughToLocalCommand().Before(func(i bintest.Invocation) error { return bintest.ExpectEnv(t, i.Env, "MY_CUSTOM_ENV=1", "LLAMAS_ROCK=absolutely") }) git.Expect().AtLeastOnce().WithAnyArguments()
Integration tests#
- Present: Yes — two layers
- Layer 1 — Bootstrap integration (
internal/job/integration/,agent/integration/):- 14
*_integration_test.gofiles covering hooks, plugins, redaction, checkout, git mirrors, artifact upload, Docker, job API socket, config allowlisting, job verification, and environment propagation - Run with
go test ./internal/job/integration/...— no build tag required; these run locally against the OS TestMainininternal/job/integration/main_test.gostarts abintest.StartServer()for all tests to share, then re-runs itself as abuildkite-agent bootstrapbinary for the subprocess to call back intoExecutorTester.RunAndCheck(t, ...)shells out to the real bootstrap binary; tests validate actual environment variable propagation, hook execution order, and output content- Platform-aware: Windows conditional paths for
.bat/.ps1hooks,runtime.GOOSguards, UDS path-length workaround (/tmpinstead of system temp on Linux for socket names ≤ 108 chars)
- 14
- Layer 2 — E2E (
internal/e2e/):- Gated by
//go:build e2ebuild tag; requires a real Buildkite API token and registered cluster TestMainauthenticates viaapi.Client.GetTokenIdentity, discovers org/cluster slugs- Tests start a live agent process (
tc.startAgent()), trigger a real build (tc.triggerBuild()), poll for completion with a context timeout, and fetch/assert logs Dockerfile-e2eandpipeline.e2e.ymlshow these run in CI on actual Buildkite infrastructure
- Gated by
- Separation: Integration tests live in
integration/subdirectories under bothinternal/job/andagent/; e2e tests are isolated ininternal/e2e/with a//go:build e2etag
Test quality observations#
What’s done well:
- Three-tier test pyramid is explicit and intentional: unit tests with interface fakes → bootstrap integration with
bintest→ live e2e. Each tier tests a different failure mode; the layers don’t duplicate each other’s concerns. bintestis architecturally clever: Because the agent’s most critical behavior is what environment variables and arguments reach external processes (git, ssh, docker), mocking at the OS binary level — rather than the Go interface level — catches an entire class of bugs invisible to interface mocks. The pattern ofPassthroughToLocalCommand().Before(assertEnvFunc)combines real behavior with assertion, avoiding the need to stub return values.TestMaindual-mode trick in integration tests:main_test.godetects whether it’s being invoked as a test runner or as abuildkite-agentsubprocess. This allows the test binary to serve as the bootstrap executable itself, eliminating the need to pre-build a separate binary for integration tests.- Heavy use of
t.Parallel(): Most integration tests callt.Parallel()at the top, making full use of Go’s parallel subtest support. Combined withbintest’s shared server, this means dozens of real subprocess invocations run concurrently without port conflicts. httptest.NewServerfor API layer: API tests avoid interface mocks entirely; the realapi.Clientserialization/deserialization is exercised on every test run.- Platform coverage: Windows paths are explicitly handled in integration tests with
runtime.GOOSguards and appended test cases, not hidden behind a separate CI-only job.
- Three-tier test pyramid is explicit and intentional: unit tests with interface fakes → bootstrap integration with
What could improve:
- No mock generation: All interface fakes are hand-written. For the current set of narrow interfaces this is fine, but as the
APIClientinterface incore/grows it will require manual maintenance. ExecutorTestersetup is heavyweight: Creating a temp home directory, git repo, and multiple mock processes for each test is expensive. Tests rely ont.Parallel()to amortize this, but there is no shared fixture orTestMain-level setup for the heavier filesystem work.- e2e tests require live credentials: There is no recorded-response or contract-test alternative, so e2e tests cannot run in forks or offline. A cassette-based approach (e.g.
go-vcr) could enable offline replay for a subset of scenarios. - Limited assertion libraries: The mix of
testify/assert,gotest.tools/v3/assert, and rawif got != wantcomparisons is inconsistent.gotest.toolsis used in integration tests andtestifyin unit tests, with no apparent policy.
- No mock generation: All interface fakes are hand-written. For the current set of narrow interfaces this is fine, but as the
Patterns worth emulating:
bintest-style OS-level binary mocking for systems that shell out to external processes — far more realistic than interface mocks for CLI agents, CI runners, and build tools.TestMaindual-mode binary in integration tests — allows the test binary to impersonate the real binary without a separate build step, keeping integration tests self-contained and fast to iterate.- Narrow per-package interface fakes — each package defines only the API methods it consumes; tests supply minimal implementations. This keeps test code small and focused on the package under test.
httptest.NewServer+ real client for HTTP API testing — exercises the full serialization stack without infrastructure, catching JSON field name mismatches, header handling, and status code parsing that interface mocks miss entirely.