gRPC Testing in Go: grpc-go Test Patterns

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 messages

Generate code:

protoc --go_out=. --go-grpc_out=. user.proto

Unit 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.out

CI Integration

# GitHub Actions
- name: Run gRPC tests
  run: |
    go test -v -race -coverprofile=coverage.out ./...
    go tool cover -func=coverage.out

Production 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.

Read more