React 和 Redux 测试策略

当 Firefox 附加组件团队将 addons.mozilla.org 移植到由 API 支持的 单页应用程序 时,我们选择了 ReactRedux 来实现 强大的状态管理,令人愉悦的 开发者 工具,以及可测试性。由于存在竞争的工具和技术,实现可测试性并非完全显而易见。

以下是我们发现非常有效的几种测试策略。

测试必须快速有效

我们希望测试能够非常快,这样我们就可以快速发布高质量的功能,而不会让人感到沮丧。等待测试结果会令人沮丧,但测试对于防止回归至关重要,尤其是在重构应用程序以支持新功能时。

我们的策略是只测试必要的东西,并且只测试一次。为了实现这一点,我们对每个单元进行隔离测试,并模拟其依赖项。这是一种称为单元测试的技术,在我们的例子中,单元通常是单个React 组件

不幸的是,在 JavaScript 等动态语言中安全地执行此操作非常困难,因为没有快速的方法来确保模拟对象与真实对象同步。为了解决这个问题,我们依赖静态类型(通过 Flow)的安全机制,它可以提醒我们组件是否错误地使用其他组件——单元测试可能无法捕获到这种错误。

一套单元测试加上静态类型分析非常快且有效。我们使用 Jest,因为它也很快,并且可以让我们在需要时专注于测试的子集。

测试与 Redux 连接的组件

在动态语言中进行隔离测试的风险并非完全可以通过静态类型来缓解,尤其是因为第三方库通常不包含类型定义(从头开始创建类型定义很麻烦)。此外,与 Redux 连接的组件很难进行隔离,因为它们依赖 Redux 功能来使其属性与状态保持同步。我们采用了一种策略,使用真实的 Redux 商店 触发所有状态更改。

事实证明,使用真实的 Redux 商店进行测试很快。Redux 的设计非常适合测试,因为操作、reducer 和状态是相互解耦的。当我们对应用程序状态进行更改时,测试会提供正确的反馈。这也使得它感觉非常适合测试。除了测试之外,Redux 架构对于调试、扩展,尤其是 开发 非常有用。

考虑以下与 Redux 连接的组件作为示例:(为了简洁起见,本文中的示例没有定义 Flow 类型,但你可以这里了解如何定义。)

import { connect } from 'react-redux';
import { compose } from 'redux';

// Define a functional React component.
export function UserProfileBase(props) {
  return (
    <span>{props.user.name}</span>
  );
}

// Define a function to map Redux state to properties.
function mapStateToProps(state, ownProps) {
  return { user: state.users[ownProps.userId] };
}

// Export the final UserProfile component composed of
// a state mapper function.
export default compose(
  connect(mapStateToProps),
)(UserProfileBase);

你可能很想通过传递合成用户属性来测试它,但这会绕过 Redux 和你所有的状态映射逻辑。相反,我们通过分发真实的 action 来将用户加载到状态中进行测试,并对连接的组件渲染的内容进行断言。

import { mount } from 'enzyme';
import UserProfile from 'src/UserProfile';

describe('<UserProfile>', () => {
  it('renders a name', () => {
    const store = createNormalReduxStore();
    // Simulate fetching a user from an API and loading it into state.
    store.dispatch(actions.loadUser({ userId: 1, name: 'Kumar' }));

    // Render with a user ID so it can retrieve the user from state.
    const root = mount(<UserProfile userId={1} store={store} />);

    expect(root.find('span')).toEqual('Kumar');
  });
});

使用 Enzyme 的 mount() 渲染整个组件可以确保 mapStateToProps() 正常工作,并且 reducer 做了该特定组件期望的事情。它模拟了真实应用程序从 API 请求用户并分发结果时会发生的事情。但是,由于 mount() 会渲染所有组件,包括嵌套组件,因此它不允许我们单独测试 UserProfile。为此,我们需要使用浅渲染,这将在下面进行解释。

用于依赖注入的浅渲染

假设 UserProfile 组件依赖 UserAvatar 组件来显示用户的照片。它可能如下所示

export function UserProfileBase(props) {
  const { user } = props;
  return (
    <div>
      <UserAvatar url={user.avatarURL} />
      <span>{user.name}</span>
    </div>
  );
}

由于 UserAvatar 将有自己的单元测试,所以 UserProfile 测试只需要确保它正确地调用了 UserAvatar 的接口。它的接口是什么?任何 React 组件的接口只是它的属性。Flow 有助于验证属性的数据类型,但我们还需要测试来检查数据值。

使用 Enzyme,我们不需要以传统依赖注入的方式将依赖项替换为模拟对象。我们只需通过浅渲染来推断它们的 存在。测试可能如下所示

import UserProfile, { UserProfileBase } from 'src/UserProfile';
import UserAvatar from 'src/UserAvatar';
import { shallowUntilTarget } from './helpers';

describe('<UserProfile>', () => {
  it('renders a UserAvatar', () => {
    const user = {
      userId: 1, avatarURL: 'https://cdn/image.png',
    };
    store.dispatch(actions.loadUser(user));

    const root = shallowUntilTarget(
      <UserProfile userId={1} store={store} />,
      UserProfileBase
    );

    expect(root.find(UserAvatar).prop('url'))
      .toEqual(user.avatarURL);
  });
});

此测试没有调用 mount(),而是使用名为 shallowUntilTarget() 的自定义辅助函数渲染组件。你可能已经熟悉 Enzyme 的 shallow(),但它只渲染树中的第一个组件。我们需要创建一个名为 shallowUntilTarget() 的辅助函数,它将渲染所有“包装器”(或 高阶)组件,直到到达我们的目标 UserProfileBase

希望 Enzyme 能够很快发布类似于 shallowUntilTarget() 的功能,但 实现 很简单。它在循环中调用 root.dive(),直到 root.is(TargetComponent) 返回 true。

使用这种浅渲染方法,现在可以单独测试 UserProfile,但仍然可以像真实应用程序一样分发 Redux action。

测试会在树中查找 UserAvatar 组件,并简单地确保 UserAvatar 会收到正确的属性(UserAvatarrender() 函数不会被执行)。如果 UserAvatar 的属性发生更改,而我们忘记更新测试,测试可能会通过,但 Flow 会提醒我们违反了规则。

React 和浅渲染的优雅之处在于,它们让我们免费获得了依赖注入,而无需注入任何依赖项!这种测试策略的关键在于,UserAvatar 的实现可以自由地自行演变,而不会破坏 UserProfile 测试。如果更改单元的实现迫使你修复大量无关的测试,那么这表明你的测试策略可能需要重新考虑。

用子组件组合,而不是属性

当使用子组件而不是通过属性传递 JSX组合组件时,React 和浅渲染的强大功能就会真正显现出来。例如,假设你想将 UserAvatar 包装在公共 InfoCard 中以进行布局。以下是将它们作为子组件组合在一起的方法

export function UserProfileBase(props) {
  const { user } = props;
  return (
    <div>
      <InfoCard>
        <UserAvatar url={user.avatarURL} />
      </InfoCard>
      <span>{user.name}</span>
    </div>
  );
}

在进行此更改后,上面的相同断言仍然可以工作!以下是再次执行此操作的方法

expect(root.find(UserAvatar).prop('url'))
  .toEqual(user.avatarURL);

在某些情况下,你可能很想通过属性而不是子组件来传递 JSX。但是,常见的 Enzyme 选择器(如 root.find(UserAvatar))将不再起作用。让我们看一个通过 content 属性将 UserAvatar 传递给 InfoCard 的示例

export function UserProfileBase(props) {
  const { user } = props;
  const avatar = <UserAvatar url={user.avatarURL} />;
  return (
    <div>
      <InfoCard content={avatar} />
      <span>{user.name}</span>
    </div>
  );
}

这仍然是一个有效的实现,但测试起来并不容易。

测试通过属性传递的 JSX

有时你真的无法避免通过属性传递 JSX。假设 InfoCard 需要完全控制渲染某些标题内容。

export function UserProfileBase(props) {
  const { user } = props;
  return (
    <div>
      <InfoCard header={<Localized>Avatar</Localized>}>
        <UserAvatar url={user.avatarURL} />
      </InfoCard>
      <span>{user.name}</span>
    </div>
  );
}

你将如何测试它?你可能很想执行完整的 Enzyme mount(),而不是 shallow() 渲染。你可能认为它会为你提供更好的测试覆盖率,但这种额外的覆盖率并不必要——InfoCard 组件将有自己的测试。UserProfile 测试只需要确保 InfoCard 获取了正确的属性。以下是测试方法。

import { shallow } from 'enzyme';
import InfoCard from 'src/InfoCard';
import Localized from 'src/Localized';
import { shallowUntilTarget } from './helpers';

describe('<UserProfile>', () => {
  it('renders an InfoCard with a custom header', () => {
    const user = {
      userId: 1, avatarURL: 'https://cdn/image.png',
    };
    store.dispatch(actions.loadUser(user));

    const root = shallowUntilTarget(
      <UserProfile userId={1} store={store} />,
      UserProfileBase
    );

    const infoCard = root.find(InfoCard);

    // Simulate how InfoCard will render the
    // header property we passed to it.
    const header = shallow(
      <div>{infoCard.prop('header')}</div>
    );

    // Now you can make assertions about the content:
    expect(header.find(Localized).text()).toEqual('Avatar');
  });
});

这比完整的 mount() 更好,因为它允许 InfoCard 实现自由地演变,只要它的属性不发生更改。

测试组件回调

除了通过属性传递 JSX 之外,将回调传递给 React 组件也很常见。回调属性使得围绕公共功能构建抽象变得非常容易。假设我们正在使用 FormOverlay 组件在 UserProfileManager 组件中渲染编辑表单。

import FormOverlay from 'src/FormOverlay';

export class UserProfileManagerBase extends React.Component {
  onSubmit = () => {
    // Pretend that the inputs are controlled form elements and
    // their values have already been connected to this.state.
    this.props.dispatch(actions.updateUser(this.state));
  }

  render() {
    return (
      <FormOverlay onSubmit={this.onSubmit}>
        <input id="nameInput" name="name" />
      </FormOverlay>
    );
  }
}

// Export the final UserProfileManager component.
export default compose(
  // Use connect() from react-redux to get props.dispatch()
  connect(),
)(UserProfileManagerBase);

如何测试UserProfileManagerFormOverlay的集成?你可能再次想进行完整的mount(),特别是如果你正在测试与第三方组件的集成,比如Autosuggest。但是,完整的mount()并不是必要的。

就像之前的例子一样,UserProfileManager测试可以简单地检查传递给FormOverlay的属性。这是安全的,因为FormOverlay将有自己的测试,并且Flow将验证属性。以下是如何测试onSubmit属性的示例。

import FormOverlay from 'src/FormOverlay';
import { shallowUntilTarget } from './helpers';

describe('<UserProfileManager>', () => {
  it('updates user information', () => {
    const store = createNormalReduxStore();
    // Create a spy of the dispatch() method for test assertions.
    const dispatchSpy = sinon.spy(store, 'dispatch');

    const root = shallowUntilTarget(
      <UserProfileManager store={store} />,
      UserProfileManagerBase
    );

    // Simulate typing text into the name input.
    const name = 'Faye';
    const changeEvent = {
      target: { name: 'name', value: name },
    };
    root.find('#nameInput').simulate('change', changeEvent);

    const formOverlay = root.find(FormOverlay);

    // Simulate how FormOverlay will invoke the onSubmit property.
    const onSubmit = formOverlay.prop('onSubmit');
    onSubmit();

    // Make sure onSubmit dispatched the correct ation.
    const expectedAction = actions.updateUser({ name });
    sinon.assertCalledWith(dispatchSpy, expectedAction);
  });
});

这测试了UserProfileManagerFormOverlay的集成,而无需依赖于FormOverlay的实现。它使用sinon来监视store.dispatch()方法,以确保在用户调用onSubmit()时分发了正确的操作。

每一次改变都从一个Redux操作开始

Redux的架构很简单:当你想改变应用程序状态时,就分发一个操作。在最后一个测试onSubmit()回调的例子中,测试简单地断言分发了actions.updateUser(...)。就是这样。这个测试假设一旦updateUser()操作被分发,一切都会按计划进行。

那么像我们这样的应用程序是如何实际更新用户的呢?我们会将一个saga连接到操作类型。updateUser() saga将负责向API发出请求,并在收到响应时分发进一步的操作。saga本身将有自己的单元测试。由于UserProfileManager测试是在没有saga的情况下运行的,所以我们不必担心模拟saga的功能。这种架构使测试变得非常容易;像redux-thunk这样的工具可能提供类似的优势。

总结

这些例子说明了一些在addons.mozilla.org上非常有效地解决常见测试问题的模式。以下是这些概念的回顾。

  • 我们分发真实的Redux操作来测试应用程序状态的改变。
  • 我们只使用浅层渲染来测试每个组件一次。
  • 我们尽可能避免完整的DOM渲染(使用mount())。
  • 我们通过检查属性来测试组件集成。
  • 静态类型帮助我们验证组件属性。
  • 我们模拟用户事件并断言什么操作被分发了。

想要更多地参与Firefox附加组件社区吗?有很多方法可以为附加组件生态系统做出贡献,无论你的技能和经验水平如何,都有很多东西可以学习。

关于 kumar303

Kumar为各种项目(例如支持Firefox附加组件的项目)开发Mozilla Web服务和工具。他还开发了很多随机的开源项目

更多来自kumar303的文章…


一条评论

  1. Gabriel Micko

    很棒的文章,谢谢!

    2018年4月24日 下午1:16

本文评论已关闭。