Go Basic: Understanding Testing and Benchmarking in Go

Ensuring code quality and performance.

full lessons here👇:
https://programmerscareer.com/golang-basic-skill/

Generated by AI, there may be errors, for reference only

Topic 1.1: Introduction to Testing in Go

Testing your code is fundamental to ensuring its quality and correctness. Tests validate that your code behaves as expected and makes it less prone to errors. If you work in a team, it guarantees that modifications by others don’t break existing functionality.

Go has a built-in package called testing which provides support for automated tests in your Go code. Go’s philosophy encourages well-tested code, and the testing package reflects this, providing easy-to-use features for you to incorporate testing into your coding practice.

In Go, each test is associated with a test function, which follows these rules:

  • It must be in a file that ends with _test.go.
  • The test function must start with the word Test.
  • The test function takes one argument only i.e., t *testing.T.

Here’s an example of what a simple test might look like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package stringutil  

import "testing"

func TestReverse(t *testing.T) {
cases := []struct {
in, want string
}{
{"Hello, world", "dlrow ,olleH"},
{"Hello, 世界", "界世 ,olleH"},
{"", ""},
}
for _, c := range cases {
got := Reverse(c.in)
if got != c.want {
t.Errorf("Reverse(%q) == %q, want %q", c.in, got, c.want)
}
}
}

In this example, TestReverse is the testing function. It’s testing the Reversefunction of our stringutil package.

Topic 1.2: Understanding Unit Tests

To ensure the reliability of your code, you should test its smallest testable components, referred to as “units”. This style of testing is known as “unit testing”.

Each unit test is an independent test case that verifies the functionality of a specific part of your code. In Go, unit tests are typically created using the testing package, as explained previously.

When we create unit tests, we should follow these guidelines:

  • Each unit test should be independent and able to run separately from other tests.
  • Tests should be simple, clear, and easy to understand.
  • The test should clearly show what it’s testing and what are the expected results.

Here’s a basic example using the strconv.Itoa function:

1
2
3
4
5
6
7
8
9
10
11
12
13
package gorilla  

import (
"strconv"
"testing"
)

func TestItoa(t *testing.T) {
str := strconv.Itoa(454545)
if str != "454545" {
t.Errorf("Expected strconv.Itoa to return '454545' but got %v", str)
}
}

In this example, TestItoa is our unit test. We’re testing the Itoa function of the strconv package. If strconv.Itoa(454545) doesn’t return the string “454545”, the test fails, and an error message gets displayed.

Topic 1.3: Table-Driven Tests

Testing different cases often requires writing multiple tests with nearly identical test logic but different input values. This can result in a lot of redundant code. Table-driven tests allow you to test different input values for your function using just a single test.

Table-driven tests use a single test function that loops over a table of test cases. The table is an array consisting of struct values, where each struct represents one test case.

Let’s have a look at a table-driven test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package stringutil  

import "testing"

func TestReverse(t *testing.T) {
var tests = []struct {
input string
expected string
}{
{"hello", "olleh"},
{"world", "dlrow"},
{"", ""},
}
for _, tt := range tests {
actual:= Reverse(tt.input)
if actual!=tt.expected{
t.Errorf("Reverse(%q) = %q; expected %q", tt.input, actual, tt.expected)
}
}
}

In this example, we’re creating multiple test cases for the function Reverse. The input and expected values for all test cases are defined inside the testsvariable. These cases run inside the for loop, where the Reverse function is called with the input value and checked against the expected output.

If the function fails on any test case, it will print an error message with the input it failed on, the actual output, and the expected output.

This makes it easier to detect which test case failed and why, making debugging easier.

Topic: 1.4 Mocking and Interfaces

Testing complex applications often requires dealing with external services, databases, and APIs, which, under normal circumstances, run independently of our codebase. During testing, these services can have unpredictable behavior or even become unavailable. That’s when mockingcomes in.

Mocking is the technique of replacing parts of your software — typically those that have external dependencies — with dummy implementations, for testing purposes.

Go provides an elegant way of dealing with this using interfaces. A Go interface makes it easy to substitute the real dependencies with mock objects during testing.

Here’s a simple example. Let’s consider the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Downloader interface {  
Download(string) ([]byte, error)
}

type FileDownloader struct {}

func (fd FileDownloader) Download(url string) ([]byte, error) {
//File download logic
}

func processFile(d Downloader) {
content, err := d.Download("http://example.com")
if err != nil {
fmt.Println(err)
return
}
// Process the content…
}

In this example, FileDownloader is our actual implementation we’d use to download a file. The function processFile takes an argument of type Downloader, an interface that FileDownloader implements.

Now, if we want to test the processFile function without having to hit the outside website, we’d need to create a mock implementation of the Downloader interface. We’ll call it MockDownloader.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type MockDownloader struct {  
mockContent []byte
mockError error
}

func (md MockDownloader) Download(_ string) ([]byte, error) {
return md.mockContent, md.mockError
}

func Test_processFile(t *testing.T) {
d := &MockDownloader{
mockContent: []byte("test content"),
mockError: nil,
}
processFile(d)
// Check if the processFile handled the content correctly
}

In the Test_processFile test function, we’re creating an instance of MockDownloader and passing it to the processFile function. This way, the function is tested independently of the external dependency.

Topic: 1.5 Testing Coverage

Test coverage in Go is a measurement of how much of your code is covered by tests. It’s a handy metric that can give you an idea of the extent of your testing.

To calculate test coverage in Go, we use the built-in go test tool with the -cover flag.

1
go test -cover

This command, when run in your package directory, will perform tests and return a statement like the one below:

1
2
PASS  
coverage: 80% of statements

This percentage tells you how much of your code is “covered” by your tests.

If you want more detailed coverage information, you can use the -coverprofile flag, along with specifying an output file:

1
go test -coverprofile=coverage.out

After you’ve done this, you can analyze the coverage with the go tool cover -html command, which will produce a detailed, color-coded HTML page:

1
go tool cover -html=coverage.out

This will open up a browser window with your code, showing which parts of your code are covered by tests and which aren’t. Lines covered by tests are green and lines without coverage are in red.

Understanding your testing coverage can help you create more comprehensive tests and ensure your code works as expected.

Topic 1.6: Introduction to Benchmarking

While testing helps us ensure that our programs are functioning correctly, it’s equally important to know how efficiently they’re running. This is where benchmarking comes into play.

Benchmarking is the technique of measuring the performance of your code. You typically use it to find out how much time your function takes to execute or how much memory it consumes. It’s used to identify bottlenecks in your code and to find areas of improvement.

In Go, you may write benchmarks similarly to tests, but with a slight variation in the function signature. Benchmark functions start with the word Benchmark followed by the name of the function you’re about to benchmark.

Let’s consider you have the following function that you’d like to benchmark:

1
2
3
4
5
6
7
8
9
package stringutils  

func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}

To benchmark the Reverse function, you could write a benchmark function like this:

1
2
3
4
5
6
7
8
9
package stringutils  

import "testing"

func BenchmarkReverse(b *testing.B) {
for i := 0; i < b.N; i++ {
Reverse("The quick brown fox jumps over the lazy dog")
}
}

A benchmark function receives a pointer to testing.B. This contains the number of iterations that the benchmark loop should make, among other fields. It automatically adjusts the value of b.N to achieve an optimal balance between precision and running time.

To run the benchmarks, you can use the go test command with the -benchflag:

1
go test -bench=.

The . after the bench flag indicates that Go should run all benchmarks in the current directory.

By analyzing the results of your benchmarks, you could make adjustments to your functions to improve their performance.

Topic 1.7: Benchmarking Different Parts of the Code

Sometimes your entire function might not be the bottleneck, and the slowdown could be due to just a small part of it. In such cases, benchmarking the entire function won’t be as helpful. That’s where benchmarking specific parts of the code come in handy.

However, benchmarking specific parts of a function can be a bit tricky in Go, since the b.N loop controls the number of times the function executes. If you want to benchmark a particular part of that function, you’ll have to ensure that part gets executed b.N number of times.

Here’s a simple way to do it:

1
2
3
4
5
6
7
8
9
10
11
12
package stringutils  

import "testing"

func BenchmarkReverseSubstring(b *testing.B) {
// Data setup
data := "The quick brown fox jumps over the lazy dog"
b.ResetTimer() // Resetting timer to exclude data setup time
for i := 0; i < b.N; i++ {
Reverse(data[i%len(data)/2 : len(data)/2 +i%len(data)/2])
}
}

In this modified benchmark, only half of the data string is reversed at a time, simulating the process of benchmarking a particular part of a function.

When you run this benchmark, you can compare its output with the output of the earlier benchmark where the entire string was reversed, giving insight into where the potential bottleneck in the function might be.

Keep in mind, though, that benchmarking itself adds an overhead, so it’s best not to have too many benchmarks in your actual program. They are tools for your development and testing environment to help you write efficient programs.

Topic 1.8: Analysis and Parsing Benchmark Results

Once we have benchmarked our Go code, it’s important to understand the results. Parsing benchmark results involves understanding what each of the output numbers signifies and how changes in those numbers relate to the changes you made in your code.

When you run a benchmark in Go, here is a typical output you would get:

1
BenchmarkReverse-4 1000000 1238 ns/op

Here’s what each part of this output means:

  • BenchmarkReverse-4: This is the name of the testing function. The -4indicates that the benchmark was run using 4 CPUs.
  • 1000000: This is the number of times the operation was performed during the benchmark test.
  • 1238 ns/op: This is the cost of the operation. In this case, it’s saying that our Reverse function takes 1238 nanoseconds on average to perform one operation.

Armed with this knowledge, you can now compare the benchmark outputs before and after code changes to check whether the changes have improved the performance or made it worse. You can also compare the performances of different functions or different parts of a function to locate any bottlenecks.

Topic: 1.9 Overview and Importance of Good Practices

As software engineers, one of the things we should always strive for is writing clean, efficient, and manageable code. All the techniques we’ve learned above, like testing, benchmarking, and analyzing the results, are part of a broader agenda that emphasizes good practices in coding.

Why are these practices important? Here are a few reasons:

  • Readability and Maintainability: Well-tested and benchmarked code is likely clean and manageable. This makes it easier for others (and for your future self) to read and maintain your code.
  • Performance: Benchmarking helps you find bottlenecks in your program. By continually optimizing these areas, you ensure that your code performs efficiently.
  • Reliability: By testing your code and fixing bugs, you increase the reliability of your program.
  • Collaboration: Good coding practices like writing descriptive comments, using meaningful variable names, and organizing code logically, all make collaboration with other developers easier.

Remember, the aim is not just to make your code work. It’s to create code that can stand the test of time, be understood by others, perform efficiently, and be reliable.

Topic: 1.10 Review, Practice, and Assessments

In this lesson block, we have covered several topics, mainly focusing on testing and benchmarking your Go code. A quick recap:

  1. Writing Tests in Go: We learned about the importance of testing, how to write, run, and benchmark tests in Go.
  2. Performance Metrics: Understanding how to interpret the test results and performance metrics.
  3. Benchmarking: We explored how to benchmark specific parts of the function to dig deeper and find out where the bottleneck might be.
  4. Analysis and parsing benchmark results: We learned about making sense of benchmark results and drawing insights from them to optimize the code.
  5. Importance of Good Practices: Finally, we discussed the importance of good coding practices, including testing, benchmarking, writing descriptive comments, using meaningful variable names, and logically organizing the code.

Now, it’s time to put your knowledge to the test. Let’s have a quick assessment:

  1. Write a small description, in your own words, on how to benchmark Go functions.
  2. What information does a typical Go benchmark output provide? Give an example and describe each part of the output.
  3. Why is it important to follow good coding practices, and what are some of these practices?

中文文章: https://programmerscareer.com/zh-cn/go-basic-13/
Author: Wesley Wei – Twitter Wesley Wei – Medium
Note: If you choose to repost or use this article, please cite the original source.

Go Basic: Understanding Command Line, Environment Variables in Go Go Basic: Understanding Time, Epoch, Time Formatting Parsing, Random Numbers, Number Parsing, URL Parsing, sha256 Hashes, base64 Encoding in Go

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×