A Go test calls t.Fatal from a goroutine it started. Why is that wrong, and what should it do instead?
answer
- FailNow only ends its own goroutine
- Goexit is not an exception and unwinds nothing
- the test may sit waiting for a value nobody sends
- Log and Error are the goroutine-safe ones
- send the error back and Fatal on the test goroutine
basics
~20 st.Fatal stops only the goroutine that calls it, because FailNow uses runtime.Goexit. The test function itself keeps running, or blocks forever waiting for a value that goroutine will now never send. Hand the failure back to the test goroutine instead.
solid answer
~50 s`t.Fatal` is `t.Log` plus `t.FailNow`, and `FailNow` stops execution by calling `runtime.Goexit`, which terminates only the goroutine that called it. The documented rule is that `FailNow`, `Fatal`, `Fatalf`, `SkipNow`, `Skip` and `Skipf` may be called only from the goroutine running the test function; the `Log` and `Error` variations may be called from several goroutines at once. So a `t.Fatalf` in a spawned goroutine kills that goroutine mid-flight and the test function runs on. Typically that goroutine was supposed to send a result the test is waiting to receive, so the test blocks until the binary's timeout kills the run. The fix is structural: the goroutine reports its error over a buffered channel and returns, the test goroutine receives it and calls `t.Fatalf` itself. `t.Errorf` from the goroutine is legal, but it stops nothing, so the test must still wait for the result before it returns.
code
go · 9 linesres := make(chan bool, 1)
go func() {
v, err := eng.Evaluate("checkout_v2")
if err != nil {
t.Fatalf("Evaluate: %v", err) // ends this goroutine only
}
res <- v
}()
v := <-res // on the error path nothing is ever sentgo deeper
Remember the rule as a rule: t.Fatal, t.FailNow and t.Skip belong on the test's own goroutine. Inside a go func in a test, use t.Errorf or send the error back instead.
Explain why: FailNow stops execution with runtime.Goexit, which only ends the calling goroutine, and Go gives no goroutine a way to abort another. Know that Log and Error are the goroutine-safe reporting methods.
Diagnose it from the symptom — a suite that hangs to the test binary's timeout with a goroutine dump, or a log that continues past an apparent abort — and describe the structural fix where the spawned goroutine reports and the test goroutine decides.
Make it a property of how the team writes concurrent tests rather than a bug caught twice a year: a review rule about reporting calls inside go func, and a shared shape for handing results back so no test depends on a goroutine ending another.
## The rule, stated exactly The `testing` package documents which methods are goroutine-restricted. A test ends when its test function returns or calls `FailNow`, `Fatal`, `Fatalf`, `SkipNow`, `Skip` or `Skipf` — and **those methods must be called only from the goroutine running the test function**. The other reporting methods, the `Log` and `Error` variations, may be called simultaneously from multiple goroutines. That is not a style rule. It follows directly from how `FailNow` stops a test. ## Why FailNow cannot work from another goroutine `FailNow` marks the test failed and then calls `runtime.Goexit()`. `Goexit` terminates **the calling goroutine** — it runs that goroutine's deferred calls and then the goroutine is gone. It does not panic, it does not signal anything, and it has no way to reach into a different goroutine and stop it. Go has no mechanism for one goroutine to abort another; that is a deliberate design property of the language, not an omission in `testing`. So when a spawned goroutine calls `t.Fatalf`, the message is logged, the test is marked failed, and then that goroutine — and only that goroutine — disappears. The test function is somewhere else entirely, still executing. ## What that looks like on call Take a test for a feature-flag evaluation engine that spawns a goroutine to evaluate a rule and send the result back on a channel. On the error path the goroutine calls `t.Fatalf("Evaluate: %v", err)` and, believing the test has ended, sends nothing. Meanwhile the test function is blocked on `v := <-res`. Nobody will ever send. The test hangs until the test binary's timeout fires, kills the process and prints a dump of every goroutine's stack. What the person triaging sees is a timeout in a suite that used to pass, and a stack dump in which the interesting goroutine — the one that failed — is already gone. The milder variants are just as confusing: - if the test does not wait on that goroutine, the test function runs to completion; the failure is recorded, but every statement after the point you believed the test had aborted also ran, so the log is full of consequential noise from a state you thought was unreachable; - if the goroutine had cleanup deferred, that cleanup runs at `Goexit` time, out of order relative to the test's own steps; - worst of all, the shape reads as correct on review — `t.Fatalf` is right there in the diff, and nothing about it looks different from the `t.Fatalf` on the line above the `go` statement. ## The fix: report inward, decide in the test The reliable structure is that a spawned goroutine never decides that the test is over. It computes a value or an error and hands it back; the test goroutine, which is the only one allowed to end the test, does the deciding. A buffered error channel is the smallest form of that: the goroutine sends its error and returns, and the test selects between the error channel and the result channel, calling `t.Fatalf` on the error branch. Buffering by one matters — an unbuffered send would block if the test goroutine has already given up on receiving. The same principle scales: collect results into a slice guarded by a mutex, or a channel the test drains, and assert once the work is accounted for. What must not vary is who calls `Fatal`. ## When t.Errorf from a goroutine is fine `t.Errorf` and `t.Logf` are explicitly safe to call from other goroutines, and for a purely informational failure that is a legitimate simplification: the goroutine records the problem, the test is marked failed, and no control flow is implied. Two caveats attach. First, it stops nothing, so the goroutine must not then continue into code that assumes the check passed. Second, the test function must still wait for that goroutine to be done before it returns — the failure has to be recorded while the test is still the thing that owns it. ## Porting habits that produce this bug This is one of the most reliable bugs to appear when a suite is ported from an ecosystem where a failed assertion throws. There, an assertion inside a callback or worker thread raises, the exception unwinds, and the framework catches it somewhere useful. The mental model transfers — "the assertion aborts things" — and in Go it is simply false off the test goroutine, because `Goexit` is not an exception and there is nothing to unwind into. The review heuristic is short and worth applying mechanically: **inside a `go func()` in a test, `Fatal`, `Fatalf`, `FailNow`, `Skip` and `SkipNow` are all wrong.** Replace them with a value sent back to the test, or with `t.Errorf` plus a `return`.
- Is t.Errorf safe to call from a goroutine the test started?Yes. The `Log` and `Error` variations are documented as callable from multiple goroutines simultaneously; only `Fatal`, `Fatalf`, `FailNow`, `Skip`, `Skipf` and `SkipNow` are restricted to the test's own goroutine. But `t.Errorf` stops nothing, so the goroutine must return by itself, and the test function must still wait for that work to be accounted for before it returns.
- Why does runtime.Goexit not take the process down the way an unrecovered panic would?`Goexit` terminates only the calling goroutine, after running its deferred calls. It is not a panic, so there is nothing to propagate and nothing to recover. Every other goroutine, including the one running the test function, keeps executing normally — which is exactly why a `Fatal` in a spawned goroutine fails to stop the test.
- What does this bug look like to whoever is triaging the red run?Usually a timeout: the suite hangs because the test is blocked receiving a value the exited goroutine never sent, and the run dies on the test binary's timeout with a dump of every goroutine's stack. The milder version is a test whose log shows work continuing well past the point the author believed it had aborted.
- How would you catch this shape in review rather than at 3am?Scan for `Fatal`, `Fatalf`, `FailNow`, `Skip` or `SkipNow` inside a `go func()` in a test file — all of them are wrong there, with no exceptions to weigh. The fix is always the same: send the value or error back to the test goroutine, or use `t.Errorf` plus an explicit `return`.
saying these in an interview costs you the question
- Thinks t.Fatal aborts the test no matter which goroutine calls it
- Believes FailNow panics, so a recover somewhere would catch it
- Assumes a passing result proves the spawned goroutine succeeded
- Says t.Errorf is also forbidden from spawned goroutines
- Expects runtime.Goexit in one goroutine to stop the others