Table-driven tests are great because they remove the need for duplicate testing setup for similar test cases. In following this approach, the input and the expected output of these test cases are conveniently placed in a list (the table).
I’ve come across folks trying to write a table-driven test even though it increases the complexity of the test (anecdotal?). For such instances, separate tests for the test cases would’ve resulted in easier-to-understand code.
To demonstrate what I seen, here’s a sample HTTP handler that accepts a POST JSON request to create a TODO:
func (c *Controller) CreateTODO(w http.ResponseWriter, r *http.Request) {
// Is method allowed
if r.Method != http.MethodPost {
http.Error(w, "method is not POST", http.StatusMethodNotAllowed)
return
}
// Authentication call
token := r.Header.Get("AuthToken")
if !c.auth.IsAuthenticated(token) {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
// Decoding and validation
todo := &TODO{}
if err := json.NewDecoder(r.Body).Decode(todo); err != nil {
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
return
}
if err := todo.Validate(); err != nil {
http.Error(w, "invalid todo: "+err.Error(), http.StatusBadRequest)
return
}
// Database call
if err := c.db.CreateTODO(todo); err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
respond(w, 201, "todo created")
}
There are broadly 4 steps here before a TODO is considered to be successfully created:
- Validation of allowed method (POST)
- Authentication via an auth client
- Decoding of JSON from request body, and validation of fields
- Database INSERT call via a DB client/ORM
The auth client c.auth
and db client c.db
are fields of the Controller
struct.
type Controller struct {
auth Authenticator
db Database
}
type Authenticator interface {
IsAuthenticated(token string) bool
}
type Database interface {
CreateTODO(todo *TODO) error
}
Mocks can be generated for these interfaces to be used in unit tests.
Good table-driven test
The 3rd step (Decoding of JSON from request body, and validation of fields) from the above is a great candidate for table-driven tests because there could be multiple test inputs that can share the same testing setup. Here’s a small list of these:
- Invalid JSON in the request body
- Empty TODO name
- Empty TODO category
All of these will result in Bad Requests, albeit different response bodies.
A table-driven test for it would look something like this:
func TestController_CreateTODO_BadRequestErrors(t *testing.T) {
testCases := []struct {
name string
requestBody string
expectedResponse string
}{
{"invalid json", `{"name"}`, "invalid json: invalid character '}' after object key\n"},
{"empty name", `{"name": ""}`, "invalid todo: empty name\n"},
{"empty category", `{"name": "task1", "category": ""}`, "invalid todo: empty category\n"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Setup mocks
c := gomock.NewController(t)
defer c.Finish()
auth := mock.NewMockAuthenticator(c)
db := mock.NewMockDatabase(c)
ctrl := api.NewController(auth, db)
// Setup response recorder and request
w := httptest.NewRecorder()
rBody := bytes.NewBufferString(tc.requestBody)
r := httptest.NewRequest(http.MethodPost, "http://example.com/todos", rBody)
r.Header.Add("AuthToken", testToken)
// Expectations from mocks
auth.EXPECT().IsAuthenticated(testToken).Return(true)
// Call HTTP handler
ctrl.CreateTODO(w, r)
resp := w.Result()
// Assertions
assertEqual(t, 400, resp.StatusCode)
assertEqual(t, tc.expectedResponse, responseBody(resp))
})
}
}
Full code for this snippet can be found in this file: controller_test.go.
Bad table-driven test
If you try to write a table-driven test that covers 1st, 2nd, and 4th step, and the success case, it would look like this:
func TestController_CreateTODO_BadTableDrivenTest(t *testing.T) {
testCases := []struct {
name string
requestMethod string
expectAuthCall bool
authCallReturn bool
expectDBCall bool
dbCallReturn error
expectedStatusCode int
expectedResponse string
}{
{"method not allowed", http.MethodGet, false, false, false, nil, 405, "method is not POST\n"},
{"unauthenticated", http.MethodPost, true, false, false, nil, 401, "unauthenticated\n"},
{"db error", http.MethodPost, true, true, true, errors.New("failed to commit txn"), 500, "db error: failed to commit txn\n"},
{"success", http.MethodPost, true, true, true, nil, 201, "todo created"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Setup mocks …
// Setup response recorder and request …
if tc.expectAuthCall {
auth.EXPECT().IsAuthenticated(testToken).Return(tc.authCallReturn)
}
if tc.expectDBCall {
db.EXPECT().CreateTODO(&api.TODO{"task1", "cat1"}).Return(tc.dbCallReturn)
}
ctrl.CreateTODO(w, r)
resp := w.Result()
assertEqual(t, tc.expectedStatusCode, resp.StatusCode)
assertEqual(t, tc.expectedResponse, responseBody(resp))
})
}
}
Notice in the table:
requestMethod
is really needed only for the “method not allowed” case but has to be present even for other test cases.expectAuthCall
andexpectDBCall
are needed to set mock expectations (EXPECT()
) for certain test cases.authCallReturn
anddbCallReturn
are needed byEXPECT()
calls.
Overall, trying to fit multiple dissimilar cases into a table-driven test has resulted in a more complicated test.
Contrarily, if you were to separate these test cases into separate tests, then granted, there will be more code, but the tests will be easier to understand. For instance, if the “method not allowed” case was its own separate test, it would look something like this:
func TestController_CreateTODO_MethodNotAllowed(t *testing.T) {
// Setup mocks …
// Setup response recorder and request …
ctrl.CreateTODO(w, r)
resp := w.Result()
assertEqual(t, 405, resp.StatusCode)
assertEqual(t, "method is not POST\n", responseBody(resp))
}
The test case itself is simple because no client calls are expected, but adding it as a part of the table-driven test made it more complicated.
If this HTTP handler doesn’t look complex enough, then understand that most HTTP handlers are way more sophisticated. For instance, they might use more clients (besides auth and db) for external services (e.g. cache). Also, EXPECT()
calls might require different arguments for different test cases, which will then need to be part of the table - making the table bigger.
Conclusion
Obvious to many, but not to some: Avoid writing a table-driven test where test cases have different testing setups.
Working sample code for everything here can be found in this repo: github.com/shivamMg/table-driven-tests-go.
All tests shown here can be found in this file: controller_test.go.
Thank you Avinash for reviewing the post’s draft.