So far, we have discussed white- and black-box tests, integration, and end-to-end tests. By including tests from all these categories in our projects, we can rest assured that the code base will behave as expected in a multitude of different scenarios.
Now, imagine we are working on a particular feature and we only want to run the unit tests. Alternatively, we may only need to run the integration tests to ensure that our changes do not introduce regression to other packages. How can we do that?
The rather simplistic approach would be to maintain separate folders for each test category, but that would veer away from what is considered to be idiomatic Go. Another alternative would be to add the category name as a prefix or suffix to our tests and run go test with the -run flag (or with the -check.f flag if we are using a third-party package such as gocheck [3]) to only run the tests whose names match a particular regular expression. It stands to reason that while this approach will work, it's quite error-prone; for larger code bases, we would need to compose elaborate regular expressions that might not match all the tests that we need to run.
A smarter solution would be to take advantage of Go's support for conditional compilation and repurpose it to serve our needs. This is a great time to explain what conditional compilation is all about and, most importantly, how it works under the hood.
When a package is being built, the go build command scans the comments inside each Go file, looking for special keywords that can be interpreted as compiler directives. Build tags are one example of such an annotation. They are used by go build to decide whether a particular Go file in a package should be passed to the Go compiler. The general syntax for a build tag is as follows:
To be correctly recognized by go build, all the build tags must appear as a comment at the top of a Go file. While you are allowed to define multiple build tags, it is very important that the last build tag is separated with a blank (non-comment) line from the package name declaration. Otherwise, go build will just assume that the build tag is part of a package-level comment and simply ignore it. Software engineers that are new to the concept of Go build tags occasionally fall into this trap, so if you find yourself scratching your head, wondering why build tags are not being picked up, the lack of a blank line after the build tag is the most likely suspect.
Let's take a closer look at the intricacies of the tag syntax and elaborate on the rules that are applied by go build to interpret the list of tags following the +build keyword:
- Tags separated by whitespace are evaluated as a list of OR conditions.
- Tags separated by a comma are evaluated as a list of AND conditions.
- Tags beginning with ! are treated as NOT conditions.
- If multiple +build lines are defined, they are joined together as an AND condition.
The go build command recognizes several predefined tags for the target operating system (for example, linux, windows, darwin), CPU architecture (for example, amd64, 386, arm64), and even the version of the Go compiler (for example, go1.10 to specify Go 1.10 onward). The following table shows a few examples that use tags to model complex build constraints.
Build Target Scenario | Build tag |
Only when the target is Linux | linux |
Linux or macOS | linux darwin |
x64 targets but only with Go compiler >= 1.10 | amd64,go1.10 |
32-bit Linux OR 64-bit all platforms except OS X | linux,386 amd64,!darwin |
By now, you should have a better understanding of how build tags work. But how does all this information apply to our particular use case? First of all, let me highlight the fact that test files are also regular Go files and, as such, they are also scanned for the presence of build tags! Secondly, we are not limited to the built-in tags – we can also define our own custom tags and pass them to go build or go test via the -tags command-line flag.
You can probably see where I am going with this… We can start by defining a build tag for each family of tests, for example, integration_tests, unit_tests, and e2e_tests. Additionally, we will define an all_tests tag since we need to retain the capability to run all the tests together. Finally, we will edit our test files and add the following build tag annotations:
- +build unit_tests all_tests to the files containing the unit tests
- +build integration_tests all_tests to the files containing the integration tests
- +build e2e_tests all_tests to the files containing the end-to-end tests
If you wish to experiment with the preceding example, you can check out the contents of the Chapter04/buildtags package.