A Dog’s Life With Test Fixtures

… or how not to make it feel so

Rafał Lewandowski
Boclips Product & Engineering Blog
6 min readJun 21, 2022

--

Raúl and Alex on the lookout for improved unit testing experiences

It’s tricky to get away without unit tests in software projects that are meant to be put to practical use and go beyond a small handful of classes, functions or source files. They help with building confidence in code’s correctness, catch regressions, provide documentation and, if you practice Test-Driven Development (🤞), steer you towards lean implementations.

An average Joe of a unit test consists of three steps:

  1. setup — where you ensure that all the data and other dependencies necessary to execute the test are in place,
  2. exercise—where you invoke the piece of code under test,
  3. verify—where you assert your expectations against the results.

The preconditions that we need to get together in the first one are sometimes called a test fixture. It’s a pretty broad term and can range from a set of variables that you initialise and pass to the function being tested, through preloaded database entries, up to an entire testing environment. This post is about the first of those.

Initialising variables is no rocket science, but as your codebase grows and evolves it may sometimes get out of hand and become a bit of a drudgery. It shouldn’t be — test code, just like any code, should be comfortable to work with. Let’s take a look at different approaches for initialising them and see if there are ways to make our lives easier.

Just construct it!

Let’s consider aDog class. It represents a crucial business domain entity, but at the moment is still in its early days and is quite simple. A dog has a name and a list of mischief it may have perpetrated recently:

Side note: the example above (and most others in this article) is in Kotlin. If you haven’t used Kotlin but are familiar with Java, data classes are a convenient way of defining POJOs. All the usual boilerplate code is generated for you.

We’d like to make sure that all dogs are able to say that they’re a good boy (or girl) if they conducted no mischief:

The benefit here is simplicity — we just create a dog instance by calling the class constructor. Being a basic language concept, the constructor is easily understood and saves us from creating additional helpers or importing third party libraries. Everything is in one place, improving test readability.

Of course this simplicity comes with a catch. As soon as Dog gets a new field or the type of one of the existing fields changes, a ripple goes through all your tests that rely on creating dogs. Your text editor/IDE may be of help to some extent, but a change like this one usually means revisiting all the places where the constructor is invoked.

Another thing to note is that readability will decrease with each additional property. It’s easy to wrap your head around a class with just a few properties, but it’s quite the opposite if they become hefty:

That’s a lot of typing for such a simple test case! It’s also unclear which properties are actually relevant for the assertion at hand and which ones are there just to satisfy the constructor signature—the test has effectively become more difficult to read and understand.

Fake it till you make it

In situations where we don’t test the class itself, but need an instance of it as input to another function, a viable way of working around the problem of verbose initialisation is to use stubs. Stub is a test double that’s meant to return whatever it’s been pre-programmed with:

I suppose you’re more likely to see stubbing used for more complex behaviours like database repositories or services talking to external APIs.

Using them to provide simple inputs that would otherwise be tedious to assemble can be fine too. What we’re ultimately meaning to do is highlight the relevant parts and keep the test concise, which is in line with the general idea of test doubles. Beside maintaining readability, using a stub has also put us in a safe spot with regard to Dog‘s constructor signature changes—future field additions or deletions won’t affect the test above (unless you decide to remove the relevant property itself).

Help yourself

Another approach that you’re probably familiar with is to have a helper function that will hide the complexity of initialisation away and return an instance with expected data.

Not only are we keeping the tests lean and readable, but we’re also reusing some code, which arguably is a pretty good place to be in. The number of lines that need to be changed when we modify the Dog class in the future is reduced as well.

What I believe is important here is making sure that the properties relevant to given test suite are explicitly configurable as input parameters. Doing so highlights the important bits and makes going through the test feel like reading a consistent story.

Alternatively, implicit configuration can be denoted in the helper function name. For example, if we had a suite of tests that were interested in mannerliness of border collies, we could have something like this:

Conversely, a thing to avoid would be for your test to implicitly rely on specific external setup. Let’s consider a test of a function that identifies good boys (males):

Code above will be quite confusing unless you happen to remember what the helper function is doing internally. Avoiding such obscurities gets even more important once you start extracting helpers into their own components to use across multiple test suites. It usually pays off to create a new, dedicated helper and stay readable.

But in an ideal world we’d like to keep the configuration transparent while having a convenient way of supporting many different scenarios. After all, nobody likes to type more than is actually needed! How to achieve that? This question transitions us smoothly to…

Builders

Configuring new object instances in different ways can be handled in a neat manner with builders. They typically look something like this:

Builder gives us flexibility to vary setups and brings readability. We get to set only the fields that are relevant for the test we’re working on right now, any others are handled for us. What’s more, if Dog gets enriched with a new property in the future — barking style, perhaps — all we need to do is update the builder. Existing tests oblivious to barking will just continue to work.

I’ve intentionally excluded the builder’s implementation because it can be verbose. Writing one yourself is likely still better than relying on the class constructor everywhere, but there are ways to make this easy. Lombok is an example of a library that will generate the boilerplate, but sometimes features built directly into the language can be just what the doctor ordered!

Here we’ve used Kotlin’s named and default function arguments to achieve the same functionality as we’d get with a “proper” builder. Another example in TypeScript below. It mostly relies on JavaScript spread operator for object literals, but the Partial for type safety deserves a mention as well:

The examples are in languages I’m familiar with, but I would expect most of their modern peers to provide a convenient way of achieving the above in one way or another. Be curious about how your language can be of assistance — you may be spared some typing or a dependency import.

What’s additionally worth mentioning is that the helpers shown above also implement the factory pattern, because if we want any dog and aren’t picky about names, breeds etc. we can just invoke them with no parameters.

The ease of use and versatility provided by the factory/builder approach makes it our favourite way of getting test data set up at Boclips.

I recommend that you give it a whirl!

Wrapping up

Assigning variables is a small and inconspicuous area. We often don’t think about it much, but it can have impact just like any other part of the codebase. To summarise:

  • write your tests in a way that will make them easy to refactor and play along with the rest of the code as it grows,
  • strive to make your fixtures tell a story of what’s gonna be happening in the test. Use descriptive names and mercilessly remove any superfluous noise,
  • keep revisiting areas you usually take for granted and think if they can be improved to your benefit.

Test code is the backbone of your software and should ultimately be treated with equal care as any part of the production code. It should be easy to understand. It should be convenient to refactor and extend. It should be fun to work with. Give it the attention it deserves and let it be your friend 🐶

--

--

Rafał Lewandowski
Boclips Product & Engineering Blog

Software engineer. Making videos a first-class citizen of educational tools at Boclips.