67

So I'm moving away from class based components to functional components but am stuck while writing test with jest/enzyme for the methods inside the functional components which explicitly uses hooks. Here is the stripped down version of my code.

function validateEmail(email: string): boolean {
  return email.includes('@');
}

const Login: React.FC<IProps> = (props) => {
  const [isLoginDisabled, setIsLoginDisabled] = React.useState<boolean>(true);
  const [email, setEmail] = React.useState<string>('');
  const [password, setPassword] = React.useState<string>('');

  React.useLayoutEffect(() => {
    validateForm();
  }, [email, password]);

  const validateForm = () => {
    setIsLoginDisabled(password.length < 8 || !validateEmail(email));
  };

  const handleEmailChange = (evt: React.FormEvent<HTMLFormElement>) => {
    const emailValue = (evt.target as HTMLInputElement).value.trim();
    setEmail(emailValue);
  };

  const handlePasswordChange = (evt: React.FormEvent<HTMLFormElement>) => {
    const passwordValue = (evt.target as HTMLInputElement).value.trim();
    setPassword(passwordValue);
  };

  const handleSubmit = () => {
    setIsLoginDisabled(true);
      // ajax().then(() => { setIsLoginDisabled(false); });
  };

  const renderSigninForm = () => (
    <>
      <form>
        <Email
          isValid={validateEmail(email)}
          onBlur={handleEmailChange}
        />
        <Password
          onChange={handlePasswordChange}
        />
        <Button onClick={handleSubmit} disabled={isLoginDisabled}>Login</Button>
      </form>
    </>
  );

  return (
  <>
    {renderSigninForm()}
  </>);
};

export default Login;

I know I can write tests for validateEmail by exporting it. But what about testing the validateForm or handleSubmit methods. If it were a class based components I could just shallow the component and use it from the instance as

const wrapper = shallow(<Login />);
wrapper.instance().validateForm()

But this doesn't work with functional components as the internal methods can't be accessed this way. Is there any way to access these methods or should the functional components be treated as a blackbox while testing?

skyboyer
  • 22,209
  • 7
  • 57
  • 64
acesmndr
  • 8,137
  • 3
  • 23
  • 28
  • 1
    I removed my old answer because it was wrong, sorry... BTW: yes, the functional components are black boxes while testing – NoriSte Feb 19 '19 at 16:54

5 Answers5

64

In my opinion, you shouldn't worry about individually testing out methods inside the FC, rather testing it's side effects. eg:

  it('should disable submit button on submit click', () => {
    const wrapper = mount(<Login />);
    const submitButton = wrapper.find(Button);
    submitButton.simulate('click');

    expect(submitButton.prop('disabled')).toBeTruthy();
  });

Since you might be using useEffect which is async, you might want to wrap your expect in a setTimeout:

setTimeout(() => {
  expect(submitButton.prop('disabled')).toBeTruthy();
});

Another thing you might want to do, is extract any logic that has nothing to do with interacting with the form intro pure functions. eg: instead of:

setIsLoginDisabled(password.length < 8 || !validateEmail(email));

You can refactor:

Helpers.js

export const isPasswordValid = (password) => password.length > 8;
export const isEmailValid    = (email) => {
  const regEx = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

  return regEx.test(email.trim().toLowerCase())
}

LoginComponent.jsx

import { isPasswordValid, isEmailValid } from './Helpers';
....
  const validateForm = () => {
    setIsLoginDisabled(!isPasswordValid(password) || !isEmailValid(email));
  };
....

This way you could individually test isPasswordValid and isEmailValid, and then when testing the Login component, you can mock your imports. And then the only things left to test for your Login component would be that on click, the imported methods get called, and then the behaviour based on those response eg:

- it('should invoke isPasswordValid on submit')
- it('should invoke isEmailValid on submit')
- it('should disable submit button if email is invalid') (isEmailValid mocked to false)
- it('should disable submit button if password is invalid') (isPasswordValid mocked to false)
- it('should enable submit button if email is invalid') (isEmailValid and isPasswordValid mocked to true)

The main advantage with this approach is that the Login component should just handle updating the form and nothing else. And that can be tested pretty straight forward. Any other logic, should be handled separately (separation of concerns).

Alex Stoicuta
  • 882
  • 9
  • 7
  • 2
    it seems that the only way to test an effect produced by `useEffect`, is through `setTimeout` function. I was hoping that _enzyme_ would provide a slightly more intuitive DSL for waiting async effects to complete, but seems there's nothing out there yet. – Evgeniya Manolova Dec 20 '19 at 12:15
  • How would coverage treat such testing? will the inner functions/hooks be marked as covered? – user636312 Apr 28 '20 at 10:56
  • 2
    Agree mostly, with one big challenge. The Login component's single responsibility is to expose the behavior of logging in (not just updating a form). I wouldn't extract logic that is _very specific_ to the behavior of logging in to another file AND mock those imports in your test (feel free to extract, but do not mock). By doing so you're tightly coupling the tests of your Login component to its implementation, which leads to test fragility (any refactoring of the logic you extracted can break your tests, even if behavior hasn't changed). Test the behavior of Login, not the implementation! – M. Irvin May 05 '20 at 14:26
  • I can confirm that code coverage, which is mandated in some apps, is hurt by not testing the functions inside a FC. The easiest approach is to move your functions outside of the FC so that you use jest to mock or spy on them. However, this only makes sense for some component types and it is difficult to test functions inside a FC that do not have an observable outcome. The point...tryi to abstract the functions needed and put them in a place were they can be reused so that you cut down on code and improve testing. – Sigma VX May 28 '20 at 13:04
  • 1
    thank you for your great answer, but I don't totally agree with you. There are still cases where I really want to test those methods (validateForm and handleSubmit) call. Yes, I agree that we can test the side effects, but this does not let me narrow down my test cases to small details. It's very common that a series of functions calls result in a single side effect. Using your solution I cannot test these functions one by one. – Kasra Dec 31 '20 at 18:01
7

Cannot write comments but you must note that what Alex Stoicuta said is wrong:

setTimeout(() => {
  expect(submitButton.prop('disabled')).toBeTruthy();
});

this assertion will always pass, because ... it's never executed. Count how many assertions are in your test and write the following, because only one assertion is performed instead of two. So check your tests now for false positive)

it('should fail',()=>{
 expect.assertions(2);

 expect(true).toEqual(true);

 setTimeout(()=>{
  expect(true).toEqual(true)
 })
})

Answering your question, how do you test hooks? I don't know, looking for an answer myself, because for some reason the useLayoutEffect is not being tested for me...

John Archer
  • 105
  • 1
  • 6
  • 3
    This does not provide an answer to the question. Once you have sufficient reputation you will be able to comment on any post; instead, provide answers that don't require clarification from the asker. – Johan Jun 07 '19 at 14:14
  • 1
    @John Try mounting instead of shallowing from enzyme for testing lifecycle methods. – acesmndr Jun 11 '19 at 16:00
3

So by taking Alex's answer I was able to formulate the following method to test the component.

describe('<Login /> with no props', () => {
  const container = shallow(<Login />);
  it('should match the snapshot', () => {
    expect(container.html()).toMatchSnapshot();
  });

  it('should have an email field', () => {
    expect(container.find('Email').length).toEqual(1);
  });

  it('should have proper props for email field', () => {
    expect(container.find('Email').props()).toEqual({
      onBlur: expect.any(Function),
      isValid: false,
    });
  });

  it('should have a password field', () => {
    expect(container.find('Password').length).toEqual(1);
  });

  it('should have proper props for password field', () => {
    expect(container.find('Password').props()).toEqual({
      onChange: expect.any(Function),
      value: '',
    });
  });

  it('should have a submit button', () => {
    expect(container.find('Button').length).toEqual(1);
  });

  it('should have proper props for submit button', () => {
    expect(container.find('Button').props()).toEqual({
      disabled: true,
      onClick: expect.any(Function),
    });
  });
});

To test the state updates like Alex mentioned I tested for sideeffects:

it('should set the password value on change event with trim', () => {
    container.find('input[type="password"]').simulate('change', {
      target: {
        value: 'somenewpassword  ',
      },
    });
    expect(container.find('input[type="password"]').prop('value')).toEqual(
      'somenewpassword',
    );
  });

but to test the lifecycle hooks I still use mount instead of shallow as it is not yet supported in shallow rendering. I did seperate out the methods that aren't updating state into a separate utils file or outside the React Function Component. And to test uncontrolled components I set a data attribute prop to set the value and checked the value by simulating events. I have also written a blog about testing React Function Components for the above example here: https://medium.com/@acesmndr/testing-react-functional-components-with-hooks-using-enzyme-f732124d320a

acesmndr
  • 8,137
  • 3
  • 23
  • 28
3

Currently Enzyme doesn't support React Hooks and Alex's answer is correct, but looks like people (including myself) were struggling with using setTimeout() and plugging it into Jest.

Below is an example of using Enzyme shallow wrapper that calls useEffect() hook with async calls that results in calling useState() hooks.

// This is helper that I'm using to wrap test function calls
const withTimeout = (done, fn) => {
    const timeoutId = setTimeout(() => {
        fn();
        clearTimeout(timeoutId);
        done();
    });
};

describe('when things happened', () => {
    let home;
    const api = {};

    beforeEach(() => {
        // This will execute your useEffect() hook on your component
        // NOTE: You should use exactly React.useEffect() in your component,
        // but not useEffect() with React.useEffect import
        jest.spyOn(React, 'useEffect').mockImplementation(f => f());
        component = shallow(<Component/>);
    });

    // Note that here we wrap test function with withTimeout()
    test('should show a button', (done) => withTimeout(done, () => {
        expect(home.find('.button').length).toEqual(1);
    }));
});

Also, if you have nested describes with beforeEach() that interacts with component then you'll have to wrap beforeEach calls into withTimeout() as well. You could use the same helper without any modifications.

dimka
  • 76
  • 5
0

Instead of isLoginDisabled state, try using the function directly for disabled. Eg.

const renderSigninForm = () => (
<>
  <form>
    <Email
      isValid={validateEmail(email)}
      onBlur={handleEmailChange}
    />
    <Password
      onChange={handlePasswordChange}
    />
    <Button onClick={handleSubmit} disabled={(password.length < 8 || !validateEmail(email))}>Login</Button>
  </form>
</>);

When I was trying similar thing and was trying to check state(enabled/disabled) of the button from the test case, I didn't get the expected value for the state. But I removed disabled={isLoginDisabled} and replaced it with (password.length < 8 || !validateEmail(email)), it worked like a charm. P.S: I am a beginner with react, so have very limited knowledge on react.

shoan
  • 1,303
  • 15
  • 22