Testing a gRPC Service in Go With Table Driven Tests
Join the DZone community and get the full member experience.
Join For FreeEveryone knows that 100% of code coverage does not exist and adds no added value. In fact, every day, what we really want is to test our business logic, the intelligence of our application.
In this article, we are going to start from a small Go-based CLI application that does not yet have unit tests, and then, we will do some gRPC unit tests.
Let's start by taking a look at our app.
Designing a CLI in Go is child's play, and if you have read one of my previous articles, published in this website, you have already realized that this is really the case; we can create a CLI application in a few minutes :-).
So, I created a CLI application that handles a gRPC server and a gRPC client. Source code: https://github.com/scraly/hello-world.
Regarding dependency management, I am directly involved in the use of Go modules. Thanks to this, you no longer have to deal with the GOPATH. You can start by cloning the repository hosted on GitHub (for Go < 1.13 version you have to clone outside your GOPATH if you have one):
$ git clone https://github.com/scraly/hello-world.git
The first thing to know about our application, is that to make life easier, for a build, dependencies management, tests, files and mocks generation, code format, static tests execution ... we use a magefile. It is a “Make file” coded in Go, and it’s very practical.
Thanks to this magefile, we do not have to execute all the extended commands that will allow us to generate the mocks, test the static files, run the unit tests, and build our application to generate the binary.
After cloning the Git repository, I invite you to run the following command that will allow you to download and install the necessary tools:
$ go run mage.go -d tools
And that's all! No need to go get tools hosted in 50 repositories on GitHub or curl and install them. With magefile, you’ll get all the useful binaries that will allow you to build, run linters, execute your tests units, check your licenses, generate your mocks, etc.
The only thing to do now is to make a complete build of our application:
x
$ go run mage.go
# Build Info ---------------------------------------------------------------
Go version : go1.12
Git revision : f7ee9e3
Git branch : master
Tag : 0.0.1
# Core packages ------------------------------------------------------------
## Vendoring dependencies
## Generate code
### Protobuf
#### Lint protobuf
## Format everything
## Lint go code
## Running unit tests
∅ cli/hello-world
∅ cli/hello-world/cmd
∅ cli/hello-world/config
∅ cli/hello-world/dispatchers/grpc
∅ internal/services/pkg/v1
✓ internal/services/pkg/v1/greeter (1.125s)
∅ internal/version
∅ pkg/protocol/helloworld/v1
DONE 0 tests in 3.425s
# Artifacts ---------------------------------------------------------------- >
Building hello-world [github.com/scraly/hello-world/cli/hello-world]
Great! All the steps went well, and we get our little binary ready to use!
$ ll bin total 39232 -rwxr-xr-x 1 uidn3817 CW01\Domain
Users 19M 7 aoû 18:55
hello-world
Let's take a look at our app. What does it really do? Let's start by launching our gRPC server:
$ bin/hello-world server grpc
Then, in another tab of your terminal, launch the gRPC client to call our sayHello method:
$ bin/hello-world client greeter sayHello -s 127.0.0.1:5555 <<<
'{"name": "me"}' { "message": "hello me" }%
Our application works properly; it does what we wanted it to do: answer hello + a string field when we say “sayHello.”
But, am I forced to build and re-generate everything to run the unit tests?
No, not at all, with the following command, we can easily execute our Unit tests:
xxxxxxxxxx
$ go run mage.go
go:test
## Running unit tests
∅ cli/hello-world (1ms)
∅ cli/hello-world/cmd
∅ cli/hello-world/config
∅ cli/hello-world/dispatchers/grpc
∅ internal/services/pkg/v1
✓ internal/services/pkg/v1/greeter (1.092s)
∅ internal/version
∅ pkg/protocol/helloworld/v1
DONE 0 tests in 3.239s
As you can see, 0 unit test were run successfully. We will deal with them in the next section, but before that, it is necessary to know what is behind this target go:test
(in our magefile.go):
// Test run go test func (Go) Test() error { color.Cyan("## Running unit tests") sh.Run("mkdir", "-p", "test-results/junit") return sh.RunV("gotestsum", "--junitfile", "test-results/junit/unit-tests.xml", "--", "-short", "-race", "-cover", "-coverprofile", "test-results/cover.out", "./...") }
The code above shows that we use the gotestsum tool to run our unit tests and that test results are exported in JUnit format in a file, named test-results/junit/unit-tests.xml.
So, you can run the tests through the magefile or by using gotestsum (if you first installed the utility on your machine):
$ gotestsum --junitfile test-results/junit/unit-tests.xml -- -short -race
-cover -coverprofile test-results/cover.out ./…
Gotestsum
Gotestsum, what is this new tool? Go test is not enough?
Let's answer this question. One of the benefits of Go is its ecosystem of tools that allow us to make our lives easier. To test your code just do:
xxxxxxxxxx
$ go test ./…
? github.com/scraly/hello-world/cli/hello-world
[no test files]
? github.com/scraly/hello-world/cli/hello-world/cmd
[no test files]
?github.com/scraly/hello-world/cli/hello-world/config
[no test files]
? github.com/scraly/hello-world/cli/hello-world/dispatchers/grpc
[no test files]
? github.com/scraly/hello-world/internal/services/pkg/v1
[no test files]
okgithub.com/scraly/hello-world/internal/services/pkg/v1/greeter0.037s
[no tests to run]
? github.com/scraly/hello-world/internal/version
[no test files]
? github.com/scraly/hello-world/pkg/protocol/helloworld/v1
[no test files]
The test tool is integrated with Go. This is convenient, but not very user-friendly and integrable in all CI/CD solutions, for example.
That's why gotestsum, a small Go utility, designed to run tests with go test
improves the display of results, making a more human-readable, practical report with possible output directly in JUnit format.
How to Test gRPC
Our app is a gRPC client/server, so this means that when we call the sayHello
method, a client/server communication is triggered, but no question to test the gRPC calls in our unit tests. We will only test the intelligence of our application.
Our gRPC server is based on a protobuf file named pkg/protocol/helloworld/v1/greeter.proto:
xxxxxxxxxx
syntax = "proto3";
package helloworld.v1;
option csharp_namespace = "Helloworld.V1";
option go_package = "helloworldv1";
option java_multiple_files = true;
option java_outer_classname = "GreeterProto";
option java_package = "com.scraly.helloworld.v1";
option objc_class_prefix = "HXX";
option php_namespace = "Helloworld\\V1";
// The greeting service definition.
service Greeter {
// Sends a greeting.
rpc SayHello (HelloRequest)
returns (HelloReply) {}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings.
message HelloReply {
string message = 1;
}
From this proto, we generated the .go files thanks to the gen protobuf
command:
$ go run mage.go gen:protobuf
### Protobuf
#### Lint protobuf
The standard Go library provides us a package that allows us to test our Go program. A test file in Go must be placed in the same folder as the file we want to test and finished with the _test.go extension. This formalism must be followed so that the Go executable recognizes our test files.
The first step is to create a service_test.go file that is placed next to service.go.
We are going to name the package of this test file greeter_test and we will start by importing the testing package and creating the function we are going to test, which ouputs:
xxxxxxxxxx
package greeter_test
import ( "testing" )
func TestSayHello(t *testing.T) {
}
Warning: Each test function must be written as funcTest***(t *testing.T)
, where "***" represents the name of the function we want to test.
Let’s Write Tests With Table-Driven Tests
In our application, we will not test everything, but we will start by testing our business logic, the intelligence of our application. In our app, what interests us is what is inside service.go:
hello-world
internal
services
pkg
v1
greeter
service.go
xxxxxxxxxx
package greeter
import ( "context" ... )
type service struct { }
// New services instance
func New() apiv1.Greeter {
return &service{}
}
// --------------------------------------------------------------------------
func (s *service) SayHello(ctx context.Context, req
*helloworldv1.HelloRequest) (*helloworldv1.HelloReply, error) {
res := &helloworldv1.HelloReply{}
// Check request
if req == nil {
log.Bg().Error("request must not be nil")
return res, xerrors.Errorf("request must not be nil")
}
if req.Name == "" {
log.Bg().Error("name but not be empty in the request")
return res, xerrors.Errorf("name but not be empty in the request")
}
res.Message = "hello " + req.Name
return res, nil
}
As you can see, in order to cover the maximum amount of our code, we will have to test at least three cases:
- The request is nil.
- The request is empty (the name field is empty).
- The name field is filled in the request.
Table Driven Tests
Instead of creating a test case method, and copying-and-pasting it, we're going to follow Table Driven Tests, which will make life a lot easier.
Writing good tests is not easy, but in many situations, you can cover a lot of things with table driven tests: each table entry is a complete test case with the inputs and the expected results. Sometimes additional information is provided. The test output is easily readable. If you usually find yourself using copy and paste when writing a test, ask yourself if refactoring in a table-driven test may be a better option.
Given a test case table, the actual test simply scans all entries in the table and performs the necessary tests for each entry. The test code is written once and is depreciated on all table entries. It is therefore easier to write a thorough test with good error messages.
Example:
I start by the definition of my test cases:
xxxxxxxxxx
testCases := []struct {
name string
req *helloworldv1.HelloRequest
message string expectedErr bool
}
{
{
name: "req ok",
req: &helloworldv1.HelloRequest {
Name: "me"
},
message: "hello me",
expectedErr: false,
},
{
name: "req with empty name",
req: &helloworldv1.HelloRequest{},
expectedErr: true,
},
{
name: "nil request",
req: nil,
expectedErr: true,
},
}
Good practice is to provide a name for our test case, so if an error occurs during its execution the name of the test case will be written and we will see easily where is our error.
Then, I loop through all the test cases. I call my service and depending on whether or not I wait for an error, I test its existence, otherwise I test if the result is that expected:
xxxxxxxxxx
for _, tc := range testCases {
testCase := tc
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
g := NewGomegaWithT(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.Background()
// call
greeterSvc := greeter.New()
response, err := greeterSvc.SayHello(ctx, testCase.req)
// assert results expectations
if testCase.expectedErr {
g.Expect(response).ToNot(BeNil(), "Result should be nil")
g.Expect(err).ToNot(BeNil(), "Result should be nil")
} else {
g.Expect(response.Message).To(Equal(testCase.message))
}
})
}
Let's Practice
Concretely, to add our test case we will:
- Create a service_test.go file next to its service.go counterpart that we want to test.
- Create a
TestSayHello
function(t * testing.T)
. - Define our test cases.
- Loop in all test cases, call our service, and test the error if expected.
Here's what we expect:
xxxxxxxxxx
package greeter_test
import ( "context" "testing" . "github.com/onsi/gomega" "github.com/scraly/hello-world/internal/services/pkg/v1/greeter" helloworldv1 "github.com/scraly/hello-world/pkg/protocol/helloworld/v1" )
func TestSayHello(t *testing.T) {
testCases := []struct {
name string
req *helloworldv1.HelloRequest
message string
expectedErr bool
}
{
{
name: "req ok",
req: &helloworldv1.HelloRequest{
Name: "me"
},
message: "hello me",
expectedErr: false,
},
{
name: "req with empty name",
req: &helloworldv1.HelloRequest{},
expectedErr: true,
},
{
name: "nil request",
req: nil,
expectedErr: true,
},
}
for _, tc := range
testCases {
testCase := tc
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
g := NewGomegaWithT(t)
ctx := context.Background()
// call
greeterSvc := greeter.New()
response, err := greeterSvc.SayHello(ctx, testCase.req)
t.Log("Got : ", response)
// assert results expectations
if testCase.expectedErr {
g.Expect(response).ToNot(BeNil(), "Result should be nil")
g.Expect(err).ToNot(BeNil(), "Result should be nil")
} else {
g.Expect(response.Message).To(Equal(testCase.message))
}
}
)
}
}
Aurélie, your code is nice! But why create a new variable, testCase
, which takes a value, tc
, when you could have used tc
directly?
The answer is in this article: https://gist.github.com/posener/92a55c4cd441fc5e5e85f27bca00872.
In short, without this line, there is a bug with the t.Parallel()
well known to Gophers — we use a closure that is in a go routine. So, instead of executing three test cases: "req ok," "Req with empty name," and "nil request," there would be three test runs but always with the values of the first test case :-(.
And, what is gomega?
Gomega is a Go library that allows you to make assertions. In our example, we check if what we got is null, not null, or equal to an exact value, but the gomega library is much richer than that.
To run your newly created unit tests, if you use VisualStudio Code, you can directly run them in your IDE; it's very convenient:
Open the service_test.go file.
Then, click in the “run package tests” link:
- Open the service.go file:
The code highlighted in green is the code that is covered by the tests — super, not a red line in sight. We covered everything!
Otherwise, we can run all the unit tests of our project in the command line thanks to our marvelous magefile:
$ go run mage.go
go:test
go:analyzecoverage
## Running unit tests
∅cli/hello-world
∅ cli/hello-world/cmd
∅ cli/hello-world/config
∅cli/hello-world/dispatchers/grpc
∅ internal/services/pkg/v1
✓internal/services/pkg/v1/greeter (1.089)
∅ internal/version
∅ pkg/protocol/helloworld/v1
DONE 4 tests in 2.973s
## Analyze tests coverage 2019/08/12 14:10:56
Analyzing file test-results/cover.out 2019/08/12 14:10:56
Business Logic file 2019/08/12 14:10:56
Minimum coverage threshold percentage 90.000000 % 2019/08/12 14:10:56
Nb Statements: 10
Coverage percentage: 100.000000 %
Awesome, we've got 100% of test coverage on our business logic!
Conclusion
If you're in the habit of copying paste when writing your test cases, I think you'll have to seriously take a look at Table Driven Tests, it's really a good practice to follow when writing unit tests and as As we have seen, writing unit tests that cover our code becomes child's play.
Further Reading
Opinions expressed by DZone contributors are their own.
Comments