Writing Ginkgo Specs
Author correct Ginkgo specs — container vs. subject vs. setup nodes, the declare-in-container/initialize-in-setup rule that prevents the most common Ginkgo bug, JustBeforeEach for separating creation from configuration, DeferCleanup for restore-not-clear teardown, and GinkgoHelper() for failure locations that point at the caller instead of the helper.
A Ginkgo spec that initializes a shared variable directly inside a Describe block instead of in BeforeEach looks fine and often passes — until specs run in a different random order and start mutating each other's state, because that variable was only ever set once, at test-tree construction time, not fresh per spec.
Who it's for: Go developers new to Ginkgo/Gomega BDD-style testing, teams reviewing Ginkgo specs for the declare-vs-initialize mistake, engineers extracting test helpers who want failure messages to point at the right line, anyone deciding between BeforeEach and JustBeforeEach for shared test setup
Example
"Why do my Ginkgo tests fail only when run in random order?" → The construction-time-vs-run-time explanation for why a variable initialized directly in a Describe body gets shared and mutated across specs, the fix (declare in the container, initialize in BeforeEach), and when to reach for JustBeforeEach instead to separate input configuration from object creation
New here? 3-minute setup guide → | Already set up? Copy the template below.
# Writing Ginkgo Specs
Author good Ginkgo specs: container nodes (`Describe`/`Context`/`When`), subject nodes (`It`/`Specify`), setup/cleanup nodes (`BeforeEach`, `JustBeforeEach`, `AfterEach`, `DeferCleanup`, `BeforeSuite`/`AfterSuite`), the "declare in container, initialize in setup" rule, separating creation from configuration, reusable test helpers with `GinkgoHelper()`, and `By`/`GinkgoWriter` output.
## The Shape of a Spec
```go
var _ = Describe("Books", func() {
var book *books.Book // declare here...
BeforeEach(func() {
book = &books.Book{ // ...initialize here, fresh per spec
Title: "Les Miserables",
Author: "Victor Hugo",
Pages: 2783,
}
Expect(book.IsValid()).To(BeTrue()) // assertions in setup are fine
})
Describe("categorizing", func() {
Context("with more than 300 pages", func() {
It("is a novel", func() {
Expect(book.Category()).To(Equal(books.CategoryNovel))
})
})
})
})
```
Containers (`Describe`/`Context`/`When`) organize; they're identical — pick the one that reads as a sentence. Subjects (`It`/`Specify`) hold the assertions; one spec runs per `It`. The spec's full name is the concatenation of every container text plus the `It` text — write them to read as a phrase.
## The Rule That Prevents Most Bugs: Declare in Container, Initialize in Setup
Container bodies run once, at tree-construction time. So:
- **Declare** shared variables in the container body (`var book *books.Book`).
- **Initialize** them in `BeforeEach` so each spec gets a clean copy.
```go
// WRONG — runs once at construction; every spec shares & pollutes one book
var _ = Describe("Books", func() {
book := &books.Book{Pages: 2783} // initialized at construction time
It("mutates", func() { book.Pages = 0 })
It("expects 2783", func() { Expect(book.Pages).To(Equal(2783)) }) // flaky under randomization
})
```
The same trap explains: no assertions in container bodies (they fire at construction, with no spec active) and no expensive/stateful work in container bodies. If you see logic directly inside a `Describe`/`Context`/`When` body, it almost always belongs in a `BeforeEach`.
## Setup and Cleanup Nodes — and Their Ordering
| Node | Runs |
|---|---|
| `BeforeEach` | Before each spec, outer→inner. The workhorse. |
| `JustBeforeEach` | After all `BeforeEach`es, just before the `It`. |
| `JustAfterEach` | Just after the `It`, before any `AfterEach`. |
| `AfterEach` | After each spec, inner→outer (reverse). |
| `BeforeSuite`/`AfterSuite` | Once, around the whole suite (top-level only). |
| `BeforeAll`/`AfterAll` | Once per `Ordered` container. |
`JustBeforeEach` separates creation from configuration. Let nested `BeforeEach`es *configure* inputs into declared variables, and do the single *creation* step in `JustBeforeEach` — so each context overrides just the inputs it cares about:
```go
var jsonString string
BeforeEach(func() { jsonString = `{"id":1,"name":"Sally"}` }) // base config
JustBeforeEach(func() { user, err = NewUser(jsonString) }) // creation, runs last
Context("with malformed JSON", func() {
BeforeEach(func() { jsonString = `{"oops"` }) // override one input
It("errors", func() { Expect(err).To(HaveOccurred()) })
})
```
Use it deliberately — deeply nested `JustBeforeEach`es get hard to follow.
## Cleanup: Prefer DeferCleanup, and Restore Rather Than Clear
`DeferCleanup` registers teardown next to the setup that needs it, and runs in LIFO order (like `defer`). It works in any setup/subject node and adapts to scope (called in `BeforeSuite`, it cleans up after the suite; in `BeforeEach`, after the spec):
```go
BeforeEach(func() {
original := os.Getenv("MODE")
os.Setenv("MODE", "test")
DeferCleanup(os.Setenv, "MODE", original) // captured args passed at cleanup time
})
```
- `DeferCleanup` accepts `func()`, `func() error` (a non-nil error fails the spec), captured arguments, and a `func(ctx SpecContext)` form.
- Restore original state; don't blindly clear it. `os.Unsetenv` after the test wrongly assumes the var started unset — capture and restore instead (as above).
- `DeferCleanup` is a function call, not a node — it's the one cleanup mechanism you may use inside setup/subject closures. You may not define nodes (`It`, `BeforeEach`, …) inside a running closure.
## Output: GinkgoWriter and By
- `GinkgoWriter` buffers logs and only prints them when a spec fails (or always under `-v`) — so passing specs stay quiet. Use `GinkgoWriter.Printf(...)`, or `GinkgoWriter.TeeTo(w)` to also stream live.
- `By("...")` annotates steps in a long spec; the annotations surface on failure (and under `-v`) to show how far the spec got. It records into the spec's timeline.
```go
It("processes an order", func() {
By("submitting the cart")
// ...
By("charging the card")
// ...
})
```
## Failures, in Brief
A failed Gomega assertion calls `Fail`, which panics; Ginkgo recovers it, marks the spec failed, and still runs cleanup. Code after a failed assertion in the same closure does not run. Use `Skip("reason")` to skip a spec at runtime.
## Test Helpers — Keep Failure Locations Honest with GinkgoHelper()
Extract repeated setup or assertions into plain Go functions. The catch: a `Fail` (or failed Gomega assertion) inside a helper reports the helper's own line — useless for knowing which call failed. Mark the helper with `GinkgoHelper()` and Ginkgo skips that frame when computing the failure location, pointing at the spec that called it instead:
```go
func expectValidBook(b *books.Book) {
GinkgoHelper() // this frame is ignored in failure locations
Expect(b).NotTo(BeNil())
Expect(b.IsValid()).To(BeTrue()) // a failure here is reported at the CALLER
}
It("accepts a good book", func() {
expectValidBook(book) // ← failures point here, not inside the helper
})
```
`GinkgoHelper()` composes. Mark every helper in a chain (`expectValidBook` → `expectStorable` → …) and the reported location is always the spec that kicked it off. Prefer it over the older manual frame-counting: `Fail(msg, offset)` or the `Offset(n)` decorator — those break the moment helpers call helpers, forcing you to bump every offset.
A helper that fails from a goroutine uses `GinkgoHelperGo` — it runs your code on a new goroutine, already implies `defer GinkgoRecover()`, and gives you a `helperFail` to use for the helper's own failures (so they still report at the call site); caller-supplied assertions report inline:
```go
func EnsureSprockets(n int, fn func(int)) {
GinkgoHelper()
GinkgoHelperGo(func(helperFail func(string, ...int)) { // implies defer GinkgoRecover()
if n == 0 {
helperFail("sprockets must not be zero") // reported at the EnsureSprockets call site
}
fn(n) // caller's assertions report inline
})
}
```
With Gomega, a helper's own assertions can run through `g := gomega.NewGomega(helperFail); g.Expect(...)`.
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 grounded reference for writing correct Ginkgo specs, built around Ginkgo's two-phase model: container bodies (Describe/Context/When) run once at tree-construction time, while setup and subject nodes (BeforeEach, It) run fresh per spec. That distinction produces the single rule that prevents most Ginkgo bugs — declare shared variables in the container, initialize them in BeforeEach — and explains why assertions and expensive work never belong directly inside a container body.
It covers the full setup/cleanup node table and their execution order (BeforeEach outer-to-inner, AfterEach inner-to-out, JustBeforeEach after all BeforeEaches for separating input configuration from object creation), DeferCleanup's LIFO teardown registered next to the setup it undoes (restore original state, don't just clear it), GinkgoWriter and By for output that only surfaces on failure, and GinkgoHelper()/GinkgoHelperGo for keeping failure locations pointed at the calling spec instead of buried inside a shared test helper.
Quick Start
Step 1: Create a Project Folder
mkdir ginkgo-specs && cd ginkgo-specs
Step 2: Download the Template
Click Download above, then:
mv ~/Downloads/CLAUDE.md ./
Step 3: Write or Review Specs
claude
Point Claude at a new or existing Ginkgo test file and ask it to write specs, review them for the declare/initialize mistake, or extract a shared assertion into a GinkgoHelper()-marked function. It will apply the container/setup distinction and the correct cleanup and helper patterns.
Tips & Best Practices
- Randomize spec execution order locally (
ginkgo --randomize-all) — it's the fastest way to surface a variable that was wrongly initialized in a container body instead ofBeforeEach. - Reach for
JustBeforeEachonly when severalContexts need to override just one input before a shared creation step; overusing it produces hard-to-follow nesting. - Mark every helper in a call chain with
GinkgoHelper(), not just the outermost one — it composes, and a single unmarked frame in the middle breaks the failure-location chain for everything above it.
Limitations
- Covers spec authoring specifically — table-driven specs and asynchronous/timeout testing are separate, related topics.
- The declare/initialize rule and node-ordering table are specific to Ginkgo's construction/run-phase model; they don't map directly onto Go's standard
testingpackage or other test frameworks. - Assumes Ginkgo and Gomega are already wired into the test suite (
RegisterFailHandler,RunSpecs) — initial suite bootstrap is out of scope.