Skip to content
gohacks.dev
APIs

Testing gRPC Services in Go With bufconn and Table Tests

Test a Go gRPC service through the real stack with bufconn: table tests for status codes, streaming, metadata, interceptors and deadlines, no ports or mocks.

10 min read
Summarize in AI
Flat illustration of a test report with one failing test.

A gRPC service has more moving parts than its method bodies. A request is serialised, travels through interceptors, reaches a handler, and the answer is a message or a status with a code and details. Tests that call the handler method directly skip most of that. Tests that start a server on a real port work but are slower, flaky when ports clash and awkward in CI.

Tested with Go 1.25.6, grpc-go v1.75.0, protobuf-go v1.36.8, protoc 29.3, macOS on Apple Silicon (arm64)

The standard answer in grpc-go is bufconn: an in-memory net.Listener and dialer. The server and client run the real gRPC stack in the same process and talk through a memory buffer. This article builds a test setup around it, and a set of tests that covers status codes, streams, metadata, interceptors and deadlines. It ties to unary RPCs, streaming, interceptors and the general style in table-driven tests. It ran on Go 1.25.6.

The service

A small service with a unary method that can fail and a server streaming method that can fail:

proto/math.protoprotobuf
syntax = "proto3";
package math.v1;
option go_package = "example.test/lab/mathpb";

service Math {
  rpc Divide(DivideRequest) returns (DivideReply);
  rpc Fib(FibRequest) returns (stream FibReply);
}

message DivideRequest { double a = 1; double b = 2; }
message DivideReply { double result = 1; }
message FibRequest { int32 count = 1; }
message FibReply { int64 value = 1; }
tools.goGo
//go:build tools

package main

import (
	_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
	_ "google.golang.org/protobuf/cmd/protoc-gen-go"
)
server.goGo
package main

import (
	"context"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/status"

	pb "example.test/lab/mathpb"
)

type mathServer struct{ pb.UnimplementedMathServer }

func (mathServer) Divide(ctx context.Context, r *pb.DivideRequest) (*pb.DivideReply, error) {
	if r.GetB() == 0 {
		return nil, status.Error(codes.InvalidArgument, "division by zero")
	}
	if md, ok := metadata.FromIncomingContext(ctx); ok && len(md.Get("x-precision")) > 0 && md.Get("x-precision")[0] == "int" {
		return &pb.DivideReply{Result: float64(int64(r.GetA() / r.GetB()))}, nil
	}
	return &pb.DivideReply{Result: r.GetA() / r.GetB()}, nil
}

func (mathServer) Fib(r *pb.FibRequest, s grpc.ServerStreamingServer[pb.FibReply]) error {
	if r.GetCount() < 0 || r.GetCount() > 90 {
		return status.Error(codes.OutOfRange, "count must be between 0 and 90")
	}
	a, b := int64(0), int64(1)
	for i := int32(0); i < r.GetCount(); i++ {
		if err := s.Send(&pb.FibReply{Value: a}); err != nil {
			return err
		}
		a, b = b, a+b
	}
	return nil
}

Divide returns InvalidArgument on a zero divisor and reads an optional x-precision metadata key. Fib streams Fibonacci numbers and returns OutOfRange for counts outside 0 to 90.

The test helper

server_test.goGo
package main

import (
	"context"
	"errors"
	"io"
	"net"
	"testing"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/status"
	"google.golang.org/grpc/test/bufconn"

	pb "example.test/lab/mathpb"
)

// newClient starts the real server on an in-memory listener and returns a connected client.
// No port, no network, no flakiness: bufconn is a net.Listener backed by memory.
func newClient(t *testing.T, opts ...grpc.ServerOption) pb.MathClient {
	t.Helper()
	lis := bufconn.Listen(1 << 20)
	srv := grpc.NewServer(opts...)
	pb.RegisterMathServer(srv, mathServer{})
	go srv.Serve(lis)
	t.Cleanup(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 pb.NewMathClient(conn)
}

func ctx(t *testing.T) context.Context {
	t.Helper()
	c, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	t.Cleanup(cancel)
	return c
}

func TestDivide(t *testing.T) {
	c := newClient(t)
	tests := []struct {
		name     string
		a, b     float64
		wantCode codes.Code
		want     float64
	}{
		{"exact", 10, 4, codes.OK, 2.5},
		{"negative", -9, 3, codes.OK, -3},
		{"by zero", 1, 0, codes.InvalidArgument, 0},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			r, err := c.Divide(ctx(t), &pb.DivideRequest{A: tc.a, B: tc.b})
			if got := status.Code(err); got != tc.wantCode {
				t.Fatalf("code = %v (%v), want %v", got, err, tc.wantCode)
			}
			if err == nil && r.GetResult() != tc.want {
				t.Errorf("result = %v, want %v", r.GetResult(), tc.want)
			}
		})
	}
}

func TestDivideReadsMetadata(t *testing.T) {
	c := newClient(t)
	withMD := metadata.AppendToOutgoingContext(ctx(t), "x-precision", "int")
	r, err := c.Divide(withMD, &pb.DivideRequest{A: 7, B: 2})
	if err != nil || r.GetResult() != 3 {
		t.Fatalf("got %v, %v; want 3 with the x-precision metadata", r.GetResult(), err)
	}
}

func TestFibStreams(t *testing.T) {
	c := newClient(t)
	stream, err := c.Fib(ctx(t), &pb.FibRequest{Count: 10})
	if err != nil {
		t.Fatal(err)
	}
	var got []int64
	for {
		m, err := stream.Recv()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			t.Fatal(err)
		}
		got = append(got, m.GetValue())
	}
	want := []int64{0, 1, 1, 2, 3, 5, 8, 13, 21, 34}
	if len(got) != len(want) {
		t.Fatalf("got %v, want %v", got, want)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("got %v, want %v", got, want)
		}
	}
}

func TestFibOutOfRangeIsReportedOnRecv(t *testing.T) {
	c := newClient(t)
	stream, err := c.Fib(ctx(t), &pb.FibRequest{Count: 500})
	if err != nil {
		t.Fatal(err) // opening a stream does not fail; the status arrives with the first Recv
	}
	if _, err := stream.Recv(); status.Code(err) != codes.OutOfRange {
		t.Fatalf("Recv = %v, want OutOfRange", err)
	}
}

func TestInterceptorRejectsWithoutToken(t *testing.T) {
	auth := grpc.UnaryInterceptor(func(c context.Context, req any, _ *grpc.UnaryServerInfo, next grpc.UnaryHandler) (any, error) {
		if md, _ := metadata.FromIncomingContext(c); len(md.Get("authorization")) == 0 {
			return nil, status.Error(codes.Unauthenticated, "no token")
		}
		return next(c, req)
	})
	c := newClient(t, auth)
	if _, err := c.Divide(ctx(t), &pb.DivideRequest{A: 1, B: 1}); status.Code(err) != codes.Unauthenticated {
		t.Fatalf("without a token: %v", err)
	}
	signed := metadata.AppendToOutgoingContext(ctx(t), "authorization", "Bearer x")
	if _, err := c.Divide(signed, &pb.DivideRequest{A: 1, B: 1}); err != nil {
		t.Fatalf("with a token: %v", err)
	}
}

func TestExpiredDeadline(t *testing.T) {
	c := newClient(t)
	expired, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
	defer cancel()
	if _, err := c.Divide(expired, &pb.DivideRequest{A: 1, B: 1}); status.Code(err) != codes.DeadlineExceeded {
		t.Fatalf("got %v, want DeadlineExceeded", err)
	}
}
main.goGo
package main

func main() {}

The heart is newClient. It creates a bufconn.Listen(1 << 20) listener (the number is the buffer size), builds a grpc.Server with whatever server options the test passes, registers the real implementation, and serves in a goroutine. The client dials with grpc.WithContextDialer, a function that connects through lis.DialContext, and the passthrough:///bufnet target so the name is not resolved. t.Cleanup stops the server and closes the connection when the test finishes, so tests do not leak goroutines or ports.

Three details are worth copying. The helper takes ...grpc.ServerOption, which lets a test add an interceptor, a message size limit or keepalive settings without a new helper per scenario. It returns the generated client interface (pb.MathClient), so tests read like application code. And each test gets its own server, so tests are independent and can run in parallel.

The tests

Shell
mkdir -p bin
go build -o bin/protoc-gen-go google.golang.org/protobuf/cmd/protoc-gen-go
go build -o bin/protoc-gen-go-grpc google.golang.org/grpc/cmd/protoc-gen-go-grpc
PATH="$PWD/bin:$PATH" protoc --go_out=. --go_opt=module=example.test/lab --go-grpc_out=. --go-grpc_opt=module=example.test/lab proto/*.proto
go test -race -count=1 -v . | grep -E '^(--- |    --- |ok|FAIL|PASS)' | sed -E 's/ \([0-9.]+s\)//'
Text
--- PASS: TestDivide
    --- PASS: TestDivide/by_zero
    --- PASS: TestDivide/negative
    --- PASS: TestDivide/exact
--- PASS: TestDivideReadsMetadata
--- PASS: TestFibStreams
--- PASS: TestFibOutOfRangeIsReportedOnRecv
--- PASS: TestInterceptorRejectsWithoutToken
--- PASS: TestExpiredDeadline
PASS
ok  	example.test/lab	1.388s

Status codes in a table. TestDivide has three cases: an exact result, a negative number and a division by zero expecting InvalidArgument. The test compares status.Code(err) with the expected code first, then the value for the success cases. Subtests call t.Parallel() and share the single client; a grpc.ClientConn is safe for concurrent use. This is the shape for any unary method: one row per behaviour, with the code as part of the expectation.

Metadata. TestDivideReadsMetadata attaches x-precision: int with AppendToOutgoingContext and checks the server used it (7 / 2 gives 3). Real metadata through the real stack means a typo in a key name is caught.

Streaming. TestFibStreams opens the stream, reads until io.EOF and compares the whole sequence. The loop structure is the one to remember: io.EOF is a clean end, any other error is a failure.

Errors in streams arrive late. TestFibOutOfRangeIsReportedOnRecv asks for 500 numbers. The call to Fib itself succeeded and returned a stream. The error came on the first Recv: OutOfRange. Opening a server stream does not wait for the handler, so a test that checks only the error from the opening call would pass for a handler that always fails. Always read from the stream to see how it ends.

Interceptors. TestInterceptorRejectsWithoutToken passes a grpc.UnaryInterceptor as a server option to the helper. Without an authorization header the call fails with Unauthenticated, and with one it succeeds. Authentication, validation, rate limits and error mapping can all be tested this way, and so can the chain order if you pass several (interceptors). Run the real interceptors from the production wiring, not copies.

Deadlines. TestExpiredDeadline builds a context whose deadline passed a second ago and expects DeadlineExceeded. No sleeping is needed, because an already-expired context fails fast, and the test is instant and deterministic. To test the server reacting to a deadline, use a handler that blocks on ctx.Done() and a short timeout, as in the deadlines article.

The output lists every test passing under the race detector: the three TestDivide subtests (in whatever order the parallel scheduler ran them) and the five other tests, in about a second including the race detector overhead.

What bufconn does not cover

bufconn is in-memory, so some things behave differently from a real network.

  • TLS. You can use TLS over bufconn by passing the same credentials, which tests certificate handling. What it does not test is the network.
  • Network failures. Timeouts, resets and slow links do not occur. To test those, use a real listener with a forwarder that misbehaves, as in the keepalive lab.
  • Load balancing and name resolution. Needs multiple servers and a resolver; a manual resolver works (load balancing).
  • Packet size and flow control. The buffer size you give Listen limits how much can be in flight; flow-control behaviour in tests with large messages can differ from the network.

Keep a few tests on a real localhost listener for these, and a smoke test in staging.

Testing handlers without a connection

For pure logic, call the method directly: mathServer{}.Divide(ctx, &pb.DivideRequest{...}) and examine the result and status.Code(err). It is the fastest test and appropriate for business logic that does not depend on metadata or interceptors. Use the bufconn route for everything that involves the framework: metadata, interceptors, streaming, serialisation and codes as the client sees them. The grpc.io guide on testing shows the plain client and server setup that this builds on.

For streaming handlers, direct testing needs a fake stream that implements grpc.ServerStreamingServer[T], which takes more code than the bufconn route and tests less. Prefer bufconn for streams.

Mocks and fakes for clients

When your code under test is a client of another gRPC service, you need a stand-in for that service. Three options, from lightest to heaviest:

  1. Implement the generated client interface with a small hand-written fake in the test (type fakeMath struct{ pb.MathClient } overriding the methods you use). Quick, and fine for simple cases.
  2. Run a fake server on bufconn with the generated Unimplemented...Server embedded and the needed methods overridden, so the real client stack, interceptors and codes are exercised. This is the most faithful in-process option.
  3. A mock framework (gomock, mockery) generating the client mock, which is useful when you need call-order assertions (gomock and mockery).

My default is option 2. A fake server returning scripted statuses also makes retry and error-handling tests easy.

Habits that keep gRPC tests healthy

  • One server per test through t.Cleanup. No shared global state.
  • Context with a timeout in every test, so a hung stream fails in seconds and not at the global test timeout.
  • Assert on status codes, not on error strings. Messages are for humans and change.
  • Test every error path your handler can return, each with its own code.
  • Run with -race, since streaming handlers and interceptors are concurrent.
  • Check for goroutine leaks after streaming tests with goleak (goroutine leaks).
  • Regenerate in CI and fail on a diff, so tests always run against current generated code (code generation).

The bufconn package is documented at pkg.go.dev/google.golang.org/grpc/test/bufconn, and the grpc-go repository's own examples use the same pattern for their tests.

Also inTesting
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.