37 Code Testing Other Types of Testing in Go and Iam Testing Introduction

37 Code Testing Other Types of Testing in Go and IAM Testing Introduction #

Hello, I’m Kong Lingfei.

In the previous lesson, I introduced two types of testing in Go: unit testing and performance testing. In addition to these, there are other types and methods of testing in Go that are worth understanding and mastering. Additionally, the IAM project has written a large number of test cases that use different writing methods. You can validate your learned testing knowledge by studying the IAM test cases.

Today, I will introduce other types of testing in Go, including example testing, TestMain function, mock testing, fake testing, and explain how the IAM project writes and runs test cases.

Example Test #

An example test starts with Example and does not have input and return parameters. It is usually saved in the example_test.go file. Example tests may include comments starting with Output: or Unordered output:. These comments are placed at the end of the function. Comments starting with Unordered output: will ignore the order of the output lines.

When executing the go test command, these example tests will be executed, and go test will compare the output of the example tests with the comments (ignoring leading and trailing whitespace). If they are equal, the example test passes; otherwise, it fails. Here is an example test (located in the example_test.go file):

func ExampleMax() {
    fmt.Println(Max(1, 2))
    // Output:
    // 2
}

When running the go test command to test the ExampleMax example test:

$ go test -v -run='Example.*'
=== RUN   ExampleMax
--- PASS: ExampleMax (0.00s)
PASS
ok      github.com/marmotedu/gopractise-demo/31/test    0.004s

As you can see, ExampleMax test passes. It passes because fmt.Println(Max(1, 2)) outputs 2 to the standard output, which matches the 2 following // Output:.

When example tests do not include comments starting with Output: or Unordered output:, running go test will only compile the functions but not execute them.

Naming Convention for Example Tests #

Example tests need to follow certain naming conventions so that Godoc can associate them with package-level identifiers. For example, consider the following example test (located in the example_test.go file):

package stringutil_test

import (
    "fmt"

    "github.com/golang/example/stringutil"
)

func ExampleReverse() {
    fmt.Println(stringutil.Reverse("hello"))
    // Output: olleh
}

Godoc will provide this example next to the documentation of the Reverse function, as shown in the image below:

Image

Example tests are named with Example followed by an optional string. The string can be a function name, type name, or type_method name, connected by an underscore _. For example:

func Example() { ... } // Represents examples for the entire package
func ExampleF() { ... } // Example for function F
func ExampleT() { ... } // Example for type T
func ExampleT_M() { ... } // Example for method T_M

When a function/type/method has multiple example tests, they can be differentiated by suffixes. The suffix must start with a lowercase letter. For example:

func ExampleReverse()
func ExampleReverse_second()
func ExampleReverse_third()

Large Examples #

Sometimes, we need to write a large example test. In this case, we can write a whole file example, which has the following characteristics: the file ends with _test.go; it contains only one example test, with no unit tests or performance tests in the file; it includes at least one package-level declaration. When displaying this type of example test, Godoc directly shows the entire file. For example:

package sort_test

import (
    "fmt"
    "sort"
)

type Person struct {
    Name string
    Age  int
}

func (p Person) String() string {
    return fmt.Sprintf("%s: %d", p.Name, p.Age)
}

// ByAge implements sort.Interface for []Person based on
// the Age field.
type ByAge []Person

func (a ByAge) Len() int           { return len(a) }
func (a ByAge) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }

func Example() {
    people := []Person{
        {"Bob", 31},
        {"John", 42},
        {"Michael", 17},
        {"Jenny", 26},
    }

    fmt.Println(people)
    sort.Sort(ByAge(people))
    fmt.Println(people)

    // Output:
    // [Bob: 31 John: 42 Michael: 17 Jenny: 26]
    // [Michael: 17 Jenny: 26 Bob: 31 John: 42]
}

A package can contain multiple whole file examples, each example in a separate file, such as example_interface_test.go, example_keys_test.go, example_search_test.go, and so on.

TestMain Function #

Sometimes when we are doing tests, we may need to do some preparation work before the test, such as creating a database connection; and do some clean-up work after the test, such as closing the database connection, cleaning up test files, etc. In this case, we can add a TestMain function in the _test.go file, with a *testing.M parameter.

TestMain is a special function (similar to the main function). When the test cases are executed, the TestMain function will be executed first, and then we can call the m.Run() function in TestMain to execute normal test functions. We can write the preparation logic before m.Run() and the clean-up logic after m.Run().

In the example test file math_test.go, we add the following TestMain function:

func TestMain(m *testing.M) {
    fmt.Println("do some setup")
    m.Run()
    fmt.Println("do some cleanup")
}

Executing go test, the output is as follows:

$ go test -v
do some setup
=== RUN   TestAbs
--- PASS: TestAbs (0.00s)
...
=== RUN   ExampleMax
--- PASS: ExampleMax (0.00s)
PASS
do some cleanup
ok  	github.com/marmotedu/gopractise-demo/31/test	0.006s

Before running the test cases, “do some setup” is printed, and after the test cases are run, “do some cleanup” is printed.

In the test cases of the IAM project, a fake database is connected before executing the test cases using the TestMain function. The code is as follows (located in the internal/apiserver/service/v1/user_test.go file):

func TestMain(m *testing.M) {
    fakeStore, _ := fake.NewFakeStore()
    store.SetClient(fakeStore)
    os.Exit(m.Run())
}

Unit tests, performance tests, example tests, and TestMain function are the test types supported by go test. In addition, to test functions that use Go Interfaces inside the function, we also have two test types: Mock tests and Fake tests.

Mock Testing #

Generally speaking, external dependencies are not allowed in unit tests. This means that these external dependencies need to be mocked. In Go, various mock tools are often used to simulate dependencies.

GoMock is a testing framework developed and maintained by the official Golang team. It provides a complete interface-based mocking functionality, which can integrate well with the built-in testing package in Golang, as well as other testing environments. The GoMock testing framework consists of two parts: the GoMock package and the mockgen tool. The GoMock package is used to manage the lifecycle of objects, while the mockgen tool is used to generate mock class source files corresponding to the interface. Now, let me introduce the GoMock package and the mockgen tool, as well as how to use them in detail.

Installing GoMock #

To use GoMock, you first need to install the GoMock package and the mockgen tool. The installation method is as follows:

$ go get github.com/golang/mock/gomock
$ go install github.com/golang/mock/mockgen

Next, I will demonstrate how to use GoMock with an example of getting the latest Golang version. The directory structure of the example code is as follows (the code under the directory can be found here):

tree .
.
├── go_version.go
├── main.go
└── spider
    └── spider.go

The spider.go file defines a Spider interface. The code in spider.go is as follows:

package spider

type Spider interface {
    GetBody() string
}

The GetBody method in the Spider interface can fetch the Build version field on the homepage of https://golang.org to get the latest version of Golang.

In the go_version.go file, we call the GetBody method of the Spider interface. The code in go_version.go is as follows:

package gomock

import (
    "github.com/marmotedu/gopractise-demo/gomock/spider"
)

func GetGoVersion(s spider.Spider) string {
    body := s.GetBody()
    return body
}

The GetGoVersion function directly returns a string representing the version. Under normal circumstances, we would write the following unit test code:

func TestGetGoVersion(t *testing.T) {
    v := GetGoVersion(spider.CreateGoVersionSpider())
    if v != "go1.8.3" {
        t.Error("Get wrong version %s", v)
    }
}

The above test code depends on the spider.CreateGoVersionSpider() method, which returns an instance of a Spider interface (a spider) that has been implemented. However, in many cases, the spider.CreateGoVersionSpider() spider may not be implemented yet or cannot be executed in the unit testing environment (e.g., connecting to a database in the unit testing environment). In this case, the TestGetGoVersion test case cannot be executed.

So how can we run the TestGetGoVersion test case in this situation? We can use a mock tool to mock a spider instance. Next, I will explain the specific operations.

First, use the mockgen tool provided by GoMock to generate the implementation of the interface to be mocked. Execute the following command in the gomock directory:

$ mockgen -destination spider/mock/mock_spider.go -package spider github.com/marmotedu/gopractise-demo/gomock/spider Spider

The above command will generate the mock_spider.go file in the spider/mock directory:

$ tree .
.
├── go_version.go
├── go_version_test.go
├── go_version_test_traditional_method.go~
└── spider
    ├── mock
    │   └── mock_spider.go
    └── spider.go

In the mock_spider.go file, some functions/methods are defined to support writing the TestGetGoVersion test function. At this point, our unit test code is as follows (see the go_version_test.go file):

package gomock

import (
    "testing"

    "github.com/golang/mock/gomock"

    spider "github.com/marmotedu/gopractise-demo/gomock/spider/mock"
)

func TestGetGoVersion(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockSpider := spider.NewMockSpider(ctrl)
    mockSpider.EXPECT().GetBody().Return("go1.8.3")
    goVer := GetGoVersion(mockSpider)

    if goVer != "go1.8.3" {
        t.Errorf("Get wrong version %s", goVer)
    }
}

This version of TestGetGoVersion mocks a Spider interface using GoMock instead of implementing a Spider interface. This greatly reduces the complexity of writing unit test cases. With mocking, many functions that cannot be tested become testable.

Through the above test case, we can see that GoMock can work closely with the testing framework introduced in the previous lesson.

Introduction to the mockgen tool #

Above, I introduced how to use GoMock to write unit test cases. Among them, we use the mockgen tool to generate mock code. The mockgen tool provides many useful features, and I will explain them in detail here.

The mockgen tool is provided by GoMock and is used to mock a Go interface. It can automatically generate mock code based on the given interface. Here, there are two modes to generate mock code: source mode and reflect mode.

  1. Source mode

If there is an interface file, you can use the following command to generate mock code:

$ mockgen -destination spider/mock/mock_spider.go -package spider -source spider/spider.go

The above command mocks the Spider interface defined in the spider/spider.go file and saves the mock code in the spider/mock/mock_spider.go file. The package name of the file is spider.

The parameters of the mockgen tool are as follows:

Image

  1. Reflection Mode

In addition, the mockgen tool also supports generating mock code through the use of reflection. It is enabled by passing two non-flag arguments, the import path and a comma-separated list of interfaces. Other arguments are shared with the source code mode, for example:

$ mockgen -destination spider/mock/mock_spider.go -package spider github.com/marmotedu/gopractise-demo/gomock/spider Spider

Using mockgen with Annotations #

If there are multiple files scattered in different locations, generating mock files requires executing the mockgen command multiple times for each file (assuming the package names are different). This operation can be cumbersome. mockgen also provides a way to generate mock files using annotations, which requires the use of the go generate tool.

In the code of the interface file, add the following comments (see spider.go for specific code):

//go:generate mockgen -destination mock_spider.go -package spider github.com/cz-it/blog/blog/Go/testing/gomock/example/spider Spider

Now, we only need to execute the following command in the gomock directory to automatically generate the mock code:

$ go generate ./...

Writing Unit Test Cases with Mock Code #

After the mock code is generated, we can start using them. Here, we will combine testing to write a unit test case that uses mock code.

Firstly, we need to create a mock controller in the unit test code:

ctrl := gomock.NewController(t)

Pass *testing.T to GoMock to generate a Controller object that controls the entire process of mocking. After finishing the operations, it is necessary to perform cleanup, so it is common to defer Finish after NewController. The code is as follows:

defer ctrl.Finish()

Then, we can call the mock object:

mockSpider := spider.NewMockSpider(ctrl)

Here, spider is the package name passed in the mockgen command, followed by the object creation function in the format NewMockXxxx, where Xxx is the interface name. We need to pass in the controller object and return a mock instance.

Next, with the mock instance, we can call its assertion method EXPECT().

gomock uses a chain call method, where functions calls are connected like a chain using dots. For example:

mockSpider.EXPECT().GetBody().Return("go1.8.3")

To mock a method of an interface, we need to mock the method’s input parameters and return values. We can use parameter matching to mock the input parameters and use the Return method of the mock instance to mock the return values. Next, we will separately describe how to specify input parameters and return values.

Let’s start with specifying input parameters. If the function has parameters, we can use parameter matching to represent the function’s parameters. For example:

mockSpider.EXPECT().GetBody(gomock.Any(), gomock.Eq("admin")).Return("go1.8.3")

gomock supports the following parameter matching:

  • gomock.Any(): represents any input parameter.
  • gomock.Eq(value): represents a value equivalent to value.
  • gomock.Not(value): represents a value other than value.
  • gomock.Nil(): represents nil.

Next, let’s see how to specify return values.

EXPECT() returns the mock instance and then calls the method of the mock instance. This method returns the first Call object, which can be used to constrain it, such as using the Return method of the mock instance to constrain its return value. The Call object provides the following methods to constrain the mock instance:

func (c *Call) After(preReq *Call) *Call // Declares that the call should be performed after preReq is completed
func (c *Call) AnyTimes() *Call // Allows the call to be performed 0 or more times
func (c *Call) Do(f interface{}) *Call // Declares an operation to be performed when matching
func (c *Call) MaxTimes(n int) *Call // Sets the maximum number of times the call should be performed to n
func (c *Call) MinTimes(n int) *Call // Sets the minimum number of times the call should be performed to n
func (c *Call) Return(rets ...interface{}) *Call // Declares the values to return from the mock function call
func (c *Call) SetArg(n int, value interface{}) *Call // Declares that the value of the nth argument is set using a pointer
func (c *Call) Times(n int) *Call // Sets the number of times the call should be performed to n

The above methods provided by the Call object are listed above. Next, I will introduce three commonly used constraint methods: specifying return values, specifying the number of executions, and specifying the execution order.

  1. Specifying return values

We can provide the Return function of the Call to specify the return value of the interface. For example:

mockSpider.EXPECT().GetBody().Return("go1.8.3")
  1. Specifying the number of executions

Sometimes, we need to specify how many times a function should be executed. For example, for a function that accepts network requests, we need to calculate how many times it is executed. We can use the Times function of the Call to specify the number of executions:

mockSpider.EXPECT().Recv().Return(nil).Times(3)

In the above code, the Recv function is executed three times. gomock also supports other execution count restrictions:

  • AnyTimes(): represents 0 to many times.
  • MaxTimes(n int): represents a maximum of n times if not set.
  • MinTimes(n int): represents a minimum of n times if not set.
  1. Specifying the execution order

Sometimes, we also need to specify the execution order, such as performing the Init operation before executing the Recv operation:

initCall := mockSpider.EXPECT().Init()
mockSpider.EXPECT().Recv().After(initCall)

Finally, we can use go test to test the unit test code that uses the mock code:

$ go test -v
=== RUN   TestGetGoVersion
--- PASS: TestGetGoVersion (0.00s)
PASS
ok  	github.com/marmotedu/gopractise-demo/gomock	0.002s

Fake Testing #

In Go project development, for complex interfaces, we can also create a fake implementation of an interface for testing. Fake testing means creating a fake instance for an interface. As for how to implement a fake instance, you need to implement it according to your own business needs. For example, in the IAM project, the iam-apiserver component implements a fake store. You can find the code in the fake directory. Since the IAM project testing part will be covered in the practical testing section later, we will not go into further details here.

When to write and execute unit test cases? #

Earlier, I introduced the basics of Go code testing. Now, I would like to share an important knowledge point when doing testing: when to write and execute unit test cases.

Before coding: TDD #

Image

Test-Driven Development (TDD) is a core practice and technique of agile development. It is also a design methodology. In simple terms, the principle of TDD is to write test case code before developing the functional code and then write the functional code to make it pass the test. The benefits of TDD are that the code that passes the test definitely meets the requirements, helps to program to an interface, reduces code coupling, and greatly reduces the likelihood of bugs.

However, the disadvantages of TDD are also obvious. Since the test cases are written before the code design, they may limit the developer’s overall code design. Moreover, TDD requires a high level of expertise and represents a different way of thinking compared to traditional development, making it difficult to implement. In addition, because test cases need to be written first, TDD may also affect the development progress of the project. Therefore, under circumstances where it is not feasible, it is not advisable to blindly pursue the use of TDD for business code development.

Alongside coding: Incremental #

Writing unit tests for incremental code in a timely manner is a good practice. On the one hand, because at this point we have some understanding of the requirements, we can write better unit tests to validate correctness. In addition, detecting problems during the unit testing phase rather than during integration testing saves us the cost of fixing them.

On the other hand, during the process of writing unit tests, we can also reflect on the correctness and rationality of the business code, driving us to better reflect on the design of the code and make adjustments as necessary.

After coding: Legacy #

After completing the business requirements, we may encounter a situation where there was no test planning due to tight release timelines. In the development phase, code was manually tested for functionality.

If there are significant new requirements or if maintenance has become a problem for this portion of legacy code, it is a good opportunity to add unit tests. Adding unit tests to legacy code can further help the refactoring process by promoting a better understanding of the original logic and increasing the refactored code’s confidence while reducing risk.

However, adding unit tests to legacy code may require recalling and understanding the details of requirements and logic design. Sometimes, the person writing the unit tests is not the original code designer, so there are certain drawbacks to writing and executing unit tests after coding.

Test Coverage #

When writing unit tests, we should think comprehensively to cover all test cases. However, sometimes we may miss some cases. Go provides a tool called “cover” to measure test coverage. It can be divided into two steps.

Step 1: Generate test coverage data:

$ go test -coverprofile=coverage.out
do some setup
PASS
coverage: 40.0% of statements
do some cleanup
ok  	github.com/marmotedu/gopractise-demo/test	0.003s

The above command generates a coverage file named coverage.out in the current directory.

Image

Step 2: Analyze the coverage file:

$ go tool cover -func=coverage.out
do some setup
PASS
coverage: 40.0% of statements
do some cleanup
ok  	github.com/marmotedu/gopractise-demo/test	0.003s
[colin@dev test]$ go tool cover -func=coverage.out
github.com/marmotedu/gopractise-demo/test/math.go:9:	Abs		100.0%
github.com/marmotedu/gopractise-demo/test/math.go:14:	Max		100.0%
github.com/marmotedu/gopractise-demo/test/math.go:19:	Min		0.0%
github.com/marmotedu/gopractise-demo/test/math.go:24:	RandInt		0.0%
github.com/marmotedu/gopractise-demo/test/math.go:29:	Floor		0.0%
total:							(statements)	40.0%

In the output of the above command, we can see which functions have not been tested and which branches inside the functions have not been fully tested. The cover tool calculates the coverage based on the ratio of executed code lines to total lines. In this case, the test coverage for the Abs and Max functions is 100%, and the test coverage for the Min and RandInt functions is 0%.

We can also use go tool cover -html to generate an HTML-formatted analysis file, which can provide a clearer view of the test coverage:

$ go tool cover -html=coverage.out -o coverage.html

The above command will generate a coverage.html file in the current directory. By opening the coverage.html file in a browser, we can see the test coverage of the code more clearly, as shown in the following image:

From the image above, we can see that the red part of the code has not been tested. This helps us to add targeted test cases for the specific code, rather than being confused and not knowing which code needs test cases.

In Go project development, test coverage is often required for code merging. Therefore, when conducting code testing, we need to generate code coverage data files simultaneously. By analyzing this file, we can determine whether our code test coverage meets the requirements. If not, the code test fails.

IAM Project Testing in Action #

Next, let me introduce how the IAM project writes and runs test cases, which can help you deepen your understanding of the content above.

How does the IAM project run test cases? #

Firstly, let’s take a look at how the IAM project executes test cases.

In the IAM project’s source code root directory, you can run make test to execute the test cases. make test will execute the go.test pseudo target in the iam/scripts/make-rules/golang.mk file, and the rules are as follows:

.PHONY: go.test
go.test: tools.verify.go-junit-report
  @echo "===========> Run unit test"
  @set -o pipefail;$(GO) test -race -cover -coverprofile=$(OUTPUT_DIR)/coverage.out \
    -timeout=10m -short -v `go list ./...|\
    egrep -v $(subst $(SPACE),'|',$(sort $(EXCLUDE_TESTS)))` 2>&1 | \
    tee >(go-junit-report --set-exit-code >$(OUTPUT_DIR)/report.xml)
  @sed -i '/mock_.*.go/d' $(OUTPUT_DIR)/coverage.out # remove mock_.*.go files from test coverage
  @$(GO) tool cover -html=$(OUTPUT_DIR)/coverage.out -o $(OUTPUT_DIR)/coverage.html

In the above rules, we set a timeout and enable race detection when running go test. We also enable code coverage checking, and the coverage test data is saved in the coverage.out file. In Go project development, not all packages need unit testing, so the above command also filters out some packages that do not need testing. These packages are configured in the EXCLUDE_TESTS variable:

EXCLUDE_TESTS=github.com/marmotedu/iam/test github.com/marmotedu/iam/pkg/log github.com/marmotedu/iam/third_party github.com/marmotedu/iam/internal/pump/storage github.com/marmotedu/iam/internal/pump github.com/marmotedu/iam/internal/pkg/logger

At the same time, go-junit-report is called to convert the result of go test into an XML format report file, which can be used by CI systems such as Jenkins for analysis and display. The above code also generates a coverage.html file, which can be stored in an artifact repository for further analysis and viewing.

Note that mock code does not need to write test cases. In order to avoid affecting the unit test coverage of the project, the coverage data for mock code needs to be removed from the coverage.out file. The go.test rule deletes these unnecessary data as follows:

sed -i '/mock_.*.go/d' $(OUTPUT_DIR)/coverage.out # remove mock_.*.go files from test coverage

In addition, we can also perform unit test coverage testing by running make cover. make cover will execute the go.test.cover pseudo target in the iam/scripts/make-rules/golang.mk file, and the rules are as follows:

.PHONY: go.test.cover
go.test.cover: go.test
  @$(GO) tool cover -func=$(OUTPUT_DIR)/coverage.out | \
    awk -v target=$(COVERAGE) -f $(ROOT_DIR)/scripts/coverage.awk

The above target depends on go.test, which means that unit testing will be performed before executing the unit test coverage target. Then, the total unit test coverage is calculated based on the coverage data coverage.out generated by the unit test. Here, it is calculated using the coverage.awk script.

If the unit test coverage does not meet the requirements, the Makefile will throw an error and exit. The unit test coverage threshold can be set using the COVERAGE variable in the Makefile.

By default, COVERAGE is set to 60, but we can also manually specify it on the command line, for example:

$ make cover COVERAGE=80

In order to ensure that the unit test coverage of the project meets the requirements, it is necessary to set quality red lines for unit test coverage. Generally, these red lines are difficult to guarantee by developers’ self-discipline, so a good method is to integrate these red lines into the CI/CD process.

Therefore, in the Makefile file, I put cover in the dependencies of the all target, and it is placed before the build, that is, all: gen add-copyright format lint cover build. This way, each time we run make, the code will be automatically tested and the unit test coverage will be calculated. If the coverage does not meet the requirements, the build will stop; if it meets the requirements, the build will continue to the next step of the build process.

IAM Project Test Case Sharing #

Next, I will show you some test cases of the IAM project. Since I have already provided detailed explanations of the implementation methods of these test cases in Article 36 and the first half of this article, here I will only list the specific implementation code without explaining the implementation methods of these codes again.

  1. Unit Test Cases

We can manually write unit test code or use the gotests tool to generate unit test code.

First, let’s take a look at the case of manually writing test code. The unit test code can be found in Test_Option, as follows:

func Test_Option(t *testing.T) {
    fs := pflag.NewFlagSet("test", pflag.ExitOnError)
    opt := log.NewOptions()
    opt.AddFlags(fs)

    args := []string{"--log.level=debug"}
    err := fs.Parse(args)
    assert.Nil(t, err)

    assert.Equal(t, "debug", opt.Level)
}

In the above code, the github.com/stretchr/testify/assert package is used to compare the results.

Next, let’s take a look at the case of using the gotests tool to generate unit test code (Table-Driven test pattern). For efficiency reasons, most of the IAM project’s unit test cases are generated by the gotests tool to generate test case template code, and then test cases are filled in based on these template code. The code can be found in the service_test.go file.

  1. Performance Test Cases

The performance test case of the IAM project can be found in the BenchmarkListUser test function. The code is as follows:

func BenchmarkListUser(b *testing.B) {
	opts := metav1.ListOptions{
		Offset: pointer.ToInt64(0),
		Limit:  pointer.ToInt64(50),
	}
	storeIns, _ := fake.GetFakeFactoryOr()
u := &userService{
    store: storeIns,
}

for i := 0; i < b.N; i++ {
    _, _ = u.List(context.TODO(), opts)
}

}

  1. Example Test Cases

Example test cases for the IAM project can be found in the example_test.go file. Here is an example of a test code from example_test.go:

func ExampleNew() {
    err := New("whoops")
    fmt.Println(err)

    // Output: whoops
}
  1. TestMain Test Cases

The TestMain test case for the IAM project can be found in the TestMain function in the user_test.go file:

func TestMain(m *testing.M) {
    _, _ = fake.GetFakeFactoryOr()
    os.Exit(m.Run())
}

The TestMain function initializes the fake Factory and then calls m.Run() to execute the test cases.

  1. Mock Test Cases

The mock code can be found in the internal/apiserver/service/v1/mock_service.go file, and the test cases that use the mock can be found in the create_test.go file. Since the code is quite long, I recommend opening the links to see the specific implementation of the test cases.

You can generate all the mock files by running the following command in the root directory of the IAM project:

$ go generate ./...
  1. Fake Test Cases

The implementation of the fake store code is located in the internal/apiserver/store/fake directory. The usage of the fake store can be found in the user_test.go file:

func TestMain(m *testing.M) {
    _, _ = fake.GetFakeFactoryOr()
    os.Exit(m.Run())
}

func BenchmarkListUser(b *testing.B) {
    opts := metav1.ListOptions{
        Offset: pointer.ToInt64(0),
        Limit:  pointer.ToInt64(50),
    }
    storeIns, _ := fake.GetFakeFactoryOr()
    u := &userService{
        store: storeIns,
    }

    for i := 0; i < b.N; i++ {
        _, _ = u.List(context.TODO(), opts)
    }
}

The above code initializes a fake instance (store.Factory interface type) through TestMain:

func GetFakeFactoryOr() (store.Factory, error) {
    once.Do(func() {
        fakeFactory = &datastore{
            users:    FakeUsers(ResourceCount),
            secrets:  FakeSecrets(ResourceCount),
            policies: FakePolicies(ResourceCount),
        }
    })

    if fakeFactory == nil {
        return nil, fmt.Errorf("failed to get mysql store fatory, mysqlFactory: %+v", fakeFactory)
    }

    return fakeFactory, nil
}

The GetFakeFactoryOr function creates some fake users, secrets, and policies and saves them in the fakeFactory variable for later use in test cases such as BenchmarkListUser and Test_newUsers.

Other Testing Tools/Packages #

Finally, let me share some commonly used testing tools/packages in Go projects. Since there is a lot of content, I won’t go into detail. If you’re interested, you can click on the links to learn more. I have divided these testing tools/packages into two categories: testing frameworks and mock tools.

Testing Frameworks #

  • Testify Framework: Testify is a prediction tool for Go test, which can make your test code more elegant and efficient, and provide more detailed test results.
  • GoConvey Framework: GoConvey is a testing framework for Golang, which can manage and run test cases, provide rich assertion functions, and support many web interface features.

Mock Tools #

In this lesson, I introduced the GoMock framework provided by the Go official website, but there are also other excellent mock tools available for us to use. These mock tools are used in different mocking scenarios, as I have already introduced in Lesson 10. However, to make our testing knowledge system in this lesson more complete, I will mention them again here for you to review.

  • sqlmock: Used to simulate database connections. Databases are common dependencies in projects, and sqlmock can be used when dealing with database dependencies.
  • httpmock: Used to mock HTTP requests.
  • bouk/monkey: Monkey patching, which can modify the implementation of any function by replacing function pointers. If the methods mentioned earlier, such as golang/mock, sqlmock, and httpmock, cannot meet our needs, we can try using monkey patching to mock dependencies. It can be said that monkey patching provides the ultimate solution for mocking dependencies in unit testing.

Summary #

In this lecture, I introduced some testing methods other than unit testing and performance testing.

In addition to example testing and the TestMain function, I also provided a detailed introduction to Mock testing, which is how to use GoMock to test interfaces that are not easily implemented in a unit testing environment. In most cases, GoMock can be used to mock interfaces, but for interfaces with more complex business logic, we can test the code by faking an interface implementation, which is also known as Fake testing.

Furthermore, I discussed when to write and execute test cases. We can choose to write test cases before writing the code, while writing the code, or after writing the code according to our needs.

To ensure unit test coverage, we should also set a quality threshold for unit test coverage for the whole project and incorporate it into the CI/CD pipeline. We can generate test coverage data using the go test -coverprofile=coverage.out command and analyze the coverage file using the go tool cover -func=coverage.out command.

The IAM project uses a variety of testing methods and techniques to test the code. In order to deepen your understanding of testing knowledge, I also provided some test cases for your reference, learning, and verification. You can go back to the previous section to review specific test cases.

In addition to these, we can also use other testing frameworks, such as the Testify framework and the GoConvey framework. In Go code testing, the most commonly used mocking framework is GoMock provided by Go’s official package. However, there are still other excellent mocking tools available for us to use in different scenarios, such as sqlmock, httpmock, bouk/monkey, etc.

Exercises #

  1. Please use sqlmock to mock a GORM database instance and write unit test cases for GORM’s CRUD operations.
  2. Think about what other excellent testing frameworks, testing tools, mock tools, and testing techniques are available for Go project development. Feel free to share your thoughts in the comments section.

Feel free to discuss and exchange ideas with me in the comment section. See you in the next lesson.