Skip to content
gohacks.dev
Testing

Split Integration Tests From Unit Tests With Build Tags

Keep Go integration tests out of the default run with a //go:build integration tag, run them with -tags integration and wire both into separate CI jobs.

7 min read
Summarize in AI
Flat illustration of a test report with one failing test.

Unit tests should be quick, hermetic and runnable by anyone who clones the repository. Integration tests talk to a real database, a queue or a service, and they need something the cloner may not have. Putting both in one go test ./... makes the first kind pay for the second: the run is slow, or it fails on a laptop with no database. The build tag is the oldest and most reliable way to separate them.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article sets up the split on Go 1.25.6, shows what each mode compiles and runs, and covers the details that cause trouble later: the skip inside the tagged file, the CI jobs, and the blind spot a tag creates.

The split

store.goGo
package store

import "os"

// Dsn reads the database address the integration test needs.
func Dsn() string { return os.Getenv("DATABASE_URL") }
unit_test.goGo
package store

import "testing"

func TestDsnUnit(t *testing.T) { t.Log("unit test, no database needed") }
integration_test.goGo
//go:build integration

package store

import "testing"

func TestDsnIntegration(t *testing.T) {
	if Dsn() == "" {
		t.Skip("DATABASE_URL not set")
	}
	t.Logf("would connect to %s", Dsn())
}

The first line of the integration file, //go:build integration, is a build constraint. A file whose constraint is false is excluded from the package, as though it did not exist. The line must come before the package clause with a blank line after it, and it must be exactly this form: the old // +build syntax still works but go vet flags a mismatch. integration is an ordinary tag name; it is not special to Go, and any word works, as long as everyone agrees on it.

What each mode does

Shell
F='s/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: #    #'
echo "# default: the integration file is not even compiled"
go test -count=1 -v . 2>&1 | grep -E '^(--- |ok)' | sed -E "$F; s/\t[0-9.]+s$//"
echo; echo "# -tags integration, no database"
go test -count=1 -v -tags integration . 2>&1 | grep -E '(^--- |ok|skip|DATABASE)' | sed -E "$F; s/\t[0-9.]+s$//"
echo; echo "# -tags integration, with DATABASE_URL"
DATABASE_URL=postgres://localhost/test go test -count=1 -v -tags integration . 2>&1 | grep -E '(^--- |would|ok)' | sed -E "$F; s/\t[0-9.]+s$//"
echo; echo "# go list shows which files each mode builds"
go list -f '{{.TestGoFiles}}' . ; go list -tags integration -f '{{.TestGoFiles}}' .
Text
# default: the integration file is not even compiled
--- PASS: TestDsnUnit
ok  	example.test/lab

# -tags integration, no database
    DATABASE_URL not set
--- SKIP: TestDsnIntegration
--- PASS: TestDsnUnit
ok  	example.test/lab

# -tags integration, with DATABASE_URL
    would connect to postgres://localhost/test
--- PASS: TestDsnIntegration
--- PASS: TestDsnUnit
ok  	example.test/lab

# go list shows which files each mode builds
[unit_test.go]
[integration_test.go unit_test.go]

Default. go test -v . ran only TestDsnUnit. The integration file was not compiled, which matters beyond speed: its imports are not needed, so a package that the integration test imports (a database driver, a container library) does not have to be in the module graph for the unit run. Dependencies only the tagged files use still appear in go.mod, but the build never touches them.

Tag set, no database. With -tags integration the second test is compiled and found, and it skipped itself: DATABASE_URL not set. That guard is deliberate. A tag tells the toolchain that you want integration tests; it does not tell you the dependency is running. Without the check, the test would try to connect to an empty address and fail with a connection error, which reads as a bug in the code. A skip with a reason is clearer for a developer who typed -tags integration out of habit, and a CI job can still fail on skips if it needs to (see below).

Tag set, with the address. With DATABASE_URL set, the test ran and logged would connect to postgres://localhost/test. In a real file it would open the connection and run queries.

What the compiler saw. The last block uses go list -f '{{.TestGoFiles}}' to show the set of test files in each mode: [unit_test.go] and then [integration_test.go unit_test.go]. This command is the quickest way to debug a tag that is not doing what you expect. If a file you think is included is missing, the list says so.

Wiring it into a project

A Makefile makes the two modes the obvious commands:

Shell
test:
	go test ./...

test-integration:
	go test -tags integration -count=1 ./...

-count=1 belongs on the integration target, because the test cache does not know the state of your database, as the cache article explains. In CI, run the two as separate jobs. The unit job runs on every push and has no services. The integration job starts the dependency, for example with a service container or testcontainers, sets the environment variable, and runs with the tag. A failure in either job points at a different kind of problem.

Put the tag on the files, not on whole packages, unless a package contains nothing else. A mixed package can keep its unit tests in service_test.go and its integration tests in service_integration_test.go. The file name suffix is only a convention that helps people find them, and the constraint line is what does the work. Do not name a file *_integration.go without _test, because an unusual name before _test.go can look like a GOOS or GOARCH constraint: only known OS and architecture names do, so integration is safe, but linux or arm64 in a file name would constrain the build.

Do not let the skips pile up

A test that skips when its dependency is missing can pass silently for months in a CI job whose container never started. The failure is invisible: the job is green, the log says SKIP, and nobody reads it. Two defences.

  1. In the CI integration job, set a variable, say REQUIRE_INTEGRATION=1, and have the helper call t.Fatal instead of t.Skip when it is set. A missing dependency then fails the job.
  2. Count the skips: run with -json and fail the job if any test with the integration prefix reported skip. The JSON form is described in the JSON output note.

Tag or -short?

They answer different questions. A build tag chooses which code is compiled; -short chooses which compiled tests run. testing.Short() is convenient for a few slow tests in a mostly fast package, but it relies on each test remembering to check, and a test that forgets runs in the fast mode and makes it slow. A tag cannot be forgotten, since the file is not there. Use tags for the category (anything needing an external service) and -short for the long tail of slow-but-self-contained tests. The short and skip note covers the second.

The blind spot

A tag hides the file from the default build, and that includes go vet, the compiler, the linters and go build ./.... An integration test file can fail to compile for weeks and nobody knows until the tagged job runs. Prevent that two ways: run go vet -tags integration ./... in the default CI job, which type-checks the tagged files without running them, and make the integration job required for merging. Golangci-lint has a build-tags setting for the same reason.

Also consider putting integration tests in a separate package or directory instead of a tag. A directory like internal/integration with its own tests is excluded by go test ./internal/... patterns only if you choose, and everything in it always compiles. Its drawback is that it cannot test unexported code. Both layouts work; pick one and keep to it.

Where this does not apply

If your integration tests are fast and hermetic (SQLite in memory, an httptest server), they are unit tests by cost, and a tag only makes them easier to forget. Keep the split for tests that need something external. The syntax is defined in the Go command documentation, and the package-level rules are in the go/build documentation, and the surrounding practice is in the Go testing guide.

Also inTooling
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.