相关文章推荐
心软的猕猴桃  ·  OSMNX & Cenpy — ...·  3 月前    · 
酷酷的鸵鸟  ·  CUSTOMBUILD : error : ...·  5 月前    · 

Testing JavaScript with Jest

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

How to write unit tests in JavaScript with Jest

Writing unit tests for code with dependencies can be difficult. This is especially true for large code bases where it's tricky to come up with fixtures that will cover all the cases we need to test.

But what if we could control the return value of a function dependency, no matter what arguments it is called with?

This is where mock functions come in.

Mock functions are a testing tool that allows us to track how function dependencies are called and control their return values. This makes it possible for us to manipulate the control flow of the tested program and reach even those difficult-to-reproduce edge-cases when writing tests.

This article will provide an introduction into the concepts behind mocking and how it relates to unit testing. We will learn how to mock functions and imported function modules with Jest, and write tests that rely on those mocks to increase the coverage of our test cases.

We will assume that we're testing a couple of validation rules:

// isInteger.js
module.exports = (value) => Number.isSafeInteger(value);
    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!

To mock an imported function with Jest we use the jest.mock() function.

jest.mock() is called with one required argument - the import path of the module we're mocking. It can also be called with an optional second argument - the factory function for the mock. If the factory function is not provided, Jest will automock the imported module.

💡Note
Jest automock is the automatic mocking of imported modules with surface-level replacement implementations. Automocking is disabled by default since Jest 15, but can be enabled by configuring Jest with the automock flag.

When testing isAtLeast18() we have to keep in mind that the isInteger() dependency affects the module's behaviour:

  • if isInteger() is false , isAtLeast18() is false;
  • if isInteger() is true , isAtLeast18() depends on the value argument.
  • We'll start by testing the case of isInteger() returning false.

    The isInteger.js module has a single default export - the isInteger() function. We will mock the imported module with a factory function that behaves just like the default export and returns a function. That function, when called, will always return false.

    // isAtLeast18.spec.js
    const isAtLeast18 = require("./isAtLeast18");
    // The mock factory returns the function () => false
    jest.mock("./isInteger", () => () => false);
    describe("isAtLeast18", () => {
        it("fails if value is not recognised as integer", () => {
            // Should pass, but fails because of the isInteger() mock
            expect(isAtLeast18(123)).toBe(false);
            // Should fail either way
            expect(isAtLeast18("abc")).toBe(false);
        Enter fullscreen mode
        Exit fullscreen mode
    

    💡Note
    The import path of the mocked module must match the import path that is present in the module we're testing. The isAtLeast18.js module imports the isInteger.js module under the path "./isInteger". This is why our mock import path is also "./isInteger".

    isAtLeast18() will now always return false no matter what we call it with, because the isInteger() mock is set to always return false.

    But what about the case when isInteger() returns true?

    To mock different return values depending on the test we will create a mock function.

    💡Note
    This unit test is a solitary unit test because the tested unit is isolated from its dependencies. Read more about solitary unit tests in the previous article: How to write unit tests in JavaScript with Jest.

    A mock function is a function that replaces the actual implementation of a function with a "fake" (mock) implementation.

    Mock functions track how they are called by external code. With a mock function we can know the number of times the function was called, the arguments it was called with, the result it returned, and more. This ability to "spy" on function calls is why mock functions are also called spies.

    We use mock functions to override original function behaviour with custom mock implementations. Mock implementations help us control the return values of a function. This makes our tests more predictable (deterministic) and easier to write.

    To mock a function with Jest we use the jest.fn() function.

    jest.fn() can be called with an implementation function as an optional argument. If an implementation is provided, calling the mock function will call the implementation and return it's return value.

    If no implementation is provided, calling the mock returns undefined because the return value is not defined.

    // Without implementation, this mock returns `undefined`.
    const mockUndefined = jest.fn();
    // With implementation, this mock returns `true`.
    const mockTrue = jest.fn(() => true).
        Enter fullscreen mode
        Exit fullscreen mode
    
    const mockOne = jest.fn(() => false);
    // Example error: expect(jest.fn()).toHaveBeenCalledWith(...expected)
    const mockTwo = jest.fn(() => false).mockName('mockTwo');
    // Example error: expect(mockTwo).toHaveBeenCalledWith(...expected)
        Enter fullscreen mode
        Exit fullscreen mode
    

    To change the mock implementation of a function with Jest we use the mockImplementation() method of the mocked function.

    The mockImplementation() method is called with the new implementation as its argument. The new implementation will then be used in place of the previous one when the mock is called.

    // The initial mock is a function that returns `true`.
    const myMock = jest.fn(() => true);
    // The new mock implementation has the function return `false`.
    myMock.mockImplementation(() => false);
        Enter fullscreen mode
        Exit fullscreen mode
    
    // isAtLeast18.spec.js
    const isAtLeast18 = require("./isAtLeast18");
    const isInteger = require("./isInteger");
    // The mock factory returns a mocked function
    jest.mock("./isInteger", () => jest.fn());
    describe("isAtLeast18", () => {
        it("fails if value is not recognised as integer", () => {
            // For this test we'll mock isInteger to return `false`
            isInteger.mockImplementation(() => false);
            expect(isAtLeast18(123)).toBe(false);
            expect(isAtLeast18("abc")).toBe(false);
        it("passes if value is recognised as integer and is at least 18", () => {
            // For this test we'll mock isInteger to return `true`
            isInteger.mockImplementation(() => true);
            expect(isAtLeast18(123)).toBe(true);
            expect(isAtLeast18("abc")).toBe(false);
        Enter fullscreen mode
        Exit fullscreen mode
    

    💡Note
    jest.mock() works by modifying the Node module cache to give us the mock instead of the original implementation whenever we import a mocked module in a test file. To support ES module imports - where import statements have to come first in a file - Jest automatically hoists jest.mock() calls to the top of the module. Read more about this technique here.

    To check if a function was called correctly with Jest we use the expect() function with specific matcher methods to create an assertion.

    We can use the toHaveBeenCalledWith() matcher method to assert the arguments the mocked function has been called with.

    To assert how many times the mocked function has been called so far, we can use the toHaveBeenCalledTimes() matcher method.

    // isAtLeast18.spec.js
    const isAtLeast18 = require("./isAtLeast18");
    const isInteger = require("./isInteger");
    jest.
    
    
    
    
        
    mock("./isInteger", () => jest.fn());
    describe("isAtLeast18", () => {
      it("fails if value is not recognised as integer", () => {
        isInteger.mockImplementation(() => false);
        expect(isAtLeast18(123)).toBe(false);
            // We expect isInteger to be called with 123
        expect(isInteger).toHaveBeenCalledWith(123);
            // We expect isInteger to be called once
        expect(isInteger).toHaveBeenCalledTimes(1);
        Enter fullscreen mode
        Exit fullscreen mode
    

    💡Note
    While these are the most common matcher methods for functions, there are more matcher methods available in the Jest API docs.

    Jest tracks all calls to mocked functions. A mocked function will remember the arguments and times it has been called, as well as the results of those calls.

    When reusing mocked functions between tests it is useful to reset their states before running new tests to get a clear baseline. We can do that by clearing mocked functions between tests.

    // isAtLeast18.spec.js
    const isAtLeast18 = require("./isAtLeast18");
    const isInteger = require("./isInteger");
    jest.mock("./isInteger", () => jest.fn());
    describe("isAtLeast18", () => {
      it("fails if value is not recognised as integer", () => {
        isInteger.mockImplementation(() => false);
        expect(isAtLeast18(123)).toBe(false);
        expect(isInteger).toHaveBeenCalledWith(123);
        expect(isInteger).toHaveBeenCalledTimes(1);
            // Clear the mock so the next test starts with fresh data
        isInteger.mockClear();
      it("passes if value is recognised as integer and is at least 18", () => {
        isInteger.mockImplementation(() => true);
        expect(isAtLeast18(123)).toBe(true);
        expect(isInteger).toHaveBeenCalledWith(123);
            // Without clearing, there would be 2 calls total at this point
        expect(isInteger).toHaveBeenCalledTimes(1);
        Enter fullscreen mode
        Exit fullscreen mode
    
    // isAtLeast18.spec.js
    const isAtLeast18 = require("./isAtLeast18");
    const isInteger = require("./isInteger");
    jest.mock("./isInteger", () => jest.fn());
    // Clear mock data before each test
    beforeEach(() => {
      isInteger.mockClear();
    describe("isAtLeast18", () => {
      it("fails if value is not recognised as integer", () => {
        isInteger.mockImplementation(() => false);
        expect(isAtLeast18(123)).toBe(false);
        expect(isInteger).toHaveBeenCalledWith(123);
        expect(isInteger).toHaveBeenCalledTimes(1);
      it("passes if value is recognised as integer and is at least 18", () => {
        isInteger.mockImplementation(() => true);
        expect(isAtLeast18(123)).toBe(true);
        expect(isInteger).toHaveBeenCalledWith(123);
        expect(isInteger).toHaveBeenCalledTimes(1);
        Enter fullscreen mode
        Exit fullscreen mode
    

    💡Note
    Jest provides four functions to hook into the set-up and tear-down process, both before and after each or all of the tests in a test file. These functions are: afterAll(), afterEach(), beforeAll(), beforeEach(). The afterAll() and beforeAll() variants are called only once for the entire test file. The afterEach() and beforeEach() variants are called once for every test in the test file.

    To reuse mocks with Jest we create mocks in a __mocks__/ subdirectory adjacent to the module we want to mock.

    Mock files in the __mocks__/ subdirectory are used to automock the modules they are adjacent to when the module is mocked with jest.mock(). This is useful when dealing with a lot of repetition in setting up mocks such as when mocking common dependencies or configuration objects because it makes writing a mock factory function unnecessary.

    Assuming a common configuration file that is used by many different modules, mocking it would look like this:

    // common/config.js
    module.exports = { foo: "bar" };
        Enter fullscreen mode
        Exit fullscreen mode
    

    💡Note
    Remember: mocks and automocking are only in effect when running tests with Jest. They do not have an effect on the code in development or production.

    That's it! We're now ready to mock imported functions with Jest.

    // isAtLeast18.spec.js
    const isAtLeast18 = require("./isAtLeast18");
    const isInteger = require("./isInteger");
    // The mock factory returns a mocked function
    jest.mock("./isInteger", () => jest.fn());
    beforeEach(() => {
      isInteger.mockClear();
    describe("isAtLeast18", () => {
      it("fails if value is not recognised as integer", () => {
        isInteger.mockImplementation(() => false);
        expect(isAtLeast18(123)).toBe(false);
        expect(isInteger).toHaveBeenCalledWith(123);
        expect(isInteger).toHaveBeenCalledTimes(1);
      it("passes if value is recognised as integer and is at least 18", () => {
        isInteger.mockImplementation(() => true);
        expect(isAtLeast18(123)).toBe(true);
        expect(isInteger).toHaveBeenCalledWith(123);
        expect(isInteger).toHaveBeenCalledTimes(1);
        Enter fullscreen mode
        Exit fullscreen mode
    
  • Write more comprehensive tests and use fixtures to cover any additional cases. If you've done your homework from the previous article, try continuing from where you left off.
  • 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 imported functions with Jest before? What was your experience like?

    Leave a comment and start a discussion!

    // isInteger.js
     module.exports = (value) => {
        return Number.isSafeInteger(value);
        Enter fullscreen mode
        Exit fullscreen mode
    module.exports = (value) => {
        if (isInteger(value) && value >= 18)
            return true;
            return false;
        Enter fullscreen mode
        Exit fullscreen mode
              

    If I use the __mocks__
    And I want to use mockImplementation at the same time
    what is the way ?

    // __mocks__/any.js
    module.exports = value => jest.fn()
        Enter fullscreen mode
        Exit fullscreen mode
          

    Built on Forem — the open source software that powers DEV and other inclusive communities.

    Made with love and Ruby on Rails. DEV Community © 2016 - 2024.