Go Testing Mistakes That Quietly Make Your Tests Useless
Ten Go testing mistakes that leave tests green and useless: t.Fatal in goroutines, ignored errors, map order, shared state and sleeps, with what vet says.

A failing test is the test doing its job. The expensive ones are the tests that cannot fail: they run, they pass, they appear in the coverage report, and they check nothing. They are written with good intentions and the mistakes are small. This list collects ten, in rough order of how often they cost something, with the check that exposes each. Where a tool can catch the mistake, I ran it on Go 1.25.6 and quote what it said.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
The lab
package main
import (
"sync"
"testing"
)
func TestMistakeFatalInGoroutine(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
t.Fatal("this calls FailNow from the wrong goroutine") // vet reports it
}()
wg.Wait()
}
func TestMistakeIgnoredError(t *testing.T) {
_, err := parse("x")
_ = err // the test passes whatever parse does
}
func TestMistakeMapOrder(t *testing.T) {
m := map[string]int{"a": 1, "b": 2, "c": 3}
got := ""
for k := range m {
got += k
}
if got != "abc" { // passes only when the runtime happens to iterate in order
t.Logf("got %q", got)
}
}
func parse(s string) (int, error) { return 0, nil }
func main() {}echo "# go vet"
go vet . 2>&1 | grep -v '^#' | sed -E 's#^vet: ##; s#^\./##'
echo
echo "# go test"
go test -count=1 -v -run 'MapOrder|IgnoredError' . 2>&1 | grep -E '^(---|ok|\s+mistakes)' | sed -E 's/ \([0-9.]+s\)//; s/\t[0-9.]+s$//; s#^ +[a-z_]+\.go:[0-9]+: # #'# go vet
mistakes_test.go:13:3: call to (*testing.T).Fatal from a non-test goroutine
# go test
--- PASS: TestMistakeIgnoredError
--- PASS: TestMistakeMapOrder
ok example.test/labThe file holds three of the mistakes as tests, and the output shows what the toolchain makes of them.
1. t.Fatal from another goroutine
TestMistakeFatalInGoroutine starts a goroutine that calls t.Fatal. FailNow, which Fatal uses, stops the goroutine it is called from and must be called from the test's own. Called elsewhere, it marks the test failed but does not stop the test, the rest of the test code runs on a state it assumed would not exist, and in the worst case the failure is reported after the test has finished. Vet catches it: call to (*testing.T).Fatal from a non-test goroutine. Use t.Error in goroutines and signal the test goroutine over a channel, then fail there. Run go vet in CI so it cannot slip back.
2. The ignored error
TestMistakeIgnoredError calls parse("x") and writes _ = err. The test passes whatever parse does, including failing outright. The shape is common: an error is assigned to silence the compiler, and the assertion is left for later. Check errors in tests as strictly as in production: if err != nil { t.Fatal(err) } for setup, and a specific comparison with errors.Is or the message when the error is the subject. A linter such as errcheck flags the dropped error in non-test code and, configured for tests, in test code too. I did not run errcheck here.
3. Depending on map iteration order
TestMistakeMapOrder builds a string by ranging over a map and then, if it differs from "abc", only logs. It passes in the recorded run; it would pass if the string came out as "cab" too, because the branch never fails. Two mistakes in one: the code under test relies on an order that Go randomises deliberately, and the test does not assert. Sort the keys before comparing, or compare as maps. When a test passes whether or not a condition holds, delete the condition or make it fatal.
4. State shared between tests
A package-level map or counter modified by one test and read by another makes the result depend on the order of execution. The failure shows up with -shuffle, with -run on one test, or when a new test is added in between. Create state per test, reset it in t.Cleanup, and run CI with -shuffle=on. The flaky tests guide shows a lab where four of six shuffled runs failed.
5. Sleeping instead of waiting
time.Sleep(100 * time.Millisecond) in a test is a guess, and a wrong one on some machine. Wait for the thing you need: a channel, a WaitGroup, a poll with a deadline, or a virtual clock with testing/synctest. See stopping flaky time based tests.
6. Asserting nothing
A test that calls a function and checks nothing tests that it does not panic. Sometimes that is the intent, as in a fuzz target. Usually it is a forgotten assertion, written to raise the coverage figure. The coverage article shows a suite scoring 100% that did not notice a changed boundary. In review, ask of each test: if I deleted the body of the function under test, would this fail? Mutation testing asks it of the whole suite; see the mutation article.
7. Mocking the thing under test
A mock that returns the value the test then asserts on has tested the mock. This happens when the mock is so broad that the code under test is a pass-through, or when the test stubs a method of the very type it checks. Mock the boundaries, a network client or a clock, and let your own code run for real. Fakes, stubs and mocks draws the line.
8. Only the happy path
The input that works is the easiest to write and the least likely to fail. The bugs are at the boundaries: empty input, one element, the maximum, negative numbers, invalid UTF-8, a failing dependency, a cancelled context. For each function, list three ways it can be misused and add a row for each to the table. Property tests and fuzzing generate these for you.
9. Never running with -race
Concurrent code that is tested without the race detector is tested halfway. A data race may produce the correct answer a thousand times. go test -race in CI turns it into a failure with both stacks. See the race detector in CI.
10. Believing a green run
A passing suite means the cases written pass. It says nothing about the cases not written, the environment the tests do not model, or whether the assertions are strong. Treat green as the absence of a known problem. Complement it with coverage to find untested code, mutation testing to test the assertions, and production monitoring to see what the tests missed.
Two that do not make the top ten
Loop variable capture in subtests used to belong on this list. Since Go 1.22, each iteration has its own variable, so a t.Run closure over the loop variable is correct, provided the module's go.mod says go 1.22 or later. On older language versions the old problem persists: check the go line.
Testing private details. Tests that reach into unexported state break when the implementation changes. They are a cost, not a silent failure, and the unexported functions article discusses when to accept it.
How to find these in a suite you already have
Reading a thousand tests for the mistakes above is not practical, so use the tools to narrow the search.
- Run
go vet ./...and read the whole list. It reportst.Fatalin goroutines, malformed test names (a function namedTestfoois not a test), copied locks and printf mistakes. - Run with
-shuffle=on -count=3 -raceonce. Order dependence and races surface as failures. - Search for suspicious text:
time.Sleepin_test.go,_ = err,t.Logwhere an assertion should be, tests with no calls tot.Error,t.Fatalor a library assertion. - Run a mutation tool on one package, as in the mutation article. Survivors are tests that cannot fail.
- Break the code on purpose in the three places you are least sure of, and see whether anything goes red. This takes ten minutes and tells you more than a coverage figure.
The last one is the cheapest. It also builds the instinct that makes the checklist unnecessary: after a few times you read a test and see what it would let through.
Why these mistakes survive review
Each is reasonable in the moment, and the failure is invisible. A green test does not draw a reviewer's eye, and a long diff of tests is skimmed, not read. A few habits shift that. Ask the author to show the test failing before the fix, for every bug fix. Prefer tests short enough to read at a glance, which is the reason for builders and table tests. And treat a deleted assertion as a changed behaviour, to be justified in the description. None of this needs a tool, only the agreement that tests are code and are reviewed as code.
A review checklist
When reviewing a test, ask five questions.
- What would make this fail? If nothing, it is not a test.
- Does it call
t.Fatalort.FailNowonly from the test goroutine? - Does it handle every error, and assert the specific one when an error is the point?
- Does it depend on order, time, the network or shared state?
- Does it run under
-raceand-shufflein CI?
The Go team documents the testing rules in the testing package, and go vet lists its analyzers in the vet documentation. For the wider practice, see the Go testing guide.


