skip to content

Testing Concurrent Go Code

Concurrency tests have to be deterministic: synchronize with a channel, not a sleep, and prove no goroutine outlived the test. Interviewers ask because a sleep-based test is the recurring flake.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

In a Go test, what should replace time.Sleep when waiting for a goroutine to finish?

level: juniorimportance: must knowfreq 62%

answer

  1. wait for a signal, not a duration
  2. the goroutine's last act tells you
  3. close(done), then receive in the test
  4. buffer of one if the test may bail
  5. select with a deadline case, not a sleep

basics

~20 s

Let the goroutine signal on a channel and have the test receive from it, so the test unblocks exactly when the work is done. Add a select with a time.After case so a hang fails the test instead of stalling it.

solid answer

~50 s

A sleep encodes a guess about how long the goroutine needs: on a loaded CI machine the guess is too short and the test fails for no reason, and on every green run it wastes that many milliseconds. Instead create `done := make(chan struct{})`, have the goroutine `close(done)` as its last act, and have the test block on `<-done`; the test continues the instant the work is published, not a fixed delay later. The receive is also the ordering guarantee — whatever the goroutine wrote before the close is visible to the test after the receive, which no sleep promises. Wrap it as `select { case <-done: case <-time.After(2*time.Second): t.Fatal("timed out") }` so a goroutine that never finishes produces a named failure rather than a hung test binary. If the goroutine hands back a value, send it on a buffered result channel instead.

go deeper

for a junior

Be ready to write the four lines from memory: make a channel, close it in the goroutine, receive in the test, then assert. Say plainly that a sleep waits for a duration while a receive waits for the event.

for a middle

Explain why the receive also gives you visibility of what the goroutine wrote, and why the result channel should be buffered when the test can walk away from the wait. Show the select with a deadline case.

for a senior

Show judgment about a suite full of sleeps: which ones can be mechanically converted, how to size the deadline so it never becomes the new flake, and why a hang must be converted into a failure with a message.

for a principal

Own the standard: sleeps in test code are a defect class, not a style preference. Be ready to argue what the team's test helpers should provide so nobody has to hand-roll a wait, and what CI does when a test times out.

## Why a sleep is the wrong wait A Go test that does `go compute(); time.Sleep(50 * time.Millisecond); assert(...)` is not waiting for the goroutine — it is waiting for a duration and hoping the goroutine got there first. That has two independent defects, and both of them hurt. 1. **It is a guess about speed.** The 50 ms was measured on a developer laptop with an idle CPU. In CI the same test runs on a shared machine alongside a dozen other packages, the goroutine is descheduled, and the assertion fires before the value is written. That is the classic test that passes only because a sleep happened to be long enough on somebody's laptop, and it is the single largest source of a suite nobody trusts any more. 2. **It is dead time on every green run.** A hundred such tests, each sleeping 50 ms, is five seconds added to every single run, forever, even when everything works. Worse, a sleep gives you no *ordering* guarantee. Go's memory model does not say "a write becomes visible after 50 milliseconds"; it defines visibility through synchronising operations. Reading a variable the goroutine wrote, with nothing but a sleep in between, is an unsynchronised read even when it happens to return the right value. ## The signal, not the duration The fix is to make the goroutine *tell you* it is finished, over a channel: ```go done := make(chan struct{}) go func() { result = compute() close(done) }() <-done ``` `close(done)` is a good completion signal because every receiver of a closed channel is released and gets the zero value immediately — you can wait on it from several places, and closing is idempotent-looking to readers (though closing twice panics, so exactly one goroutine should own the close). Receiving from `done` blocks the test until the close actually happens: no earlier, no later. The wait now costs exactly what the work costs. When the goroutine produces a value rather than just finishing, send the value instead: ```go res := make(chan int, 1) go func() { res <- compute() }() got := <-res ``` ## Make the channel buffered when the test may give up Note the capacity of 1. If the test abandons the wait — because a deadline fired, or an earlier assertion failed the test — an *unbuffered* send has no receiver and the goroutine parks on it forever. That goroutine is now leaked for the rest of the test binary's life, holding whatever it captured. A buffer of one lets the goroutine deposit its value and exit even if nobody ever reads it. ## Always bound the wait A bare `<-done` turns a bug into a hang: if the goroutine deadlocks, the test does not fail, it stops, and you find out only when the whole test binary is killed by its timeout, long after the useful context is gone. Bound it: ```go select { case <-done: case <-time.After(2 * time.Second): t.Fatal("compute did not finish within 2s") } ``` The deadline is not a sleep in disguise, and the distinction matters: a sleep is on the *success* path and its length decides whether the test is correct; a `time.After` case is on the *failure* path and only decides how long you wait before declaring a hang. That is why it should be generous — seconds, not milliseconds. A tight deadline reintroduces exactly the machine-speed dependence you were removing. ## Do the asserting on the test goroutine Assert *after* the receive, in the test function itself. Reading `result` from the test goroutine after `<-done` is properly ordered, and any failure is reported by the goroutine the testing package expects to report it. Doing the assertion inside the spawned goroutine instead brings a separate hazard: the fail-fast methods of `testing.T` do not stop the test when called from another goroutine. ## The shape to remember Create the channel, close or send from the goroutine as its final act, receive in the test with a generous deadline case, then assert. Any test where you can point at a number of milliseconds and say "we hope it takes less than that" should be rewritten this way.

  • Why make the result channel buffered when the test may stop waiting?
    If the test abandons the wait after a deadline or an earlier failure, an unbuffered send has no receiver and the goroutine parks on it forever, leaking for the life of the test binary. A capacity of one lets the goroutine deposit its value and return even when nobody reads it.
  • How long should the time.After deadline in the wait be?
    Generous — seconds, not the expected duration. It sits on the failure path: its only job is to turn a hang into a named failure. Sizing it close to how long the work usually takes brings back the machine-speed dependence you removed by dropping the sleep.
  • The goroutine returns a value and an error, not just completion. What changes?
    Send a small struct on a buffered channel — `type out struct { v int; err error }` — receive it in the test, and check `err` there. Keeping both the value and the error on one channel means the test has everything at the join point and never reads a shared variable the goroutine wrote.

saying these in an interview costs you the question

  • Says a long enough sleep is fine on CI
  • Reads the goroutine's variable with only a sleep in between
  • Blocks on an unbuffered channel the test may abandon
  • Uses no deadline, so a hung goroutine stalls the suite
  • Tunes the sleep upward whenever the test flakes
open as a page

Why is calling t.Fatalf from a goroutine your Go test spawned unsafe?

level: middleimportance: should knowfreq 48%

basics

~20 s

Fatalf records the failure and then ends only the goroutine that called it, through runtime.Goexit. The test function keeps running with the broken state, and if it returns first the late call panics. From a spawned goroutine use Errorf and return, or send the error back.

open as a page

A Go concurrency bug fails once in 200 CI runs. How do you build a test that reproduces it?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Isolate the suspect component into its own test, drive it with replayed recorded traffic at a chosen concurrency, raise GOMAXPROCS, and repeat the case hundreds of times under go test -race. Then quantify: measure the failure rate per hundred runs before and after the fix.

open as a page

How do you pin a specific goroutine interleaving in a Go test without time.Sleep?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

Give a test-only fake collaborator two unbuffered channels: it sends on one when it is entered and blocks receiving on the other. The test receives the first signal, so it knows the goroutine is stopped inside that call, does its second operation, then releases the fake.

open as a page