How to Write Integration Tests in Go

Write Go integration tests by creating Test functions in _test.go files that verify interactions between components or external systems.

The gap between unit tests and reality

You write a function that saves a user to a database. The unit test passes because you mocked the database driver. You deploy to staging. The real database rejects the insert because of a unique constraint you missed in the mock. The test didn't catch it because the mock was too perfect. It accepted everything without checking the rules the real system enforces.

Integration tests bridge that gap. They run your code against real dependencies to prove the pieces fit together. Instead of a mock that always returns success, you use a real file system, a real HTTP server, or a real database instance. The test exercises the boundary where your code meets the outside world. This catches bugs in serialization, network protocols, file permissions, and driver behavior that unit tests cannot see.

Unit tests verify logic in isolation. Integration tests verify interaction. You need both. Unit tests give you speed and precision. Integration tests give you confidence that the system actually works when deployed.

Integration tests prove the system works, not just the parts.

Minimal integration test

Here's the skeleton of an integration test: create a real resource, use it, clean up. This example writes to a temporary file and reads it back. It exercises the operating system's file system, not just memory buffers.

package main

import (
	"os"
	"testing"
)

// TestFileRoundtrip verifies writing and reading a temporary file works end-to-end.
func TestFileRoundtrip(t *testing.T) {
	// Create a temp file in the OS default temp dir.
	// The pattern ensures unique names to avoid collisions between parallel tests.
	f, err := os.CreateTemp("", "integration-*.txt")
	if err != nil {
		t.Fatal(err)
	}
	// Clean up the file when the test function returns.
	// This prevents cluttering the filesystem.
	defer os.Remove(f.Name())
	// Close the file handle before removing it.
	// Defer executes in LIFO order, so this runs first.
	defer f.Close()

	// Write payload to the file handle.
	_, err = f.WriteString("payload")
	if err != nil {
		t.Fatal(err)
	}

	// Read the file content back using the path.
	// This exercises the OS file system, not just memory.
	data, err := os.ReadFile(f.Name())
	if err != nil {
		t.Fatal(err)
	}

	// Assert the content matches what we wrote.
	if string(data) != "payload" {
		t.Errorf("expected payload, got %q", string(data))
	}
}

When you run go test, the compiler includes files ending in _test.go and links them against your package. The testing package provides the runner. Functions starting with Test and taking *testing.T are discovered automatically. The runner executes each test, captures output, and reports failures.

If you name your function testIntegration with a lowercase t, the compiler won't complain, but the runner skips it with testing: warning: no tests to run. The test runner only picks up functions starting with Test and taking *testing.T.

Test files in the same package can call unexported functions. This lets you test internal logic without exporting it to the public API. You get the benefits of testing private code while keeping the package interface clean.

Integration tests prove the system works, not just the parts.

Gating tests with build tags

Integration tests often require external services like a database or a message queue. You don't want these tests running every time you save a file or in the fast unit test stage of your CI pipeline. They are slower and require infrastructure. Go supports build tags to separate these tests from the default suite.

Place a build constraint at the top of the file. The compiler ignores the file unless you pass the matching tag to the build command.

//go:build integration

package main

import "testing"

// TestDBConnection runs only when the integration tag is present.
func TestDBConnection(t *testing.T) {
	// Connect to a real database instance.
	// This test is skipped by default to keep go test fast.
	t.Log("Running against real database")
}

Run the gated tests with go test -tags=integration ./.... This command tells the compiler to include files with the integration build tag. Your CI configuration can run unit tests quickly on every commit and run integration tests only on pull requests or nightly builds.

The community convention is to use build tags for heavy tests. This keeps the developer feedback loop fast while ensuring integration tests run regularly. You can also use a flag like -run Integration to filter by function name, but build tags are more robust because they exclude the test binary entirely, reducing compile time.

Gate your heavy tests. Keep the fast feedback loop fast.

Testing HTTP handlers with httptest

HTTP handlers are a common place for integration tests. You want to verify that the handler sets the correct status code, writes headers, and returns the expected body. The standard library provides httptest to spin up a real server for testing without managing ports or configuration.

Here's a handler that returns a greeting.

package main

import (
	"net/http"
)

// HandleGreeting writes a fixed response to the client.
func HandleGreeting(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	w.Write([]byte("Hello, World"))
}

Here's the integration test using httptest.NewServer. This starts a real TCP listener on a random port. The test makes a real HTTP request to that server. This exercises the full HTTP stack, including header handling and status codes, which mocks often skip.

package main

import (
	"io"
	"net/http"
	"net/http/httptest"
	"testing"
)

// TestHandleGreetingIntegration verifies the handler via a real HTTP server.
func TestHandleGreetingIntegration(t *testing.T) {
	// httptest.NewServer starts a real TCP listener.
	// This catches bugs in header handling or status codes that mocks miss.
	server := httptest.NewServer(http.HandlerFunc(HandleGreeting))
	// Stop the server when the test finishes.
	// This releases the port and resources.
	defer server.Close()

	// Perform a real GET request against the test server.
	resp, err := http.Get(server.URL)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		t.Fatal(err)
	}

	if resp.StatusCode != http.StatusOK {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
	if string(body) != "Hello, World" {
		t.Errorf("expected Hello, World, got %q", string(body))
	}
}

Functions that perform I/O should accept context.Context as the first argument. Pass ctx through your integration test so you can cancel long-running operations. The http.Request carries a context, so handlers can check for cancellation. This prevents tests from hanging if the server gets stuck.

Use the standard library. httptest gives you a real server without the overhead.

Pitfalls and cleanup

Integration tests introduce complexity. Resources must be cleaned up. Tests can be flaky if they depend on timing or shared state. Go provides tools to manage this.

Use t.Cleanup instead of defer for test teardown when you use subtests. t.Cleanup registers a function to run after the test completes. It works correctly even inside subtests, whereas defer runs when the subtest function returns, which might be before the parent test finishes.

func TestResource(t *testing.T) {
	// t.Cleanup registers a function to run after the test.
	// It works correctly even inside subtests.
	t.Cleanup(func() {
		t.Log("Cleaning up resources")
	})
}

Subtests help isolate failures. If you have multiple integration scenarios, wrap them in t.Run. This allows you to run specific parts with go test -run TestAPI/Create. It also provides better reporting when one scenario fails.

func TestAPI(t *testing.T) {
	t.Run("Create", func(t *testing.T) {
		// test create operation
	})
	t.Run("Read", func(t *testing.T) {
		// test read operation
	})
}

Common runtime issues include resource leaks and race conditions. If you forget to close a file or connection, you might eventually hit too many open files on the system. The OS limits the number of open file descriptors. Always close resources. If you share state between goroutines without synchronization, the race detector catches it with WARNING: DATA RACE. Run tests with go test -race to find these bugs early.

Defer order is LIFO. Close the file before you delete it.

When to use integration tests

Choosing the right test level depends on what you need to verify. Use the right tool for the job.

Use unit tests when you need to verify logic in isolation, such as parsing a string or calculating a value, without touching I/O.

Use integration tests when you need to verify that your code interacts correctly with external systems like databases, file systems, or HTTP services.

Use mocks when the external dependency is slow, expensive, or unavailable in the test environment, and you only care about the interface contract.

Use end-to-end tests when you need to validate the entire application flow from the user interface down to the database, usually running against a staging-like environment.

Use httptest when you want to test HTTP handlers with a real server loop but don't need a full browser or external network call.

Mocks save time. Integration tests save trust.

Where to go next