Day 125 — Testing gRPC Services¶
Month 5 · Week 2 · ⬅ Day 124 · Day 126 ➡ · Journal index
🎯 Learning Objective¶
Write fast, hermetic tests for gRPC services using an in-memory bufconn listener, and assert on status codes and interceptor behavior.
📚 Topics¶
google.golang.org/grpc/test/bufconn(in-memory transport)grpc.WithContextDialer; testing the real client/server stack- Asserting
status.Code(err); testing interceptors directly
📖 Reading / Sources¶
-
bufconnpackage - gRPC-Go testing example (bufconn)
-
statuspackage - Go blog — table-driven tests / subtests
📝 Notes¶
bufconnis an in-memorynet.Listener: the client and server talk over a buffer instead of a real socket. No ports, no TLS, no flakiness — tests run in microseconds and in parallel → [[bufconn]] [[hermetic-tests]].- Wire it up:
lis := bufconn.Listen(1<<20), start the realgrpc.Serveronlisin a goroutine, then dial withgrpc.WithContextDialer(func(ctx, _) (net.Conn, error){ return lis.DialContext(ctx) })andgrpc.WithTransportCredentials(insecure.NewCredentials()). You're exercising the full stack — codec, interceptors, status mapping — not a mock → [[integration-test]]. - Assert on errors with
status.FromError(err)then checkst.Code(); never string-match the error text.if status.Code(err) != codes.NotFound { ... }→ [[status-codes]]. - Interceptors are just functions — unit-test them in isolation by passing a fake
handlerand asserting it ran (or didn't), without any server at all. This is the cheapest test and where most logic lives → [[interceptors]]. - Pass a real
context.Contextwith a deadline (context.WithTimeout) so a hung server fails the test instead of blocking forever. Context is always the first param → [[context]]. - Use
t.Cleanup(func(){ srv.Stop(); lis.Close() })so each test tears down its server; combine witht.Parallel()for speed → [[t-cleanup]]. - For client code that calls a backend, define the generated client interface and pass a fake — but prefer bufconn for handler tests so the codec and interceptors are real.
💻 Code Examples¶
func dialBuf(t *testing.T) userpb.UsersClient {
lis := bufconn.Listen(1 << 20)
srv := grpc.NewServer(grpc.UnaryInterceptor(AuthUnary))
userpb.RegisterUsersServer(srv, &usersImpl{})
go srv.Serve(lis)
t.Cleanup(func() { srv.Stop() })
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return lis.DialContext(ctx)
}),
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
return userpb.NewUsersClient(conn)
}
func TestGetUser_NotFound(t *testing.T) {
client := dialBuf(t)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := client.GetUser(ctx, &userpb.GetUserRequest{Id: "nope"})
if status.Code(err) != codes.NotFound { // assert the CODE, not the string
t.Fatalf("got %v, want NotFound", status.Code(err))
}
}
The interceptor-as-a-plain-function tests live in the stdlib exercise:
exercises/month-05/week-2/chain· Run:go test ./exercises/month-05/week-2/...
🏋️ Exercises / Practice¶
| Exercise | Status | Link |
|---|---|---|
| Table-driven test for interceptor chain order | ✅ | exercises/month-05/week-2/chain |
| Bearer-parsing tests (valid/invalid/edge) | ✅ | exercises/month-05/week-2/metadata |
🐛 Mistakes Made¶
- Asserted on
err.Error()text → broke when I reworded the message. Switched tostatus.Code(err). - Forgot a context deadline; a deadlocked handler made the test hang until the package timeout. Added
context.WithTimeout.
❓ Open Questions¶
- Best pattern for testing streaming RPCs over bufconn — do I drive the stream directly or wrap it?
🧠 Active Recall (answer without looking)¶
- Q: Why test gRPC handlers over
bufconninstead of a real TCP listener?
A
`bufconn` is an in-memory listener: no real ports, no TLS setup, no flaky networking. Tests are fast, parallelizable, and hermetic, yet still exercise the full client/server stack (codec, interceptors, status mapping).- Q: How should you assert that an RPC failed with the expected error?
A
Inspect the gRPC status: `status.Code(err)` (or `status.FromError`) and compare to the expected `codes.X`. Don't string-match `err.Error()`, which is brittle.🪶 Feynman Reflection¶
bufconn is a pretend network pipe living in RAM. The real gRPC client and server plug into either end and talk exactly as they would over TCP, but instantly and without sockets. So my tests run the genuine code path — interceptors, encoding, error mapping — at unit-test speed. And because an interceptor is just a function, I can also test it alone by handing it a fake "next" handler.
🕳️ Knowledge Gaps¶
- Streaming-RPC test ergonomics and how to inject metadata in a bufconn test cleanly.
✅ Summary¶
I can stand up an in-memory gRPC server with bufconn, test the full request path, assert on status codes (not strings), and unit-test interceptors as plain functions.
⏭️ Next Steps / Prep for Tomorrow¶
- Day 126: week review + active recall across the whole week.
| Time spent | Difficulty | Confidence |
|---|---|---|
| 90 min | 🟦🟦⬜⬜⬜ | 🟦🟦🟦⬜⬜ |
Suggested commit: test(grpc): bufconn service tests & interceptor unit tests (day 125)