π A guide to unit testing in Javascript
This is a living document. New ideas are always welcome. Contribute: fork, clone, branch, commit, push, pull request.
All the information provided has been compiled & adapted from many references, some of them cited at the end of the document. The guidelines are illustrated by my own examples, fruit of my personal experience writing and reviewing unit tests. Many thanks to all of the sources of information & contributors.
π Last edit: September 2023.
Unit = Unit of work
The work can involve multiple methods and classes, invoked by some public API that can:
A unit test should test the behaviour of a unit of work: for a given input, it expects an end result that can be any of the above.
Unit tests are isolated and independent of each other
The code is designed to support this independence (see "Design principles" below).
Unit tests are lightweight tests
Unit tests are code too
They should be easily readable and maintainable.
Don't hesitate to refactor them to help your future self. For instance, it should be trivial to understand why a test is failing just by looking at its own code, without having to search in many places in the test file (variables declared in the top-level scope, closures, test setup & teardown hooks, etc.).
β’ Back to ToC β’
The key to good unit testing is to write testable code. Applying simple design principles can help, in particular:
β’ Back to ToC β’
The goal of these guidelines is to make your tests:
These are the 3 pillars of good unit testing.
All the following examples assume the usage of the Jest testing framework.
β’ Back to ToC β’
Test-Driven Development is a design process, not a testing process. It's a highly-iterative process in which you design, test, and code more or less at the same time. It goes like this:
This process works well for two reasons:
Notice also how code written without a test-first approach is often very hard to test!
β’ Back to ToC β’
:(
it("calculates a RPN expression", () => {
const result = RPN("5 1 2 + 4 * - 10 /");
expect(result).toBe(-0.7);
});
:)
it("returns null when the expression is an empty string", () => {
const result = RPN("");
expect(result).toBeNull();
});
From there, start building the functionalities incrementally.
β’ Back to ToC β’
Build your tests suite from the simple case to the more complex ones. Keep in mind the incremental design. Deliver new code fast, incrementally, and in short iterations:
:(
it("returns null when the expression is an empty string", () => {
const result = RPN("");
expect(result).toBeNull();
});
it("calculates a RPN expression", () => {
const result = RPN("5 1 2 + 4 * - 10 /");
expect(result).toBe(-0.7);
});
:)
describe("The RPN expression evaluator", () => {
it("returns null when the expression is an empty string", () => {
const result = RPN("");
expect(result).toBeNull();
});
it("returns the same value when the expression holds a single value", () => {
const result = RPN("42");
expect(result).toBe(42);
});
describe("Additions", () => {
it("calculates a simple addition", () => {
const result = RPN("41 1 +");
expect(result).toBe(42);
});
// ...
it("calculates a complex addition", () => {
const result = RPN("2 9 + 15 3 + + 7 6 + +");
expect(result).toBe(42);
});
});
// ...
describe("Complex expressions", () => {
it("calculates an expression containing all 4 operators", () => {
const result = RPN("5 1 2 + 4 * - 10 /");
expect(result).toBe(-0.7);
});
});
});
β’ Back to ToC β’
Don't hesitate to nest your suites to structure logically your tests in subsets:
:(
describe("A set of functionalities", () => {
it("does something nice", () => {});
it("a subset of functionalities does something great", () => {});
it("a subset of functionalities does something awesome", () => {});
it("another subset of functionalities also does something great", () => {});
});
:)
describe("A set of functionalities", () => {
it("does something nice", () => {});
describe("A subset of functionalities", () => {
it("does something great", () => {});
it("does something awesome", () => {});
});
describe("Another subset of functionalities", () => {
it("also does something great", () => {});
});
});
β’ Back to ToC β’
Tests names should be concise, explicit, descriptive and in correct English. Read the output of the test runner and verify that it is understandable!
Keep in mind that someone else will read it too and that tests can be the live documentation of the code:
:(
describe("myGallery", () => {
it("init set correct property when called (thumb size, thumbs count)", () => {});
});
:)
describe("The Gallery instance", () => {
it("calculates the thumb size when initialized", () => {});
it("calculates the thumbs count when initialized", () => {});
});
In order to help you write test names properly, you can use the "unit of work - scenario/context - expected behaviour" pattern:
describe("[unit of work]", () => {
it("[expected behaviour] when [scenario/context]", () => {});
});
Or if there are many tests that follow the same scenario or are related to the same context:
describe("[unit of work]", () => {
describe("when [scenario/context]", () => {
it("[expected behaviour]", () => {});
});
});
For example:
:) :)
describe("The Gallery instance", () => {
describe("when initialized", () => {
it("calculates the thumb size", () => {});
it("calculates the thumbs count", () => {});
// ...
});
});
You might also want to use this pattern to describe a class and its methods:
describe("Gallery", () => {
describe("init()", () => {
it("calculates the thumb size", () => {});
it("calculates the thumbs count", () => {});
});
describe("goTo(index)", () => {});
// ...
});
Also, tests "should not begin with should".
β’ Back to ToC β’
This pattern is a good support to help you read and understand tests more easily:
describe("Gallery", () => {
describe("goTo(index)", () => {
it("displays the image identified by its index", () => {
// arrange
const myGallery = new Gallery();
const index = 1;
// act
myGallery.goTo(index);
// assert
expect(document.getElementById("image-1")).toBeVisible();
});
});
});
This pattern is also named "Given-When-Then" or "Setup-Exercise-Verify".
β’ Back to ToC β’
Always use simple statements. Don't use loops and/or conditionals. If you do, you add a possible entry point for bugs in the test itself:
β’ Back to ToC β’
Remember, unit tests are a design specification of how a certain behaviour should work, not a list of observations of everything the code happens to do:
:(
it("computes the result of an expression", () => {
const multiplySpy = jest.spyOn(Calculator, "multiple");
const subtractSpy = jest.spyOn(Calculator, "subtract");
const result = Calculator.compute("(21.5 x 2) - 1");
expect(multiplySpy).toHaveBeenCalledWith(21.5, 2);
expect(subtractSpy).toHaveBeenCalledWith(43, 1);
expect(result).toBe(42);
});
:)
it("computes the result of the expression", () => {
const result = Calculator.compute("(21.5 x 2) - 1");
expect(result).toBe(42);
});
β’ Back to ToC β’
:(
it("adds a user in memory", () => {
usersManager.addUser("Dr. Falker");
expect(usersManager._users[0].name).toBe("Dr. Falker");
});
A better approach is to test at the same level of the API:
:)
it("adds a user in memory", () => {
usersManager.addUser("Dr. Falker");
expect(usersManager.hasUser("Dr. Falker")).toBe(true);
});
Here, a balance has to be found, unit-testing some key parts can be beneficial.
β’ Back to ToC β’
Factories can:
:(
describe("The UserProfile class", () => {
let userProfile;
let pubSub;
beforeEach(() => {
const element = document.getElementById("my-profile");
pubSub = { notify() {} };
userProfile = new UserProfile({
element,
pubSub,
likes: 0,
});
});
it('publishes a topic when a new "like" is given', () => {
jest.spyOn(pubSub, "notify");
userProfile.incLikes();
expect(pubSub.notify).toHaveBeenCalledWith("likes:inc", { count: 1 });
});
it("retrieves the number of likes", () => {
userProfile.incLikes();
userProfile.incLikes();
expect(userProfile.getLikes()).toBe(2);
});
});
:)
function createUserProfile({ likes = 0 } = {}) {
const element = document.getElementById("my-profile"),;
const pubSub = { notify: jest.fn() };
const userProfile = new UserProfile({
element,
pubSub
likes,
});
return {
pubSub,
userProfile,
};
}
describe("The UserProfile class", () => {
it('publishes a topic when a new "like" is given', () => {
const {
userProfile,
pubSub,
} = createUserProfile();
userProfile.incLikes();
expect(pubSub.notify).toHaveBeenCalledWith("likes:inc");
});
it("retrieves the number of likes", () => {
const { userProfile } = createUserProfile({ likes: 40 });
userProfile.incLikes();
userProfile.incLikes();
expect(userProfile.getLikes()).toBe(42);
});
});
Factories can be particularly useful when dealing with the DOM:
:(
describe("The search component", () => {
describe("when the search button is clicked", () => {
let container;
let form;
let searchInput;
let submitInput;
beforeEach(() => {
fixtures.inject(`<div id="container">
<form class="js-form" action="/search">
<input type="search">
<input type="submit" value="Search">
</form>
</div>`);
container = document.getElementById("container");
form = container.getElementsByClassName("js-form")[0];
searchInput = form.querySelector("input[type=search]");
submitInput = form.querySelector("input[type=submith]");
});
it("validates the text entered", () => {
const search = new Search({ container });
jest.spyOn(search, "validate");
search.init();
input(searchInput, "peace");
click(submitInput);
expect(search.validate).toHaveBeenCalledWith("peace");
});
});
});
:)
function createHTMLFixture() {
fixtures.inject(`<div id="container">
<form class="js-form" action="/search">
<input type="search">
<input type="submit" value="Search">
</form>
</div>`);
const container = document.getElementById("container");
const form = container.getElementsByClassName("js-form")[0];
const searchInput = form.querySelector("input[type=search]");
const submitInput = form.querySelector("input[type=submith]");
return {
container,
form,
searchInput,
submitInput,
};
}
describe("The search component", () => {
describe("when the search button is clicked", () => {
it("validates the text entered", () => {
const { container, searchInput, submitInput } = createHTMLFixture();
const search = new Search({ container });
jest.spyOn(search, "validate");
search.init();
input(searchInput, "peace");
click(submitInput);
expect(search.validate).toHaveBeenCalledWith("peace");
});
});
});
Here also, there's a trade-off to find between applying the DRY principle and readability.
β’ Back to ToC β’
If a method has several end results, each one should be tested separately so that whenever a bug occurs, it will help you locate the source of the problem directly:
:(
it("sends the profile data to the API and updates the profile view", () => {
// expect(...)to(...);
// expect(...)to(...);
});
:)
it("sends the profile data to the API", () => {
// expect(...)to(...);
});
it("updates the profile view", () => {
// expect(...)to(...);
});
Pay attention when writing "and" or "or" in your test names ;)
β’ Back to ToC β’
Having edge cases covered will:
:(
it("calculates the value of an expression", () => {
const result = RPN("5 1 2 + 4 * - 10 /");
expect(result).toBe(-0.7);
});
:)
describe("The RPN expression evaluator", () => {
// edge case
it("returns null when the expression is an empty string", () => {
const result = RPN("");
expect(result).toBeNull();
});
// edge case
it("returns the same value when the expression holds a single value", () => {
const result = RPN("42");
expect(result).toBe(42);
});
// edge case
it("throws an error whenever an invalid expression is passed", () => {
const compute = () => RPN("1 + - 1");
expect(compute).toThrow();
});
// general case
it("calculates the value of an expression", () => {
const result = RPN("5 1 2 + 4 * - 10 /");
expect(result).toBe(-0.7);
});
});
β’ Back to ToC β’
:(
describe("when the user has already visited the page", () => {
// storage.getItem('page-visited', '1') === '1'
describe("when the survey is not disabled", () => {
// storage.getItem('survey-disabled') === null
it("displays the survey", () => {
const storage = window.localStorage;
storage.setItem("page-visited", "1");
storage.setItem("survey-disabled", null);
const surveyManager = new SurveyManager();
jest.spyOn(surveyManager, "display");
surveyManager.start();
expect(surveyManager.display).toHaveBeenCalled();
});
});
});
We created a permanent storage of data. What happens if we do not properly clean it between tests? We might affect the result of other tests. By using dependency injection, we can prevent this behaviour:
:)
describe("when the user has already visited the page", () => {
// storage.getItem('page-visited', '1') === '1'
describe("when the survey is not disabled", () => {
// storage.getItem('survey-disabled') === null
it("displays the survey", () => {
// E.g. https://github.com/tatsuyaoiw/webstorage
const storage = new MemoryStorage();
storage.setItem("page-visited", "1");
storage.setItem("survey-disabled", null);
const surveyManager = new SurveyManager(storage);
jest.spyOn(surveyManager, "display");
surveyManager.start();
expect(surveyManager.display).toHaveBeenCalled();
});
});
});
β’ Back to ToC β’
The idea to keep in mind is that dependencies can still be real objects. Don't mock everything because you can. Consider using the real version if:
β’ Back to ToC β’
Examples of complex user interactions:
These interactions involve many units of work and should be handled at a higher level by end-to-end tests. They will usually take more time to execute, they could be flaky (false negatives) and they will require debugging whenever a failure is reported.
For these complex user scenarios, consider using tools like Playwright or Cypress, or manual QA testing.
β’ Back to ToC β’
Example of simple user actions:
These actions can be easily tested by simulating DOM events, for example:
describe('when clicking on the "Preview profile" link', () => {
it("shows the preview if it is hidden", () => {
const { userProfile, previewLink } = createUserProfile({
previewIsVisible: false,
});
jest.spyOn(userProfile, "showPreview");
click(previewLink);
expect(userProfile.showPreview).toHaveBeenCalled();
});
it("hides the preview if it is visible", () => {
const { userProfile, previewLink } = createUserProfile({
previewIsVisible: true,
});
jest.spyOn(userProfile, "hidePreview");
click(previewLink);
expect(userProfile.hidePreview).toHaveBeenCalled();
});
});
Note how simple the tests are because the UI (DOM) layer does not mix with the business logic layer:
The next step could be to test the logic implemented in showPreview()
or hidePreview()
.
β’ Back to ToC β’
Whenever a bug is found, create a test that replicates the problem before touching any code. Then fix it.
β’ Back to ToC β’
Never. Ever. Tests have a reason to be or not.
Don't comment them out because they are too slow, too complex or produce false negatives. Instead, make them fast, simple and trustworthy. If not, remove them completely.
β’ Back to ToC β’
Take time to read the API documentation of the testing framework that you have chosen to work with.
Having a good knowledge of the framework will help you in reducing the size and complexity of your test code and, in general, will help you during development.
When reviewing code, always start by reading the code of the tests. Tests are mini use cases of the code that you can drill into.
It will help you understand the intent of the developer very quickly (could be just by looking at the names of the tests).
β’ Back to ToC β’
Because experience is the only teacher. Ultimately, greatness comes from practicing; applying the theory over and over again, using feedback to get better every time.
β’ Back to ToC β’
There's a ton of resources available out there, here are just a few I've found useful...
β’ Back to ToC β’
This style guide is also available in other languages:
β’ Back to ToC β’