【译】用 Enzyme 测试使用 Hooks 的 React 函数组件

4,983 阅读10分钟

原文地址: medium.com/@acesmndr/t…
译文地址:github.com/xiao-T/note…
本文版权归原作者所有,翻译仅用于学习。


React 函数组件本质上是一个返回 React Element 的简单函数。这是 React v16.8 中最值得期待的功能,通过 Hooks 的文档,我们知道使用 Hooks 可以在无状态的函数组件中注入 state 和生命周期方法,让组件变成 stateful。Hooks 简单的语法和即插即用的特性,让编写函数组件变得有趣,反观,编写类组件变得有点麻烦。

看下这个用 Hooks 实现的 React 函数组件。

import React from 'react';

export default function Login(props) {
  const { email: propsEmail, password: propsPassword, dispatch } = props;
  const [isLoginDisabled, setIsLoginDisabled] = React.useState(true);
  const [email, setEmail] = React.useState(propsEmail || '');
  const [password, setPassword] = React.useState(propsPassword || '');

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

  const validateEmail = text => /@/.test(text);

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

  const handleEmailBlur = evt => {
    const emailValue = evt.target.value.trim();
    setEmail(emailValue);
  };

  const handlePasswordChange = evt => {
    const passwordValue = evt.target.value.trim();
    setPassword(passwordValue);
  };

  const handleSubmit = () => {
    dispatch('submit(email, password)');
    setIsLoginDisabled(true);
  };

  return (
    <form>
      <input
        type="email"
        placeholder="email"
        className="mx-auto my-2"
        onBlur={handleEmailBlur}
      />
      <input
        type="password"
        className="my-2"
        onChange={handlePasswordChange}
        value={password}
      />
      <input
        type="button"
        className="btn btn-primary"
        onClick={handleSubmit}
        disabled={isLoginDisabled}
        value="Submit"
      />
    </form>
  );
}

上面函数组件中的 form 由一个不可控的 email input 和一个可控 password input 组成,内部通过 useStateuseEffect Hooks 来更新 state,然后,提交按钮触发一个提交的动作。

针对这个组件我要覆盖各种各样的情况,因此,我不得不为函数组件编写测试用例。

Enzyme 和 Jest

如何安装 Enzyme 和 Jest,我不打算做过多的介绍。为了让这片文章更加简短,我假设你们已经对其有所了解。简单的说,Jest 就一个 javascript 测试框架,我用它来写测试;Enzyme 是一个测试工具库,为了更加容易的编写测试用例,两者配合一起使用。以下是有关 Jest 和 Enzyme 相关设置的资源:

Shallow vs Mount

对于外行来说,mount 会渲染组件的所有的节点,但是,shallow 就像它的名字一样它只会渲染组件本身,并不会渲染子组件。

相比 mount 我更喜欢 shallow,这是因为它更有助于对组件做单元测试,而不是断言组件内部的行为。如果,我们用到了一些 UI 组件库比如:Reactstrap,这就非常有用。因为,我们并不想对这些库里面的组件进行测试(因为组件自身已经测试过了)。如果,我们用 mount,那么组件库的相关节点也会被渲染。shallow 并不会渲染它们,我们可以正常使用这些组件。同时,相比 mount,shallow 在性能方面也更有优势。

过去,我编写的类组件都是用 shallow,并配合 Jest 来测试的。Enzyme 中类组件的测试文档非常完善。但是,关于函数组件的测试文档就很少,我在使用的时候,也只是刚刚发布。React 团队推荐使用 react-testing-library 去测试 Hooks。

使用 Enzyme 我无法找到合适方式访问并测试那些可以更新组件 state 的内部方法。google 搜索后,也没找到合适的方案通过 Enzyme shallow 测试函数组件,我试了各种方法,比如:在 stackoverflow 提问。然后得到了 Alex 的回复:

因为,我们无法知道 hooks 是通过 spy ,还是组件自身调用的,但是,我们可以通过检测 state 的更新来确定 hooks 的执行状态,这似乎是正确的测试函数组件的方法。

测试组件的 UI 和 Props

因此,测试 Login 组件,我们用 shallow 渲染它。为了测试完整的 UI,我们可以通过 快照 来测试。快照是组件渲染后完整的 html 内容。它包含所有的元素,如果,有任何改动新的快照不能和上一次的匹配就会失败。

然后,测试渲染后的组件我们用 find 选择器,来取保所有的元素都存在并与 props 匹配,以便检测 props 的完整性。

import React from 'react';
import { shallow } from 'enzyme';
import Login from '../index';

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('input[type="email"]').length).toEqual(1);
  });

  it('should have proper props for email field', () => {
    expect(container.find('input[type="email"]').props()).toEqual({
      className: 'mx-auto my-2',
      onBlur: expect.any(Function),
      placeholder: 'email',
      type: 'email',
    });
  });

  it('should have a password field', () => { /* Similar to above */ });
  it('should have proper props for password field', () => { /* Trimmed for less lines to read */ });
  it('should have a submit button', () => { /* */ });
  it('should have proper props for submit button', () => { /* */ });
});

我们可以测试个别的属性来代替测试所有的属性。

例如,测试密码框的 value 属性我们可以这么做:

但是,为了更简单的编写测试用例,我更倾向于检测所有的属性。你不必关心哪些属性需要测试,哪些会被漏掉,这节省了时间,也满足了需求。

现在,我们来测试有传递属性的 Login 组件,我们使用上面相同的方法,来检测当传递 initalProps 时相关的属性是否有改变。

describe('<Login /> with other props', () => {
  const initialProps = {
    email: 'acesmndr@gmail.com',
    password: 'notapassword',
  };
  const container = shallow(<Login {...initialProps} />);

  it('should have proper props for email field', () => {
    expect(container.find('input[type="email"]').props()).toEqual({
      className: 'mx-auto my-2',
      onBlur: expect.any(Function),
      placeholder: 'email',
      type: 'email',
    });
  });

  it('should have proper props for password field', () => {
    expect(container.find('input[type="password"]').props()).toEqual({
      className: 'my-2',
      onChange: expect.any(Function),
      type: 'password',
      value: 'notapassword',
    });
  });

  it('should have proper props for submit button', () => { /* */ });
});

测试 state 的更新

函数组件通过 useState 来维护 state。因为,state hook 在组件内部并不能导出,因此,我们无法通过调用来测试它们。为了测试 state 是否有更新,我们可以 simulate 事件或者调用组件的属性方法,来检测 state 是否有更新并正确的渲染组件。

换句话说:我们要检测副作用。

从 React 16.8.5 开始支持 useState,因此,我们需要相同或者16.8.5以上版本 React。

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

  it('should call the dispatch function and disable the submit button on button click', () => {
    container.find('input[type="button"]').simulate('click');
    expect(
      container.find('input[type="button"]').prop('disabled'),
    ).toBeTruthy();
    expect(initialProps.dispatch).toHaveBeenCalledTimes(1);
  });

替代 simulate 的另外一方法是:可以通过调用挂载在 prop 上的方法,并传递必要的参数。

当我们需要测试自定义组件上的方法时这非常有用。

以下是触发 onDropdownClose 的方式:

生命周期 hooks

在使用 shallow 渲染组件时,useEffect 这种生命周期相关 hooks 还不支持 (这些 hooks 不会被调用),因此,我们需要用 mount 代替。就像 useState 一样,我们可以模式事件或者作为属性方法来执行,然后检测 props 是否有更新来验证 hooks 是否正确。

describe('<Login /> test effect hooks', () => {
  const container = mount(<Login />);

  it('should have the login disabled by default', () => {
    expect(
      container.find('input[type="button"]').prop('disabled'),
    ).toBeTruthy();
  });

  it('should have the login enabled with valid values', () => {
    container.find('input[type="password"]').simulate('change', {
      target: {
        value: 'validpassword',
      },
    });
    expect(container.find('input[type="button"]').prop('disabled')).toBeFalsy();
  });

  it('should have the login disabled with invalid values', () => {
    container.find('input[type="email"]').simulate('blur', { /* */ });
    expect(
      container.find('input[type="button"]').prop('disabled'),
    ).toBeTruthy();
  });
});

有关 Enzyme 支持的生命周期 hooks 详情可以看

那些不更新 state 的方法

那些不需要维护 state 的方法可以重构从组件内部抽离放在单独文件中,单独测试这些功能函数,而不是在组件内部测试它们。如果,这个方法对组件非常特别,并不能提取到外面,我们也可以把它们跟组件放在同个一个文件中,但是,不要放在组件内部。为了使函数标准化,我们可以把它们抽象单一的方法。

export const LoginMethods = () => {
  const isEmailValid = text => /@/.test(text);
  const isPasswordValid = password => password.length >= 8;
  const areFormFieldsValid = (email, password) =>
    isEmailValid(email) && isPasswordValid(password);

  return {
    isEmailValid,
    isPasswordValid,
    areFormFieldsValid,
  };
};

export default function Login(props) {
  /* useState declarations unchanged */

  React.useLayoutEffect(() => {
    setIsLoginDisabled(!LoginMethods().areFormFieldsValid(email, password));
  }, [email, password]);

这时,测试它们就非常的直接了。

describe('LoginMethods()', () => {
  it('isEmailValid should return false if email is invalid', () => {
    expect(LoginMethods().isEmailValid('notvalidemail')).toBeFalsy();
    expect(LoginMethods().isEmailValid('notvalidemail.aswell')).toBeFalsy();
  });
  it('isEmailValid should return false if email is valid', () => {
    expect(LoginMethods().isEmailValid('validemail@gmail.com')).toBeTruthy();
  });
  /* Similar for isPasswordValid and areFormFieldsValid */
});

测试不可控的组件

但是,如何测试不可控组件呢?由于,email input 是不可控的,它的 state 并不会受组件内部控制。如果,给组件设置一个 value 属性,我将会得到一个错误提示:onChange 是必须的,否则,组件将会变成一个只读的可控组件,并不能输入任何值。

因此,为了在不设置 value 的情况下测试组件,我们将会把 email 的 state 赋值给 data-value 属性。

把 value 赋值给 data-value 后,我们就可以像可控组件一样通过模拟事件来测试,然后,检查一下 data-value 是否正确。

it('should set the email data value prop', () => {
    container.find('input[type="email"]').simulate('blur', {
      target: {
        value: 'email@gmail.com',
      },
    });
    expect(container.find('input[type="email"]').prop('data-value')).toEqual(
      'email@gmail.com',
    );
  });

现在,我们的测试覆盖率应该达到了100%,这就代表着,你已经成功的测试了组件,并有适当的覆盖率。

重构无状态组件和自定义 Hook(可选)

为了减少不可控组件的相关问题,有一个实现上的建议(多谢 Rohit dai),就是把 state 和生命周期相关的 hooks 抽离真实的组件,然后把它们作为自定义 hook来测试。

把 hook 抽离到一个单独方法中,并返回一个对象,然后,通过自定义 hook 把对象注入到函数组件中。通过这种实现后,函数组件被拆分成了一个自定义 hook 和一个无状态组件。通过注入自定义组件让无状态组件变得有状态。

import React from 'react';

export const LoginMethods = () => { /* Same as before */ };

export const useLoginElements = props => {
  const { email: propsEmail, password: propsPassword, dispatch } = props;
  const [isLoginDisabled, setIsLoginDisabled] = React.useState(true);
  const [email, setEmail] = React.useState(propsEmail || '');
  const [password, setPassword] = React.useState(propsPassword || '');

  React.useEffect(() => {
    setIsLoginDisabled(!LoginMethods().areFormFieldsValid(email, password));
  }, [email, password]);

  const handleEmailBlur = evt => {
    const emailValue = evt.target.value.trim();
    setEmail(emailValue);
  };

  const handlePasswordChange = evt => {
    const passwordValue = evt.target.value.trim();
    setPassword(passwordValue);
  };

  const handleSubmit = () => {
    dispatch('submit(email, password)');
    setIsLoginDisabled(true);
  };

  return {
    emailField: {
      onBlur: handleEmailBlur,
      value: email,
    },
    passwordField: {
      onChange: handlePasswordChange,
      value: password,
    },
    submitBtn: {
      onClick: handleSubmit,
      disabled: isLoginDisabled,
    },
  };
};

export default function Login(props) {
  const { emailField, passwordField, submitBtn } = useLoginElements(props);

  return (
    <form>
      <input
        type="email"
        placeholder="email"
        className="mx-auto my-2"
        onBlur={emailField.onBlur}
      />
      <input
        type="password"
        className="my-2"
        {...passwordField}
      />
      <input
        type="button"
        className="btn btn-primary"
        value="Submit"
        {...submitBtn}
      />
    </form>
  );

这将会解决不可控组件的问题,我们甚至可以通过自定义 hook 中的 value 属性(在 emailField 元素中)导出 email state,然后,在组件中,我们可以放弃那些不需要的属性,就像上面的示例一样只用那些必要的属性。上面的示例中我们只用到了来自 emailFieldonBlur 方法。现在,我们可以把所有方法作为属性暴露出来,然后测试它们,其实,我们并不一定真正的用到它们。

测试自定义 hook

现在,为了测试自定义 hook,我们把它引入到一个函数组件中,如果,我们不这么做,hook 将无法测试,这是因为 hooks 设计之初就只能在函数组件中使用。然后,我们期望自定义 hook 可以在函数组件中正常工作。

describe('useLoginElements', () => {
  const Elements = () => {
    const props = useLoginElements({});
    return <div {...props} />;
  }; // since hooks can only be used inside a function component we wrap it inside one
  const container = shallow(<Elements />);

  it('should have proper props for email field', () => {
    expect(container.prop('emailField')).toEqual({
      value: '',
      onBlur: expect.any(Function),
    });
  });

  it('should set value on email onBlur event', () => {
    container.prop('emailField').onBlur({
      target: {
        value: 'newemail@gmail.com',
      },
    });
    expect(container.prop('emailField').value).toEqual('newemail@gmail.com');
  });

  it('should have proper props for password field', () => { /* Check onChange and value props */ });
  /* check other functional behavior of the component */
});

describe('<Login/>', () => {
  const container = shallow(<Login />);
  it('should match the snapshot', () => {
    expect(container.html()).toMatchSnapshot();
  });
 /* Test for other ui aspects of the page and not the functional behavior of the component */
});

最后,在 login 组件中,我们只需测试 UI,并不要关心其中的方法和行为。这样,我们就可以把 App 的行为和 UI 分开。

总结

  • 测试组件的整个 props 对象,而不是测试单个 prop
  • 不管有没有传递 props,可以复用相同的测试规范
  • 通过模拟事件,检查副作用,来测试 hooks
  • 测试不支持的 hooks 要使用 mount,并检查副作用
  • 那些不需要更新组件 state 逻辑要提取到组件外部
  • 使用 data-attributes 来测试不可控组件

这些方法并不是一成不变的,但是,我一直在用。希望对你们有所帮助。利用自定义 hooks 去测试函数组件是否为正确的方法,或者,你有更好的方式测试函数组件,请在评论中告诉我。

多谢支持。👏 😇