reactjsunit-testingjestjsreact-testing-librarywindow.location

FE: Unit testing window.location.href changing on button click (test fails)


I am trying to test when the window.location.href changes after a button is clicked using react-testing-library. I have seen examples online where you manually update window.location.href inside of a test case as so window.location.href = 'www.randomurl.com' and then follow it with expect(window.location.href).toEqual(www.randomurl.com). While this indeed will pass, I want to avoid this as I'd rather simulate the user actions instead of injecting the new value into the test. If I do that, even if I remove my button click (which is what will actually trigger the function call) the expect will still pass because I have anyway manually updated the window.location.href in my test

What I've opted for is having goToThisPage func (which will redirect the user) to be placed outside of my functional component. I then mock goToThisPage in my test file and in my test case check whether it has been called. I do know that the goToThisPage is being triggered because I included a console.log and when I run my tests I see it in my terminal. Nonetheless, the test still fails. I have been playing around with both spyOn and jest.doMock/mock with no luck

component.js

import React from 'react'
import { ChildComponent } from './childcomponent';
export const goToThisPage = () => {
  const url = '/url'
  window.location.href = url;
  console.log('reached');
};

export const Component = () => {
 return (<ChildComponent goToThisPage={ goToThisPage }/>)
}

export default Component;

Test file:

  import * as Component from './component'
  import userEvent from '@testing-library/user-event';

  jest.doMock('./component', () => ({
   goToThisPage: jest.fn(),
  }));

  describe('goToThisPage', () => {
    test('should call goToThisPage when button is clicked', async () => {
        const goToThisPageSpy = jest.spyOn(Component, 'goToThisPage');
        const { container, getByTestId } = render(<Component.Component />);

        userEvent.click(screen.getByTestId('goToThisPage')); // this is successfully triggered (test id exists in child component)
        expect(goToThisPageSpy).toHaveBeenCalled();
        // expect(Component.goToThisPage()).toHaveBeenCalled(); this will fail and say that the value must be a spy or mock so I opted for using spy above
    });
});

Note: when I try to just do jest.mock I got this error Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: undefined. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports. When testing out with jest.doMock the error disappeared but the actual test fails.

I am open to hear more refined ideas of solving my issue if someone believes this solution could be improved. Thanks in advance

Edit: This is another approach I have tried out

  import { Component, goToThisPage } from './component'
  import userEvent from '@testing-library/user-event';

  describe('goToThisPage', () => {
    test('should call goToThisPage when button is clicked', async () => {
        const goToThisPageSpy = jest.spyOn(Component, 'goToThisPage'); 
// I am not certain what I'd put as the first value in the spy. Because `goToThisPage` is an external func of <Component/> & not part of the component
        const { container, getByTestId } = render(<Component />);

        userEvent.click(screen.getByTestId('goToThisPage'));
        expect(goToThisPageSpy).toHaveBeenCalled();
    });
});

Solution

  • Save yourself the headache and split the goToThisPage function into its own file. You seem to be mocking the goToThisPage function fine but when the Component is rendered with react testing library it doesn't seem render with the mocked function but defaults to what the function would normally do. This easiest way would be just to mock the function from its own file. If you truly want to keep the function in the same file you will need to make some adjustments, see (example #2) but I do not recommend this path.

    See below for examples

    Example 1: (Recommended) Split function into it's own file

    Component.spec.jsx

    import React from "react";
    import Component from "./Component";
    import { render, screen } from "@testing-library/react";
    import userEvent from "@testing-library/user-event";
    import * as goToThisPage from "./goToThisPage";
    
    jest.mock('./goToThisPage');
    
    describe("goToThisPage", () => {
        test("should call goToThisPage when button is clicked", async () => {
            const goToThisPageSpy = jest.spyOn(goToThisPage, 'default').mockImplementation(() => console.log('hi'));
            render(<Component />);
            userEvent.click(screen.getByTestId("goToThisPage"));
            expect(goToThisPageSpy).toHaveBeenCalled();
        });
    });
    

    goToThisPage.js

    export const goToThisPage = () => {
        const url = "/url";
        window.location.href = url;
    };
    
    export default goToThisPage;
    

    Component.jsx

    import React from "react";
    import ChildComponent from "./ChildComponent";
    import goToThisPage from "./goToThisPage";
    
    export const Component = () => {
        return <ChildComponent goToThisPage={goToThisPage} />
    };
    
    export default Component;
    

    Example 2: (Not Recommend for React components!)

    We can also get it working by calling the goToThisPage function via exports. This ensures the component is rendered with our spyOn and mockImplementation. To get this working for both browser and jest you need to ensure we run the original function if it's on browser. We can do this by creating a proxy function that determines which function to return based on a ENV that jest defines when it runs.

    Component.jsx

    import React from "react";
    import ChildComponent from "./ChildComponent";
    
    export const goToThisPage = () => {
        const url = "/url";
        window.location.href = url;
    };
    
    // jest worker id, if defined means that jest is running
    const isRunningJest = !!process.env.JEST_WORKER_ID;
    
    // proxies the function, if jest is running we return the function
    // via exports, else return original function. This is because
    // you cannot invoke exports functions in browser!
    const proxyFunctionCaller = (fn) => isRunningJest ? exports[fn.name] : fn;
    
    export const Component = () => {
        return <ChildComponent goToThisPage={proxyFunctionCaller(goToThisPage)} />
    };
    
    export default Component;
    

    Component.spec.jsx

    import React from "react";
    import { render, screen } from "@testing-library/react";
    import userEvent from "@testing-library/user-event";
    
    describe("goToThisPage", () => {
        test("should call goToThisPage when button is clicked", async () => {
            const Component = require('./Component');
            const goToThisPageSpy = jest.spyOn(Component, 'goToThisPage').mockImplementation(() => console.log('hi'));
    
            render(<Component.default />);
    
            userEvent.click(screen.getByTestId("goToThisPage"));
            expect(goToThisPageSpy).toHaveBeenCalled();
        });
    });
    

    You can move the function proxy to it's own file but you need to pass exports into the proxy function as exports is scoped to it's own file.

    Example code

    // component.js
    import React from "react";
    import ChildComponent from "./ChildComponent";
    import proxyFunctionCaller from "./utils/proxy-function-caller";
    
    export const goToThisPage = () => {
        const url = "/url";
        window.location.href = url;
    };
    
    export const Component = () => {
        return <ChildComponent goToThisPage={proxyFunctionCaller(typeof exports !== 'undefined' ? exports : undefined, goToThisPage)} />
    };
    
    export default Component;
    
    // utils/proxy-function-caller.js
    
    // jest worker id, if defined means that jest is running
    const isRunningJest = !!process.env.JEST_WORKER_ID;
    
    // proxies the function, if jest is running we return the function
    // via exports, else return original function. This is because
    // you cannot invoke exports functions in browser!
    const proxyFunctionCaller = (exports, fn) => isRunningJest ? exports[fn.name] : fn;
    
    export default proxyFunctionCaller;
    

    There are other ways to do this but I would follow the first solution as you should be splitting utility functions into it's own files anyway. Goodluck.

    Example 3 for @VinceN

    You can mock a function that lives in the same file using the below example files.

    SomeComponent.tsx

    import * as React from 'react';
    
    const someFunction = () => 'hello world';
    
    const SomeComponent = () => {
      return (
        <div data-testid="innards">
          {someFunction()}
        </div>
      )
    }
    
    export default SomeComponent;
    
    

    SomeComponent.spec.tsx

    import SomeComponent from './SomeComponent';
    import { render, screen } from "@testing-library/react";
    
    jest.mock('./SomeComponent', () => ({
      __esModule: true,
      ...jest.requireActual('./SomeComponent'),
      someFunction: jest.fn().mockReturnValue('mocked!')
    }));
    
    describe('<SomeComponent />', () => {
      it('renders', () => {
          render(<SomeComponent />);
          const el = screen.getByTestId('innards');
    
          expect(el.textContent).toEqual('mocked!');
      });
    });