{{announcement.body}}
{{announcement.title}}

Testing a gRPC Service in Go With Table Driven Tests

DZone 's Guide to

Testing a gRPC Service in Go With Table Driven Tests

In this article, we discuss how to test a gRPC service in Go with Table-Driven tests in order to enhance our test automation.

· DevOps Zone ·
Free Resource

Everyone 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 GOPATHYou 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.

You may also like: 10 Tips to Improve Automated Performance Testing Within CI Pipelines (Part 2).

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:

Shell




x


1
$ go run mage.go 
2
 
          
3
# Build Info ---------------------------------------------------------------
4
Go version : go1.12
5
Git revision : f7ee9e3
6
Git branch : master
7
Tag : 0.0.1
8
 
          
9
# Core packages ------------------------------------------------------------
10
## Vendoring dependencies
11
## Generate code
12
### Protobuf
13
#### Lint protobuf
14
## Format everything
15
## Lint go code
16
## Running unit tests
17
 
          
18
∅ cli/hello-world
19
∅ cli/hello-world/cmd
20
∅ cli/hello-world/config
21
∅ cli/hello-world/dispatchers/grpc
22
∅ internal/services/pkg/v1
23
✓ internal/services/pkg/v1/greeter (1.125s)
24
∅ internal/version
25
∅ pkg/protocol/helloworld/v1
26
 
          
27
DONE 0 tests in 3.425s
28
 
          
29
# Artifacts ---------------------------------------------------------------- >
30
Building hello-world [github.com/scraly/hello-world/cli/hello-world]
26
 
          



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\DomainUsers 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:

Shell




xxxxxxxxxx
1
12


 
1
$ go run mage.go 
2
 
          
3
go:test 
4
## Running unit tests 
5
∅ cli/hello-world (1ms) 
6
∅ cli/hello-world/cmd 
7
∅ cli/hello-world/config 
8
∅ cli/hello-world/dispatchers/grpc 
9
∅ internal/services/pkg/v1 
10
✓ internal/services/pkg/v1/greeter (1.092s) 
11
∅ internal/version 
12
∅ pkg/protocol/helloworld/v1 
13
 
          
14
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:

Shell




xxxxxxxxxx
1
10


1
$ go test ./… 
2
? github.com/scraly/hello-world/cli/hello-world
3
[no test files] 
4
? github.com/scraly/hello-world/cli/hello-world/cmd
5
[no test files] 
6
?github.com/scraly/hello-world/cli/hello-world/config
7
[no test files] 
8
? github.com/scraly/hello-world/cli/hello-world/dispatchers/grpc
9
[no test files] 
10
? github.com/scraly/hello-world/internal/services/pkg/v1
11
[no test files] 
12
okgithub.com/scraly/hello-world/internal/services/pkg/v1/greeter0.037s 
13
[no tests to run] 
14
? github.com/scraly/hello-world/internal/version
15
[no test files] 
16
? github.com/scraly/hello-world/pkg/protocol/helloworld/v1
17
[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:

ProtoBuf




xxxxxxxxxx
1
24


 
1
syntax = "proto3"; 
2
package helloworld.v1; 
3
 
          
4
option csharp_namespace = "Helloworld.V1"; 
5
option go_package = "helloworldv1"; 
6
option java_multiple_files = true; 
7
option java_outer_classname = "GreeterProto"; 
8
option java_package = "com.scraly.helloworld.v1"; 
9
option objc_class_prefix = "HXX"; 
10
option php_namespace = "Helloworld\\V1"; 
11
 
          
12
// The greeting service definition. 
13
service Greeter { 
14
    // Sends a greeting. 
15
    rpc SayHello (HelloRequest) 
16
    returns (HelloReply) {} 
17
} 
18
 
          
19
// The request message containing the user's name. 
20
message HelloRequest { 
21
    string name = 1; 
22
} 
23
// The response message containing the greetings. 
24
message HelloReply { 
25
    string message = 1; 
26
}



From this proto, we generated the .go files thanks to the gen protobuf command:

$ go run mage.go gen:protobuf
### Protobuf
#### Lint protobuf

Generating .go files

Generating .go files

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:

Go




xxxxxxxxxx
1


1
package greeter_test 
2
import ( "testing" ) 
3
 
          
4
func TestSayHello(t *testing.T) { 
5
 
          
6
}



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

Go




xxxxxxxxxx
1
31


 
1
package greeter 
2
import ( "context" ... ) 
3
 
          
4
type service struct { } 
5
// New services instance 
6
 
          
7
func New() apiv1.Greeter { 
8
  return &service{} 
9
} 
10
 
          
11
// --------------------------------------------------------------------------
12
 
          
13
func (s *service) SayHello(ctx context.Context, req                      
14
    *helloworldv1.HelloRequest) (*helloworldv1.HelloReply, error) { 
15
  
16
    res := &helloworldv1.HelloReply{} 
17
  
18
    // Check request 
19
    if req == nil { 
20
        log.Bg().Error("request must not be nil") 
21
        return res, xerrors.Errorf("request must not be nil") 
22
    } 
23
 
          
24
    if req.Name == "" { 
25
        log.Bg().Error("name but not be empty in the request") 
26
        return res, xerrors.Errorf("name but not be empty in the request") 
27
    } 
28
 
          
29
    res.Message = "hello " + req.Name 
30
    return res, nil 
31
}
32
 
          



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:

Go




xxxxxxxxxx
1
10


 
1
testCases := []struct { 
2
    name string 
3
    req *helloworldv1.HelloRequest 
4
    message string expectedErr bool 
5
}
6
{ 
7
  { 
8
    name: "req ok", 
9
    req: &helloworldv1.HelloRequest {
10
      Name: "me"
11
    },
12
    message: "hello me", 
13
    expectedErr: false, 
14
  }, 
15
  { 
16
    name: "req with empty name", 
17
    req: &helloworldv1.HelloRequest{}, 
18
    expectedErr: true, 
19
  }, 
20
  { 
21
    name: "nil request", 
22
    req: nil, 
23
    expectedErr: true, 
24
  }, 
25
}
26
 
          
27
 
          
10
      Name: "me"



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:

Go




xxxxxxxxxx
1
24


1
for _, tc := range testCases { 
2
  testCase := tc 
3
  t.Run(testCase.name, func(t *testing.T) { 
4
    t.Parallel() 
5
    g := NewGomegaWithT(t) 
6
    ctrl := gomock.NewController(t) 
7
    defer ctrl.Finish() 
8
    ctx := context.Background() 
9
    
10
    // call 
11
    greeterSvc := greeter.New() 
12
    response, err := greeterSvc.SayHello(ctx, testCase.req) 
13
      
14
    // assert results expectations 
15
    if testCase.expectedErr { 
16
      g.Expect(response).ToNot(BeNil(), "Result should be nil")
17
      g.Expect(err).ToNot(BeNil(), "Result should be nil")
18
    } else { 
19
      g.Expect(response.Message).To(Equal(testCase.message))
20
    } 
21
  })
22
}
23
 
          
24
 
          


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:

Go




xxxxxxxxxx
1
10


1
package greeter_test 
2
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" ) 
3
 
          
4
func TestSayHello(t *testing.T) { 
5
  testCases := []struct { 
6
    name string 
7
    req *helloworldv1.HelloRequest 
8
    message string 
9
    expectedErr bool
10
  }
11
  
12
  { 
13
    { 
14
      name: "req ok", 
15
      req: &helloworldv1.HelloRequest{
16
        Name: "me"
17
      }, 
18
      message: "hello me", 
19
      expectedErr: false, 
20
    }, 
21
    { 
22
      name: "req with empty name", 
23
      req: &helloworldv1.HelloRequest{}, 
24
      expectedErr: true, 
25
    }, 
26
    { 
27
      name: "nil request", 
28
      req: nil, 
29
      expectedErr: true, 
30
    }, 
31
  } 
32
  
33
  for _, tc := range 
34
    testCases { 
35
        testCase := tc 
36
        t.Run(testCase.name, func(t *testing.T) { 
37
          t.Parallel() 
38
          g := NewGomegaWithT(t) 
39
          ctx := context.Background() 
40
          
41
          // call 
42
          greeterSvc := greeter.New() 
43
          response, err := greeterSvc.SayHello(ctx, testCase.req) 
44
          t.Log("Got : ", response) 
45
          
46
          // assert results expectations 
47
          if testCase.expectedErr { 
48
            g.Expect(response).ToNot(BeNil(), "Result should be nil")
49
            g.Expect(err).ToNot(BeNil(), "Result should be nil") 
50
          } else { 
51
            g.Expect(response.Message).To(Equal(testCase.message)) 
52
          } 
53
        }
54
     ) 
55
  } 
56
}
38
          g := NewGomegaWithT(t) 


Aurélie, your code is nice! But why create a new variable, testCase, which takes a value, tcwhen 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:

Running package tests

Running package tests


  • 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:

    Shell




    x
    20


     
    1
    $ go run mage.go 
    2
    go:test 
    3
    go:analyzecoverage 
    4
    ## Running unit tests 
    5
    ∅cli/hello-world 
    6
    ∅ cli/hello-world/cmd 
    7
    ∅ cli/hello-world/config 
    8
    ∅cli/hello-world/dispatchers/grpc 
    9
    ∅ internal/services/pkg/v1 
    10
    ✓internal/services/pkg/v1/greeter (1.089)
    11
    ∅ internal/version 
    12
    ∅ pkg/protocol/helloworld/v1 
    13
     
              
    14
    DONE 4 tests in 2.973s 
    15
    ## Analyze tests coverage 2019/08/12 14:10:56 
    16
    Analyzing file test-results/cover.out 2019/08/12 14:10:56 
    17
    Business Logic file 2019/08/12 14:10:56 
    18
    Minimum coverage threshold percentage 90.000000 % 2019/08/12 14:10:56 
    19
    Nb Statements: 10 
    20
    Coverage percentage: 100.000000 %
    12
     pkg/protocol/helloworld/v1 



    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

    Topics:
    ci/cd, devops, golang, gopher, grpc, tests, tutorial

    Opinions expressed by DZone contributors are their own.

    {{ parent.title || parent.header.title}}

    {{ parent.tldr }}

    {{ parent.urlSource.name }}