Testing in Go - a crash article to get you going

Read more articles on Go
Testing in Go - a crash article to get you going

Subscribe

Get updated 1 - 2 times a month when new articles are published, no spam ever.

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.

 
go
package 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.

 
go
package 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.

 
go
package 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.

 
go
func 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.

 
go
package 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.

 
go
package 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.

 
go
package 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.

 
go
package 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.

 
go
package 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

 
go
package 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.

 
go
package 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.

 
go
package 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 #

 
bash
go install go.uber.org/mock/mockgen@latest

Generate mocks for the Calculator interface and store it under mocks/calculator.go.

 
bash
mockgen -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.

 
bash
go get go.uber.org/mock/gomock

You can now rewrite the tests using the MockCalculator from mocks/calculator.go.

 
go
package 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 suites.

Let's rewrite our tests using testify.

Install testify package.

 
bash
go get github.com/stretchr/testify

Test Suite #

Rewrite the test to use a suite.Suite.

 
go
package 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 #

 
go
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 { 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.

Subscribe

Get updated 1 - 2 times a month when new articles are published, no spam ever.

    TheDeveloperCafe © 2022-2024