Files
gh-buzzdan-ai-coding-rules-…/skills/testing/examples/grpc-bufconn.md
2025-11-29 18:02:42 +08:00

319 lines
9.7 KiB
Markdown

# gRPC Testing with bufconn and Rich Client Mocks
## When to Use This Example
Use this when:
- Testing gRPC servers
- Need bidirectional streaming tests
- Want in-memory gRPC (no network I/O)
- Testing server-client interactions
- Need rich DSL for readable tests
**Dependency Level**: Level 1 (In-Memory) - Uses `bufconn` for in-memory gRPC connections
**Key Insight**: When testing a **gRPC server**, mock the **clients** that connect to it. When testing a **gRPC client**, mock the **server**.
## Implementation
### Rich gRPC Client Mock with DSL
When your **System Under Test (SUT) is a gRPC server**, create rich client mocks:
```go
// internal/testutils/grpc_client_mock.go
package testutils
import (
"context"
"io"
"sync"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
pb "myproject/grpc_api/gen/go/traces/v1"
)
// TaskmonOnCluster is a rich gRPC client mock with DSL for testing gRPC servers.
// It connects to your gRPC server and provides helper methods for assertions.
type TaskmonOnCluster struct {
clusterRoutingKey string
taskmonID string
stream pb.RemoteTracesService_StreamTracesClient
mu sync.RWMutex
receivedQuery *pb.TracesQuery
receivedQueriesPayloads []string
}
// OpenTaskmonToWekaHomeStream creates a gRPC client mock that connects to your server.
// This is the constructor for the mock - returns a rich DSL object.
func OpenTaskmonToWekaHomeStream(
ctx context.Context,
client pb.RemoteTracesServiceClient,
clusterRoutingKey, taskmonID string,
) (*TaskmonOnCluster, error) {
// Inject metadata (like session tokens) into context
md := metadata.Pairs("X-Taskmon-session-token", clusterRoutingKey)
ctx = metadata.NewOutgoingContext(ctx, md)
// Open streaming connection to the server (your SUT)
stream, err := client.StreamTraces(ctx, grpc.Header(&md))
if err != nil {
return nil, err
}
return &TaskmonOnCluster{
stream: stream,
clusterRoutingKey: clusterRoutingKey,
taskmonID: taskmonID,
receivedQueriesPayloads: []string{},
}, nil
}
// SessionToken returns the session token (useful for assertions)
func (m *TaskmonOnCluster) SessionToken() string {
return m.clusterRoutingKey
}
// Close closes the stream (idempotent)
func (m *TaskmonOnCluster) Close() {
if m.stream == nil {
return
}
m.stream.CloseSend()
}
// ListenToStreamAndAssert is a helper that listens to server messages and asserts.
// This makes tests read like documentation!
func (m *TaskmonOnCluster) ListenToStreamAndAssert(
t *testing.T,
expectedQueryPayload,
resultPayload string,
) {
for {
query, err := m.stream.Recv()
if err == io.EOF {
break
}
require.NoError(t, err, "Failed to receive query from server")
// Store received data (thread-safe)
m.mu.Lock()
m.receivedQuery = query
m.receivedQueriesPayloads = append(m.receivedQueriesPayloads, string(query.TracesQueryPayload))
m.mu.Unlock()
// Assert expected payload
require.Equal(t, expectedQueryPayload, string(query.TracesQueryPayload))
// Send response back to server
response := &pb.TracesFromServer{
TraceServerRoute: query.TraceServerRoute,
TracesPayload: []byte(resultPayload),
MessageId: query.MessageId,
}
err = m.stream.Send(response)
require.NoError(t, err, "Failed to send response")
}
}
// LastReceivedQuery returns the last received query (thread-safe)
func (m *TaskmonOnCluster) LastReceivedQuery() *pb.TracesQuery {
m.mu.RLock()
defer m.mu.RUnlock()
return m.receivedQuery
}
// ReceivedQueriesPayloads returns all received payloads (thread-safe)
func (m *TaskmonOnCluster) ReceivedQueriesPayloads() []string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.receivedQueriesPayloads
}
```
## Usage in Integration Tests
### Complete Test Suite Example
```go
//go:build integration
package integration_test
import (
"context"
"net"
"testing"
"time"
"github.com/stretchr/testify/suite"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
pb "myproject/grpc_api/gen/go/traces/v1"
"myproject/internal/remotetraces"
"myproject/internal/testutils"
)
type RemoteTracesTestSuite struct {
suite.Suite
lis *bufconn.Listener // In-memory gRPC connection
ctx context.Context
natsServer *nserver.Server // In-memory NATS
}
func (suite *RemoteTracesTestSuite) SetupSuite() {
suite.ctx = context.Background()
// Start in-memory NATS server (Level 1)
natsServer, err := testutils.RunNATsServer()
suite.Require().NoError(err)
suite.natsServer = natsServer
// Connect to NATS
natsAddress := "nats://" + natsServer.Addr().String()
nc, err := natsremotetraces.ConnectToRemoteTracesSession(suite.ctx, natsAddress, 2, 2, 10)
suite.Require().NoError(err)
// ** System Under Test: gRPC Server **
// Use bufconn for in-memory gRPC (no network I/O!)
suite.lis = bufconn.Listen(1024 * 1024)
s := grpc.NewServer()
// Your gRPC server implementation
remoteTracesServer := remotetraces.NewGRPCServer(nc, 10, 10, time.Second)
pb.RegisterRemoteTracesServiceServer(s, remoteTracesServer)
go func() {
if err := s.Serve(suite.lis); err != nil {
suite.NoError(err)
}
}()
}
func (suite *RemoteTracesTestSuite) bufDialer(ctx context.Context, _ string) (net.Conn, error) {
return suite.lis.DialContext(ctx)
}
func (suite *RemoteTracesTestSuite) TestStreamTraces() {
// Create gRPC client (connects to your server)
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(suite.bufDialer),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
suite.Require().NoError(err)
defer conn.Close()
client := pb.NewRemoteTracesServiceClient(conn)
// Create rich gRPC client mock (testutils DSL!)
clusterRoutingKey := "test-cluster-123"
taskmonMock, err := testutils.OpenTaskmonToWekaHomeStream(
suite.ctx, client, clusterRoutingKey, "taskmon-1")
suite.Require().NoError(err)
defer taskmonMock.Close()
expectedQuery := "fetch_traces_query"
expectedResult := "traces_result_data"
// Start listening (this makes the test readable!)
go taskmonMock.ListenToStreamAndAssert(suite.T(), expectedQuery, expectedResult)
// Send query to server (via NATS or HTTP API)
// ... your test logic here ...
// Assert using helper methods
suite.Eventually(func() bool {
return taskmonMock.LastReceivedQuery() != nil &&
string(taskmonMock.LastReceivedQuery().TracesQueryPayload) == expectedQuery
}, 5*time.Second, 500*time.Millisecond)
}
func TestRemoteTracesTestSuite(t *testing.T) {
suite.Run(t, new(RemoteTracesTestSuite))
}
```
## Why This Pattern is Excellent
1. **Rich DSL** - `OpenTaskmonToWekaHomeStream()` returns friendly object with helper methods
2. **Helper Methods** - `ListenToStreamAndAssert()`, `LastReceivedQuery()`, `ReceivedQueriesPayloads()`
3. **Thread-Safe** - Mutex protects shared state for concurrent access
4. **Readable Tests** - Tests read like documentation, clear intent
5. **In-Memory** - Uses `bufconn` (no network I/O, pure Go)
6. **Reusable** - Same mock for unit, integration, and system tests
7. **Event-Driven** - Can add channels for connection events if needed
## Key Design Principles
### Testing Direction
- **Testing a server?** → Mock the **clients** that connect to it
- **Testing a client?** → Mock the **server** it connects to
### DSL Benefits
- Use rich DSL objects with helper methods
- Make tests read like documentation
- Hide complexity behind clean interfaces
- Provide thread-safe state tracking
- Enable fluent assertions
### In-Memory with bufconn
`bufconn` provides an in-memory, full-duplex network connection:
- No network I/O overhead
- No port allocation needed
- Faster than TCP loopback
- Perfect for CI/CD
- Deterministic behavior
## Benefits
- **No Docker required** - Pure Go, works anywhere
- **No binary downloads** - Everything in-memory
- **No network I/O** - Unless testing actual network code
- **Perfect for CI/CD** - Fast, reliable, no external dependencies
- **Lightning fast** - Microsecond startup time
- **Thread-safe** - Concurrent test execution safe
## Alternative: Testing gRPC Clients
If you're testing a **gRPC client**, mock the **server** instead:
```go
// internal/testutils/grpc_server_mock.go
type MockGRPCServer struct {
pb.UnimplementedRemoteTracesServiceServer
mu sync.Mutex
receivedQueries []*pb.TracesQuery
}
func (m *MockGRPCServer) StreamTraces(stream pb.RemoteTracesService_StreamTracesServer) error {
// Mock server implementation
// Store received queries, send responses
// ...
return nil
}
// Usage
server := testutils.NewMockGRPCServer()
lis := bufconn.Listen(1024 * 1024)
s := grpc.NewServer()
pb.RegisterRemoteTracesServiceServer(s, server)
// ... test your client against this mock server
```
## Key Takeaways
1. **bufconn is Level 1** - In-memory, no external dependencies
2. **Mock the opposite end** - Server → mock clients, Client → mock server
3. **Rich DSL makes tests readable** - Helper methods, clear intent
4. **Thread-safe state tracking** - Use mutexes for concurrent access
5. **Reusable across test levels** - Same infrastructure everywhere
6. **Check for official test harnesses first** - Many libraries provide them (like NATS)