The perfect unit test

There’s a common theme I find with people who tell me that they don’t find unit
testing useful, and it’s normally that they are writing bad tests. This is
completely understandable, particularly if you’re newer to unit testing. It’s
hard to write good …

There’s a common theme I find with people who tell me that they don’t find unit
testing useful, and it’s normally that they are writing bad tests. This is
completely understandable, particularly if you’re newer to unit testing. It’s
hard to write good tests and it takes practice to get there. All the things
we’re going to talk about today were learned the hard way; the pain of bad unit
tests lead me to creating my own rules for how to write a good unit test. It’s
these rules that we’re going to talk about today.

Why are bad tests so bad?

When you have application code that is messy, it’s hard to work with. But
hopefully you have some tests alongside it, and those help you. It’s OK to work
with hard code if you’ve got tests backing you up. That confidence tests give
you can go along way to erasing the effect of bad code.

Bad tests don’t have any code to help you work with them. You don’t write tests
for your tests. You could, but then you’d have to write tests for your tests
for your tests and that’s a spiral none of us want to go down…

Characteristics of bad tests

It’s hard to define a set of traits that make a bad test, because a bad test is
really any test that doesn’t follow the rules we’re about to talk about.

If you’ve ever looked at a test and had no idea what it’s testing, or you can’t
obviously spot the assertion, that’s a bad test. A test with a poorly written
description (it('works') is a personal favourite) is a bad test.

Tests are bad if you don’t find them useful. The entire point of having
tests is to increase your productivity, workflow and confidence in your
codebase. If a test isn’t doing that (or actively making it worse), it’s a bad
test.

I firmly believe that bad tests are worse than no tests.

A good test starts with a good name

The good news is that the rules of a good test are easy to remember and very
intuitive once you’ve got used to them!

A good test has a succinct, descriptive name. If you can’t come up with a
short name, prefer clarity over saving on line length.

it('filters products based on the query-string filters', () => {})

You should be able to know just from the description what a test’s purpose is
for. You’ll sometimes see people name it tests based on the method it tests
instead:

it('#filterProductsByQueryString', () => {})

But this doesn’t help – imagine being new to this code and trying to figure out
exactly what the function does. In this case the name is pretty descriptive, but
an actual human readable string is always better if you can come up with one.

Another guide line for naming tests is to ensure you can read the sentence with
the it at the beginning. So if I’m reading the test below, in my head I read
one big sentence:

"it filters products based on the query-string filters"

it('filters products based on the query-string filters', () => {})

Tests that don’t do this, even if the string is descriptive, feel clunky:

it('the query-string is used to filter products', () => {})

The three parts of a good test

Once you’ve got your test named well it’s time to focus on the body. A good test
follows the same pattern every single time:

it('filters products based on the query-string filters', () => {
// STEP ONE: SETUP
// STEP TWO: INVOKE CODE
// STEP THREE: ASSERT
})

Let’s go through each of those steps in turn.

Setup

The first stage of any unit test is the setup: this is where you get your test
data in order, or mock any functions that you might need to for this test to
run.

it('filters products based on the query-string filters', () => {
// STEP ONE: SETUP
const queryString = '?brand=Nike&size=M'

const products = [
{ brand: 'Nike', size: 'L', type: 'sweater' },
{ brand: 'Adidas', size: 'M', type: 'tracksuit' },
{ brand: 'Nike', size: 'M', type: 't-shirt' },
]

// STEP TWO: INVOKE CODE
// STEP THREE: ASSERT
})

The set up should establish everything you need to perform the test. In this
case I’m creating the query string and the list of products that I’m going to
use to test against. Notice my choice of data for the products too: I’ve got
items that deliberately don’t match the query string, along with one that does.
If I only had products that matched the query string, this test wouldn’t prove
that the filtering works.

Invoke code

This step is normally the shortest: you should call the function that you need
to test. Your test data will have been created by the first step, so you should
just be passing variables into a function at this point.

it('filters products based on the query-string filters', () => {
// STEP ONE: SETUP
const queryString = '?brand=Nike&size=M'

const products = [
{ brand: 'Nike', size: 'L', type: 'sweater' },
{ brand: 'Adidas', size: 'M', type: 'tracksuit' },
{ brand: 'Nike', size: 'M', type: 't-shirt' },
]

// STEP TWO: INVOKE CODE
const result = filterProductsByQueryString(products, queryString)

// STEP THREE: ASSERT
})

If the test data is very short, I might merge step one and two, but most of
the time I find the value in splitting the steps out very explicitly to be
worth the extra lines it takes up.

Assert

This is the best step! It’s where all your hard work pays off and we check that
what we’re expecting to happen actually did.

I call this the assert step as we’re making assertions, but these days I tend to
use Jest and it’s expect function, so you could call this the "Expectation
Step" too if you wanted.

it('filters products based on the query-string filters', () => {
// STEP ONE: SETUP
const queryString = '?brand=Nike&size=M'

const products = [
{ brand: 'Nike', size: 'L', type: 'sweater' },
{ brand: 'Adidas', size: 'M', type: 'tracksuit' },
{ brand: 'Nike', size: 'M', type: 't-shirt' },
]

// STEP TWO: INVOKE CODE
const result = filterProductsByQueryString(products, queryString)

// STEP THREE: ASSERT
expect(result).toEqual([{ brand: 'Nike', size: 'M', type: 't-shirt' }])
})

And with that, we have a perfect unit test:

  1. It has a descriptive name that reads clearly and is succinct.
  2. It has a clear setup phase where we construct test data.
  3. The invoking step is limited to simply calling our function with our test
    data.
  4. Our assertion is clear and demonstrates the behaviour we’re testing clearly.

Small improvements

Whilst I wouldn’t actually include the // STEP ONE: SETUP comments in my real
tests, I do find it useful to put a blank line between all three parts. So if
this test was in my codebase for real, it would look like this:

it('filters products based on the query-string filters', () => {
const queryString = '?brand=Nike&size=M'
const products = [
{ brand: 'Nike', size: 'L', type: 'sweater' },
{ brand: 'Adidas', size: 'M', type: 'tracksuit' },
{ brand: 'Nike', size: 'M', type: 't-shirt' },
]

const result = filterProductsByQueryString(products, queryString)

expect(result).toEqual([{ brand: 'Nike', size: 'M', type: 't-shirt' }])
})

If we’re building a system that has products in it, I’d look to create an easier
way to create these products. I created the
test-data-bot library to do
exactly this. I won’t dive into how it works, but it lets you easily create
factories to create test data. If we had that setup (the README has full
instructions) we could have this test like so:

it('filters products based on the query-string filters', () => {
const queryString = '?brand=Nike&size=M'
const productThatMatches = productFactory({ brand: 'Nike', size: 'M' })

const products = [
productFactory({ brand: 'Nike', size: 'L' }),
productFactory({ brand: 'Adidas', size: 'M' }),
productThatMatches,
]

const result = filterProductsByQueryString(products, queryString)

expect(result).toEqual([productThatMatches])
})

By doing this we remove all the details of products that are irrelevant to this
test (notice how the type field is not present in our test now) and lets us
easily keep our test data in sync with the real data by updating our factory.

I also pull the product that I want to match out into its own constant so we can
reuse it in the assertion step. This avoids duplication and makes the test
clearer – having a piece of test data titled productThatMatches is a strong
hint that it’s what we’re expecting our function to return.

Conclusion

If you have these rules in mind whilst writing unit tests I’m confident that
you’ll find your tests easier to work with and more useful in your development
workflow. Testing is just like anything else: it takes time and practice.
Remember the three steps: setup, invoke, assert and you’ll be writing
perfect unit tests before you know it ?.


Print Share Comment Cite Upload Translate
APA
Jack Franklin | Sciencx (2024-03-29T12:19:53+00:00) » The perfect unit test. Retrieved from https://www.scien.cx/2019/04/12/the-perfect-unit-test/.
MLA
" » The perfect unit test." Jack Franklin | Sciencx - Friday April 12, 2019, https://www.scien.cx/2019/04/12/the-perfect-unit-test/
HARVARD
Jack Franklin | Sciencx Friday April 12, 2019 » The perfect unit test., viewed 2024-03-29T12:19:53+00:00,<https://www.scien.cx/2019/04/12/the-perfect-unit-test/>
VANCOUVER
Jack Franklin | Sciencx - » The perfect unit test. [Internet]. [Accessed 2024-03-29T12:19:53+00:00]. Available from: https://www.scien.cx/2019/04/12/the-perfect-unit-test/
CHICAGO
" » The perfect unit test." Jack Franklin | Sciencx - Accessed 2024-03-29T12:19:53+00:00. https://www.scien.cx/2019/04/12/the-perfect-unit-test/
IEEE
" » The perfect unit test." Jack Franklin | Sciencx [Online]. Available: https://www.scien.cx/2019/04/12/the-perfect-unit-test/. [Accessed: 2024-03-29T12:19:53+00:00]
rf:citation
» The perfect unit test | Jack Franklin | Sciencx | https://www.scien.cx/2019/04/12/the-perfect-unit-test/ | 2024-03-29T12:19:53+00:00
https://github.com/addpipe/simple-recorderjs-demo