Skip to content
gohacks.dev
Testing

Test gRPC Services in Go With bufconn and No Network

Test a gRPC server and its client in one Go process with bufconn: an in-memory listener, a custom dialer, deadlines, status codes and clean shutdown.

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

A gRPC handler tested by calling its method directly skips most of what makes it a gRPC handler. The status codes are only values, the interceptors never run, the metadata is missing and the deadline does nothing. To test those you need a client and a server talking over a connection, and you do not want a real port in a unit test. The bufconn package in grpc-go is the answer: an in-memory net.Listener that carries real gRPC framing between a server and a client in the same process.

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

This article builds one setup helper and runs four tests through it on Go 1.25.6. To avoid a protobuf generation step, the service under test is the standard health service that ships with grpc-go, which has unary and streaming methods. The generated-code workflow for your own services is in the gRPC tutorial, and the same helper works for any service you register.

The setup helper

health_test.goGo
package main

import (
	"context"
	"net"
	"testing"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/health"
	healthpb "google.golang.org/grpc/health/grpc_health_v1"
	"google.golang.org/grpc/status"
	"google.golang.org/grpc/test/bufconn"
)

// startServer serves the standard health service over an in-memory listener.
func startServer(t *testing.T, hs *health.Server) *grpc.ClientConn {
	t.Helper()
	lis := bufconn.Listen(1 << 20)
	srv := grpc.NewServer()
	healthpb.RegisterHealthServer(srv, hs)
	go func() { _ = 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 conn
}

func TestCheckServing(t *testing.T) {
	hs := health.NewServer()
	hs.SetServingStatus("billing", healthpb.HealthCheckResponse_SERVING)
	c := healthpb.NewHealthClient(startServer(t, hs))

	ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
	defer cancel()
	resp, err := c.Check(ctx, &healthpb.HealthCheckRequest{Service: "billing"})
	if err != nil {
		t.Fatal(err)
	}
	t.Logf("billing: %v", resp.Status)
}

func TestUnknownServiceIsNotFound(t *testing.T) {
	c := healthpb.NewHealthClient(startServer(t, health.NewServer()))
	_, err := c.Check(t.Context(), &healthpb.HealthCheckRequest{Service: "nope"})
	t.Logf("code=%v msg=%q", status.Code(err), status.Convert(err).Message())
	if status.Code(err) != codes.NotFound {
		t.Fatalf("want NotFound, got %v", err)
	}
}

func TestWatchSeesStatusChange(t *testing.T) {
	hs := health.NewServer()
	hs.SetServingStatus("billing", healthpb.HealthCheckResponse_SERVING)
	c := healthpb.NewHealthClient(startServer(t, hs))

	ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
	defer cancel()
	stream, err := c.Watch(ctx, &healthpb.HealthCheckRequest{Service: "billing"})
	if err != nil {
		t.Fatal(err)
	}
	first, _ := stream.Recv()
	hs.SetServingStatus("billing", healthpb.HealthCheckResponse_NOT_SERVING)
	second, _ := stream.Recv()
	t.Logf("first=%v second=%v", first.Status, second.Status)
}

func TestDeadlineIsEnforced(t *testing.T) {
	c := healthpb.NewHealthClient(startServer(t, health.NewServer()))
	ctx, cancel := context.WithTimeout(t.Context(), time.Nanosecond)
	defer cancel()
	_, err := c.Check(ctx, &healthpb.HealthCheckRequest{})
	t.Logf("code=%v", status.Code(err))
}

startServer does five things, in this order.

  1. bufconn.Listen(1 << 20) creates a listener with a one megabyte buffer per direction. A message larger than the buffer blocks until the other side reads, so size it above your largest message in tests. One megabyte is a common choice.
  2. It builds a grpc.Server and registers the service. In a real test this is where you register your implementation and pass your interceptors with grpc.ChainUnaryInterceptor.
  3. It serves in a goroutine, and registers srv.Stop with t.Cleanup.
  4. It creates the client with grpc.NewClient and a context dialer, lis.DialContext, so every connection the client opens lands on the in-memory listener.
  5. It registers conn.Close with t.Cleanup.

Cleanups run last in, first out, so the connection closes before the server stops. That order matters: stopping the server first makes the client log a connection error on its way out, which can look like a test failure when it is only noise. The article on t.Cleanup has the ordering rules.

The target string

grpc.NewClient("passthrough:///bufnet", ...) is deliberate. NewClient defaults to the dns resolver, which would try to resolve the name bufnet and fail or stall. The passthrough scheme hands the address to the dialer unchanged, and the custom dialer ignores it anyway. Older code used grpc.DialContext with grpc.WithBlock, which both dialled eagerly. NewClient does not connect until the first call, so a helper error shows up on the first RPC rather than in setup. If a test hangs, check the scheme first.

Four behaviours through one connection

Shell
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: #    #'
Text
    billing: SERVING
--- PASS: TestCheckServing
    code=NotFound msg="unknown service"
--- PASS: TestUnknownServiceIsNotFound
    first=SERVING second=NOT_SERVING
--- PASS: TestWatchSeesStatusChange
    code=DeadlineExceeded
--- PASS: TestDeadlineIsEnforced

A successful call. TestCheckServing sets the status of billing to SERVING and checks it with a two second deadline. The deadline is a safety net, not part of the test: if the helper is wrong, the test fails after two seconds with DeadlineExceeded rather than hanging until the package timeout. Put a context with a timeout on every RPC in a test for that reason.

A status code. TestUnknownServiceIsNotFound asks for a service that was never registered and receives NotFound with the message unknown service. status.Code(err) extracts the code and status.Convert(err).Message() the text. Asserting on the code, not the message, is the right default: codes are the contract, and a message is free to improve. The error mapping rules, and which codes clients retry, are in the article on gRPC error codes.

A stream. TestWatchSeesStatusChange opens a server stream, reads the first message, flips the status on the server and reads the second. Recv blocks until a message arrives, so there is no sleep and no polling. The two reads give SERVING then NOT_SERVING. Streams are where direct method calls help the least, because the stream object, flow control and cancellation are part of the behaviour. If the server never sent the second message, the two second deadline would end the test.

A deadline. TestDeadlineIsEnforced uses a context that has already expired and gets DeadlineExceeded back. The client library checks the deadline before and during the call, so the status is produced by the real gRPC machinery. A more interesting version tests that a handler stops work when the client's deadline passes, by blocking the handler on ctx.Done() and asserting that it returned. That test is only possible through a connection, because the server derives its context from the incoming request.

Interceptors, metadata and credentials

What the connection buys is that everything between the client call and the handler runs. Pass interceptors as server options and the unary ones execute in order. Send metadata with metadata.AppendToOutgoingContext and read it in the handler with metadata.FromIncomingContext. Authentication is the same: a per-RPC credential on the client, a check in an interceptor on the server, and a test that a missing token gets Unauthenticated and a bad one gets PermissionDenied, as the authentication article does it.

Transport security is the one thing the helper leaves out. insecure.NewCredentials() skips TLS, because a test of your handler logic does not need a certificate. A test that depends on mutual TLS needs a real listener on loopback, or a credentials.NewTLS configuration wrapped around the bufconn connection with a certificate generated in the test. Keep that as a separate, small test.

Isolation and parallelism

Each call to startServer creates a private listener, so tests share nothing and can call t.Parallel. The cost per test is a server, a connection and a few goroutines, and go test -v reported 0.00s for every test in this run. There is no reason to share a server across tests unless the service holds expensive state, and a shared server makes a test that leaves a stream open interfere with the next one.

The race detector is on in the lab, and it matters here: the server handler and the test body run on different goroutines, so a handler that writes to a field the test reads without synchronisation is caught. That is a bug a direct method call would never expose.

When bufconn is not enough

The helper skips the real network stack. It does not exercise connection establishment, keepalive timers, MaxConnectionAge, TCP backpressure, name resolution, load balancing policies or HTTP/2 flow control limits across a real socket. For those, use a listener on 127.0.0.1:0 and accept the extra milliseconds, or run an integration test against a deployed environment. It also cannot tell you what happens through a proxy or a service mesh.

A good division of labour is a table of handler tests with plain method calls for business logic, a bufconn suite for the contract (codes, metadata, streaming, interceptors, deadlines), and a handful of tests on a real port for transport behaviour. The grpc-go repository documents the package in its bufconn directory, and the gRPC guide on error handling lists the status codes the second test relies on.

Also inAPIs
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.