In this tutorial I will teach you all the basics of idiomatic testing in Go using the best practices developed by the language designers and the community. The main weapon will be the standard testing package. The target will be a sample program that solves a simple problem from Project Euler.
Square Sum Difference
The sum square difference problem is pretty simple: "Find the difference between the sum of the squares of the first one hundred natural numbers and the square of the sum."
This particular problem can be solved rather concisely especially if you know your Gauss. For example, the sum of the first N natural numbers is (1 + N) * N / 2
, and the sum of squares of the first N integers is: (1 + N) * (N * 2 + 1) * N / 6
. So the whole problem can be solved by the following formula and assigning 100 to N:
(1 + N) * (N * 2 + 1) * N / 6 - ((1 + N) * N / 2) * ((1 + N) * N / 2)
Well, that's very specific, and there isn't much to test. Instead, I created some functions that are a little more general than what's needed for this problem, but can serve for other programs in the future (project Euler has 559 problems right now).
The code is available on GitHub.
Here are the signatures of the four functions:
// The MakeIntList() function returns an array of consecutive integers // starting from 1 all the way to the `number` (including the number) func MakeIntList(number int) []int // The squareList() function takes a slice of integers and returns an // array of the quares of these integers func SquareList(numbers []int) []int // The sumList() function takes a slice of integers and returns their sum func SumList(numbers []int) int // Solve Project Euler #6 - Sum square difference func Process(number int) int
Now, with our target program in place (please forgive me, TDD zealots), let's see how to write tests for this program.
The Testing Package
The testing package goes hand in hand with the go test
command. Your package tests should go in files with the "_test.go" suffix. You can split your tests across several files that follow this convention. For example: "whatever1_test.go" and "whatever2_test.go". You should put your test functions in these test files.
Every test function is a publicly exported function whose name starts with "Test", accepts a pointer to a testing.T
object, and returns nothing. It looks like:
func TestWhatever(t *testing.T) { // Your test code goes here }
The T object provides various methods you can use to indicate failure or record errors.
Remember: only test functions defined in test files will be executed by the go test
command.
Writing Tests
Every test follows the same flow: set up the test environment (optional), feed the code under test input, capture the result, and compare it to the expected output. Note that inputs and results don't have to be arguments to a function.
If the code under test is fetching data from a database then the input will be making sure the database contains appropriate test data (which may involve mocking at various levels). But, for our application, the common scenario of passing input arguments to a function and comparing the result to the function output is sufficient.
Let's start with the SumList()
function. This function takes a slice of integers and returns their sum. Here is a test function that verifies SumList()
behaves as it should.
It tests two test cases, and if an expected output doesn't match the result, it calls the Error()
method of the testing.T object.
func TestSumList_NotIdiomatic(t *testing.T) { // Test []{} -> 0 result := SumList([]int{}) if result != 0 { t.Error( "For input: ", []int{}, "expected:", 0, "got:", result) } // Test []{4, 8, 9} -> 21 result = SumList([]int{4, 8, 9}) if result != 21 { t.Error( "For input: ", []int{}, "expected:", 0, "got:", result) } }
This is all straightforward, but it looks a little verbose. Idiomatic Go testing uses table-driven tests where you define a struct for pairs of inputs and expected outputs and then have a list of these pairs that you feed in a loop to the same logic. Here is how it is done for testing the SumList()
function.
type List2IntTestPair struct { input []int output int } func TestSumList(t *testing.T) { var tests = []List2IntTestPair{ {[]int{}, 0}, {[]int{1}, 1}, {[]int{1, 2}, 3}, {[]int{12, 13, 25, 7}, 57}, } for _, pair := range tests { result := SumList(pair.input) if result != pair.output { t.Error( "For input: ", pair.input, "expected:", pair.output, "got:", result) } } }
This is much better. It is easy to add more test cases. It's easy to have the full spectrum of test cases in one place, and if you decide to change the test logic you don't need to change multiple instances.
Here is another example for testing the SquareList()
function. In this case, both the input and the output are slices of integers, so the test pair struct is different, but the flow is identical. One interesting thing here is that Go doesn't provide a built-in way to compare slices, so I use reflect.DeepEqual()
to compare the output slice to the expected slice.
type List2ListTestPair struct { input []int output []int } func TestSquareList(t *testing.T) { var tests = []List2ListTestPair{ {[]int{}, []int{}}, {[]int{1}, []int{1}}, {[]int{2}, []int{4}}, {[]int{3, 5, 7}, []int{9, 25, 49}}, } for _, pair := range tests { result := SquareList(pair.input) if !reflect.DeepEqual(result, pair.output) { t.Error( "For input: ", pair.input, "expected:", pair.output, "got:", result) } } }
Running Tests
Running tests is as simple as typing go test
in your package directory. Go will find all the files with the "_test.go" suffix and all the functions with the "Test" prefix and run them as tests. Here is what it looks like when everything is OK:
(G)/project-euler/6/go > go test PASS ok _/Users/gigi/Documents/dev/github/project-euler/6/go 0.006s
Not very dramatic. Let me break a test on purpose. I'll change the test case for SumList()
such that the expected output for summing 1 and 2 will be 7.
func TestSumList(t *testing.T) { var tests = []List2IntTestPair{ {[]int{}, 0}, {[]int{1}, 1}, {[]int{1, 2}, 7}, {[]int{12, 13, 25, 7}, 57}, } for _, pair := range tests { result := SumList(pair.input) if result != pair.output { t.Error( "For input: ", pair.input, "expected:", pair.output, "got:", result) } } }
Now, when you type go test
, you get:
(G)/project-euler/6/go > go test --- FAIL: TestSumList (0.00s) 006_sum_square_difference_test.go:80: For input: [1 2] expected: 7 got: 3 FAIL exit status 1 FAIL _/Users/gigi/Documents/dev/github/project-euler/6/go 0.006s
That states pretty well what happened and should give you all the information you need to fix the problem. In this case, the problem is that the test itself is wrong and the expected value should be 3. That's an important lesson. Don't automatically assume that if a test fails the code under test is broken. Consider the entire system, which includes the code under test, the test itself, and the test environment.
Test Coverage
To ensure your code works, it's not enough to have passing tests. Another important aspect is test coverage. Do your tests cover every statement in the code? Sometimes even that is not enough. For example, if you have a loop in your code that runs until a condition is met, you may test it successfully with a condition that works, but fail to notice that in some cases the condition may always be false, resulting in an infinite loop.
Unit Tests
Unit tests are like brushing your teeth and flossing. You shouldn't neglect them. They are the first barrier against problems and will let you have confidence in refactoring. They are also a boon when trying to reproduce issues and being able to write a failing test that demonstrates the issue that passes after you fix the issue.
Integration Tests
Integration tests are necessary as well. Think of them as visiting the dentist. You may be OK without them for a while, but if you neglect them for too long it won't be pretty.
Most non-trivial programs are made of multiple inter-related modules or components. Problems can often occur when wiring those components together. Integration tests give you confidence that your entire system is operating as intended. There are many other types of tests like acceptance tests, performance tests, stress/load tests and full-fledged whole system tests, but unit tests and integration tests are two of the foundational ways to test software.
Conclusion
Go has built-in support for testing, a well-defined way to write tests, and recommended guidelines in the form of table-driven tests.
The need to write special structs for every combination of inputs and outputs is a little annoying, but that's the price you pay for Go's simple by design approach.
No comments:
Post a Comment