Air — Testing#

Test metrics#

  • Test files: 9 (out of 26 total .go files — ratio ≈ 1:2.9)
  • Ratio (test files / source files): ~35% — respectable for a CLI tool of this scope
  • Test frameworks: stdlib testing + github.com/stretchr/testify (assert + require)

Test organization#

  • Placement: Same package (package runner) throughout — white-box access to unexported fields and methods. No _test package suffix is used anywhere.
  • Helper packages: None. There is no separate testutil/ or mocks/ directory. Test helpers are co-located as unexported functions (initTestEnv, chdir, GetPort, waitingPortReady, waitingPortConnectionRefused, waitForEngineState).
  • Fixtures: runner/_testdata/ contains shell scripts (child.sh, grandchild.sh, run-detached-process.sh, run-many-processes.sh), a watching/ directory tree for watch-path tests, and a toml/inner/ directory for config resolution tests. Fixtures are read from disk at test time (not embedded with //go:embed).

Test patterns#

Table-driven tests#

  • Prevalence: Heavy — 39 occurrences of t.Run( across test files
  • Style: Anonymous struct slices with descriptive name field:
    tests := []struct{ name string; input ...; expected ... }{ ... }
    for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ... }) }
  • Example: runner/config_test.go and runner/util_test.go — all config parsing and path utility branches are covered via table-driven cases
  • Mixed style: Some older tests (notably in engine_test.go) still use if err != nil { t.Fatalf(...) } assertions directly rather than testify — showing incremental adoption of testify over time

Mocking approach#

  • Strategy: No mocking framework. The only seam for injection is the exiter interface (one method: Exit(int)), which allows tests to intercept os.Exit calls without killing the test process. Everything else is tested through real types.
  • Example: engine_test.go constructs a real Engine via NewEngine("", nil, true) (the true flag enables debug mode, which suppresses actual binary spawning in some paths). Tests interact with actual filesystem, actual goroutines, and actual TCP sockets.

Integration tests#

  • Present: Yes — embedded within engine_test.go as regular Test* functions (not separated by build tag or naming convention)
  • How: TestRebuild, TestCtrlCWhenHaveKillDelay, TestCtrlCWhenREngineIsRunning, and related tests spin up a full Engine.Run() in a goroutine, build and launch a real Go HTTP server binary, then probe TCP ports to verify the subprocess is alive. They trigger a rebuild by writing a newline to main.go and wait for the port to cycle through ECONNREFUSED and back to accepting connections.
  • Separation: None at the code level. These heavyweight tests are distinguished only informally — t.Skip("unstable on Windows") guards are used where cross-platform execution is unreliable. CI timeout is multiplied by 2x when CI=true is set to accommodate slower runners.
  • initTestEnv pattern: A helper that creates a temp directory with a minimal Go HTTP server (main.go) configured to listen on a given port. This synthetic project is what air rebuilds during integration tests — a clever, self-contained approach that avoids requiring an external project.

Smoke tests (shell-level E2E)#

  • Mechanism: Separate GitHub Actions workflow (smoke_test.yml / smoke_test_reuse_job.yml) — not Go tests at all. The actual air binary is installed (make install), run with nohup against smoke_test/check_rebuild/, then a newline is appended to main.go. The workflow counts occurrences of "running" in the output log and asserts the count equals 2 (initial run + rebuild).
  • Coverage: Runs on ubuntu, macos, and windows in parallel.
  • Value: This is the only true black-box end-to-end verification that the file-watching + rebuild loop works as a user would experience it.

Concurrency testing#

  • TestProxyStream (proxy_stream_test.go): Spawns 10 concurrent goroutines calling stream.AddSubscriber() with a sync.WaitGroup, then fires Reload() concurrently while 10 goroutines drain subscriber channels. Tests that all 10 receive the message and that RemoveSubscriber/Stop work correctly. Uses atomic.Int32 for the reload counter — mirroring the production code’s own atomic patterns.
  • Port polling loop in engine tests uses a 20ms ticker rather than time.Sleep, keeping integration tests fast while remaining race-free.

Test quality observations#

What’s done well#

  • Real integration tests for the core rebuild loop. For a file-watching tool, the most important test is “did the rebuild actually happen?” — and Air tests exactly that with a real subprocess.
  • GetPort() helper allocates a free TCP port by binding and immediately releasing, then passes that port into the test binary. This avoids hard-coded port collisions across parallel test runs.
  • CI timeout multiplier (timeoutMultiplier = 2.0 when CI=true) prevents flaky failures on slow GitHub Actions runners without inflating local test times.
  • Cross-platform coverage: Unit tests and smoke tests both run on ubuntu, macos, and windows in CI. Windows-specific code paths (binary suffixes, cmd.exe pre/post commands) have dedicated test fixtures.
  • proxy_stream_test.go: A tight, focused concurrent test that exercises race conditions in the pub-sub subscriber map — runs fast and is deterministic due to WaitGroup synchronization.
  • Shell fixtures in _testdata/: Scripts like run-many-processes.sh and run-detached-process.sh allow testing process-kill behavior on Unix without embedding subprocess logic in Go tests.

What could improve#

  • No test/build tag separation between unit and integration tests. TestRebuild and TestRegexes live in the same file and run in the same go test invocation. On a slow machine, a developer running tests locally hits a 10-second port-poll wait just to verify regex behavior. A //go:build integration tag on the heavyweight tests would improve local feedback cycles.
  • Mixed assertion styles: Some tests use raw if err != nil { t.Fatal } and others use require.NoError. Consistency would improve readability.
  • GetPort() is inherently racy: The bind-and-release pattern has a TOCTOU window where another process could claim the port before the test binary binds it. Acceptable in practice, but worth noting.
  • No coverage of main.go logic: The CLI entry point (flag parsing, InitConfig, signal goroutine) has no direct test coverage. The smoke tests cover it indirectly.
  • No fuzz tests: Config parsing (TOML loading + mergo merge + reflection-based flag override) is a complexity hotspot that would benefit from testing.F fuzz coverage.

Patterns worth emulating#

  • initTestEnv for self-contained integration tests: Creating a minimal synthetic project in t.TempDir() lets tests validate the full build-watch-rebuild cycle without any external dependencies. Directly applicable to other CLI tools that wrap go build.
  • Smoke test as a separate workflow: Keeping the black-box behavioral test outside Go’s test runner (as a shell script in CI) is a pragmatic choice that avoids fighting with go test timeouts and process supervision. For tools that wrap OS processes, this separation clarifies intent.
  • CI timeout multiplier pattern: if os.Getenv("CI") != "" { multiplier = 2.0 } is a low-ceremony way to make integration test timeouts adaptive without separate slow/fast profiles.