写点什么

一文搞懂 Go gRPC 服务 Handler 单元测试

作者:Tony Bai
  • 2023-11-25
    辽宁
  • 本文字数:9754 字

    阅读完需:约 32 分钟

一文搞懂Go gRPC服务Handler单元测试

在云原生时代和微服务架构背景下,HTTP 和 RPC 协议成为服务间通信和与客户端交互的两种主要方式。对于 Go 语言而言,标准库提供了 net/http/httptest 包,为开发人员提供了便捷的方式来构建服务端 HTTP Handler 单元测试的测试脚手架代码,而无需真正建立 HTTP 服务器,让开发人员可以聚焦于对 Handler 业务逻辑的测试。比如下面这个示例:


// grpc-test-examples/httptest/http_handler_test.go
func myHandler(w http.ResponseWriter, r *http.Request) { // 设置响应头 w.Header().Set("Content-Type", "text/plain")
// 根据请求方法进行不同的处理 switch r.Method { case http.MethodGet: // 处理GET请求 fmt.Fprint(w, "Hello, World!") ... ... }}
func TestMyHandler(t *testing.T) { // 创建一个ResponseRecorder来记录Handler的响应 rr := httptest.NewRecorder()
// 创建一个模拟的HTTP请求,可以指定请求的方法、路径、正文等 req, err := http.NewRequest("GET", "/path", nil) if err != nil { t.Fatal(err) }
// 调用被测试的Handler函数,传入ResponseRecorder和Request对象 // 这里假设被测试的Handler函数为myHandler myHandler(rr, req)
// 检查响应状态码和内容 if rr.Code != http.StatusOK { t.Errorf("Expected status 200; got %d", rr.Code) } expected := "Hello, World!" if rr.Body.String() != expected { t.Errorf("Expected body to be %q; got %q", expected, rr.Body.String()) }}
复制代码


注:对 http client 端的单元测试,也可以利用httptest的NewServer来构建一个fake的http server


然而,对于使用主流的gRPC等RPC协议的服务端Handler来说,是否存在类似 httptest 的测试脚手架生成工具包呢?对 gRPC 的服务端 Handler 有哪些单元测试的方法呢?在这篇文章中,我们就一起来探究一下。

1. 建立被测的 gRPC 服务端 Handler

我们首先来建立一个涵盖多种 gRPC 通信模式的服务端 Handler 集合。


gRPC 支持四种通信模式,它们分别为:


  • 简单 RPC(Simple RPC,也称为 Unary RPC)


这是最简单的,也是最常用的 gRPC 通信模式,简单来说就是一请求一应答


  • 服务端流 RPC(Server-streaming RPC)


客户端发来一个请求,服务端通过流返回多个应答。


  • 客户端流 RPC(Client-streaming RPC)


客户端通过流发来多个请求,服务端以一个应答回复。


  • 双向流 RPC(Bidirectional-Streaming RPC)


客户端通过流发起多个请求,服务端也通过流对应返回多个应答。


注:关于 gRPC 四种通信方式的详情,可以参考我之前写的《gRPC客户端的那些事儿》一文。


我们这个 SUT(被测目标)是包含以上四种通信模式的 gRPC 服务,它的Protocol Buffers文件如下:


// grpc-test-examples/grpctest/IDL/proto/mygrpc.proto
syntax = "proto3";
package mygrpc;
service MyService { // Unary RPC rpc UnaryRPC(RequestMessage) returns (ResponseMessage) {}
// Server-Streaming RPC rpc ServerStreamingRPC(RequestMessage) returns (stream ResponseMessage) {}
// Client-Streaming RPC rpc ClientStreamingRPC(stream RequestMessage) returns (ResponseMessage) {}
// Bidirectional-Streaming RPC rpc BidirectionalStreamingRPC(stream RequestMessage) returns (stream ResponseMessage) {}}
message RequestMessage { string message = 1;}
message ResponseMessage { string message = 1;}
复制代码


通过 protoc,我们可基于上述 proto 文件生成 MyService 桩(Stub)代码,生成的代码放在了 mygrpc 目录下面:


// grpc-test-examples/grpctest/Makefile
all: gen
gen: protoc -I ./IDL/proto mygrpc.proto --gofast_out=plugins=grpc:./mygrpc
复制代码


注:你的环境下需要安装protocprotoc-gen-go才能正确执行上面生成命令,具体的安装方法可参考protoc安装文档


注:除了使用经典的protoc基于 proto 文件生成 Go 源码外,也可以基于 Go 开发的buf cli进行代码生成和 API 管理。buf cLi 是现代、快速、高效的 Protobuf API 管理的终极工具,为基于 Protobuf 的开发和维护提供了全面的解决方案。等有机会的时候,我在以后的文章中详细说说 buf。


有了生成的桩代码后,我们便可以建立一个 gRPC 服务器:


// grpc-test-examples/grpctest/main.go
package main import ( pb "demo/mygrpc" "log" "net"
"google.golang.org/grpc")
func main() { // 创建 gRPC 服务器 lis, err := net.Listen("tcp", ":50051") if err != nil { log.Fatalf("failed to listen: %v", err) } s := grpc.NewServer()
// 注册 MyService 服务 pb.RegisterMyServiceServer(s, &server{})
// 启动 gRPC 服务器 log.Println("Starting gRPC server...") if err := s.Serve(lis); err != nil { log.Fatalf("failed to serve: %v", err) }}
复制代码


我们看到:在 main 函数中,我们创建了一个 TCP 监听器,并使用 grpc.NewServer()创建了一个 gRPC 服务器。然后,我们通过调用 pb.RegisterMyServiceServer()将 server 类型的实例注册到 gRPC 服务器上,以处理来自客户端的请求。最后,我们启动 gRPC 服务器并监听指定的端口。


上面代码中注册到服务器中的 server 类型就是实现了 MyService 服务接口的具体类型,它实现了 MyService 定义的所有方法:


// grpc-test-examples/grpctest/server.go
package main
import ( "context" "fmt" "strconv"
pb "demo/mygrpc")
type server struct{}
func (s *server) UnaryRPC(ctx context.Context, req *pb.RequestMessage) (*pb.ResponseMessage, error) { message := "Unary RPC received: " + req.Message fmt.Println(message)
return &pb.ResponseMessage{ Message: "Unary RPC response", }, nil}
func (s *server) ServerStreamingRPC(req *pb.RequestMessage, stream pb.MyService_ServerStreamingRPCServer) error { message := "Server Streaming RPC received: " + req.Message fmt.Println(message)
for i := 0; i < 5; i++ { response := &pb.ResponseMessage{ Message: "Server Streaming RPC response " + strconv.Itoa(i+1), } if err := stream.Send(response); err != nil { return err } }
return nil}
func (s *server) ClientStreamingRPC(stream pb.MyService_ClientStreamingRPCServer) error { var messages []string
for { req, err := stream.Recv() if err != nil { return err }
messages = append(messages, req.Message)
if req.Message == "end" { break } }
message := "Client Streaming RPC received: " + fmt.Sprintf("%v", messages) fmt.Println(message)
return stream.SendAndClose(&pb.ResponseMessage{ Message: "Client Streaming RPC response", })}
func (s *server) BidirectionalStreamingRPC(stream pb.MyService_BidirectionalStreamingRPCServer) error { for { req, err := stream.Recv() if err != nil { return err }
message := "Bidirectional Streaming RPC received: " + req.Message fmt.Println(message)
response := &pb.ResponseMessage{ Message: "Bidirectional Streaming RPC response", } if err := stream.Send(response); err != nil { return err } }}
复制代码


在上面代码中,我们创建了一个 server 结构体类型,并实现了 MyService 的所有 RPC 方法。每个方法都接收相应的请求消息,并返回对应的响应消息。我们的目标仅是演示如何对上述 gRPC Handler 进行单元测试,所以这里的实现逻辑非常简单。


接下来,我们就来逐一对这些 gRPC 的 Handler 方法进行单测,我们先从简单的 UnaryRPC 方法开始。

2. Unary RPC Handler 的单元测试

Unary RPC 是最简单,也是最容易理解的 RPC 通信模式,即客户端与服务端采用一请求一应答的模式。server 类型的 UnaryRPC Handler 方法的原型如下:


// grpc-test-examples/grpctest/server.go
func (s *server) UnaryRPC(ctx context.Context, req *pb.RequestMessage) (*pb.ResponseMessage, error)
复制代码


就像文章开头做的那个 httpserver 的 handler 单测一样,我们肯定不想真实启动一个 gRPC server,也不想测试 gRPC 服务器本身。我们只想测试服务端 handler 方法的逻辑是否正确。


观察一下这个方法原型,我们发现它仅依赖两个消息结构:RequestMessage 和 ResponseMessage,这两个消息结构是上面基于 proto 文件自动生成的,这样我们就可以不借助任何工具包实现对 UnaryRPC handler 方法的单测,也无需启动真实的 gRPC Server:


// grpc-test-examples/grpctest/server_test.go
type server struct{}
func TestServerUnaryRPC(t *testing.T) { s := &server{}
req := &pb.RequestMessage{ Message: "Test message", }
resp, err := s.UnaryRPC(context.Background(), req) if err != nil { t.Fatalf("UnaryRPC failed: %v", err) }
expectedResp := &pb.ResponseMessage{ Message: "Unary RPC response", }
if resp.Message != expectedResp.Message { t.Errorf("Unexpected response. Got: %s, Want: %s", resp.Message, expectedResp.Message) }}
复制代码


将其改造为基于subtest和表驱动的测试也非常 easy:


// grpc-test-examples/grpctest/server_test.go
func TestServerUnaryRPCs(t *testing.T) { tests := []struct { name string requestMessage *pb.RequestMessage expectedResp *pb.ResponseMessage }{ { name: "Test Case 1", requestMessage: &pb.RequestMessage{ Message: "Test message", }, expectedResp: &pb.ResponseMessage{ Message: "Unary RPC response", }, }, // Add more test cases as needed }
s := &server{}
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resp, err := s.UnaryRPC(context.Background(), tt.requestMessage) if err != nil { t.Fatalf("UnaryRPC failed: %v", err) }
if resp.Message != tt.expectedResp.Message { t.Errorf("Unexpected response. Got: %s, Want: %s", resp.Message, tt.expectedResp.Message) } }) }}
复制代码


如果 gRPC handler 测试都像 UnaryRPC 这样简单那就好了,但实际上...,好吧,我们继续向下看就好了。

3. 针对 Streaming 通信模式的单元测试

3.1 ServerStreamingRPC 的测试

前面说过,gRPC 支持三种 Streaming 通信模式:Server-Streaming RPC、Client-Streaming RPC 和 Bidirectional-Streaming RPC。


我们先来看看 Server-Streaming RPC 的方法原型:


// grpc-test-examples/grpctest/server.gofunc (s *server) ServerStreamingRPC(req *pb.RequestMessage, stream pb.MyService_ServerStreamingRPCServer) error 
复制代码


我们看到除了 RequestMessag 外,该方法还依赖一个 MyService_ServerStreamingRPCServer 的类型,这个类型是一个接口类型:


// grpc-test-examples/mygrpc/mygrpc.pb.go
type MyService_ServerStreamingRPCServer interface { Send(*ResponseMessage) error grpc.ServerStream}
复制代码


到这里,你脑子中可能已经冒出了一个想法:使用fake object来对ServerStreamingRPC进行单测,这的确是一个可行的方法,我们下面就基于这个思路实现一下。


注:关于基于 fake object 进行单测的内容,大家可以看看我以前写的一篇文章《[]单测时尽量用 fake object(https://tonybai.com/2023/04/20/provide-fake-object-for-external-collaborators)》。

3.2 基于 fake object 的测试

我们首先创建一个实现 MyService_ServerStreamingRPCServer 的 fake object 用以代替真实运行 RPC 服务器时由服务器传入的 stream object:


// grpc-test-examples/grpctest/server_with_fakeobject_test.go
import ( "testing"
pb "demo/mygrpc"
"google.golang.org/grpc")
type fakeServerStreamingRPCStream struct { grpc.ServerStream responses []*pb.ResponseMessage}
func (m *fakeServerStreamingRPCStream) Send(resp *pb.ResponseMessage) error { m.responses = append(m.responses, resp) return nil}
复制代码


我们看到 fakeServerStreamingRPCStream 的 Send 方法只是将收到的 ResponseMessage 追加到且内部的 ResponseMessage 切片中。


接下来我们为 ServerStreamingRPC 编写测试用例:


// grpc-test-examples/grpctest/server_with_fakeobject_test.go
func TestServerServerStreamingRPC(t *testing.T) { s := &server{} req := &pb.RequestMessage{ Message: "Test message", } stream := &fakeServerStreamingRPCStream{} err := s.ServerStreamingRPC(req, stream) if err != nil { t.Fatalf("ServerStreamingRPC failed: %v", err) } expectedResponses := []string{ "Server Streaming RPC response 1", "Server Streaming RPC response 2", "Server Streaming RPC response 3", "Server Streaming RPC response 4", "Server Streaming RPC response 5", } if len(stream.responses) != len(expectedResponses) { t.Errorf("Unexpected number of responses. Got: %d, Want: %d", len(stream.responses), len(expectedResponses)) } for i, resp := range stream.responses { if resp.Message != expectedResponses[i] { t.Errorf("Unexpected response at index %d. Got: %s, Want: %s", i, resp.Message, expectedResponses[i]) } } }
复制代码


在这个测试中,ServerStreamingRPC 接收一个请求(req),并通过 fake stream object 的 Send 方法返回了 5 个 response,通过与预期的 response 对比,即可做出测试是否通过的断言。


到这里,我们看到:fake object 完全满足对 gRPC Server Handler 进行测试的要求。不过我们需要针对不同的 Handler 建立不同的 fake object 类型,和文初基于 httptest 创建的测试用例相比,用例间欠缺了一些一致性。


那 grpc-go 是否提供了类似 httptest 的工具来帮助我们更一致的实现 grpc server handler 的测试用例呢?我们继续往下看。

3.3 利用 grpc-go 提供的测试工具包

grpc-go 项目在 test 下提供了 bufconn 包,可以帮助我们像 httptest 那样建立用于测试的“虚拟 gRPC 服务器”,下面是基于 bufconn 包建立 gRPC 测试用服务器的代码:


// grpc-test-examples/grpctest/server_with_buffconn_test.go
package main
import ( "context" "log" "net" "testing"
pb "demo/mygrpc"
"google.golang.org/grpc" "google.golang.org/grpc/test/bufconn")
func newGRPCServer(t *testing.T) (pb.MyServiceClient, func()) { // 创建 bufconn.Listener 作为服务器的监听器 listener := bufconn.Listen(1024 * 1024)
// 创建 gRPC 服务器 srv := grpc.NewServer()
// 注册服务处理程序 pb.RegisterMyServiceServer(srv, &server{})
// 在监听器上启动服务器 go func() { if err := srv.Serve(listener); err != nil { t.Fatalf("Server failed to start: %v", err) } }()
// 创建 bufconn.Dialer 作为客户端连接 dialer := func(context.Context, string) (net.Conn, error) { return listener.Dial() }
// 使用 DialContext 和 bufconn.Dialer 创建客户端连接 conn, err := grpc.DialContext(context.Background(), "bufnet", grpc.WithContextDialer(dialer), grpc.WithInsecure()) if err != nil { t.Fatalf("Failed to dial server: %v", err) }
// 创建客户端实例 client := pb.NewMyServiceClient(conn) return client, func() { err := listener.Close() if err != nil { log.Printf("error closing listener: %v", err) } srv.Stop() }}
复制代码


newGRPCServer 是一个用于在测试中创建 gRPC 服务器和客户端的辅助函数,它使用 bufconn.Listen 创建一个 bufconn.Listener 作为服务器的监听器。bufconn 包提供了一种在内存中模拟网络连接的方法。然后,它使用 grpc.NewServer()创建了一个新的 gRPC 服务器实例,并使用 pb.RegisterMyServiceServer 将待测的服务实例(这里是 server 类型实例)注册到 gRPC 服务器中。接下来,它创建了与该服务器建连的 gRPC 客户端,由于该客户端要与 bufconn.Listener 建连,这里用了一个 dialer 函数,该函数将通过调用 listener.Dial()来建立与服务器的连接。之后基于该连接,我们创建了 MyServiceClient 的客户端实例,并返回,供测试用例使用。


基于 newGPRCServer 这种方式,我们改造一下 UnaryRPC 的测试用例:


// grpc-test-examples/grpctest/server_with_buffconn_test.go
func TestServerUnaryRPCWithBufConn(t *testing.T) { client, shutdown := newGRPCServer(t) defer shutdown()
tests := []struct { name string requestMessage *pb.RequestMessage expectedResp *pb.ResponseMessage }{ { name: "Test Case 1", requestMessage: &pb.RequestMessage{ Message: "Test message", }, expectedResp: &pb.ResponseMessage{ Message: "Unary RPC response", }, }, // Add more test cases as needed }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resp, err := client.UnaryRPC(context.Background(), tt.requestMessage) if err != nil { t.Fatalf("UnaryRPC failed: %v", err) }
if resp.Message != tt.expectedResp.Message { t.Errorf("Unexpected response. Got: %s, Want: %s", resp.Message, tt.expectedResp.Message) } }) }}
复制代码


我们看到,相对于前面的 TestServerUnaryRPCs,两者复杂度在一个层次。如果结合下面的 ServerStreamRPC 的测试用例,你就能看出这种方式在测试用例一致性方面的优势了:


// grpc-test-examples/grpctest/server_with_buffconn_test.go
func TestServerServerStreamingRPCWithBufConn(t *testing.T) { client, shutdown := newGRPCServer(t) defer shutdown()
req := &pb.RequestMessage{ Message: "Test message", }
stream, err := client.ServerStreamingRPC(context.Background(), req) if err != nil { t.Fatalf("ServerStreamingRPC failed: %v", err) }
expectedResponses := []string{ "Server Streaming RPC response 1", "Server Streaming RPC response 2", "Server Streaming RPC response 3", "Server Streaming RPC response 4", "Server Streaming RPC response 5", }
gotResponses := []string{}
for { resp, err := stream.Recv() if err != nil { break } gotResponses = append(gotResponses, resp.Message) }
if len(gotResponses) != len(expectedResponses) { t.Errorf("Unexpected number of responses. Got: %d, Want: %d", len(gotResponses), len(expectedResponses)) }
for i, resp := range gotResponses { if resp != expectedResponses[i] { t.Errorf("Unexpected response at index %d. Got: %s, Want: %s", i, resp, expectedResponses[i]) } }}
复制代码


我们再也无需为每个 Server Handler 建立各自的 fake object 了!


由此看到:grpc-go 的 test/bufconn 就是类似 httptest 的那个 grpc server handler 的测试脚手架搭建工具。

3.4 其他 Streaming 模式的 Handler 测试

有了 bufconn 这一利器,其他 Streaming 模式的 Handler 测试实现逻辑就大同小异了。本文示例中的 ClientStreamingRPC 和 BidirectionalStreamingRPC 两个 Handler 的测试用例就作为作业,交给各位读者去完成吧!

4. 小结

在本文中,我们详细探讨了如何对 gRPC 服务端 Handler 进行单元测试,我们的目标是找到像 net/http/httptest 包那样的,可以为 gRPC 服务端 handler 测试提供脚手架代码帮助的测试方法。


我们按照 gRPC 的四种通信方式,由简到难的逐一探讨各种 Handler 的单测方法。UnaryRPC handler 测试最为简单,毫无技巧的普通测试逻辑便能应付。


但一旦涉及 streaming 通信方式的测试,我们就需要借助类似 fake object 的单测技术了。但 fake object 也有不足,那就是需要为每个 RPC handler 建立单独的 fake object,费时费力还缺少一致性!


好在,grpc-go 项目为我们提供了 test/bufconn 包,该包可以像 net/http/httptest 包那样帮助我们快速建立可复用的测试脚手架代码,这样我们便可以为所有服务端 RPC Handler 建立一致、稳定的单元测试用例了!


当然,服务端 RPC Handler 的单测方法可能不止文中提及这些,各位读者如果有更好的方法和实践,欢迎在评论区留言!


本文涉及的源码可以在这里下载。

5. 参考资料

发布于: 刚刚阅读数: 4
用户头像

Tony Bai

关注

还未添加个人签名 2017-12-01 加入

极客时间专栏《Go语言第一课》讲师

评论

发布
暂无评论
一文搞懂Go gRPC服务Handler单元测试_Go_Tony Bai_InfoQ写作社区