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/cmp for deep comparisons, github.com/davecgh/go-spew/spew for value dumps, go.uber.org/mock/gomock for 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 _test suffix. Terraform tests internal state heavily, which favors white-box placement.
  • Helper packages:
    • internal/providers/testing/ — The central MockProvider type: a ~500-line hand-written struct implementing providers.Interface. Each RPC method gets three spy fields: FooCalled bool, FooRequest providers.FooRequest, and FooResponse *providers.FooResponse, plus an optional FooFn func(...) override. Tests set only the fields they care about; all others default to zero values. The var _ providers.Interface = (*MockProvider)(nil) compile-time check ensures it tracks interface evolution.
    • internal/command/testing/TestProvider for CLI-level command tests, a lighter wrapper around MockProvider.
    • internal/cloudplugin/mock_cloudproto1/ — Generated gomock mock for the gRPC CommandServiceClient interface. 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 core internal/terraform/ package loads them via testModule(t, "plan-good") which reads from internal/terraform/testdata/. Command tests also have a large internal/command/testdata/ tree covering dozens of CLI workflows.

Test patterns#

Table-driven tests#

  • Prevalence: Very heavy — 2,299 occurrences of t.Run, testCases, or tc.name patterns across *_test.go files
  • Style: Anonymous struct slice is the dominant form: tests := []struct{ name string; ... }{{...}, {...}} followed by for _, 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 a testContext2, loads a testModule, and inspects tfdiags.Diagnostics.

Mocking approach#

  • Strategy: Hand-written spy structs (the MockProvider pattern). Each method on MockProvider records that it was called, stores the last request, and returns either a preset response or delegates to an optional Fn override:
    func (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{}
    }
    This gives tests precise control: they can assert p.PlanResourceChangeCalled, inspect p.PlanResourceChangeRequest, or inject custom behavior via Fn.
  • 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 mockery or 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 (via TestMain + go build), then runs it as a subprocess against real or simulated providers. Covers init, plan, apply, provider installation, state backends, and the test command itself. The TF_ACC=1 environment variable gates tests that reach external services.
  • Equivalence tests: testing/equivalence-tests/ — A snapshot-based golden-output test layer using the external terraform-equivalence-testing binary. Tests define an HCL configuration + expected command output. On PR open, CI runs diff and comments on divergences. On PR merge, CI runs update and 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 e2etest package; tests that need real network access use t.Skip() when TF_ACC is 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):

JobCommandScope
Unit testsgo test -cover ./... (all modules)All packages
Race detectorgo test -race ./internal/terraform ./internal/command ./internal/statesHigh-concurrency packages only
End-to-end testsTF_ACC=1 go test -v ./internal/command/e2etestFull binary workflow
Equivalence testsExternal terraform-equivalence-testing diffCLI 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/Fn pattern 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 .tf files in testdata/ 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: e2etest compiling and running a real terraform binary 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 -race rather 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 _test package 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 within internal/.
  • 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: The testContext2 helper correctly calls t.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#

  1. The MockProvider spy pattern: For interfaces with many RPC-style methods, the Called/Request/Response/Fn triple per method is more useful than gomock for most test assertions — you get free call recording without setting up expectations, and optional Fn injection for cases that need dynamic behavior.
  2. 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.
  3. Scoped race detection: Running -race only 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.
  4. E2E binary compilation in TestMain: The pattern of compiling the real binary once in TestMain and running all E2E tests against it is reusable for any CLI tool that has integration behavior visible only at the binary level.