The plan for this series of articles is to review the various topics related to testing in Golang below and provide a condensed summary of these topics, as well as some of my experiences in development over the years.

However, Tips or Tricks are a matter of art, and collecting skills alone won’t do you much good at development.

Of course, at present, IT is not possible for me to talk about the problem of tao, so what I will do in the near future will be technical content, and I will always pay attention not to cross the line. Also hope the reader does not expect too high, here will not talk about what advanced things, of course, it is impossible to learn what will become a master of things.

The topic of this series is “Go Testing”, which is divided into two parts to make a review. The first part is the basic part, which is basically a review of the content that front-line development should face every day. I think it will help you reflect. The second is an in-depth article, which will take a shallow look at some relatively uncommon or not commonly used topics such as integration testing.

Original: Go Testing 1

The basic article

To prepare

First let’s assume that you have done some code development and written a package named YY that contains the following code implementation:

package yy

func Factorial(num int) int {
	return factorialTailRecursive(num)
}

func factorialTailRecursive(num int) int {
	return factorial(1, num)
}

func factorial(accumulator, val int) int {
	if val == 1 {
		return accumulator
	}
	return factorial(accumulator*val, val- 1)}Copy the code

Subsequent chapters will use this source code to explain how to verify and test it.

Unit testing

What’s This?

Unit tests are for any specific function, whether it is an exported function interface or an internal utility function that is not exported. You can perform a set of tests on that function to prove that it does what it says it does.

Because the test against a small unit of code (such as a function), it is also known as a unit test. The early days of Unit testing go back at least to Pascal, and if you’ve ever learned Pascal you know that you can organize a Pascal source file into a Unit that allows you to receive input, allows you to do output, The external world interacts with it through exposed interface member functions. Such code units are conducive to code reuse and completeness testing. In early software engineering, this series of concepts were gradually condensed in practice, and finally the concept of Unit Test was formed. A code unit is an important term in this concept, and we often think of a Function, a Procedure, or even a Class, Base Class, or Method/Message of a Class. Even a file, or a Package, is a unit of code.

So what you need to understand is that when you, as a coder, implement a specific goal and create a set of functions to implement it, you must also implement a set of unit tests to prove that each unit of code that makes up the specific goal works as intended.

Unit testing looks at the source code from a micro perspective, so we don’t care what the higher-level business logic requirements are, but rather examine concretely whether an implementation (such as an exact function) works. For example, if you create a factorial function, we don’t care how the factorial is used in the higher-level business logic, but we care in the unit testing phase whether the function gets the correct expected output for both legal and illegal inputs. That’s what unit testing is all about.

For Golang, writing unit tests is easy:

  1. In a package for exampleyyCreate a new go source file in the_test.goEnding, for exampleyy_test.go
  2. It is available in this fileyyoryy_testAs the package name
  3. Write a Test function entry whose signature must start with Test and whose argument must bet *testing.T(For performance test functionsb *benchmark.B)
  4. Write the test code in the function body, if you think the test failed, uset.Fatal("..." )Throw an exception in the manner of; If no exception normally terminates the function body, the test is considered passed.
  5. It can be used during executiont.Log(...)Output log text. similarlyt.FatalIt also outputs a log file in the form of an error.

Simple example

So, we can look at a sample that can be written like this:

func TestOne(t *testing.T) {
	ret := yy.Factorial(3)
	if ret == 6 {
		t.Log(ret)
	} else {
		t.Fatal("bad")}}Copy the code

The result of running this test case is:

❯ go test -v -test.run '^TestOne$'. / yy / = = = RUN TestOne yy_test. Go, 11:6 - PASS: TestOne (0.00 s) PASS ok github.com/hedzr/pools/yy 0.114 sCopy the code

As you can see, on the command line, we have qualified the execution of only test cases determined by the re by -test.run ‘^TestOne$’. Note that without the -v parameter, you will not see the log output before the PASS, but only the summary after the PASS.

Complex example

The above example is so simple that such a use case proves nothing in the real world. So we actually wrote a use case that was much more complex and did enough validation for all the critical cases to say that the proof was OK.

func TestFactorial(t *testing.T) {
	for i, tst := range []struct {
		input, expected int
	}{
		{0.1},
		{1.1},
		{2.2},
		{3.6},
		{4.24},
		{5.120} {},ifret := yy.Factorial(tst.input); ret ! = tst.expected { t.Fatalf("%3d. for Factorial(%d) expecting %v but got %v", i, tst.input, tst.expected, ret)
		}
	}
}
Copy the code

We’ll run it, but it will fail because our source code does not handle input values less than or equal to zero, resulting in infinite recursion:

❯ go test -v -test.run '^TestFactorial$' ./yy/ 
....
testing.runTests(0xc00000c0a0, 0x121ed80, 0x2, 0x2, 0xbfd978f38b91f1d0, 0x8bb2cf3ef8, 0x1226260, 0x100d150)
        /usr/local/opt/go/libexec/src/testing/testing.go:1447 +0x2e8
testing.(*M).Run(0xc000022080, 0x0)
        /usr/local/opt/go/libexec/src/testing/testing.go:1357 +0x245 main.main() _testmain.go:47 +0x138 FAIL github.com/hedzr/pools/yy 1.461 s FAILCopy the code

So now we need to update the source code implementation as follows:

func factorial(accumulator, val int) int {
	if val <= 1 {  // <---- For simplicity, we just changed this line
		return accumulator
	}
	return factorial(accumulator*val, val- 1)}Copy the code

Now for the test:

❯ go test -v -test.run '^TestFactorial$'. / yy / = = = RUN TestFactorial - PASS: TestFactorial (0.00 s) PASS ok github.com/hedzr/pools/yy 0.295 sCopy the code

Then there’s no problem. It’s fine.

Coverage testing

What’s This?

What it means to cover a test is to walk through every branch of the code you implement as a test case and expect the test to execute as expected.

In general, coverage testing is a type of unit testing, and we expect as much test coverage of code as possible.

In Golang, however, coverage tests can be listed separately because we can actually include the use cases of comprehensive tests in the general category, so the boundary between comprehensive tests and unit tests may not be clear and the distinction is not significant.

Performing coverage tests in Golang requires two steps:

go test -v . -coverprofile=coverage.txt -covermode=atomic
go tool cover -html=coverage.txt -o cover.html
Copy the code

The first step here is similar to the usual unit test, but with two additional parameters: -coverprofile specifies an intermediate file to collect coverage test results. The optional -covermode can specify how the coverage test is performed. Currently, three values can be used: set, count, and atomic. The default value is atomic, and you can stick with atomic in most cases; detailed explanations are beyond the scope of this article.

Tips & Tricks

Obviously, writing coverage tests can be a headache. It takes a lot of brain work to reasonably walk through all the branches of a function. Here are some tips to help you:

1. Do not useos.Exit(n)

This means writing your function implementation with a little effort and not using unrecoverable code branches like OS.exit (1). In the same way, log.Fatal or any other similar derivative should not be used.

The reason is simple, such a non-recoverable branch, once in the test, the whole test process is broken, you will not be able to run a full coverage test and get analysis results.

2. Limited usepanic(...)

If the semantics of the business logic require a fatal error in a scenario to express the non-continuability of the business logic itself, the error can be appropriately encapsulated to express its lethality. Such as:

type bizlogicErr struct{
  isFatal bool
  msg string
  inner error
}

func (b *bizlogicErr) Error(a) string {
  if b.isFatal {
    return "FATAL: " + b.err.Error()
  }
  return b.err.Error()
}

func (b *bizlogicErr) IsFatal(a) bool { return b.isFatal }
Copy the code

However, if you sometimes want to save effort, or if you don’t have to overwrap, you can also terminate a task by panic.

For the upper level caller, the lower level implementation could be a sequence of calls such as A -> B -> C -> D. Assuming a fatal error occurs during the B call and panic terminates itself, both C and D would be logically skipped. The upper level caller can pick up the panic error through the defer Recover mechanism and control how to report it to the business caller.

So panic can be used in low-level calls. After all, it is also a deadly exception that can be recovered. We can think of it as a C++ exception; It is even a very useful feature in many cases, especially in cases where the underlying nesting and branching are complex, and Panic can be returned directly to the control layer where RECOVER is located with minimal code.

For specific function implementations with Panic, we can test the panic branch by wrapping a layer of RECOVER in our test code:

// ----- a.go

func a(v int) int {
  if v < 0 {
    panic("neg")}return v
}

// ----- a_test.go

func TestA(t *testing.T) {
  for _, tst := range []struct{
    input, expect int
  }{
    { 9 -.- 1 },
    { 1.1} {},ifret := warpA(tst.input); ret ! = tst.expect { t.Fatal(...) }}}func wrapA(in int) (ret int) {
  defer func(a){
    if err := recover(a); err! =nil {
      ret = - 1
    }
  }
  
  ret = a(in)
  return
}
Copy the code
3. Use as much as possiblefunc fn(...) (... , err error)Function prototype of

The first is to take advantage of Golang’s multi-return nature as much as possible by always appending error returns to the list of return values.

When writing your implementation of a function, try not to handle error values directly in the function body if there is no intent to wrap or mask them, but instead return the error to the superior caller.

Or a unified error encapsulation is performed and then returned to the superior caller.

Second, Golang allows the return value to be named, so we think the best function body is to make good use of the named return value and always contain an error value return:

// Typical implementation
func IsRegularFile(path string) (bool, error) {
	fileInfo, err := os.Stat(path)
	iferr ! =nil {
		return false, err
	}
	return fileInfo.Mode().IsRegular(), err
}

// Improved implementation
func IsRegularFile(path string) (yes bool, err error) {
	var fileInfo os.FileInfo
	fileInfo, err = os.Stat(path)
	if err == nil {
		yes = fileInfo.Mode().IsRegular()
	}
	return
}
Copy the code

Using a named return value makes the return statement much more concise. Using a useful name for the return value makes the code clearer, which actually helps your code. For the caller, an explicit return value name helps the caller understand the output of the function more clearly than if it were returned anonymously.

In addition, you can see that in function implementations with named return values, it is easy to rewrite all branches into the same return statement. Of course, this may not be good for a very complex function body, but for a general implementation, it is better to reduce the number of branch paths in the function body down to a limited number of return statements.

Of course, when a named return value is used, some local variables have to be explicitly declared in the function body, such as the fileInfo declaration in the above example. This problem is usually not a burden either. Consider bringing most local variable declarations up to the function entrance and using var(…). In a way that pulls it together:

func A(...). {
  var(
    fileInfo os.FileInfo
    yes      bool
    ti       int...). . }Copy the code

Another way to avoid local variable declarations is to list it as another named return value. This is an interesting one, a little rogue, but not necessarily, and sometimes a more interesting one:

func IsRegularFile(path string) (yes bool, fileInfo os.FileInfo, err error) {
	fileInfo, err = os.Stat(path)
	if err == nil {
		yes = fileInfo.Mode().IsRegular()
	}
	return
}
Copy the code

Adoption is a matter of opinion.

Use 4.if err == nil

In many guides, we are advised to close a branch as soon as possible:

func A(filePath string) (err error) {
  var f *os.File
  iff, err = os.Open(filePath); err! =nil{
    return
  }
  
  if! f.IsRegular() {return errors.New("not regular file")}/ /...
}
Copy the code

That is, if you find an error, return immediately.

This approach can be beneficial in many situations. It fits the reader’s habit of thinking, finds the error, goes, next, continues the logic of the correct time…

In addition, this approach is useful for reducing conditional branch nesting, and in many cases it effectively flattens the nested branches.

However, when we need to do coverage tests, such functions under test are the focus of consuming test cases. To complete an override test for such a function, you must set each if err! = nil {return} Prepare a use case to ensure that the branch is walked over.

So, we recommend the if err == nil override in this case:

func A(filePath string) (err error) {
  var f *os.File
  if f, err = os.Open(filePath); err == nil {
    // open ok
    if f.IsRegular() {
      // go further ...}}//
  return
}
Copy the code

Imagine more complete code.

Obviously, there are two disadvantages:

  1. Deeply nested
  2. Illegal cases are not verbose and the caller may not know the specific reason for the failure

If you can tolerate both of these shortcomings, use our rewritten scheme, which allows you to test a successful path as if you have completed the test of the entire function.

This is also not a very good technique because it also ignores the detection of various critical cases, which is inconsistent with the purpose of coverage testing. So it’s up to your group to decide whether to use it or not.

Adoption is a matter of opinion.

Go test Command line parameter

Due to the lack of space to explain each one, this chapter only lists the most important, most commonly used and most likely to be used symbols. 456

Regular grammar

Execute all test cases under the current package folder of the current project, but do not recurse to subdirectories
go test .
# Execute all test cases in the current project current folder and display the log content during the test process, no recursive subdirectory
go test -v .

Similar to go test., but also recurses all test cases in the subdirectory
go test. /... gotest -v ./...
Copy the code

Execute specific test cases

go test -v . -test.run '^TestOne$'
Copy the code

You can rewrite this regex to accomplish a particular test case or set of test cases.

Perform coverage tests

The following two sentences are used to generate the cover test report cover.html
go test -v . -coverprofile=coverage.txt -covermode=atomic
go tool cover -html=coverage.txt -o cover.html

The maximum use case execution time can also be executed, beyond which the test is judged to have failed
go test -v . -coverprofile=coverage.txt -covermode=atomic -timeout=20m
Copy the code

Detect data contention issues during testing

go test -v -race .
Copy the code

When data race detection mode is turned on, potential data race problems are most likely to be detected and exposed after the complete test runs. If you want to detect the vast majority of data Racing problems as safely as possible, you should refine your coverage test cases to achieve over 90% code coverage to find as many potential problems as possible.

Data competition is an interesting question, but it can’t be thoroughly analyzed here. I’ll leave it for another article.

Delivering specific notifications

On the Go test command line, you can pass special parameters to the test case to inform the long test case to choose a shorter execution mode. For example, we have use cases like this:

func Test1(t *testing.T) {
  runTests(t, "a", ...)
}

func runTests(t *testing.T, baseName string, tests []test) {
	delta := 1
	if testing.Short() {
		delta = 16
	}
	for i := 0; i < len(tests); i += delta {
		name := fmt.Sprintf("%s[%d]", baseName, i)
		tests[i].run(t, name)
	}
}
Copy the code

Note the if testing.short () {} statement, which checks the go test command line for -test.short and then sets a larger increment to reduce the number of loops in the test loop, thereby reducing the test time.

So when we need a faster test, we can:

go test -v ./... -test.short
Copy the code

Other Introductory tutorials

Unit Testing made easy in Go. In this article, we will learn about… | by Uday Hiwarale | RunGo | Medium is a very interesting tutorial, it has many vscode screenshots, that alone is enough to be recommended, this very carefully.

Of course, although MY article does not love screenshots, but also very attentively, agree with the friend might as well like me.


  1. go – The Go Programming Language – Testing Flags↩
  2. go – The Go Programming Language – Testing Functions↩
  3. Golang.org/pkg/testing…↩