3
votes

I have a React hooks functional component that I'd like to test with Jest/Enzyme. I would like test its tertiary render behaviour based upon a useState value. I can't seem to find any example online. There is no 'click' to simulate - no API call to mock because at the end, I still need to test based upon the useState value. In the past, with class components, I could set the state. With the new hooks, I can't. So, basically - how do I mock an async await inside a mocked submitForm function so that the render behaves properly?

Here's my component:

import React, { useState } from 'react';
import { Redirect } from 'react-router-dom';

import Form from 'core/Form';

export const Parent = ({submitForm}) => {
  const [formValues, setFormValues] = useState({});
  const [redirect, setRedirect] = useState(false);

  const handleChange = name => evt => {
    setFormValues({ ...formValues, [name]: evt.target.value });
  };

  const onSubmit = async () => {
      try {
        const res = await submitForm(formValues);
        if (res) setRedirect(true);
        else setRedirect(false);
      } catch (err) {
        console.log('Submit error: ', err);
      }
  };

  return redirect ? (
    <Redirect push to={path} />
  ) : (
    <Form onSubmit={onSubmit} values={formValues} onChange={handleChange} />
  );
};

export default Parent;

Here's my testing so far:

import React from 'react';
import { shallow } from 'enzyme';
import { Redirect } from 'react-router-dom';

import Parent from './Parent';
import Form from 'core/Form';

let wrapper, props;
.
.
.

describe('<Parent /> rendering', () => {
  beforeEach(() => {
    props = createTestProps();
    wrapper = shallow(<Parent {...props} />);
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  const setState = jest.fn();
  const useStateSpy = jest.spyOn(React, 'useState');
  useStateSpy.mockImplementation(init => [init, setState]);

  it('Should render 1 Form', () => {
    expect(wrapper.find(Form)).toHaveLength(1);
  });

  it('renders Redirect after API call', () => {
    setRedirect = jest.fn(() => false);

    expect(wrapper.find(Redirect)).toHaveLength(1);
  });

  it('renders Form before API call', () => {
    setRedirect = jest.fn(() => true);

    expect(wrapper.find(Form)).toHaveLength(1);
  });
});
1
You shouldn't have been directly setting the state for testing previously, either! That's implementation, not behaviour - ideally, you should be able to switch back and forth between functional and class components without changing the tests, for example. The state is set by the Form child component, so if you're shallow rendering you could mock out that component to get access to the setter, or do a deeper render and actually interact with the Form. - jonrsharpe
Why would you mark me down for not knowing something I am asking a question about? Also, your answer is confusing. - Adrian Bartholomew
Here's an example from another SO reply directly setting state. it('should render the Notification component if state.error is true', () => { const loginComponent = shallow(<Login />); loginComponent.setState({ error: true }); expect(loginComponent.find(Notification).length).toBe(1); }); stackoverflow.com/questions/44399181/… - Adrian Bartholomew
The fact that it was possible (and that user wanted to do it) didn't make it right! See e.g. testing-library.com/docs/react-testing-library/faq for a library that deliberately implements a less-coupled philosophy. - jonrsharpe
Also, you haven't understood my question. You wrote - "The state is set by the Form child component" - which is incorrect. If that were so, I'd simply 'mount' instead of 'shallow' and simulate the submit button click of the child Form component. However, the state is set by the truthiness of the response of an API call. The submit button simply initiates the call in the parent. - Adrian Bartholomew

1 Answers

4
votes

You don't need to spy useState hook. Which means you should not test these hooks and methods of the component directly. Instead, you should test components' behavior(the state, props and what is rendered)

E.g.

index.tsx:

import React, { useState } from 'react';
import { Redirect } from 'react-router-dom';

export const Form = ({ onSubmit, onChange, values }) => <form onSubmit={onSubmit}></form>;
const path = '/user';

export const Parent = ({ submitForm }) => {
  const [formValues, setFormValues] = useState({});
  const [redirect, setRedirect] = useState(false);

  const handleChange = (name) => (evt) => {
    setFormValues({ ...formValues, [name]: evt.target.value });
  };

  const onSubmit = async () => {
    try {
      const res = await submitForm(formValues);
      if (res) setRedirect(true);
      else setRedirect(false);
    } catch (err) {
      console.log('Submit error: ', err);
    }
  };

  return redirect ? (
    <Redirect push to={path} />
  ) : (
    <Form onSubmit={onSubmit} values={formValues} onChange={handleChange} />
  );
};

export default Parent;

index.test.tsx:

import Parent, { Form } from './';
import React from 'react';
import { shallow } from 'enzyme';
import { Redirect } from 'react-router-dom';
import { act } from 'react-dom/test-utils';

const whenStable = async () =>
  await act(async () => {
    await new Promise((resolve) => setTimeout(resolve, 0));
  });

describe('60137762', () => {
  it('should render Form', () => {
    const props = { submitForm: jest.fn() };
    const wrapper = shallow(<Parent {...props}></Parent>);
    expect(wrapper.find(Form)).toBeTruthy();
  });

  it('should handle submit and render Redirect', async () => {
    const props = { submitForm: jest.fn().mockResolvedValueOnce(true) };
    const wrapper = shallow(<Parent {...props}></Parent>);
    wrapper.find(Form).simulate('submit');
    await whenStable();
    expect(props.submitForm).toBeCalledWith({});
    expect(wrapper.find(Redirect)).toBeTruthy();
  });

  it('should handle submit and render Form', async () => {
    const props = { submitForm: jest.fn().mockResolvedValueOnce(false) };
    const wrapper = shallow(<Parent {...props}></Parent>);
    wrapper.find(Form).simulate('submit');
    await whenStable();
    expect(props.submitForm).toBeCalledWith({});
    expect(wrapper.find(Form)).toBeTruthy();
  });

  it('should handle error if submit failure', async () => {
    const logSpy = jest.spyOn(console, 'log');
    const mError = new Error('network');
    const props = { submitForm: jest.fn().mockRejectedValueOnce(mError) };
    const wrapper = shallow(<Parent {...props}></Parent>);
    wrapper.find(Form).simulate('submit');
    await whenStable();
    expect(props.submitForm).toBeCalledWith({});
    expect(logSpy).toHaveBeenCalledWith('Submit error: ', mError);
  });
});

Unit test results with coverage report:

 PASS  stackoverflow/60137762/index.test.tsx
  60137762
    ✓ should render Form (18ms)
    ✓ should handle submit and render Redirect (15ms)
    ✓ should handle submit and render Form (8ms)
    ✓ should handle error if submit failure (18ms)

  console.log node_modules/jest-environment-enzyme/node_modules/jest-mock/build/index.js:866
    Submit error:  Error: network
        at /Users/ldu020/workspace/github.com/mrdulin/react-apollo-graphql-starter-kit/stackoverflow/60137762/index.test.tsx:39:20
        at step (/Users/ldu020/workspace/github.com/mrdulin/react-apollo-graphql-starter-kit/stackoverflow/60137762/index.test.tsx:44:23)
        at Object.next (/Users/ldu020/workspace/github.com/mrdulin/react-apollo-graphql-starter-kit/stackoverflow/60137762/index.test.tsx:25:53)
        at /Users/ldu020/workspace/github.com/mrdulin/react-apollo-graphql-starter-kit/stackoverflow/60137762/index.test.tsx:19:71
        at new Promise (<anonymous>)
        at Object.<anonymous>.__awaiter (/Users/ldu020/workspace/github.com/mrdulin/react-apollo-graphql-starter-kit/stackoverflow/60137762/index.test.tsx:15:12)
        at Object.<anonymous> (/Users/ldu020/workspace/github.com/mrdulin/react-apollo-graphql-starter-kit/stackoverflow/60137762/index.test.tsx:37:47)
        at Object.asyncJestTest (/Users/ldu020/workspace/github.com/mrdulin/react-apollo-graphql-starter-kit/node_modules/jest-jasmine2/build/jasmineAsyncInstall.js:100:37)
        at resolve (/Users/ldu020/workspace/github.com/mrdulin/react-apollo-graphql-starter-kit/node_modules/jest-jasmine2/build/queueRunner.js:43:12)
        at new Promise (<anonymous>)
        at mapper (/Users/ldu020/workspace/github.com/mrdulin/react-apollo-graphql-starter-kit/node_modules/jest-jasmine2/build/queueRunner.js:26:19)
        at promise.then (/Users/ldu020/workspace/github.com/mrdulin/react-apollo-graphql-starter-kit/node_modules/jest-jasmine2/build/queueRunner.js:73:41)

-----------|---------|----------|---------|---------|-------------------
File       | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
-----------|---------|----------|---------|---------|-------------------
All files  |   78.57 |      100 |      40 |   93.75 |                   
 index.tsx |   78.57 |      100 |      40 |   93.75 | 12                
-----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        3.716s, estimated 5s

Source code: https://github.com/mrdulin/react-apollo-graphql-starter-kit/tree/master/stackoverflow/60137762