Day 29 — Testing: httptest & net.Pipe
Learning Objectives
- Test HTTP handlers without starting a real server
- Test TCP protocol code using
net.Pipe()— an in-memory bidirectional connection - Write integration tests that cover your full middleware chain
Key Concepts
// httptest.NewRecorder — captures response without network
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/api/data?id=1", nil)
handler.ServeHTTP(w, r)
// Assert
assert(w.Code == 200)
assert(w.Header().Get("X-Request-ID") != "")
// httptest.NewServer — real listening server on random port
ts := httptest.NewServer(handler)
defer ts.Close()
resp, err := http.Get(ts.URL + "/api/data")
// httptest.NewTLSServer — TLS server with self-signed cert
ts := httptest.NewTLSServer(handler)
defer ts.Close()
client := ts.Client() // pre-configured to trust the test cert
resp, err := client.Get(ts.URL + "/api/data")
// net.Pipe — in-memory bidirectional TCP connection (no network)
server, client := net.Pipe()
Exercise: Test the Logger Middleware
// middleware/logger_test.go
package middleware_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestLoggerMiddleware(t *testing.T) {
var logBuffer strings.Builder
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(201)
w.Write([]byte(`{"created":true}`))
})
logged := LoggerMiddleware(&logBuffer)(handler)
tests := []struct {
name string
method, path string
wantStatus int
wantLogContains []string
}{
{
name: "GET request logged",
method: "GET", path: "/api/users",
wantStatus: 201,
wantLogContains: []string{"GET", "/api/users", "201", "request_id"},
},
{
name: "request ID injected in response",
method: "POST", path: "/api/items",
wantStatus: 201,
wantLogContains: []string{"POST", "/api/items"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logBuffer.Reset()
w := httptest.NewRecorder()
r := httptest.NewRequest(tt.method, tt.path, nil)
logged.ServeHTTP(w, r)
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
}
if w.Header().Get("X-Request-ID") == "" {
t.Error("X-Request-ID header not set")
}
logEntry := logBuffer.String()
for _, want := range tt.wantLogContains {
if !strings.Contains(logEntry, want) {
t.Errorf("log missing %q in: %s", want, logEntry)
}
}
// Validate log is valid JSON
var entry map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(logEntry)), &entry); err != nil {
t.Errorf("log is not valid JSON: %v", err)
}
})
}
}
func TestNetPipe_ProtocolTest(t *testing.T) {
// Test TCP protocol code without a real network
server, client := net.Pipe()
defer server.Close()
defer client.Close()
// Server goroutine: echo server
go func() {
buf := make([]byte, 1024)
n, _ := server.Read(buf)
server.Write(buf[:n])
}()
// Client sends and reads echo
msg := []byte("hello proxy")
client.Write(msg)
response := make([]byte, len(msg))
n, err := client.Read(response)
if err != nil { t.Fatal(err) }
if string(response[:n]) != string(msg) {
t.Errorf("echo = %q, want %q", response[:n], msg)
}
}
Real-World Context
httptest tests are 100x faster than spinning up a real server. Your CI runs 500 middleware tests per second vs 5 per second with real HTTP. net.Pipe() lets you test your TCP proxy, protocol parser, and TLS handshake without touching the network stack.