用bufconn包对Golang gRPC客户端和服务器应用程序进行单元测试

798 阅读3分钟

在这个例子中,我们将创建一个简单的gRPC客户端和服务器应用程序。我们不使用第三方的嘲弄包,而是使用Golang本地的bufconn包进行测试。

bufconn可以帮助你启动一个服务器,但不是在一个真正的套接字/端口上,这很好。你的测试客户端对它的调用就像通过一个真实的端口与一个真实的服务器对话一样。这个包最好的一点是,你的测试实际上是在与真实的网络行为进行交互。所有这些都发生在内存连接上,而不是传统的操作系统级别的资源。你仍然可以通过一个端口获得正常的网络连接的预期行为。

先决条件

安装gRPC

go get -u google.golang.org/grpc

安装protobuf

# MacOS
brew install protobuf

安装protoc-gen-go

go get -u github.com/golang/protobuf/protoc-gen-go

安装bufconn

go get -u google.golang.org/grpc/test/bufconn

服务器

结构

├── Makefile
├── cmd
│   └── server
│       └── main.go
├── internal
│   └── bank
│       └── account
│           ├── deposit_server.go
│           └── deposit_server_test.go
└── pkg
    └── proto
        └── bank
            └── account
                ├── deposit.pb.go
                └── deposit.proto

文件

制作文件
.PHONY: compile
compile: ## Compile the proto file.
	protoc -I pkg/proto/bank/account/ pkg/proto/bank/account/deposit.proto --go_out=plugins=grpc:pkg/proto/bank/account/

.PHONY: run
run: ## Build and run server.
	go build -race -ldflags "-s -w" -o bin/server cmd/server/main.go
	bin/server
pkg/proto/bank/account/deposit.proto
syntax = "proto3";

package account;

option go_package = ".;account";

message DepositRequest {
    float amount = 1;
}

message DepositResponse {
    bool ok = 1;
}

service DepositService {
    rpc Deposit(DepositRequest) returns (DepositResponse) {}
}
pkg/proto/bank/account/deposit.pb.go

这个文件是用make compile 命令生成的,所以不显示内容。

cmd/server/main.go
package main

import (
	"log"
	"net"

	"github.com/inanzzz/client/internal/bank/account"
	"google.golang.org/grpc"

	pb "github.com/inanzzz/client/pkg/proto/bank/account"
)

func main() {
	log.Println("Server running ...")

	listener, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalln(err)
	}

	server := grpc.NewServer()

	pb.RegisterDepositServiceServer(server, &account.DepositServer{})

	log.Fatalln(server.Serve(listener))
}
internal/bank/account/deposit_server.go
package account

import (
	"context"
	"log"

	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"

	pb "github.com/inanzzz/client/pkg/proto/bank/account"
)

type DepositServer struct {
	pb.UnimplementedDepositServiceServer
}

func (*DepositServer) Deposit(ctx context.Context, req *pb.DepositRequest) (*pb.DepositResponse, error) {
	log.Println(req.GetAmount())

	if req.GetAmount() < 0 {
		return nil, status.Errorf(codes.InvalidArgument, "cannot deposit %v", req.GetAmount())
	}

	return &pb.DepositResponse{Ok: true}, nil
}
internal/bank/account/deposit_server_test.go
package account

import (
	"context"
	"fmt"
	"log"
	"net"
	"testing"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/grpc/test/bufconn"

	pb "github.com/inanzzz/client/pkg/proto/bank/account"
)

func dialer() func(context.Context, string) (net.Conn, error) {
	listener := bufconn.Listen(1024 * 1024)

	server := grpc.NewServer()

	pb.RegisterDepositServiceServer(server, &DepositServer{})

	go func() {
		if err := server.Serve(listener); err != nil {
			log.Fatal(err)
		}
	}()

	return func(context.Context, string) (net.Conn, error) {
		return listener.Dial()
	}
}

func TestDepositServer_Deposit(t *testing.T) {
	tests := []struct {
		name    string
		amount  float32
		res     *pb.DepositResponse
		errCode codes.Code
		errMsg  string
	}{
		{
			"invalid request with negative amount",
			-1.11,
			nil,
			codes.InvalidArgument,
			fmt.Sprintf("cannot deposit %v", -1.11),
		},
		{
			"valid request with non negative amount",
			0.00,
			&pb.DepositResponse{Ok: true},
			codes.OK,
			"",
		},
	}

	ctx := context.Background()

	conn, err := grpc.DialContext(ctx, "", grpc.WithInsecure(), grpc.WithContextDialer(dialer()))
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	client := pb.NewDepositServiceClient(conn)

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			request := &pb.DepositRequest{Amount: tt.amount}

			response, err := client.Deposit(ctx, request)

			if response != nil {
				if response.GetOk() != tt.res.GetOk() {
					t.Error("response: expected", tt.res.GetOk(), "received", response.GetOk())
				}
			}

			if err != nil {
				if er, ok := status.FromError(err); ok {
					if er.Code() != tt.errCode {
						t.Error("error code: expected", codes.InvalidArgument, "received", er.Code())
					}
					if er.Message() != tt.errMsg {
						t.Error("error message: expected", tt.errMsg, "received", er.Message())
					}
				}
			}
		})
	}
}

客户端

结构

├── Makefile
├── cmd
│   └── client
│       └── main.go
├── internal
│   └── bank
│       └── account
│           ├── deposit_client.go
│           └── deposit_client_test.go
└── pkg
    └── proto
        └── bank
            └── account
                ├── deposit.pb.go
                └── deposit.proto

文件

制作文件
.PHONY: compile
compile: ## Compile the proto file.
	protoc -I pkg/proto/bank/account/ pkg/proto/bank/account/deposit.proto --go_out=plugins=grpc:pkg/proto/bank/account/

.PHONY: run
run: ## Build and run client.
	go build -race -ldflags "-s -w" -o bin/client cmd/client/main.go
	bin/client
pkg/proto/bank/account/deposit.proto
syntax = "proto3";

package account;

option go_package = ".;account";

message DepositRequest {
    float amount = 1;
}

message DepositResponse {
    bool ok = 1;
}

service DepositService {
    rpc Deposit(DepositRequest) returns (DepositResponse) {}
}
pkg/proto/bank/account/deposit.pb.go

这个文件是用make compile 命令生成的,所以不显示内容。

cmd/client/main.go
package main

import (
	"context"
	"log"
	"time"

	"github.com/inanzzz/client/internal/bank/account"
	"google.golang.org/grpc"
)

func main() {
	log.Println("Client running ...")

	conn, err := grpc.Dial(":50051", grpc.WithInsecure(), grpc.WithBlock())
	if err != nil {
		log.Fatalln(err)
	}
	defer conn.Close()

	response, err := account.
		NewDepositClient(conn, time.Second).
		Deposit(context.Background(), 1990.01)

	log.Println(response)
	log.Println(err)
}
internal/bank/account/deposit_client.go
package account

import (
	"context"
	"fmt"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/status"

	pb "github.com/inanzzz/client/pkg/proto/bank/account"
)

type DepositClient struct {
	conn    *grpc.ClientConn
	timeout time.Duration
}

func NewDepositClient(conn *grpc.ClientConn, timeout time.Duration) DepositClient {
	return DepositClient{
		conn:    conn,
		timeout: timeout,
	}
}

func (d DepositClient) Deposit(ctx context.Context, amount float32) (bool, error) {
	client := pb.NewDepositServiceClient(d.conn)

	request := &pb.DepositRequest{Amount: amount}

	ctx, cancel := context.WithDeadline(ctx, time.Now().Add(d.timeout))
	defer cancel()

	response, err := client.Deposit(ctx, request)
	if err != nil {
		if er, ok := status.FromError(err); ok {
			return false, fmt.Errorf("grpc: %s, %s", er.Code(), er.Message())
		}
		return false, fmt.Errorf("server: %s", err.Error())
	}

	return response.GetOk(), nil
}
internal/bank/account/deposit_client_test.go
package account

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net"
	"testing"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/grpc/test/bufconn"

	pb "github.com/inanzzz/client/pkg/proto/bank/account"
)

type mockDepositServer struct {
	pb.UnimplementedDepositServiceServer
}

func (*mockDepositServer) Deposit(ctx context.Context, req *pb.DepositRequest) (*pb.DepositResponse, error) {
	if req.GetAmount() < 0 {
		return nil, status.Errorf(codes.InvalidArgument, "cannot deposit %v", req.GetAmount())
	}

	return &pb.DepositResponse{Ok: true}, nil
}

func dialer() func(context.Context, string) (net.Conn, error) {
	listener := bufconn.Listen(1024 * 1024)

	server := grpc.NewServer()

	pb.RegisterDepositServiceServer(server, &mockDepositServer{})

	go func() {
		if err := server.Serve(listener); err != nil {
			log.Fatal(err)
		}
	}()

	return func(context.Context, string) (net.Conn, error) {
		return listener.Dial()
	}
}

func TestDepositClient_Deposit(t *testing.T) {
	tests := []struct {
		name   string
		amount float32
		res    bool
		err    error
	}{
		{
			"invalid request with negative amount",
			-1.11,
			false,
			fmt.Errorf("grpc: InvalidArgument, cannot deposit %v", -1.11),
		},
		{
			"valid request with non negative amount",
			0.00,
			true,
			nil,
		},
	}

	ctx := context.Background()

	conn, err := grpc.DialContext(ctx, "", grpc.WithInsecure(), grpc.WithContextDialer(dialer()))
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			response, err := NewDepositClient(conn, time.Second).Deposit(context.Background(), tt.amount)

			if response != tt.res {
				t.Error("error: expected", tt.res, "received", response)
			}

			if err != nil && errors.Is(err, tt.err) {
				t.Error("error: expected", tt.err, "received", err)
			}
		})
	}
}

测试

服务器

$ go test -v -run TestDepositServer_Deposit ./internal/bank/account/

=== RUN   TestDepositServer_Deposit
=== RUN   TestDepositServer_Deposit/invalid_request_with_negative_amount
=== RUN   TestDepositServer_Deposit/valid_request_with_non_negative_amount
--- PASS: TestDepositServer_Deposit (0.00s)
    --- PASS: TestDepositServer_Deposit/invalid_request_with_negative_amount (0.00s)
    --- PASS: TestDepositServer_Deposit/valid_request_with_non_negative_amount (0.00s)
PASS
ok  	github.com/inanzzz/client/internal/bank/account	0.011s

# When you run with test coverage
coverage: 100.0% of statements

客户端

$ go test -v -run TestDepositClient_Deposit ./internal/bank/account/

=== RUN   TestDepositClient_Deposit
=== RUN   TestDepositClient_Deposit/invalid_request_with_negative_amount
=== RUN   TestDepositClient_Deposit/valid_request_with_non_negative_amount
--- PASS: TestDepositClient_Deposit (0.00s)
    --- PASS: TestDepositClient_Deposit/invalid_request_with_negative_amount (0.00s)
    --- PASS: TestDepositClient_Deposit/valid_request_with_non_negative_amount (0.00s)
PASS
ok  	github.com/inanzzz/client/internal/bank/account	0.011s

# When you run with test coverage
coverage: 90.9% of statements