Introduction #
In this article, you will learn about testing in Go.
We will explore the testing tools that Go provides out of the box for testing your programs, as well as some popular testing libraries within the Go ecosystem.
🎯 This article is quite dense, so I recommend reading it thoroughly from start to finish.
Basic Unit Test #
Let's start with a simple Go program with a function Add
that adds two number.
gopackage main import "fmt" func main() { result := Add(10, 12) fmt.Println(result) } func Add(a, b int) int { return a + b }
main.go
Now let's write a test for the Add
function.
Test File #
In Go you keep all your test with filenames that end with a _test.go
.
Create a file called main_test.go
.
gopackage main
main_test.go
Writing a test #
In your test file Go will look for functions that have a prefix Test
and accept an argument of type *testing.T
.
func Test<Name>(t *testing.T)
Any function defined with the above format in the test file is automatically picked up by Go and ran as a test.
Considering the above let's write a test for the Add
function.
gopackage main import "testing" func TestAdd(t *testing.T) { result := Add(10, 10) wantResult := 20 if result != wantResult { t.Errorf("wanted %d, got %d", wantResult, result) } }
main_test.go
*testing.T
provides many functions, one of this is t.Errorf
which should be used to tell Go that your test has failed, i.e. making assertions.
Running Tests #
To run tests use the following command.
bash$ go test ./... ok github.com/gurleensethi/go-testing
All tests will pass.
Test File
When we run tests, Go will look for files that end with a _test.go
and run all the tests defined in the file.
Any function who's name starts with Test
is considered to be a test and Go run's it automatically.
Run tests in verbose mode using the -v
flag.
bash$ go test -v ./... === RUN TestAdd --- PASS: TestAdd (0.00s) PASS ok github.com/gurleensethi/playground 0.448s
Failing tests #
Let's see how failing tests look like.
Introduce a bug in the Add
function.
gofunc Add(a, b int) int { return a + b + 1 // 👈 bug }
main.go
Run the tests again using go test ./...
bash$ go test ./... --- FAIL: TestAdd (0.00s) main_test.go:10: wanted 20, got 21 FAIL FAIL github.com/gurleensethi/playground 0.464s FAIL
Running in verbose mode using the -v
flag.
bash$ go test -v ./... === RUN TestAdd main_test.go:10: wanted 20, got 21 --- FAIL: TestAdd (0.00s) FAIL FAIL github.com/gurleensethi/playground 0.499s FAIL
Table Driven Tests #
Let's say you wanted to test multiple test cases, for example Add(10, 10)
and Add(1, 3)
.
You would do the following.
gopackage main import ( "testing" ) func TestAdd(t *testing.T) { // Test Case 1 result := Add(10, 10) want := 20 if result != want { t.Errorf("wanted %d, got %d", want, result) } // Test Case 2 result = Add(1, 3) want = 4 if result != want { t.Errorf("wanted %d, got %d", want, result) } }
main_test.go
While this works perfetly fine, there is a better way to structure tests where you have multiple test cases called table-driven tests.
I haven't seen this pattern in many other languages; Go was the first language where I encountered table-driven tests. So, this might be new for you as well.
Let's rewrite the TestAdd
function with table driven tests methodology.
gopackage main import ( "fmt" "testing" ) func TestAdd(t *testing.T) { // 👇 Create a slice of all the tests cases you want to run. testsCases := []struct { a int b int want int }{ { a: 10, b: 10, want: 20, }, { a: 1, b: 3, want: 4, }, } // 👇 Loop over all the test cases and run them. for _, tc := range testsCases { t.Run(fmt.Sprintf("%d add %d", tc.a, tc.b), func(t *testing.T) { result := Add(tc.a, tc.b) if result != tc.want { t.Errorf("wanted %d, got %d", tc.want, result) } }) } }
main_test.go
Table driven tests are used when you want to tests multiple cases against a function.
Running the above using go test -v ./...
bash$ go test -v ./... === RUN TestAdd === RUN TestAdd/10_add_10 === RUN TestAdd/1_add_3 --- PASS: TestAdd (0.00s) --- PASS: TestAdd/10_add_10 (0.00s) --- PASS: TestAdd/1_add_3 (0.00s) PASS ok github.com/gurleensethi/playground 0.413s
Pay attention to the t.Run
function, it runs the provided function with the given name as a subset test.
From the testing documentation
Run runs f as a subtest of t called name. It runs f in a separate goroutine and blocks until f returns or calls t.Parallel to become a parallel test. Run reports whether f succeeded (or at least did not fail before calling t.Parallel).
Test Coverage #
The go test
command also provides a way to check test coverage using the -cover
flag.
Let's run tests on the current file using the coverage flag.
go$ go test -v -cover ./... === RUN TestAdd === RUN TestAdd/10_add_10 === RUN TestAdd/1_add_3 --- PASS: TestAdd (0.00s) --- PASS: TestAdd/10_add_10 (0.00s) --- PASS: TestAdd/1_add_3 (0.00s) PASS coverage: 33.3% of statements ok github.com/gurleensethi/playground (cached) coverage: 33.3% of statements
main_test.go
We have a test coverage of 33.3%, i.e. 33.3% of our source code has been covered with tests.
Let's add a new function called Subtract
and see what happens to the test coverage.
gopackage main import "fmt" func main() { result := Add(10, 12) fmt.Println(result) } func Add(a, b int) int { return a + b } func Subtract(a, b int) int { return a - b }
main.go
Run the tests again.
go$ go test -v -cover ./... === RUN TestAdd === RUN TestAdd/10_add_10 === RUN TestAdd/1_add_3 --- PASS: TestAdd (0.00s) --- PASS: TestAdd/10_add_10 (0.00s) --- PASS: TestAdd/1_add_3 (0.00s) PASS coverage: 25.0% of statements ok github.com/gurleensethi/playground 0.466s coverage: 25.0% of statements
main_test.go
The coverage dropped to 25% because we added code that has not been covered with test cases.
Remove the Subtract
function and proceed.
TestMain - Controlling the entrpoint #
Sometimes you need to do setup before running any tests and teardown after all tests are ran.
You can use TestMain(*testing.M)
function for this purpose.
gopackage main import ( "fmt" "os" "testing" ) func TestMain(m *testing.M) { fmt.Println("Before all tests") exitCode := m.Run() fmt.Println("After all tests") os.Exit(exitCode) } func TestAdd(t *testing.T) { // ... }
main_test.go
Running tests you will get the following output.
bash$ go test -v ./... Before all tests === RUN TestAdd === RUN TestAdd/10_add_10 === RUN TestAdd/1_add_3 --- PASS: TestAdd (0.00s) --- PASS: TestAdd/10_add_10 (0.00s) --- PASS: TestAdd/1_add_3 (0.00s) PASS After all tests ok github.com/gurleensethi/playground
Calling m.Run()
When you have a TestMain(*testing.M)
function, Go will not run any tests, it will call the TestMain
and you have to call m.Run
which runs all the tests.
Mocking #
I have written a dedicated article on mocking which you can read - Mocking Interfaces in Go
Let's say the Add
function was provided from a library that conforms to a Calculator
interface that has an Add
function.
gopackage main type Calculator interface { Add(a, b int) int } type calculatorImpl struct{} func NewCalculator() *calculatorImpl { return &calculatorImpl{} } func (c *calculatorImpl) Add(a, b int) int { return a + b }
calculator.go
Let's say you are writing an app that uses Add
functionality
gopackage main import "fmt" func main() { calculator := NewCalculator() app := myApp{Calculator: calculator} result := app.Add(10, 10) fmt.Println(result) } type myApp struct { Calculator Calculator } func (app *myApp) Add(a, b int) int { return app.Calculator.Add(a, b) }
main.go
You would rewrite the tests for myApp
as follows.
gopackage main import ( "fmt" "testing" ) func TestAdd(t *testing.T) { testsCases := []struct { a int b int want int }{ { a: 10, b: 10, want: 20, }, { a: 1, b: 3, want: 4, }, } for _, tc := range testsCases { t.Run(fmt.Sprintf("%d add %d", tc.a, tc.b), func(t *testing.T) { calculator := NewCalculator() app := myApp{Calculator: calculator} result := app.Add(tc.a, tc.b) if result != tc.want { t.Errorf("wanted %d, got %d", tc.want, result) } }) } }
main_test.go
Let's say the Calculator library was making http calls behind the scene to make calculations and you didn't want to use the actualy implementation but just a mock implementation of caclulcator.
You can create a mock manually.
gopackage main import ( "fmt" "testing" ) // 👇 Mock Calculator type mockCalculator struct { } // 👇 Add function with satisfies the Calculator interface func (c *mockCalculator) Add(a, b int) int { return a + b } func TestAdd(t *testing.T) { testsCases := []struct { a int b int want int }{ { a: 10, b: 10, want: 20, }, { a: 1, b: 3, want: 4, }, } for _, tc := range testsCases { t.Run(fmt.Sprintf("%d add %d", tc.a, tc.b), func(t *testing.T) { app := myApp{Calculator: &mockCalculator{}} // 👈 using the mock calculator instead result := app.Add(tc.a, tc.b) if result != tc.want { t.Errorf("wanted %d, got %d", tc.want, result) } }) } }
main_test.go
Using a Mocking Library #
While the Caculator
interface is simple, sometimes it can be difficult to write mocks manually, in such cases you can use a mocking library.
There are many out there, and you should try a bunch of them to see which one fits your needs best.
We are going to use a mocking library mainted by Uber called uber-go/mock.
Install the mocking tool #
bashgo install go.uber.org/mock/mockgen@latest
Generate mocks for the Calculator
interface and store it under mocks/calculator.go
.
bashmockgen -source=calculator.go -destination=mocks/calculator.go -package=mocks
You can read about all the available flags in mockgen here.
Install the mock package in your project.
bashgo get go.uber.org/mock/gomock
You can now rewrite the tests using the MockCalculator
from mocks/calculator.go
.
gopackage main import ( "fmt" "testing" "github.com/gurleensethi/playground/mocks" "go.uber.org/mock/gomock" ) func TestAdd(t *testing.T) { testsCases := []struct { a int b int want int }{ { a: 10, b: 10, want: 20, }, { a: 1, b: 3, want: 4, }, } for _, tc := range testsCases { t.Run(fmt.Sprintf("%d add %d", tc.a, tc.b), func(t *testing.T) { ctrl := gomock.NewController(t) cal := mocks.NewMockCalculator(ctrl) // We are expecting our app to call the `MockCalculcator.Add` // function on the calculator with the provided arguments // and we return a mock result. cal. EXPECT(). Add(gomock.Eq(tc.a), gomock.Eq(tc.b)). Return(tc.a + tc.b). Times(1) // We expect `MockCalculcator.Add` to be called only once. app := myApp{Calculator: cal} result := app.Add(tc.a, tc.b) if result != tc.want { t.Errorf("wanted %d, got %d", tc.want, result) } }) } }
main_test.go
Run tests.
bash$ go test -v ./... ? github.com/gurleensethi/playground/mocks [no test files] === RUN TestAdd === RUN TestAdd/10_add_10 === RUN TestAdd/1_add_3 --- PASS: TestAdd (0.00s) --- PASS: TestAdd/10_add_10 (0.00s) --- PASS: TestAdd/1_add_3 (0.00s) PASS ok github.com/gurleensethi/playground 0.421s
Don't worry if you don't understand the above snippet in detail, you can use any mocking package that you like.
Mocking
The main idea with mocking is you are creating a mock implementation (which you control) of 3rd party dependencies in your app.
Using a popular testing package - testify #
Testify is a popular Go package for testing, its essentially a toolkit that provides assertion
& require
functionality, mock
generation and bulding test suite
s.
Let's rewrite our tests using testify.
Install testify
package.
bashgo get github.com/stretchr/testify
Test Suite #
Rewrite the test to use a suite.Suite
.
gopackage main import ( "fmt" "testing" "github.com/gurleensethi/playground/mocks" "github.com/stretchr/testify/suite" "go.uber.org/mock/gomock" ) // 1️⃣ Declare a suite. type CalculatorSuite struct { suite.Suite } // 2️⃣ Add a test to the suite. func (s *CalculatorSuite) TestAdd() { testsCases := []struct { a int b int want int }{ { a: 10, b: 10, want: 20, }, { a: 1, b: 3, want: 4, }, } for _, tc := range testsCases { // Suite provides many useful functions. // One of them is `T()` which returns the original `testing.T`. s.T().Run(fmt.Sprintf("%d add %d", tc.a, tc.b), func(t *testing.T) { ctrl := gomock.NewController(t) cal := mocks.NewMockCalculator(ctrl) // We are expecting our app to call the `Add` // function on the calculator with the provided arguments // and we return a mock result. cal. EXPECT(). Add(gomock.Eq(tc.a), gomock.Eq(tc.b)). Return(tc.a + tc.b). Times(1) app := myApp{Calculator: cal} result := app.Add(tc.a, tc.b) if result != tc.want { t.Errorf("wanted %d, got %d", tc.want, result) } }) } } // 3️⃣ Run the suite. func TestCalculatorSuite(t *testing.T) { suite.Run(t, &CalculatorSuite{}) }
Run tests.
bash$ go test -v ./... ? github.com/gurleensethi/playground/mocks [no test files] === RUN TestCalculatorSuite === RUN TestCalculatorSuite/TestAdd === RUN TestCalculatorSuite/TestAdd/10_add_10 === RUN TestCalculatorSuite/TestAdd/1_add_3 --- PASS: TestCalculatorSuite (0.01s) --- PASS: TestCalculatorSuite/TestAdd (0.00s) --- PASS: TestCalculatorSuite/TestAdd/10_add_10 (0.00s) --- PASS: TestCalculatorSuite/TestAdd/1_add_3 (0.00s) PASS ok github.com/gurleensethi/playground 0.587s
Require for making assertions #
gofunc (s *CalculatorSuite) TestAdd() { testsCases := []struct { a int b int want int }{ { a: 10, b: 10, want: 20, }, { a: 1, b: 3, want: 4, }, } for _, tc := range testsCases { s.T().Run(fmt.Sprintf("%d add %d", tc.a, tc.b), func(t *testing.T) { ctrl := gomock.NewController(t) cal := mocks.NewMockCalculator(ctrl) // We are expecting our app to call the `Add` // function on the calculator with the provided arguments // and we return a mock result. cal. EXPECT(). Add(gomock.Eq(tc.a), gomock.Eq(tc.b)). Return(tc.a + tc.b). Times(1) app := myApp{Calculator: cal} result := app.Add(tc.a, tc.b) // 👇 s.Require().Equal(result, tc.want) }) } }
Let's say there was bug in the Add
program, you will get an output like following when you run tests.
bash$ go test -v ./... ? github.com/gurleensethi/playground/mocks [no test files] === RUN TestCalculatorSuite === RUN TestCalculatorSuite/TestAdd === RUN TestCalculatorSuite/TestAdd/10_add_10 === NAME TestCalculatorSuite/TestAdd main_test.go:52: Error Trace: /Users/gurleensethi/Developer/Projects/GoProjects/Playground/main_test.go:52 Error: Not equal: expected: 21 actual : 20 Test: TestCalculatorSuite/TestAdd === NAME TestCalculatorSuite/TestAdd/10_add_10 testing.go:1576: test executed panic(nil) or runtime.Goexit: subtest may have called FailNow on a parent test --- FAIL: TestCalculatorSuite (0.01s) --- FAIL: TestCalculatorSuite/TestAdd (0.00s) --- FAIL: TestCalculatorSuite/TestAdd/10_add_10 (0.00s) FAIL FAIL github.com/gurleensethi/playground 0.574s FAIL
If you have reached this point, congratulations now you know a lot about testing in Go and are equipped with writing tests to increase confidense in your software.