319 lines
9.7 KiB
Markdown
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)
|