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:
t.Run("diff meta title and title, publishable", func(t *testing.T) {
p := Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaTitle: "SEO Optimized Title",
}
assertTrue(t, IsPublishable(p))
})
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:
// Post represents a large struct.
type Post struct {
Title string
Description string
MetaTitle string
MetaDescription string
MetaKeywords []string
Published bool
// For readability we will go for these
// 6 fields, but there can potentially
// be many more.
}
To check if a Post
is ready to be published we will implement an IsPublishable
function:
func IsPublishable(p Post) bool {
// ...
}
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.
package main
import (
"testing"
)
func TestIsPublishable(t *testing.T) {
t.Run("empty, not publishable", func(t *testing.T) {
p := Post{}
assertFalse(t, IsPublishable(p))
})
t.Run("missing title, not publishable", func(t *testing.T) {
p := Post{
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
}
assertFalse(t, IsPublishable(p))
})
t.Run("missing description, not publishable", func(t *testing.T) {
p := Post{
Title: "My smile is stuck",
Content: "My spirit is made up of the ocean",
}
assertFalse(t, IsPublishable(p))
})
t.Run("missing content, not publishable", func(t *testing.T) {
p := Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
}
assertFalse(t, IsPublishable(p))
})
t.Run("min, published, not publishable", func(t *testing.T) {
p := Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
Published: true,
}
assertFalse(t, IsPublishable(p))
})
t.Run("min, not published, publishable", func(t *testing.T) {
p := Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
}
assertTrue(t, IsPublishable(p))
})
t.Run("same meta title and title, not publishable", func(t *testing.T) {
p := Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaTitle: "My smile is stuck",
}
assertFalse(t, IsPublishable(p))
})
t.Run("diff meta title and title, publishable", func(t *testing.T) {
p := Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaTitle: "SEO Optimized Title",
}
assertTrue(t, IsPublishable(p))
})
t.Run("same meta desc and desc, not publishable", func(t *testing.T) {
p := Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaDescription: "I cannot go back to your frownland",
}
assertFalse(t, IsPublishable(p))
})
t.Run("diff meta desc and desc, publishable", func(t *testing.T) {
p := Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaDescription: "SEO Optimized description",
}
assertTrue(t, IsPublishable(p))
})
t.Run("single keyword, publishable", func(t *testing.T) {
p := Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaKeywords: []string{"frownland"},
}
assertTrue(t, IsPublishable(p))
})
t.Run("max keywords, publishable", func(t *testing.T) {
p := Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaKeywords: []string{
"my", "smile", "is", "stuck", "i",
},
}
assertTrue(t, IsPublishable(p))
})
t.Run("too many keywords, not publishable", func(t *testing.T) {
p := Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaKeywords: []string{
"my", "smile", "is", "stuck", "i", "can't",
},
}
assertFalse(t, IsPublishable(p))
})
}
func assertTrue(t *testing.T, v bool) {
t.Helper()
if !v {
t.Errorf("expected true, but got false")
}
}
func assertFalse(t *testing.T, v bool) {
t.Helper()
if v {
t.Errorf("expected false, but got true")
}
}
// Post represents a large struct.
type Post struct {
Title string
Description string
Content string
MetaTitle string
MetaDescription string
MetaKeywords []string
Published bool
}
func IsPublishable(p Post) bool {
if p.Title == "" || p.Description == "" || p.Content == "" ||
p.Published ||
p.Title == p.MetaTitle ||
p.Description == p.MetaDescription ||
len(p.MetaKeywords) > 5 {
return false
}
return true
}
As you can see, each test follows the same pattern.
t.Run("<name>", func(t *testing.T){
// 1. setup a Post{}
// 2. get result of IsPublishable
// 3. verify the result
})
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:
tests := map[string]struct{
post Post
want bool
}{
"<case name>": {
post: Post{}, // input of a test case.
want: false, // expected result of a case.
},
// ...
}
We can then run our tests by iterating over this table:
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got := IsPublishable(tc.post)
if got != tc.want {
t.Errorf("wanted %v, but got %v", tc.want, got)
}
})
}
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.
package main
import (
"testing"
)
func TestIsPublishable(t *testing.T) {
tests := map[string]struct {
post Post
want bool
}{
"empty, not publishable": {
post: Post{},
want: false,
},
"missing title, not publishable": {
post: Post{
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
},
want: false,
},
"missing description, not publishable": {
post: Post{
Title: "My smile is stuck",
Content: "My spirit is made up of the ocean",
},
want: false,
},
"missing content, not publishable": {
post: Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
},
want: false,
},
"min, published, not publishable": {
post: Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
Published: true,
},
want: false,
},
"min, not published, publishable": {
post: Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
Published: false,
},
want: true,
},
"same meta title and title, not publishable": {
post: Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaTitle: "My smile is stuck",
},
want: false,
},
"diff meta title and title, publishable": {
post: Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaTitle: "SEO Optimized Title",
},
want: true,
},
"same meta desc and desc, not publishable": {
post: Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaDescription: "I cannot go back to your frownland",
},
want: false,
},
"diff meta desc and desc, publishable": {
post: Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaDescription: "Take my hand and come with me",
},
want: true,
},
"single keyword, publishable": {
post: Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaKeywords: []string{"frownland"},
},
want: true,
},
"max keywords, publishable": {
post: Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaKeywords: []string{
"my", "smile", "is", "stuck", "i",
},
},
want: true,
},
"too many keywords, not publishable": {
post: Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaKeywords: []string{
"my", "smile", "is", "stuck", "i", "can't",
},
},
want: false,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got := IsPublishable(tc.post)
if got != tc.want {
t.Errorf("wanted %v, but got %v", tc.want, got)
}
})
}
}
// Post represents a large struct.
type Post struct {
Title string
Description string
Content string
MetaTitle string
MetaDescription string
MetaKeywords []string
Published bool
}
func IsPublishable(p Post) bool {
if p.Title == "" || p.Description == "" || p.Content == "" ||
p.Published ||
p.Title == p.MetaTitle ||
p.Description == p.MetaDescription ||
len(p.MetaKeywords) > 5 {
return false
}
return true
}
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:
package main
import (
"testing"
)
func TestIsPublishable(t *testing.T) {
publishable := map[string]Post{
"min, not published": {
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
Published: false,
},
"different meta title and title": {
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaTitle: "SEO Optimized Title",
},
"different meta description and description": {
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaDescription: "SEO Optimized description",
},
"single keyword": {
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaKeywords: []string{"frownland"},
},
"max keywords": {
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaKeywords: []string{
"my", "smile", "is", "stuck", "i",
},
},
}
for name, tc := range publishable {
t.Run(name, func(t *testing.T) {
assertTrue(t, IsPublishable(tc))
})
}
notPublishable := map[string]Post{
"empty": {},
"missing title": {
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
},
"missing description": {
Title: "My smile is stuck",
Content: "My spirit is made up of the ocean",
},
"missing content": {
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
},
"min, published": {
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
Published: true,
},
"same meta title and title": {
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaTitle: "My smile is stuck",
},
"same meta description and description": {
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaDescription: "I cannot go back to your frownland",
},
"too many keywords": {
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
MetaKeywords: []string{
"my", "smile", "is", "stuck", "i", "can't",
},
},
}
for name, tc := range notPublishable {
t.Run(name, func(t *testing.T) {
assertFalse(t, IsPublishable(tc))
})
}
}
func assertTrue(t *testing.T, v bool) {
t.Helper()
if !v {
t.Errorf("expected true, but got false")
}
}
func assertFalse(t *testing.T, v bool) {
t.Helper()
if v {
t.Errorf("expected false, but got true")
}
}
// Post represents a large struct.
type Post struct {
Title string
Description string
Content string
MetaTitle string
MetaDescription string
MetaKeywords []string
Published bool
}
func IsPublishable(p Post) bool {
if p.Title == "" || p.Description == "" || p.Content == "" ||
p.Published ||
p.Title == p.MetaTitle ||
p.Description == p.MetaDescription ||
len(p.MetaKeywords) > 5 {
return false
}
return true
}
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:
type modFunc func(p *Post)
publishable := map[string]modFunc{
"<case name>": func(p *Post) {
// modify default Post here.
},
// ...
}
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
:
for name, mFunc := range publishable {
t.Run(name, func(t *testing.T) {
// create a default post
p := Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
}
// the modification function modifies the default post
mFunc(&p)
// test with the modified post.
assertTrue(t, IsPublishable(p))
})
}
If we apply this to our test cases it looks like this:
package main
import (
"testing"
)
func TestIsPublishable(t *testing.T) {
type modFunc func(*Post)
publishable := map[string]modFunc{
"min, not published": func(p *Post) {
// nothing to do
},
"different meta title and title": func(p *Post) {
p.MetaTitle = "SEO Optimized Title"
},
"different meta description and description": func(p *Post) {
p.MetaDescription = "SEO Optimized description"
},
"single keyword": func(p *Post) {
p.MetaKeywords = []string{"frownland"}
},
"max keywords": func(p *Post) {
p.MetaKeywords = []string{
"my", "smile", "is", "stuck", "i",
}
},
}
for name, mFunc := range publishable {
t.Run(name, func(t *testing.T) {
p := Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "My spirit is made up of the ocean",
}
mFunc(&p)
assertTrue(t, IsPublishable(p))
})
}
notPublishable := map[string]modFunc{
"empty": func(p *Post) {
*p = Post{}
},
"missing title": func(p *Post) {
p.Title = ""
},
"missing description": func(p *Post) {
p.Description = ""
},
"missing content": func(p *Post) {
p.Content = ""
},
"min, published": func(p *Post) {
p.Published = true
},
"same meta title and title": func(p *Post) {
p.MetaTitle = "My smile is stuck"
},
"same meta description and description": func(p *Post) {
p.MetaDescription = "I cannot go back to your frownland"
},
"too many keywords": func(p *Post) {
p.MetaKeywords = []string{
"my", "smile", "is", "stuck", "i", "can't",
}
},
}
for name, mFunc := range notPublishable {
t.Run(name, func(t *testing.T) {
p := Post{
Title: "My smile is stuck",
Description: "I cannot go back to your frownland",
Content: "Content of a blog post",
}
mFunc(&p)
assertFalse(t, IsPublishable(p))
})
}
}
func assertTrue(t *testing.T, v bool) {
t.Helper()
if !v {
t.Errorf("expected true, but got false")
}
}
func assertFalse(t *testing.T, v bool) {
t.Helper()
if v {
t.Errorf("expected false, but got true")
}
}
// Post represents a large struct.
type Post struct {
Title string
Description string
Content string
MetaTitle string
MetaDescription string
MetaKeywords []string
Published bool
}
func IsPublishable(p Post) bool {
if p.Title == "" || p.Description == "" || p.Content == "" ||
p.Published ||
p.Title == p.MetaTitle ||
p.Description == p.MetaDescription ||
len(p.MetaKeywords) > 5 {
return false
}
return true
}
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."