Я читал раздел документации по модульному тестированию, и хотя в нем есть пример того, как тестировать тупой компонент, возможность модульного тестирования «интеллектуального компонента» (тех, которые используют метод connect ()) не рассматривается. Оказывается, модульное тестирование интеллектуального компонента немного сложнее из-за компонента-оболочки, который создает connect (). Частично проблема заключается в том, что для обертывания компонента через connect () требуется наличие свойства store (или контекста).
Я попытался сделать это, и я надеялся получить небольшую обратную связь о том, есть ли лучший способ сделать это. И если то, что я сделал, в конечном итоге будет выглядеть разумно, я подумал, что я подниму PR, чтобы добавить некоторую информацию об этом в документацию по модульному тестированию.
Для начала я взял существующий примерный компонент в разделе документации, посвященный модульному тесту, и обернул его в connect () для передачи данных от создателей действий, связанных с состоянием и отправкой:
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);
В файле модульного теста это тоже похоже на пример.
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 и может измениться. Очевидно, что для своих модульных тестов я переместил эти методы в общий файл утилиты модульного тестирования, поэтому, если бы методы хранилища действительно изменились, мне нужно было бы изменить свой код только в одном месте.
Мысли? Кто-нибудь еще экспериментировал с модульным тестированием интеллектуальных компонентов и нашел лучший способ делать вещи?
Альтернативой является также экспорт класса заголовка (без украшений), чтобы его можно было импортировать и протестировать отдельно.
Также вам не нужен вызов 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" }
])
});
});
Самый полезный комментарий
@ernieturner Я думаю, что @ghengeveld имел в виду, что если вы используете модули ES6 повсюду, декорированный компонент Header все равно можно экспортировать по умолчанию, а простой компонент React может быть дополнительным именованным экспортом. Например -
Благодаря этому двойному экспорту ваше основное приложение может использовать интеллектуальный компонент, как и раньше, с помощью
import Header from './header.js'
то время как ваши тесты могут обходить redux и тестировать поведение основного компонента напрямую с помощьюimport {HeaderDumbComponent} from '../components/header.js'