Ginkgo Timeouts and Async Testing
Make Ginkgo specs interruptible and test asynchronous behavior correctly — SpecContext cancellation, NodeTimeout/SpecTimeout/GracePeriod, Eventually/Consistently with proper context propagation, and the two goroutine rules (GinkgoRecover, poll-don't-block) that prevent one failed assertion from crashing an entire Go test suite.
A goroutine in a Ginkgo spec that calls Expect() without defer GinkgoRecover() doesn't just fail that one test — a panic Ginkgo can't recover crashes the entire suite, and the fix is one line most Go developers writing async tests have never heard of.
Who it's for: Go developers writing Ginkgo/Gomega tests that involve goroutines, channels, or network calls, engineers debugging a Ginkgo spec that hangs instead of failing, teams setting per-node or per-spec timeouts for flaky or slow integration tests, anyone whose Eventually() assertion isn't retrying the way they expect
Example
"My Ginkgo spec hangs forever instead of timing out" → The interruptible-node model explained (only setup/subject nodes accept a cancellable context; propagate ctx into every blocking call), the NodeTimeout vs SpecTimeout vs GracePeriod distinction, and the exact reason a bare <-done channel read blocks past a goroutine's failure instead of surfacing it
New here? 3-minute setup guide → | Already set up? Copy the template below.
# Ginkgo Timeouts and Async Testing
Make Ginkgo specs interruptible and test asynchronous behavior correctly: `SpecContext`/`context.Context` cancellable nodes, `NodeTimeout`/`SpecTimeout`/`GracePeriod`, the `--timeout` flag, `Abort` and `SIGINT` behavior, Gomega `Eventually`/`Consistently`, and the `defer GinkgoRecover()` rule for goroutines.
Failure in Ginkgo is a panic that Ginkgo recovers; a goroutine that fails needs `defer GinkgoRecover()` or it crashes the whole suite instead of just the one spec.
## Interruptible Nodes: Take a Context, Honor Cancellation
A setup or subject node becomes interruptible simply by accepting a `SpecContext` (or plain `context.Context`) — Ginkgo supplies one automatically:
```go
It("likes to sleep in", func(ctx context.Context) {
select {
case <-ctx.Done(): return // honor cancellation and exit promptly
case <-time.After(time.Hour): ...
}
}, NodeTimeout(time.Second))
```
On timeout or interrupt, Ginkgo cancels the context to tell the node to stop. Pass `ctx` down into every blocking call (`libraryClient.SaveBook(ctx, book)`, `exec.CommandContext(ctx, ...)`) so the cancellation actually propagates. Only setup/subject nodes are interruptible — container nodes are not, because they run at construction time.
- `ctx.Deadline()` does NOT report the node's deadline. Ginkgo manages cancellation timing itself (to snapshot a progress report first), so it does not use a `WithDeadline` context. Trust `<-ctx.Done()`, not `Deadline()`.
- `SpecContext` satisfies `context.Context`; you may wrap it (`context.WithValue`) and Ginkgo still cancels the result on time.
## The Timeout Decorators
| Decorator | Scope |
|---|---|
| `NodeTimeout(d)` | Deadline for one interruptible node. |
| `SpecTimeout(d)` | Deadline for the whole spec lifecycle — `It` only. |
| `GracePeriod(d)` | How long Ginkgo waits after cancelling before leaking the node. |
- `SpecTimeout` can only be more lenient than a node's `NodeTimeout` — it caps the sum of all nodes (`BeforeEach`+`It`+`AfterEach`); a per-node `NodeTimeout` inside it is always more stringent.
- Cleanup still runs after a timeout. When `SpecTimeout` fires, Ginkgo cancels the current node, then runs `AfterEach`/`AfterAll`/`DeferCleanup` (each under its own `NodeTimeout`/grace). The timeout is a "mark failed" threshold, not a hard kill.
- A node that ignores cancellation is "leaked," not killed. After the grace period Ginkgo gives up waiting and moves on. Leaking is deliberately preferred over hanging forever — but a leaked goroutine keeps running and can call `Fail`/`AddReportEntry` and pollute a *later* spec. Always make blocking code respond to `ctx.Done()`.
- `DeferCleanup`/`DescribeTable` entries are interruptible too — give the cleanup/entry func a `ctx` first arg; don't capture and reuse the parent node's `ctx` (it's already cancelled by cleanup time).
## Interrupting the Whole Suite
- `ginkgo --timeout=DURATION` — suite-wide budget across all suites (default `1h`).
- `Abort("reason")` — end the suite immediately from within a spec (programmatic interrupt).
- `SIGINT`/`SIGTERM` (`^C`) — interrupt: cancel the current interruptible node, run its cleanup + reporting nodes, skip the rest, exit failed. Escalation: a second interrupt skips cleanup (still runs reporting); a third bails immediately. To inspect a suite without stopping it, send `SIGINFO`/`SIGUSR1` for a progress report.
## Async Assertions: Eventually / Consistently
Use Gomega's `Eventually` (polls until the matcher passes or it times out) and `Consistently` (polls and requires the matcher to hold the whole interval — the way to assert something *doesn't* happen). Three input shapes: bare values (channels, `gbytes`), functions returning `(value[, error])`, and functions taking a `Gomega`.
```go
It("publishes a book", func(ctx SpecContext) {
buffer := gbytes.NewBuffer()
c := publisher.Publish(ctx, book, buffer) // pass ctx so it cancels cleanly
Eventually(ctx, buffer).Should(gbytes.Say(`Publish complete!`))
var result publisher.PublishResult
Eventually(ctx, c).WithTimeout(time.Second).Should(Receive(&result)) // poll, don't <-c
}, SpecTimeout(time.Second*30))
```
Propagate the spec deadline into the poll with `.WithContext(ctx)` or the positional `Eventually(ctx, ...)` — now a node timeout/interrupt makes the `Eventually` exit immediately instead of running its own clock. Gomega also auto-injects the context and `.WithArguments(...)` into a polled function whose first params are `(ctx)`, so `Eventually(client.Connect).WithContext(ctx).Should(Succeed())` works (pass the method reference, not `client.Connect()`).
### Assertions Inside the Polled Function — Use g, Never Global Expect
Pass a function taking `func(g Gomega, ...)` and assert with `g.Expect` so a failed poll retries instead of failing the spec outright:
```go
Eventually(func(g Gomega, ctx SpecContext) { // g Gomega must be first
messages, err := gmail.Fetch(ctx, jane.EmailAddress)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(messages).To(ContainElement(WithTransform(subjectOf, Equal(want))))
}).WithContext(ctx).Should(Succeed()) // Succeed() = "no failures in the func"
```
Using the global `Expect` inside an `Eventually` defeats the retry — the first failure calls Ginkgo's `Fail` and the spec dies with no second attempt. The local `g` lets `Eventually` catch and re-poll.
## Goroutines: The Two Rules That Bite
```go
It("repaginates", func() {
done := make(chan any)
go func() {
defer GinkgoRecover() // RULE 1
Expect(book.SetFontSize(28)).To(Succeed())
close(done)
}()
Eventually(done).Should(BeClosed()) // RULE 2: poll, don't block on <-done
})
```
1. Any goroutine that may call `Fail` or a Gomega assertion needs `defer GinkgoRecover()`. Ginkgo can't recover a panic raised on a goroutine it didn't start — without this, one failed assertion crashes the entire suite.
2. Don't block the spec on a channel fed by a failing goroutine. If the goroutine fails before `close(done)`, a bare `<-done` blocks until the node *times out* instead of reporting the real failure. `Eventually(done).Should(BeClosed())` lets the failure surface immediately.
Get new playbooks like this one
One email a week with new Claude Code workflows. Free, like everything here.
No spam. Unsubscribe anytime.
What This Does
A focused reference for Ginkgo's interruptible-node model and asynchronous testing idioms, built on Ginkgo's actual failure mechanics: a failed assertion is a panic Ginkgo recovers, and a node becomes interruptible simply by accepting a SpecContext or context.Context that Ginkgo cancels on timeout or interrupt. Covers the three timeout decorators (NodeTimeout for one node, SpecTimeout for the whole It lifecycle, GracePeriod for how long Ginkgo waits before giving up on a non-cooperating node), suite-wide interruption via --timeout, Abort, and SIGINT/SIGTERM escalation, and the fact that cleanup still runs after a timeout fires — a timeout is a "mark failed" threshold, not a hard kill.
The async-assertion half covers Gomega's Eventually/Consistently with correct context propagation (.WithContext(ctx) so a node timeout cancels the poll immediately instead of running its own clock) and the critical g.Expect pattern for assertions inside a polled function — using the global Expect there silently defeats retry. It closes with the two goroutine rules that cause the most confusing Ginkgo failures: missing defer GinkgoRecover() crashes the entire suite instead of failing one spec, and blocking on a channel fed by a failing goroutine hangs the node until timeout instead of reporting the real failure.
Quick Start
Step 1: Create a Project Folder
mkdir ginkgo-async && cd ginkgo-async
Step 2: Download the Template
Click Download above, then:
mv ~/Downloads/CLAUDE.md ./
Step 3: Write or Debug Async Specs
claude
Point Claude at a Ginkgo spec that hangs, times out unexpectedly, or crashes the whole suite from one goroutine failure. It will apply the interruptible-node model and the goroutine rules to diagnose and fix it correctly.
Tips & Best Practices
- Propagate
ctxinto every blocking call inside an interruptible node — aNodeTimeoutonly cancels the context; it's the code's job to actually stop whenctx.Done()fires. - Always use
.WithContext(ctx)(or the positionalEventually(ctx, ...)form) so a spec-level timeout can cut short a stuck poll instead of lettingEventuallyrun out its own independent clock. - Any goroutine that touches a Gomega assertion needs
defer GinkgoRecover()as its first line — treat this as non-negotiable, since the failure mode without it (an entire suite crash) is far worse than a normal spec failure.
Limitations
- Covers timeouts and asynchronous testing specifically — general Ginkgo authoring (container/subject/setup nodes, table specs) is a separate topic.
ctx.Deadline()does not report a node's actual Ginkgo-managed deadline — code relying on it directly for timing logic will get incorrect values; use<-ctx.Done().- Concepts are Ginkgo/Gomega-specific; the underlying Go
context.Contextcancellation pattern transfers elsewhere, but the decorator names and grace-period mechanics do not.