Convert Figma logo to code with AI

mawrkus logojs-unit-testing-guide

📙 A guide to unit testing in Javascript

1,956
176
1,956
1

Top Related Projects

12,902

Next generation testing framework powered by Vite.

44,166

Delightful JavaScript Testing.

22,590

☕️ simple, flexible, fun javascript test framework for node.js & the browser

15,742

Simple JavaScript testing framework for browsers and node.js

46,847

Fast, easy and reliable testing for anything that runs in a browser.

20,731

Node.js test runner that lets you develop with confidence 🚀

Quick Overview

The js-unit-testing-guide is a comprehensive resource for JavaScript developers focusing on unit testing best practices. It provides a detailed guide on writing effective unit tests, covering various aspects of testing methodologies, tools, and techniques specifically tailored for JavaScript applications.

Pros

  • Offers in-depth explanations and best practices for unit testing in JavaScript
  • Covers a wide range of topics, from basic concepts to advanced techniques
  • Provides practical examples and code snippets to illustrate key points
  • Regularly updated to reflect current trends and tools in the JavaScript testing ecosystem

Cons

  • May be overwhelming for beginners due to the extensive amount of information
  • Some sections might require prior knowledge of testing concepts
  • Lacks interactive elements or exercises for hands-on practice
  • Primarily focuses on unit testing, with limited coverage of other testing types

Code Examples

This repository is a guide and does not contain a code library. Therefore, there are no specific code examples to showcase. However, the guide itself includes numerous code snippets and examples throughout its content to illustrate various testing concepts and techniques.

Getting Started

As this is a guide rather than a code library, there's no specific installation or setup process. To get started:

  1. Visit the GitHub repository: mawrkus/js-unit-testing-guide
  2. Read through the README.md file for an overview of the guide's structure
  3. Navigate through the different sections of the guide based on your interests or needs
  4. Implement the suggested practices in your own JavaScript projects

The guide is organized into several main sections, including:

  • General guidelines
  • Naming
  • Arrange, Act, Assert
  • Practices
  • Assertions
  • Mocking

Each section contains detailed explanations, best practices, and examples to help you improve your unit testing skills in JavaScript.

Competitor Comparisons

12,902

Next generation testing framework powered by Vite.

Pros of Vitest

  • Faster test execution due to its Vite-based architecture
  • Native TypeScript support without additional configuration
  • Built-in code coverage reporting

Cons of Vitest

  • Less comprehensive documentation compared to js-unit-testing-guide
  • Primarily focused on Vite-based projects, potentially limiting its use in other environments
  • Newer project with a smaller community and ecosystem

Code Comparison

js-unit-testing-guide example:

describe('calculateTotal', () => {
  it('should return the sum of all items', () => {
    const items = [{ price: 10 }, { price: 20 }, { price: 30 }];
    expect(calculateTotal(items)).toBe(60);
  });
});

Vitest example:

import { describe, it, expect } from 'vitest';

describe('calculateTotal', () => {
  it('should return the sum of all items', () => {
    const items = [{ price: 10 }, { price: 20 }, { price: 30 }];
    expect(calculateTotal(items)).toBe(60);
  });
});

The code examples are similar, with Vitest requiring explicit imports from the 'vitest' package. Both use a describe-it structure and similar assertion syntax, making it easy for developers to transition between the two.

44,166

Delightful JavaScript Testing.

Pros of Jest

  • Comprehensive testing framework with built-in assertion library, mocking, and code coverage
  • Zero-config setup for most JavaScript projects
  • Parallel test execution for faster performance

Cons of Jest

  • Steeper learning curve for beginners compared to simpler testing guides
  • Potentially overkill for small projects or those requiring specific testing setups
  • Less flexibility in choosing individual testing tools and libraries

Code Comparison

js-unit-testing-guide example:

describe('sum', () => {
  it('should add two numbers correctly', () => {
    expect(sum(2, 3)).toBe(5);
  });
});

Jest example:

test('adds 2 + 3 to equal 5', () => {
  expect(sum(2, 3)).toBe(5);
});

Summary

Jest is a full-featured testing framework, offering a complete solution for JavaScript testing. It provides many built-in features and requires minimal configuration. However, it may be more complex for beginners and less flexible for specific setups.

js-unit-testing-guide, on the other hand, is a comprehensive guide to JavaScript unit testing principles and best practices. It offers more flexibility in tool choice and is potentially easier for beginners to understand, but requires more manual setup and configuration.

The choice between the two depends on project requirements, team expertise, and desired level of control over the testing environment.

22,590

☕️ simple, flexible, fun javascript test framework for node.js & the browser

Pros of Mocha

  • Widely adopted and mature testing framework with extensive ecosystem
  • Flexible and supports various assertion libraries and test styles
  • Built-in support for asynchronous testing and promises

Cons of Mocha

  • Requires additional setup and configuration compared to js-unit-testing-guide
  • Steeper learning curve for beginners due to its extensive features
  • May be overkill for small projects or simple testing needs

Code Comparison

js-unit-testing-guide example:

describe('MyClass', () => {
  it('should do something', () => {
    const myClass = new MyClass();
    expect(myClass.doSomething()).toBe(true);
  });
});

Mocha example:

const assert = require('assert');
describe('MyClass', () => {
  it('should do something', () => {
    const myClass = new MyClass();
    assert.strictEqual(myClass.doSomething(), true);
  });
});

Summary

js-unit-testing-guide is a comprehensive guide for JavaScript unit testing best practices, while Mocha is a feature-rich testing framework. js-unit-testing-guide provides valuable insights and principles for writing effective unit tests, making it an excellent resource for learning and improving testing skills. Mocha, on the other hand, offers a robust testing environment with extensive features and flexibility, making it suitable for a wide range of projects and testing scenarios. The choice between the two depends on the specific needs of the project and the developer's experience level.

15,742

Simple JavaScript testing framework for browsers and node.js

Pros of Jasmine

  • Comprehensive testing framework with built-in assertion library and test runner
  • Extensive documentation and large community support
  • Supports both browser and Node.js environments

Cons of Jasmine

  • Steeper learning curve for beginners compared to js-unit-testing-guide
  • More complex setup and configuration required
  • Less flexibility in choosing individual testing tools

Code Comparison

js-unit-testing-guide example:

describe('calculateTotal', () => {
  it('should return the sum of all items', () => {
    const items = [{ price: 10 }, { price: 20 }, { price: 30 }];
    expect(calculateTotal(items)).toBe(60);
  });
});

Jasmine example:

describe('calculateTotal', function() {
  it('should return the sum of all items', function() {
    var items = [{ price: 10 }, { price: 20 }, { price: 30 }];
    expect(calculateTotal(items)).toEqual(60);
  });
});

The code examples are similar, with minor syntax differences. Jasmine uses function declarations instead of arrow functions and toEqual instead of toBe for assertions.

46,847

Fast, easy and reliable testing for anything that runs in a browser.

Pros of Cypress

  • Comprehensive end-to-end testing framework with built-in tools for debugging and test recording
  • Real-time reloading and automatic waiting for elements, reducing flaky tests
  • Extensive documentation and active community support

Cons of Cypress

  • Limited to testing web applications, not suitable for unit testing JavaScript functions
  • Steeper learning curve due to its comprehensive nature and unique API
  • Can be slower for large test suites compared to lightweight unit testing frameworks

Code Comparison

js-unit-testing-guide example (using Jest):

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

Cypress example:

describe('My First Test', () => {
  it('Visits the Kitchen Sink', () => {
    cy.visit('https://example.cypress.io')
    cy.contains('type').click()
    cy.url().should('include', '/commands/actions')
  })
})

Summary

While js-unit-testing-guide focuses on best practices for JavaScript unit testing, Cypress is a comprehensive end-to-end testing framework. js-unit-testing-guide is more suitable for developers looking to improve their unit testing skills, while Cypress excels in automating browser-based testing scenarios. The choice between the two depends on the specific testing needs of a project.

20,731

Node.js test runner that lets you develop with confidence 🚀

Pros of ava

  • Concurrent test execution for faster performance
  • Simple and minimal API, reducing boilerplate code
  • Built-in support for ES6 and async functions

Cons of ava

  • Less extensive documentation compared to js-unit-testing-guide
  • Steeper learning curve for beginners due to its unique approach
  • Limited ecosystem of plugins and extensions

Code Comparison

js-unit-testing-guide example:

describe('Calculator', () => {
  it('should add two numbers correctly', () => {
    const result = calculator.add(2, 3);
    expect(result).toBe(5);
  });
});

ava example:

import test from 'ava';

test('Calculator adds two numbers correctly', t => {
  const result = calculator.add(2, 3);
  t.is(result, 5);
});

Summary

js-unit-testing-guide is a comprehensive guide for JavaScript unit testing, offering best practices and examples for various testing scenarios. It's an excellent resource for learning and improving testing skills.

ava is a test runner focused on simplicity and performance. It provides a minimal API and concurrent test execution, making it suitable for projects that prioritize speed and simplicity.

While js-unit-testing-guide offers more extensive documentation and guidance, ava provides a more streamlined testing experience with built-in support for modern JavaScript features.

Convert Figma logo designs to code with AI

Visual Copilot

Introducing Visual Copilot: A new AI model to turn Figma designs to high quality code using your components.

Try Visual Copilot

README

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