【Golang】Test - Unit Test

Posted by 西维蜀黍 on 2021-07-28, Last Modified on 2021-10-17

Unit Tests

Typically, the code we’re testing would be in a source file named something like intutils.go, and the test file for it would then be named intutils_test.go.

package main

import (
    "fmt"
    "testing"
)

func IntMin(a, b int) int {
    if a < b {
        return a
    }
    return b
}

// A test is created by writing a function with a name beginning with Test.
func TestIntMinBasic(t *testing.T) {
    ans := IntMin(2, -2)
    if ans != -2 {

        t.Errorf("IntMin(2, -2) = %d; want -2", ans)
    }
}

Run all tests in the current project in verbose mode.

$ go test -v

Suite

The Run methods of T allows defining subtests, without having to define separate functions for each. This enables uses like table-driven benchmarks and creating hierarchical tests. It also provides a way to share common setup and tear-down code:

func TestFoo(t *testing.T) {
    // <setup code>
    t.Run("A=1", func(t *testing.T) { ... })
    t.Run("A=2", func(t *testing.T) { ... })
    t.Run("B=1", func(t *testing.T) { ... })
    // <tear-down code>
}

Each subtest and sub-benchmark has a unique name: the combination of the name of the top-level test and the sequence of names passed to Run, separated by slashes, with an optional trailing sequence number for disambiguation.

Mock

Let’s take an imaginary scenario where we need to read the first N bytes from an io.Reader and return them as a string. It would look something like this:

readn.go
// readN reads at most n bytes from r and returns them as a string.
func readN(r io.Reader, n int) (string, error) {
	buf := make([]byte, n)
	m, err := r.Read(buf)
	if err != nil {
		return "", err
	}
	return string(buf[:m]), nil
}

Obviously, the main thing to test is that the function readN, when given various input, returns the correct output. This can be done with table testing. But there are two other non-trivial aspects we should cover, which are checking that:

  • r.Read is called with a buffer of size n.
  • r.Read returns an error if one is thrown.

In order to know the size of the buffer that is passed to r.Read, as well as take control of the error that it returns, we need to mock the r being passed to readN. If we look at the Go documentation on type Reader, we see what io.Reader looks like:

type Reader interface {
	   Read(p []byte) (n int, err error)
}

That seems rather easy. All we have to do in order to satisfy io.Reader is have our mock own a Read method. So our ReaderMock can be as follows:

type ReaderMock struct {
	ReadMock func([]byte) (int, error)
}

func (m ReaderMock) Read(p []byte) (int, error) {
	return m.ReadMock(p)
}

Let’s analyze the above code for a little bit. Any instance of ReaderMock clearly satisfies the io.Reader interface because it implements the necessary Read method. Our mock also contains the field ReadMock, allowing us to set the exact behavior of the mocked method, which makes it super easy for us to dynamically instantiate whatever we need.

A great memory-free trick for ensuring that the interface is satisfied at run time is to insert the following into our code:

var _ io.Reader = (*MockReader)(nil)

This checks the assertion but doesn’t allocate anything, which lets us make sure that the interface is correctly implemented at compile time, before the program actually runs into any functionality using it. An optional trick, but helpful.

Moving on, let’s write our first test, in which r.Read is called with a buffer of size n. To do this, we use our ReaderMock as follows:

func TestReadN_bufSize(t *testing.T) {
	total := 0
	mr := &MockReader{func(b []byte) (int, error) {
		total = len(b)
		return 0, nil
	}}
	readN(mr, 5)
	if total != 5 {
		t.Fatalf("expected 5, got %d", total)
	}
}

As you can see above, we’ve defined the behavior for the Read function of our “fake” io.Reader with a scope variable, which can be later used to assert the validity of our test. Easy enough.

Let’s look at the second scenario we need to test, which requires us to mock Read to return an error:

func TestReadN_error(t *testing.T) {
	expect := errors.New("some non-nil error")
	mr := &MockReader{func(b []byte) (int, error) {
		return 0, expect
	}}
	_, err := readN(mr, 5)
	if err != expect {
		t.Fatal("expected error")
	}
}

In the above testing, any call to mr.Read (our mocked Reader) will return the defined error, thus it is safe to assume that the correct functioning of readN will do the same.

Reference