Refactored Telegram

It’s all just ones and zeros under the cover

Test Helpers and Test Packages in Go

My previous role was as a Java developer. In Java, a unit test shares the same package as the code being tested; meaning that the test has full access to the private fields and methods that the code has. This is useful for building test helpers; the methods and data structures used for setting up a test, or creating test data.

Unit tests in Go can be written in the same way, and I did this when writing tests for my personal projects1. But since starting a new role as a Go developer on a commercial project, I’ve come around to the approach of using test packages for my unit tests. Writing tests this way is arguably better (more on that below), but I was unsure how best to write the helpers I will need to keep these tests concise.

Test Packages

For those unfamiliar with test packages in Go, here’s a quick primer. Usually in Go, all the source files in a package, including the tests, have to be in the same directory. The tests can also belong to the same package; but doing so can make it possible to violate the idea of unit testing, which is black-box testing of the package interface and behaviour. This is a bit of a slippery slope: as soon as you start writing tests based on how the package is implemented, it makes it difficult to perform the refactoring that is needed to keep a high-quality code-base.

It is for this reason that Go allows for tests to be written in a special test package. These act like a completely separate package: the test code does not have access to any of the private members of the package itself; and can only use the public interface of the package, which it must import like all other external packages. A test package can be defined by simply adding _test to the end of the actual package itself. For example, if the source file all belong to the package network, a test package can be declared with the name network_test can also be defined when running the tests.

This works for ensuring that the principals of black-box testing are observed, but it means these test helpers cannot be defined in these test packages if they need to access private symbols. To do so, they will need to be defined somewhere within the network package itself. But it’s also no good simply including these helpers as part of the public interface of the package. They may make use of testing.T, or do things that should not be available to code using the public in a production setting. Therefore, they must be available to the tests, and only the tests.

The Solution

I previously thought it wasn’t possible to write helpers that satisfy both constraints. I had it in my head that if one test file belonged to a test package, then all the test files had to be in the same test package. I’ve since discovered that this is not the case: unlike the source Go files, test Go files do not need to be of the same package. It is possible to have test files that belong to the network package, and test files that belong to network_test, within the same directory.

So the solution to the problem that I’ve devised is simply to have helper functions declared in a helpers_test.go file, and that belongs in the same package as the Go source files. Each of these helpers would be public, meaning that the test will have access to them. But since they only appear in a file that ends in _test, they will not be part of the public interface.

I’ve used this on the current project I’m working on, and it works great.

Example

To demonstrate this further, here’s a coding example of the particular scenario. Let’s say the particular type I wanted to test is the following:

// bufferedconn.go

package network

type BufferedConn struct {
	conn           net.Conn
	bufferedReader *bufio.Reader
}

func NewBufferedConn(conn net.Conn) *BufferedReader {
	return &BufferedConn{
		conn:           conn,
		bufferedReader: bufio.NewReader(conn),
	}
}

func (bc *BufferedConn) Read(b []byte) (int, error) {
	return bc.bufferedReader.Read(b)
}

func (bc *BufferedConn) Write(b []byte) (int, error) {
	return bc.conn.Write(b)
}

func (bc *BufferedConn) BufferedReader() *bufio.Reader {
	return bc.bufferedReader
}

This is basically a struct providing a buffered reader implementation of a network connection, while providing a method for writing directly to the channel, and a way to get access to the buffered reader directly. An use case for this type might be to read a HTTP request, executes a modified version of the request, and write the response. I’ll leave out how this method would be implemented, but let’s say it will have the following signature:

func ExecuteModifiedRequest(bc *BufferedConn) error {
	// ...
}

The Test

A test for this particular type might look like the following. Note that we want to do proper black-box testing, so we are using a test package here:

// bufferedconn_test.go

package network_test

import (
	"example.com/network"
	"http"
)

func Test_ExecuteModifiedRequest(t *testing.T) {
	var bufferedConn *BufferedConn

	req, _ := http.NewRequest("GET", "http://www.example.com/", nil)
	bufferedConn = newBufferedConnWithRequest(req)

	err := network.ExecuteModifiedRequest(bufferedConn)

	resp := readWrittenHttpResponse(bufferedConn, req)

	if err != nil {
		t.Errorf("Expected error to be nil, but was %v", err)
	}
	if !checkWrittenResponse(bufferedConn) {
		t.Error("Response is incorrect")
	}
}

But here is the problem: how should newBufferedConnWithRequest or readWrittenHttpResponse be written? The conn and bufferedReader fields are private, meaning that they are not available to the test code within network_test.

The Helpers

The solution is to write these as public functions of the network package inside a file with the name helpers_test.go:

// bufferedconn_helper_test.go

package network

import (
	"bufio"
	"bytes"
)

func NewBufferedConnWithRequest(req *http.Request) *BufferedConn {
	outBfr := new(bytes.Buffer)
	inBfr := new(bytes.Buffer)
	req.Write(outBfr)

	return &BufferedConn{
		conn:           &mockedConn{outBfr: outBfr, inBfr: inBfr},
		bufferedReader: bufio.NewReader(outBfr),
	}
}

func ReadWrittenHttpResponse(bufferedConn *BufferedConn, req *http.Request) *http.Response {
	bufReader := bufio.NewReader(bufferedConn.conn.(*mockedConn).inBfr)
	resp, _ := http.ReadResponse(bufReader, req)
	return resp
}

type mockedConn struct {
	outBfr *bytes.Buffer
	inBfr  *bytes.Buffer
}

func (mc *mockedConn) Read(b []byte) (int, error) {
	return mc.outBfr.Read(b)
}

func (mc *mockedConn) Write(b []byte) (int, error) {
	return mc.inBfr.Write(b)
}

// Other implemented net.Conn methods...

Now the test itself looks like the following:

// bufferedconn_test.go

package network_test

import (
	"example.com/network"
	"http"
)

func Test_ExecuteModifiedRequest(t *testing.T) {
	var bufferedConn *BufferedConn
	
	req, _ := http.NewRequest("GET", "http://www.example.com/", nil)
	bufferedConn = network.NewBufferedConnWithRequest(req)
	
	err := network.ExecuteModifiedRequest(bufferedConn)
	
	resp := network.ReadWrittenHttpResponse(bufferedConn, req)
	
	if err != nil {
		t.Errorf("Expected error to be nil, but was %v", err)
	}
	if !checkWrittenResponse(bufferedConn) {
		t.Error("Response is incorrect")
	}
}

I understand that this is a long winded post for explaining a concept and technique that’s relatively trivial. The fact is that this was one last blocker that was preventing me from adopting test packages within my various Go projects. Black-box testing is the proper way to writing unit tests, and being able to do this while also keeping the tests concise means that my test code will improve as a result.


  1. Although, to be honest, this didn’t happen that often. ↩︎