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.


Prerequisites


Install gRPC


go get -u google.golang.org/grpc

Install protobuf


# MacOS
brew install protobuf

Install protoc-gen-go


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

Install bufconn


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

Server


Structure


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

Files


Makefile

.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

This file is generated with make compile command so not showing the content.


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())
}
}
}
})
}
}

Client


Structure


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

Files


Makefile

.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

This file is generated with make compile command so not showing the content.


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)
}
})
}
}

Tests


Server


$ 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

Client


$ 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