Frontend Unit Testing Best Practices

published on 12 September 2022

In this guide we will present some common best practices for frontend unit testing. We will first outline some of the benefits and rationale behind each recommendation, followed by examples of how each principle could be applied in practice to improve a set of test cases.

Although the examples below will use JavaScript and Jest testing framework, the principles discussed are broadly applicable to any language. However, it is worth noting these are best practices, not necessarily rules, so being mindful of exceptions is always best practice number zero.

1. Using linting rules for tests

Linting or styling rules such as those provided by eslint are commonly used as standard in most modern frontend code-bases. They help to automatically highlight errors in your IDE, such as test expectations that may never be reached and may lead to silently failing tests:

using-linting-rules-for-tests-qai4h

It is well worth considering the linting rules available for your test framework and how they may help to avoid more common testing mistakes. 

Why

  • Linting can automatically highlight and suggest fixes to common mistakes.
  • Ensure consistency across a code base and between many contributors.
  • Integrations with most modern IDE’s can help in tracking and correcting linting errors as they are being written, before compile time.
  • Can automatically run linter checks as part of a pre-commit hook (using tools like Husky). Ensuring that all test code passes linting rules before it is saved to version-control.

Example Code To Test

    // Example asynchronous GET request using Promises

    function asyncRequest() {
      return fetch("some.api.com")
        .then(resp => resp.json())
        .catch(() => {
          throw new Error("Failed to fetch from some.api.com");
        })
    }

Bad Test Example

// Bad - jest/valid-expect-in-promise lint error - The expect call may
// not be resolved if the async function never resolves.

it("should resolve a successful fetch - bad", () => {
  asyncRequest().then((data) => {
    expect(data).toEqual({ id: 123 });
  });
});

// Bad - jest/no-conditional-expect lint error - Expect call will not
// be reached if the async function does not throw an error.

it("should catch fetch errors - bad", async () => {
  try {
    await asyncRequest();
  } catch (e) {
    expect(e).toEqual(new Error("Failed to fetch from some.api.com"));
  }
});

Better Test Example

// Better - No linting error - Use async and await to ensure the
// expectation is called after the promise resolves.

it('should resolve fetch - better', async () => {
  const result = await asyncRequest();
  expect(result).toBeDefined();
})

// Better - No linting error - Use async and await to ensure the
// expectation is always called.

it("should catch fetch errors - better", async () => {
  await expect(asyncRequest()).rejects.toThrow(
    new Error("Failed to fetch from some.api.com")
  );
});

Further Notes

Some recommended default linting rules for writing tests with Jest:

Consider adding other linting rules for popular frontend JS testing libraries:

How to set up eslint rules for a typical JS/React project generated using create-react-app

  • In package.json, add the following eslintConfig section
  • This will run the recommended eslint rules for all .jsx and .tsx files
"eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest",
      "eslint:recommended"
    ],
    "overrides": [
      {
        "files": ["**/*.js?(x)", "**/*.ts?(x)"],
        "plugins": ["jest"],
        "extends": ["plugin:jest/recommended"]
      }
    ]
  },

2. Do not repeat yourself

Use beforeEach/afterEach code blocks and utility functions to encapsulate logic that is repeated across multiple tests.

Why

  • Can easily contain and update shared logic if it is in one place.
  • Less chance to forget common setup/teardown logic if it is automatically called within a beforeEach/afterEach code block.
  • Reducing repetition can make tests shorter and more readable.

Example Code To Test

    // Example of a function for validating a hotel booking object

    function validateBooking(booking) {
      const validationMessages = [];

      if (booking.startDate >= booking.endDate) {
        validationMessages.push(
          "Error - Booking end date should be after the start date"
        );
      }
      if (!booking.guests) {
        validationMessages.push(
          "Error - Booking must have at least one guest"
        );
      }

      return validationMessages;
    }

Bad Test Example

// Bad - Repeating initialisation for a very similar booking object

describe("ValidateBooking - Bad", () => {
  it("should return an error if the start and end date are the same", () => {
    const mockBooking = {
      id: "12345",
      userId: "67890",
      locationId: "ABCDE",
      guests: 2,
      startDate: new Date(2022, 10, 10),
      endDate: new Date(2022, 10, 10),
    };

    expect(validateBooking(mockBooking)).toEqual([
      "Error - Booking end date should be after the start date",
    ]);
  });

  it("should return an error if there are fewer than one guests", () => {
    const mockBooking = {
      id: "12345",
      userId: "67890",
      locationId: "ABCDE",
      guests: 0,
      startDate: new Date(2022, 10, 10),
      endDate: new Date(2022, 10, 12),
    };

    expect(validateBooking(mockBooking)).toEqual([
      "Error - Booking must have at least one guest",
    ]);
  });

  it("should return no errors if the booking is valid", () => {
    const mockBooking = {
      id: "12345",
      userId: "67890",
      locationId: "ABCDE",
      guests: 2,
      startDate: new Date(2022, 10, 10),
      endDate: new Date(2022, 10, 12),
    };

    expect(validateBooking(mockBooking)).toEqual([]);
  });
});

Better Test Example

// Better - Creation of a valid booking object is delegated to a
// reusable factory function createMockValidBooking.
 
describe("ValidateBooking - Better", () => {
  function createMockValidBooking() {
    return {
      id: "12345",
      userId: "67890",
      locationId: "ABCDE",
      guests: 2,
      startDate: new Date(2022, 10, 10),
      endDate: new Date(2022, 10, 12),
    };
  }
 
  it("should return an error if the start and end dates are the same", () => {
    const mockBooking = {
      ...createMockValidBooking(),
      startDate: new Date(2022, 10, 10),
      endDate: new Date(2022, 10, 10),
    };
 
    expect(validateBooking(mockBooking)).toEqual([
      "Error - Booking end date should be after the start date",
    ]);
  });
 
  it("should return an error if there are fewer than one guests", () => {
    const mockBooking = {
      ...createMockValidBooking(),
      guests: 0,
    };
 
    expect(validateBooking(mockBooking)).toEqual([
      "Error - Booking must have at least one guest",
    ]);
  });
 
  it("should return no errors if the booking is valid", () => {
    const mockBooking = createMockValidBooking();
 
    expect(validateBooking(mockBooking)).toEqual([]);
  });
});

3. Group-related tests in describe blocks

Why

  • Well titled describe blocks help to organise test files by separating related tests into groups.
  • Easier to encapsulate setup/teardown logic specific to a subset of tests by adding a beforeEach/afterEach for a single describe block (see “Do not repeat yourself” rule above).
  • Inner describe blocks can extend setup logic from the outer describe blocks.

Example Code To Test

    // Example of a simplified Stack data structure class

    class Stack {

      constructor() {
        this._items = [];
      }

      push(item) {
        this._items.push(item);
      }

      pop() {
        if(this.isEmpty()) {
          throw new Error("Error - Cannot pop from an empty stack");
        }

        return this._items.pop();
      }

      peek() {
        if(this.isEmpty()) {
          throw new Error("Error - Cannot peek an empty stack");
        }

        return this._items[this._items.length-1];
      }

      isEmpty() {
        return this._items.length === 0;
      }

    }

Bad Test Example

    // Bad - No inner "describe" blocks used to group tests.
    // Note the repeated use of "on an empty stack" or
    // "on a non-empty stack" in each test title.

    describe("Stack - Bad", () => {
      it("should return isEmpty as true if the stack is empty", () => {
        const stack = new Stack();

        expect(stack.isEmpty()).toBe(true);
      });

      it("should return isEmpty as false if the stack is non-empty", () => {
        const stack = new Stack();
        stack.push(123);

        expect(stack.isEmpty()).toBe(false);
      });

      it("should throw error when peeking on an empty stack", () => {
        const stack = new Stack();

        expect(() => stack.peek()).toThrowError(
          "Error - Cannot peek an empty stack"
        );
      });

      it("should return the top item when peeking a non-empty stack", () => {
        const stack = new Stack();
        stack.push(123);

        expect(stack.peek()).toEqual(123);
      });

      it("should throw an error when popping from an empty stack", () => {
        const stack = new Stack();

        expect(() => stack.pop()).toThrowError(
          "Error - Cannot pop from an empty stack"
        );
      });

      it("should return the top item when popping a non-empty stack", () => {
        const stack = new Stack();
        stack.push(123);

        expect(stack.pop()).toEqual(123);
      });
    });

Better Test Example

    // Better - Using inner "describe" blocks to group related tests.
    // Also using beforeEach to reduce repeating initialization
    // within each test.

    describe("Stack - Better", () => {
      let stack;

      beforeEach(() => {
        stack = new Stack();
      });

      describe("empty stack", () => {
        it("should return isEmpty as true", () => {
          expect(stack.isEmpty()).toBe(true);
        });

        it("should throw error when peeking", () => {
          expect(() => stack.peek()).toThrowError(
            "Error - Cannot peek an empty stack"
          );
        });

        it("should throw an error when popping", () => {
          expect(() => stack.pop()).toThrowError(
            "Error - Cannot pop from an empty stack"
          );
        });
      });

      describe("non-empty stack", () => {
        beforeEach(() => {
          stack.push(123);
        });

        it("should return isEmpty as false", () => {
          expect(stack.isEmpty()).toBe(false);
        });

        it("should return the top item when peeking", () => {
          expect(stack.peek()).toEqual(123);
        });

        it("should return the top item when popping", () => {
          expect(stack.pop()).toEqual(123);
        });
      });
    });

4. A unit test should only have one reason to fail

Why

  • As the name implies, a unit test should only test a single "unit" of code.
  • If there is one reason to fail, there is less time spent identifying the root cause of the test failure.
  • Follows the single-responsibility principle from SOLID practices.
  • Encourages shorter and more readable unit tests.

Example Code To Test

The Stack class detailed above will be reused for the examples in this section.

Bad Test Example

    // Bad - A single test is checking two separate logical branches
    // of the "pop" function.
    // Calling "stack.pop()" twice with two expectations.

    describe("Stack - Bad", () => {
      let stack;

      beforeEach(() => {
        stack = new Stack();
      });

      it("should only allow popping when the stack is non-empty", () => {
        expect(() => stack.pop()).toThrowError();

        stack.push(123);
        expect(stack.pop()).toEqual(123);
      });
    });

Better Test Example

    // Better - Each test goes through a single logical branch
    // of the "pop" function.
    // A single "stack.pop()" call and a single "expect" call per test.

    describe("Stack - Better", () => {
      let stack;

      beforeEach(() => {
        stack = new Stack();
      });

      it("should throw an error when popping from an empty stack", () => {
        expect(() => stack.pop()).toThrowError();
      });

      it("should return the top item when popping a non-empty stack", () => {
        stack.push(123);

        expect(stack.pop()).toEqual(123);
      });
    });

5. Keep tests independent

Why

  • The outcome of one test should not affect any other tests.
  • Sharing state or mock instances between tests may lead to brittle or “flaky” tests that will pass erroneously.
  • Dependent tests may fail unexpectedly if a new test is added or if test order changes.

Example Code To Test

    // Simple example of a function that accepts a callback
    // and executes it after a set number of milliseconds.

    function callInFive(callback) {
      setTimeout(callback, 5000);
    }

Bad Test Example

    // Bad - The tests below are NOT independent.
    // The mockCallback is not being reset between tests.
    // The timer from one test is not cleared before starting the next.

    describe("callInFive - Bad", () => {
      beforeEach(() => {
        // Using jest library to control passage of time within each test
        jest.useFakeTimers();
      })

      const mockCallback = jest.fn();

      it("should not call callback before five seconds elapse", () => {
        callInFive(mockCallback);

        jest.advanceTimersByTime(5000 - 1);

        expect(mockCallback).not.toHaveBeenCalled();
      });

      it("should call callback after five seconds elapse", () => {
        callInFive(mockCallback);

        jest.advanceTimersByTime(5000);

        expect(mockCallback).toHaveBeenCalled();
      });
    });

Better Test Example

    // Better - We reset any ongoing timers and faked/mocked
    // functions between tests.

    describe("callInFive - Better", () => {

      beforeEach(() => {
        jest.useFakeTimers();
        // Remember to reset any mocks and spies before starting each test
        jest.resetAllMocks();
      })

      // Remember to clear up any remaining pending timers and
      // restore native time functionality
      afterEach(() => {
        jest.runOnlyPendingTimers();
        jest.useRealTimers();
      })

      const mockCallback = jest.fn();

      it("should not call callback before five seconds elapse", () => {
        callInFive(mockCallback);

        jest.advanceTimersByTime(5000 - 1);

        expect(mockCallback).not.toHaveBeenCalled();
      });

      it("should call callback after five seconds elapse", () => {
        callInFive(mockCallback);

        jest.advanceTimersByTime(5000);

        expect(mockCallback).toHaveBeenCalled();
      });
    });

Further Notes

  • You can configure Jest testing library to automatically reset mocks before each test by adding resetMocks: true in your jest config file or alternatively adding the following to package.json
      "jest": {
        "resetMocks": true
      }
  • Starting a React project with create-react-app will automatically add resetMocks: true to the built-in jest config (see the docs). Which is equivalent to automatically calling jest.resetAllMocks() before each test.
  • Ensuring that your tests run in random order may help to identify cases where unit tests are not independent.

6. Test a variety of input parameters

Why

  • Ideally our tests should verify all possible code paths through the code under test.
  • We want to be confident that our deployed code can handle the full range of inputs that a user can possibly enter.
  • We may be biassed towards testing using inputs that trigger code paths we know to be correct. Testing a range of inputs can help verify edge cases we may not have considered.

Example Code To Test

    // Example of a function that creates an array of specific length
    // and filled with a given value.

    function initArray(length, value) {
      // Note that there is a mistake in this if condition -
      // we throw an error if the length is zero.
      if (!length) {
       throw new Error(
        "Invalid parameter length - must be a number greater or equal to 0"
       );
      }

      return new Array(length).fill().map(() => value);
    }

Bad Test Example

// Bad - The below tests pass, and it may seem like these tests cover
// all code paths, but note that 0 is falsy in JS - and the below tests
// do not check the case of trying to create an array of length = 0
// or length = -1.

describe("initArray - Bad", () => {
  it("should create an array of given size filled with the same value", () => {
    expect(initArray(3, { id: 123 })).toEqual([
      { id: 123 },
      { id: 123 },
      { id: 123 },
    ]);
  });

  it("should throw an error if the array length parameter is invalid", () => {
    expect(() => initArray(undefined, { id: 123 })).toThrowError();
  });
});

Better Test Example

    // Better - Added tests for the edge case of creating an array of
    // length = 0 and length = -1

    describe("initArray - Better", () => {
      it("should create an array of given size filled with the same value", () => {
        expect(initArray(3, { id: 123 })).toEqual([
          { id: 123 },
          { id: 123 },
          { id: 123 },
        ]);
      });

      it("should handle an array length parameter of 0", () => {
        expect(initArray(0, { id: 123 })).toEqual([]);
      });

      it("should throw an error if the array length parameter is -1", () => {
        expect(() => initArray(-1, { id: 123 })).toThrowError();
      });

      it("should throw an error if the array length parameter is invalid", () => {
        expect(() => initArray(undefined, { id: 123 })).toThrowError();
      });
    });

Further Notes

We can also catch mistakes with unhandled inputs using a testing framework like fast-check. This will test our functions against a range of random values, which can automatically improve our code coverage and increase the chance of finding a bug.

    // Better - Can also use a framework like fast-check to generate
    // test cases against a range of inputs.
    // By default each assert will run 100 times with random input values.

    describe("initArray - Better - Using fast-check", () => {
      it("should return an array of specified length", () =>
        fc.assert(
          fc.property(
            fc.integer({ min: 0, max: 100 }),
            fc.anything(),
            (length, value) => {
              expect(initArray(length, value).length).toEqual(length);
            }
          )
        ));

      it("should throw an error if initialising array of length < 0", () =>
        fc.assert(
          fc.property(
            fc.integer({ max: -1 }),
            fc.anything(),
            (length, value) => {
              expect(() => initArray(length, value)).toThrowError();
            })
        ));
    });

Conclusion

To summarise, in this blog we have outlined the benefits for the following practices in frontend testing, and how they are applicable to example tests in JavaScript/Jest:

  1. Using linting rules for tests
  2. Do not repeat yourself
  3. Group related tests in describe blocks
  4. A unit test should only have one reason to fail
  5. Keep tests independent
  6. Test a variety of input parameters

These are just a few of the conventions to consider when writing tests, so if you are interested in learning more about frontend best practices you may find relevant blogs from Meticulous on Frontend Testing Pyramid or JavaScript UI Test best practices.

Thank you for reading!

Meticulous

Meticulous is a tool for software engineers to easily create end-to-end tests. Use the Meticulous open source CLI to open an instrumented browser which records your actions and translates them into a test. Meticulous makes it easy to integrate these tests into your CI.

Meticulous has an option to automatically mock out all network calls when simulating a recorded sequence of actions. If you use this option, you do not need a backend environment to use Meticulous and Meticulous tests never cause side effects (like affecting analytics) or hit your backend.

Create your first test in 60 seconds using our docs or watch the demo.

Authored by Alex Langdon

Read more