DEV Community

John Teague
John Teague

Posted on

Make Testing Easier with Test Fixture Generators

When building an application, you need to making testing it as easy as possible. Using a test fixture generator can help keep your tests maintainable and easy to modify as your application changes.

Test data anti patterns

When you first starting building an application, it's easy to fall into some anti-patterns that can lead to problems as your application grows and matures

Static JSON files

The simplest approach that most people use when creating test data is to use a json file. But this approach can run into problems when you need to test different scenarios.
Lets say you have a user object that you've put into a json file
test-user.json

{
    "firstName": 'John',
    "lastName": "Doe",
    "isActive": true
}
Enter fullscreen mode Exit fullscreen mode

And import it into your test. But this is now a single instance of the object, so any changes you make will be reflected in all tests in the file. It can lead do

import user from './test-user.json'
describe('tests with user', () => {
    it('should do something when the user is disabled', () => {
        user.isActive = false
        //...
    })
    it('should do something when user is enabled', () => {
        //user is disabled in this test because it was changed in the previous test.
    })
})
Enter fullscreen mode Exit fullscreen mode

This can lead unexpected test failures that can be spot. The more complex the variations, the harder it is to setup your tests correctly. You might be tempted to create different test objects that set to specific states the object could be in for different tests. But that creates another set of problems, because if the object shape changes, you'll need to change multiple states.

When to use

Okay, it's a bit harsh to call this an anti-pattern. Using static JSON files works best when it's static data that doesn't really change or changes to it does not affect the behavior of your application. I typically have a combination of JSON fixtures along side generated fixtures and use them in tandem.

Partial Objects

Another common approach is to include a partial object with each test that have the fields of interest for the test.

it('should do something when the user is disabled', () => {
        const user = {isActive: false}
        //...
    })
    it('should do something when user is enabled', () => {
        const user = {isActive: true}
        // ...        
    })
Enter fullscreen mode Exit fullscreen mode

This can create a lot of duplication throughout your test code, and if the behavior of your system changes where you need to change the shape or use other fields for making decisions, you've now got a bunch of test data scattered throughout your tests that will need to change.

When to use

Never. Honestly, don't do this. Either use a static JSON file where you'll have a valid object and kept in one place in your code, so you will only need to make changes in one place when that is needed.

Test fixture Generators

Using a test fixture generator solves all of these problems. The test fixture is defined only once, so if there are changes to your object graph, you'll make those changes only once and all tests will be updated.

Test fixtures are created each time you need them, so if you change your fixture, it will not effect other tests (unless you want them too). Those weird test failures will go away and your test harness will be more reliable. As a result, your tests will be easier to manage as your application grows. It also puts developers into the pit of success when creating tests; lowering the barrier to writing more and better tests for your app.

Efate, a modern test fixture generator

Efate is a modern test fixture generator, design for use in JavaScript or Typescript. generates modular fixtures that can be imported in and reduces the use of strings for both fixture definition and usages. It allows you to override specific fields during fixture creation and returning full object definitions with dummy data for other fields. It is also extensible, allowing you to create custom field generator definitions.

Defining a fixture

Let's assume you have a typscript interface for your User object

export interface Account {
  userName: string;
  passWord: string;
}
export interface User {
    interface User {
    id?: number;
    email: string;
    dateStarted: Date;
    roles: string[];
    isActive: boolean;
    account: Account
}
Enter fullscreen mode Exit fullscreen mode

To define a fixture, you need to import a fixture generator factory. The factory is used to allow for extensibility, which we'll talk about in a later post. The factory will return a function used to create the fixtures.

Then you will create the fixture, specifying how each field should be populated.

import {createFixtureFactory} from 'efate';
const createFixture = createFixtureFactory();
const accountFixture = createFixture<Account>(t => {
    t.userName.asString();
    t.password.asString();
})
const userFixture = createFixture<User>(t => {
    t.id.asNumber();
    t.firstName.asString();
    t.email.asEmail();
    t.dateStarted.asDate({ incrementDay: true }));
    t.roles.asArray();
    t.isActive.asBoolean();
    t.account.fromFixture(accountFixture);
})
export {accountFixture, userFixture};
Enter fullscreen mode Exit fullscreen mode

As you can see, you have control over how each field in your object will be created. Some of the definition functions, like asDate takes additional options to control how they are generated. You can also nest your fixtures together to create a robust object graph and the entire graph will be valid. There are a lot more definition functions to use for different types, or with different behavior to give you the flexibility you need to create your fixtures as need. Or you can pass your own function to have complete control over field definition.

If you're using TypeScript, you'll get full autocomplete on the type parameter in the createFixture callback function, making it really easy to create fixtures.

Using a fixture

To use these fixtures in your tests, you simple import the fixture

import userFixture from './fixtures';
define('test group', () => {
    it('should use the user object', () => {
        // creates a user with all default values;
        const user = userFixture.create(); 
        //...
    })
})
Enter fullscreen mode Exit fullscreen mode

This creates a fully populated user object with all field populated with dummy data.

    {
        id: 1,
        firstName: 'firstName1',
        email: 'email1@test.com',
        dateStarted: // valid date object
        roles: ['role1', 'role2', 'role3']
        isActive: true,
        account: {
            userName: 'userName1',
            password: 'password1'
        }
    }
Enter fullscreen mode Exit fullscreen mode

Calling create again in the same module will increment the number on each value so they are different, but follow a simple pattern.

If you need to create an object with specific values, you pass an object with the fields you need to specify. If you're using typescript, the overriding object will be typed so you can get autocomplete help there as well.

const user = userFixture.create({isActive: true, roles:['user']);
Enter fullscreen mode Exit fullscreen mode

It creates an object just like above, but those fields have been overridden

    {
        id: 2, // incremented if in the same module as previous fixture,
        firstName: 'firstName2',
        //...
        isActive: true,
        roles: ['user']
    }
Enter fullscreen mode Exit fullscreen mode

You can also override nested objects as well, and all other fields still automatically populated

    /*
    will create a user with account.firstName overridden, but account.password still generated
    */
    const user = userFixture.create({account: {userName: 'custom user name'});
Enter fullscreen mode Exit fullscreen mode

You can also create arrays of data with different ways of overriding the objects in the array

      // create an array with 5 entries, all generated data
      const fixtures = userFixture.createArrayWith(5);

      // create an array with 5 entries, all with the isActive field overridden
      const fixtures = userFixture.createArrayWith(5, {isActive: true});

      // create an array with the first 2 entries overriden
      const fixtures = userFixture.createArrayWith(5, [
      {isActive: true},
      {isActive: false}
      ]);

      // use a function to override array entries
      const fixtures = userFixture.createArrayWith(5, (idx, create) => {
          if(idx < 2){
              return create({firstName: 'alpha'})
          } else {
              return create({firstName: 'beta'})
          }
      })
Enter fullscreen mode Exit fullscreen mode

There's a lot more ways to customize your fixtures, The tests are a good way of seeing all of the features and how they work.

I've built lots applications over the course of my career and thousands of tests. Each time that I don't think I need a test fixture generator, I always end up regretting it. I treat my test code like I do my application code. I want it to be as easy to understand as possible with as little duplication as possible. Using generators like efate have helped me do that. I hope it can do the same for you. If you have any suggestions on features to add or suggestions to make it easier to use, I would love your feedback (or better yet a pull request!).

Oldest comments (2)

Collapse
 
leandrofontellas profile image
Leandro

Isn't just possible to when you import the static JSON file instead of direcly using it. Create new objects as it is needed? So you will be able to isolate the instance, and if you need it in multiple tests maybe consider using beforeEach hook, with a "global" reference to your user instance.

import user from './test-user.json'
describe('tests with user', () => {
    it('should do something when the user is disabled', () => {
        const fakeUser = Object.create(user);
        //...
    })
    it('should do something when user is enabled', () => {
        const fakeUser = Object.create(user);
        //...
    })
})
Enter fullscreen mode Exit fullscreen mode
Collapse
 
jcteague profile image
John Teague • Edited

I probably should have explicitly called this out as one of the problems with JSON files. The problem when trying to copy your objects is that most built in methods only make shallow copies of your object. In the example above, your cloned fakeUser still has has a reference to the original roles array and to the account object. Changes to the original will change all of your copies and vice versa.

The most reliable way to make a deep copy is

const copy is JSON.parse(JSON.stringify(user))
Enter fullscreen mode Exit fullscreen mode

It's really the only you can guarantee deep copy (or use lodash or underscore).

If you are on Node 17 or higher you can use structuredClone

Alas my work is still on node 16, so I haven't had a chance to use this yet.

I also created a sandbox to demonstrate.