36 Code Testing How to Write Go Language Unit Tests and Performance Test Cases

36 Code Testing How to Write Go Language Unit Tests and Performance Test Cases #

Hello, I’m Kong Lingfei.

Starting today, we will enter the module of service testing, which mainly introduces how to test our Go projects.

In Go project development, we not only need to develop functionality, but it is also equally important to ensure that these functionalities are stable, reliable, and have good performance. To ensure this, we need to test the code. Developers usually perform unit tests and performance tests, which are used to test whether the code functions correctly and whether the code meets performance requirements.

Each language usually has its own testing package/module, and Go is no exception. In Go, we can use the testing package to perform unit tests and performance tests on the code. In this lecture, I will use some examples to explain how to write unit test and performance test cases. In the next lecture, I will introduce how to write other types of tests and provide test cases for the IAM project.

How to test Go code? #

Go language has a built-in testing framework called testing, which can be used to implement unit tests (T type) and performance tests (B type), and can be executed using the go test command.

When executing test cases with go test, the testing is done on a per-package basis. To execute tests, you need to specify the package name, such as go test package_name. If no package name is specified, the package where the command is executed will be selected by default. go test will traverse the source code files ending with _test.go and execute the test functions prefixed with Test, Benchmark, or Example.

To demonstrate how to write test cases, I have prepared four functions in advance. Assume these functions are saved in the math.go file under the test directory, with the package name test. The code for math.go is as follows:

package test

import (
    "fmt"
    "math"
    "math/rand"
)

// Abs returns the absolute value of x.
func Abs(x float64) float64 {
    return math.Abs(x)
}

// Max returns the larger of x or y.
func Max(x, y float64) float64 {
    return math.Max(x, y)
}

// Min returns the smaller of x or y.
func Min(x, y float64) float64 {
    return math.Min(x, y)
}

// RandInt returns a non-negative pseudo-random int from the default Source.
func RandInt() int {
    return rand.Int()
}

In the upcoming content, I will demonstrate how to write test cases to perform unit tests and performance tests on these functions. Now let’s take a look at the naming conventions for tests.

Testing Naming Conventions #

When we write tests for Go code, we need to follow certain naming conventions for test files, test functions, and test variables. These conventions come from both official sources and the community. Here, I will introduce the naming conventions for test files, packages, test functions, and test variables.

Naming Conventions for Test Files #

Go test files must end with _test.go. For example, if we have a file named person.go, its test file must be named person_test.go. This is because Go needs to distinguish between regular code files and test files. These test files can be loaded by the go test command line tool to test the code we write, but they are ignored by the Go build process because these test code are not required for running the Go program.

Naming Conventions for Packages #

Go tests can be categorized as white-box testing or black-box testing.

  • White-box testing: In white-box testing, tests are placed in the same Go package as the production code. This allows us to test both exported and unexported identifiers in the Go package. When our unit tests need to access unexported variables, functions, or methods in the Go package, we need to write white-box test cases.
  • Black-box testing: In black-box testing, tests are placed in a separate Go package from the production code. In this case, we can only test the exported identifiers in the Go package. This means that our test package will not have access to any internal functions, variables, or constants in the production code.

In white-box testing, the name of the testing package in Go needs to be the same as the package being tested. For example, if person.go defines a package named person, the testing file person_test.go also needs to have the package name person. This means that person.go and person_test.go should be in the same directory.

In black-box testing, the name of the testing package in Go needs to be different from the package being tested, but it can still be in the same directory. For example, if person.go defines a package named person, the testing file person_test.go needs to have a package name different from person, usually named person_test.

If black-box testing is not required, it is recommended to use white-box testing for unit testing. On one hand, this is the default behavior of the go test tool; on the other hand, by using white-box testing, we can test and use unexported identifiers.

The naming conventions for test files and packages are enforced by the Go language and the go test tool.

Naming Conventions for Functions #

Test case functions must start with Test, Benchmark, or Example. For example, TestXxx, BenchmarkXxx, ExampleXxx, where Xxx can be any combination of alphanumeric characters starting with an uppercase letter. This convention is enforced by the Go language and the go test tool, and Xxx is generally the name of the function being tested.

In addition to these conventions, there are some community guidelines that are not mandatory but make our test function names more understandable. For example, if we have the following function:

package main

type Person struct {
    age int64
}

func (p *Person) older(other *Person) bool {
    return p.age > other.age
}

It is clear that we can name the test function TestOlder, which clearly indicates that it is a test case for the Older function. But if we want to test the TestOlder function with multiple test cases, how should we name these test cases? You may suggest naming them TestOlder1, TestOlder2, and so on.

In fact, there are better naming methods. In this case, we can name the function as TestOlderXxx, where Xxx represents a specific scenario or description of the Older function. For example, the strings.Compare function has the following test functions: TestCompare, TestCompareIdenticalString, TestCompareStrings.

Naming Conventions for Variables #

Go language and go test do not enforce any naming conventions for variables. However, when writing unit test cases, there are some conventions that are worth following.

Unit test cases often have an expected output, and in the unit test, we compare the expected output with the actual output to determine if the unit test passes. To express the actual output and the expected output clearly, we can name these two types of outputs as expected/actual or got/want. For example:

if c.expected != actual {
    t.Fatalf("Expected User-Agent '%s' does not match '%s'", c.expected, actual)
}

Or:

if got, want := diags[3].Description().Summary, undeclPlural; got != want {
    t.Errorf("wrong summary for diagnostic 3\ngot:  %s\nwant: %s", got, want)
}

For other variable names, we can follow the variable naming conventions recommended by Go, such as:

  • Variable names in Go should be short rather than long, especially for locally scoped variables.
  • The further a variable is from its declaration, the more descriptive its name should be.
  • For variables like loops and indices, the names can be single letters (e.g., i). If it is an uncommon variable or a global variable, the variable name needs to be more descriptive.

Above, I introduced some basic knowledge about Go testing. Next, let’s take a look at how to write unit test cases and performance test cases.

Unit Testing #

The unit test case function starts with Test, for example, TestXxx or Test_xxx (where Xxx can be any combination of letters and numbers, with the first letter capitalized). The function parameter must be *testing.T, which can be used to record errors or testing status.

We can call the methods Error, Errorf, FailNow, Fatal, and FatalIf of testing.T to indicate that the test did not pass; Call the methods Log and Logf to record test information. The function list and related descriptions are shown in the following table:

Image

The following code shows two simple unit test functions (the functions are located in the file math_test.go):

func TestAbs(t *testing.T) {
    got := Abs(-1)
    if got != 1 {
        t.Errorf("Abs(-1) = %f; want 1", got)
    }
}

func TestMax(t *testing.T) {
    got := Max(1, 2)
    if got != 2 {
        t.Errorf("Max(1, 2) = %f; want 2", got)
    }
}

Execute the go test command to run the unit test cases:

$ go test
PASS
ok      github.com/marmotedu/gopractise-demo/31/test    0.002s

The go test command automatically collects all test files, which are files in the format *_test.go, and extracts all test functions and executes them. The go test command also supports the following three parameters.

  • -v, display the execution details of all test functions:
$ go test -v
=== RUN   TestAbs
--- PASS: TestAbs (0.00s)
=== RUN   TestMax
--- PASS: TestMax (0.00s)
PASS
ok      github.com/marmotedu/gopractise-demo/31/test    0.002s
  • -run <regexp>, specify the test functions to be executed:
$ go test -v -run='TestA.*'
=== RUN   TestAbs
--- PASS: TestAbs (0.00s)
PASS
ok      github.com/marmotedu/gopractise-demo/31/test    0.001s

In the example above, we only ran the test functions starting with TestA.

  • -count N, specify the number of times to execute the test functions:
$ go test -v -run='TestA.*' -count=2
=== RUN   TestAbs
--- PASS: TestAbs (0.00s)
=== RUN   TestAbs
--- PASS: TestAbs (0.00s)
PASS
ok      github.com/marmotedu/gopractise-demo/31/test    0.002s

Test Cases with Multiple Inputs #

The unit test cases introduced earlier only have one input. However, often we need to test whether a function can return correctly under different inputs. In this case, we can write a slightly more complex test case to support test cases with multiple inputs. For example, we can modify TestAbs to the following function:

func TestAbs_2(t *testing.T) {
    tests := []struct {
        x    float64
        want float64
    }{
        {-0.3, 0.3},
        {-2, 2},
        {-3.1, 3.1},
        {5, 5},
    }

    for _, tt := range tests {
        if got := Abs(tt.x); got != tt.want {
            t.Errorf("Abs() = %f, want %v", got, tt.want)
        }
    }
}

In the above test case function, we define a struct array, where each element of the array represents a test case. The value of the array element includes the input and the expected return value:

tests := []struct {
    x    float64
    want float64
}{
    {-0.3, 0.3},
    {-2, 2},
    {-3.1, 3.1},
    {5, 5},
}

The above test case is executed by putting the tested function in a for loop:

for _, tt := range tests {
    if got := Abs(tt.x); got != tt.want {
        t.Errorf("Abs() = %f, want %v", got, tt.want)
    }
}
t.Errorf("Abs() = %f, want %v", got, tt.want)

The above code passes the input to the tested function and compares the return value of the tested function with the expected return value. If they are equal, it means the test passes. If they are not equal, it means the test fails. In this way, we can test different inputs and outputs in a single test case. To add a new test case, you can simply add the input and the expected return value. These test cases share the rest of the testing code.

In the test case above, we compare the actual return value and the expected return value with got != tt.want. We can also use functions provided by the github.com/stretchr/testify/assert package to compare the results, for example:

func TestAbs_3(t *testing.T) {
    tests := []struct {
        x    float64
        want float64
    }{
        {-0.3, 0.3},
        {-2, 2},
        {-3.1, 3.1},
        {5, 5},
    }

    for _, tt := range tests {
        got := Abs(tt.x)
        assert.Equal(t, got, tt.want)
    }
}

Using assert to compare the results has the following benefits:

  • Friendly output result, easy to read.
  • The code becomes more concise because it eliminates the need for the if got := Xxx(); got != tt.wang {} judgment.
  • You can add additional message explanations for each assertion, for example assert.Equal(t, got, tt.want, "Abs test").

The assert package also provides many other functions for developers to compare results, such as Zero, NotZero, Equal, NotEqual, Less, True, Nil, NotNil, etc. If you want to learn more about these functions, you can refer to go doc github.com/stretchr/testify/assert.

Automatically Generating Unit Test Cases #

From what we have learned above, you may have noticed that test cases can actually be abstracted into the following model:

image

In code, it can be represented as follows:

func TestXxx(t *testing.T) {
    type args struct {
        // TODO: Add function input parameter definition.
    }

    type want struct {
         // TODO: Add function return parameter definition.
    }
    tests := []struct {
        name string
        args args
        want want
    }{
        // TODO: Add test cases.
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := Xxx(tt.args); got != tt.want {
                t.Errorf("Xxx() = %v, want %v", got, tt.want)
            }
        })
    }
}

Since test cases can be abstracted into some models, we can generate test code automatically based on these models. There are some excellent tools in the Go community that can automatically generate test code, and I recommend using the gotests tool.

Now, let me explain how to use the gotests tool, which can be divided into three steps.

Step 1, install the gotests tool:

$ go get -u github.com/cweill/gotests/...

The gotests command format is: gotests [options] [PATH] [FILE] .... gotests can generate test code for all functions in the Go source files under PATH, or it can only generate test code for functions in a specific FILE.

Step 2, go to the test code directory and run gotests to generate test cases:

$ gotests -all -w .

The above command will generate test code for all Go source files in the current directory.

Step 3, add test cases:

After generating the test cases, you only need to add the input and expected output for the tests. The following test case is generated by gotests:

func TestUnpointer(t *testing.T) {
    type args struct {
        offset *int64
        limit  *int64
    }
    tests := []struct {
        name string
        args args
        want *LimitAndOffset
    }{
        // TODO: Add test cases.
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := Unpointer(tt.args.offset, tt.args.limit); !reflect.DeepEqual(got, tt.want) {
                t.Errorf("Unpointer() = %v, want %v", got, tt.want)
            }
        })
    }
}

You only need to fill in the test data at the TODO position. The completed test case can be found in the gorm_test.go file: gorm_test.go.

Performance Testing #

Earlier, I talked about unit testing to check if the code functions correctly. Now let’s look at performance testing, which is used to verify if the code meets the performance requirements.

The test case function for performance testing must start with Benchmark, for example, BenchmarkXxx or Benchmark_Xxx (where Xxx is any combination of alphanumeric characters with the first letter capitalized).

The function parameter must be *testing.B, and inside the function, b.N is used as the loop count. The value of N is adjusted dynamically at runtime until the performance test function runs for a sufficiently long time to ensure reliable timing. Here is an example of a simple performance test function (located in the file math_test.go):

func BenchmarkRandInt(b *testing.B) {
    for i := 0; i < b.N; i++ {
        RandInt()
    }
}

By default, the go test command does not run the performance test functions. You need to specify the -bench <pattern> parameter to execute the performance test functions. -bench can be followed by a regular expression to select the performance test functions to be executed. For example, go test -bench=".*" means executing all the benchmark test functions. After executing go test -bench=".*", the output will be as follows:

$ go test -bench=".*"
goos: linux
goarch: amd64
pkg: github.com/marmotedu/gopractise-demo/31/test
BenchmarkRandInt-4      97384827                12.4 ns/op
PASS
ok      github.com/marmotedu/gopractise-demo/31/test    1.223s

The above result only shows the execution result of the performance test function. The execution result of the BenchmarkRandInt performance test function is as follows:

BenchmarkRandInt-4   	90848414	        12.8 ns/op

Each performance execution result has three columns, representing different meanings. Let’s take the example of the function above:

  • BenchmarkRandInt-4: BenchmarkRandInt is the name of the tested function, and 4 represents that 4 CPU threads participated in this test, which is the default value of GOMAXPROCS.
  • 90848414: The loop in the function was executed 90848414 times.
  • 12.8 ns/op: Indicates that the average execution time per loop is 12.8 nanoseconds. The smaller the value, the higher the code performance.

If our performance test function requires some time-consuming preparation work before the loop, we need to reset the performance test time counter. For example:

func BenchmarkBigLen(b *testing.B) {
    big := NewBig()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        big.Len()
    }
}

Of course, we can also stop the performance test time counter before the loop and then start the timer again. For example:

func BenchmarkBigLen(b *testing.B) {
    b.StopTimer() // Stop the time counter for pressure testing
    big := NewBig()
    b.StartTimer() // Start the timer again
    for i := 0; i < b.N; i++ {
        big.Len()
    }
}

Performance testing with type B also supports the following 4 parameters:

  • benchmem: Outputs memory allocation statistics.
$ go test -bench=".*" -benchmem
goos: linux
goarch: amd64
pkg: github.com/marmotedu/gopractise-demo/31/test
BenchmarkRandInt-4      96776823                12.8 ns/op             0 B/op          0 allocs/op
PASS
ok      github.com/marmotedu/gopractise-demo/31/test    1.255s

After specifying the -benchmem parameter, two more columns are added to the execution result: 0 B/op, which indicates how much memory is allocated per execution (in bytes). The smaller the value, the less memory the code occupies. 0 allocs/op indicates how many times memory is allocated per execution. The smaller the value, the fewer memory allocations, meaning higher code performance.

  • benchtime: Specifies the test time and loop execution count (the format should be Nx, for example, 100x).
$ go test -bench=".*" -benchtime=10s # Specify the testing time
goos: linux
goarch: amd64
pkg: github.com/marmotedu/gopractise-demo/31/test
BenchmarkRandInt-4      910328618                13.1 ns/op
PASS
ok      github.com/marmotedu/gopractise-demo/31/test    13.260s
$ go test -bench=".*" -benchtime=100x # Specify the loop execution count
goos: linux
goarch: amd64
pkg: github.com/marmotedu/gopractise-demo/31/test
BenchmarkRandInt-4      100                       16.9 ns/op
PASS
ok      github.com/marmotedu/gopractise-demo/31/test    0.003s
  • cpu: Specifies GOMAXPROCS.
  • timeout: Specifies the timeout for executing the test function.
$ go test -bench=".*" -timeout=10s
goos: linux
goarch: amd64
pkg: github.com/marmotedu/gopractise-demo/31/test
BenchmarkRandInt-4      97375881                12.4 ns/op
PASS
ok      github.com/marmotedu/gopractise-demo/31/test    1.224s

Summary #

After code development is complete, we need to write unit test cases for the code and, if necessary, write performance test cases for some functions. Go language provides the testing package for us to write test cases and execute them using the go test command.

When running test cases, go test looks for Go source files with a fixed format of file names and executes functions with a fixed format within them. These functions are the test cases. This requires our test file names and function names to comply with the requirements of the go test tool: Go test file names must end with _test.go; test case functions must start with Test, Benchmark, or Example. In addition, when writing test cases, we also need to pay attention to the naming conventions for packages and variables.

In Go project development, the most frequently written test cases are unit test cases. Unit test case functions start with Test, such as TestXxx or Test_xxx (where Xxx can be any combination of letters and numbers, with the first letter capitalized). The function parameter must be *testing.T, which can be used to report errors or test status. We can call the methods Error, Errorf, FailNow, Fatal, and FatalIf of testing.T to indicate a failed test; we can call the Log and Logf methods to record test information.

Here is a simple unit test function:

func TestAbs(t *testing.T) {
    got := Abs(-1)
    if got != 1 {
        t.Errorf("Abs(-1) = %f; want 1", got)
    }
}

After writing the test cases, we can use the go test command line tool to execute these test cases. Additionally, we can use the gotests tool to automatically generate unit test cases, thus reducing the workload of writing test cases.

In Go project development, we often need to write performance test cases. Performance test case functions must start with Benchmark and use *testing.B as the function parameter. They can be run using go test -bench <pattern>.

Exercises #

  1. Write a PrintHello function that returns the string Hello World, and write unit tests to test the PrintHello function.
  2. Consider when to use white-box testing and when to use black-box testing.

Welcome to discuss and communicate with me in the comments section. See you in the next lesson.