Ginkgo Table and Dynamic Specs
Parameterize and generate Ginkgo test specs correctly — DescribeTable/Entry, the four ways to name an entry, DescribeTableSubtree for multi-It rows, reusable entry sets, and the one gotcha (Entry params evaluate at construction time, not BeforeEach time) that causes most table-spec bugs.
A DescribeTable entry that reads shelf["Les Miserables"] looks correct and compiles fine — but it reads a nil map, because Ginkgo evaluates every Entry argument when the test tree is built, before a single BeforeEach has run, and that one timing fact breaks more table-driven Go tests than any other Ginkgo quirk.
Who it's for: Go developers writing table-driven tests with Ginkgo and Gomega, teams migrating from repetitive It blocks to DescribeTable, anyone debugging a table spec where an Entry sees a nil or zero-value variable, engineers extracting shared It blocks across Contexts into reusable closures
Example
"Why does my DescribeTable entry get a nil pointer even though BeforeEach sets it up?" → The construction-time-vs-run-time explanation with a wrong/right code pair, plus the struct-per-row pattern for tables with many parameters and DescribeTableSubtree for entries that need multiple It blocks each
New here? 3-minute setup guide → | Already set up? Copy the template below.
# Ginkgo Table and Dynamic Specs
Parameterize and generate Go test specs with Ginkgo's `DescribeTable`/`Entry` DSL and the idioms for generating specs from loops and data. All of it is syntactic sugar that runs during the tree-construction phase, before any spec actually runs — the gotchas below all follow from that one fact.
Prefer `DescribeTable`/`DescribeTableSubtree` with shared configuration over multiple repeated `It`s.
## DescribeTable + Entry
`DescribeTable(desc, specFunc, ...Entry)` generates one container holding one `It` per `Entry`. `Entry(desc, params...)`'s params are passed to `specFunc` at run time and must match `specFunc`'s signature — you get a clear runtime message if they don't.
```go
DescribeTable("Extracting the author's first and last name",
func(author string, isValid bool, firstName, lastName string) {
book := &books.Book{Title: "My Book", Author: author, Pages: 10}
Expect(book.IsValid()).To(Equal(isValid))
Expect(book.AuthorFirstName()).To(Equal(firstName))
Expect(book.AuthorLastName()).To(Equal(lastName))
},
Entry("both names", "Victor Hugo", true, "Victor", "Hugo"),
Entry("one name", "Hugo", true, "", "Hugo"),
Entry("no name", "", false, "", ""),
)
```
A `DescribeTable` is just a container, so nest it inside `Describe`/`Context` and surround it with `BeforeEach` — setup runs fresh before each entry's spec.
### The gotcha: Entry params are evaluated at construction time
`Entry(...)` arguments are evaluated when the tree is built — before any `BeforeEach` has run. An `Entry` cannot read a variable initialized in `BeforeEach`; it will see the zero value (a `nil` map or pointer).
```go
var shelf map[string]*books.Book
BeforeEach(func() { shelf = loadShelf() }) // runs at RUN time
// WRONG — shelf is nil when Entry is evaluated at construction time
DescribeTable("category", func(b *books.Book, c books.Category) { ... },
Entry("novel", shelf["Les Miserables"], books.CategoryNovel), // nil pointer!
)
// RIGHT — pass a key, dereference shelf inside the spec closure (run time)
DescribeTable("category", func(key string, c books.Category) {
Expect(shelf[key].Category()).To(Equal(c))
},
Entry("novel", "Les Miserables", books.CategoryNovel),
)
```
## The Four Ways to Describe an Entry
| Mechanism | How |
|---|---|
| Explicit string | `Entry("both names", ...)` |
| `nil` | `Entry(nil, 1, 2, 3)` → auto-named from params: `Entry: 1, 2, 3` |
| Table-level description closure | pass `func(a,b,c int) string {...}` as the 3rd arg to `DescribeTable`; renders every `nil` entry |
| `EntryDescription(fmt)` | `EntryDescription("%d + %d = %d")` as the 3rd arg to `DescribeTable`, or per-entry |
The description closure must return `string` and accept the same params as `specFunc`. Per-entry, the first arg may itself be a closure or `EntryDescription` (overrides the table default):
```go
DescribeTable("addition", func(a, b, c int) { Expect(a + b).To(Equal(c)) },
EntryDescription("%d + %d = %d"), // table default
Entry(nil, 1, 2, 3), // "1 + 2 = 3"
Entry("zeros", 0, 0, 0), // explicit
Entry(EntryDescription("%[3]d = %[1]d + %[2]d"), 10, 100, 110), // per-entry override
Entry(func(a, b, c int) string { return fmt.Sprintf("%d = %d", a+b, c) }, 4, 3, 7),
)
```
## Decorating Entries
`Entry` and `DescribeTable` accept every Ginkgo decorator: `Entry("flaky case", FlakeAttempts(3), ...)`, `Entry(..., Label("slow"))`. Focus/pending shortcuts: `FEntry`/`PEntry`/`XEntry` (and `FDescribeTable`/`PDescribeTable`). `PEntry` needs no params; focus/pending obey the same precedence as everywhere else in Ginkgo.
## DescribeTableSubtree — Many Its Per Row
When you want a whole subtree (multiple `It`s, their own setup) per entry, use `DescribeTableSubtree`. Its body runs at construction time, once per entry, inside a fresh container — you must place `It`s inside it or no specs are generated:
```go
DescribeTableSubtree("handling requests",
func(url string, code int, message string) {
var resp *http.Response
BeforeEach(func() {
var err error
resp, err = http.Get(url)
Expect(err).NotTo(HaveOccurred())
DeferCleanup(resp.Body.Close)
})
It("returns the status code", func() { Expect(resp.StatusCode).To(Equal(code)) })
It("returns the message", func() {
body, _ := io.ReadAll(resp.Body)
Expect(string(body)).To(Equal(message))
})
},
Entry("default", "example.com/response", http.StatusOK, "hello world"),
Entry("missing", "example.com/missing", http.StatusNotFound, "wat?"),
)
```
## Patterns
- **Struct-per-row** for many params — inscrutable positional entries like `Entry(nil, 12, 1.2, 8.5, 11, 2783)` are unreadable. Define a type and pass it: `Entry(nil, BookFormatting{FontSize: 12, LineHeight: 1.2, ...}, 2783)`.
- **Reusable `[]TableEntry`** — share one entry set across tables: `var InvalidBooks = []TableEntry{ Entry("empty", &books.Book{}), ... }`, then `DescribeTable("storing errors", storeFn, InvalidBooks)` and `DescribeTable("reading errors", readFn, InvalidBooks)`. Or feed the slice to `DescribeTableSubtree` to attach multiple specs per entry.
## Loading Fixture Data: Do It in TestXxx, Not BeforeSuite
If the spec structure depends on external data, that data must be available *during* tree construction. `BeforeSuite` runs in the run phase — too late; a loop reading a `BeforeSuite`-populated slice generates zero specs. Load it in the `TestXxx` bootstrap function before `RunSpecs`:
```go
var fixtureBooks []*books.Book
func TestBooks(t *testing.T) {
RegisterFailHandler(Fail)
g := NewGomegaWithT(t) // wrap t to assert before RunSpecs
fixtureBooks = LoadFixturesFrom("./fixtures/books.json")
g.Expect(fixtureBooks).NotTo(BeEmpty())
RunSpecs(t, "Books Suite")
}
var _ = Describe("fixtures", func() {
for _, book := range fixtureBooks { // populated before construction — works
book := book
It("stores "+book.Title, func() { Expect(library.Store(book)).To(Succeed()) })
}
})
```
This works because `TestBooks` runs before tree construction, so `fixtureBooks` is populated when the loop runs, and because the function passed to `Describe` is not invoked until tree construction time.
## Shared Behaviors
To reuse identical `It`s across `Context`s that differ only in setup, put the `It`s in a closure and call it inside each `Context` body — it runs at construction time, adding those specs to each context. Because the closure is defined in the same scope, it closes over the shared variable that each `Context`'s `BeforeEach` configures:
```go
AssertFailedBehavior := func() {
It("can't be stored", func() { Expect(library.IsStorable(book)).To(BeFalse()) })
It("fails to store", func() { Expect(library.Store(book)).To(MatchError(books.ErrStoringBook)) })
}
Context("when the book has no title", func() {
BeforeEach(func() { book = &books.Book{Author: "Victor Hugo", Pages: 2783} })
AssertFailedBehavior()
})
Context("when the book is nil", func() {
BeforeEach(func() { book = nil })
AssertFailedBehavior()
})
```
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 table-driven and dynamically generated spec idioms, anchored on the one fact that explains every gotcha: DescribeTable, Entry, and loop-generated specs are all syntactic sugar that runs during Ginkgo's tree-construction phase, before any BeforeEach or spec body executes. Covers DescribeTable/Entry syntax, the four ways to name or auto-name an entry, decorating entries with the same decorators as any other node, DescribeTableSubtree for entries that need multiple Its and their own setup, the struct-per-row pattern for tables with many parameters, reusable []TableEntry slices shared across tables, and the correct place to load fixture data that determines spec structure (TestXxx before RunSpecs, never BeforeSuite).
The centerpiece is the construction-time-vs-run-time distinction: an Entry's arguments are evaluated when the tree is built, so an entry that tries to read a BeforeEach-initialized variable silently gets the zero value — a nil map or pointer — instead of an error. The fix (pass a key, dereference inside the spec closure) and the same principle applied to shared-behavior closures across Contexts round out the reference.
Quick Start
Step 1: Create a Project Folder
mkdir ginkgo-tables && cd ginkgo-tables
Step 2: Download the Template
Click Download above, then:
mv ~/Downloads/CLAUDE.md ./
Step 3: Write Table Specs
claude
Point Claude at a Go test file using Ginkgo and ask it to convert repetitive It blocks into a DescribeTable, or to debug a table spec that's seeing unexpected zero values. It will apply the construction-time rule correctly and pick the right entry-naming and row-sharing pattern for the situation.
Tips & Best Practices
- If an
Entryneeds data that only exists after setup runs, pass a lookup key instead of the value itself, and resolve it inside the spec closure — the fix is almost always this shape. - Switch from positional
Entry(nil, 12, 1.2, 8.5, 11, 2783)arguments to a named struct the moment a table grows past two or three parameters; unreadable entries are the most common table-spec code-review complaint. - Use
DescribeTableSubtreeinstead ofDescribeTablethe moment one row needs more than one assertion or its ownBeforeEach— trying to cram multiple concerns into a singleItper entry is a sign you want the subtree form.
Limitations
- Covers table and dynamic specs specifically — general Ginkgo authoring (container/subject/setup nodes,
JustBeforeEach,GinkgoHelper()) is a separate topic. - Assumes familiarity with Ginkgo's two-phase construction/run model; if that model itself is unfamiliar, read Ginkgo's own overview docs first.
- Code samples target Ginkgo's Go DSL — the concepts (deferred evaluation, construction- vs. run-time data) don't transfer directly to other BDD frameworks with different execution models.