This page looks best with JavaScript enabled

Unit Testing, Test Coverage and CI with Travis in Go

 ·   ·  ☕ 7 min read

You can’t think of deploying your application to production without testing it.
Neither you can manage a large codebase with confidence without it.
Let us go through some basics of unit testing in golang.

This post is structured in the following manner:

  1. unit testing basics in golang (jump)
    • inbuilt code coverage command
  2. understanding subtests and helper function (jump)
  3. Travis CI integration (jump)
    • running test against multiple version of go

Unit Testing in golang

Before I start, I must say that do not test code, test the behavior.
Because code can be written in spaghetti code as well as [clean code][].
Moreover, a unit test gives you confidence in messing up the code to refactor it.

Our hello world code below in the file hello.go:

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

import "fmt"

func Hello(hame string) string {
    return "Hello, " + name + "!"
}

func main() {
    fmt.Println(Hello("Chris"))
}

How do we test it? We’ll create a hello_test.go file in the same directory.

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

import "testing"

// TestHello: By convention this function will be called
// Test + function we're testing.
func TestHello(t *testing.T) {
    got := Hello("Chris")
    want := "Hello, Chris!"

    if got != want {
        t.Errorf("got %q want %q", got, want)
    }
}

Given above is one of the basic kinds of a test function.
One test function is testing a single function in main.go code.

But there might come a time where one func is not enough to cover all the lines code. We’ll see that later, for now, let’s run the test by running go test -v in shell:

$ go test -v
=== RUN   TestHello
--- PASS: TestHello (0.00s)
PASS
ok      _/home/sntshk/repos/unittest     0.004s

I have already talked about test coverage in my last post.
In simple words, test coverage shows how much code is covered by unit tests.

$ go test -cover
PASS
coverage: 100.0% of statements
ok      _/home/sntshk/repos/unittest     0.004s

More on test and coverage

As we can see, at the current state, our code has 100% of coverage by tests, which is a really good sign.
As we add some new features to our Hello function, we’ll see that changing.

I’ve changed hello.go to print “Hello, World!” when no name is passed:

+const prefix = "Hello, "
+
 func Hello(name string) string {
-       return "Hello, " + name + "!"
+       if name == "" {
+               name = "World"
+       }
+       return prefix + name + "!"
 }

Let’s run the test coverage again:

$ go test -cover
PASS
coverage: 66.7% of statements
ok      _/home/sntshk/repos/unittest     0.003s

All of a sudden our tests are only covering 66% of the code.
This is because our if name == "" section is not being covered by any test.
Let’s write some test cases and run the coverage again to check the status.

Subtests

In our test, we can extend our same test function with the use of subtests. If you are coming from Python world, you can relate it to an actual instance method you write for unittest.TestCase inherited class.

I will refactor and extend the test:

17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func TestHello(t *testing.T) {
    t.Run("saying hello to people", func(t *testing.T) {
        got := Hello("Chris")
        want := "Hello, Chris"

        if got != want {
            t.Errorf("got %q want %q", got, want)
        }
    })

    t.Run("say 'Hello, World' when an empty string is supplied", func(t *testing.T) {
        got := Hello("")
        want := "Hello, World!"

        if got != want {
            t.Errorf("got %q want %q", got, want)
        }
    })
}

Here I’ve used t.Run which takes name of the test as the first argument.
The second argument is a closure function. It is the function where the test body exists.
Name of the test is visible when you run the test. You’ll see this text in next test run.

Subtests are also useful when multiple tests cases require same prequisites or post-requisites.
In some languages this can be achieved by using setup and teardown functions/methods.
In golang the code before first t.Run is setup and code after last t.Run teardown.
Which I personally find beautiful.

Let’s see how our tests are doing.

$ go test -v
=== RUN   TestHello
=== RUN   TestHello/saying_hello_to_people
=== RUN   TestHello/say_'Hello,_World'_when_an_empty_string_is_supplied
--- PASS: TestHello (0.00s)
    --- PASS: TestHello/saying_hello_to_people (0.00s)
    --- PASS: TestHello/say_'Hello,_World'_when_an_empty_string_is_supplied (0.00s)
PASS
ok      _/home/sntshk/repos/unittest     0.003s

Another reason to name test runners are that you can you can only run those runners by passing it to -run. As shown below:

$ go test -v -run=TestHello/saying_hello_to_people
=== RUN   TestHello
=== RUN   TestHello/saying_hello_to_people
--- PASS: TestHello (0.00s)
    --- PASS: TestHello/saying_hello_to_people (0.00s)
PASS
ok      _/home/sntshk/repos/unittest     0.004s

Let’s see the coverage.

$ go test -cover
PASS
coverage: 100.0% of statements
ok      _/home/sntshk/repos/unittest     0.003s

Our coverage is back up again.

As an end note for coverage, 100% test coverage is not always possible.
But don’t just simply ignore it. But more is good.
You can tell the effectiveness of the tests in the increasing codebase.

Helper Functions

We can also use the helper function to dry up the code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func TestHello(t *testing.T) {
    assertCorrectMessage := func(t *testing.T, got, want string) {
        t.Helper()
        if got != want {
            t.Errorf("got %q want %q", got, want)
        }
    }

    t.Run("saying hello to people", func(t *testing.T) {
        got := Hello("Chris")
        want := "Hello, Chris"
        assertCorrectMessage(t, got, want)
    })

    t.Run("empty string defaults to 'World'", func(t *testing.T) {
        got := Hello("")
        want := "Hello, World"
        assertCorrectMessage(t, got, want)
    })

}

We must use t.Helper() is those such functions. These helper functions can also reside outsite the main test function e.g. outside TestHello in this case.

Continuous Integration with Travis CI

Continuous Integration is the practice of merging in small code changes frequently -
rather than merging in a large change at the end of a development cycle.
The goal is to build healthier software by developing and testing in smaller increments.
This is where Travis CI comes in. This is not the only tool in the market.

When you run a build, Travis CI clones your GitHub repository into a brand-new virtual environment, and carries out a series of tasks to build and test your code. If one or more of those tasks fail, the build is considered broken. If none of the tasks fail, the build is considered passed and Travis CI can deploy your code to a web server or application host.

Travis-CI Docs

At this point you should have already pushed your repo to some remote like GitHub or GitLab.

The minimum .travis.yml should must have the language and script to run for test.

1
2
3
language: go

script: go test -v

This is what happens when you git commit and git push your code.

Passing test on Travis

Passing test on Travis

I consider adding some customizations for the sake of my optimization OCD.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
language: go

go:
  - 1.14

git:
  depth: 1  # default is 50
  quiet: true

script: go test -v

Builds can also fail. Below is an example of a failed build.

Failed test on Travis

Failed test on Travis

The build is considered failed when a command in the script phase returned non-zero exit code.

Run test against multiple version of go

To test against multiple go versions, I will pass more version to go key. This works the same way in other languages.

3
4
5
go:
  - 1.13
  - 1.14

Below is how it looks on Travis Dashboard. Our build launched 2 Jobs for each version.

Build with 2 Jobs on Travis

Build with 2 Jobs on Travis

There are many more things a CI provider can do. Like you can push a docker image if your test passes. Or you can deploy to Elastic Beanstalk or another cloud provider on a passed test. Or you can launch builds on each pull request which is sent on your public repo.

This is just to scratch the surface. I will post more as I use this service.

More information on enabling Travis in your repo is available at https://docs.travis-ci.com/user/tutorial/

Share on

Santosh Kumar
WRITTEN BY
Santosh Kumar
Fullstack Developer at Method Studios