Skip to content

Commit e0d2d1a

Browse files
authored
table tests: make guidance less prescriptive (#174)
1 parent bd5d08e commit e0d2d1a

File tree

3 files changed

+276
-4
lines changed

3 files changed

+276
-4
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 2023-05-09
2+
3+
- Test tables: Discourage tables with complex, branching test bodies.
4+
15
# 2023-04-13
26

37
- Errors: Add guidance on handling errors only once.

src/test-table.md

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
# Test Tables
22

3-
Use table-driven tests with [subtests] to avoid duplicating code when the core
4-
test logic is repetitive.
3+
Table-driven tests with [subtests] can be a helpful pattern for writing tests
4+
to avoid duplicating code when the core test logic is repetitive.
5+
6+
If a system under test needs to be tested against _multiple conditions_ where
7+
certain parts of the the inputs and outputs change, a table-driven test should
8+
be used to reduce redundancy and improve readability.
59

610
[subtests]: https://blog.golang.org/subtests
711

@@ -100,6 +104,136 @@ for _, tt := range tests {
100104
}
101105
```
102106

107+
## Avoid Unnecessary Complexity in Table Tests
108+
109+
Table tests can be difficult to read and maintain if the subtests contain conditional
110+
assertions or other branching logic. Table tests should **NOT** be used whenever
111+
there needs to be complex or conditional logic inside subtests (i.e. complex logic inside the `for` loop).
112+
113+
Large, complex table tests harm readability and maintainability because test readers may
114+
have difficulty debugging test failures that occur.
115+
116+
Table tests like this should be split into either multiple test tables or multiple
117+
individual `Test...` functions.
118+
119+
Some ideals to aim for are:
120+
121+
* Focus on the narrowest unit of behavior
122+
* Minimize "test depth", and avoid conditional assertions (see below)
123+
* Ensure that all table fields are used in all tests
124+
* Ensure that all test logic runs for all table cases
125+
126+
In this context, "test depth" means "within a given test, the number of
127+
successive assertions that require previous assertions to hold" (similar
128+
to cyclomatic complexity).
129+
Having "shallower" tests means that there are fewer relationships between
130+
assertions and, more importantly, that those assertions are less likely
131+
to be conditional by default.
132+
133+
Concretely, table tests can become confusing and difficult to read if they use multiple branching
134+
pathways (e.g. `shouldError`, `expectCall`, etc.), use many `if` statements for
135+
specific mock expectations (e.g. `shouldCallFoo`), or place functions inside the
136+
table (e.g. `setupMocks func(*FooMock)`).
137+
138+
However, when testing behavior that only
139+
changes based on changed input, it may be preferable to group similar cases
140+
together in a table test to better illustrate how behavior changes across all inputs,
141+
rather than splitting otherwise comparable units into separate tests
142+
and making them harder to compare and contrast.
143+
144+
If the test body is short and straightforward,
145+
it's acceptable to have a single branching pathway for success versus failure cases
146+
with a table field like `shouldErr` to specify error expectations.
147+
148+
<table>
149+
<thead><tr><th>Bad</th><th>Good</th></tr></thead>
150+
<tbody>
151+
<tr><td>
152+
153+
```go
154+
func TestComplicatedTable(t *testing.T) {
155+
tests := []struct {
156+
give string
157+
want string
158+
wantErr error
159+
shouldCallX bool
160+
shouldCallY bool
161+
giveXResponse string
162+
giveXErr error
163+
giveYResponse string
164+
giveYErr error
165+
}{
166+
// ...
167+
}
168+
169+
for _, tt := range tests {
170+
t.Run(tt.give, func(t *testing.T) {
171+
// setup mocks
172+
ctrl := gomock.NewController(t)
173+
xMock := xmock.NewMockX(ctrl)
174+
if tt.shouldCallX {
175+
xMock.EXPECT().Call().Return(tt.giveXResponse, tt.giveXErr)
176+
}
177+
yMock := ymock.NewMockY(ctrl)
178+
if tt.shouldCallY {
179+
yMock.EXPECT().Call().Return(tt.giveYResponse, tt.giveYErr)
180+
}
181+
182+
got, err := DoComplexThing(tt.give, xMock, yMock)
183+
184+
// verify results
185+
if tt.wantErr != nil {
186+
require.EqualError(t, err, tt.wantErr)
187+
return
188+
}
189+
require.NoError(t, err)
190+
assert.Equal(t, want, got)
191+
})
192+
}
193+
}
194+
```
195+
196+
</td><td>
197+
198+
```go
199+
func TestShouldCallX(t *testing.T) {
200+
// setup mocks
201+
ctrl := gomock.NewController(t)
202+
xMock := xmock.NewMockX(ctrl)
203+
xMock.EXPECT().Call().Return("XResponse", nil)
204+
205+
yMock := ymock.NewMockY(ctrl)
206+
207+
got, err := DoComplexThing("inputX", xMock, yMock)
208+
209+
require.NoError(t, err)
210+
assert.Equal(t, "want", got)
211+
}
212+
213+
func TestShouldCallYAndFail(t *testing.T) {
214+
// setup mocks
215+
ctrl := gomock.NewController(t)
216+
xMock := xmock.NewMockX(ctrl)
217+
218+
yMock := ymock.NewMockY(ctrl)
219+
yMock.EXPECT().Call().Return("YResponse", nil)
220+
221+
_, err := DoComplexThing("inputY", xMock, yMock)
222+
assert.EqualError(t, err, "Y failed")
223+
}
224+
```
225+
</td></tr>
226+
</tbody></table>
227+
228+
This complexity makes it more difficult to change, understand, and prove the
229+
correctness of the test.
230+
231+
While there are no strict guidelines, readability and maintainability should
232+
always be top-of-mind when deciding between Table Tests versus separate tests
233+
for multiple inputs/outputs to a system.
234+
235+
## Parallel Tests
236+
103237
Parallel tests, like some specialized loops (for example, those that spawn
104238
goroutines or capture references as part of the loop body),
105239
must take care to explicitly assign loop variables within the loop's scope to

style.md

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3588,8 +3588,12 @@ See also [go vet: Printf family check](https://kuzminva.wordpress.com/2017/11/07
35883588

35893589
### Test Tables
35903590

3591-
Use table-driven tests with [subtests](https://blog.golang.org/subtests) to avoid duplicating code when the core
3592-
test logic is repetitive.
3591+
Table-driven tests with [subtests](https://blog.golang.org/subtests) can be a helpful pattern for writing tests
3592+
to avoid duplicating code when the core test logic is repetitive.
3593+
3594+
If a system under test needs to be tested against *multiple conditions* where
3595+
certain parts of the the inputs and outputs change, a table-driven test should
3596+
be used to reduce redundancy and improve readability.
35933597

35943598
<table>
35953599
<thead><tr><th>Bad</th><th>Good</th></tr></thead>
@@ -3686,6 +3690,136 @@ for _, tt := range tests {
36863690
}
36873691
```
36883692

3693+
#### Avoid Unnecessary Complexity in Table Tests
3694+
3695+
Table tests can be difficult to read and maintain if the subtests contain conditional
3696+
assertions or other branching logic. Table tests should **NOT** be used whenever
3697+
there needs to be complex or conditional logic inside subtests (i.e. complex logic inside the `for` loop).
3698+
3699+
Large, complex table tests harm readability and maintainability because test readers may
3700+
have difficulty debugging test failures that occur.
3701+
3702+
Table tests like this should be split into either multiple test tables or multiple
3703+
individual `Test...` functions.
3704+
3705+
Some ideals to aim for are:
3706+
3707+
* Focus on the narrowest unit of behavior
3708+
* Minimize "test depth", and avoid conditional assertions (see below)
3709+
* Ensure that all table fields are used in all tests
3710+
* Ensure that all test logic runs for all table cases
3711+
3712+
In this context, "test depth" means "within a given test, the number of
3713+
successive assertions that require previous assertions to hold" (similar
3714+
to cyclomatic complexity).
3715+
Having "shallower" tests means that there are fewer relationships between
3716+
assertions and, more importantly, that those assertions are less likely
3717+
to be conditional by default.
3718+
3719+
Concretely, table tests can become confusing and difficult to read if they use multiple branching
3720+
pathways (e.g. `shouldError`, `expectCall`, etc.), use many `if` statements for
3721+
specific mock expectations (e.g. `shouldCallFoo`), or place functions inside the
3722+
table (e.g. `setupMocks func(*FooMock)`).
3723+
3724+
However, when testing behavior that only
3725+
changes based on changed input, it may be preferable to group similar cases
3726+
together in a table test to better illustrate how behavior changes across all inputs,
3727+
rather than splitting otherwise comparable units into separate tests
3728+
and making them harder to compare and contrast.
3729+
3730+
If the test body is short and straightforward,
3731+
it's acceptable to have a single branching pathway for success versus failure cases
3732+
with a table field like `shouldErr` to specify error expectations.
3733+
3734+
<table>
3735+
<thead><tr><th>Bad</th><th>Good</th></tr></thead>
3736+
<tbody>
3737+
<tr><td>
3738+
3739+
```go
3740+
func TestComplicatedTable(t *testing.T) {
3741+
tests := []struct {
3742+
give string
3743+
want string
3744+
wantErr error
3745+
shouldCallX bool
3746+
shouldCallY bool
3747+
giveXResponse string
3748+
giveXErr error
3749+
giveYResponse string
3750+
giveYErr error
3751+
}{
3752+
// ...
3753+
}
3754+
3755+
for _, tt := range tests {
3756+
t.Run(tt.give, func(t *testing.T) {
3757+
// setup mocks
3758+
ctrl := gomock.NewController(t)
3759+
xMock := xmock.NewMockX(ctrl)
3760+
if tt.shouldCallX {
3761+
xMock.EXPECT().Call().Return(tt.giveXResponse, tt.giveXErr)
3762+
}
3763+
yMock := ymock.NewMockY(ctrl)
3764+
if tt.shouldCallY {
3765+
yMock.EXPECT().Call().Return(tt.giveYResponse, tt.giveYErr)
3766+
}
3767+
3768+
got, err := DoComplexThing(tt.give, xMock, yMock)
3769+
3770+
// verify results
3771+
if tt.wantErr != nil {
3772+
require.EqualError(t, err, tt.wantErr)
3773+
return
3774+
}
3775+
require.NoError(t, err)
3776+
assert.Equal(t, want, got)
3777+
})
3778+
}
3779+
}
3780+
```
3781+
3782+
</td><td>
3783+
3784+
```go
3785+
func TestShouldCallX(t *testing.T) {
3786+
// setup mocks
3787+
ctrl := gomock.NewController(t)
3788+
xMock := xmock.NewMockX(ctrl)
3789+
xMock.EXPECT().Call().Return("XResponse", nil)
3790+
3791+
yMock := ymock.NewMockY(ctrl)
3792+
3793+
got, err := DoComplexThing("inputX", xMock, yMock)
3794+
3795+
require.NoError(t, err)
3796+
assert.Equal(t, "want", got)
3797+
}
3798+
3799+
func TestShouldCallYAndFail(t *testing.T) {
3800+
// setup mocks
3801+
ctrl := gomock.NewController(t)
3802+
xMock := xmock.NewMockX(ctrl)
3803+
3804+
yMock := ymock.NewMockY(ctrl)
3805+
yMock.EXPECT().Call().Return("YResponse", nil)
3806+
3807+
_, err := DoComplexThing("inputY", xMock, yMock)
3808+
assert.EqualError(t, err, "Y failed")
3809+
}
3810+
```
3811+
</td></tr>
3812+
</tbody></table>
3813+
3814+
This complexity makes it more difficult to change, understand, and prove the
3815+
correctness of the test.
3816+
3817+
While there are no strict guidelines, readability and maintainability should
3818+
always be top-of-mind when deciding between Table Tests versus separate tests
3819+
for multiple inputs/outputs to a system.
3820+
3821+
#### Parallel Tests
3822+
36893823
Parallel tests, like some specialized loops (for example, those that spawn
36903824
goroutines or capture references as part of the loop body),
36913825
must take care to explicitly assign loop variables within the loop's scope to

0 commit comments

Comments
 (0)