Working with a lot of large structs in tests can be a bit of a pain.
While this is an extreme example I made up so I could get a video, I have encountered (and written) similarly structured code in real life.
If we zoom in on one test, you get something like this:
What makes this painful?
- It takes effort to identify relevant fields, you need to read the test name and match it to the struct fields in your head.
- Requires touching each test for certain changes. If you add a new required field to
Post
, you will probably need to touch every existing test case. - Large structs take up screen space, making it harder to navigate your code base.
I think itโs easy to end up with tests like these if youโre not tidying up.
When youโre developing a new feature you generally need to add tests, preferably without disturbing the ones already in place.
Often, the easiest way to add a new test is to copy-paste an existing test and adapt it. If you donโt take steps to tidy up afterwards you will eventually end up with something resembling the video.
If youโre not tidying up because of lack of energy and/or time, I wonโt be able to help you. However, if youโre unsure how to tidy up, youโre in the right place :)
This "tidying up" is called refactoring: it means restructuring your code without changing its external behavior.
The situation
Letโs give ourselves some code to work with.
Imagine weโre working on a blogging application:
- The user drafts (potentially incomplete) blog posts. Maybe they only want to start with a headline, or a small blurb to kick off a new post.
- But, before posts can be published they need to follow some quality rules.
We are tasked with creating a function that determines if a blog post is ready to be published.
To begin, we need a blog post struct:
To check if a Post
is ready to be published we will implement an IsPublishable
function:
Post
will be a function input, however the techniques in this article can also be applied to other test data. Think of things like, expected output structs, mock expectations etc.
For our example, a Post
is considered publishable when:
Title
is not empty.Description
is not empty.Content
is not empty.MetaTitle
can be empty, but should be different fromTitle
when provided.MetaDescription
can be empty, but should be different fromDescription
when provided.MetaKeywords
can be empty, but there should be less than 5.Published
isfalse
, only drafts can be published.
IsPublishable
does not need to worry about validation. If a field is non-empty the value will already have been validated elsewhere.
The tests
Weโre not going to focus on the implementation of IsPublishable
, but on the tests that verify it.
I consider this the minimum test cases we need:
Test post | Is Publishable? |
---|---|
empty | no |
missing title | no |
missing description | no |
missing content | no |
already published | no |
not yet published | yes |
same meta title and title | no |
diff. meta title and title | yes |
same meta desc. and desc. | no |
diff meta desc. and desc. | yes |
single keyword | yes |
max nr. of keywords | yes |
too many keywords | no |
If you have trouble coming up with test cases, it can help to write your tests first (and verify that they fail) before writing the implementation.
We will begin by implementing them in the โcopy-pasteโ style we discussed earlier. Below you can find all cases as individual sub tests.
As you can see, each test follows the same pattern.
We can make this pattern explicit by restructuring the tests as table tests.
Table tests
Table tests are a common pattern in Go: You create a โtable of test dataโ by storing the data for each case in a slice or map. You can then iterate over your cases and run a sub test for each.
To identify what data to store in the table we can look at the elements that change per test. For our tests that will be two things:
- The input
Post
. - The expected result of
IsPublishable
.
I like to store my table tests in maps, because you can conveniently name them by using a string
as a key.
There is no order to the elements in a map, so tests will be ran in random order. If you need your tests to run in a specific order, use a slice.
To store our tests in a table we will need a table that looks something like this:
We can then run our tests by iterating over this table:
If we apply this to our earlier test cases, we get the following code. This is essentially an implementation of the table we saw in the last section.
Splitting tables
When your table grows larger it can become unwieldy. You can then split your table into several smaller tables.
I tend to split table tests according to their expected result, this often results in one table for โsuccess casesโ and one for โerror casesโ.
If we split our table according to expected results we get:
- publishable for cases where expect
IsPublishable
to betrue
. - notPublishable for cases where we expecte
IsPublishable
to befalse
.
These two tables also mean the want
field is no longer needed. Which in turn means we can get rid of the entire anonymous struct and have two maps of type map[string]Post
.
If we apply all that to our code we get the following:
Modification functions
In the above sections we restructured the tests, but we havenโt really looked at the Post
declarations. Most of them share the same fields and values.
The process for specifying these fields can be described as:
- Start out with a
Title
,Description
andContent
. - Do a small modification that makes the
Post
publishable or not publishable.
We can make this pattern explicit by using functions as values:
- For each test we create a
Post
with some sensible defaults. - Our testing table will contain functions that modify the default post.
- We then call
IsPublishable
with the modified post.
Adapting our testing tables to use a โmodifying functionโ (modFunc
for short) looks like this:
Because a modFunc
takes a pointer, any changes made to p
will be visible outside of it.
p
. If required you can replace the entire struct by using *p = Post{}
.
We also need to adapt our โtest execution loopโ to create a default Post
and call each modFunc
:
If we apply this to our test cases it looks like this:
As you can see this drastically cuts the number of fields we need to specify in the tables. It also emphasizes which fields are relevant to which case.
The downside is that you canโt see the final Post
in the source code anymore. Breakpoints and debuggers are your friends when working with tests like this.
If youโre test code is getting difficult to manage, I think this is a trade-off worth making.
Outro
I hope this article gave you some ideas on how to keep your tests manageable. We discussed:
- Consolidating similarly shaped tests into table tests.
- Splitting table tests into multiple tables to keep things manageable.
- Using modification functions to make struct declaration more concise.
If you know of any other ways to simplify working with tests, have questions and/or comments: Let me know :)
Thatโs it for now. Have a good one!
Get my free newsletter every second week
Used by 500+ developers to boost their Go skills.
"I'll share tips, interesting links and new content. You'll also get a brief guide to time for developers for free."