Air — Testing#
Test metrics#
- Test files: 9 (out of 26 total
.gofiles — 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_testpackage suffix is used anywhere. - Helper packages: None. There is no separate
testutil/ormocks/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), awatching/directory tree for watch-path tests, and atoml/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
namefield:tests := []struct{ name string; input ...; expected ... }{ ... } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ... }) } - Example:
runner/config_test.goandrunner/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 useif 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
exiterinterface (one method:Exit(int)), which allows tests to interceptos.Exitcalls without killing the test process. Everything else is tested through real types. - Example:
engine_test.goconstructs a realEngineviaNewEngine("", nil, true)(thetrueflag 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.goas regularTest*functions (not separated by build tag or naming convention) - How:
TestRebuild,TestCtrlCWhenHaveKillDelay,TestCtrlCWhenREngineIsRunning, and related tests spin up a fullEngine.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 tomain.goand 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 whenCI=trueis set to accommodate slower runners. initTestEnvpattern: 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 whatairrebuilds 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 actualairbinary is installed (make install), run withnohupagainstsmoke_test/check_rebuild/, then a newline is appended tomain.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 callingstream.AddSubscriber()with async.WaitGroup, then firesReload()concurrently while 10 goroutines drain subscriber channels. Tests that all 10 receive the message and thatRemoveSubscriber/Stopwork correctly. Usesatomic.Int32for 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.0whenCI=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.exepre/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 likerun-many-processes.shandrun-detached-process.shallow 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.
TestRebuildandTestRegexeslive in the same file and run in the samego testinvocation. On a slow machine, a developer running tests locally hits a 10-second port-poll wait just to verify regex behavior. A//go:build integrationtag on the heavyweight tests would improve local feedback cycles. - Mixed assertion styles: Some tests use raw
if err != nil { t.Fatal }and others userequire.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.gologic: 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 +
mergomerge + reflection-based flag override) is a complexity hotspot that would benefit fromtesting.Ffuzz coverage.
Patterns worth emulating#
initTestEnvfor self-contained integration tests: Creating a minimal synthetic project int.TempDir()lets tests validate the full build-watch-rebuild cycle without any external dependencies. Directly applicable to other CLI tools that wrapgo 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 testtimeouts 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.