{
  "$type": "site.standard.document",
  "canonicalUrl": "https://rednafi.com/go/testing-unary-grpc-services/",
  "description": "How to test unary gRPC services in Go - handler logic, interceptors, deadlines, metadata propagation, and rich error details - all in-memory with bufconn.",
  "path": "/go/testing-unary-grpc-services/",
  "publishedAt": "2026-03-23T00:00:00.000Z",
  "site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
  "tags": [
    "Go",
    "gRPC",
    "Testing",
    "Distributed Systems"
  ],
  "textContent": "> We don't want to test gRPC or an HTTP server itself, we simply want to test our method's\n> logic. The simple answer to this question is to de-couple gRPC's work from the actual\n> work.\n>\n> -- John Doak, [Testing gRPC methods]\n\nThat advice is right most of the time. If your handler is a thin shell over business logic\nthat lives behind an interface, you can test the logic without gRPC at all. [Inject a fake],\ncall the method, check the result.\n\nBut sometimes you do need to test the gRPC layer. Maybe you want to verify that status codes\nsurvive the round trip through serialization and HTTP/2 trailers. Maybe you have\ninterceptors that add logging or auth, deadlines that need to propagate as grpc-timeout\nheaders, metadata that carries trace IDs between services, or structured error details\nattached via status.WithDetails. In those cases, you need the real gRPC stack running.\n\nThat's what [bufconn] does. It's an in-memory [net.Listener] from the gRPC-Go library that\nlets you start a real gRPC server and connect a real client to it, all inside the test\nprocess. The gRPC code paths are the same as production, but the underlying connection is an\nin-memory pipe instead of a TCP socket.\n\nThis post walks through testing a unary gRPC service at two levels: calling the handler\ndirectly without any transport, and using bufconn for in-memory integration tests that\nexercise the full stack - including interceptors, deadlines, metadata, and rich error\ndetails. Streaming RPCs have different patterns and are out of scope here.\n\nI'll use a small BookStore service as the running example.\n\nThe BookStore service\n\nThe gRPC service has two RPCs: create a book and get a book by ID.\n\nThe server struct takes a Store interface. It translates between protobuf types and domain\ntypes, validates inputs, and maps errors to gRPC status codes:\n\nCreateBook uses status.WithDetails to attach structured field violations to validation\nerrors, so clients can programmatically inspect which fields failed:\n\n- (1) creates a status with InvalidArgument - same code as a plain status.Error, so\n  existing tests that check codes.InvalidArgument still pass\n- (2) WithDetails attaches a BadRequest proto to the status. The details serialize into\n  trailing metadata during transport and deserialize on the client via status.Details() -\n  we'll test that round trip later\n- (3) wraps store errors as Internal\n\nGetBook is simpler - it maps a missing book to a NotFound status:\n\nTesting the handler directly\n\nMost of your handler tests should look like this: create a Server with a fake store and\ncall the handler methods as regular Go functions, without starting a gRPC server or opening\na connection.\n\nFirst, the fake store. It's an in-memory map that satisfies the Store interface:\n\nWith that in place, the test creates a Server struct directly and calls CreateBook and\nGetBook as plain method calls:\n\nHere:\n\n- (1) creates a Server with the fake store, no gRPC server involved\n- (2) calls the handler as a regular Go method\n\nYou can verify error codes the same way:\n\nThis works because the handler returns status.Error(codes.NotFound, ...), and\nstatus.FromError can parse that even without gRPC transport involved.\n\nThese tests are fast and cover the handler's logic: validation, store delegation, error\nmapping. For many services, this is enough.\n\nWhat direct calls miss\n\nBut the handler test has a blind spot. The status.Error returned by GetBook never\ntravels through the gRPC transport. In production, that error gets serialized into an HTTP/2\ntrailer, sent over the wire, and deserialized on the client side. The direct call skips all\nof that.\n\nThe request and response never go through protobuf serialization, so issues like default\nvalue handling or zero-value round-tripping won't surface. status.FromError works on the\noriginal status.Error object without it ever being serialized into an HTTP/2 trailer and\nreconstructed on the other side.\n\nServer and client interceptors for auth, logging, or retries only fire when a real gRPC call\ngoes through grpc.Server, which direct calls bypass entirely. Deadlines set via\ncontext.WithTimeout on the client never propagate as grpc-timeout headers, and metadata\nattached via metadata.AppendToOutgoingContext never reaches the server.\n\nRich error details attached via status.WithDetails travel through trailing metadata during\ntransport - the direct test never exercises that path. And you're testing the handler in\nisolation, not that the generated client and server actually agree on the wire format.\n\nFor a service where the handler is a thin adapter and the real logic lives behind the\nStore interface, none of this matters and direct calls are fine. But if you have\ninterceptors, need deadline propagation, pass metadata between services, or return\nstructured error details, you need the real stack.\n\nEnter bufconn\n\nFor HTTP services, Go has [httptest]. httptest.NewServer spins up a real HTTP server on a\nlocalhost port. httptest.NewRecorder skips the server entirely and calls\nhandler.ServeHTTP as a plain function. bufconn sits between these two: it runs a real gRPC\nserver and client through the full transport stack (HTTP/2 framing, protobuf serialization,\ninterceptors), but the underlying connection is an in-memory pipe rather than a TCP socket.\n\n| Approach                  | Real server? | Real transport? | Real TCP? |\n| ------------------------- | ------------ | --------------- | --------- |\n| Direct handler call       | No           | No              | No        |\n| bufconn                 | Yes          | Yes             | No        |\n| net.Listen(\"tcp\", \":0\") | Yes          | Yes             | Yes       |\n\n> [!NOTE]\n>\n> httptest.NewServer actually allocates a real TCP port on localhost. bufconn doesn't,\n> which means no port conflicts when running tests in parallel in CI. If Go's httptest had\n> an option to use net.Pipe() instead of TCP ([#14200]), bufconn would be the gRPC\n> equivalent of that.\n\nStarting a real gRPC server on net.Listen(\"tcp\", \":0\") works too. The OS assigns a free\nport, so there are no conflicts, but each test pays for TCP setup and teardown. Under heavy\nparallelism you can also hit ephemeral port exhaustion. bufconn avoids both while exercising\nthe same gRPC code paths.\n\nSetting up bufconn\n\nThe test helper starts a gRPC server on a bufconn listener and returns a connected client.\nIt accepts optional grpc.ServerOption values so callers can pass interceptors:\n\nWalking through each piece:\n\n- (1) opts ...grpc.ServerOption lets tests inject interceptors or other server\n  configuration. Existing tests that call startServer(t, store) are unchanged.\n- (2) bufconn.Listen(1 << 20) creates an in-memory listener with a 1 MB buffer. This\n  replaces net.Listen(\"tcp\", \":0\"). No port is allocated.\n- (3) grpc.NewServer(opts...) forwards any server options to the gRPC server.\n- (4) srv.Serve(lis) starts the gRPC server in a goroutine, same as production but\n  listening on the in-memory pipe instead of a socket.\n- (5) t.Cleanup(srv.GracefulStop) shuts down the server when the test ends. Graceful stop\n  waits for in-flight RPCs to finish before closing.\n- (6) \"passthrough:///bufconn\" tells gRPC to skip DNS resolution. The actual address\n  string doesn't matter because the custom dialer ignores it.\n- (7) WithContextDialer replaces the default TCP dialer. The custom function ignores the\n  address (the _ parameter) and calls lis.DialContext, which returns the client end of\n  the bufconn in-memory pipe. The server is already calling srv.Serve(lis) on the other\n  end, so they're connected through shared memory rather than a network socket.\n\nEvery test calls startServer with a fresh store, gets back a connected client, and\nexercises the full gRPC round trip.\n\nServer tests with bufconn\n\nThe same three scenarios from the direct tests, but now going through the real gRPC\ntransport.\n\nCreate a book, then get it back:\n\n- (1) starts a real gRPC server on a bufconn listener and returns a client connected to it\n- (2) client.CreateBook is now a real gRPC call that goes through protobuf serialization\n  and the HTTP/2 transport, unlike the direct test where it was a plain method call\n\nThe error code test looks identical to the direct version, but now the NotFound status\ntravels through the wire as an HTTP/2 trailer instead of staying in-process:\n\nThe InvalidArgument test for empty titles follows the same pattern. If the proto\ndefinitions or the gRPC transport had a bug in status code serialization, these tests would\ncatch it while the direct tests wouldn't.\n\nTesting interceptors\n\nInterceptors are middleware for gRPC. A unary server interceptor wraps every RPC call -\ncommon uses include logging, authentication, and request tagging. Here's one that generates\na request ID and sets it as response header metadata:\n\n- (1) generates a simple request ID from the current timestamp. In production you'd use a\n  UUID library, but UnixNano avoids an external dependency for this example\n- (2) grpc.SetHeader attaches the ID as response header metadata. The client can retrieve\n  it with the grpc.Header call option\n- (3) calls the next handler in the chain\n\nThe test passes the interceptor as a server option and verifies the response carries the\nheader:\n\n- (1) passes the interceptor to startServer via the variadic opts parameter. This is why\n  startServer accepts ...grpc.ServerOption - interceptor tests can inject middleware\n  without changing the helper's signature\n- (2) declares a metadata.MD to capture response headers\n- (3) grpc.Header(&header) is a call option that tells the gRPC client to populate\n  header with the server's response headers after the call completes\n- (4) verifies the interceptor set the x-request-id header\n\nThis test can't work with direct handler calls. Interceptors only fire when a request goes\nthrough grpc.Server, which means you need the real transport stack - exactly what bufconn\nprovides.\n\nTesting deadlines\n\ngRPC propagates deadlines automatically. When the client sets a timeout via\ncontext.WithTimeout, gRPC encodes it as a grpc-timeout header in the request. The server\nreceives a context whose deadline matches the client's, and if that deadline fires before\nthe handler returns, the framework returns codes.DeadlineExceeded to the client.\n\nTo test this, we need a store that's slow enough to trigger the deadline. A slowStore\nwraps memStore and adds a delay to Get:\n\n- (1) waits for delay before delegating to the real store\n- (2) returns immediately if the context is canceled or its deadline fires\n\nThe test wires up a slowStore with a 2-second delay, creates a book (fast, since Create\nisn't overridden), then calls GetBook with a 100ms timeout:\n\nThe DeadlineExceeded the client sees comes from gRPC's transport layer, not from the\nhandler. If the deadline hadn't fired, slowStore would eventually return the book, and if\nthe store returned an error for some other reason, GetBook would wrap it as\ncodes.NotFound. The fact that the test gets DeadlineExceeded instead of NotFound\nproves the deadline traveled through the wire: the client encoded it as a grpc-timeout\nheader, the server's context inherited it, and when it fired, the framework short-circuited\nthe response.\n\nDirect handler calls can't test this. context.WithTimeout on a direct call would still\ncancel the context and slowStore would return ctx.Err(), but GetBook would wrap that\nas codes.NotFound since it treats all store errors the same way. You'd never see\nDeadlineExceeded without the real transport.\n\nTesting metadata propagation\n\n[Metadata][metadata] in gRPC carries application-defined key-value pairs alongside RPCs.\nServices use it to propagate auth tokens, trace IDs, and request correlation IDs between\nservices. Testing that metadata survives the round trip requires the real transport.\n\n> [!IMPORTANT]\n>\n> gRPC uses two HTTP/2 frame types: HEADERS for request/response [metadata], and trailing\n> HEADERS (after the body) for status codes, error messages, and WithDetails payloads.\n\nHere's an interceptor that reads x-request-id from incoming metadata and echoes it back as\na response header. This is a test helper, not production code - it isolates the metadata\nround trip for verification:\n\n- (1) metadata.FromIncomingContext extracts the metadata that the client attached to the\n  request. This is the server-side API for reading incoming metadata\n- (2) echoes the x-request-id value back as a response header\n\nThe test attaches metadata to the outgoing request and verifies it comes back:\n\n- (1) metadata.AppendToOutgoingContext attaches key-value pairs to the context. When the\n  gRPC client makes the call, these become request metadata (the gRPC equivalent of HTTP\n  request headers)\n- (2) captures response headers into header\n- (3) verifies the server echoed back the exact value\n\nThis pattern is how you'd test that auth tokens, trace IDs, or correlation IDs propagate\ncorrectly through your service. The metadata travels through the gRPC transport as HTTP/2\nheaders - AppendToOutgoingContext on the client side, FromIncomingContext on the server\nside, SetHeader back to the client. None of this machinery runs in a direct handler call.\n\nTesting rich error details\n\nThe CreateBook handler uses status.WithDetails to attach structured field violations to\nvalidation errors. The details are serialized as protobuf messages in trailing metadata\nduring transport. To verify they survive the round trip, we need the real transport.\n\nThe test sends both fields empty to trigger two violations, then digs into the details:\n\n- (1) s.Details() deserializes the protobuf messages that WithDetails attached on the\n  server side. They traveled through trailing metadata in the HTTP/2 response\n- (2) type-asserts the first detail as errdetails.BadRequest from the [errdetails]\n  package\n\nWithDetails always marshals each proto message into a google.protobuf.Any wrapper, and\nDetails() always unmarshals them back - that happens even in a direct test. What the\ndirect test skips is the transport-level round trip: the entire Status proto (including\nits details) gets encoded into the grpc-status-details-bin trailing metadata, transmitted\nover HTTP/2, and reconstructed on the client side via status.FromError. A direct test\nwould pass even if that wire-level serialization was broken.\n\nChoosing your testing level\n\nDirect handler calls are the fastest option. You create a Server with a fake store and\ncall methods directly, with no gRPC server or transport involved. This covers handler logic\n(validation, error mapping, store delegation). For many services it's all you need.\n\nWhen you need to verify that status codes survive the round trip, that interceptors fire,\nthat deadlines propagate as grpc-timeout headers, that metadata round-trips correctly, or\nthat WithDetails error information deserializes on the client side, bufconn is the next\nstep. The request goes through protobuf serialization, HTTP/2 framing, and the interceptor\nchain, all in-memory.\n\nStarting a real TCP server with net.Listen(\"tcp\", \":0\") adds the OS networking layer on\ntop of that. You'd reach for this to validate TLS/mTLS configuration, test actual network\nbehavior, or run interop tests against clients in other languages. For most Go-to-Go service\ntesting, bufconn is enough and avoids the port allocation overhead.\n\nThe full working example is on [GitHub].\n\n\n\n\n[Testing gRPC methods]:\n    https://medium.com/@johnsiilver/testing-grpc-methods-6a8edad4159d\n\n[Inject a fake]:\n    /go/mocking-libraries-bleh/\n\n[bufconn]:\n    https://pkg.go.dev/google.golang.org/grpc/test/bufconn\n\n[net.Listener]:\n    https://pkg.go.dev/net#Listener\n\n[httptest]:\n    https://pkg.go.dev/net/http/httptest\n\n[#14200]:\n    https://github.com/golang/go/issues/14200\n\n[metadata]:\n    https://pkg.go.dev/google.golang.org/grpc/metadata\n\n[errdetails]:\n    https://pkg.go.dev/google.golang.org/genproto/googleapis/rpc/errdetails\n\n[GitHub]:\n    https://github.com/rednafi/examples/tree/main/testing-grpc-unary-service",
  "title": "Testing unary gRPC services in Go"
}