Getting Started with Unit Tests in Go

📅 Posted: Dec 6, 2020

Welcome Gophers, in this first video of the series, we are going to be covering the basics of getting started testing your Go applications.

Why Do We Test?

So, first of all, why do we test our applications? This might seem like a simple question to answer but it’s good to reaffirm the exact reasons as to why we test before we go down the wrong path and start developing tests that don’t provide us any real value.

When we write tests, the one thing we are wanting to do is to validate that our application works the way we want it to.

We aren’t writing tests to hit some meaningless code-coverage statistic as this can be fairly easy to “game”. Instead, we are wanting to write tests that directly correlate to how people will be using that system or piece of code.

Types of Tests

When it comes to the types of testing we can do, there are a few different types that we should be aware of and they should be separated into distinct files.

  • Unit Tests - These are the lowest level tests that validate that at a base level, a function can take in an input and produce the correct output.
  • Integration Tests - These are slightly more involved tests that cover a wider aspect of your code. They cover a particular use-case within your system, but they don’t talk to upstream or downstream systems. These dependencies are often mocked to simulate how our system would react given mocked responses from other systems.
  • End-2-End Tests - These are the most involved tests that require the most effort to setup and tear down.

As you go down the scale the tests require more and more effort to properly implement. However, the value that they provide is far greater as you are able to accurately model and validate that your system handles the demands that your users typically place on it.

Running Tests in Go

As we dive deeper into the course, we’ll be covering more in-depth theory and going deeper into these topics. For now though, let’s start with the basics and start writing our first tests in Go.

Let’s consider we were building a package in Go that required you to create a function that checked to see if a number was an Armstrong number or not.

Note - An Armstrong number is a 3-digit number such that each of the sum of the cubes of its digits equal the number itself: 371 = 3^3 + 7^3 + 1^3

package calculator

import "math"

// CalculateIsArmstrong takes in a 3 digit number 'n'
// and returns true if it is an Armstrong number
// Armstrong number example 371 == 3^3 + 7^3 + 1^3
func CalculateIsArmstrong(n int) bool {
	a := n / 100
	b := n % 100 / 10
	c := n % 10
	return n == int(math.Pow(float64(a), 3)+math.Pow(float64(b), 3)+math.Pow(float64(c), 3))
}

Let’s consider how we would test this.

We know for sure that 371 is indeed an Armstrong number. Let’s start our journey by adding a simple unit test to validate what we know for sure:

package calculator_test

import (
	"testing"

	"github.com/TutorialEdge/go-testing-bible/calculator"
)

type TestCase struct {
	value    int
	expected bool
	actual   bool
}

func TestCalculateIsArmstrong(t *testing.T) {
	testCase := TestCase{
		value:    371,
		expected: true,
	}

	testCase.actual = calculator.CalculateIsArmstrong(testCase.value)
	if testCase.actual != testCase.expected {
		t.Fail()
	}
}

In this example, we’ve been a little more verbose than we have to be, but for people coming in to the project and understanding what is going on, this added verbosity can help them quickly get up to speed with what the tests are doing.

With this in place, let’s attempt to run our tests now:

$ go test ./...

This ./... notation at the end of our test command denotes that we want to run all of the tests within our Go package. Upon running this, you should see the results of our tests and you will see that our test has passed and we’ve validated to some degree that our program works as intended.

Negative Test Cases

So we’ve validated that our function returns true when we expect it to turn true, however we also need to validate the negative cases to ensure our function isn’t returning true when in fact it should be false.

Let’s open up the calculator_test.go file and below where we have our positive test case, let’s add a new test:

package calculator_test

import (
    "testing"
    
    "github.com/TutorialEdge/go-testing-bible/calculator"
)

type TestCase struct {
    value int
    expected bool
    actual bool
}

func TestCalculateIsArmstrong(t *testing.T) {
    testCase := TestCase{
        value: 371,
        expected: true,
    }

    testCase.actual = CalculateIsArmstrong(testCase.value)
    if testCase.actual != testCase.expected {
        t.Fail()
    }
}

func TestNegativeCalculateIsArmstrong(t *testing.T) {
    testCase := TestCase{
        value: 372,
        expected: false,
    }

    testCase.actual = CalculateIsArmstrong(testCase.value)
    if testCase.actual != testCase.expected {
        t.Fail()
    }
}

Once again, we’ve follow the same format when writing a new test. We’ve started by prefixing our function name with Test... and we’ve passed in a pointer to testing.T as the parameter.

Within the body of our test, we’ve reused the same format as our positive test case but just changed the testCase fields.

With this in place, let’s run our tests again and check to see if they pass:

$ go test ./...

Awesome, we’ve been able to successfully validate to some degree that our application is working as intended. This has given us the a minimum level of confidence that if we make changes to our code and our tests work, we haven’t inadvertently broken our application.

Throughout the rest of this course, I’m going to be enabling you to write better tests by showing you a number of different strategies that you can employ when it comes to testing your own applications. In the next tutorial, we are going to be looking at how we can start working with subtests.