Viper — Testing#

Test metrics#

  • Test files: 12
  • Source files: ~21 (non-test .go files; 33 total including test files)
  • Ratio (test files / source files): ~0.57 — slightly over half, reasonable for a library
  • Test frameworks: github.com/stretchr/testify/assert + github.com/stretchr/testify/require (universal); stdlib testing throughout; no gomock, ginkgo, goconvey, or gocheck

Test organization#

  • Placement: Mixed — majority of tests use the package viper white-box package (same package), giving access to unexported fields and internal helpers. The codec sub-packages (internal/encoding/*/) use their own package (black-box). finder_example_test.go uses package viper_test for a public API example.
  • Helper packages:
    • internal/testutil — single file, single function: AbsFilePath(t, path) wraps filepath.Abs with t.Fatal on error. Minimal, deliberately scoped. Follows the “testing helper as package” pattern cleanly with t.Helper().
  • Fixtures: No testdata/ directories. All config fixtures are inline byte literals at the top of viper_test.go (e.g., yamlExample, tomlExample, jsonExample, dotenvExample, remoteExample). Config content stays co-located with the tests that use it.

Test patterns#

Table-driven tests#

  • Prevalence: Moderate — used where input/output variation is the primary concern; not used for every test
  • Style: Anonymous struct slice, iterated with t.Run for subtests
  • Example: util_test.go:63TestAbsPathify uses a []struct{ input, output string } slice and iterates without subtesting (simpler loop, assertions inline)
  • Subtests via t.Run: Heavy use for grouping related scenarios under a single test function — e.g., TestGetConfigFile in viper_test.go has 8 subtests ("config file set", "find file", "precedence", "without extension", "experimental finder", "finder", etc.), each constructing its own afero.MemMapFs

In-memory filesystem for isolation#

  • Strategy: afero.NewMemMapFs() is the dominant isolation technique for file-system-dependent tests. Rather than creating real temp directories, tests construct virtual file systems and inject them via v.SetFs(fs). This produces hermetic, fast tests with no cleanup burden.
  • Example: viper_test.go:175TestGetConfigFile creates a MemMapFs, adds dirs and files, then calls v.getConfigFile() to verify resolution logic. No t.TempDir() needed.
  • Real FS tests: t.TempDir() is used in initDirs (viper_test.go:126) for tests that specifically validate directory-scanning behavior on real paths (e.g., TestDirsSearch). The choice between in-memory and real FS is deliberate.

Mocking approach#

  • Strategy: Interface-based fakes and stubs — no mock generation framework. Dependencies such as Finder are small interfaces that are trivially satisfied by test stubs.
  • Example: finder_test.go:11finderStub struct implements the Finder interface with a hardcoded results []string field. Constructed inline in the test. Zero dependency on gomock or mockery.
  • Codec fakes: encoding_test.go:10 — a local codec struct implements Codec (encode returns nil, decode is a no-op), sufficient to test DefaultCodecRegistry registration and lookup in isolation.
  • pflag stubs: viper_test.go:153stringValue type implements pflag.Value directly in the test file to exercise flag binding without a full pflag flag.

Environment variable testing#

  • Approach: t.Setenv(key, value) (stdlib, cleans up automatically on test exit). Used throughout viper_test.go for testing env binding, prefix stripping, and key replacer behavior. No global state leaks.

Error assertion#

  • Style: assert.ErrorAs used to verify the custom error type hierarchy (viper_test.go:1681):
    assert.ErrorAs(t, err, &ConfigFileNotFoundError{})
    assert.ErrorAs(t, err, &FileNotFoundFromSearchError{})
    assert.ErrorAs(t, err, &fileLookupError)
    This validates both the deprecated and current error types simultaneously — a test specifically designed to protect the Unwrap()-based deprecation migration.

Build-tag variant testing#

  • CI matrix: The GitHub Actions workflow (ci.yaml:51) runs go test with three tag variants: "", "viper_finder", and "viper_bind_struct". This ensures the experimental feature code paths are covered under their respective build tags.
  • Example: viper_test.go:309"experimental finder" subtest calls NewWithOptions(ExperimentalFinder()), testing the finder code path that only compiles with viper_finder build tag.

Benchmarks#

  • Present: Yes — 3 benchmarks in viper_test.go (lines 2678–2713): BenchmarkGetBool, BenchmarkGet, BenchmarkGetBoolFromMap
  • Purpose: The third benchmark (BenchmarkGetBoolFromMap) explicitly comments “the perfect result for the above” — it measures a raw map lookup as a performance baseline, allowing comparison against Viper’s multi-layer lookup overhead.

Integration tests#

  • Present: No. No *_integration_test.go or *_e2e_test.go files. No Docker, testcontainers, or external service dependency. Tests are fully hermetic.
  • Separation: Not applicable — there is no integration test tier.

Cross-platform awareness#

  • Windows skipping: skipWindows(t) helper (viper_test.go:2716) is called in tests that rely on POSIX path behavior. CI runs on ubuntu-latest, macos-latest, and windows-latest with fail-fast: false to let platform-specific failures surface independently.

Test quality observations#

  • What’s done well:

    • afero in-memory filesystem injection is the gold standard for testing a config library’s file-loading logic. Tests are fast, hermetic, and exercise the full resolution path without touching the real filesystem.
    • The error type deprecation test at viper_test.go:1681 is exemplary: it simultaneously asserts that both the old and new error types satisfy errors.As, making regression on the Unwrap() chain impossible to miss.
    • Stub-based mocking via small interfaces keeps test files lean — finderStub (5 lines) replaces a full gomock setup.
    • CI matrix covering 3 Go versions × 3 platforms × 3 build tag variants (27 combinations) is thorough for a library of this breadth.
    • t.Setenv and t.TempDir ensure environment and filesystem cleanup without explicit defer cleanup code.
  • What could improve:

    • viper_test.go at 2720 lines is too long. It mixes unit tests for internal helpers, integration-style tests using real temp dirs, flag binding tests, and YAML edge cases. Splitting by concern (config loading, env binding, flag binding, type coercion) would improve navigation.
    • Table-driven tests are used in util_test.go but not consistently across viper_test.go. Many tests for similar behavior (e.g., TestGetString, TestGetBool) repeat boilerplate instead of parameterizing. Consistent table-driven style would reduce volume.
    • The codec sub-package tests (internal/encoding/*/codec_test.go) each have only 2-3 test functions. Round-trip property testing (encode → decode → compare) would be a natural addition and is absent.
    • No coverage enforcement in CI — the test command (go test -race -v -shuffle=on ./...) does not include -coverprofile. Coverage gating or reporting would catch gaps in the multi-source priority logic.
  • Patterns worth emulating:

    • afero.MemMapFs for filesystem tests — swapping the filesystem via interface injection, then using an in-memory implementation in tests, is directly applicable to any code that touches the filesystem.
    • Benchmark-as-baseline pattern — including a raw-operation benchmark alongside the real implementation benchmark lets readers immediately see the overhead of the abstraction layer.
    • Error deprecation test — asserting that errors.As works for both the old and new error types simultaneously when implementing Unwrap() for deprecation is a pattern worth copying whenever error types are migrated.
    • Build-tag matrix in CI — running the full test suite under each feature flag combination via a CI matrix is cheap and catches flag-conditional bugs that a single test run would miss.