Initial commit
This commit is contained in:
318
skills/testing/examples/grpc-bufconn.md
Normal file
318
skills/testing/examples/grpc-bufconn.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user