19/07/2020 - GO
In this example we are going to create a simple gRPC client and server application. Rather than using third party mocking packages, we are going to use a Golang native bufconn package for testing.
bufconn helps you starting up a server but not on a real socket/port which is great. Your test client makes calls to it as talking to a real server through a real port. The best thing about this package is that, your tests are actually interacting with a real network behaviour. All these take place over an in-memory connection as opposed to a classic OS level resources. You still get the expected behaviour of a normal network connection over a port.
go get -u google.golang.org/grpc
# MacOS
brew install protobuf
go get -u github.com/golang/protobuf/protoc-gen-go
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
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) {}
}
This file is generated with make compile
command so not showing the content.
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))
}
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
}
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
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) {}
}
This file is generated with make compile
command so not showing the content.
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)
}
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
}
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