gRPC Testing in Go: grpc-go Test Patterns
Go has first-class gRPC support via google.golang.org/grpc. The grpc-go package provides everything you need to build and test gRPC services — including testing utilities that let you run tests against real in-process servers without opening network ports.
This guide covers the patterns that matter for production Go gRPC services: bufconn for in-process testing, gomock for stub generation, interceptor testing, and streaming RPC patterns.
Project Setup
Assuming a service defined by this proto:
// user.proto
syntax = "proto3";
package user;
option go_package = "github.com/your-org/user/pb";
service UserService {
rpc GetUser (GetUserRequest) returns (GetUserResponse);
rpc CreateUser (CreateUserRequest) returns (CreateUserResponse);
rpc ListUsers (ListUsersRequest) returns (stream UserResponse);
rpc UpdateUsers (stream UpdateUserRequest) returns (UpdateUsersResponse);
}
message GetUserRequest { string user_id = 1; }
message GetUserResponse {
string user_id = 1;
string name = 2;
string email = 3;
}
// ... other messagesGenerate code:
protoc --go_out=. --go-grpc_out=. user.protoUnit Tests with Mocked Dependencies
For unit tests of your service handler, mock the dependencies (database, other services) and call handler methods directly:
// user_service_test.go
package service_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/your-org/user/pb"
"github.com/your-org/user/service"
)
type mockUserRepository struct {
users map[string]*service.User
}
func (m *mockUserRepository) GetByID(ctx context.Context, id string) (*service.User, error) {
user, ok := m.users[id]
if !ok {
return nil, service.ErrNotFound
}
return user, nil
}
func (m *mockUserRepository) Create(ctx context.Context, user *service.User) error {
m.users[user.ID] = user
return nil
}
func TestGetUser_ExistingUser_ReturnsUser(t *testing.T) {
repo := &mockUserRepository{
users: map[string]*service.User{
"user-123": {ID: "user-123", Name: "Alice", Email: "alice@example.com"},
},
}
svc := service.NewUserService(repo)
resp, err := svc.GetUser(context.Background(), &pb.GetUserRequest{
UserId: "user-123",
})
assert.NoError(t, err)
assert.Equal(t, "user-123", resp.UserId)
assert.Equal(t, "Alice", resp.Name)
assert.Equal(t, "alice@example.com", resp.Email)
}
func TestGetUser_NotFound_ReturnsNotFoundError(t *testing.T) {
repo := &mockUserRepository{users: map[string]*service.User{}}
svc := service.NewUserService(repo)
_, err := svc.GetUser(context.Background(), &pb.GetUserRequest{
UserId: "nonexistent",
})
assert.Error(t, err)
st, ok := status.FromError(err)
assert.True(t, ok)
assert.Equal(t, codes.NotFound, st.Code())
}In-Process Integration Tests with bufconn
bufconn from the google.golang.org/grpc/test/bufconn package creates an in-memory connection — a real gRPC client/server connection without opening a network port. This is the standard Go gRPC integration testing pattern.
// integration_test.go
package service_test
import (
"context"
"net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
pb "github.com/your-org/user/pb"
"github.com/your-org/user/service"
)
const bufSize = 1024 * 1024
func setupTestServer(t *testing.T) (*grpc.ClientConn, func()) {
t.Helper()
listener := bufconn.Listen(bufSize)
server := grpc.NewServer()
pb.RegisterUserServiceServer(server, service.NewUserService(
service.NewInMemoryRepository(), // in-memory repo for tests
))
go func() {
if err := server.Serve(listener); err != nil {
// Server stopped — expected during cleanup
}
}()
conn, err := grpc.DialContext(
context.Background(),
"bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return listener.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
require.NoError(t, err)
cleanup := func() {
conn.Close()
server.Stop()
listener.Close()
}
return conn, cleanup
}
func TestUserService_CreateAndRetrieve(t *testing.T) {
conn, cleanup := setupTestServer(t)
defer cleanup()
client := pb.NewUserServiceClient(conn)
ctx := context.Background()
// Create user
createResp, err := client.CreateUser(ctx, &pb.CreateUserRequest{
Name: "Bob",
Email: "bob@example.com",
})
require.NoError(t, err)
assert.NotEmpty(t, createResp.UserId)
// Retrieve user
getResp, err := client.GetUser(ctx, &pb.GetUserRequest{
UserId: createResp.UserId,
})
require.NoError(t, err)
assert.Equal(t, "Bob", getResp.Name)
assert.Equal(t, "bob@example.com", getResp.Email)
}
func TestUserService_GetNonexistent_ReturnsNotFound(t *testing.T) {
conn, cleanup := setupTestServer(t)
defer cleanup()
client := pb.NewUserServiceClient(conn)
_, err := client.GetUser(context.Background(), &pb.GetUserRequest{
UserId: "does-not-exist",
})
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok)
assert.Equal(t, codes.NotFound, st.Code())
}Testing Streaming RPCs
Server Streaming
func TestListUsers_ReturnsAllUsers(t *testing.T) {
conn, cleanup := setupTestServer(t)
defer cleanup()
client := pb.NewUserServiceClient(conn)
ctx := context.Background()
// Pre-populate test data
for i := 0; i < 5; i++ {
_, err := client.CreateUser(ctx, &pb.CreateUserRequest{
Name: fmt.Sprintf("User %d", i),
Email: fmt.Sprintf("user%d@example.com", i),
})
require.NoError(t, err)
}
// Test streaming list
stream, err := client.ListUsers(ctx, &pb.ListUsersRequest{})
require.NoError(t, err)
var users []*pb.UserResponse
for {
user, err := stream.Recv()
if err == io.EOF {
break
}
require.NoError(t, err)
users = append(users, user)
}
assert.Len(t, users, 5)
}Client Streaming
func TestUpdateUsers_BatchUpdate(t *testing.T) {
conn, cleanup := setupTestServer(t)
defer cleanup()
client := pb.NewUserServiceClient(conn)
ctx := context.Background()
// Create users first
ids := make([]string, 3)
for i := range ids {
resp, err := client.CreateUser(ctx, &pb.CreateUserRequest{
Name: fmt.Sprintf("User %d", i),
})
require.NoError(t, err)
ids[i] = resp.UserId
}
// Client streaming update
stream, err := client.UpdateUsers(ctx)
require.NoError(t, err)
for i, id := range ids {
err := stream.Send(&pb.UpdateUserRequest{
UserId: id,
Name: fmt.Sprintf("Updated User %d", i),
})
require.NoError(t, err)
}
resp, err := stream.CloseAndRecv()
require.NoError(t, err)
assert.Equal(t, int32(3), resp.UpdatedCount)
}Using gomock for Generated Mocks
For testing code that calls a gRPC client (not server), generate mocks with gomock:
go install github.com/golang/mock/mockgen@latest
mockgen -source=pb/user_grpc.pb.go -destination=mocks/mock_user_client.go -package=mocks// order_handler_test.go
package handler_test
import (
"context"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/your-org/order/handler"
"github.com/your-org/order/mocks"
userpb "github.com/your-org/user/pb"
)
func TestPlaceOrder_ValidUser_Succeeds(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockUserClient := mocks.NewMockUserServiceClient(ctrl)
// Set up expectation
mockUserClient.EXPECT().
GetUser(gomock.Any(), &userpb.GetUserRequest{UserId: "user-123"}).
Return(&userpb.GetUserResponse{
UserId: "user-123",
Name: "Alice",
Email: "alice@example.com",
}, nil)
orderHandler := handler.NewOrderHandler(mockUserClient)
order, err := orderHandler.PlaceOrder(context.Background(), "user-123", "product-456")
assert.NoError(t, err)
assert.NotEmpty(t, order.OrderID)
assert.Equal(t, "user-123", order.UserID)
}
func TestPlaceOrder_UserNotFound_ReturnsError(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockUserClient := mocks.NewMockUserServiceClient(ctrl)
mockUserClient.EXPECT().
GetUser(gomock.Any(), gomock.Any()).
Return(nil, status.Error(codes.NotFound, "user not found"))
orderHandler := handler.NewOrderHandler(mockUserClient)
_, err := orderHandler.PlaceOrder(context.Background(), "missing-user", "product-456")
assert.Error(t, err)
assert.Contains(t, err.Error(), "user not found")
}Testing Interceptors
func TestAuthInterceptor_NoToken_Rejects(t *testing.T) {
interceptor := middleware.NewAuthInterceptor(validatorFunc)
// Build server with interceptor
listener := bufconn.Listen(bufSize)
server := grpc.NewServer(
grpc.UnaryInterceptor(interceptor.Unary()),
)
pb.RegisterUserServiceServer(server, service.NewUserService(
service.NewInMemoryRepository(),
))
go server.Serve(listener)
defer server.Stop()
conn, _ := grpc.DialContext(context.Background(), "bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return listener.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
defer conn.Close()
client := pb.NewUserServiceClient(conn)
// Call without auth token
_, err := client.GetUser(context.Background(), &pb.GetUserRequest{UserId: "123"})
require.Error(t, err)
st, _ := status.FromError(err)
assert.Equal(t, codes.Unauthenticated, st.Code())
}
func TestAuthInterceptor_ValidToken_Passes(t *testing.T) {
// ... same setup ...
// Call with auth metadata
md := metadata.New(map[string]string{
"authorization": "Bearer valid-test-token",
})
ctx := metadata.NewOutgoingContext(context.Background(), md)
_, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: "123"})
// Should not return Unauthenticated
if err != nil {
st, _ := status.FromError(err)
assert.NotEqual(t, codes.Unauthenticated, st.Code())
}
}Table-Driven Tests
Go's idiomatic table-driven test pattern works well for gRPC:
func TestGetUser(t *testing.T) {
conn, cleanup := setupTestServer(t)
defer cleanup()
client := pb.NewUserServiceClient(conn)
ctx := context.Background()
// Pre-create test user
createResp, _ := client.CreateUser(ctx, &pb.CreateUserRequest{
Name: "Test User", Email: "test@example.com",
})
tests := []struct {
name string
userID string
wantCode codes.Code
wantName string
}{
{
name: "existing user",
userID: createResp.UserId,
wantCode: codes.OK,
wantName: "Test User",
},
{
name: "nonexistent user",
userID: "nonexistent-id",
wantCode: codes.NotFound,
},
{
name: "empty user ID",
userID: "",
wantCode: codes.InvalidArgument,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := client.GetUser(ctx, &pb.GetUserRequest{
UserId: tt.userID,
})
if tt.wantCode == codes.OK {
require.NoError(t, err)
assert.Equal(t, tt.wantName, resp.Name)
} else {
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok)
assert.Equal(t, tt.wantCode, st.Code())
}
})
}
}Running Tests
# Run all tests
go <span class="hljs-built_in">test ./...
<span class="hljs-comment"># Run with verbose output
go <span class="hljs-built_in">test -v ./...
<span class="hljs-comment"># Run specific test
go <span class="hljs-built_in">test -v -run TestGetUser ./service/...
<span class="hljs-comment"># Run with race detector (important for gRPC streaming tests)
go <span class="hljs-built_in">test -race ./...
<span class="hljs-comment"># Generate coverage
go <span class="hljs-built_in">test -coverprofile=coverage.out ./...
go tool cover -html=coverage.outCI Integration
# GitHub Actions
- name: Run gRPC tests
run: |
go test -v -race -coverprofile=coverage.out ./...
go tool cover -func=coverage.outProduction Monitoring
After deploying your gRPC service, use HelpMeTest to monitor its HTTP health endpoint:
curl -fsSL https://helpmetest.com/install | bash
helpmetest health <span class="hljs-string">"user-grpc-service" <span class="hljs-string">"2m"Add this to your deployment script — if the service crashes within 2 minutes of deployment, the missed heartbeat triggers an alert.
Conclusion
Go's gRPC testing story is excellent: bufconn gives you real in-process integration tests, gomock generates client mocks from proto definitions, and table-driven tests integrate naturally with the gRPC error model. The -race flag is especially important for streaming tests where goroutines manage stream lifecycle.
Start with bufconn integration tests covering your core RPCs, then add unit tests for business logic with hand-rolled or generated mocks. This layered approach catches both protocol bugs and logic bugs efficiently.