【问题标题】:Testing React Functional Component with Hooks using Jest使用 Jest 使用 Hooks 测试 React 功能组件
【发布时间】:2019-07-09 20:26:32
【问题描述】:

因此,我正在从基于类的组件转移到功能组件,但是在使用 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 方法呢?如果它是基于类的组件,我可以只是浅化组件并从实例中使用它

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

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

【问题讨论】:

  • 我删除了我的旧答案,因为它是错误的,对不起...顺便说一句:是的,功能组件在测试时是黑盒子

标签: reactjs typescript jestjs enzyme react-hooks


【解决方案1】:

在我看来,您不应该担心单独测试 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)。

【讨论】:

  • 看来测试useEffect产生的效果的唯一方法是通过setTimeout函数。我希望 enzyme 能够提供更直观的 DSL 来等待异步效果完成,但似乎还没有。
  • 覆盖率如何处理此类测试?内部函数/钩子会被标记为已覆盖吗?
  • 基本同意,但有一个很大的挑战。 Login 组件的唯一职责是公开登录行为(不仅仅是更新表单)。我不会提取对登录到另一个文件的行为非常具体的逻辑并在您的测试中模拟这些导入(随意提取,但不要模拟)。通过这样做,您将 Login 组件的测试与其实现紧密耦合,这会导致测试的脆弱性(您提取的逻辑的任何重构都可能破坏您的测试,即使行为没有改变)。测试 Login 的行为,而不是实现!
  • 我可以确认,某些应用程序中强制要求的代码覆盖率会因不测试 FC 内的功能而受到损害。最简单的方法是将您的函数移到 FC 之外,以便您使用 jest 来模拟或监视它们。但是,这仅对某些组件类型有意义,并且很难在 FC 内部测试没有可观察结果的功能。重点......尝试抽象出所需的功能并将它们放在一个可以重用的地方,以便您减少代码并改进测试。
  • 感谢您的出色回答,但我并不完全同意您的看法。在某些情况下,我真的想测试这些方法(validateForm 和 handleSubmit)调用。是的,我同意我们可以测试副作用,但这并不能让我将测试用例缩小到小细节。一系列函数调用导致单一副作用是很常见的。使用您的解决方案,我无法一一测试这些功能。
【解决方案2】:

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

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

这个断言将永远通过,因为......它从未被执行。计算测试中有多少个断言并编写以下内容,因为只执行一个断言而不是两个。所以现在检查你的测试是否有误报)

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

 expect(true).toEqual(true);

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

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

【讨论】:

  • 这没有提供问题的答案。一旦您有足够的声誉,您就可以对任何帖子发表评论;相反,提供不需要提问者澄清的答案。
  • @John 尝试安装而不是从酶中变浅以测试生命周期方法。
【解决方案3】:

因此,通过接受 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

【讨论】:

    【解决方案4】:

    不使用 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>
    </>);
    

    当我尝试类似的事情并尝试从测试用例中检查按钮的状态(启用/禁用)时,我没有得到该状态的预期值。但是我删除了 disabled={isLoginDisabled} 并将其替换为 (password.length

    【讨论】:

      【解决方案5】:

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

      下面是一个使用 Enzyme 浅包装器的示例,该包装器调用 useEffect() 钩子和异步调用,导致调用 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() 中。您可以使用相同的帮助程序而无需任何修改。

      【讨论】:

        猜你喜欢
        • 2021-06-24
        • 2020-03-24
        • 1970-01-01
        • 2020-04-08
        • 2020-09-21
        • 1970-01-01
        • 2020-11-02
        • 2019-06-25
        • 2021-08-30
        相关资源
        最近更新 更多