ThisSuitIsBlackNot
ThisSuitIsBlackNot

Reputation: 24063

"Invalid hook call" when mocking React HOC with Jest

I'm using react-speech-recognition to transcribe speech to text in my React app. react-speech-recognition provides the SpeechRecognition higher-order component, which injects additional properties like browserSupportsSpeechRecognition into wrapped components.

My App component looks like this:

// src/App.js
import React, { useEffect } from 'react';
import SpeechRecognition from 'react-speech-recognition';

const App = ({ transcript, browserSupportsSpeechRecognition }) => {
    useEffect(() => {
        console.log(`transcript changed: ${transcript}`);
    }, [transcript]);

    if (! browserSupportsSpeechRecognition) {
        return <span className="error">Speech recognition not supported</span>;
    }

    return <span className="transcript">{transcript}</span>;
};

const options = {
    autoStart: false,
    continuous: false
};

export default SpeechRecognition(options)(App);

I wrote some tests to emulate both browsers that support speech recognition and browsers that don't:

// src/App.spec.js
import React from 'react';
import Enzyme, { mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import chai, { expect } from 'chai';
import chaiEnzyme from 'chai-enzyme';

chai.use(chaiEnzyme());

Enzyme.configure({ adapter: new Adapter() });

// Generate a mock SpeechRecognition HOC with the given props
function mockSpeechRecognition(mockProps) {
    return function(options) {
        return function(WrappedComponent) {
            return function(props) {
                return (
                    <WrappedComponent 
                        {...props}
                        {...mockProps}
                        recognition={{}}
                    />
                );
            };
        };
    };
}

describe('App component', () => {

    beforeEach(() => jest.resetModules());

    it('should show an error when speech recognition is not supported', () => {
        jest.mock('react-speech-recognition', () => mockSpeechRecognition({
            browserSupportsSpeechRecognition: false
        }));

        const App = require('./App').default;
        const wrapper = mount(<App />);

        expect(wrapper).to.contain.exactly(1).descendants('.error');
        expect(wrapper.find('.error'))
            .to.have.text('Speech recognition not supported');
    });

    it('should show the transcript when speech recognition is supported', () => {
        jest.mock('react-speech-recognition', () => mockSpeechRecognition({
            browserSupportsSpeechRecognition: true,
            transcript: 'foo'
        }));

        const App = require('./App').default;
        const wrapper = mount(<App />);

        expect(wrapper).to.contain.exactly(1).descendants('.transcript');
        expect(wrapper.find('.transcript')).to.have.text('foo');
    });

});

When I run these tests, I get an "Invalid hook call" error that causes the tests to fail:

  ● App component › should show an error when speech recognition is not supported

    Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
    1. You might have mismatching versions of React and the renderer (such as React DOM)
    2. You might be breaking the Rules of Hooks
    3. You might have more than one copy of React in the same app
    See https://reactjs.org/warnings/invalid-hook-call-warning.html for tips about how to debug and fix this problem.

      3 | 
      4 | const App = ({ transcript, browserSupportsSpeechRecognition }) => {
    > 5 |     useEffect(() => {
        |     ^
      6 |         console.log(`transcript changed: ${transcript}`);
      7 |     }, [transcript]);
      8 | 

      at resolveDispatcher (node_modules/react/cjs/react.development.js:1465:13)
      at useEffect (node_modules/react/cjs/react.development.js:1508:20)
      at App (src/App.js:5:5)
      at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:14803:18)
      at mountIndeterminateComponent (node_modules/react-dom/cjs/react-dom.development.js:17482:13)
      at beginWork (node_modules/react-dom/cjs/react-dom.development.js:18596:16)
      at HTMLUnknownElement.callCallback (node_modules/react-dom/cjs/react-dom.development.js:188:14)
      at invokeEventListeners (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:193:27)
      at HTMLUnknownElementImpl._dispatch (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:119:9)
      at HTMLUnknownElementImpl.dispatchEvent (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:82:17)
      at HTMLUnknownElementImpl.dispatchEvent (node_modules/jsdom/lib/jsdom/living/nodes/HTMLElement-impl.js:30:27)
      at HTMLUnknownElement.dispatchEvent (node_modules/jsdom/lib/jsdom/living/generated/EventTarget.js:157:21)
      at Object.invokeGuardedCallbackDev (node_modules/react-dom/cjs/react-dom.development.js:237:16)
      at invokeGuardedCallback (node_modules/react-dom/cjs/react-dom.development.js:292:31)
      at beginWork$1 (node_modules/react-dom/cjs/react-dom.development.js:23203:7)
      at performUnitOfWork (node_modules/react-dom/cjs/react-dom.development.js:22157:12)
      at workLoopSync (node_modules/react-dom/cjs/react-dom.development.js:22130:22)
      at performSyncWorkOnRoot (node_modules/react-dom/cjs/react-dom.development.js:21756:9)
      at scheduleUpdateOnFiber (node_modules/react-dom/cjs/react-dom.development.js:21188:7)
      at updateContainer (node_modules/react-dom/cjs/react-dom.development.js:24373:3)
      at node_modules/react-dom/cjs/react-dom.development.js:24758:7
      at unbatchedUpdates (node_modules/react-dom/cjs/react-dom.development.js:21903:12)
      at legacyRenderSubtreeIntoContainer (node_modules/react-dom/cjs/react-dom.development.js:24757:5)
      at Object.render (node_modules/react-dom/cjs/react-dom.development.js:24840:10)
      at fn (node_modules/enzyme-adapter-react-16/src/ReactSixteenAdapter.js:437:26)
      at node_modules/enzyme-adapter-react-16/src/ReactSixteenAdapter.js:354:37
      at batchedUpdates$1 (node_modules/react-dom/cjs/react-dom.development.js:21856:12)
      at Object.act (node_modules/react-dom/cjs/react-dom-test-utils.development.js:929:14)
      at wrapAct (node_modules/enzyme-adapter-react-16/src/ReactSixteenAdapter.js:354:13)
      at Object.render (node_modules/enzyme-adapter-react-16/src/ReactSixteenAdapter.js:423:16)
      at new ReactWrapper (node_modules/enzyme/src/ReactWrapper.js:115:16)
      at mount (node_modules/enzyme/src/mount.js:10:10)
      at Object.<anonymous> (src/App.spec.js:38:25)

  ● App component › should show the transcript when speech recognition is supported

    Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
    1. You might have mismatching versions of React and the renderer (such as React DOM)
    2. You might be breaking the Rules of Hooks
    3. You might have more than one copy of React in the same app
    See https://reactjs.org/warnings/invalid-hook-call-warning.html for tips about how to debug and fix this problem.

      3 | 
      4 | const App = ({ transcript, browserSupportsSpeechRecognition }) => {
    > 5 |     useEffect(() => {
        |     ^
      6 |         console.log(`transcript changed: ${transcript}`);
      7 |     }, [transcript]);
      8 | 

      at resolveDispatcher (node_modules/react/cjs/react.development.js:1465:13)
      at useEffect (node_modules/react/cjs/react.development.js:1508:20)
      at App (src/App.js:5:5)
      at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:14803:18)
      at mountIndeterminateComponent (node_modules/react-dom/cjs/react-dom.development.js:17482:13)
      at beginWork (node_modules/react-dom/cjs/react-dom.development.js:18596:16)
      at HTMLUnknownElement.callCallback (node_modules/react-dom/cjs/react-dom.development.js:188:14)
      at invokeEventListeners (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:193:27)
      at HTMLUnknownElementImpl._dispatch (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:119:9)
      at HTMLUnknownElementImpl.dispatchEvent (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:82:17)
      at HTMLUnknownElementImpl.dispatchEvent (node_modules/jsdom/lib/jsdom/living/nodes/HTMLElement-impl.js:30:27)
      at HTMLUnknownElement.dispatchEvent (node_modules/jsdom/lib/jsdom/living/generated/EventTarget.js:157:21)
      at Object.invokeGuardedCallbackDev (node_modules/react-dom/cjs/react-dom.development.js:237:16)
      at invokeGuardedCallback (node_modules/react-dom/cjs/react-dom.development.js:292:31)
      at beginWork$1 (node_modules/react-dom/cjs/react-dom.development.js:23203:7)
      at performUnitOfWork (node_modules/react-dom/cjs/react-dom.development.js:22157:12)
      at workLoopSync (node_modules/react-dom/cjs/react-dom.development.js:22130:22)
      at performSyncWorkOnRoot (node_modules/react-dom/cjs/react-dom.development.js:21756:9)
      at scheduleUpdateOnFiber (node_modules/react-dom/cjs/react-dom.development.js:21188:7)
      at updateContainer (node_modules/react-dom/cjs/react-dom.development.js:24373:3)
      at node_modules/react-dom/cjs/react-dom.development.js:24758:7
      at unbatchedUpdates (node_modules/react-dom/cjs/react-dom.development.js:21903:12)
      at legacyRenderSubtreeIntoContainer (node_modules/react-dom/cjs/react-dom.development.js:24757:5)
      at Object.render (node_modules/react-dom/cjs/react-dom.development.js:24840:10)
      at fn (node_modules/enzyme-adapter-react-16/src/ReactSixteenAdapter.js:437:26)
      at node_modules/enzyme-adapter-react-16/src/ReactSixteenAdapter.js:354:37
      at batchedUpdates$1 (node_modules/react-dom/cjs/react-dom.development.js:21856:12)
      at Object.act (node_modules/react-dom/cjs/react-dom-test-utils.development.js:929:14)
      at wrapAct (node_modules/enzyme-adapter-react-16/src/ReactSixteenAdapter.js:354:13)
      at Object.render (node_modules/enzyme-adapter-react-16/src/ReactSixteenAdapter.js:423:16)
      at new ReactWrapper (node_modules/enzyme/src/ReactWrapper.js:115:16)
      at mount (node_modules/enzyme/src/mount.js:10:10)
      at Object.<anonymous> (src/App.spec.js:52:25)

However, there are no such errors when I run the dev server and view the page in a browser, and I can see the useEffect hook logging a message to the console. There are also no errors when I create a production build. I think the issue is in how I mocked out the SpeechRecognition HOC. The tests pass if I remove the useEffect hook.

This is a brand new project started with create-react-app. I only have one copy of react and react-dom and the versions match:

$ npm ls react react-dom
[email protected] /Users/NMD/max_programming_projects/react-speech-recognition-invalid-hook-call
├── [email protected] 
└── [email protected]

How can I fix this error in my tests?

Upvotes: 8

Views: 17455

Answers (3)

ThisSuitIsBlackNot
ThisSuitIsBlackNot

Reputation: 24063

It looks like this is a bug in Jest:

Invalid hook call after `jest.resetModules` for dynamic `require`s

The bug happens when you call jest.resetModules or jest.resetModuleRegistry and then require your component inside your tests.

You can work around it by removing jest.resetModules/jest.resetModuleRegistry and wrapping the requires in calls to jest.isolateModules:

describe('App component', () => {

    it('should show an error when speech recognition is not supported', () => {
        jest.mock('react-speech-recognition', () => mockSpeechRecognition({
            browserSupportsSpeechRecognition: false
        }));

        jest.isolateModules(() => {
            const App = require('./App').default;
            const wrapper = mount(<App />);

            expect(wrapper).to.contain.exactly(1).descendants('.error');
            expect(wrapper.find('.error'))
                .to.have.text('Speech recognition not supported');
        });
    });

    it('should show the transcript when speech recognition is supported', () => {
        jest.mock('react-speech-recognition', () => mockSpeechRecognition({
            browserSupportsSpeechRecognition: true,
            transcript: 'foo'
        }));

        jest.isolateModules(() => {
            const App = require('./App').default;
            const wrapper = mount(<App />);

            expect(wrapper).to.contain.exactly(1).descendants('.transcript');
            expect(wrapper.find('.transcript')).to.have.text('foo');
        });
    });

});

When I run this, all tests pass and I can see the output from the useEffect hook:

 PASS  src/App.spec.js
  App component
    ✓ should show an error when speech recognition is not supported (89ms)
    ✓ should show the transcript when speech recognition is supported (6ms)

  console.log src/App.js:6
    transcript changed: undefined

  console.log src/App.js:6
    transcript changed: foo

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        6.577s
Ran all test suites related to changed files.

Upvotes: 15

gdh
gdh

Reputation: 13682

See enzyme github open issue

You have few options but in general you need to mock your speech recognition hoc properly by passing browserSupportsSpeechRecognition as shown in the below code.

Option One

You can mock useEffect. Just write code to suit your needs in useEffect mock.

describe("App component", () => {
  beforeEach(() => jest.resetModules());

  it("should show an error when speech recognition is not supported", () => {

    jest.mock("react", () => ({
      ...jest.requireActual("React"),
      useEffect: (f) => f(),
    }));

    jest.mock("react-speech-recognition", () => {
      return mockSpeechRecognition({ browserSupportsSpeechRecognition: false });
    });

    const App = require("./App").default;
    const wrapper = mount(<App transcript={"hi"} />);

    expect(wrapper).to.contain.exactly(1).descendants(".error");
    expect(wrapper.find(".error")).to.have.text(
      "Speech recognition not supported"
    );
  });

Option Two

You do not actually need to mock speech recognition hoc at all. It is an overhead. The library guys will do their testing themselves. You can do a named export of App and import it and write regular tests.

describe("App component - no mock", () => {
  beforeEach(() => jest.resetModules());

  it("should show an error when speech recognition is not supported", () => {
    const wrapper = mount(
      <App browserSupportsSpeechRecognition={false} transcript={"hi"} />
    );

    expect(wrapper).to.contain.exactly(1).descendants(".error");
    expect(wrapper.find(".error")).to.have.text(
      "Speech recognition not supported"
    );
  });

  it("should NOT show an error when speech recognition is not supported", () => {
    const wrapper = mount(
      <App browserSupportsSpeechRecognition={true} transcript={"hi"} />
    );

    expect(wrapper).does.not.contain.descendants(".error");
    // expect(wrapper.find(".error")).to.have.text(
    //   "Speech recognition not supported"
    // );
  });
});

Option Three

Use react testing library instead of enzyme.


Above tests are run locally and they pass

References:

Upvotes: 3

J&#243;zef Podlecki
J&#243;zef Podlecki

Reputation: 11283

Could you try mock SpeechRecognition following way?

jest.mock('react-speech-recognition', () => ({
  __esModule: true, 
  default: mockSpeechRecognition({
    browserSupportsSpeechRecognition: false
  })
}));

Upvotes: 0

Related Questions