Testing & Mocking¶
One of the primary goals of GTB is to make CLI tools easily testable. By using the Props container, you can inject mock behaviors for filesystems, logging, and configuration.
Mocking the Filesystem¶
GTB uses afero for filesystem operations. In your tests, you can use afero.NewMemMapFs() to simulate a filesystem without touching the disk:
func TestMyCommand(t *testing.T) {
fs := afero.NewMemMapFs()
_ = afero.WriteFile(fs, "/config.yaml", []byte("key: value"), 0644)
props := &props.Props{
FS: fs,
// ... other props
}
// Now run your command logic using these props
}
Mocking Configuration¶
The pkg/config package provides an in-memory container builder for testing:
cfg := config.NewReaderContainer(logger, "yaml", bytes.NewReader([]byte("key: test-value")))
props.Config = cfg
Best Practices for Tests¶
- Avoid Global State: Do not rely on environment variables or global
oscalls. Use the abstractions provided inProps. - Table Driven Tests: Use Go's table-driven test pattern to verify your command logic against multiple input/config scenarios.
- Capture Output: You can provide a custom
io.Writerto theLoggerin your tests to verify exactly what is being logged.
Race Condition Avoidance¶
All tests should pass go test -race ./.... The following rules prevent data races and ensure t.Parallel() can be used safely.
No package-level mocking hooks¶
Do not create package-level var for test mocking. This pattern is fundamentally incompatible with t.Parallel() because concurrent tests mutate and restore the same global:
// BAD โ races when tests run in parallel
var execLookPath = exec.LookPath
func TestFoo(t *testing.T) {
old := execLookPath
defer func() { execLookPath = old }()
execLookPath = func(file string) (string, error) { return "/fake", nil }
// ...
}
Instead, inject dependencies through functional options or struct fields:
// GOOD โ each test gets its own instance, no shared mutable state
type Config struct {
ExecLookPath func(string) (string, error)
}
func TestFoo(t *testing.T) {
t.Parallel()
cfg := Config{ExecLookPath: exectest.FakeLookPath("/fake")}
// ...
}
The internal/exectest package provides common fakes for exec.LookPath and exec.CommandContext:
| Helper | Description |
|---|---|
exectest.FakeLookPath(path) |
Always returns the given path |
exectest.MissingLookPath() |
Always returns "not found" |
exectest.EchoCommand(output) |
Returns an echo command with the given output |
exectest.FailCommand() |
Returns a command that exits non-zero |
exectest.NoopCommand() |
Returns a no-op command |
exectest.TrackingCommand(&log) |
Records invocations into a string slice |
exectest.FakeExecutable(path) |
Fake os.Executable returning the given path |
Registry-aware tests¶
The pkg/setup registries (globalMiddleware, featureMiddleware, globalRegistry) are package-level shared state protected by mutexes. Tests that call ResetRegistryForTesting() wipe this state, making them logically incompatible with t.Parallel() against other tests in the same package โ the mutex prevents data races but does not prevent state interleaving.
Rule: tests that call setup.ResetRegistryForTesting() or setup.RegisterMiddleware() / setup.RegisterChecks() must not use t.Parallel() unless they register to unique feature names and do not reset.
Tests that only read from the registry (e.g. setup.GetChecks()) with distinct feature names can use t.Parallel() safely โ the mutex guarantees memory visibility.
Avoid cobra.OnFinalize¶
cobra.OnFinalize mutates a package-level slice inside the cobra library. Constructing multiple root commands in parallel (common in tests) races on this slice. Use defer in Execute() or middleware instead. See the race remediation spec for the full rationale.
t.Parallel() + t.Setenv() are incompatible¶
Go's testing framework panics if a test calls both t.Parallel() and t.Setenv(). Tests that modify environment variables must remain serial. Prefer injecting values through Props, Config, or functional options instead of environment variables where possible.