Testing and Debugging Go Network Applications: Theory and Practice
"Pilots don't learn to handle engine failure by waiting for one at 30,000 feet — they train in a simulator first. Testing network code is the same idea: you want to exercise timeouts, dropped connections, and concurrent clients on your own terms, in a controlled harness, long before real users do it for you in production."
Why Network Code Is Harder to Test
Every chapter so far has built code that depends on things outside your program's control: the network, timing, other machines, concurrent goroutines. That makes network code harder to test than a pure function, for a few concrete reasons:
- Nondeterminism: The order in which goroutines run, or how quickly a connection is accepted, isn't guaranteed.
- External dependencies: A test that dials a real server outside your machine is slow, flaky, and breaks without your code changing at all.
- Concurrency bugs: Race conditions may only appear under specific timing, making them easy to miss with a single test run.
The fix isn't to avoid testing network code — it's to remove the actual network from the test wherever possible, and to lean on Go's tooling for the concurrency-specific risks.
This chapter works through the same layers in order: eliminate the real socket where the test doesn't need one (httptest, net.Pipe), lean on the tools built for concurrency bugs (-race, context.WithTimeout), and finally, for whatever still slips through into a running system, use the live-debugging tools (pprof, goroutine dumps, delve).
A flaky test isn't a bad test to ignore — it's a real bug your test happened to catch before your users did.
Testing HTTP Handlers Without a Real Server
net/http/httptest lets you test an http.Handler directly, without binding a real port — you already saw the request/response model in the HTTP chapter; httptest just drives it in-process.
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
healthHandler(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rec.Code)
}
if rec.Body.String() != "ok" {
t.Errorf("expected body %q, got %q", "ok", rec.Body.String())
}
}
For tests that need a real listening server — for example, testing an actual HTTP client against it — httptest.NewServer spins one up on a random free port and tears it down automatically:
func TestUploadEndToEnd(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(uploadHandler))
defer server.Close()
resp, err := http.Post(
server.URL+"/upload", "text/plain", strings.NewReader("test data"),
)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
Exercise: Testing HTTP Handlers
httptest.NewRecorder gives you a *httptest.ResponseRecorder, which satisfies http.ResponseWriter but not http.Hijacker or (until you opt in) streaming-friendly flushing the way a real net/http connection does. A handler that hijacks the connection to speak raw bytes, or one that relies on Flush reaching the client incrementally, cannot be fully exercised this way — for those, reach for httptest.NewServer instead, which really does listen on a socket and gives handlers a genuine http.ResponseWriter backed by the real HTTP server.Testing Raw TCP Code with net.Pipe
For code built directly on net.Conn — a custom protocol handler, for instance — net.Pipe gives you two connected, in-memory net.Conn values with no actual socket or OS involvement:
func TestEchoHandler(t *testing.T) {
clientConn, serverConn := net.Pipe()
defer clientConn.Close()
defer serverConn.Close()
// The same handler you'd use with a real net.Listener.
go handleConn(serverConn)
clientConn.Write([]byte("hello"))
buf := make([]byte, 5)
if _, err := io.ReadFull(clientConn, buf); err != nil {
t.Fatalf("read failed: %v", err)
}
if string(buf) != "hello" {
t.Errorf("expected echo, got %q", buf)
}
}
Because handleConn receives a plain net.Conn interface value, it can't tell the difference between the in-memory pipe and a real TCP connection from net.Listen — the same handler code is exercised either way, which is exactly why designing around the net.Conn interface (rather than a concrete type) pays off in tests.
net.Pipe's Write blocks until a corresponding Read consumes the data — there's no OS-level buffer. Tests that write and read on the same goroutine without a matching concurrent reader will deadlock; always drive one side from a separate goroutine, as in the example above.go handleConn(serverConn) above only exits once serverConn returns an error or the handler decides to stop. If TestEchoHandler returned early — a later assertion failed and skipped the rest — a handler blocked on clientConn.Read with no matching writer would leak for the life of the whole test binary. This is exactly what t.Cleanup (to guarantee Close calls run) and go.uber.org/goleak (defer goleak.VerifyNone(t)) exist to catch — goleak snapshots running goroutines at test end and fails it if any of the test's own are still alive.Testing Concurrent Code
The concurrency chapter introduced goroutines and channels; testing them safely means combining two habits:
- Run tests with the race detector:
go test -race ./...instruments memory accesses and flags data races that a normal test run would silently miss. - Use
sync.WaitGroupto avoidtime.Sleepguesswork: A test that sleeps "long enough" for goroutines to finish is both slow and unreliable. Wait on the actual completion signal instead.
func TestConcurrentHandlers(t *testing.T) {
var wg sync.WaitGroup
results := make(chan int, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
results <- process(n)
}(i)
}
wg.Wait()
close(results)
count := 0
for range results {
count++
}
if count != 10 {
t.Errorf("expected 10 results, got %d", count)
}
}
go test -race locally on a quiet laptop can still hide a race that only shows up under the scheduling pattern of a busier CI machine or a differently-configured GOMAXPROCS. Make -race part of CI, not just something you occasionally remember to run by hand, and don't treat one clean local run as proof a concurrent function is safe.Bound every test that talks to a real network resource with a timeout via context.WithTimeout, so a hung connection fails the test loudly instead of hanging the whole test suite:
func TestDialTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var d net.Dialer
// This address is expected to be unreachable.
_, err := d.DialContext(ctx, "tcp", "10.255.255.1:81")
if err == nil {
t.Fatal("expected dial to fail")
}
}
Table-Driven Tests
For protocol parsing or validation logic — the kind of code that shows up in framing (file transfer chapter) or token verification (authentication chapter) — a table of inputs and expected outputs keeps the test readable as cases grow:
func TestParseHeader(t *testing.T) {
cases := []struct {
name string
input []byte
wantErr bool
}{
{"valid header", []byte{0, 0, 0, 0, 0, 0, 0, 10}, false},
{"truncated header", []byte{0, 0, 0}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := parseHeader(tc.input)
if (err != nil) != tc.wantErr {
t.Errorf("parseHeader(%v): error = %v, wantErr %v",
tc.input, err, tc.wantErr)
}
})
}
}
t.Run gives each case its own named subtest, so a failure points straight at the offending case instead of an opaque line number in a shared loop.
Fuzz Testing Parsers
Table-driven tests only cover the inputs you thought to write down. Wire-format parsers like parseHeader are exactly the kind of code that breaks on inputs a human wouldn't think to try — a length field that overflows an int, a byte slice one shorter than the header format requires. Go's built-in fuzzing (go test -fuzz, part of the standard testing package since Go 1.18) generates those inputs for you:
func FuzzParseHeader(f *testing.F) {
// Seed corpus: known-good and known-bad inputs to start from.
f.Add([]byte{0, 0, 0, 0, 0, 0, 0, 10})
f.Add([]byte{0, 0, 0})
f.Fuzz(func(t *testing.T, data []byte) {
// parseHeader must never panic, no matter the input —
// a malformed length prefix should return an error,
// not crash the goroutine reading off the wire.
_, _ = parseHeader(data)
})
}
Running go test -fuzz=FuzzParseHeader -fuzztime=30s mutates the seed inputs for 30 seconds, looking specifically for inputs that make the function under test panic or fail an assertion inside f.Fuzz. Any failing input it finds is saved under testdata/fuzz/ and replayed automatically by future go test runs, so a fuzz-discovered bug becomes a permanent regression test with no extra effort.
Any code that parses bytes from a socket you don't control should assume the bytes are hostile until proven otherwise — fuzzing is how you find out where that assumption breaks.
Debugging Live Network Issues
net/http/pprof: Importing it for its side effects (import _ "net/http/pprof") exposes goroutine dumps, CPU profiles, and heap snapshots over HTTP — invaluable for diagnosing a hung or leaking server without attaching a debugger.- Goroutine leaks: A server that spawns a goroutine per connection but never lets it exit (blocked forever on a channel or a read with no deadline) leaks memory slowly. The
/debug/pprof/goroutineendpoint shows exactly how many goroutines are alive and where they're stuck. - Wire-level tools: For anything that isn't explained by your own logs, packet capture tools like
tcpdumpor Wireshark show what's actually crossing the wire — useful for catching cases where your Go code believes it sent something the network never delivered. delve: Go's standard debugger (dlv) supports breakpoints, stepping, and inspecting goroutines live, including attaching to a running process for production incident debugging.
Using delve on a stuck server: dlv attach <pid> connects to an already-running process without restarting it — essential when a bug only reproduces after hours of uptime. Once attached, goroutines lists every goroutine with its current state, and goroutine <n> bt prints its stack trace, showing exactly which line each blocked goroutine is stuck on.
dlv attach stops every goroutine the moment it connects, not just the one you're inspecting — the Go runtime has no way to pause a single goroutine in isolation. On a server actively serving traffic, that means every in-flight request stalls for as long as the session holds the process paused. Prefer net/http/pprof's goroutine dump (/debug/pprof/goroutine?debug=2), which captures stacks without stopping execution; save dlv attach for cases that snapshot can't explain.Try It Yourself
Take parseHeader from this chapter and write FuzzParseHeader as shown above. Run go test -fuzz=FuzzParseHeader -fuzztime=30s and see whether it finds a panicking input — if parseHeader trusts its length field without checking it against the actual slice length, it likely will. Then add defer goleak.VerifyNone(t) to TestEchoHandler and deliberately return early, before clientConn.Close() runs, to watch goleak catch the goroutine that go handleConn(serverConn) left behind.
Frequently Asked Questions
If go test -race passes, does that mean my concurrent code is race-free?
No, and this is one of the most common misreadings of the tool in the whole chapter. The race detector only flags races that actually occur during the instrumented run — it can't see a data race hiding in a code path your test never exercised, or one that only shows up under a different scheduler timing than your laptop happened to produce. Treat a clean -race run as evidence, not proof, and keep it running continuously in CI rather than as a one-time checkbox.
Why bother with net.Pipe when I could just bind a real port on localhost for tests?
A real listener works, but it's slower to set up and tear down, and it opens the door to port collisions and OS-level flakiness that have nothing to do with your handler logic. net.Pipe gives you two connected net.Conn values with zero socket or kernel involvement, and because your handler only ever sees the net.Conn interface, it genuinely cannot tell the difference — the same test doubles as a fast, deterministic exercise of the exact code that will run against a real TCP connection in production.
My test using net.Pipe just hangs forever — what did I do wrong?
Almost certainly a synchronous write with no reader on the other end. Unlike a real socket, net.Pipe has no OS buffer, so Write blocks until a matching Read consumes the bytes. If you write and read on the same goroutine without first spinning off the other side of the conversation, you'll deadlock the test rather than fail it — always drive one end from go handleConn(serverConn) or similar, as shown earlier in this chapter.
Table-driven tests already cover my parser — do I still need to fuzz it?
Yes, because a table only contains the inputs a human thought to write down. Wire-format parsers like parseHeader are exactly the code that breaks on inputs nobody imagined trying, like a length prefix that overflows an int or a slice one byte short of what the header claims. Go's built-in fuzzer mutates your seed corpus looking specifically for those blind spots, and any panic it finds gets saved under testdata/fuzz/ as a permanent regression test — cheap insurance for a few minutes of go test -fuzz.
Why not just always reach for delve when something's wrong on a live server?
Because dlv attach pauses every goroutine in the entire process the instant it connects — there's no way for the Go runtime to freeze just the one goroutine you care about. On a server handling real traffic, that means every in-flight request stalls for as long as your debugging session holds the process. net/http/pprof's goroutine dump captures the same stack information without stopping execution, so it's worth reaching for first; save delve for the cases a snapshot genuinely can't explain.
Key Takeaways
- Prefer
httptestandnet.Pipeover real sockets in tests — they're faster, deterministic, and exercise the exact same handler code. - Always run concurrent code through
go test -race, in CI as well as locally; timing-dependent bugs rarely show up otherwise. - Bound network-touching tests with
context.WithTimeoutso a hang fails fast instead of stalling the suite. net/http/pprofand goroutine dumps are the fastest way to answer "why is this server stuck" in a live system, and they don't pause the process the way attaching a debugger does.- Fuzz parsers that read untrusted bytes off the wire; hand-written table-driven cases only cover inputs you already thought of.
- Catch goroutine leaks in tests (
goleak.VerifyNone) rather than as a slowly climbing count in a production dashboard.