Don’t use fixtures in Cypress and unit tests – use factory functions

For prospective students on the course JavaScript QA Engineer and everyone interested in the topic of test automation prepared a translation of a useful article.

We also invite you to take part in an open webinar on the topic “What a tester needs to know about JS”… In the lesson, the participants, together with an expert, will consider the features of JS that need to be kept in mind when writing tests.


Unit tests are great … when they work reliably! In fact, there is an old adage that “a bad test is worse than no test at all.” I can confirm that weeks spent chasing an accidental “false negative” test are not effective. Instead, this time could be used to write working code to help the user.

So let’s talk about one of these simplest techniques for writing less volatile tests: factory data testing.

But before we get into what factory functions are and why you should use them, let’s first try to figure out what type of unstable test they eliminate.

Aspects of tests we want to avoid

  1. High engagement

  2. Lack of type safety (which leads to lengthy refactorings and errors)

  3. Huge fixture folders

Factor functions will fix all of this.

So what are factory functions?

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

Let’s make a function that simplifies the creation process to make it easier to test.

Here is the world’s simplest 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 unstable test aspects discussed above.

We’ll start by describing how tests are usually written, and then we’ll develop a solution iteratively as we solve each problem.

An example of how unstable tests are done in the real world

It all starts innocently. Let’s say you or another motivated developer on your team would like to share your experience 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 awesome UI testing library at the time of this writing) even encourages you to use a JSON file with test data. But the problem is that it isn’t even remotely type-safe. Therefore, you might have a typo in your JSON and spend hours looking for the problem.

To illustrate this, let’s take a look at sample code for business and code for test automation. For most of these examples, we’ll assume you work for an insurance company that explains how the rules work in each state in 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 JSON to store the positive test case.

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

Now for the test code. I’ll demonstrate the problem using some psuedo code for a Cypress test, but you can imagine this happening with any test code where you load fixtures and run a test 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 good and works great until you need to test another scenario with another user. What will you do then?

Bad solution – if one file works, keep creating JSON files

Should I just create another JSON file for the fixture? Unfortunately, this simple solution comes up all the time, because it is the simplest (at first). But as the number of cases increases, so does the number of JSON files. You will need 52 different JSON files to test each page for each user in the US. When you start testing, if the user is or is not an administrator, you will have to create 104 files. That’s a lot of files!

But you still have a type safety issue. Let’s say the Product Owner comes to the team and says, “Let’s display the user’s name when we greet him.”

So you add the property name to the interface and update the user interface to work in this example.

// 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 your code for business, but the JSON fixture is outdated. And since the JSON fixture has no property name, you get the following error:

Uncaught TypeError: Cannot read property 'toLowerCase' of undefined

You should now add a name property to all 52 custom JSON fixtures. This can be solved with Typescript.

Slightly better solution: Move it to a TypeScript file

By moving the JSON from the patch file to the .ts file, the Typescript compiler will find the error 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! Once you solve the compiler problem by adding name: 'Bob Smith' in GeneralUser:, the code compiles cleanly, and best of all, your test will pass again!

You have achieved one of our three goals by achieving type safety. Unfortunately, the problem of high adhesion still exists.

For example, what happens when a developer appears who is still new to unit testing. All I think about is to check the main property that includes the remote user. So they add deleted: false into object generalUser

Babakh! Your test fails, and their test passes. This is what it means to be highly bonded.

Therefore, the developer spends several minutes (or hours) debugging and realizes that both tests have the same underlying data. So the developer uses a simple (but short-sighted solution) from the previous ones and creates another object deletedUserso there is 1 object per test. This can get out of hand quickly – I’ve seen 5,000 lines of test data files.

Here you can see how strange it might look.

// 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 must be a better way.

Good solution: Factory Function

So how do we refactor a huge object file? Let’s make 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 each test can simply call makeFakeUser()when he wants to create a user.

And the best thing about this is that by making everything random in a factory function, it shows that no single test belongs to that function. If the test is a special kind of IUser, you will have to modify it yourself later.

And it’s easy to do. Let’s imagine a remote user test where we don’t care about the username or anything like that. We only care that they are removed.

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 documents itself. Anyone looking at this test code should understand that when the API returns the remote user, we should find "Access Denied" On the page.

But I think we’ll make it even cleaner.

Better solution: just override mergePartially

The above allowed the use of the operator spreadas it was a small object. But it can be more annoying when it’s a 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 don’t want hundreds of such objects to appear.

So if we only let users override what they want, we can create really simple and basic DRY code. Imagine that there is a very specific test that a user living on “Main Street” should have.

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

Wow, it was only necessary to specify what is needed for the test, and not the other 7 properties. And we didn’t have to store the disposable in some kind of huge test file. And we also achieved our goals.

And how can we improve our function makeFakeUser to support this kind of partial override?

See how easy the library makes it mergePartially (full disclosure: I’m an escort mergePartially).

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);
      };

Summarizing

Thanks for reading how we moved from unstable and huge test code to small and independent.

I would love to hear from you what you think of this approach.


Learn more about the course JavaScript QA Engineer.

Register for the open webinar on the topic “What a tester needs to know about JS”.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *