Tailscale — Testing#

Test metrics#

  • Test files: 361
  • Total Go files: 1,452
  • Ratio (test files / source files): ~0.25 (roughly 1 test file per 4 source files)
  • Test frameworks: stdlib testing almost exclusively; testify/assert in one K8s operator test only (k8s-operator/conditions_test.go); testing/synctest (Go 1.24 experimental) in 9 files

Test organization#

  • Placement: Both package foo (white-box) and package foo_test (black-box). The black-box pattern is preferred for package boundaries — e.g., derp/derp_test.go is package derp_test, ipn/ipnserver/server_test.go is package ipnserver_test. White-box tests are used when internal access is required (e.g., ssh/tailssh/tailssh_test.go is package tailssh).
  • Helper packages: Several domain-specific test helper packages exist at named subpaths:
    • ipn/lapitest — full in-process LocalAPI server for black-box testing of ipnserver. Provides lapitest.NewServer(t, ...), typed Client objects, and actor-based auth simulation. This is the most sophisticated test helper in the repo.
    • net/stun/stuntest — runs a real STUN server bound to a loopback port; returns address and cleanup function. Pattern: addr, cleanup := stuntest.Serve(t); defer cleanup().
    • appc/appctest — app connector test helpers.
    • tailscale.com/tstest (not present in this clone) — referenced extensively via imports in test files (tstest.WhileTestRunningLogger, tstest.Replace, tstest/deptest, tstest/nettest, tstest/integration, tstest/integration/testcontrol). The tstest/integration package is run as root in CI and performs network-level integration tests. Its absence from the local clone does not diminish the pattern’s significance.
  • Fixtures: testdata/ directories exist in clientupdate/, derp/derpserver/, and ipn/ipnlocal/. No generative fixtures; testdata holds static golden files.

Test patterns#

Table-driven tests#

  • Prevalence: Heavy — 1,076 occurrences of t.Run, tests := []struct, testCases, or tc.name across test files.
  • Style: Anonymous struct slices with named fields, always iterated with for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ... }) }. Consistently uses tt as the loop variable.
  • Example: derp/derp_test.go:38TestReadFrameHeader defines a []struct{ name, input, wantType, wantLen } table and runs each with t.Run(tt.name, ...). ssh/tailssh/tailssh_test.go:59TestMatchRule tests SSH rule-matching logic against ~20 cases using the same idiom.

Mocking approach#

  • Strategy: Consumer-side narrow interfaces with hand-written fakes — no mock generation framework (no gomock, no mockery). Test code satisfies a minimal interface defined in the package under test. This aligns with the patterns analysis finding that consumer packages define local interfaces (e.g., tailssh.ipnLocalBackend with ~10 methods rather than importing the full LocalBackend).
  • envknob for configuration overrides: Tests use envknob.Setenv("TS_DEBUG_...", value) to override feature flags without dependency injection. The envknob package exposes Setenv specifically to enable this (guarded by the !ts_not_in_tests build tag). This is a controlled global-state override with t.Cleanup teardown.
  • tstest.Replace: Referenced in tailssh_test.go:549 — a helper that temporarily replaces a package-level variable for a test (Go’s approach to “mocking” global state safely with t.Cleanup teardown).
  • lapitest.Server: For ipnserver tests, a real ipnlocal.LocalBackend is wired in-process. Test actors carry identity via an ipnauth.TestActor struct. This is genuine black-box testing of the full LocalAPI stack without any mocking of the HTTP layer.
  • testcontrol.Server: A fake Tailscale control server (tstest/integration/testcontrol) used in integration tests and as a standalone binary (cmd/testcontrol). Referenced in cmd/sniproxy/sniproxy_test.go and control/controlclient/controlclient_test.go — tests bring up a real HTTP server that implements the control protocol.

Integration tests#

  • Present: Yes, multiple tiers:
    1. ssh/tailssh/tailssh_integration_test.go — tagged //go:build integrationtest. Requires root, exercises real SSH command execution against a running Tailscale SSH server. Uses Docker (testcontainers/) for environment isolation. Run manually via sudo ./tailssh.test -test.run TestIntegration.
    2. tstest/integration/ (referenced, not in clone) — network-level integration tests run in CI as root with -race. Sharded 1/4 across four parallel runners. Run via cmd/testwrapper with -exec "sudo -E".
    3. cmd/sniproxy/sniproxy_test.go — starts a real testcontrol.Server and a real DERP server in-process; exercises SNI proxy logic end-to-end.
  • How: All integration tests use in-process servers (net/http/httptest, testcontrol.Server, stuntest.Serve) rather than external Docker or Testcontainers dependencies (with the exception of the SSH integration test which uses a Dockerfile for container-isolated OS testing).
  • Separation: Integration tests are separated by build tags (integrationtest, glidertests) or by living in the dedicated tstest/integration/ package. Regular go test ./... runs only the unit/functional tests.

Flaky test management#

  • cmd/testwrapper + flakytest.Mark: Tailscale ships a custom go test wrapper (cmd/testwrapper) that retries tests marked with flakytest.Mark(t, issueURL). Each flaky test is linked to a GitHub issue tracking the root cause. Up to maxAttempts = 3 retry rounds; only the marked-flaky tests are retried — non-flaky failures cause immediate exit. Test sharding is built in: testwrapper sharded:1/4 ./... delegates to tool/listpkgs for package partitioning. This is Tailscale’s bespoke answer to test flakiness at scale.

testing/synctest usage#

  • 9 test files use Go 1.24’s experimental testing/synctest for deterministic time and goroutine control. Examples: health/health_test.go, control/controlbase/conn_test.go, derp/derphttp/derphttp_test.go. This is bleeding-edge — most Go projects have not yet adopted this API. Tailscale is an early adopter, consistent with its aggressive Go version tracking.

Zero-allocation and benchmark tests#

  • 82 benchmark functions (b.N) are spread across the codebase. testing.AllocsPerRun is used to assert zero-allocation hot paths (e.g., derp_test.go:80 — verifying ReadFrameHeader allocates nothing). This is appropriate for a high-performance networking daemon.

Dependency tracking tests#

  • client/local/local_test.go uses tstest/deptest.DepChecker to assert that the client/local package does not accidentally pull in heavy dependencies. This is a rare and valuable pattern: a test that guards import graph size.

Test quality observations#

  • What’s done well:

    • Pervasive table-driven tests with consistent naming conventions (tt, tc).
    • Rich in-process test infrastructure (lapitest, testcontrol) that tests realistic multi-layer behavior without process boundaries.
    • The flakytest + testwrapper system is a mature, production-grade solution to the flaky-test problem that many teams handle with ad-hoc retries or disabling.
    • envknob.Setenv provides a clean, controlled global-override mechanism for feature flag testing without needing interface injection.
    • Consumer-defined narrow interfaces mean tests pass lightweight fakes rather than constructing full system objects.
    • testing/synctest adoption shows investment in time-deterministic tests for async subsystems.
    • Dependency tracking tests (deptest) guard against silent binary bloat.
  • What could improve:

    • The tstest package tree is missing from this clone, so the full extent of Tailscale’s shared test utilities cannot be assessed — but its referenced API surface suggests significant investment.
    • Only one test file uses testify/assert (k8s-operator/conditions_test.go), which is inconsistent. The rest of the codebase uses raw t.Errorf/t.Fatalf — a deliberate choice but occasionally verbose for complex struct comparisons.
    • checklocks: annotations (mutex ownership enforcement via go.uber.org/goleak or a custom vet checker) appear only 17 times — the codebase has significant concurrency but formal lock discipline annotations are sparse.
  • Patterns worth emulating:

    • flakytest.Mark(t, issueURL) + testwrapper: Linking flaky tests to tracking issues and retrying them automatically is immediately applicable to any project with CI flakiness. The implementation (~400 lines) is self-contained and easy to transplant.
    • lapitest server pattern: Building a typed, in-process HTTP test server that accepts testing.TB and wires real subsystem components is far superior to unit-testing with mocks for HTTP APIs. The NewUnstartedServer/Start/Client trio follows the httptest idiom but extends it with domain-specific actor management.
    • Consumer-side narrow interfaces for fakes: Defining a 10-method local interface rather than depending on a 200-method concrete type eliminates fragile mocks and allows tests to compile against a trivial struct.
    • envknob test overrides: The envknob.Setenv + build-tag approach (!ts_not_in_tests) to enable feature flag overrides in tests — without changing production code paths — is a clean pattern for feature-flag-heavy codebases.