Table driven of Golang high quality unit testing: from introduction to true fragrance

Posted by jesus_hairdo on Sat, 22 Jan 2022 02:01:59 +0100

Author: Lei Chang, senior engineer of Tencent cloud monitoring

As a program

How to be free from external force (leadership?) Under the stress of

Write a single test voluntarily?

That must be the belief that benefits > costs

Single test saves time for fixing bug s in the future > time spent on writing single test

In order to ensure that the above inequality holds, it is strongly recommended that you consider the table driven method! Table driven method!! Table driven method!!! (only three times)

Using table driven, you can quickly and painlessly write high-quality single tests, so as to reduce the psychological threshold of "I want to write single tests", and finally achieve the magical effect of writing easily and cool all the time! (personally tested and trusted)

What is table driven?

The concept of table driven approach is not unique to Golang or the testing field; It is a programming mode and belongs to a kind of data-driven programming.

The core of table driven method is to separate the changeable data from the stable data processing process and put it into the table; Instead of directly mixing in multiple branches of if else / switch case.

Simple example: write a func, enter the index day, and output the day of the week. If there are only two or three days a week, it's ok to use if else / switch case directly.

But if there are seven days a week, the code seems a little outrageous!

// GetWeekDay returns the week day name of a week day index.func GetWeekDay(index int) string {   if index == 0 {      return "Sunday"   }   if index == 1 {      return "Monday"   }   if index == 2 {      return "Tuesday"   }   if index == 3 {      return "Wednesday"   }   if index == 4 {      return "Thursday"   }   if index == 5 {      return "Friday"   }   if index == 6 {      return "Saturday"   }   return "Unknown"}

Obviously, the logic of the control process is not complex, but a simple and rough mapping (0 - > Sunday, 1 - > Monday...); The only difference between branches is the variable data, not the process itself.

If you split the data and put it into multiple rows of the table (the table is generally implemented with an array; one item of the array is a row of the table), and eliminate a large number of repeated processes, the code will be much simpler:

// GetWeekDay returns the week day name of a week day index.func GetWeekDay(index int) string {   if index < 0 || index > 6 {      return "Unknown"   }   weekDays := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}   return weekDays[index]}

It is also true to move this method to the field of single test.

A test case generally includes the following parts:

  • Stable process
    • Define test cases
    • Define input data and expected output data
    • Run the test case and get the actual output
    • Compare the expected output with the actual output
  • Volatile data
    • input data
    • Expected output data

The table driven single measurement method is to precipitate the process into a reusable template and hand it over to the machine for automatic generation; Humans only need to prepare the data part, fill their own multiple different data into the table row by row, and give it to the process template to construct sub test cases, look up the table, run the data, compare the results, and write a single test.

Why does a single test need table driven?

After understanding the concept of table driven, you can probably foresee the following benefits of table driven single test:

  • Write fast: humans only need to prepare data without constructing processes.
  • Strong readability: the data is constructed into a table, the structure is clearer, and the data changes line by line are clearly compared.
  • Sub test cases are independent of each other: each data is a row in the table and is constructed into an independent sub test case by the process template.
  • Strong debuggability: because each line of data is constructed into sub test cases, it can be run and debugged separately.
  • Strong scalability / Maintainability: changing a sub test case is to change a row of data in the table.

Next, we can see the benefits of table driven by comparing different single test styles of TestGetWeekDay.

Example 1: tile multiple test case s of low-quality single test

From 0 - > Sunday, 1 - > Monday... To 6 - > Saturday, write a separate test case for each data:

// test case for index=0func TestGetWeekDay_Sunday(t *testing.T) {   index := 0   want := "Sunday"   if got := GetWeekDay(index); got != want {      t.Errorf("GetWeekDay() = %v, want %v", got, want)   }}
// test case for index=1func TestGetWeekDay_Monday(t *testing.T) {   index := 1   want := "Monday"   if got := GetWeekDay(index); got != want {      t.Errorf("GetWeekDay() = %v, want %v", got, want)   }}
...

At a glance, there are too many duplicate codes and poor maintainability; In addition, these test cases for the same method are divided into multiple parallel test cases. If they are tiled in the same file as the test cases of other methods, they lack structured organization and poor readability.

Example 2: tile multiple subtest s for low-quality single test

In fact, starting from Go 1.7, there can be multiple subtests in a test case. These subtests are created using the t.Run method:

func TestGetWeekDay(t *testing.T) {   // a subtest named "index=0"   t.Run("index=0", func(t *testing.T) {      index := 0      want := "Sunday"      if got := GetWeekDay(index); got != want {         t.Errorf("GetWeekDay() = %v, want %v", got, want)      }   })
   // a subtest named "index=1"   t.Run("index=1", func(t *testing.T) {      index := 1      want := "Monday"      if got := GetWeekDay(index); got != want {         t.Errorf("GetWeekDay() = %v, want %v", got, want)      }   })
   ...

Example 2 is simpler than the first example, and the sub tests are still independent of each other, which can be run and debugged separately. As shown in the figure, in the IDE (the local version used in this article is GoLand 2021.3), you can run/debug each subtest separately:

[Click to view large picture]

The log of go test also supports structured output of subtest running results:

[Click to view large picture]

Example 2 Summary: when there are many subtest s, you still have to write a lot of duplicate process codes, which is cumbersome and difficult to maintain.

Example 3: high quality single test table driven

To generate a table driven single test template, it is very simple. Just right-click the method name > generate > test for function in GoLand:

[Click to view large picture]

GoLand will automatically generate the following template, and we only need to fill in the red box, that is, the most core data table used to drive the single test:

[Click to view large picture]

It is not difficult to see that, on the basis of example 2, this template continues to reduce duplicate code and no longer tile subtests. Instead, it puts the public process into a loop, drives the loop traversal with multiple rows of data in the data table, and constructs a subtest for each row of data.

Therefore, humans only need to fill in the data in the form of a table in the red box in the figure above, and the test case is written:

[Click to view large picture]

Each line of data is constructed into an independent subtest by t.Run, which can be run/debug separately:

[Click to view large picture]

The structured log can also be printed by go test:

[Click to view large picture]

How to write a table driven single test?

In fact, in the above example 3, we can see the basic writing of table driven single test:

[Click to view large picture]

Each row of data in the data table generally includes the name, input and expected output of the subtest.

The filled code example is as follows:

func TestGetWeekDay(t *testing.T) {   type args struct {      index int   }   tests := []struct {      name string      args args      want string   }{      {name: "index=0", args: args{index: 0}, want: "Sunday"},      {name: "index=1", args: args{index: 1}, want: "Monday"},      {name: "index=2", args: args{index: 2}, want: "Tuesday"},      {name: "index=3", args: args{index: 3}, want: "Wednesday"},      {name: "index=4", args: args{index: 4}, want: "Thursday"},      {name: "index=5", args: args{index: 5}, want: "Friday"},      {name: "index=6", args: args{index: 6}, want: "Saturday"},      {name: "index=-1", args: args{index: -1}, want: "Unknown"},      {name: "index=8", args: args{index: 8}, want: "Unknown"},   }   for _, tt := range tests {      t.Run(tt.name, func(t *testing.T) {         if got := GetWeekDay(tt.args.index); got != tt.want {            t.Errorf("GetWeekDay() = %v, want %v", got, tt.want)         }      })   }}

Note: give each line of subtest a meaningful name as its identification. Otherwise, the readability of self-test is poor, and GoLand's separate test does not recognize it!

[Click to view large picture]

Advanced play

table-driven + parallel

By default, all subtests of a test case are executed serially. If parallelism is required, t.Parallel must be explicitly specified in t.Run to make this subtest execute in parallel with other subtets with t.Parallel:

for _, tt := range tests {   tt := tt // New variable TT t.run (TT. Name, func (t * testing. T) {t.parallel() / / parallel test t.logf ("Name:% s; args:% d; Want:% s", tt.name, tt.args.index, tt.want) if get: = getweekday (tt.args. Index); get! = tt.want {t.errorf ("getweekday() =% v, want% v", get, tt.want)}}

Note here that an additional sentence TT = TT is added in the cycle. If it is not added, it will fall into a classic pit of Go language loop variables. There are several reasons:

  1. The variable tt of the for loop iterator is shared by each loop. That is, tt is always the same tt; Each loop only changes the value of tt, while the address and variable name remain unchanged.
  2. Each subtest added with t.Parallel will not be executed immediately after it is passed to its own go routine, but will pause and wait for all subtests parallel to it to be initialized.
  3. Then, when the Go scheduler really starts to execute all subtest s, the external for loop has run out; The value of its iterator variable tt has got the last value of the loop.
  4. Therefore, all subtest go routes get the same tt value, that is, the last value of the loop.

The worst thing is that if you don't print some log s, you can't find this problem, because although you check the last set of input and output in each cycle, if this set of values can pass, all tests can pass without exposing the problem:

[Click to view large picture]

In order to solve this problem, the most commonly used method is tt: = tt in the above code, that is, a new variable is created inside the code block of each cycle to save the current tt value. (of course, the new variable can be called tt or other names; if it is called tt, the scope of the new tt is within the current loop, covering the tt shared by all the loops outside.)

table-driven + assert

The Go standard library itself does not provide assertions, but we can introduce assertions with the help of the assert sub Library of the test library to make the code more concise and readable.

For example, in the above TestGetWeekDay, we used the following statement to make judgment:

if got != tt.want {   t.Errorf("GetWeekDay() = %v, want %v", got, tt.want)}

If assert, the judgment code can be simplified to:

assert.Equal(t, tt.want, got, "should be equal")

The complete code is as follows:

func TestGetWeekDay(t *testing.T) {   type args struct {      index int   }   tests := []struct {      name string      args args      want string   }{      {name: "index=0", args: args{index: 0}, want: "Sunday"},      {name: "index=1", args: args{index: 1}, want: "Monday"},      {name: "index=2", args: args{index: 2}, want: "Tuesday"},      {name: "index=3", args: args{index: 3}, want: "Wednesday"},      {name: "index=4", args: args{index: 4}, want: "Thursday"},      {name: "index=5", args: args{index: 5}, want: "Friday"},      {name: "index=6", args: args{index: 6}, want: "Saturday"},      {name: "index=-1", args: args{index: -1}, want: "Unknown"},      {name: "index=8", args: args{index: 8}, want: "Unknown"},   }   for _, tt := range tests {      t.Run(tt.name, func(t *testing.T) {         got := GetWeekDay(tt.args.index)         assert.Equal(t, tt.want, got, "should be equal")      })   }}

The output of the error log is also more structured. For example, we change the first row of table data to the following, which makes this subtest error:

{name: "index=0", args: args{index: 0}, want: "NotSunday"},

You will get the following error log:

[Click to view large picture]

In addition, assert logic can also be used as a func type field and directly placed in each row of data in table:

func TestGetWeekDay(t *testing.T) {   type args struct {      index int   }   tests := []struct {      name   string      args   args      assert func(got string)   }{      {         name: "index=0",         args: args{index: 0},         assert: func(got string) {            assert.Equal(t, "Sunday", got, "should be equal")         }},      {         name: "index=1",         args: args{index: 1},         assert: func(got string) {            assert.Equal(t, "Monday", got, "should be equal")         }},   }   for _, tt := range tests {      t.Run(tt.name, func(t *testing.T) {         got := GetWeekDay(tt.args.index)         if tt.assert != nil {            tt.assert(got)         }      })   }}

table-driven + mock

When the tested method has third-party dependencies, such as database and other service interfaces, when writing a single test, you can abstract the external dependencies into interfaces, and then mock to simulate various behaviors of external dependencies.

With the help of the official gomock framework of Go, we can use its mockgen tool to generate the Mock class source file corresponding to the interface, and then use the gomock package to combine these Mock classes for pile driving test in the test case.

For example, we can transform the previous GetWeekDay func as a method of the WeekDayClient structure and rely on an external interface WeekDayService to get the results:

package main
type WeekDayService interface {   GetWeekDay(int) string}
type WeekDayClient struct {   svc WeekDayService}
func (c *WeekDayClient) GetWeekDay(index int) string {   return c.svc.GetWeekDay(index)}

Use the mockgen tool to generate mocks for interfaces:

mockgen -source=weekday_srv.go -destination=weekday_srv_mock.go -package=main

Then, change the single test template automatically generated by GoLand and add the logic of mock and assert:

package main
import (   "github.com/golang/mock/gomock"   "github.com/stretchr/testify/assert"   "testing")
func TestWeekDayClient_GetWeekDay(t *testing.T) {   // dependency fields   type fields struct {      svc *MockWeekDayService   }   // input args   type args struct {      index int   }   // tests   tests := []struct {      name    string      fields  fields      args    args      prepare func(f *fields)      assert  func(got string)   }{      {         name: "index=0",         args: args{index: 0},         prepare: func(f *fields) {            f.svc.EXPECT().GetWeekDay(gomock.Any()).Return("Sunday")         },         assert: func(got string) {            assert.Equal(t, "Sunday", got, "should be equal")         }},      {         name: "index=1",         args: args{index: 1},         prepare: func(f *fields) {            f.svc.EXPECT().GetWeekDay(gomock.Any()).Return("Monday")         },         assert: func(got string) {            assert.Equal(t, "Monday", got, "should be equal")         }},   }   for _, tt := range tests {      t.Run(tt.name, func(t *testing.T) {         // arrange         ctrl := gomock.NewController(t)         defer ctrl.Finish()         f := fields{            svc: NewMockWeekDayService(ctrl),         }         if tt.prepare != nil {            tt.prepare(&f)         }
         // act         c := &WeekDayClient{            svc: f.svc,         }         got := c.GetWeekDay(tt.args.index)
         // assert         if tt.assert != nil {            tt.assert(got)         }      })   }}

Logic description of mock and assert:

  1. fields is the field in the WeekDayClient struct. In order to mock, the original type of WeekDayService of the external dependency svc is replaced with the MockWeekDayService generated by mockgen during the single test.
  2. In each subtest data, add a prepare field of func type. Fields can be used as input parameters to prepare fields Various behaviors of SVC are mock.
  3. In the preparation stage of each t.Run, create a mock controller, use the controller to create a mock object, call prepare to inject behavior into the mock object, and finally take the mock object as the implementation of the interface for WeekDayClient to use as an external dependency.

Custom template

If you feel that the table driven test template automatically generated by GoLand generate > test for XX is not easy to use, you can consider using GoLand Live Template to customize the template.

For example, if many methods in the code are similar to GetWeekDay above, the common parts can be extracted and made into a table driven + parallel + mock + assert code template:

func Test$NAME$(t *testing.T) {   // dependency fields   type fields struct {   }   // input args   type args struct {   }   // tests   tests := []struct {      name    string      fields  fields      args    args      prepare func(f *fields)      assert  func(got string)   }{      // TODO: Add test cases.   }   for _, tt := range tests {      tt := tt      t.Run(tt.name, func(t *testing.T) {         // run in parallel         t.Parallel()
         // arrange         ctrl := gomock.NewController(t)         defer ctrl.Finish()         f := fields{}         if tt.prepare != nil {            tt.prepare(&f)         }
         // act         // TODO: add test logic
         // assert         if tt.assert != nil {            tt.assert($GOT$)         }      })   }}

Then open GoLand > preference > editor > live template to create a new custom template:

[Click to view large picture]

Paste the code in the Template text, and check Go in the Define scope of application section, and then save it.

Then, when writing code later, we can call the code template by typing the name of the Live Template:

[Click to view large picture]

Then, change the $$variable part and TODO business logic to use it.

epilogue

To tell you the truth, the author's painting style of writing a single test before is quite close to the low-quality single test in this article. It is not only laborious in writing and debugging, but also high in later maintenance cost. In this way, it is unclear whether writing a single test improves or reduces my productivity.

However, the turnaround of fate found the table driven single measurement method. After that, I began to improve, and also studied other relevant tools and practices, and gradually improved the efficiency and quality of single test writing.

contact us

Code scanning and cloud monitoring assistant

Join more technical exchange groups

Follow us and learn about the latest developments of Tencent cloud monitoring

Other articles recommended: