一文搞懂 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[1]。
然而,對於使用主流的 gRPC 等 RPC 協議的服務端 Handler[2] 來說,是否存在類似 httptest 的測試腳手架生成工具包呢?對 gRPC 的服務端 Handler 有哪些單元測試的方法呢?在這篇文章中,我們就一起來探究一下。
- 建立被測的 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 客戶端的那些事兒 [3]》一文。
我們這個 SUT(被測目標) 是包含以上四種通信模式的 gRPC 服務,它的 Protocol Buffers[4] 文件如下:
// 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
注:你的環境下需要安裝 protoc[5] 和 protoc-gen-go[6] 才能正確執行上面生成命令,具體的安裝方法可參考 protoc 安裝文檔 [7]。
注:除了使用經典的 protoc[8] 基於 proto 文件生成 Go 源碼外,也可以基於 Go 開發的 buf cli[9] 進行代碼生成和 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 方法開始。
- 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[10] 和表驅動的測試也非常 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 這樣簡單那就好了,但實際上...,好吧,我們繼續向下看就好了。
- 針對 Streaming 通信模式的單元測試
3.1 ServerStreamingRPC 的測試
前面說過,gRPC 支持三種 Streaming 通信模式:Server-Streaming RPC、Client-Streaming RPC 和 Bidirectional-Streaming RPC。
我們先來看看 Server-Streaming RPC 的方法原型:
// grpc-test-examples/grpctest/server.go
func (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 進行單測 [11],這的確是一個可行的方法,我們下面就基於這個思路實現一下。
注:關於基於 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 的測試用例就作爲作業,交給各位讀者去完成吧!
- 小結
在本文中,我們詳細探討了如何對 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 的單測方法可能不止文中提及這些,各位讀者如果有更好的方法和實踐,歡迎在評論區留言!
本文涉及的源碼可以在這裏 [12] 下載。
- 參考資料
-
Testing gRPC methods[13] - https://medium.com/@johnsiilver/testing-grpc-methods-6a8edad4159d
-
《gRPC Up and Running》[14] - https://book.douban.com/subject/34796013/
-
Mocking the Universe: Two Techniques for Testing gRPC with Mocks[15] - https://rotational.io/blog/mocking-the-universe/
Gopher Daily(Gopher 每日新聞) - https://gopherdaily.tonybai.com
我的聯繫方式:
-
微博 (暫不可用):https://weibo.com/bigwhite20xx
-
微博 2:https://weibo.com/u/6484441286
-
博客:tonybai.com
-
github: https://github.com/bigwhite
-
Gopher Daily 歸檔 - https://github.com/bigwhite/gopherdaily
參考資料
[1]
利用 httptest 的 NewServer 來構建一個 fake 的 http server: https://tonybai.com/2023/04/20/provide-fake-object-for-external-collaborators
[2]
gRPC 等 RPC 協議的服務端 Handler: https://tonybai.com/2021/09/26/the-design-of-the-response-for-grpc-server
[3]
gRPC 客戶端的那些事兒: https://tonybai.com/2021/09/17/those-things-about-grpc-client
[4]
Protocol Buffers: https://protobuf.dev/
[5]
protoc: https://grpc.io/docs/protoc-installation/
[6]
protoc-gen-go: https://github.com/golang/protobuf/tree/master/protoc-gen-go
[7]
protoc 安裝文檔: https://grpc.io/docs/protoc-installation/
[8]
protoc: https://grpc.io/docs/protoc-installation/
[9]
buf cli: https://github.com/bufbuild/buf
[10]
基於 subtest: https://tonybai.com/2023/03/15/an-intro-of-go-subtest/
[11]
使用 fake object 來對 ServerStreamingRPC 進行單測: https://tonybai.com/2023/04/20/provide-fake-object-for-external-collaborators/
[12]
這裏: https://github.com/bigwhite/experiments/tree/master/grpc-test-examples
[13]
Testing gRPC methods: https://medium.com/@johnsiilver/testing-grpc-methods-6a8edad4159d
[14]
《gRPC Up and Running》: https://book.douban.com/subject/34796013/
[15]
Mocking the Universe: Two Techniques for Testing gRPC with Mocks: https://rotational.io/blog/mocking-the-universe/
[16]
“Gopher 部落” 知識星球: https://public.zsxq.com/groups/51284458844544
[17]
鏈接地址: https://m.do.co/c/bff6eed92687
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/6VH5uZ1l-EgnVAJQ7MMqbg