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_SUFFIXRun 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 changesIntegrate 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 generateServer-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.goWrite 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 updateThis 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.