Fix: Go Test Not Working — Tests Not Running, Failing Unexpectedly, or Coverage Not Collected
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 0Or 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 TestAddis recognized;func testAddorfunc addTestis 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) orpackage foo(white-box testing) intentionally. Mixing them in one file causes compilation errors. t.Fatalvst.Error—t.Fatalstops the current test immediately;t.Errormarks failure but continues. Usingt.Fatalinside a goroutine doesn’t stop the test — use a channel orsync.WaitGroupinstead.
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 browserParallel 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 := tccapture 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/testifyimport (
"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/parserIf 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-run — go 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 packages — go 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Go Generics Type Constraint Error — Does Not Implement or Cannot Use as Type
How to fix Go generics errors — type constraints, interface vs constraint, comparable, union types, type inference failures, and common generic function pitfalls.
Fix: Go Error Handling Not Working — errors.Is, errors.As, and Wrapping
How to fix Go error handling — errors.Is vs ==, errors.As for type extraction, fmt.Errorf %w for wrapping, sentinel errors, custom error types, and stack traces.
Fix: Go Panic Not Recovered — panic/recover Patterns and Common Pitfalls
How to handle Go panics correctly — recover() placement, goroutine panics, HTTP middleware recovery, defer ordering, distinguishing panics from errors, and when not to use recover.
Fix: Supertest Not Working — Requests Not Sending, Server Not Closing, or Assertions Failing
How to fix Supertest HTTP testing issues — Express and Fastify setup, async test patterns, authentication headers, file uploads, JSON body assertions, and Vitest/Jest integration.