Go is a modern programming language and as such was designed with incredible testing support out of the gate. Project and file organization combined with official tooling and a lean but powerful (and also official) testing library: that's all you need to know to get started.
Let's start from the top: the
go test command
Go’s standard library already has everything you need and there’s a go test command included in its binary that, by default, picks up all test files in a given path and executes all test cases.
Test files are expected to be located within the same package of the code they are testing. It doesn’t have to be like this, but this colocation is a well-liked and followed a pattern. For one, it makes it easy to know where tests for any given package are, instead of relying on a single test or specs directory without any enforceable structure.
There’s also the possibility of customizing the setup and teardown for all tests in each package, which is a handy feature when creating integration tests that depend on starting and shutting down servers or connections, for example.
Each function that starts with the prefix Test and receives a single *testing.T argument is a different test case. As we’ll see further, multiple test cases for the same function are encouraged to be colocated in a tabular structure.
Related to this structure, it’s possible to run the tests for an entire project, tests for a single package, or even tests that match a specific name pattern.
One way to approach testing in Go is by creating a new function for every test case. But rare are the situations where you’re comfortable with testing a single (input, output) tuple.
For that reason, a well used and recommended pattern to test functions is through tables where you describe several (input, output) tuples and check all of them in the same loop.
To paint a proper picture, imagine yourself creating a function that takes a time value and returns a human-readable indication of time passed since right now. What are some test cases for it? Here’s some I could think of:
A good case for table testing is the ease of adding and checking new behavior. If you’re into TDD, it feels organic to write your function in an iterative way, you could add the barebones return “now” function and keep adding new time units, seconds, minutes, days, etc.
On the other hand, all test cases rely on the same inner loop code. This can become especially challenging to maintain with complex integration test cases where side effects are expected for some table rows but not others.
White box vs Black box testing
You saw the tests — behold the code:
There’s a public function called Since and that’s that. When we rely only on the public interface of a package on our tests, that’s what we call black box testing. Black box in the sense that we see nothing behind the public interface.
To guarantee that you’re really in a black box, Go allows you to have one extra package name under the same directory, that’s the same name of the real package with the _test suffix.
One problem when testing things that depend on time is that the test cases might not always pass, depending on how much time it took in each run. In our case, if we had many, many cases in the table, it could be that time.Now() was already delayed by one second or more when the loop reached the last one.
So, to illustrate what a white box test is, let us create a private function that receives a second argument:
Now both files belong to the same package, and we have the guarantee that we’re always comparing to a controlled now value. White box testing has no assurances that the tester isn’t playing with the private, or inner workings, of the test target, for good or bad. In some situations, it might be very handy or even desirable, as in the example above.
In most cases, however, you should stick with black box testing. They are the only ones that can guarantee the public interface, the one your clients will use, has the expected behavior independently of knowing, and even mutating, private state.
Unit vs Integration testing
The example we had so far is a good representation of what a unit test is. It depends only on the software code base itself. However, it’s really common for software to engage in external communications and talk with other software.
It’s very possible that you’ll write gRPC/HTTP calls, queries to database systems, and other things of this sort. One way of taking these code paths into account when testing is through mocks.
A good way to encapsulate code that makes external calls in Go is by using interfaces. Then you could have an actual implementation that talks with the real thing and either write a mock yourself or use an automatic tool like go mock. This way you can still rely on unit tests for the rest of the code but at the cost of not knowing whether the actual integration would work as expected.
It’s important to note that when you rely extensively on mocking interfaces you’re not testing the actual integration, this is in reality an effective bypass of it. So mocks are good to create unit tests in addition to integration ones, especially when checking many scenarios in integration tests prove too cumbersome.
Integration tests have incredible value, and you should prefer them almost always. Unit testing is relevant when specific bits of business logic are highly complex and testing them separate requires substantially less preparation than through integration.
So as the last piece of interesting information for this article I would like to mention tags. Go provides a simple and efficient mechanism of tagging code files in order to execute them only when one wants.
Since the file above has the integration tag in it, it’ll only run through go test if we include the flag -tags=integration. This way, only unit tests, that require nothing but the code to execute, run by default, due to having no tags associated.
Now you know the most important bits there is to know about testing in Go, but this is still far from all there is. Testing is an interesting craft with many colors, and much of it isn’t specific to Go. Give it a try!