Golang Testing with TDD
Learning the basics of Go with a test-driven development approach
Go, also known as Golang, is an open-source programming language that could match your business usecase especially if you are prioritizing built-in error-handling and speed. To increase the overall system quality and avoid defects, it’s also important to emphasize the tests for your go modules. Well, there are different approaches to Go testing but what we’d like to demonstrate is a way that’s familiar for developers in Python or Typescript using the testify
library. The first thing you’ll notice when you use the testify
library is how it is almost like jest
. Furthermore, our approach is simplified but would still follow the test-driven development (TDD) approach.
By the end of this tutorial, hopefully, the readers will gain a better understanding of go basic tests, go coverage and refactoring, and go advanced test modules. Below is the accompanying repo: https://github.com/williaminfante/go_test_starter
Overview
We’ll introduce three kinds of functions in our starter package. These functions were made specifically to introduce the go testing concepts.
SayHello()
— basicsTestPickAnInteger()
— subtests, refactoring, coverageTestCheckHealth()
— use of more advanced testing libraries
To initially create the package, we can run go mod init
like below:
mkdir go_test_starter
go mod init github.com/williaminfante/go_test_starter
For tutorials in creating Go modules or packages, I would suggest this tutorial link: https://golang.org/doc/tutorial/create-module
We’ll identify our main module as package starter
in the starter.go
file.
For the tests in Go, we usually append _test.go
to their associated files. Go will then identify *_test.go
files when running tests and coverage. In this example, we’ll have starter_test.go
in the same directory as starter.go
and identify our tests with package starter_test
. The common module testing
will be handy to support automated testing and run our go test
commands later. With our simplified approach, we’ll mainly use the the assert
package in the testify
library to check equality for actual and expected values.
Basics of Go Testing
We’ll create a function, SayHello
, and by convention, it’s usually handy if we can map that function from the package starter
to test names like TestSayHello()
.
We’d like the first function to accept a string name argument like "William"
that it uses to return a string "Hello William. Welcome!"
. Notice how the assert.Equal()
is almost like the expect().toEqual()
from the Jest library.
The test still has a syntax error, so we’ll need to create the SayHello()
function in starter.go
to match the function signature in the test.
To run tests in Go, we can just use go test
. We can use go test -v
for a verbose version. During the initial phase of development, I prefer using go test -v
.
Cool, so it’s running, and we just need to find a way to use the input "William"
and plug it in the SayHello()
function. This is a straightforward one and we’ll use the value %v
in the argument so we’ll end up with:
You can try another name and it should still work as expected when you run go test
.
Coverage and Refactoring in Go Tests
For the next function, we’d like to test and create the function OddOrEven()
that accepts an integer and tells if it is an odd or even integer.
Initially, we wanted to check if the function accepts an integer like 45
, the function returns the string "45 is an odd number"
:
Like the first function we had, we’ll create a function with the same signature in starter.go
to remove the syntax error and run the test:
A quick run of go test
will provide the error:
To minimally satisfy the test requirement, we probably can just pass a string "45 is an odd number"
but let’s make it more dynamic and use the input.
This would make the test pass. But if we have another test item for an even number,
the test would fail:
We can use existing functions like the modulo math.Mod()
and just need a bit of conversion since math.Mod()
requires float64
as an input. We can see the following results to a modified function:
It satisfies the previous two items (45
and 42
), but can it satisfy 0
?
It does! Thinking about the test case, we also have negative values in integers. So, let’s try negative numbers this time like -45
.
Noticed that the test failed when we thought we almost had it. For this module, return value is -1
instead of 1
. So you can already see some parallelism to real-life use cases that could make your supposedly-very-reliable function to still potentially fail with some edge cases. So, we modified the test to:
We’ll also test that for a negative even number like -42
and it works.
Subtests and refactoring
When we’re testing a function, there may be instances where we want to group the test items into meaningful subcategories. For example, for a more complicated function, we may want to have a subtest for the happy path, another subtest for an edge-case and maybe another subtest for error-handling.
In our example, we may want to create subgroups for non-negative cases and negative cases. Golang still allows us to do these subtests and it’s also a good way to reuse input values.
This also makes it easier to read the test cases. In our example, we don’t have common values that we can refactor for the simple usecase, but we can group the test items to easily see that the created function can work for both non-negative and negative integers like so:
It’s also easy to understand and see a summary of the test when we run go test -v
as they’re grouped together.
Coverage
Speaking of creating a summary for the test, it’s also important to ensure that the conditions we mentioned the function that will actually be tested. A coverage to the desired output like coverage.out
is simply:
go test -v -coverprofile coverage.out
We can also create an html
formatted document by running a command similar to:
go tool cover -html=coverage.out -o coverage.html
Currently, we have 100% coverage!
Suppose that we did not check the even integers and only checked the odd integers, we may only get a coverage of 80% (if we include the SayHello()
testing).
Just note that a line coverage of 100% does not automatically mean there are no additional tests needed. If we removed all the negative subtests, the coverage would still show 100% coverage but we have not tested the case when math.Mod(float64(num), 2) == -1
. So yes, a 100% coverage is definitely a good goal but there’s more to line coverage that we’ll need.
Because we have followed the TDD approach, coverage will not be much of an issue for us! We are making sure that we are “covering” all the processes in our function since we created the tests first even before proceeding to create the function. We’re building the function incrementally while achieving almost 100% coverage in each step. Even during the times where we partially created the test, we have almost 100% coverage! Who wouldn’t want that, right? This is just one of the ways that we can benefit from TDD. Oh, and I also like that the tests using the TDD approach create a handy and automatically updated documentation for that package/module.
Advanced Testing Modules
For the third function in starter.go
, I intentionally did not follow the TDD approach as my main purpose here is to demonstrate how we can leverage advanced testing modules.
Now for example, we just wanted to quickly create a health check if a page could be loaded. For us to easily accomplish this task, we can make use of the http
package in golang with import "net/http"
. We can simply create a health check like:
Now, how do we plan to check this http
call? We can leverage the included testing library using import "net/http/httptest"
. It’s not just important to do the line coverage here but to simulate the http
behaviour. We can create our mock writer and request using httptest.NewRequest()
and httptest.NewRecorder()
. And after running our CheckHealth()
function, we should expect that the body contains the "health check passed"
like what has been written in the CheckHealth()
function.
With the test module httptest
, additional relevant tests can be added. For example, we can check the io.ReadAll()
function does not cause any error returning a nil
value and that the status code is 200 (OK)
. More information on the error handling approach and advanced mocking will be demonstrated in a later article.
Summary
I hope that gave an overview of the basics of golang testing with some elements of the test-driven approach. With that, I am just listing here a quick rundown on what we’ve covered:
- generating automated test
- using test coverage
- using TDD elements with golang
- using specialised test packages like
testify
andnet/http/httptest
A working module for the starter tests and functions is placed in here: https://github.com/williaminfante/go_test_starter
And if you’d like a quick look at the final files: