Unit Testing
Go has a built-in testing framework that requires no external dependencies. Tests live alongside the code they test, run with a single command, and follow conventions that make them easy to write and read. The testing package is deliberately simple — no assertions library, no test lifecycle hooks, just functions that report pass or fail.
go test
The go test command finds and runs all test functions in files ending with _test.go.
# Run tests in the current package
go test
# Run tests in all packages
go test ./...
# Verbose output — see each test name and result
go test -v ./...
# Run a specific test by name
go test -run TestCreateUser ./...
Test output is minimal by default. A passing test produces no output. A failing test shows the file, line number, and failure message.
Test Files & Functions
Test files are named with a _test.go suffix and live in the same directory as the code they test.
user/
user.go
user_test.go
Test functions must start with Test, take a single *testing.T parameter, and be in a _test.go file.
// user.go
package user
func FullName(first, last string) string {
return first + " " + last
}
// user_test.go
package user
import "testing"
func TestFullName(t *testing.T) {
got := FullName("Jane", "Doe")
want := "Jane Doe"
if got != want {
t.Errorf("FullName(\"Jane\", \"Doe\") = %q, want %q", got, want)
}
}
$ go test -v
=== RUN TestFullName
--- PASS: TestFullName (0.00s)
PASS
t.Error, t.Fatal & t.Log
The *testing.T type provides methods for reporting test results.
t.Errorf marks the test as failed but continues execution. Use this when you want to report multiple failures in one test run.
func TestValidation(t *testing.T) {
if err := Validate(""); err == nil {
t.Errorf("Validate(\"\") should return an error")
}
if err := Validate("ok"); err != nil {
t.Errorf("Validate(\"ok\") returned unexpected error: %v", err)
}
}
t.Fatalf marks the test as failed and stops the test function immediately. Use this when continuing would cause a panic or produce meaningless results.
func TestDatabaseQuery(t *testing.T) {
db, err := setupTestDB()
if err != nil {
t.Fatalf("failed to set up test database: %v", err)
}
// If setup failed, this code does not run
result, err := db.Query("SELECT 1")
if err != nil {
t.Fatalf("query failed: %v", err)
}
_ = result
}
t.Logf prints output only when the test fails or when running with -v. Use it for debugging information.
func TestComplexLogic(t *testing.T) {
result := ComputeSomething(input)
t.Logf("intermediate result: %+v", result)
if result.Score < 0 {
t.Errorf("expected non-negative score, got %d", result.Score)
}
}
Table-Driven Tests
Table-driven tests are the signature Go testing pattern. You define test cases as a slice of structs and loop over them.
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -1, -2, -3},
{"zero", 0, 0, 0},
{"mixed signs", -5, 10, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
$ go test -v
=== RUN TestAdd
=== RUN TestAdd/positive_numbers
=== RUN TestAdd/negative_numbers
=== RUN TestAdd/zero
=== RUN TestAdd/mixed_signs
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/positive_numbers (0.00s)
--- PASS: TestAdd/negative_numbers (0.00s)
--- PASS: TestAdd/zero (0.00s)
--- PASS: TestAdd/mixed_signs (0.00s)
PASS
Table-driven tests make it easy to add new cases. They keep the test logic in one place and the test data in another.
Subtests with t.Run
t.Run creates a named subtest. Subtests appear individually in the output, can be run selectively, and can be parallelized independently.
func TestUserService(t *testing.T) {
t.Run("Create", func(t *testing.T) {
svc := NewUserService(newMockStore())
user, err := svc.Create("Alice")
if err != nil {
t.Fatalf("Create failed: %v", err)
}
if user.Name != "Alice" {
t.Errorf("Name = %q, want %q", user.Name, "Alice")
}
})
t.Run("Delete", func(t *testing.T) {
svc := NewUserService(newMockStore())
err := svc.Delete(999)
if err == nil {
t.Error("Delete(999) should return not-found error")
}
})
}
Run a specific subtest:
go test -run TestUserService/Create
Parallel Tests with t.Parallel
Call t.Parallel() at the start of a test or subtest to run it concurrently with other parallel tests. This speeds up test suites with slow tests.
func TestSlowOperations(t *testing.T) {
tests := []struct {
name string
input string
}{
{"case1", "input1"},
{"case2", "input2"},
{"case3", "input3"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := SlowOperation(tt.input)
if result == "" {
t.Error("expected non-empty result")
}
})
}
}
When using t.Parallel() in a loop, the subtest closure captures tt correctly in Go 1.22+ thanks to the loop variable fix. In earlier versions, you need to shadow the variable: tt := tt.
Test Helpers
Extract shared setup logic into helper functions. Mark them with t.Helper() so that failure messages point to the calling test, not the helper.
func newTestServer(t *testing.T) *Server {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("failed to open test database: %v", err)
}
t.Cleanup(func() {
db.Close()
})
return NewServer(db)
}
func TestServerHealth(t *testing.T) {
srv := newTestServer(t) // if this fails, the error points here
resp := srv.HealthCheck()
if resp.Status != "ok" {
t.Errorf("health check status = %q, want %q", resp.Status, "ok")
}
}
t.Cleanup registers a function that runs after the test finishes, similar to defer but tied to the test lifecycle. It is especially useful in helpers since you cannot defer in the caller's scope from a helper function.
No Assertions Library Needed
Go's testing package does not include assertion functions like assertEqual. You use if statements and t.Errorf. This is deliberate — it keeps the testing API small and the failure messages clear.
// Standard Go test style
if got != want {
t.Errorf("got %v, want %v", got, want)
}
That said, the testify package is widely used and provides assertions, require (fatal assertions), and mock helpers.
import "github.com/stretchr/testify/assert"
func TestWithTestify(t *testing.T) {
result := Compute(42)
assert.Equal(t, 100, result)
assert.NoError(t, err)
assert.Contains(t, message, "success")
}
Both styles are valid. The standard library approach is more verbose but has zero dependencies. Testify reduces boilerplate but adds a dependency. Choose based on your team's preference.
Common Pitfalls
- Not using t.Helper(). Without it, failure messages point to the helper function instead of the test that called it, making failures hard to locate.
- Using t.Fatal in a goroutine. Calling
t.Fatalort.FailNowfrom a goroutine other than the test goroutine causes a panic. Uset.Errorinstead and synchronize properly. - Forgetting the loop variable capture. In Go versions before 1.22,
t.Runin a loop must shadow the loop variable (tt := tt) to avoid all subtests sharing the last value. - Ignoring test output. Running tests without
-vhidest.Logoutput for passing tests. Use-vwhen debugging. - Testing private functions directly. Tests in the same package can access unexported functions, but consider whether you should. Test the public API when possible — it is more resilient to refactoring.
Key Takeaways
- Tests live in
_test.gofiles and run withgo test. - Test functions start with
Testand accept*testing.T. - Use
t.Errorffor non-fatal failures,t.Fatalffor fatal ones. - Table-driven tests with
t.Runare the standard Go testing pattern. - Use
t.Parallel()to run independent tests concurrently. - Mark helper functions with
t.Helper()and uset.Cleanup()for teardown. - Go's built-in testing is deliberately simple. No assertions library is needed, though
testifyis a popular choice.