Dealing with large structs in tests

Avatar of the author Willem Schots
7 Sep, 2023
~17 min.
RSS

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 {
	// ...
}

In our examples 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 from Title when provided.
  • MetaDescription can be empty, but should be different from Description when provided.
  • MetaKeywords can be empty, but there should be less than 5.
  • Published is false, 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.

main.go
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.

main.go
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 be true.
  • notPublishable for cases where we expecte IsPublishable to be false.

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:

main.go
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:

  1. Start out with a Title, Description and Content.
  2. Do a small modification that makes the Post publishable or not publishable.

We can make this pattern explicit by using functions as values:

  1. For each test we create a Post with some sensible defaults.
  2. Our testing table will contain functions that modify the default post.
  3. 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.

You are not limited to modifying fields on 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:

main.go
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!

🎓

Subscribe to my Newsletter and Keep Learning.

Gain access to more content and get notified of the latest articles:

I send emails every 1-2 weeks and will keep your data safe. You can unsubscribe at any time.

Hello! I'm the Willem behind willem.dev

I created this website to help new Go developers, I hope it brings you some value! :)

You can follow me on Twitter/X, LinkedIn or Mastodon.

Thanks for reading!