271 lines
7.1 KiB
Markdown
271 lines
7.1 KiB
Markdown
# gRPC Testing Reference
|
|
|
|
## Testing with `bufconn`
|
|
|
|
`bufconn` creates in-memory connections that exercise the full gRPC stack (serialization, interceptors, metadata) without network overhead. This is the standard approach for gRPC unit and integration tests.
|
|
|
|
### Basic Setup
|
|
|
|
```go
|
|
func setupTest(t *testing.T) pb.UserServiceClient {
|
|
t.Helper()
|
|
lis := bufconn.Listen(1024 * 1024)
|
|
t.Cleanup(func() { lis.Close() })
|
|
|
|
srv := grpc.NewServer()
|
|
pb.RegisterUserServiceServer(srv, newTestService())
|
|
t.Cleanup(func() { srv.Stop() })
|
|
go srv.Serve(lis)
|
|
|
|
conn, err := grpc.NewClient("passthrough:///bufconn",
|
|
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
|
return lis.DialContext(ctx)
|
|
}),
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
t.Cleanup(func() { conn.Close() })
|
|
return pb.NewUserServiceClient(conn)
|
|
}
|
|
```
|
|
|
|
### Setup with Interceptors
|
|
|
|
Test your interceptors by including them in the test server:
|
|
|
|
```go
|
|
func setupTestWithInterceptors(t *testing.T) pb.UserServiceClient {
|
|
t.Helper()
|
|
lis := bufconn.Listen(1024 * 1024)
|
|
t.Cleanup(func() { lis.Close() })
|
|
|
|
srv := grpc.NewServer(
|
|
grpc.ChainUnaryInterceptor(
|
|
loggingInterceptor,
|
|
authInterceptor,
|
|
recoveryInterceptor,
|
|
),
|
|
)
|
|
pb.RegisterUserServiceServer(srv, newTestService())
|
|
t.Cleanup(func() { srv.Stop() })
|
|
go srv.Serve(lis)
|
|
|
|
conn, err := grpc.NewClient("passthrough:///bufconn",
|
|
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
|
return lis.DialContext(ctx)
|
|
}),
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
t.Cleanup(func() { conn.Close() })
|
|
return pb.NewUserServiceClient(conn)
|
|
}
|
|
```
|
|
|
|
## Testing Error Codes
|
|
|
|
Always verify that RPCs return the expected gRPC status codes — clients rely on codes for retry and error-handling logic.
|
|
|
|
```go
|
|
func TestGetUser_NotFound(t *testing.T) {
|
|
client := setupTest(t)
|
|
_, err := client.GetUser(context.Background(), &pb.GetUserRequest{UserId: "nonexistent"})
|
|
|
|
st, ok := status.FromError(err)
|
|
if !ok {
|
|
t.Fatalf("expected gRPC status error, got: %v", err)
|
|
}
|
|
if st.Code() != codes.NotFound {
|
|
t.Errorf("expected NotFound, got %s: %s", st.Code(), st.Message())
|
|
}
|
|
}
|
|
```
|
|
|
|
### Table-Driven Error Code Tests
|
|
|
|
```go
|
|
func TestGetUser_Errors(t *testing.T) {
|
|
client := setupTest(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
req *pb.GetUserRequest
|
|
wantCode codes.Code
|
|
}{
|
|
{
|
|
name: "empty user ID",
|
|
req: &pb.GetUserRequest{UserId: ""},
|
|
wantCode: codes.InvalidArgument,
|
|
},
|
|
{
|
|
name: "user not found",
|
|
req: &pb.GetUserRequest{UserId: "nonexistent"},
|
|
wantCode: codes.NotFound,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := client.GetUser(context.Background(), tt.req)
|
|
st, ok := status.FromError(err)
|
|
if !ok {
|
|
t.Fatalf("expected gRPC status error, got: %v", err)
|
|
}
|
|
if st.Code() != tt.wantCode {
|
|
t.Errorf("code = %s, want %s; message: %s", st.Code(), tt.wantCode, st.Message())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing Streaming RPCs
|
|
|
|
### Server Streaming
|
|
|
|
```go
|
|
func TestListUsers_Stream(t *testing.T) {
|
|
client := setupTest(t)
|
|
stream, err := client.ListUsers(context.Background(), &pb.ListUsersRequest{})
|
|
if err != nil {
|
|
t.Fatalf("ListUsers: %v", err)
|
|
}
|
|
|
|
var users []*pb.User
|
|
for {
|
|
user, err := stream.Recv()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("Recv: %v", err)
|
|
}
|
|
users = append(users, user)
|
|
}
|
|
|
|
if len(users) != 3 {
|
|
t.Errorf("got %d users, want 3", len(users))
|
|
}
|
|
}
|
|
```
|
|
|
|
### Client Streaming
|
|
|
|
```go
|
|
func TestBatchCreate_ClientStream(t *testing.T) {
|
|
client := setupTest(t)
|
|
stream, err := client.BatchCreateUsers(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("BatchCreateUsers: %v", err)
|
|
}
|
|
|
|
for _, u := range testUsers {
|
|
if err := stream.Send(u); err != nil {
|
|
t.Fatalf("Send: %v", err)
|
|
}
|
|
}
|
|
|
|
resp, err := stream.CloseAndRecv()
|
|
if err != nil {
|
|
t.Fatalf("CloseAndRecv: %v", err)
|
|
}
|
|
if resp.Created != int32(len(testUsers)) {
|
|
t.Errorf("created = %d, want %d", resp.Created, len(testUsers))
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing Metadata
|
|
|
|
Verify that interceptors correctly read/write metadata:
|
|
|
|
```go
|
|
func TestAuth_Metadata(t *testing.T) {
|
|
client := setupTestWithInterceptors(t)
|
|
|
|
// Without auth token → Unauthenticated
|
|
_, err := client.GetUser(context.Background(), &pb.GetUserRequest{UserId: "1"})
|
|
if st, _ := status.FromError(err); st.Code() != codes.Unauthenticated {
|
|
t.Errorf("expected Unauthenticated without token, got %s", st.Code())
|
|
}
|
|
|
|
// With valid token → success
|
|
md := metadata.Pairs("authorization", "Bearer valid-token")
|
|
ctx := metadata.NewOutgoingContext(context.Background(), md)
|
|
resp, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: "1"})
|
|
if err != nil {
|
|
t.Fatalf("expected success with valid token: %v", err)
|
|
}
|
|
if resp.User == nil {
|
|
t.Error("expected user in response")
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing Deadlines
|
|
|
|
```go
|
|
func TestGetUser_DeadlineExceeded(t *testing.T) {
|
|
client := setupTest(t) // server handler sleeps for 5s
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
|
|
_, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: "slow"})
|
|
st, _ := status.FromError(err)
|
|
if st.Code() != codes.DeadlineExceeded {
|
|
t.Errorf("expected DeadlineExceeded, got %s", st.Code())
|
|
}
|
|
}
|
|
```
|
|
|
|
## Integration Test Patterns
|
|
|
|
For tests that hit real dependencies (database, external services), use build tags to separate them:
|
|
|
|
```go
|
|
//go:build integration
|
|
|
|
func TestUserService_Integration(t *testing.T) {
|
|
// Connect to real gRPC server
|
|
conn, err := grpc.NewClient("localhost:50051",
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
client := pb.NewUserServiceClient(conn)
|
|
|
|
// Test full roundtrip
|
|
created, err := client.CreateUser(context.Background(), &pb.CreateUserRequest{
|
|
Name: "integration-test",
|
|
Email: "test@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
|
|
got, err := client.GetUser(context.Background(), &pb.GetUserRequest{
|
|
UserId: created.User.Id,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GetUser: %v", err)
|
|
}
|
|
if got.User.Name != "integration-test" {
|
|
t.Errorf("name = %q, want %q", got.User.Name, "integration-test")
|
|
}
|
|
}
|
|
```
|
|
|
|
Run integration tests separately:
|
|
|
|
```bash
|
|
go test -tags=integration ./...
|
|
```
|