چطور در golang یک سرویس gRPC را در تست‌ها mock کنیم

قسمت دوم از سری مطالب «چطور یک سیستم مایکروسرویس روی کوبرنیتز بسازیم».

کد کامل این پست رو می‌تونید اینجا ببینید.

gRPC

ما تقریبا در تمامی سرویس‌های پلتفرم از gRPC برای ارتباط بین سرویس‌ها استفاده می‌کنیم. gRPC یک فریم‌ورک بهینه توسعه RPC است که قبلا قابلیت‌هاش در شرکت‌های بزرگ اثبات شده است.

در کنار کارایی gRPC مهمترین دلیلی که ما gRPC رو تقریبا همه جا استفاده می‌کنیم پروتکل انتقال پیام این فریم ورک یعنی Protocol Buffers است. حتی در سرویس‌هایی که از gRPC استفاده نمی‌کنیم، پیام‌هایی که به سمت سرویس‌های داخلی ارسال می‌کنیم را توسط پروتکل‌بافر (یا به اختصار پروتوباف) منتقل می‌کنیم.

اینجا از یک مثال ساده برای نمایش نحوه mock کردن gRPC استفاده می‌کنم.

ایجاد یک سرویس gRPC

در این مطلب ابتدا با تعریف یک فایل پروتوباف اون رو کامپایل و سپس یک کلاینت برای این سرویس پیاده‌سازی و در نهایت در تست‌ها این کلاینت gRPC رو mock می‌کنیم.

تعریف فایل Protobuf

این سرویس از دو rpc تشکیل شده که یکی با دریافت پیام PING پیام PONG رو در جواب بر می‌گردونه و دومی با دریافت هر پیامی، همون پیام رو در جواب بر می‌گردونه:

syntax = "proto3";

package pb;

service Signal {
  rpc Ping (PingRequest) returns (PingResponse) {}
  rpc Echo (EchoRequest) returns (EchoResponse) {}
}

option go_package = ".;pb";

message PingRequest {
  string message = 1;
}

message PingResponse {
  string message = 1;
}

message EchoRequest {
  string message = 1;
}

message EchoResponse {
  string message = 1;
}

کدهای پروتوباف رو کامپایل می‌کنیم:

protoc -I/opt/include -I/usr/local/include -Ipb \
		--go_out=./pb --go_opt=paths=source_relative \
    	--go-grpc_out=./pb --go-grpc_opt=paths=source_relative \
    	pb/*.proto

پیاده‌سازی کلاینت

کلاینت رو به عنوان دو http.HandlerFunc پیاده سازی می‌کنم:

package signal

import (
	"context"
	"encoding/json"
	"grpc-mock-example/pb"
	"log"
	"net/http"
	"time"

	"google.golang.org/grpc/status"

	"google.golang.org/grpc"
)

var (
	conn   *grpc.ClientConn
	client pb.SignalClient
)

func New(serverAddr string) pb.SignalClient {
	var opts []grpc.DialOption
	opts = append(opts, grpc.WithInsecure())
	opts = append(opts, grpc.WithBlock())
	conn, err := grpc.Dial(serverAddr, opts...)
	if err != nil {
		log.Fatalf("fail to dial: %v", err)
	}
	client = pb.NewSignalClient(conn)

	return client
}

func Ping(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	message := r.URL.Path[len("/ping/"):]
	ping, err := client.Ping(ctx, &pb.PingRequest{Message: message})
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		e, ok := status.FromError(err)

		if ok {
			json.NewEncoder(w).Encode(e.Message())
			return
		}

		em := err.Error()
		log.Printf("%s", em)
		json.NewEncoder(w).Encode(em)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=UTF-8")
	json.NewEncoder(w).Encode(ping)
}

func Echo(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	message := r.URL.Path[len("/echo/"):]
	echo, err := client.Echo(ctx, &pb.EchoRequest{Message: message})
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		e, ok := status.FromError(err)

		if ok {
			json.NewEncoder(w).Encode(e.Message())
			return
		}

		json.NewEncoder(w).Encode(err.Error())
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=UTF-8")
	json.NewEncoder(w).Encode(echo)
}

func Close() {
	conn.Close()
}

برای تولید mock ها از پکیج gomock استفاده می‌کنیم:

mockgen -source=pb/signal_grpc.pb.go -package=mocks SignalClient > ./mocks/signal_client_mock.go;

و بالاخره mock کردن gRPC در تست‌ها

package signal

import (
	"bytes"
	"encoding/json"
	"fmt"
	"grpc-mock-example/internal"
	"grpc-mock-example/mocks"
	"grpc-mock-example/pb"
	"net/http"
	"net/url"
	"reflect"
	"testing"

	"github.com/golang/mock/gomock"
	fuzz "github.com/google/gofuzz"
)

type pingRequest struct {
	message string
}

func (r *pingRequest) Matches(msg interface{}) bool {
	m, ok := msg.(*pb.PingRequest)
	if !ok {
		return false
	}

	return m.Message == r.message
}

func (r *pingRequest) String() string {
	return fmt.Sprintf("is %s %v", r.message, r.message)
}

type customResponseWriter struct {
	header     http.Header
	body       *bytes.Buffer
	statusCode int
}

func (c customResponseWriter) Header() http.Header {
	return c.header
}

func (c customResponseWriter) Write(body []byte) (int, error) {
	return c.body.Write(body)
}

func (c customResponseWriter) WriteHeader(statusCode int) {
	c.statusCode = statusCode
}

func TestPing(t *testing.T) {
	var msg string
	f := fuzz.New()
	ctrl := gomock.NewController(t)
	signalClient := mocks.NewMockSignalClient(ctrl)

	f.Fuzz(&msg)

	signalClient.
		EXPECT().
		Ping(gomock.Any(), &pingRequest{message: "Ping"}, gomock.Any()).
		Return(nil, internal.ErrInvalidPing).
		AnyTimes()

	signalClient.
		EXPECT().
		Ping(gomock.Any(), &pingRequest{message: "PING"}, gomock.Any()).
		Return(&pb.PingResponse{Message: "PONG"}, nil).
		AnyTimes()

	client = signalClient

	type args struct {
		w customResponseWriter
		r *http.Request
	}
	tests := []struct {
		name    string
		args    args
		want    string
		wantErr bool
	}{
		{
			name: "Ping",
			args: args{
				w: customResponseWriter{
					header: http.Header{},
					body:   &bytes.Buffer{},
				},
				r: &http.Request{
					Method: "GET",
					URL: &url.URL{
						Path: "/ping/Ping",
					},
				},
			},
			want: "\"invalid ping message\"\n",
		},
		{
			name: "PING",
			args: args{
				w: customResponseWriter{
					header: http.Header{},
					body:   &bytes.Buffer{},
				},
				r: &http.Request{
					Method: "GET",
					URL: &url.URL{
						Path: "/ping/PING",
					},
				},
			},
			want: "{\"message\":\"PONG\"}\n",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			Ping(tt.args.w, tt.args.r)

			got := tt.args.w.body.String()
			if !reflect.DeepEqual(tt.args.w.body.String(), tt.want) {
				t.Errorf("TestPing() = %v, want %v", got, tt.want)
			}
		})
	}
}

کد کامل این پست رو می‌تونید اینجا ببینید.

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *