Skip to content

Fix: Go Test Not Working — Tests Not Running, Failing Unexpectedly, or Coverage Not Collected

FixDevs · (Updated: )

Part of:  Go, Rust & Systems Errors

Quick Answer

How to fix Go testing issues — test function naming, table-driven tests, t.Run subtests, httptest, testify assertions, and common go test flag errors.

The Problem

A test function is written but go test doesn’t run it:

// utils_test.go
func testAdd(t *testing.T) {  // Lowercase 't' — not recognized as a test
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("expected 5, got %d", result)
    }
}

Or a test fails with an unclear error:

--- FAIL: TestCalculate (0.00s)
    calc_test.go:15: expected 10, got 0

Or go test ./... reports no test files:

?   mypackage [no test files]

Or race conditions appear only sometimes:

WARNING: DATA RACE
goroutine 6 [running]:
main.counter++

Why This Happens

Go’s test runner has strict naming conventions and common pitfalls:

  • Test functions must start with Test (capital T)func TestAdd is recognized; func testAdd or func addTest is not.
  • Test file must end in _test.go — files not matching this pattern are excluded from test builds.
  • Package naming matters — use package foo_test (black-box testing) or package foo (white-box testing) intentionally. Mixing them in one file causes compilation errors.
  • t.Fatal vs t.Errort.Fatal stops the current test immediately; t.Error marks failure but continues. Using t.Fatal inside a goroutine doesn’t stop the test — use a channel or sync.WaitGroup instead.

Go’s testing model is also opinionated in ways that surprise developers coming from JUnit, RSpec, or Jest. There is no built-in setup/teardown decorator, no nested describe blocks, no implicit assertion DSL. The testing package gives you raw building blocks — t.Run for subtests, t.Cleanup for teardown, and plain Go for assertions. If you write tests assuming a richer framework, you end up with skipped tests, leaked resources, or assertions that never fail because the comparison wrote the wrong operator.

The cached test runner adds another layer of confusion. Go caches successful test results based on the test binary and environment. If you change an external file the test reads but not the Go source, go test reports (cached) and skips execution. The cache invalidates on source changes, but a stale assumption about caching often makes a failing test look fixed when nothing actually ran. Use go test -count=1 to bypass the cache during diagnostic sessions, and treat the (cached) marker as a flag — not a green light.

Fix 1: Follow Test Naming Conventions

Every test function must follow Go’s naming rules:

// WRONG — won't be detected by go test
func testAdd(t *testing.T) { }        // lowercase 't'
func TestingAdd(t *testing.T) { }     // 'Testing' not 'Test'
func AddTest(t *testing.T) { }        // wrong suffix position
func TestAdd() { }                    // missing *testing.T parameter

// CORRECT — recognized by go test
func TestAdd(t *testing.T) { }         // Unit test
func BenchmarkAdd(b *testing.B) { }    // Benchmark
func FuzzAdd(f *testing.F) { }         // Fuzz test (Go 1.18+)
func TestMain(m *testing.M) { }        // Test main (setup/teardown)

// File must be named with _test.go suffix
// utils_test.go — correct
// utils.test.go — wrong (. not _)
// test_utils.go — wrong (no _test suffix)

Package declaration options:

// Black-box testing (recommended for public API testing)
// Can only access exported identifiers
package calculator_test

import (
    "testing"
    "mymodule/calculator"
)

func TestAdd(t *testing.T) {
    result := calculator.Add(2, 3)
    // ...
}

// White-box testing (access to unexported identifiers)
package calculator

func TestInternalHelper(t *testing.T) {
    result := internalHelper()  // Can access unexported functions
    // ...
}

Fix 2: Write Table-Driven Tests

Table-driven tests are the idiomatic Go pattern for testing multiple inputs:

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -1, -2, -3},
        {"zeros", 0, 0, 0},
        {"mixed", -5, 10, 5},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            result := Add(tc.a, tc.b)
            if result != tc.expected {
                t.Errorf("Add(%d, %d) = %d, want %d", tc.a, tc.b, result, tc.expected)
            }
        })
    }
}

Running specific subtests:

# Run all tests
go test ./...

# Run a specific test function
go test -run TestAdd ./...

# Run a specific subtest
go test -run "TestAdd/positive_numbers" ./...

# Run with verbose output
go test -v -run TestAdd ./...

# Run with race detector (always use in CI)
go test -race ./...

# Run with coverage
go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out  # Open in browser

Parallel subtests for faster execution:

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"case1", 1, 2, 3},
        {"case2", 4, 5, 9},
    }

    for _, tc := range tests {
        tc := tc  // Capture range variable — required before Go 1.22
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()  // Each subtest runs in parallel
            result := Add(tc.a, tc.b)
            if result != tc.expected {
                t.Errorf("got %d, want %d", result, tc.expected)
            }
        })
    }
}

Note: In Go 1.22+, loop variables are scoped per iteration, so the tc := tc capture is no longer needed. For earlier versions, it’s required to prevent all subtests from using the final loop value.

Fix 3: Test HTTP Handlers with httptest

Use the net/http/httptest package to test HTTP handlers without starting a real server:

package handlers_test

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

func TestGetUserHandler(t *testing.T) {
    // Create a request
    req := httptest.NewRequest(http.MethodGet, "/users/123", nil)
    req.Header.Set("Authorization", "Bearer test-token")

    // Create a response recorder
    rec := httptest.NewRecorder()

    // Call the handler directly
    GetUserHandler(rec, req)

    // Assert response
    if rec.Code != http.StatusOK {
        t.Errorf("expected status 200, got %d", rec.Code)
    }

    var body map[string]any
    if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
        t.Fatalf("failed to decode response: %v", err)
    }
    if body["id"] != "123" {
        t.Errorf("expected id '123', got %v", body["id"])
    }
}

// Test a full router/mux
func TestRouter(t *testing.T) {
    router := setupRouter()  // Your router setup function

    tests := []struct {
        method   string
        path     string
        body     string
        wantCode int
    }{
        {http.MethodGet, "/users", "", http.StatusOK},
        {http.MethodPost, "/users", `{"name":"Alice"}`, http.StatusCreated},
        {http.MethodGet, "/users/999", "", http.StatusNotFound},
    }

    for _, tc := range tests {
        t.Run(tc.method+" "+tc.path, func(t *testing.T) {
            var bodyReader *strings.Reader
            if tc.body != "" {
                bodyReader = strings.NewReader(tc.body)
            }
            req := httptest.NewRequest(tc.method, tc.path, bodyReader)
            if tc.body != "" {
                req.Header.Set("Content-Type", "application/json")
            }
            rec := httptest.NewRecorder()
            router.ServeHTTP(rec, req)
            if rec.Code != tc.wantCode {
                t.Errorf("expected %d, got %d", tc.wantCode, rec.Code)
            }
        })
    }
}

Fix 4: Mock Dependencies with Interfaces

Go’s interface system makes mocking straightforward without external frameworks:

// Production code — depend on an interface, not a concrete type
type UserRepository interface {
    GetUser(id string) (*User, error)
    CreateUser(user *User) error
}

type UserService struct {
    repo UserRepository
}

func (s *UserService) GetProfile(id string) (*Profile, error) {
    user, err := s.repo.GetUser(id)
    if err != nil {
        return nil, fmt.Errorf("get user: %w", err)
    }
    return &Profile{Name: user.Name, Email: user.Email}, nil
}

// Test code — implement the interface as a mock
type mockUserRepo struct {
    users map[string]*User
    err   error
}

func (m *mockUserRepo) GetUser(id string) (*User, error) {
    if m.err != nil {
        return nil, m.err
    }
    user, ok := m.users[id]
    if !ok {
        return nil, fmt.Errorf("user %s not found", id)
    }
    return user, nil
}

func (m *mockUserRepo) CreateUser(user *User) error {
    return m.err
}

// Test using the mock
func TestGetProfile(t *testing.T) {
    t.Run("success", func(t *testing.T) {
        repo := &mockUserRepo{
            users: map[string]*User{
                "1": {ID: "1", Name: "Alice", Email: "[email protected]"},
            },
        }
        svc := &UserService{repo: repo}
        profile, err := svc.GetProfile("1")
        if err != nil {
            t.Fatal(err)
        }
        if profile.Name != "Alice" {
            t.Errorf("expected Alice, got %s", profile.Name)
        }
    })

    t.Run("user not found", func(t *testing.T) {
        repo := &mockUserRepo{err: fmt.Errorf("not found")}
        svc := &UserService{repo: repo}
        _, err := svc.GetProfile("999")
        if err == nil {
            t.Fatal("expected error, got nil")
        }
    })
}

Fix 5: Setup and Teardown Patterns

Handle test setup, teardown, and shared state with TestMain and helper functions:

package db_test

import (
    "os"
    "testing"
)

var testDB *sql.DB

// TestMain runs before any test in the package
func TestMain(m *testing.M) {
    // Setup
    var err error
    testDB, err = sql.Open("postgres", os.Getenv("TEST_DATABASE_URL"))
    if err != nil {
        log.Fatalf("failed to open test database: %v", err)
    }

    if err := runMigrations(testDB); err != nil {
        log.Fatalf("failed to run migrations: %v", err)
    }

    // Run tests
    exitCode := m.Run()

    // Teardown
    testDB.Close()
    os.Exit(exitCode)
}

// Per-test cleanup using t.Cleanup
func TestCreateUser(t *testing.T) {
    // Insert test data
    userID := insertTestUser(t, testDB)

    // t.Cleanup runs after the test (and subtests) finish
    t.Cleanup(func() {
        testDB.Exec("DELETE FROM users WHERE id = $1", userID)
    })

    // Test logic
    user, err := GetUser(testDB, userID)
    if err != nil {
        t.Fatal(err)
    }
    if user.ID != userID {
        t.Errorf("expected ID %d, got %d", userID, user.ID)
    }
}

// Skip tests that need external dependencies
func TestWithExternalService(t *testing.T) {
    if os.Getenv("INTEGRATION_TESTS") == "" {
        t.Skip("skipping integration test; set INTEGRATION_TESTS=1 to run")
    }
    // ... integration test code
}

Fix 6: Use testify for Cleaner Assertions

The testify library reduces boilerplate and produces clearer failure messages:

go get github.com/stretchr/testify
import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "github.com/stretchr/testify/mock"
)

func TestUserService(t *testing.T) {
    user := &User{ID: "1", Name: "Alice", Email: "[email protected]"}

    // assert — continues test on failure
    assert.Equal(t, "Alice", user.Name)
    assert.NotNil(t, user)
    assert.Contains(t, user.Email, "@")
    assert.Len(t, user.Name, 5)

    // require — stops test on failure (like t.Fatal)
    // Use require when subsequent assertions depend on this one
    require.NotNil(t, user, "user must not be nil")
    require.Equal(t, "1", user.ID)  // If this fails, test stops here

    // Error assertions
    err := validateUser(user)
    assert.NoError(t, err)

    _, err = GetUser("nonexistent")
    assert.Error(t, err)
    assert.ErrorContains(t, err, "not found")
}

// testify/mock — generate mock implementations
type MockUserRepo struct {
    mock.Mock
}

func (m *MockUserRepo) GetUser(id string) (*User, error) {
    args := m.Called(id)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*User), args.Error(1)
}

func TestWithMock(t *testing.T) {
    mockRepo := new(MockUserRepo)

    // Set expectation
    mockRepo.On("GetUser", "1").Return(&User{ID: "1", Name: "Alice"}, nil)
    mockRepo.On("GetUser", "999").Return(nil, errors.New("not found"))

    svc := &UserService{repo: mockRepo}

    user, err := svc.GetProfile("1")
    require.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)

    // Verify all expected calls were made
    mockRepo.AssertExpectations(t)
}

Cross-Tool Comparison: Picking a Go Test Stack

The default go test binary is the floor, not the ceiling. Each ecosystem layer addresses a different gap, and mixing two assertion libraries in one repository creates noise that hurts every later developer. Pick deliberately.

Stdlib testing plus table-driven tests is the idiomatic baseline. It compiles fast, integrates with go vet, -race, and -cover with zero ceremony, and produces failure messages you control. The cost is verbose assertions: every check is if got != want { t.Errorf(...) }. For libraries and small services, this is fine and arguably preferable — the surface area stays minimal and dependencies stay zero.

testify (stretchr/testify) is the most common addition. assert.Equal, assert.NoError, and require.* collapse three lines of comparison into one and produce diff output that pinpoints the exact field that mismatched. Reach for testify when your structs have more than four fields or when you want stricter assertions like assert.WithinDuration for timestamps. Avoid mixing testify/mock with hand-written interface mocks in the same codebase — pick one mocking style and stay consistent.

Ginkgo and Gomega bring BDD-style Describe/Context/It blocks closer to RSpec or Jest. They shine when a system under test has many nested setup conditions, like Kubernetes operators where each scenario reuses fixtures. The trade-off is heavier syntax, slower compilation, and a steeper ramp for newcomers. If your test suite is mostly pure functions, Ginkgo is overkill; if it’s an operator with controller-runtime fakes, it pays for itself.

gotestsum wraps go test to give you pretty output, JUnit XML for CI, and rerun-on-fail. It is purely a runner — assertions stay in stdlib or testify. Most CI pipelines benefit from gotestsum even if the rest of the stack is plain go test.

Coverage versus benchmarks versus fuzz testing are three separate modes that share the same test binary. Coverage (-cover, -coverprofile) measures line execution. Benchmarks (func BenchmarkX(b *testing.B)) run timed iterations and accept -bench=. -benchmem flags. Fuzz tests (Go 1.18+, func FuzzX(f *testing.F)) generate randomized inputs and persist crash corpora to testdata/fuzz/. Don’t try to mix benchmarks and coverage in the same invocation — -cover inserts instrumentation that distorts benchmark numbers. Run them in separate CI steps.

# Three separate runs, three separate purposes
go test -race -cover ./...
go test -bench=. -benchmem -run=^$ ./...
go test -fuzz=FuzzParseInput -fuzztime=30s ./pkg/parser

If you came from JUnit (Java) or pytest (Python), the closest match in Go is stdlib + testify + table-driven subtests. If you came from RSpec or Jasmine, Ginkgo will feel familiar but will be slower than the idiomatic path.

Still Not Working?

Tests pass locally but fail in CI — the most common causes are timing dependencies, environment variable differences, and hardcoded file paths. Use t.TempDir() for temp files (auto-cleaned), os.Getenv for config, and avoid time.Sleep in tests — use channels or require.Eventually from testify instead.

Tests appear cached and won’t re-rungo test caches successful results keyed by the test binary, environment variables, and any file accessed via os.Open. If your test reads from a database, a network service, or a file outside the module, Go can’t detect the change and reports (cached). Pass -count=1 to force re-execution, or list the external file in go:embed or testdata/ so the build system tracks it.

Race detector misses goroutine leaks-race catches data races but not goroutines that outlive the test. Use go.uber.org/goleak in TestMain to fail the package when a test leaks: defer goleak.VerifyNone(t) inside individual tests, or goleak.VerifyTestMain(m) for the whole package. This catches time.AfterFunc and unclosed channels that race detection alone misses.

Build cache reuses stale generated code — if your tests depend on go generate output (mocks, protobuf, sqlc), the build cache won’t rerun generation. Run go generate ./... in CI before go test, and add a quick git diff --exit-code afterward to fail the build if generated files drift.

t.Fatal inside a goroutine panics — calling t.Fatal or t.FailNow from a goroutine other than the test goroutine causes a panic. Use t.Error (which is goroutine-safe) and coordinate with a WaitGroup or channel:

func TestConcurrent(t *testing.T) {
    var wg sync.WaitGroup
    errCh := make(chan error, 1)

    wg.Add(1)
    go func() {
        defer wg.Done()
        if err := doWork(); err != nil {
            errCh <- err  // Don't call t.Fatal here
        }
    }()

    wg.Wait()
    close(errCh)

    if err := <-errCh; err != nil {
        t.Fatal(err)  // Call t.Fatal from the test goroutine
    }
}

Coverage not including all packagesgo test -cover ./... only covers packages that have tests. To measure coverage for packages without test files, use -coverpkg=./...:

go test -coverpkg=./... -coverprofile=coverage.out ./...

Build constraints exclude test files — if a test file has a //go:build constraint at the top (like //go:build integration), it won’t run unless you pass the tag: go test -tags integration ./....

For related Go issues, see Fix: Go Goroutine Deadlock, Fix: Go Channel Deadlock, Fix: Go Map Concurrent Access, and Fix: Go Goroutine Leak.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles