When unit tests aren't enough
You write a test that connects to a real PostgreSQL database. You run go test and the build fails because the database isn't running on your laptop. You fix the test by mocking the database, but now you have no way to verify the actual SQL queries work against a real engine. You need a mechanism to tell the compiler: "Include this file only when I explicitly ask for it."
Build tags solve this by letting you exclude files from the default build. They keep your standard test suite fast and focused on logic, while keeping integration tests available for when you need to verify external dependencies.
Build tags as compile-time filters
Build tags act as constraints on the Go toolchain. They sit at the top of a source file and tell the compiler whether to include that file in the current build. Think of a build tag as a bouncer at a club. The compiler is the guest. If the guest doesn't have the right tag on their wrist, the bouncer turns them away and the file never gets compiled.
Build tags are evaluated at compile time, not runtime. The compiler decides which files to process before it even looks at the code inside them. This means you can have heavy imports in your integration test file without slowing down your unit test suite. The compiler only processes those imports when the tag is active. This is crucial for tests that import database drivers or gRPC clients. Those packages can be large and slow to compile. By hiding them behind a build tag, you keep your unit test suite snappy.
Build tags are compile-time filters, not runtime switches.
The minimal integration test
Here's the simplest way to mark a file for integration testing. The directive goes at the very top, before the package declaration.
//go:build integration
package mypackage
import "testing"
// TestIntegration connects to a real service and verifies behavior.
func TestIntegration(t *testing.T) {
// Test code here
}
The //go:build integration line tells the compiler to skip this file unless the integration tag is active. The package mypackage declaration defines the package. The import "testing" statement brings in the test framework. The TestIntegration function contains the test logic.
Run the tests with the tag enabled:
go test -tags=integration ./...
The -tags=integration flag activates the tag. The ./... pattern runs tests in the current directory and all subdirectories. Without the flag, the file is invisible to the compiler.
How the compiler processes tags
When you run go test, the toolchain scans every .go file in the directory. It reads the build constraints at the top of each file. If a file has a constraint that isn't met, the compiler treats the file as if it doesn't exist. The file is skipped entirely. No parsing, no type checking, no compilation.
This skipping happens before the compiler checks for syntax errors or unused imports. You can have code in an integration test file that would normally fail to compile, and it won't matter as long as the tag is inactive. The compiler ignores what it doesn't see.
This behavior also affects tools like go vet and gofmt. These tools respect build tags. If a file is excluded by a tag, go vet won't analyze it and gofmt won't format it unless you pass the tag to the tool. This keeps your development workflow consistent. You don't have to worry about integration test code interfering with your unit test builds.
Realistic example with database connection
Real integration tests usually need external resources. Here's a test that connects to a local database. The file is excluded from normal builds, so you don't need the database running to compile your library.
//go:build integration
package db
import (
"context"
"testing"
"time"
)
// TestDBConnection verifies the database driver works against a live instance.
func TestDBConnection(t *testing.T) {
// Context with timeout prevents the test from hanging forever.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Connect to the database using environment variables.
// The connection string comes from the environment, not hardcoded.
conn, err := Connect(ctx, "postgres://localhost/testdb")
if err != nil {
t.Fatalf("failed to connect: %v", err)
}
// Close the connection when the test finishes.
defer conn.Close()
// Ping the database to verify the connection is alive.
if err := conn.Ping(ctx); err != nil {
t.Fatalf("ping failed: %v", err)
}
}
The //go:build integration directive excludes the file from default builds. The import block brings in necessary packages. The TestDBConnection function verifies the database driver. The context.WithTimeout call prevents the test from hanging. The defer cancel() call cleans up the context. The Connect call uses environment variables. The if err != nil block checks for errors. The defer conn.Close() call cleans up the connection. The Ping call verifies liveness.
Convention aside: context.Context always goes as the first parameter in Go functions. The Connect function follows this convention. The ctx parameter is named ctx by convention. Functions that take a context should respect cancellation and deadlines.
Complex constraints and CI/CD
You can combine tags using boolean logic. The syntax supports &&, ||, and !. This lets you create complex constraints. For example, you might want to run integration tests only on Linux, or skip them in CI.
//go:build integration && linux
package mypackage
This file is included only if both integration and linux tags are active. You can also negate tags.
//go:build integration && !ci
package mypackage
This file is included if integration is active but ci is not. This is useful for tests that require interactive input or local resources that aren't available in continuous integration.
In continuous integration, you need to run integration tests automatically. Here's how to configure a GitHub Actions workflow to run them.
name: Test
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Run unit tests
run: go test ./...
- name: Run integration tests
run: go test -tags=integration ./...
The name: Test line names the workflow. The on: [push] line triggers on push. The jobs: block defines jobs. The test: block names the job. The runs-on: line sets the runner OS. The steps: block lists steps. The uses: actions/checkout@v4 step checks out code. The uses: actions/setup-go@v5 step sets up Go. The with: block configures Go version. The - name: Run unit tests step names the unit test step. The run: go test ./... command runs unit tests. The - name: Run integration tests step names the integration test step. The run: go test -tags=integration ./... command runs integration tests.
This configuration runs unit tests first. If unit tests fail, integration tests don't run. This saves time. The -tags=integration flag ensures integration tests run only when explicitly requested.
Pitfalls and silent failures
The most common mistake is forgetting to pass the tag when running the tests. If you run go test ./... without -tags=integration, the integration test file is invisible. The test suite passes, but you haven't actually run the integration checks. The compiler gives no warning about skipped files. You have to trust your CI configuration to run the correct command.
Another trap is syntax errors in the directive. The //go:build line must be at the top of the file, usually preceded only by blank lines or comments. If you put it after the package declaration, the compiler rejects the file with //go:build must be at the top of the file.
Also, avoid using build tags for feature flags in production code. Tags are for build configuration, not runtime behavior. Use environment variables or configuration files for runtime decisions. Build tags change the compiled binary. Runtime config changes behavior without recompiling.
Convention aside: Go 1.17+ uses //go:build. Older code uses // +build. The new syntax is preferred. Stick to //go:build for all new code. The community expects //go:build at the top. Don't mix old and new syntax.
The worst goroutine bug is the one that never logs. The worst integration test is the one that never runs.
When to use build tags
Use build tags when you need to exclude files from the default build to keep compilation fast. Use a separate package for integration tests when the test setup requires complex initialization that conflicts with the main package. Use environment variables to configure test targets when the integration test needs to connect to different services in different environments. Use standard unit tests with mocks when you want to verify logic without external dependencies.