How to write unit tests

Create a _test.go file with Test functions and run go test to verify your code logic.

The safety net for your code

You just refactored the discount calculator. The code runs, the customer gets the right price, and you feel great. Then you touch one line to add a new rule, and suddenly the edge case for free items returns a negative number. The bug slipped through because you only checked the happy path in your head.

Unit tests catch these regressions before they reach production. They are small, automated checks that verify a single function or method behaves correctly given specific inputs. In Go, testing is not an afterthought. The language includes a testing package and a go test command built directly into the toolchain. You write a file, you run a command, the tool tells you what broke. There are no external frameworks to install and no configuration files to manage.

How the testing package works

Go discovers tests by convention. The test runner looks for files ending in _test.go within your package. Inside those files, it searches for functions that start with the prefix Test followed by a capital letter. The function must accept a single argument of type *testing.T.

The *testing.T type is the test context. It provides methods to report failures, log output, skip tests, and manage cleanup. The testing framework calls your function and passes in a T instance. If the function returns without calling any failure methods, the test passes. If you call t.Errorf or t.Fatalf, the test fails.

Convention dictates that the parameter name is always t. You will see t *testing.T in almost every Go codebase. Do not name it test or ctx. Stick to t so other developers recognize the pattern instantly.

Minimal example

Here is the simplest test. A function adds two numbers. The test calls it and checks the result.

// add.go
package mathops

// Add returns the sum of a and b.
func Add(a, b int) int {
    return a + b
}
// add_test.go
package mathops

import "testing"

// TestAdd verifies the Add function returns the correct sum.
func TestAdd(t *testing.T) {
    // Call the function under test
    result := Add(2, 3)
    // Check the result against the expected value
    if result != 5 {
        // t.Errorf logs the failure but allows the test to continue
        t.Errorf("Add(2, 3) = %d; want 5", result)
    }
}

Run the test with go test. The command compiles the package, compiles the test file, links them into a binary, and executes the binary. If the test passes, you see PASS. If it fails, you see the error message from t.Errorf.

Tests that don't run are worse than no tests. Name them right.

Walkthrough: what happens at runtime

When you run go test, the toolchain performs several steps. It compiles your package code. It compiles the _test.go files. It generates a main function that calls every Test function it found. It runs the binary.

The test runner executes tests sequentially by default. It creates a new *testing.T instance for each test function. If a test calls t.Errorf, the runner records the failure but continues running the rest of the test function. If a test calls t.Fatalf, the runner stops that test function immediately and marks it as failed.

You can run tests with verbose output using go test -v. This prints the name of each test as it runs and shows all log output. You can run a specific test using go test -run TestAdd. The argument is a regular expression, so go test -run Add runs any test containing "Add".

The compiler rejects a test function if the signature is wrong. You get wrong type for argument 1 (have int, want *testing.T) if you pass the wrong type. If you name the function testAdd with a lowercase 't', the test runner silently ignores it. The function must start with Test and a capital letter.

Realistic example: table-driven tests

Writing one test per case leads to repetitive code. Go developers use table-driven tests. You define a slice of structs, each holding an input and an expected output. You loop over the slice and run the check. This keeps the test logic separate from the test data.

Table-driven tests are the standard pattern in Go. They make it easy to add new cases without duplicating the assertion logic. They also enable subtests, which provide granular output and allow you to run specific cases.

// divide_test.go
package mathops

import "testing"

// TestDivide uses a table-driven approach to check multiple cases.
func TestDivide(t *testing.T) {
    // Define test cases as a slice of structs
    tests := []struct {
        name     string
        a, b     int
        want     int
        wantErr  bool
    }{
        {"normal", 10, 2, 5, false},
        {"zero divisor", 10, 0, 0, true},
        {"negative", -10, 2, -5, false},
    }

    // Loop over each test case
    for _, tt := range tests {
        // Run each case as a subtest for better output
        t.Run(tt.name, func(t *testing.T) {
            got, err := Divide(tt.a, tt.b)
            // Check if error presence matches expectation
            if (err != nil) != tt.wantErr {
                t.Errorf("Divide(%d, %d) error = %v, wantErr %v", tt.a, tt.b, err, tt.wantErr)
                return
            }
            // Check the value if no error was expected
            if got != tt.want {
                t.Errorf("Divide(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
            }
        })
    }
}

The t.Run method creates a subtest. It takes a name and a function. The function receives a new *testing.T instance scoped to that subtest. This allows you to run specific subtests with go test -run TestDivide/normal. It also means if one subtest fails, the others still run.

Table-driven tests separate data from logic. Add a new case by appending to the slice. The loop handles the rest.

Benchmarks and examples

Go supports two other special function types: benchmarks and examples. Benchmarks measure performance. Examples generate documentation.

Benchmarks start with Benchmark and accept a *testing.B argument. The B type provides a N field that tells the function how many times to run the loop. The testing framework adjusts N to get a stable measurement.

// benchmark_test.go
package mathops

import "testing"

// BenchmarkAdd measures the performance of the Add function.
func BenchmarkAdd(b *testing.B) {
    // b.N is set by the testing framework to run enough iterations
    for i := 0; i < b.N; i++ {
        Add(1, 2)
    }
}

Run benchmarks with go test -bench=.. The dot matches all benchmarks. The output shows the number of operations per second.

Examples start with Example and take no arguments. They are code snippets that demonstrate how to use a function. If the example ends with a comment starting with Output:, the test runner executes the code and compares the output to the comment.

// example_test.go
package mathops

import "fmt"

// ExampleAdd demonstrates how to use the Add function.
func ExampleAdd() {
    result := Add(1, 2)
    // Output: 3
    fmt.Println(result)
}

Examples serve as living documentation. They run as tests, so they stay in sync with the code. If the output changes, the test fails.

Pitfalls and common errors

Tests can be tricky if you ignore the conventions. Here are the most common issues.

Using t.Errorf versus t.Fatalf matters. t.Errorf logs the failure and continues. t.Fatalf stops the test immediately. Use Fatalf when a failure makes the rest of the test meaningless. For example, if you fail to create a test database, you cannot run the queries. Use Fatalf to stop.

Shared state causes flaky tests. If two tests modify a global variable, they interfere with each other. The order of test execution is not guaranteed. Always isolate tests. Create fresh data for each test. Use t.Cleanup to release resources after the test finishes.

// cleanup_test.go
package mathops

import "testing"

// TestCleanup demonstrates how to release resources.
func TestCleanup(t *testing.T) {
    // Create a resource
    resource := createResource()
    // Register a cleanup function
    t.Cleanup(func() {
        resource.close()
    })
    // Use the resource
    resource.doWork()
}

The t.Cleanup function runs after the test completes, even if the test fails. This ensures resources are always released.

Testing private functions is usually a mistake. Private functions are implementation details. Test the public API. If you need to test a private function, it might be a sign that the function should be public or that the design needs refactoring.

The compiler complains with undefined: testing if you forget to import the package. It rejects the program with wrong type for argument if the signature is wrong. It ignores functions that don't match the naming convention.

Tests are code. Treat them with the same care as production logic.

Decision: when to use what

Use t.Errorf when you want to record a failure but continue executing the rest of the test function.

Use t.Fatalf when a failure invalidates the remaining checks, preventing further errors from masking the root cause.

Use table-driven tests when you need to verify the same function against many different inputs and expected outputs.

Use t.Run when you want to group related checks into subtests for granular output and selective execution.

Use t.Cleanup when the test allocates resources like files or connections that must be released after the test completes.

Use BenchmarkXxx when you need to measure the performance of a function and compare changes over time.

Use ExampleXxx when you want to provide runnable documentation that verifies the output matches the expected result.

Use plain assertions when the logic is simple and a single check is sufficient.

Where to go next