Redux: Модульное тестирование интеллектуальных компонентов

Созданный на 20 авг. 2015  ·  19Комментарии  ·  Источник: reduxjs/redux

Я читал раздел документации по модульному тестированию, и хотя в нем есть пример того, как тестировать тупой компонент, возможность модульного тестирования «интеллектуального компонента» (тех, которые используют метод connect ()) не рассматривается. Оказывается, модульное тестирование интеллектуального компонента немного сложнее из-за компонента-оболочки, который создает connect (). Частично проблема заключается в том, что для обертывания компонента через connect () требуется наличие свойства store (или контекста).

Я попытался сделать это, и я надеялся получить небольшую обратную связь о том, есть ли лучший способ сделать это. И если то, что я сделал, в конечном итоге будет выглядеть разумно, я подумал, что я подниму PR, чтобы добавить некоторую информацию об этом в документацию по модульному тестированию.

Для начала я взял существующий примерный компонент в разделе документации, посвященный модульному тесту, и обернул его в 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 (или response-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 Комментарий

Альтернативой является также экспорт класса заголовка (без украшений), чтобы его можно было импортировать и протестировать отдельно.

Также вам не нужен вызов jsdomReact в каждом файле, по крайней мере, если вы используете Mocha. Вот мой 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 Для этого мы также предлагаем общедоступный API getWrappedInstance() поэтому вы можете положиться на него, если беспокоитесь о прямом доступе к 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 Приносим извинения за недоразумение. Я все еще использую импорт в стиле CommonJS, так что я все еще довольно ржавый по модулям ES6. Это действительно кажется хорошим решением для публикации в документации. Я полагаю, вы также можете предоставить методы mapStateToProps / mapDispatchToProps, чтобы их можно было протестировать без необходимости иметь дело с имитацией магазина, например

export class HeaderDumpComponent ...

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

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

Вполне возможно, что большинство методов карты будут достаточно простыми, чтобы не потребовать модульного тестирования, но у меня есть некоторые из них, которые стоит проверить, поскольку они являются важной частью функциональности моих компонентов.

Спасибо за предложения всем, если вам нужна помощь с документацией, дайте мне знать.

Мы пробуем рекомендуемый подход, описанный выше, прямо сейчас, но нам он не совсем удобен. Это оставляет параметры, переданные в connect непроверенными вне интеграционных тестов. Я понимаю, что фиктивное хранилище может быть потенциально хрупким, но я чувствую, что именно здесь может возникнуть что-то вроде redux-mock-store , если оно может поддерживать тестирование компонентов. Как вы относитесь к этому направлению?

@carpeliam

Почему бы просто не использовать обычный магазин и не увлажнить его до определенного начального состояния?
Имитировать хранилище нужно только в том случае, если вы хотите проверить, какие действия отправляются.

@carpeliam

См. Модульные тесты для примеров counter и todomvc в этом репо.

У нас возникла проблема, когда мы рендерили интеллектуальный компонент внутри другого компонента и не могли использовать поверхностный рендерер (компонент использовал методы componentDid *). Мы сделали заглушку (используя sinon) функцию подключения, чтобы вернуть функцию, которая возвращала простой компонент React.

Он хрупкий, и мы надеемся перейти к поверхностному рендереру, как только сможем перейти на React 0.14, но этот метод на время разблокировал нас в наших тестах.

У меня тоже с этим проблемы. Я новичок как в React, так и в Redux, так что это, вероятно, то, что меня сдерживает. Как вам удалось это обойти? @songawee

Предлагаемый метод двойного экспорта оставляет функцию select (mapStoreToState) непроверенной. Чтобы протестировать его самостоятельно, его тоже нужно экспортировать, еще одно изменение в названии тестирования.

Мне было бы интересно найти способ заставить shallowRenderer работать с обернутым компонентом connect. Моя текущая проблема заключается в том, что при использовании поверхностного рендеринга он возвращает только компонент Connect, чего и следовало ожидать.

Предлагаемый метод двойного экспорта оставляет функцию select (mapStoreToState) непроверенной. Чтобы протестировать его самостоятельно, его тоже нужно экспортировать, еще одно изменение в названии тестирования.

Не совсем «во имя тестирования». Фактически, мы рекомендуем вам определять функции-селекторы (чем в конечном итоге является mapStateToProps ) вместе с редукторами и тестировать их вместе. Эта логика не зависит от пользовательского интерфейса и не должна быть связана с ним. Взгляните на пример shopping-cart для некоторых селекторов.

Итак, @gaearon , вы предлагаете получить все данные из состояния с помощью этой функции _selector_? Разве это не приводит к большим ненужным накладным расходам, поскольку в большинстве случаев люди просто считывают кучу свойств из состояния и назначают их реквизитам компонентов?

Да, общий предлагаемый шаблон для Redux - использовать функции селектора практически везде, а не обращаться к state.some.nested.field напрямую. Они могут быть очень простыми «простыми» функциями, но обычно их объединяют с помощью библиотеки Reselect, которая предоставляет возможности запоминания.

Почему это приведет к дополнительным накладным расходам?

Я выполнял двойной экспорт, как описано здесь, но, читая источник connect я понял, что «Контейнер» хранит ссылку на «тупой» компонент в статическом свойстве WrappedComponent :

Так что вместо:

// 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 рейтинги