wireguard-go — Testing#
Test metrics#
- Test files: 22 (out of 100 total Go files)
- Ratio (test files / source files): ~28%
- Test frameworks: stdlib
testingonly — 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_testpackage 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/bindtest—ChannelBindandChannelEndpoint: a fully functional in-processconn.Bindimplementation backed by Go channels. Pairs of channel binds wire two devices together without OS sockets. Used bydevice_test.goto run end-to-end device tests entirely in memory.tun/tuntest—ChannelTUN: an in-processtun.Devicebacked by channels (Inbound/Outbound). Also providesPing(), a function that constructs a valid ICMPv4 packet with correct checksums, enabling protocol-level packet injection into tests.device/bind_test.go—DummyBindandDummyEndpoint: 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 defineconst raceEnabled bool. Tests that are slow under-raceuse 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—[]KDFTestwithkey,input,t0/t1/t2hex strings; iterates to verify BLAKE2s KDF outputs against known vectors.device/allowedips_test.go:16-39—[]testPairCommonBitsvalidates thecommonBits()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.go—SlowRouteris a naive O(n) sorted list that correctly implements longest-prefix-match for AllowedIPs.TestTrieRandomruns 10,000 random IPv4 and IPv6 lookup operations and assertsSlowRouter.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, callsCreateMessageInitiation→ConsumeMessageInitiation→CreateMessageResponse→ConsumeMessageResponse→BeginSymmetricSession, then asserts thatchainKey,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.Deviceandconn.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,fakeTUNDeviceSizedindevice_test.go). - Interface-satisfied-by-var check:
bindtest.go:29-30uses the patternvar _ conn.Bind = (*ChannelBind)(nil)andvar _ 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:
TestTwoDevicePingindevice/device_test.gocreates two fullDeviceinstances, each with aChannelTUNand either aChannelBind(no OS) or a realconn.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.goalongside unit tests. ThegenTestPair(tb, realSocket bool)parameter controls whether OS sockets or channel-based transport is used. - Up/Down cycling:
TestUpDownruns 10 × 50 iterations ofUp()/Down()with random nanosecond sleeps in goroutines — a stress test for the state machine’s race safety. - Concurrency safety test:
TestConcurrencySafetyexplicitly documents its purpose: “intended to be used with the race detector to catch data races.” It runs continuous packet traffic while concurrently mutatingpersistent_keepalive_interval, changing the private key, and callingBindUpdate().
Race detector as a first-class tool#
- Pattern: Build tags
//go:build raceand//go:build !racedefine araceEnabledconstant 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-24—if raceEnabled { startTrials /= 10 }— the WaitPool test scales from 100,000 to 10,000 iterations when running under-raceto avoid excessive slowdown.
Goroutine leak detection#
- Approach: Custom
goroutineLeakCheck(t)function usingruntime/pprof— captures the goroutine count and stacks before the test runs, then asserts after the test completes (viat.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 viat.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.Timefield inRatelimiterstruct, set totime.Nowin production. - Example:
ratelimiter/ratelimiter_test.go:92-105— test replacesrate.timeNowwith a closure that returns a controllednowvariable.timeSleepadvancesnowby the requested duration and callsrate.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:
BenchmarkLatencyandBenchmarkThroughputindevice_test.go— measure round-trip latency and packet throughput using the real device pipeline with OS sockets.BenchmarkUAPIGet— measures UAPI config read performance.BenchmarkTrieIPv4/IPv6inallowedips_test.go— measures AllowedIPs lookup performance at various peer and prefix counts.BenchmarkWaitPool,BenchmarkWaitPoolEmpty,BenchmarkSyncPoolinpools_test.go— direct comparison benchmarks betweenWaitPooland stdlibsync.Pool, validating the performance case for the custom implementation.BenchmarkThroughputuses a customb.ReportMetricapproach reportingns/opandpacket-lossas separate metrics — more informative than the default timing alone.
Disabled tests#
TestWaitPoolindevice/pools_test.gobegins witht.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.
ChannelBindandChannelTUNallow 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.
TestTrieRandomwithSlowRouteris 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
raceEnabledconstant and theTestConcurrencySafetytest 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.Cleanupfor teardown.genTestPairregistersp.dev.Closewitht.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.
TestWaitPoolis disabled. The pool backpressure test is skipped. TheWaitPoolimplementation is complex (documented as novel in the patterns analysis); having its test silently skipped reduces confidence.DummyBindvsbindtest.ChannelBindcoexistence. Two generations of fake bind exist (device/bind_test.goandconn/bindtest/bindtest.go). The olderDummyBindis a leftover that could be removed in favor of the more completeChannelBind.- Handshake-under-concurrent-key-change comment.
device_test.go:306notes “Set iters to a large number like 1000 to flush out data races quickly. Don’t leave it large.” The iteration count is left at1— 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.goandconn/sticky_linux_test.gorely 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)#
- Oracle / equivalence testing for data structures.
TestTrieRandomwithSlowRouteris 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. - In-process network virtualization.
ChannelTUN+ChannelBindpattern: implement the production interfaces with channel-backed fakes that live in a dedicated*testsubpackage. This enables true end-to-end testing without any OS or network dependency. - Race-detector-aware test design. Explicitly designing
TestConcurrencySafetyto 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. - 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. - Time injection via internal field. Exposing
timeNow func() time.Timeas an unexported field in rate-limiting code enables fully deterministic timing tests without exported test hooks or interface indirection.