Skip to content

mawrkus/js-unit-testing-guide

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

69 Commits
Β 
Β 
Β 
Β 

Repository files navigation

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.

πŸ“– Table of contents

  1. General principles
  1. Guidelines
  1. Resources
  2. Translations
  3. Contributors

⛩️ General principles

Unit tests

Unit = Unit of work

The work can involve multiple methods and classes, invoked by some public API that can:

  • Return a value or throw an exception
  • Change the state of the system
  • Make 3rd party calls (API, database, ...)

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

  • Any given behaviour should be specified in one and only one test
  • The execution/order of execution of one test cannot affect the others

The code is designed to support this independence (see "Design principles" below).

Unit tests are lightweight tests

  • Repeatable
  • Fast
  • Consistent
  • Easy to write and read

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 β€’

Design principles

The key to good unit testing is to write testable code. Applying simple design principles can help, in particular:

  • Use a good, consistent naming convention.
  • Don't Repeat Yourself: avoid code duplication.
  • Single responsibility: each software component (function, class, component, module) should focus on a single task.
  • Keep a single level of abstraction in the same component. For example, do not mix business logic with lower-level technical details in the same method.
  • Minimize dependencies between components: encapsulate, interchange less information between components.
  • Support configurability rather than hard-coding: to prevent having to replicate the exact same environment when testing.
  • Apply adequate design patterns: especially dependency injection, to be able to easily substitue the component's dependencies when testing.
  • Avoid global mutable state: to keep things easy to understand and predictable.

β€’ Back to ToC β€’

🧭 Guidelines

The goal of these guidelines is to make your tests:

  • Readable
  • Maintainable
  • Trustworthy

These are the 3 pillars of good unit testing.

All the following examples assume the usage of the Jest testing framework.

β€’ Back to ToC β€’

✨ Whenever possible, use TDD

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:

  1. Think: Figure out what test will best move your code towards completion. (Take as much time as you need. This is the hardest step for beginners.)
  2. Red: Write a very small amount of test code. Only a few lines... Run the tests and watch the new test fail: the test bar should turn red.
  3. Green: Write a very small amount of production code. Again, usually no more than a few lines of code. Don't worry about design purity or conceptual elegance. Sometimes you can just hardcode the answer. This is okay because you'll be refactoring in a moment. Run the tests and watch them pass: the test bar will turn green.
  4. Refactor: Now that your tests are passing, you can make changes without worrying about breaking anything. Pause for a moment, look at the code you've written, and ask yourself if you can improve it. Look for duplication and other "code smells." If you see something that doesn't look right, but you're not sure how to fix it, that's okay. Take a look at it again after you've gone through the cycle a few more times. (Take as much time as you need on this step.) After each little refactoring, run the tests and make sure they still pass.
  5. Repeat: Do it again. You'll repeat this cycle dozens of times in an hour. Typically, you'll run through several cycles (three to five) very quickly, then find yourself slowing down and spending more time on refactoring. Then you'll speed up again.

This process works well for two reasons:

  1. You're working in baby steps, constantly forming hypotheses and checking them. Whenever you make a mistake, you catch it right away. It's only been a few lines of code since you made the mistake, which makes the mistake very easy to find and fix. We all know that finding mistakes, not fixing them, is the most expensive part of programming.
  2. You're always thinking about design. Either you're deciding which test you're going to write next, which is an interface design process, or you're deciding how to refactor, which is a code design process. All of this thought on design is immediately tested by turning it into code, which very quickly shows you if the design is good or bad.

Notice also how code written without a test-first approach is often very hard to test!

β€’ Back to ToC β€’

✨ When applying TDD, always start by writing the simplest failing test

:(

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 β€’

✨ When applying TDD, always make baby steps in each cycle

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 β€’

✨ Structure your tests properly

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 β€’

✨ Name your tests properly

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 β€’

✨ Use the Arrange-Act-Assert pattern

This pattern is a good support to help you read and understand tests more easily:

  • The arrange part is where you set up the objects to be tested: initializing input variables, setting up spies, etc.
  • The act part is where you act upon the code under test: calling a function or a class method, storing the result, ...
  • The assert part is where you test your expectations.
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 β€’

✨ Avoid logic in your tests

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:

  • Conditionals: you don't know which path the test will take.
  • Loops: you could be sharing state between tests.

β€’ Back to ToC β€’

✨ Don't write unnecessary expectations

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 β€’

✨ Test the behaviour, not the internal implementation

:(

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);
});
  • Pro: changing the internal implementation will not necessarily force you to refactor the tests.
  • Con: when a test is failing, you might have to debug to know which part of the code needs to be fixed.

Here, a balance has to be found, unit-testing some key parts can be beneficial.

β€’ Back to ToC β€’

✨ Consider using factory functions

Factories can:

  • help reduce the setup code, especially if you use dependency injection,
  • make each test more readable by favoring cohesion, since the creation is a single function call in the test itself instead of the setup,
  • provide flexibility when creating new instances (setting an initial state, for example).

:(

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 β€’

✨ Don't test multiple concerns in the same test

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 β€’

✨ Cover the general case and the edge cases

Having edge cases covered will:

  • clarify what the code does in a wide range of situations,
  • capture regressions early when the code is refactored,
  • help the future reader fully understand what the code fully does, as tests can be the live documentation of the code.

:(

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 β€’

✨ Use dependency injection

:(

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 β€’

✨ Don't mock everything

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:

  • it does not create a shared state between the tests, causing unexpected side effects,
  • the code being tested does not make HTTP requests or browser page reloads,
  • the speed of execution of the tests stays within the limits you fixed,
  • it leads to a simple, nice and easy tests setup.

β€’ Back to ToC β€’

✨ Don't write unit tests for complex user interactions

Examples of complex user interactions:

  • Filling a form, drag and dropping some items then submitting the form.
  • Clicking a tab, clicking an image thumbnail then navigating through a gallery of images loaded on-demand from an API.

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 β€’

✨ Test simple user actions

Example of simple user actions:

  • Clicking on a link that toggles the visibility of a DOM element
  • Clicking on a button that performs an API call (like sending a tracking event).

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:

  • a "click" event occurs
  • a public method is called

The next step could be to test the logic implemented in showPreview() or hidePreview().

β€’ Back to ToC β€’

✨ Create new tests for every defect

Whenever a bug is found, create a test that replicates the problem before touching any code. Then fix it.

β€’ Back to ToC β€’

✨ Don't comment out tests

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 β€’

✨ Know your testing framework API

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.

✨ Review test code first

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 β€’

✨ Practice code katas, learn with pair programming

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 β€’

πŸ“™ Resources

There's a ton of resources available out there, here are just a few I've found useful...

Reading

Listening

Watching

Tools

Unit testing libraries

End-to-end testing tools

β€’ Back to ToC β€’

Code katas

🌐 Translations

This style guide is also available in other languages:

β€’ Back to ToC β€’

🫢🏾 Contributors