wireguard-go — Testing#

Test metrics#

  • Test files: 22 (out of 100 total Go files)
  • Ratio (test files / source files): ~28%
  • Test frameworks: stdlib testing only — no testify, gomock, ginkgo, or any third-party assertion library

Test organization#

  • Placement: All tests are in the same package as the code under test (e.g., package device, package ratelimiter, package replay). No _test package suffix is used anywhere. This gives tests full access to unexported symbols — intentional, since much of the interesting protocol state lives in unexported fields.
  • Helper packages:
    • conn/bindtestChannelBind and ChannelEndpoint: a fully functional in-process conn.Bind implementation backed by Go channels. Pairs of channel binds wire two devices together without OS sockets. Used by device_test.go to run end-to-end device tests entirely in memory.
    • tun/tuntestChannelTUN: an in-process tun.Device backed by channels (Inbound/Outbound). Also provides Ping(), a function that constructs a valid ICMPv4 packet with correct checksums, enabling protocol-level packet injection into tests.
    • device/bind_test.goDummyBind and DummyEndpoint: an older, simpler fake bind that drops sends and blocks receives on channels. Used by earlier unit tests before the richer bindtest package existed.
    • device/race_enabled_test.go / device/race_disabled_test.go — build-tag files that define const raceEnabled bool. Tests that are slow under -race use this constant to reduce iteration counts (e.g., pools_test.go).
  • Fixtures: No testdata/ directories. Cryptographic test vectors are embedded as literal hex strings or byte slices directly in test functions (kdf_test.go, cookie_test.go).

Test patterns#

Table-driven tests#

  • Prevalence: Moderate — used where there are natural test cases with varying inputs.
  • Style: Named struct slice, inline struct definition.
  • Examples:
    • device/kdf_test.go:16-52[]KDFTest with key, input, t0/t1/t2 hex strings; iterates to verify BLAKE2s KDF outputs against known vectors.
    • device/allowedips_test.go:16-39[]testPairCommonBits validates the commonBits() helper against hand-computed values.
  • Counter-example: Many tests are imperative scripts rather than tables, particularly the cryptographic tests where the sequence of operations matters more than the input variation (see noise_test.go, cookie_test.go, replay_test.go).

Randomized / oracle testing#

  • Approach: A reference “slow” implementation is constructed alongside the optimized production implementation. Random inputs are fed to both; their outputs must agree. This is the most architecturally interesting testing pattern in the codebase.
  • Example: device/allowedips_rand_test.goSlowRouter is a naive O(n) sorted list that correctly implements longest-prefix-match for AllowedIPs. TestTrieRandom runs 10,000 random IPv4 and IPv6 lookup operations and asserts SlowRouter.Lookup(addr) == AllowedIPs.Lookup(addr) for every one. The test also exercises peer removal, verifying that the optimized trie removes all entries correctly when peers are deleted.
  • Assessment: This is the gold standard for testing a complex data structure. The test is unambiguous, easy to maintain, and finds off-by-one errors and edge cases that hand-written tests would miss.

Protocol sequence testing#

  • Approach: Steps of the WireGuard handshake protocol are executed manually, with internal state inspected between each step. This validates that intermediate cryptographic state is identical on both sides.
  • Example: device/noise_test.go — creates two devices, calls CreateMessageInitiationConsumeMessageInitiationCreateMessageResponseConsumeMessageResponseBeginSymmetricSession, then asserts that chainKey, hash, and final session keys match between peers. Also tests encrypt-decrypt roundtrip with the derived session keys.
  • Assessment: High-value test for a security-critical code path. By stepping through the handshake manually rather than triggering it via the full device stack, the test pinpoints exactly which protocol step fails when something breaks.

Mocking approach#

  • Strategy: Manual interface fakes — no mocking framework.
  • How dependencies are mocked: The two primary abstractions (tun.Device and conn.Bind) are both interfaces. Tests substitute channel-backed implementations (ChannelTUN, ChannelBind) that are fully functional but operate entirely in memory. For simpler tests that don’t need packets to actually flow, stub implementations implement the interface with no-op or error-returning methods (fakeBindSized, fakeTUNDeviceSized in device_test.go).
  • Interface-satisfied-by-var check: bindtest.go:29-30 uses the pattern var _ conn.Bind = (*ChannelBind)(nil) and var _ conn.Endpoint = (*ChannelEndpoint)(nil) — compile-time assertions that the fakes satisfy the interface. This prevents silent drift when the interface changes.

Integration tests (end-to-end)#

  • Present: Yes, but in-process rather than via Docker or external services.
  • How: TestTwoDevicePing in device/device_test.go creates two full Device instances, each with a ChannelTUN and either a ChannelBind (no OS) or a real conn.NewDefaultBind (OS sockets). It then sends an ICMPv4 ping through device 1’s TUN outbound queue and waits to receive it on device 2’s TUN inbound queue. This exercises the full send/encrypt/transmit/receive/decrypt/deliver pipeline end-to-end.
  • Separation: Not separated by build tags or directories — integration tests live in device/device_test.go alongside unit tests. The genTestPair(tb, realSocket bool) parameter controls whether OS sockets or channel-based transport is used.
  • Up/Down cycling: TestUpDown runs 10 × 50 iterations of Up() / Down() with random nanosecond sleeps in goroutines — a stress test for the state machine’s race safety.
  • Concurrency safety test: TestConcurrencySafety explicitly documents its purpose: “intended to be used with the race detector to catch data races.” It runs continuous packet traffic while concurrently mutating persistent_keepalive_interval, changing the private key, and calling BindUpdate().

Race detector as a first-class tool#

  • Pattern: Build tags //go:build race and //go:build !race define a raceEnabled constant used to scale down iteration counts in slow tests. This is unusually disciplined — most projects either ignore the race detector or run into flaky-under-race problems silently.
  • Example: pools_test.go:21-24if raceEnabled { startTrials /= 10 } — the WaitPool test scales from 100,000 to 10,000 iterations when running under -race to avoid excessive slowdown.

Goroutine leak detection#

  • Approach: Custom goroutineLeakCheck(t) function using runtime/pprof — captures the goroutine count and stacks before the test runs, then asserts after the test completes (via t.Cleanup) that the goroutine count did not increase. If it did, it prints both sets of stacks to the test log.
  • Example: device/device_test.go:395-420 — registered via t.Cleanup; polls for up to 10 seconds (10,000 × 1ms sleep) to allow goroutines to exit naturally before declaring a leak.
  • Assessment: More robust than a simple count check — the pprof output shows exactly which goroutines are running, making leak diagnosis straightforward. No external library (like goleak) needed; the stdlib provides everything required.

Time injection#

  • Approach: Internal timeNow func() time.Time field in Ratelimiter struct, set to time.Now in production.
  • Example: ratelimiter/ratelimiter_test.go:92-105 — test replaces rate.timeNow with a closure that returns a controlled now variable. timeSleep advances now by the requested duration and calls rate.cleanup() directly, simulating time passage without actual sleeping.
  • Assessment: Clean and practical. The fake time seam is exposed only as an internal field (not exported), keeping the public API clean while making the timing-sensitive test deterministic.

Benchmarks#

Present throughout, co-located with unit tests:

  • BenchmarkLatency and BenchmarkThroughput in device_test.go — measure round-trip latency and packet throughput using the real device pipeline with OS sockets.
  • BenchmarkUAPIGet — measures UAPI config read performance.
  • BenchmarkTrieIPv4/IPv6 in allowedips_test.go — measures AllowedIPs lookup performance at various peer and prefix counts.
  • BenchmarkWaitPool, BenchmarkWaitPoolEmpty, BenchmarkSyncPool in pools_test.go — direct comparison benchmarks between WaitPool and stdlib sync.Pool, validating the performance case for the custom implementation.
  • BenchmarkThroughput uses a custom b.ReportMetric approach reporting ns/op and packet-loss as separate metrics — more informative than the default timing alone.

Disabled tests#

  • TestWaitPool in device/pools_test.go begins with t.Skip("Currently disabled"). The test logic exists and is complete, but is skipped — likely due to flakiness or to avoid slowness in CI. A note for maintainers.

Test quality observations#

What’s done well#

  • No test dependencies. Pure stdlib testing. The test binary has no additional module requirements beyond what the production code already needs. This is consistent with the project’s minimal-dependency philosophy.
  • In-process virtualization is the right abstraction. ChannelBind and ChannelTUN allow full device-level integration tests without network privileges, without Docker, and without flakiness from port conflicts or timing. Tests run in CI on any platform.
  • Oracle testing for the trie. TestTrieRandom with SlowRouter is a textbook example of correctness-by-equivalence. The 10,000-iteration random search would catch any prefix-match or node-removal bug that a hand-written test would need dozens of cases to cover.
  • Race detector integration. The build-tag-based raceEnabled constant and the TestConcurrencySafety test show explicit design for race-detector execution. This is important for a codebase where the central innovation is parallel encryption with carefully managed ordering.
  • Goroutine leak checker. Rolling a custom pprof-based checker is a small investment that prevents an entire class of subtle bugs (goroutines blocking forever after test teardown) from silently accumulating.
  • Compile-time interface checks. The var _ Interface = (*Impl)(nil) pattern in test fakes catches interface drift at compile time rather than at test runtime.
  • tb.Cleanup for teardown. genTestPair registers p.dev.Close with t.Cleanup, ensuring devices are always shut down regardless of test failure path — no deferred close in each individual test needed.

What could improve#

  • No coverage metrics visible. No CI config was found in the repository. It’s unclear whether coverage is measured or enforced.
  • TestWaitPool is disabled. The pool backpressure test is skipped. The WaitPool implementation is complex (documented as novel in the patterns analysis); having its test silently skipped reduces confidence.
  • DummyBind vs bindtest.ChannelBind coexistence. Two generations of fake bind exist (device/bind_test.go and conn/bindtest/bindtest.go). The older DummyBind is a leftover that could be removed in favor of the more complete ChannelBind.
  • Handshake-under-concurrent-key-change comment. device_test.go:306 notes “Set iters to a large number like 1000 to flush out data races quickly. Don’t leave it large.” The iteration count is left at 1 — the test in this form provides minimal race coverage for the private-key-rotation path.
  • Platform-specific tests not marked with build tags. tun/offload_linux_test.go and conn/sticky_linux_test.go rely on Linux-specific constants; they correctly use build tags implicitly via file naming convention, but the test count on non-Linux platforms drops without that being visible from the file list.

Patterns worth emulating (for the book)#

  1. Oracle / equivalence testing for data structures. TestTrieRandom with SlowRouter is the clearest example in all 50 projects of testing a complex data structure by comparing it against a deliberately simple reference implementation. Applicable whenever optimizing an algorithm.
  2. In-process network virtualization. ChannelTUN + ChannelBind pattern: implement the production interfaces with channel-backed fakes that live in a dedicated *test subpackage. This enables true end-to-end testing without any OS or network dependency.
  3. Race-detector-aware test design. Explicitly designing TestConcurrencySafety to be run under -race, and scaling iteration counts with a build-tag constant, treats the race detector as a first-class testing tool rather than an afterthought.
  4. pprof-based goroutine leak detection. goroutineLeakCheck(t) is a 25-line function using stdlib pprof that provides goroutine-level teardown validation. No external library needed; directly applicable in any daemon test suite.
  5. Time injection via internal field. Exposing timeNow func() time.Time as an unexported field in rate-limiting code enables fully deterministic timing tests without exported test hooks or interface indirection.