Skip to content

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

📝 Notes

  • bufconn is an in-memory net.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 real grpc.Server on lis in a goroutine, then dial with grpc.WithContextDialer(func(ctx, _) (net.Conn, error){ return lis.DialContext(ctx) }) and grpc.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 check st.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 handler and 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.Context with 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 with t.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 to status.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)

  1. Q: Why test gRPC handlers over bufconn instead 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).
  1. Q: How should you assert that an RPC failed with the expected error?
AInspect 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)