Terraform — Testing#
Test metrics#
- Test files: 629
- Source files (non-test): 1,278
- Ratio (test / source): ~0.49 (one test file for roughly every two source files)
- Total test functions: 3,565
- Test frameworks: stdlib
testing(primary),github.com/google/go-cmp/cmpfor deep comparisons,github.com/davecgh/go-spew/spewfor value dumps,go.uber.org/mock/gomockfor one gRPC mock package only — no testify
Test organization#
- Placement: Overwhelmingly same-package (internal) tests — 617 files use the package’s own package name; only 13 use the external
_testsuffix. Terraform tests internal state heavily, which favors white-box placement. - Helper packages:
internal/providers/testing/— The centralMockProvidertype: a ~500-line hand-written struct implementingproviders.Interface. Each RPC method gets three spy fields:FooCalled bool,FooRequest providers.FooRequest, andFooResponse *providers.FooResponse, plus an optionalFooFn func(...)override. Tests set only the fields they care about; all others default to zero values. Thevar _ providers.Interface = (*MockProvider)(nil)compile-time check ensures it tracks interface evolution.internal/command/testing/—TestProviderfor CLI-level command tests, a lighter wrapper aroundMockProvider.internal/cloudplugin/mock_cloudproto1/— Generatedgomockmock for the gRPCCommandServiceClientinterface. This is the only generated mock in the repo; all other mocking is hand-written.
- Fixtures: Extensive
testdata/directories alongside source packages containing real.tf(HCL) configuration files. The coreinternal/terraform/package loads them viatestModule(t, "plan-good")which reads frominternal/terraform/testdata/. Command tests also have a largeinternal/command/testdata/tree covering dozens of CLI workflows.
Test patterns#
Table-driven tests#
- Prevalence: Very heavy — 2,299 occurrences of
t.Run,testCases, ortc.namepatterns across*_test.gofiles - Style: Anonymous struct slice is the dominant form:
tests := []struct{ name string; ... }{{...}, {...}}followed byfor _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ... }) } - Example:
internal/terraform/context_plan_test.go— The file contains dozens of table-driven suites exercising plan behavior across HCL configuration variations. Each sub-test constructs atestContext2, loads atestModule, and inspectstfdiags.Diagnostics.
Mocking approach#
- Strategy: Hand-written spy structs (the
MockProviderpattern). Each method onMockProviderrecords that it was called, stores the last request, and returns either a preset response or delegates to an optionalFnoverride:This gives tests precise control: they can assertfunc (p *MockProvider) PlanResourceChange(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { p.Lock(); defer p.Unlock() p.PlanResourceChangeCalled = true p.PlanResourceChangeRequest = req if p.PlanResourceChangeFn != nil { return p.PlanResourceChangeFn(req) } if p.PlanResourceChangeResponse != nil { return *p.PlanResourceChangeResponse } return providers.PlanResourceChangeResponse{} }p.PlanResourceChangeCalled, inspectp.PlanResourceChangeRequest, or inject custom behavior viaFn. - gomock usage: Isolated to
internal/cloudplugin/mock_cloudproto1/for the gRPC streaming client interface. This makes sense: streaming gRPC interfaces are awkward to hand-mock, whereas provider RPCs are simple request/response structs. - No mockery: The codebase does not use
mockeryor any other mock generator for the main interfaces — the hand-written approach is preferred for richer introspection.
Integration tests#
- E2E test suite:
internal/command/e2etest/— A dedicated package that compiles a real Terraform binary from source at test startup (viaTestMain+go build), then runs it as a subprocess against real or simulated providers. Coversinit,plan,apply, provider installation, state backends, and thetestcommand itself. TheTF_ACC=1environment variable gates tests that reach external services. - Equivalence tests:
testing/equivalence-tests/— A snapshot-based golden-output test layer using the externalterraform-equivalence-testingbinary. Tests define an HCL configuration + expected command output. On PR open, CI runsdiffand comments on divergences. On PR merge, CI runsupdateand opens a follow-up PR with refreshed snapshots. This is the project’s answer to regression testing CLI output stability. - Separation: E2E tests live in a distinct
e2etestpackage; tests that need real network access uset.Skip()whenTF_ACCis unset. No build tags are used for this separation — environment variable gating is preferred.
CI pipeline#
From .github/workflows/checks.yml (runs on every PR):
| Job | Command | Scope |
|---|---|---|
| Unit tests | go test -cover ./... (all modules) | All packages |
| Race detector | go test -race ./internal/terraform ./internal/command ./internal/states | High-concurrency packages only |
| End-to-end tests | TF_ACC=1 go test -v ./internal/command/e2etest | Full binary workflow |
| Equivalence tests | External terraform-equivalence-testing diff | CLI output snapshots |
Race detection is deliberately scoped to three packages — the graph walker, the CLI command layer, and the state layer — because the race detector significantly increases wall-clock time and these three packages contain virtually all goroutine interactions.
Test quality observations#
What’s done well#
- MockProvider spy fields: The per-method
Called/Request/Response/Fnpattern gives test authors fine-grained access to every provider RPC without any mocking framework overhead. It is verbose to define but concise to use. - HCL fixture files: Using real
.tffiles intestdata/means tests exercise the actual config loader and HCL parser, catching a whole class of parsing regressions invisible to unit tests built on in-memory structs. - E2E binary compilation:
e2etestcompiling and running a realterraformbinary is rare for a project of this complexity. It catches problems that exist at the OS/process boundary (binary linking, environment variables, signal handling) that in-process tests cannot detect. - Equivalence snapshot tests: The automated diff+update workflow creates a social contract around output changes — authors are forced to acknowledge any change to user-visible output by reviewing the snapshot diff in CI. This is a sophisticated answer to the “how do we prevent invisible regressions in CLI output” problem.
- Race detection in CI: Selecting the three most concurrent packages for
-racerather than running it everywhere is a mature trade-off: maximum signal-to-noise at minimal CI cost. - Table-driven scale: 3,565 test functions with 2,299 sub-test invocations covering extensive HCL variation represents one of the most thorough test suites in Go infrastructure tooling.
What could improve#
- Near-absence of black-box tests: Only 13 of 629 test files use the
_testpackage suffix. While white-box testing is appropriate for many internal packages, the lack of external package tests means internal refactors can silently break the intended public API of packages withininternal/. - No testcontainers / real backends: Backend integration tests (S3, GCS, Azure) require real cloud credentials and are skipped in standard CI. The E2E suite covers the local and remote-state-http backends in-process but not the cloud backends.
- Inconsistent
t.Helper()usage: ThetestContext2helper correctly callst.Helper(), but many other inline helper functions in test files don’t, making failure line numbers occasionally misleading in large table-driven suites. - Limited fuzz testing: Given that Terraform parses untrusted HCL from remote modules, fuzz testing for the config loader and expression evaluator would be high-value but is absent.
Patterns worth emulating#
- The MockProvider spy pattern: For interfaces with many RPC-style methods, the
Called/Request/Response/Fntriple per method is more useful than gomock for most test assertions — you get free call recording without setting up expectations, and optionalFninjection for cases that need dynamic behavior. - Equivalence (snapshot) testing for CLI output: The diff-on-PR / update-on-merge workflow cleanly manages snapshot drift and makes output regressions visible without requiring authors to manually update golden files in the PR.
- Scoped race detection: Running
-raceonly on the packages that contain concurrent code, rather than the entire binary, is the right trade-off for large monorepos with multi-minute test suites. - E2E binary compilation in TestMain: The pattern of compiling the real binary once in
TestMainand running all E2E tests against it is reusable for any CLI tool that has integration behavior visible only at the binary level.