gRPC-Web Testing: Protobuf Mocking, buf CLI, and Browser Integration Tests

gRPC-Web Testing: Protobuf Mocking, buf CLI, and Browser Integration Tests

gRPC-Web brings protocol buffer-based communication to browser clients, but testing it requires tools at every layer: buf for schema validation, gomock for server unit tests, grpc-web-fake for browser mocks, and Playwright fetch interception for end-to-end tests. This guide covers all of them.

gRPC-Web extends gRPC to the browser by wrapping binary protobuf frames in HTTP/1.1, enabling type-safe, efficient communication between frontend applications and backend services. The strong typing from protobuf schemas is a testing superpower — schema violations are compile-time errors, not runtime surprises. But you still need tests at the schema, server, client, and integration layers. Here's a comprehensive approach.

buf CLI: Schema Linting and Breaking Change Detection

Before writing a single line of test code, validate your proto schemas with buf. Install it with brew install bufbuild/buf/buf or download from buf.build.

# buf.yaml
version: v1
breaking:
  use:
    - FILE
lint:
  use:
    - DEFAULT
  except:
    - PACKAGE_VERSION_SUFFIX

Run linting:

buf lint
# Output on success: (nothing)
<span class="hljs-comment"># Output on error:
<span class="hljs-comment"># proto/user/v1/user.proto:15:3:Field name "userId" should be lower_snake_case, like "user_id".

Run breaking change detection against your main branch:

buf breaking --against '.git#branch=main'
<span class="hljs-comment"># Catches:
<span class="hljs-comment"># - Field number changes
<span class="hljs-comment"># - Field type changes
<span class="hljs-comment"># - Field removal
<span class="hljs-comment"># - Service method removal or signature changes

Integrate this into CI so no PR can merge with proto-breaking changes unless intentional:

# .github/workflows/proto-check.yml
name: Proto Checks
on: [pull_request]
jobs:
  buf-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: bufbuild/buf-action@v1
        with:
          lint: true
          breaking: true
          breaking_against: 'https://github.com/${{ github.repository }}.git#branch=main'

Define a Test-Friendly Proto Service

// proto/product/v1/product.proto
syntax = "proto3";
package product.v1;

import "google/protobuf/timestamp.proto";

service ProductService {
  rpc GetProduct(GetProductRequest) returns (GetProductResponse);
  rpc ListProducts(ListProductsRequest) returns (stream ListProductsResponse);
  rpc CreateProduct(CreateProductRequest) returns (CreateProductResponse);
}

message GetProductRequest {
  string product_id = 1;
}

message GetProductResponse {
  string product_id = 1;
  string name = 2;
  int64 price_cents = 3;
  google.protobuf.Timestamp created_at = 4;
}

message ListProductsRequest {
  string category = 1;
  int32 limit = 2;
}

message ListProductsResponse {
  GetProductResponse product = 1;
}

message CreateProductRequest {
  string name = 1;
  int64 price_cents = 2;
}

message CreateProductResponse {
  string product_id = 1;
}

Generate Go server code and TypeScript client code:

buf generate

Server-Side Unit Tests with gomock

Use mockgen to generate mocks for your gRPC service dependencies:

go install github.com/golang/mock/mockgen@latest
mockgen -source=internal/repository/product_repository.go -destination=internal/repository/mocks/mock_product_repository.go

Write server unit tests:

// internal/service/product_service_test.go
package service_test

import (
    "context"
    "testing"

    "github.com/golang/mock/gomock"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"

    productv1 "github.com/yourorg/product/gen/product/v1"
    "github.com/yourorg/product/internal/repository/mocks"
    "github.com/yourorg/product/internal/service"
)

func TestGetProduct_ExistingProduct_ReturnsProduct(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    repoMock := mocks.NewMockProductRepository(ctrl)
    repoMock.
        EXPECT().
        FindByID(gomock.Any(), "prod-123").
        Return(&repository.Product{
            ID:         "prod-123",
            Name:       "Widget Pro",
            PriceCents: 4999,
        }, nil)

    svc := service.NewProductService(repoMock)

    resp, err := svc.GetProduct(context.Background(), &productv1.GetProductRequest{
        ProductId: "prod-123",
    })

    require.NoError(t, err)
    assert.Equal(t, "prod-123", resp.ProductId)
    assert.Equal(t, "Widget Pro", resp.Name)
    assert.Equal(t, int64(4999), resp.PriceCents)
}

func TestGetProduct_NotFound_ReturnsNotFoundError(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    repoMock := mocks.NewMockProductRepository(ctrl)
    repoMock.
        EXPECT().
        FindByID(gomock.Any(), "nonexistent").
        Return(nil, repository.ErrNotFound)

    svc := service.NewProductService(repoMock)

    _, err := svc.GetProduct(context.Background(), &productv1.GetProductRequest{
        ProductId: "nonexistent",
    })

    require.Error(t, err)
    st, ok := status.FromError(err)
    require.True(t, ok)
    assert.Equal(t, codes.NotFound, st.Code())
}

Testing gRPC Streaming Methods

Server-streaming RPCs need special test infrastructure. Use a mock stream recorder:

// internal/service/product_streaming_test.go
func TestListProducts_ValidCategory_StreamsResults(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    repoMock := mocks.NewMockProductRepository(ctrl)
    repoMock.
        EXPECT().
        FindByCategory(gomock.Any(), "electronics", 10).
        Return([]*repository.Product{
            {ID: "prod-1", Name: "Laptop", PriceCents: 99999},
            {ID: "prod-2", Name: "Phone", PriceCents: 49999},
        }, nil)

    // Use a test stream recorder
    streamMock := &mockListProductsStream{}
    svc := service.NewProductService(repoMock)

    err := svc.ListProducts(&productv1.ListProductsRequest{
        Category: "electronics",
        Limit:    10,
    }, streamMock)

    require.NoError(t, err)
    assert.Len(t, streamMock.sent, 2)
    assert.Equal(t, "prod-1", streamMock.sent[0].Product.ProductId)
    assert.Equal(t, "prod-2", streamMock.sent[1].Product.ProductId)
}

type mockListProductsStream struct {
    grpc.ServerStream
    sent []*productv1.ListProductsResponse
}

func (m *mockListProductsStream) Send(resp *productv1.ListProductsResponse) error {
    m.sent = append(m.sent, resp)
    return nil
}

func (m *mockListProductsStream) Context() context.Context {
    return context.Background()
}

Integration Tests with a Real gRPC Server

Use bufconn to run a real gRPC server in-memory without network ports:

// integration/product_integration_test.go
package integration_test

import (
    "context"
    "net"
    "testing"

    "google.golang.org/grpc"
    "google.golang.org/grpc/test/bufconn"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

const bufSize = 1024 * 1024

func startTestServer(t *testing.T) *grpc.ClientConn {
    lis := bufconn.Listen(bufSize)
    s := grpc.NewServer()

    repo := repository.NewInMemoryRepository()
    productv1.RegisterProductServiceServer(s, service.NewProductService(repo))

    go s.Serve(lis)

    t.Cleanup(func() { s.Stop() })

    conn, err := grpc.DialContext(
        context.Background(), "bufnet",
        grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
            return lis.DialContext(ctx)
        }),
        grpc.WithInsecure(),
    )
    require.NoError(t, err)
    t.Cleanup(func() { conn.Close() })
    return conn
}

func TestCreateAndGetProduct_RoundTrip(t *testing.T) {
    conn := startTestServer(t)
    client := productv1.NewProductServiceClient(conn)

    // Create
    createResp, err := client.CreateProduct(context.Background(), &productv1.CreateProductRequest{
        Name:       "Test Gadget",
        PriceCents: 2999,
    })
    require.NoError(t, err)
    assert.NotEmpty(t, createResp.ProductId)

    // Get
    getResp, err := client.GetProduct(context.Background(), &productv1.GetProductRequest{
        ProductId: createResp.ProductId,
    })
    require.NoError(t, err)
    assert.Equal(t, "Test Gadget", getResp.Name)
    assert.Equal(t, int64(2999), getResp.PriceCents)
}

Client-Side gRPC-Web Testing with grpc-web-fake

In the browser, @grpc/grpc-web is the standard client. Use grpc-web-fake to mock gRPC-Web responses without a real server:

// src/__tests__/ProductList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { FakeGrpcWebService } from 'grpc-web-fake';
import { ProductServiceClient } from '../gen/product/v1/ProductServiceClientPb';
import { GetProductRequest, GetProductResponse } from '../gen/product/v1/product_pb';
import { ProductList } from '../components/ProductList';

test('displays product fetched via gRPC-Web', async () => {
  const fakeService = new FakeGrpcWebService();

  const mockResponse = new GetProductResponse();
  mockResponse.setProductId('prod-123');
  mockResponse.setName('Widget Pro');
  mockResponse.setPriceCents(4999);

  fakeService.addUnaryHandler('GetProduct', (request: GetProductRequest) => {
    expect(request.getProductId()).toBe('prod-123');
    return mockResponse;
  });

  const client = fakeService.createClient(ProductServiceClient);

  render(<ProductList client={client} productId="prod-123" />);

  await waitFor(() => {
    expect(screen.getByText('Widget Pro')).toBeInTheDocument();
    expect(screen.getByText('$49.99')).toBeInTheDocument();
  });
});

test('shows error state when gRPC-Web returns UNAVAILABLE', async () => {
  const fakeService = new FakeGrpcWebService();

  fakeService.addUnaryHandler('GetProduct', () => {
    throw new GrpcError(StatusCode.UNAVAILABLE, 'Service temporarily unavailable');
  });

  const client = fakeService.createClient(ProductServiceClient);

  render(<ProductList client={client} productId="prod-999" />);

  await waitFor(() => {
    expect(screen.getByRole('alert')).toHaveTextContent('Service temporarily unavailable');
  });
});

Playwright gRPC-Web Interception via Fetch Mocking

gRPC-Web over HTTP/1.1 is just a fetch request with binary encoding. Playwright can intercept it:

// e2e/grpc-web.spec.ts
import { test, expect } from '@playwright/test';
import { encodeGrpcWebResponse } from './helpers/grpc-encode';
import { GetProductResponse } from '../src/gen/product/v1/product_pb';

test('product page loads data from gRPC-Web endpoint', async ({ page }) => {
  // Mock the gRPC-Web fetch call
  await page.route('**/product.v1.ProductService/GetProduct', async route => {
    const mockProto = new GetProductResponse();
    mockProto.setProductId('prod-mock');
    mockProto.setName('Intercepted Product');
    mockProto.setPriceCents(1999);

    await route.fulfill({
      status: 200,
      headers: {
        'Content-Type': 'application/grpc-web+proto',
        'grpc-status': '0',
      },
      body: encodeGrpcWebResponse(mockProto.serializeBinary()),
    });
  });

  await page.goto('http://localhost:3000/products/prod-mock');

  await expect(page.locator('[data-testid="product-name"]')).toHaveText('Intercepted Product');
  await expect(page.locator('[data-testid="product-price"]')).toHaveText('$19.99');
});

test('shows retry UI when gRPC-Web returns error status', async ({ page }) => {
  await page.route('**/product.v1.ProductService/GetProduct', async route => {
    await route.fulfill({
      status: 200,
      headers: {
        'Content-Type': 'application/grpc-web+proto',
        'grpc-status': '14',        // UNAVAILABLE
        'grpc-message': 'upstream timeout',
      },
      body: Buffer.alloc(0),
    });
  });

  await page.goto('http://localhost:3000/products/prod-123');

  await expect(page.locator('[data-testid="retry-button"]')).toBeVisible();
  await expect(page.locator('[data-testid="error-message"]')).toContainText('upstream timeout');
});

Contract Testing Across Service Boundaries

Use Pact or buf's BSR (Buf Schema Registry) to enforce contracts between frontend and backend:

// contract/product.pact.spec.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';

const { like, string } = MatchersV3;

const provider = new PactV3({
  consumer: 'ProductFrontend',
  provider: 'ProductService',
  dir: './pacts',
});

test('GetProduct returns expected shape', async () => {
  await provider
    .addInteraction({
      states: [{ description: 'product prod-123 exists' }],
      uponReceiving: 'a request to get product prod-123',
      withRequest: {
        method: 'POST',
        path: '/product.v1.ProductService/GetProduct',
        headers: { 'Content-Type': 'application/grpc-web+proto' },
      },
      willRespondWith: {
        status: 200,
        headers: { 'grpc-status': '0' },
        body: like({
          productId: string('prod-123'),
          name: string('Widget Pro'),
          priceCents: 4999,
        }),
      },
    })
    .executeTest(async mockServer => {
      const client = new ProductServiceClient(mockServer.url);
      const req = new GetProductRequest();
      req.setProductId('prod-123');

      const response = await grpcWebPromise(cb => client.getProduct(req, {}, cb));
      expect(response.getName()).toBe('Widget Pro');
    });
});

Buf Schema Registry for Team-Wide Contract Sharing

Push your schemas to the Buf Schema Registry so all services consume validated, versioned protos:

# Push to BSR
buf push buf.build/yourorg/product

<span class="hljs-comment"># Other services consume it:
<span class="hljs-comment"># buf.yaml
deps:
  - buf.build/yourorg/product:v1.2.0

<span class="hljs-comment"># Fetch dependency
buf mod update

This ensures every client consuming your gRPC service is always working against a known-good, linted, non-breaking schema version.

A layered gRPC-Web testing approach — buf schema validation in CI, gomock unit tests for server logic, bufconn integration tests, grpc-web-fake for browser components, and Playwright for end-to-end — gives you confidence from the proto file all the way to the rendered UI. HelpMeTest can run your gRPC-Web browser integration tests continuously, detecting when schema changes break client behavior before they reach users.

Read more