使用 Jest 测试带有 Hooks 的 React 功能组件

Testing React Functional Component with Hooks using Jest

所以我正在从基于 class 的组件转移到功能组件,但是在使用 jest/enzyme 为功能组件内显式使用挂钩的方法编写测试时卡住了。这是我的代码的精简版。

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;

我知道我可以通过导出来为 validateEmail 编写测试。但是如何测试 validateFormhandleSubmit 方法呢?如果它是基于 class 的组件,我可以将组件浅化并从实例中使用它作为

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

但这不适用于功能组件,因为无法通过这种方式访问​​内部方法。有什么方法可以访问这些方法,或者在测试时是否应该将功能组件视为黑盒?

在我看来,您不应该担心在 FC 中单独测试方法,而是测试它的副作用。 例如:

  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();
  });

由于您可能正在使用异步的 useEffect,因此您可能希望将期望包装在 setTimeout:

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

您可能想要做的另一件事是提取与表单介绍纯函数交互无关的任何逻辑。 例如: 而不是:

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

你可以重构:

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));
  };
....

这样你可以单独测试isPasswordValidisEmailValid,然后在测试Login组件时,你可以mock your imports。然后,Login 组件唯一需要测试的就是单击时调用导入的方法,然后是基于这些响应的行为 例如:

- 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)

这种方法的主要优点是 Login 组件应该只处理 更新表单 而没有别的。这可以非常直接地进行测试。任何其他逻辑,应单独处理 (separation of concerns).

不能写评论但是你必须注意Alex Stoicuta说的是错误的:

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

此断言将始终通过,因为...它从未执行过。数一数你的测试中有多少断言并写下以下内容,因为只执行了一个断言而不是两个。所以现在检查你的测试是否有误报)

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

 expect(true).toEqual(true);

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

回答你的问题,你如何测试钩子?我不知道,自己寻找答案,因为出于某种原因 useLayoutEffect 没有为我测试...

所以通过 Alex 的回答,我能够制定以下方法来测试组件。

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),
    });
  });
});

为了像 Alex 提到的那样测试状态更新,我测试了副作用:

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',
    );
  });

但是为了测试生命周期钩子,我仍然使用 mount 而不是 shallow,因为浅渲染尚不支持它。 我确实将不更新状态的方法分离到一个单独的 utils 文件中或在 React 函数组件之外。 为了测试不受控制的组件,我设置了一个数据属性 prop 来设置值并通过模拟事件检查该值。我还写了一篇关于为上面的例子测试 React 函数组件的博客: https://medium.com/@acesmndr/testing-react-functional-components-with-hooks-using-enzyme-f732124d320a

不要使用 isLoginDisabled 状态,而是尝试直接使用禁用功能。 例如

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

当我尝试类似的事情并试图从测试用例中检查按钮的状态 (enabled/disabled) 时,我没有得到状态的预期值。但是我删除了 disabled={isLoginDisabled} 并将其替换为 (password.length < 8 || !validateEmail(email)),效果非常好。 P.S: 我是react初学者,对react的了解非常有限

目前 Enzyme 不支持 React Hooks,Alex 的回答是正确的,但看起来人们(包括我自己)正在努力使用 setTimeout() 并将其插入 Jest。

下面是使用调用 useEffect() 挂钩的 Enzyme 浅层包装器的示例,异步调用导致调用 useState() 挂钩。

// 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);
    }));
});

此外,如果您嵌套了与组件交互的 beforeEach() 描述,那么您还必须将 beforeEach 调用包装到 withTimeout() 中。您可以在不进行任何修改的情况下使用相同的助手。