18 Quality Assurance How Go Ensures Quality Through Testing

18 Quality Assurance - How Go Ensures Quality Through Testing #

Starting from this lesson, I will take you through the fourth module of this column: Project Management. Nowadays, project development is not something that one person can complete alone. It requires collaboration between multiple people. Therefore, ensuring the quality of the code, how the code you write is used by others, and how to optimize the performance of the code in collaborative development will be covered in this module.

In this lesson, we will first learn about unit testing and benchmark testing in Go.

Unit Testing #

After developing a feature, you may directly merge the code into the code repository for deployment or use by others. However, this is not the correct approach because you haven’t tested the code you wrote yet. Code logic that has not been tested may have issues: merging it forcefully into the code repository may affect the development of other people, and deploying it forcefully may result in bugs in the production environment and affect user experience.

What is Unit Testing #

As the name suggests, unit testing focuses on testing individual units. In development, a unit can be a function, a module, etc. In general, the unit you want to test should be a complete minimal unit, such as a function in Go. This way, when each minimal unit has been verified, the entire module, or even the entire program, can be considered verified.

Unit tests are written by the developers themselves. In other words, whoever made changes to the code should also write the corresponding unit test code to verify the correctness of the changes made.

Unit Testing in Go #

Although the concept of unit testing is the same in every programming language, different languages have different designs for unit testing. Go also has its own unit testing specifications. I will explain it to you through a complete example, which is the classic Fibonacci sequence.

The Fibonacci sequence is a classic golden ratio sequence: the 0th term is 0; the 1st term is 1; starting from the 2nd term, each term is the sum of the previous two terms. So, the sequence is: 0, 1, 1, 2, 3, 5, 8, 13, 21…

Note: For the sake of summarizing the function equation later on, I specifically wrote it starting from the 0th term. In reality, there is no 0th term.

Based on the above rules, we can deduce the function equation for the sequence.

  1. F(0)=0
  2. F(1)=1
  3. F(n)=F(n - 1)+F(n - 2)

With the function equation, writing a Go function to calculate the Fibonacci sequence becomes relatively simple. Here is the code:

ch18/main.go

    func Fibonacci(n int) int {
    
       if n < 0 {
    
          return 0
    
       }
    
       if n == 0 {
    
          return 0
    
       }
    
       if n == 1 {
    
          return 1
    
       }
    
       return Fibonacci(n-1) + Fibonacci(n-2)
    
    }

This code implements the calculation of the Fibonacci sequence using recursion.

The Fibonacci function has been written and can be used by other developers. However, before using it, it needs to be unit tested. You need to create a new Go file to store the unit test code. The Fibonacci function we just wrote is in the ch18/main.go file, so the code for unit testing the Fibonacci function needs to be written in ch18/main_test.go. Here is the test code:

ch18/main_test.go

    func TestFibonacci(t *testing.T) {
    
       // Pre-defined Fibonacci sequences as test cases
    
       fsMap := map[int]int{}
    
       fsMap[0] = 0
    
       fsMap[1] = 1
    
       fsMap[2] = 1
    
       fsMap[3] = 2
    
       fsMap[4] = 3
    
       fsMap[5] = 5
    
       fsMap[6] = 8
    
       fsMap[7] = 13
    
       fsMap[8] = 21
    
       fsMap[9] = 34
    
       for k, v := range fsMap {
    
          fib := Fibonacci(k)
    
          if v == fib {
    
             t.Logf("Correct result for n=%d with value=%d", k, fib)
    
          } else {
    
             t.Errorf("Wrong result: expected %d, but computed value is %d", v, fib)
    
          }
}
}

In this unit test, I defined a set of test cases using a map, and then compared the results obtained from the Fibonacci function with the predefined results. If they are equal, it means that the Fibonacci function is calculated correctly. If they are not equal, it means that there is an error in the calculation.

To run the unit test, you can use the following command:

➜ go test -v ./ch18

This command will run all the unit tests in the “ch18” directory. Since I have only written one unit test, you can see the following result:

➜ go test -v ./ch18 

=== RUN   TestFibonacci

    main_test.go:21: Result is correct: n=0, value=0
    
    main_test.go:21: Result is correct: n=1, value=1
    
    main_test.go:21: Result is correct: n=6, value=8
    
    main_test.go:21: Result is correct: n=8, value=21
    
    main_test.go:21: Result is correct: n=9, value=34
    
    main_test.go:21: Result is correct: n=2, value=1
    
    main_test.go:21: Result is correct: n=3, value=2
    
    main_test.go:21: Result is correct: n=4, value=3
    
    main_test.go:21: Result is correct: n=5, value=5
    
    main_test.go:21: Result is correct: n=7, value=13
    
--- PASS: TestFibonacci (0.00s)

PASS

ok      gotour/ch18     (cached)

In the printed test result, you can see the “PASS” mark, which indicates that the unit test has passed. You can also see the logs I wrote in the unit test.

This is a complete Go unit test case, which is done under the testing framework provided by Go. The Go testing framework allows us to easily perform unit tests, but we need to follow five rules.

  1. The go file containing the unit test code must end with “_test.go”. The Go testing tool recognizes only files that conform to this rule.
  2. The part before “_test.go” in the unit test file’s name should preferably be the name of the go file where the function being tested is located. For example, in the above example, the unit test file is named “main_test.go” because the tested Fibonacci function is in the “main.go” file.
  3. The name of the unit test function must start with “Test” and be an exported (public) function.
  4. The signature of the test function must receive a pointer to the testing.T type and must not return any values.
  5. The function name should preferably be “Test” + the name of the function being tested. For example, in the example, it is “TestFibonacci”, which means that the tested function is Fibonacci.

By following these rules, you can easily write unit tests. The key to unit testing is to be familiar with the logic and scenarios of the business code so that you can test it as comprehensively as possible and ensure code quality. go语言18金句.png

Unit Test Coverage #

Was the Fibonacci function fully tested in the above example? This can be checked using unit test coverage.

Go provides a very convenient command to view unit test coverage. Taking the unit test of the Fibonacci function as an example again, you can use a command to view its unit test coverage.

➜ go test -v --coverprofile=ch18.cover ./ch18

This command includes the “–coverprofile” flag, which can generate a unit test coverage file. Running this command will also show the test coverage. The test coverage of the Fibonacci function is as follows:

PASS

coverage: 85.7% of statements

ok      gotour/ch18     0.367s  coverage: 85.7% of statements

You can see that the test coverage is 85.7%. From this number, it seems that the Fibonacci function has not been fully tested. At this time, you need to check the detailed unit test coverage report.

By running the following command, you can generate an HTML-formatted unit test coverage report:

➜ go tool cover -html=ch18.cover -o=ch18.html

After running this command, a “ch18.html” file will be generated in the current directory. Open it with a browser, and you will see the content shown in the figure:

image.png

Unit test coverage report

The red-marked part is not covered, and the green-marked part is already covered. This is the benefit of the unit test coverage report. With it, you can easily check whether your unit tests cover everything.

According to the report, I will modify the unit test again to cover the parts that were not covered. The modified code is as follows:

fsMap[-1] = 0

This means that since the part of graph n is shown in red in the figure, indicating that it has not been tested, we need to add another test case to test the scenario of n. Now, when you run this unit test again and check its unit test coverage, you will find that it is already 100%.

Benchmark Testing #

In addition to ensuring the correctness of the code logic we write, there are sometimes performance requirements. So how do you measure the performance of your code? This requires benchmark testing.

What is Benchmark Testing #

Benchmarking is a method used to measure and evaluate the performance metrics of software, primarily used to evaluate the performance of your code.

Benchmarking in Go #

Benchmarking in Go follows similar rules to unit testing, with the only difference being the naming convention for test functions. Let’s take the Fibonacci function as an example to demonstrate the use of benchmarking in Go.

The benchmarking code for the Fibonacci function is as follows:

ch18/main_test.go

func BenchmarkFibonacci(b *testing.B){

   for i:=0;i<b.N;i++{

      Fibonacci(10)

   }

}

This is a very simple example of benchmarking in Go. The differences from unit testing are as follows:

  1. The benchmarking function must start with “Benchmark” and must be exported.
  2. The function’s signature must receive a pointer to the “testing.B” type and should not return any values.
  3. The final “for” loop is important, as the code under test should be placed within this loop.
  4. “b.N” is provided by the benchmarking framework and represents the number of iterations in the loop. It is necessary to repeatedly call the code under test to evaluate performance.

Once the benchmark is written, you can test the performance of the Fibonacci function using the following command:

➜ go test -bench=. ./ch18

goos: darwin

goarch: amd64

pkg: gotour/ch18

BenchmarkFibonacci-8     3461616               343 ns/op

PASS

ok      gotour/ch18     2.230s

Running benchmark tests also requires using the “go test” command, but with the addition of the “-bench” flag. This flag accepts an expression as a parameter to match the benchmarking functions. “.” represents running all benchmark tests.

Let’s focus on explaining the output. Do you see the “-8” after the function name? It represents the value of GOMAXPROCS during benchmark testing. The next number, “3461616”, represents the number of iterations in the “for” loop, which is the number of times the code under test is called. Finally, “343 ns/op” indicates the time taken per iteration, which is 343 nanoseconds.

By default, benchmark tests run for 1 second, which means 3461616 calls are made in 1 second, with each call taking 343 nanoseconds. If you want to run the test for a longer duration, you can use the “-benchtime” flag. For example, to run the test for 3 seconds, the command would be:

go test -bench=. -benchtime=3s ./ch18

Timing Methods #

Before running benchmark tests, there may be some setup work involved, such as building test data, which also consumes time. To exclude this time from the benchmark, you can use the “ResetTimer” method. Here’s an example:

func BenchmarkFibonacci(b *testing.B) {

   n := 10

   b.ResetTimer() // Reset the timer

   for i := 0; i < b.N; i++ {

      Fibonacci(n)

   }

}

This avoids interference caused by the time spent in data preparation.

In addition to the “ResetTimer” method, there are also “StartTimer” and “StopTimer” methods, which allow you to control when the timer starts and stops.

Memory Statistics #

During benchmark testing, you can also track the number of memory allocations and the amount of memory allocated per operation. These metrics can serve as references for optimizing your code. Enabling memory statistics is simple using the “ReportAllocs()” method. Here’s an example:

func BenchmarkFibonacci(b *testing.B) {

   n := 10

   b.ReportAllocs() // Enable memory statistics

   b.ResetTimer() // Reset the timer

   for i := 0; i < b.N; i++ {

      Fibonacci(n)

   }

}

Now, when you run this benchmark, you can see the following results:

➜ go test -bench=.  ./ch18

I hope this explanation helps you understand benchmarking in Go!

goos: darwin
goarch: amd64
pkg: gotour/ch18
BenchmarkFibonacci-8    2486265    486 ns/op    0 B/op    0 allocs/op
PASS
ok      gotour/ch18     2.533s

You can see that compared to the original benchmark test, two more metrics have been added, namely 0 B/op and 0 allocs/op. The former indicates how many bytes of memory are allocated for each operation, and the latter indicates how many times memory is allocated for each operation. These two metrics can serve as references for code optimization, with smaller values being preferred.

Tip: Are smaller values better for these two metrics? Not necessarily, because sometimes code implementation requires space in exchange for time. So it depends on the specific business scenario, and smaller values are preferred as long as they meet the business requirements.

Concurrent Benchmark Testing #

In addition to regular benchmark testing, Go language also supports concurrent benchmark testing, where you can test the performance of your code under multiple concurrent goroutines. Taking the Fibonacci function as an example again, here is the code for conducting concurrent benchmark testing:

func BenchmarkFibonacciRunParallel(b *testing.B) {
    n := 10

    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            Fibonacci(n)
        }
    })
}

As you can see, Go language uses the RunParallel method to run concurrent benchmark tests. The RunParallel method creates multiple goroutines and distributes b.N to these goroutines for execution.

Practical Benchmark Testing #

I believe you now understand Go language’s benchmark testing and how to use it. Now let’s review with a practical example.

Using the Fibonacci function as an example again, based on the benchmark tests we conducted earlier, we found that it does not allocate new memory, meaning the slowness of the Fibonacci function is not caused by memory allocation. By ruling out this factor, we can deduce that the issue lies in the algorithm.

In recursive computations, there will inevitably be repeated calculations, which is the main factor affecting recursion efficiency. To solve the problem of repeated calculations, we can use caching to store the calculated results for reuse.

Based on this idea, I made the following modifications to the Fibonacci function code:

// Cache for storing calculated results
var cache = map[int]int{}

func Fibonacci(n int) int {
    if v, ok := cache[n]; ok {
        return v
    }

    result := 0
    switch {
    case n < 0:
        result = 0
    case n == 0:
        result = 0
    case n == 1:
        result = 1
    default:
        result = Fibonacci(n-1) + Fibonacci(n-2)
    }

    cache[n] = result
    return result
}

The core of this code is to use a map to cache the calculated results for easy reuse. After making these changes, let’s run the benchmark test again to see the effect of the optimization:

BenchmarkFibonacci-8    97823403    11.7 ns/op

As you can see, the result is 11.7 nanoseconds, which is 28 times faster than the previous 343 nanoseconds.

Summary #

Unit testing is a good way to ensure code quality, but it is not omnipotent. It can reduce the occurrence of bugs, but it shouldn’t be solely relied upon. In addition to unit testing, other measures such as code review and manual testing can be used to further ensure code quality.

In the last part of this lesson, I will introduce “Performance Optimization: How to Check and Optimize Go Code?”. Make sure to tune in!

As a exercise question for you, try using the -benchmem flag when running the go test command to perform memory profiling.