loading...
Cover image for Don't use fixtures in Cypress and unit tests- use factories

Don't use fixtures in Cypress and unit tests- use factories

dgreene1 profile image Dan Greene ・7 min read

Unit tests are great... when they work dependably! In fact, there's an old saying that "a bad test is worse than no test at all." I can attest that weeks spent chasing down a randomly "false negative" test is not efficient. Instead, you could have been using that time to write business code that helps the user.

So let's talk about one of these easiest techniques to write less brittle tests: test data factories.

But before we get into what factory functions are and why you would want to use them, let's first try to understand the type of brittle test that they eliminate.

Aspects of tests we want to avoid

  1. tight coupling
  2. lack of type safety (which causes lengthy refactoring and bugs)
  3. giant fixture folders

Factory functions will fix all of that.

So what are factory functions?

A factory function is a function that creates an object. Simple as that. Yes, there is the "abstract factory" pattern popularized by the Gang Of Four's Design Pattern book decades ago. Let's make it nice and simple.

Let's make a function that makes it easy to make stuff so we can test more easily.

Here's the world's most simple example:

interface ISomeObj {
  percentage: string;
}

export const makeSomeObj = () => {
  return {
    percentage: Math.random()
  };
}

Let's see how such a simple pattern can be used to fix the aspects of brittle tests that we described above.

We'll start by describing how tests are typically written and then we'll evolve the solution iteratively as we solve each of the problems.

A Real World Example Of How Brittle Tests Occur

It all starts innocently. You or another motivated developer on the team wanted to pay it forward and add a unit test for one of the pages. To test the function you save some test data in a JSON file. Cypress (the most amazing UI testing library at the time of this writing) even encourages you to use a test data fixture JSON file. But the problem is... it's not even remotely type safe. So you could have a typo in your JSON and spend hours chasing down the issue.

To illustrate this let's look at example business code and test automation code. For most of these examples we'll assume that you work at an insurance company that explains how the rules work for each state within the United States.

// This file is "src/pages/newYorkInfo.tsx"
import * as React from 'react';

interface IUser {
    state: string;
    address: string;
    isAdmin: boolean;
    deleted: boolean | undefined;
}

export const NewYorkUserPage: React.FunctionComponent<{ user: IUser }> = props => {
    if (props.user.state === 'NY' && !props.user.deleted) {
        const welcomeMessage = `Welcome`;
        return <h1 id="ny-dashboard">{welcomeMessage}</h1>;
    } else {
        return <div>ACCESS DENIED</div>;
    }
};

The code looks good, so let's write some JSON to store the positive test case.

// fixtures/user.json
{
    state: 'NY',
    isAdmin: true,
    address: '55 Main St',
}

And now the test code. I'll demonstrate the issue using some psuedo-code for a Cypress test, but you can imagine this occurring any test code where you load the fixture and run your assertion.

// When the UI calls the user endpoint, return the JSON as the mocked return value
cy.route('GET', '/user/**', 'fixture:user.json');
cy.visit('/dashboard');
cy.get('#ny-dashboard').should('exist')

Looks fine, and it works perfectly until you need to test another scenario involving a different user. What do you do then?

Bad Solution - If one file worked, just keep making JSON files

Should you simply create another JSON fixture file? Sadly, this simple solution happens all the time because it's the easiest (at first). But as the number of cases grows, the number of JSON files grow too. You would need 52 different JSON files in order to test every page for every user in the United States. When you start testing if a user is or isn't an administrator, you would have to create 104 files. That's a lot of files!

But you still have the problem of type safety. Let's say the Product Owner comes to the team and says "I want to be kind and display the name of the user when we welcome them."

So you add the name property to the interface and update the UI to handle for this case.

// This file is "src/pages/newYorkInfo.tsx"
import * as React from 'react';

interface IUser {
    name: string;
    state: string;
    address: string;
    isAdmin: boolean;
    deleted: boolean | undefined;
}

export const NewYorkUserPage: React.FunctionComponent<{ user: IUser }> = props => {
    if (props.user.state === 'NY' && !props.user.deleted) {
        const welcomeMessage = `Welcome ${props.user.name.toLowerCase()}!`;
        return <h1 id="ny-dashboard">{welcomeMessage}</h1>;
    } else {
        return <div>ACCESS DENIED</div>;
    }
};

It's great that you updated the business code, but the fixture JSON is out of date. And because the fixture JSON doesn't have a name property, you get the following error:

Uncaught TypeError: Cannot read property 'toLowerCase' of undefined

Now you have to add the name property to all 52 user JSON fixture files. We can solve that with Typescript.

Slightly Better Solution - Move it into a TypeScript file

By moving the JSON out of the fixture file and into a .ts file, the Typescript compiler finds the bug for you:

// this file is "testData/users"
import {IUser} from 'src/pages/newYorkInfo';

// Property 'name' is missing in type '{ state: string; isAdmin: true; address: string; deleted: false; }' but required in type 'IUser'.ts(2741)
export const generalUser: IUser = {
    state: 'NY',
    isAdmin: true,
    address: '55 Main St',
    deleted: false,
};

And we'll update the test code to use this new object.

import { generalUser } from 'testData/users';

// When the UI calls the user endpoint, return the JSON as the mocked return value
cy.route('GET', '/user/**', generalUser);
cy.visit('/dashboard');
cy.get('#ny-dashboard').should('exist')

Thanks Typescript! As soon as you solve the compiler error by adding name: 'Bob Smith' into the generalUser object, the code compiles cleanly, and best of all... your test passes again!

You've met one of our three goals by achieving type safety. Unfortunately the tight-coupling problem still exists.

For example, what happens when a developer who is new to unit testing comes along. All they were thinking about is that they need to test a feature that involves a deleted user. So they add deleted: false to the generalUser object.

Kaboom! Your test fails and their test passes. That's what it means to be tightly-coupled.

So the developer spend a few minutes (or hours) debugging and they realize that both tests share the same setup data. So the developer uses the easy (but short-sighted solution) from before and they simply create another object deletedUser so that there's 1 object per test. This can get out of hand quickly-- I've seen test data files that are 5000 lines long.

Click here to see how insane this can be.
// this file is "testData/users"
import {IUser} from 'src/pages/newYorkInfo';

export const nonAdminUser: IUser = {
    name: 'Bob',
    state: 'NY',
    isAdmin: false,
    address: '55 Main St',
    deleted: false,
};

export const adminUser: IUser = {
    name: 'Bob',
    state: 'NY',
    isAdmin: true,
    address: '55 Main St',
    deleted: false,
};

export const deletedAdminUser: IUser = {
    name: 'Bob',
    state: 'NY',
    isAdmin: true,
    address: '55 Main St',
    deleted: true,
};

export const deletedNonAdmin: IUser = {
    name: 'Bob',
    state: 'NY',
    isAdmin: false,
    address: '55 Main St',
    deleted: true,
};

// and on and on and on again...

There has to be a better way.

Good Solution: Factory Function

So how do we refactor the giant file of objects? We make it one function!

// src/factories/user
import faker from 'faker';
import {IUser} from 'src/pages/newYorkInfo';

export const makeFakeUser = (): IUser => {
    return {
        name: faker.name.firstName() + ' ' + faker.name.lastName(),
        state: faker.address.stateAbbr(),
        isAdmin: faker.random.boolean(),
        address: faker.address.streetAddress(),
        deleted: faker.random.boolean(),
    }
}

Now every test can just call makeFakeUser() when they want to create a user.

And the best part of this is by making everything random within the factory, it clarifies that no individual test owns this function. If a test ones a special kind of IUser, they're going to have to modify it on their own later.

And that's easy to do. Let's imagine the deleted user test where we don't care what the user's name is or anything. We only care that they're deleted.

import { makeFakeUser } from 'src/factories/user';
import {IUser} from 'src/pages/newYorkInfo';

// Arrange
const randomUser = makeFakeUser();
const deletedUser: IUser = { ...randomUser, ...{
  deleted: true
};
cy.route('GET', '/user/**', deletedUser);

// Act
cy.visit('/dashboard');

// Assert
cy.find('ACCESS DENIED').should('exist')

For me, the beauty of this approach is that it's self-documenting. Anyone who is looking at this test code should understand that when the API returns a deleted user, we should find "Access Denied" on the page.

But I think we make this even cleaner.

Best Solution: easy overriding with mergePartially

It was acceptable to use the spread operator above since it was a small object. But this can be more annoying when it's a heavily nested object like this one:

interface IUser {
    userName: string;
    preferences: {
        lastUpdated?: Date;
        favoriteColor?: string;
        backupContact?: string;
        mailingAddress: {
            street: string;
            city: string;
            state: string;
            zipCode: string;
        }
     }
}

You really aren't going to want to have hundreds of those objects floating around.

So if we allow users to override only what they want, we can make for some really simple and DRY setup code. Imagine there's a very specific test that must have a user who lives on "Main Street."

const userOnMainSt = makeFakeUser({
    preferences: {
        mailingAddress: {
            street: 'Main Street'
        }
    }
});

Wow, they only needed to specify what they needed for the test instead of the other 7 properties. And we didn't have to store a one-off object in some giant test file. And we met our self-commenting goals as well.

And how do we enhance our makeFakeUser function to support this kind of partial override? Check out how easy the mergePartially library makes this (full disclosure: I am the mergePartially maintainer).

const makeFakeUser = (override?: NestedPartial<IDeepObj>): IDeepObj => {
        const seed: IDeepObj = {
          userName: 'Bob Smith',
          preferences: {
            mailingAddress: {
              street: faker.address.streetAddress(),
              city: faker.address.city(),
              state: faker.address.stateAbbr(),
              zipCode: faker.address.zipCode(),
            },
          },
        };
        return mergePartially.deep(seed, override);
      };

Wrap Up

Thank you for reading along in the evolution of how we took our test code from brittle and huge test code to tiny and independent.

I'd love to hear from you on your thoughts of this approach.

Posted on by:

dgreene1 profile

Dan Greene

@dgreene1

I love to help teams grow and learn about how to write testable, efficient code that delights users.

Discussion

pic
Editor guide
 

Great write up, thanks for taking the time to describe the issue. Just ran into similar issues, and this provides clear fixes + is a great reference for my team.

 

Thank you kindly Ben. And please ask them to share with others. I’d like to see this become a standard within the TS Cypress community.

Again, it’s so generous of you to say thank you. Isn’t dev.to such a wonderful community? :)

 

πŸ”₯πŸ”₯πŸ”₯