When has a Go test table's case struct grown too many fields, and what do you do about it?
answer
- rows are cheap, columns are not
- flags most rows never set
- a branch per column means two tests
- omitted fields take the zero value
- split the table before widening it
basics
~20 sWhen the loop body branches on flags most rows leave unset, the table describes two behaviours at once. Split it into a second test function, and pick any new field's zero value so existing rows keep their meaning.
solid answer
~50 sAdding a row to a table is cheap; adding a **column** is not. The warning signs are a loop body full of `if tc.strict { ... }` branches, fields only two of forty rows set, and a `want` that means different things depending on another field. At that point the single loop body is no longer one specification, and a reader cannot tell what any given row asserts without cross-referencing three columns. The Go-specific hazard is the zero value: because rows are keyed struct literals, adding `maxDepth int` compiles cleanly and silently gives all forty existing rows `maxDepth == 0`, so their meaning changes without anyone editing them. Either make the zero value mean the previous behaviour, or accept the churn of setting it everywhere. The usual fix is a second `Test` function with its own small table, or a per-case `setup func(t *testing.T)` field when the difference really is setup rather than behaviour.
code
go · 10 linestype tmplCase struct {
name string
in string
want string
// Added in a later PR. All 40 existing keyed literals now carry
// maxDepth == 0 without anyone editing them - and 0 has to mean
// "the old behaviour", or those 40 rows quietly changed meaning.
maxDepth int
}go deeper
Know that omitted fields in a keyed struct literal take the zero value, so a new column in a test table quietly applies to every row already there.
Be able to name the smells - flags only a couple of rows set, a loop body that branches, a want field whose meaning depends on another column - and propose splitting the table.
Demonstrate the review judgment: choose the new field's polarity so its zero value preserves existing assertions, and prefer a second test function over a wider case struct.
Argue for the property that makes tables pay off - adding a case costs one line - and treat anything that raises that cost across a codebase as a maintenance decision, not a style preference.
## Rows are cheap, columns are not The table-driven pattern earns its keep because the marginal cost of a case is one line of data. That property degrades as the case struct grows: every new field is paid for by every existing row and by every future reader, and at some point the loop body stops being a specification and becomes a small interpreter for the table. The signals that you have crossed that line: - **The body branches on case fields.** One deliberate branch that every row sets — `wantErr` — is fine, because it partitions the table into two clearly labelled halves. Three independent booleans give eight possible paths and no reader will check that all eight are exercised. - **A field is set by two rows out of forty.** That field is not a property of the cases; it is a special case wearing a column. - **The meaning of `want` depends on another field.** If `want` is the parsed result in some rows and the error text in others, there are two tests here. - **The name column stops describing the behaviour** because the behaviour now lives in a combination of flags. ## The Go-specific hazard: the zero value Rows are conventionally written as keyed struct literals: ```go {name: "addition", in: "1+2", want: "3"}, ``` Keyed literals may omit any field, and omitted fields take the type's zero value. That is normally a virtue — you only write the columns a case cares about. It also means **adding a field to the case struct compiles without touching a single existing row**, and every one of them now carries `0`, `""` or `false` for the new column. That is a silent semantic change to forty assertions. If the loop body starts honouring `tc.maxDepth`, and `maxDepth == 0` means "no nesting allowed", then forty rows that used to test arbitrary nesting now test something else, and they may well still pass. Go gives you no help here: there is no optional-parameter syntax, no `Option[int]`, no way for the compiler to say "this row never considered the question". So the rule when adding a column is: **the zero value must mean what the existing rows already meant.** Pick the field's polarity to make that true — `skipNormalisation bool` rather than `normalise bool`, `maxDepth int` where 0 means unlimited — or use a pointer or a small enum when there genuinely is no safe default, so an unset row is distinguishable from a deliberate zero. Positional literals, incidentally, behave in the opposite way: adding a field breaks compilation of every row until it is updated. Loud, annoying, and occasionally exactly what you want for a table that must not silently drift. ## What to do instead **Split into a second test function.** Two tables of eight straightforward rows read better than one table of sixteen rows with a flag column, and each gets its own descriptive `Test` name, which is what CI reports. This is nearly always the right answer and is what a reviewer should push for. **Give the case a function field** when the variation is genuinely *setup* rather than *behaviour*: a `setup func(t *testing.T)` or `prepare func(t *testing.T) *Parser` column keeps the assertion single-pathed while letting an unusual row arrange its own world. Use this sparingly — a table where most rows carry a closure has become the per-case code the table was meant to replace. **Extract a helper with parameters.** If two behaviours share checking logic, write `assertParses(t *testing.T, in, want string)` and call it from two small table-driven tests, rather than merging the tables and branching. **Keep the common case in the table and the exotic case in its own test.** One hand-written test function for the pathological input is honest, readable, and does not tax thirty-nine other rows. ## Reviewing the change When a reviewer asks for "one more case", the answer is usually yes: a new row costs a line and buys coverage. When the request implies a new column, ask three questions. Does the zero value of the new field preserve every existing row's meaning? Will the loop body need a branch on it? Would a separate test function with three rows say the same thing more plainly? If the answers are no, yes, and yes, the table has outgrown itself — and the right patch is a second `Test` function, not a wider struct.
- Why does adding a field to the case struct not break the existing rows?Keyed struct literals may omit any field, and omitted fields take the type's zero value, so every row compiles and quietly acquires 0, "" or false for the new column. Positional literals behave the opposite way: adding a field fails to compile until every row is updated, which is sometimes the safer choice.
- When is a func field in the case struct justified?When the variation is setup rather than asserted behaviour - one row needs a parser configured differently or a file staged on disk. A setup func(t *testing.T) column keeps the assertion on one path. If most rows carry a closure, the table has turned back into per-case code and should be split.
- A reviewer asks for one more row in the table. When would you push back?Almost never for a row - that is the pattern working. Push back when the row cannot be expressed without a new column, or when it needs the loop body to branch. Then propose a second test function with its own small table so both specifications stay readable.
saying these in an interview costs you the question
- Adds a column and assumes existing rows are unaffected
- Treats a table with eight boolean flags as still one test
- Chooses a field polarity whose zero value changes old rows
- Puts assertion closures in most rows of the table
- Splits by input type rather than by asserted behaviour