Redux: 单元测试智能组件

创建于 2015-08-20  ·  19评论  ·  资料来源: reduxjs/redux

我通读了文档的单元测试部分,尽管其中有一个如何测试一个哑组件的示例,但并未涉及能够对“智能组件”(使用connect()方法的那些组件)进行单元测试。 事实证明,由于connect()创建的包装器组件,对智能组件进行单元测试会更加复杂。 问题的一部分是通过connect()包装组件需要有一个“存储”属性(或上下文)。

我努力尝试这样做,希望能得到一些反馈,以了解是否有更好的方法来实现它。 而且,如果我所做的最终看起来很合理,我认为我会推销PR以便在单元测试文档中添加一些信息。

首先,我将现有的示例组件放在docs的单元测试部分中,并将其包装在connect()中,以传递来自状态和调度绑定操作创建者的数据:

Header.js(智能组件)
import React, { PropTypes, Component } from 'react';
import TodoTextInput from './TodoTextInput';
import TodoActions from '../actions/TodoActions';
import connect from 'redux-react';

class Header extends Component {
  handleSave(text) {
    if (text.length !== 0) {
      this.props.addTodo(text);
    }
  }

  render() {
    return (
      <header className='header'>
          <h1>{this.props.numberOfTodos + " Todos"}</h1>
          <TodoTextInput newTodo={true}
                         onSave={this.handleSave.bind(this)}
                         placeholder='What needs to be done?' />
      </header>
    );
  }
}

export default connect(
  (state) =>  {numberOfTodos: state.todos.length},
  TodoActions
)(Header);

在单元测试文件中,它看起来也与示例类似。

Header.test.js
import expect from 'expect';
import jsdomReact from '../jsdomReact';
import React from 'react/addons';
import Header from '../../components/Header';
import TodoTextInput from '../../components/TodoTextInput';
const { TestUtils } = React.addons;

/**
 * Mock out the top level Redux store with all the required 
 * methods and have it return the provided state by default.
 * <strong i="13">@param</strong> {Object} state State to populate in store
 * <strong i="14">@return</strong> {Object} Mock store
 */
function createMockStore(state) {
  return {
    subscribe: () => {},
    dispatch: () => {},
    getState: () => {
      return {...state};
    }
  };
}

/**
 * Render the Header component with a mock store populated
 * with the provided state
 * <strong i="15">@param</strong> {Object} storeState State to populate in mock store
 * <strong i="16">@return</strong> {Object} Rendered output from component
 */
function setup(storeState) {
  let renderer = TestUtils.createRenderer();
  renderer.render(<Header store={createMockStore(storeState)} />);
  var output = renderer.getRenderedOutput();
  return output.refs.wrappedInstance();
}

describe('components', () => {
  jsdomReact();

  describe('Header', () => {
    it('should call call addTodo if length of text is greater than 0', () => {
      const output = setup({
        todos: [1, 2, 3]
      });
      var addTodoSpy = expect.spyOn(output.props, 'addTodo');

      let input = output.props.children[1];
      input.props.onSave('');
      expect(addTodoSpy.calls.length).toBe(0);
      input.props.onSave('Use Redux');
      expect(addTodoSpy.calls.length).toBe(1);
    });
  });
});

我已经简化了该测试,只是显示了相关部分,但是我不确定的重点是createMockStore方法。 如果您尝试在不带任何道具的情况下渲染Header组件,则Redux(或react-redux)会引发错误,指出该组件必须具有store道具或上下文,因为它希望是该道具的子代。 <Provider>组件。 由于我不想在单元测试中使用它,因此我创建了一种模拟它的方法,并允许测试通过它想要在商店中设置的状态。

我可以从这种方法中看到的好处是,它不仅可以测试组件中的功能,还可以测试传递给connect()的方法的功能。 我可以在这里轻松地编写另一个断言,该断言执行类似于expect(output.props.numberOfTodos).toBe(3) ,以验证我的mapStateToProps函数是否正在执行我期望的操作。

它的主要缺点是我不得不模拟Redux存储,它并不那么复杂,但是感觉它是Redux内部逻辑的一部分,并且可能会改变。 显然,对于我的单元测试,我已经将这些方法移到了通用的单元测试实用程序文件中,因此,如果存储方法确实发生了变化,则只需要在一个地方修改我的代码即可。

有什么想法吗? 是否还有其他人尝试过对智能组件进行单元测试,并找到了一种更好的做事方法?

discussion question

最有用的评论

@ernieturner我认为@ghengeveld的意思是,如果您在所有地方都使用ES6模块,则装饰的Header组件仍可以作为默认值导出,而普通的React组件可以作为附加的命名导出。 例如 -

//header.js
export class HeaderDumbComponent {
  render() {
    return <header><div>...</div></header>;
  }
}

export default connect(
  (state) =>  {numberOfTodos: state.todos.length},
  TodoActions
)(HeaderDumbComponent);

通过这种双重导出,您的主应用可以像以前一样使用import Header from './header.js'来使用智能组件,而您的测试可以绕过redux并直接使用import {HeaderDumbComponent} from '../components/header.js'测试核心组件的行为

所有19条评论

另一种选择是也导出(未修饰的)Header类,因此可以单独导入和测试它。

此外,至少在使用Mocha时,也不需要每个文件都需要jsdomReact调用。 这是我的setup.js:

import jsdom from 'jsdom';
import ExecutionEnvironment from 'react/lib/ExecutionEnvironment';

if (!global.document || !global.window) {
  global.document = jsdom.jsdom('<!doctype html><html><body></body></html>');
  global.window = document.defaultView;
  global.navigator = window.navigator;

  ExecutionEnvironment.canUseDOM = true;

  window.addEventListener('load', () => {
    console.log('JSDom setup completed: document, window and navigator are now on global scope.');
  });
}

它是通过--require命令行选项加载的: mocha -r babelhook -r test/setup --recursive (babelhook是对require('babel-core/register')的调用)。

是的,我可以同时导出Header类和修饰的类,但我希望避免只为单元测试而更改源代码。 这样做会导致我不得不更改所有包含装饰组件的位置(例如import {DecoratedHeader} from './components/Header'而不是import Header from 。/ components / Header`)。

至于jsdom设置,我纯粹是从文档中复制示例作为显示用例的一种方式,我并没有将其用作主要设置,只是一个示例。

我只是注意到上面的代码示例不完整。 为了直接测试智能组件,您必须有一些实用方法来返回refs.wrappedInstance而不只是渲染结果,因为这将为您提供连接装饰的组件(在上面的设置函数中更新) 。 这再次使单元测试依赖于Redux内部(在这种情况下,特别是react-redux内部)。 这样就可以了,但是感觉有点脆弱。

@ernieturner我们还getWrappedInstance()公共API,因此如果您担心直接访问refs可以依靠它。 还有一些工具,如react-test-tree ,也可以使其透明。

@ernieturner我认为@ghengeveld的意思是,如果您在所有地方都使用ES6模块,则装饰的Header组件仍可以作为默认值导出,而普通的React组件可以作为附加的命名导出。 例如 -

//header.js
export class HeaderDumbComponent {
  render() {
    return <header><div>...</div></header>;
  }
}

export default connect(
  (state) =>  {numberOfTodos: state.todos.length},
  TodoActions
)(HeaderDumbComponent);

通过这种双重导出,您的主应用可以像以前一样使用import Header from './header.js'来使用智能组件,而您的测试可以绕过redux并直接使用import {HeaderDumbComponent} from '../components/header.js'测试核心组件的行为

@ eugene1g @ghengeveld这实际上是解决问题的一个很好的解决方案。 随时将PR发送到“写作测试”文档中进行解释!

@ eugene1g就是我的意思。
@gaearon我会做到的。

@ghengeveld对误解

export class HeaderDumpComponent ...

export function mapStateToProps(state) ... 
export function mapDispatchToProps(dispatch) ...

export default connect(mapStateToProps, mapDispatchToProps)(HeaderDumpComponent);

大多数map方法很可能会足够简单而不必进行单元测试,但是我有一些值得验证,因为它们是我组件功能的关键部分。

感谢大家的建议,如果您需要有关文档的任何帮助,请告诉我。

我们正在尝试上面概述的推荐方法,但是我们对此并不完全满意。 这使传递给connect的参数在集成测试之外未经测试。 我知道模拟存储可能会很脆弱,但是我觉得如果支持组件测试, redux-mock-store这样

@carpeliam

为什么不只使用常规存储并以特定的初始状态对其进行水合呢?
仅当您要测试正在分派哪些操作时,才需要模拟存储。

@carpeliam

请在此存储库中查看有关countertodomvc示例的单元测试。

我们遇到的一个问题是,我们正在另一个组件内部渲染智能组件,而无法使用浅层渲染器(组件使用componentDid *方法)。 我们所做的是对connect函数进行存根(使用sinon)以返回一个返回简单React组件的函数。

它很脆弱,我们希望一旦可以迁移到React 0.14即可迁移到较浅的渲染器,但是这种方法目前在我们的测试中不受阻碍。

我也有麻烦。 我对React和Redux都比较陌生,所以这可能使我退缩。 您如何解决它? @songawee

建议的双重导出方法使select(mapStoreToState)函数未经测试。 为了独立测试它,还需要将其导出,这是测试名称的又一变化。

我很想找到一种方法来使shallowRenderer与connect的包装组件一起使用。 我当前的麻烦是使用浅层渲染器时,它只能传递回预期的Connect组件。

建议的双重导出方法使select(mapStoreToState)函数未经测试。 为了独立测试它,还需要将其导出,这是测试名称的又一变化。

并非严格“以测试的名义”。 实际上,我们建议您在简化器旁边定义选择器函数(最终是mapStateToProps ),并一起测试它们。 此逻辑独立于UI,并且不必与UI捆绑在一起。 看一下一些选择器的shopping-cart示例。

那么@gaearon ,您是否建议使用此_selector_函数从状态中获取所有数据? 难道这不会带来很多不必要的开销,因为在大多数情况下,人们只会从状态中读取一堆属性并将它们分配给组件上的props?

是的,Redux的一般建议模式是在几乎所有地方都使用选择器功能,而不是直接访问state.some.nested.field 。 它们可以是非常简单的“普通”功能,但是最常见的是使用提供备注功能的Reselect库组合在一起。

为什么会产生任何额外的开销?

我一直在执行此处所述的双重导出,但是阅读了connect源,我意识到“ Container”在WrappedComponent静态属性中保留了对“ dumb”组件的引用:

所以代替:

// header.js
export const HeaderDumbComponent = (props) => <header><div>...</div></header>
export default connect(mapStateToProps)(HeaderDumbComponent)


// header.spec.js
import { HeaderDumbComponent } from './header'

it('renders', () => {
  expect(<HeaderDumbComponent />).to.not.be.null
})

您可以在需要“哑巴”版本的地方使用WrappedComponent来避免双重导出:

// header.js
const HeaderDumbComponent = (props) => <header><div>...</div></header>
export default connect(mapStateToProps)(HeaderDumbComponent)


// header.spec.js
import Header from './header'

it('renders', () => {
  expect(<Header.WrappedComponent />).to.not.be.null
})

@gaearon - WrappedComponent似乎是一个非常稳定的属性,因为它位于文档中。 您是否出于任何原因建议不要以这种方式使用它?

我要做的是导出未包装的组件,还导出mapStateToProps和mapDispatchToProps,以便可以对这些函数进行单元测试。 这是一个例子:

import {ManageCoursePage, mapStateToProps, mapDispatchToProps} from './ManageCoursePage';
describe("mapStateToProps", () => {
    function setup(courses, authors, id) {
        const state = {
            authors: authors
        };

        const ownProps = {
            params: {
                id: id
            }
        };

        return mapStateToProps(state, ownProps);
    }

    it('sets the course when a course id is set', () => {
        const courses = [ { id: "1", foo: "bar" }, { id: "2", foo: "notbar" } ];
        const authors = [];
        const id = "1";

        const props = setup(courses, authors, id);

        expect(props.course).toBe(courses[0]);
    });

    it('sets the course when a course id is not set', () => {
        const courses = [ { id: "1", foo: "bar" }, { id: "2", foo: "notbar" } ];
        const authors = [];

        const props = setup(courses, authors, null);

        expect(props.course).toEqual({});
    });

    it('sets the course when a course id is set that is not present in the courses list', () => {
        const courses = [ { id: "1", foo: "bar" }, { id: "2", foo: "notbar" } ];
        const authors = [];
        const id = "42";

        const props = setup(courses, authors, null);

        expect(props.course).toEqual({});
    });

    it('sets the authors formatted for a drop down list', () => {
        const courses = [];
        const authors = [ { id: "1", name: "John" }, { id: "2", name: "Jill" }];

        const props = setup(courses, authors, null);

        expect(props.authors).toEqual([
            { value: "1", text: "John" },
            { value: "2", text: "Jill" }
        ])
    });
});
此页面是否有帮助?
0 / 5 - 0 等级