
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.
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:

It is well worth considering the linting rules available for your test framework and how they may help to avoid more common testing mistakes.
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 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 - 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 - 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")
);
});
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:
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest",
"eslint:recommended"
],
"overrides": [
{
"files": ["**/*.js?(x)", "**/*.ts?(x)"],
"plugins": ["jest"],
"extends": ["plugin:jest/recommended"]
}
]
},
Use beforeEach/afterEach code blocks and utility functions to encapsulate logic that is repeated across multiple tests.
// 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 - 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 - 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([]);
});
});
// 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 - 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 - 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);
});
});
});
The Stack class detailed above will be reused for the examples in this section.
// 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 - 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);
});
});
// 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 - 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 - 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();
});
});
"jest": {
"resetMocks": true
}
// 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 - 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 - 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();
});
});
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();
})
));
});
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:
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 creates and maintains an exhaustive suite of e2e ui tests with zero developer effort.
This quote from the CTO of Traba sums the product up best: "Meticulous has fundamentally changed the way we approach frontend testing in our web applications, fully eliminating the need to write any frontend tests. The software gives us confidence that every change will be completely regression tested, allowing us to ship more quickly with significantly fewer bugs in our code. The platform is easy to use and reduces the barrier to entry for backend-focused devs to contribute to our frontend codebase."
This post from our CTO (formerly lead of Palantir's main engineering group) sets out the context of why exhaustive testing can double engineering velocity. Learn more about the product here.

Meticulous creates and maintains an exhaustive suite of end-to-end UI tests with zero developer effort. Learn more here.