DEV Community

Cover image for How to test classes with Jest
Domagoj Štrekelj
Domagoj Štrekelj

Posted on

How to test classes with Jest

📣 Notice
This article is part of a series:

Testing JavaScript with Jest

To get the most out of this article, I recommend reading the previous article in the series:

How to mock imported functions with Jest


Testing functions is usually straightforward - we have our input arguments and our return value and that's it. Sometimes there's a dependency or two we need to mock, but for the most part functions are easy to reason about.

Testing classes, on the other hand, can become complex fairly quickly. Methods calling each other internally, properties being modified, internal state being tracked and changed with every call. It's not enough to check if the tested method is called correctly. We need to ensure any other methods called internally behave correctly as well - as if they were an internal dependency.

Testing modules with dependencies (internal or external) is made easier with mocks or spies. Jest makes it possible to spy on object methods similarly to creating mock functions and we can leverage that to test our classes with ease.

This article will provide an example on how to test classes with Jest. We will learn how to use spies to observe the behaviour of class methods and how to combine spies across different methods to perform more in-depth tests.

We will assume that we're testing a Validator class that validates a value according to provided validation rule IDs:

// Validator.js
module.exports = class Validator {
  ruleMap = new Map();

  setRule(id, handler) {
    this.ruleMap.set(id, handler);
  }

  getRule(id) {
    return this.ruleMap.get(id);
  }

  validate(value, ruleIds) {
    const errors = [];

    for (const ruleId of ruleIds) {
      const ruleHandler = this.getRule(ruleId);

      if (!ruleHandler(value)) {
        errors.push(ruleId);
      }
    }

    return errors;
  }
};
Enter fullscreen mode Exit fullscreen mode

We want to see what our tests will teach us about the flaws in our code by passing and failing test cases. Fixing the implementation is not covered by this article, but feel free to play with it as we move through the article.

Read on to find out more!


How to test classes with Jest?

To test classes with Jest we write assertions for static and instance methods and check if they match expectations.

The same process we use when testing functions applies to classes. The key difference is that classes with constructors need to be instantiated into objects before testing.

💡Note
We can use the before / after family of global Jest functions to instantiate objects before / after all tests run or each test runs. There is no specific rule on how to approach object creation, we choose what's best for the test we're performing.

A good first test for classes is to write assertions about their interface. We expect our Validator class to define a setRule() method. We'll test that expectation by writing an assertion to check if the setRule property of the Validator object is a function:

const Validator = require("./Validator");

describe("Validator", () => {
  const validator = new Validator();

  test("defines setRule()", () => {
    expect(typeof validator.setRule).toBe("function");
  });
});
Enter fullscreen mode Exit fullscreen mode

💡Note
There are a couple of ways to match an expectation to be a function. The typeof way is the most generic, but also the least precise. To match with strict equality against a specific function we can use .toBe(). Another way to loosely match against any function is with .toEqual(expect.any(Function)).

We can also write an assertion about the setRule() return value which we expect to be undefined:

test("setRule() returns undefined when called", () => {
  expect(validator.setRule()).toBeUndefined();
});
Enter fullscreen mode Exit fullscreen mode

We're off to a good start, but so far we've only tested the interface. To test the implementation - or how the class works on the inside - we need to use spies.


How to test method implementation using spies with Jest?

To test method implementation using spies with Jest we use the jest.spyOn() function.

jest.spyOn() is called with two required parameters - the object and the object method identifier we're spying on. The return value is a mock function (spy) with a reference to the specified object method. This allows us to call the object method and track the calls and returns value in the mock just like we would with a regular jest.fn() mock.

💡Note
Since Jest 22.1.0+ jest.spyOn() also accepts an optional third argument - an access type string value of either "get" or "set" - for spying on getters or setters specifically.

It's important to make sure we don't keep spies around longer than we need them. Spies keep track of state (function calls and their results) between tests. This state can affect our assertions and result in false positives or negatives. To clear the state we use the spy's mockClear() method.

💡Note
Keep in mind that depending on the use-case we might need to use the mockReset() method or mockRestore() method instead.

Using spies, we can now assert that the setRule() method is actually called with the arguments we provide:

test("setRule() is called with arguments", () => {
  // Prepare a spy for the validator.setRule() method.
  const setRuleSpy = jest.spyOn(validator, "setRule");

  // Create a mock rule for use as a function argument.
  const trueRule = jest.fn(() => true);

  const result = validator.setRule("true", trueRule);

  expect(result).toBeUndefined();

  // Check the spy if the method was called correctly.
  expect(setRuleSpy).toHaveBeenCalledWith("true", trueRule);

  // Restore the mock and revert original implementation.
  setRuleSpy.mockClear();
});
Enter fullscreen mode Exit fullscreen mode

💡Note
In some cases we can use the before / after family of global Jest functions to clean up our mocks between tests.


How to test class implementation using spies with Jest?

To test class implementation using spies with Jest we use the jest.spyOn() function and spy on all methods in the class that take part in the core implementation.

Consider the validate() method of our Validator object. For validate() to work, the getRule() method must be called in order to get the rule handler function. Following that, the rule handler function must be called to validate the value. The validate() call then ends by returning an array of rule IDs for which the validated value failed validation.

With that in mind, we expect the following:

  1. validate() to be called with a value and array of rule IDs;
  2. getRule() to be called with the rule ID;
  3. getRule() to return the rule handler registered under the rule ID;
  4. the rule handler to be called with the value that is validated;
  5. validate() to return an array of errors (array length depends on rule).

To test this implementation we will need spies for validate(), getRule(), and the rule handler function. We will also need to register a rule with our validator, but we can do that as part of a separate test:

// Declare mock rule outside of test to reuse it
const trueRule = jest.fn(() => true);

// Register the mock rule in the validator with a test
test("sets rule", () => {
  const setRuleSpy = jest.spyOn(validator, "setRule");
  const result = validator.setRule("true", trueRule);

  expect(setRuleSpy).toHaveBeenCalledWith("true", trueRule);
  expect(result).toBeUndefined();

  setRuleSpy.mockClear();
});

test("validates value", () => {
  const validateSpy = jest.spyOn(validator, "validate");
  const getRuleSpy = jest.spyOn(validator, "getRule");
  const result = validator.validate("foo", ["true"]);

  // Expect validate() to be called with arguments above.
  expect(validateSpy).toHaveBeenCalledWith("foo", ["true"]);

  // Expect getRule() to return the rule with ID "true"
  expect(getRuleSpy).toHaveBeenCalledWith("true");
  expect(getRuleSpy).toHaveReturnedWith(trueRule);

  // Expect rule handler to be called with validated value
  expect(trueRule).toHaveBeenCalledWith("value");
  expect(trueRule).toHaveReturnedWith(true);

  // Expect validation result to be empty array
  expect(result).toBeInstanceOf(Array);
  expect(result.length).toBe(0);

  validateSpy.mockClear();
  getRuleSpy.mockClear();
  trueRule.mockClear();
});
Enter fullscreen mode Exit fullscreen mode

That's it! We can now test our classes in-depth by using spies to track method calls and their return values.


Jest test class methods example code

The module to be tested in Validator.js:

// Validator.js
module.exports = class Validator {
  ruleMap = new Map();

  setRule(id, handler) {
    this.ruleMap.set(id, handler);
  }

  getRule(id) {
    return this.ruleMap.get(id);
  }

  validate(value, ruleIds) {
    const errors = [];

    for (const ruleId of ruleIds) {
      const ruleHandler = this.getRule(ruleId);

      if (!ruleHandler(value)) {
        errors.push(ruleId);
      }
    }

    return errors;
  }
};
Enter fullscreen mode Exit fullscreen mode

The unit test in Validator.spec.js:

// Validator.spec.js
const Validator = require("./Validator");

describe("Validator", () => {
  const validator = new Validator();
  const setRuleSpy = jest.spyOn(validator, "setRule");
  const getRuleSpy = jest.spyOn(validator, "getRule");
  const validateSpy = jest.spyOn(validator, "validate");

  const trueRule = jest.fn(() => true);

  describe(".setRule", () => {
    test("defines a function", () => {
      expect(typeof validator.setRule).toBe("function");
    });

    test("registers rule when called", () => {
      expect(validator.setRule("true", trueRule)).toBeUndefined();
      expect(setRuleSpy).toHaveBeenCalledWith("true", trueRule);

      setRuleSpy.mockClear();
    });
  });

  describe(".getRule", () => {
    test("defines a function", () => {
      expect(typeof validator.setRule).toBe("function");
    });

    test("returns registered rule", () => {
      expect(validator.getRule("true")).toBe(trueRule);
      expect(getRuleSpy).toHaveBeenCalledWith("true");

      getRuleSpy.mockClear();
    });
  });

  describe(".validate", () => {
    test("defines a function", () => {
      expect(typeof validator.setRule).toBe("function");
    });

    test("validates value without errors", () => {
      const result = validator.validate("value", ["true"]);

      expect(validateSpy).toHaveBeenCalledWith("value", ["true"]);
      expect(getRuleSpy).toHaveBeenCalledWith("true");
      expect(getRuleSpy).toHaveReturnedWith(trueRule);
      expect(trueRule).toHaveBeenCalledWith("value");
      expect(trueRule).toHaveReturnedWith(true);
      expect(result).toBeInstanceOf(Array);
      expect(result.length).toBe(0);

      validateSpy.mockClear();
      getRuleSpy.mockClear();
      trueRule.mockClear();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Homework and next steps

  • Write more comprehensive tests and use fixtures to cover any additional cases.
  • Fix the code so any failed tests pass or write a newer, better implementation.
  • Achieve 100% code coverage in the coverage report.

Thank you for taking the time to read through this article!

Have you tried mocking classes with Jest before? What was your experience like?

Leave a comment and start a discussion!

Discussion (0)