В главе 8 будет представлена технология Flux — альтернатива управления обменом данными между компонентами (используется вместо таких средств, как метод onDataChange), поэтому структура кода там будет несколько изменена. А ведь неплохо было бы допускать при таком изменении как можно меньше ошибок? Рассмотрим несколько инструментальных средств, помогающих поддерживать работоспособность вашего приложения по мере его неизбежного роста и развития. К числу таких средств относятся ESLint, Flow и Jest.
Но сначала рассмотрим общее необходимое для них средство под названием package.json.
Как использовать npm (Node Package Manager) для установки библиотек и инструментов сторонних производителей, вы уже знаете. Кроме этого, npm также позволяет упаковывать и совместно использовать ваш проект на сайте и устанавливать его другим людям. Но чтобы воспользоваться услугами, предлагаемыми npm, вам не нужно выгружать свой код на npmjs.com.
Создание пакета связано с использование файла package.json, который можно поместить в корневой каталог вашего приложения для настройки зависимостей и других дополнительных инструментов. Существует масса настроек, которые можно применить в отношении этого средства (их полный перечень можно найти на сайте ), но посмотрим, как им воспользоваться в самом скромном варианте.
Создайте в каталоге своего приложения новый файл по имени package.json:
$ cd ~/reactbook/whinepad2
$ touch package.json
И добавьте к нему следующий код:
{
"name": "whinepad",
"version": "2.0.0",
}
Вот и все, что нужно. После этого вы просто будете добавлять к этому файлу дополнительные настройки.
Сценарий build.sh, показанный в главе 5, запускал Babel следующим образом:
$ babel --presets react,es2015 js/source -d js/build
Эту команду можно упростить, переместив начальную настройку в package.json:
{
"name": "whinepad",
"version": "2.0.0",
"babel": {
"presets": [
"es2015",
"react"
]
},
}
Теперь команда будет иметь следующий вид:
$ babel js/source -d js/build
Babel (как и многие другие инструментальные средства в экосистеме JavaScript) проверяет наличие файла package.json и забирает из него варианты настройки.
npm позволяет создавать сценарии и запускать их с помощью команды npm run имя_сценария. В качестве примера переместим однострочный сценарий ./scripts/watch.sh, который был показан в главе 3, в файл package.json:
{
"name": "whinepad",
"version": "2.0.0",
"babel": {/* ... */},
"scripts": {
"watch": "watch \"sh scripts/build.sh\" js/source css/"
}
}
Теперь для оперативной сборки кода можно воспользоваться следующей командой:
# до
$ sh ./scripts/watch.sh
# после
$ npm run watch
Если продолжить усовершенствования, можно точно так же заменить build.sh, переместив его код в package.json, или же воспользоваться специализированным средством сборки (Grunt, Gulp и т.д.), которое можно настроить в package.json. И это все (применительно к целям изучения React), что вам следует знать об этом файле.
ESLint проверяет код на наличие потенциально опасных моделей, помогает добиться единообразия вашего исходного кода, проверяя, к примеру, использование отступов и расстановку другой разрядки, а также помогает по мере создания кода отловить досадные опечатки или неиспользуемые переменные. В идеале в дополнение к запуску этого средства в качестве составной части вашего процесса сборки его нужно объединить с вашей системой проверки исходного кода и с выбранным вами текстовым редактором, чтобы вы, образно выражаясь, получали по рукам, когда будете находиться к коду ближе всего.
Кроме ESLint, вам понадобятся дополнительные модули React и Babel, чтобы помочь ESLint распознать самый передовой синтаксис ECMAScript, а также воспользоваться «правилами», свойственными JSX и React:
$ npm i -g eslint eslint-plugin-react eslint-plugin-babel
Добавьте переменную eslintConfig к файлу package.json:
{
"name": "whinepad",
"version": "2.0.0",
"babel": {},
"scripts": {},
"eslintConfig": {
"parser": "babel-eslint",
"plugins": [
"babel",
"react"
],
}
}
Запустите проверку кода в отношении одного файла:
$ eslint js/source/app.js
Эта команда должна быть выполнена без ошибок, это будет означать правильное восприятие средством ESLint синтаксиса JSX и других новшеств. Но здесь есть и негативная сторона, поскольку не проводилась проверка кода на соответствие каким-либо правилам. Для каждой проверки ESLint использует правила. Сначала вам следует воспользоваться той коллекцией правил (расширением), которая рекомендована ESLint:
"eslintConfig": {
"parser": "babel-eslint",
"plugins": [],
"extends": "eslint:recommended"
}
Запуск на соответствие этим правилам приводит к выявлению ошибок:
$ eslint js/source/app.js
/Users/stoyanstefanov/reactbook/whinepad2/js/source/app.js
4:8 error "React" is defined but never used no-unused-vars
9:23 error "localStorage" is not defined no-undef
25:3 error "document" is not defined no-undef
× 3 problems (3 errors, 0 warnings)
Второе и третье сообщения касаются переменных, не имеющих определений (исходя из правила под названием no-undef), но эти переменные имеют глобальный доступ в браузере, поэтому для устранения ошибки требуется дополнительная настройка:
"env": {
"browser": true
}
Первая ошибка имеет отношение исключительно к React. С одной стороны, вам необходимо включить React, но с точки зрения ESLint в коде присутствует неиспользуемая переменная, которой здесь быть не должно. Устранить ошибку поможет добавление одного из правил, имеющихся в eslint-plugin-react:
"rules": {
"react/jsx-uses-react": 1
}
При запуске проверки кода в файле сценария schema.js будет получена ошибка еще одного типа:
$ eslint js/source/schema.js
/Users/stoyanstefanov/reactbook/whinepad2/js/source/schema.js
9:18 error Unexpected trailing comma comma-dangle
16:17 error Unexpected trailing comma comma-dangle
25:18 error Unexpected trailing comma comma-dangle
32:14 error Unexpected trailing comma comma-dangle
38:33 error Unexpected trailing comma comma-dangle
39:4 error Unexpected trailing comma comma-dangle
× 6 problems (6 errors, 0 warnings)
Слово comma-dangle означает «подвисшая запятая» (как в выражении let a = [1,] в противоположность выражению let a = [1]). Такие запятые могут считаться недопустимыми (поскольку прежде некоторыми браузерами они рассматривались как синтаксические ошибки), но выявление подобных ошибок вам на руку, поскольку упрощает проведение обновлений. Небольшие изменения в настройках превращают практику постоянного использования запятых в поощряемое действие:
"rules": {
"comma-dangle": [2, "always-multiline"],
"react/jsx-uses-react": 1
}
За полным списком правил следует обратиться к хранилищу кода, сопровождающему книгу, — этот список (как выражение лояльности к проекту) представляет собой копию собственного списка правил библиотеки React.
И наконец, добавьте проверку кода, сделав ее частью build.sh, чтобы средство ESLint контролировало ситуацию в процессе разработки, гарантируя постоянную поддержку высокого качества вашего кода:
# QA
eslint js/source
Flow — статическое средство проверки соответствия типов для JavaScript. По поводу типов в целом и особенно типов в JavaScript существуют два мнения.
Кому-то нравится, что ему, образно говоря, заглядывают через плечо, чтобы убедиться, что программа работает с правильными данными. Точно так же, как проверка кода и блочное тестирование, это вселяет уверенность, что где-нибудь, где код не проверялся (или этому не придавалось особого значения), этот код не был испорчен. Ценность соблюдения типизации повышается с ростом объема приложения и неизбежно сопутствующим ему ростом количества работающих с кодом людей.
Другим же нравится динамическая, позволяющая не придерживаться строгой типизации природа JavaScript, и они полагают, что с контролем типов слишком много мороки, поскольку приведением типов приходится заниматься лишь изредка.
Разумеется, хотите вы воспользоваться этим инструментальным средством или нет, целиком зависит от вас и от вашей команды, но оно доступно для работы.
$ npm install -g flow-bin
$ cd ~/reactbook/whinepad2
$ flow init
Команда init создает в вашем каталоге пустой файл .flowconfig. Добавьте к нему разделы ignore и include:
[ignore]
.*/react/node_modules/.*
[include]
node_modules/react
node_modules/react-dom
node_modules/classnames
[libs]
[options]
Для запуска нужно лишь набрать следующую команду:
$ flow
Или эту же команду — для проверки только одного файла либо каталога:
$ flow js/source/app.js
И наконец, добавьте к сценарию сборки в виде части процесса контроля качества — QA (quality assurance) — следующие команды:
# QA
eslint js/source
flow
В первом комментарии файла, который нужно проверить на соответствие типов, следует воспользоваться текстом @flow. Однако дело это сугубо добровольное.
Начнем с подписки простого компонента <Button> из предыдущей главы:
/* @flow */
import classNames from 'classnames';
import React, {PropTypes} from 'react';
const Button = props =>
props.href
? <a {...props} className={classNames('Button',
props.className)} />
: <button {...props} className={classNames('Button',
props.className)} />
Button.propTypes = {
href: PropTypes.string,
};
export default Button
Запустим Flow:
$ flow js/source/components/Button.js
js/source/components/Button.js:6
6: const Button = props =>
^^^^^ parameter 'props'. Missing annotation
Found 1 error
Найдена ошибка, но это положительный момент — у нас появилась возможность улучшить код! Flow жалуется, что неизвестно, для чего предназначен аргумент props.
Flow ожидает, что такая функция:
function sum(a, b) {
return a + b;
}
должна быть аннотирована следующим образом:
function sum(a: number, b: number): number {
return a + b;
}
чтобы не получалось неожиданных результатов вроде:
sum('1' + 2); // "12"
Аргумент props, получаемый функцией, является объектом. Поэтому можно сделать следующее:
const Button = (props: Object) =>
и не вызвать никаких нареканий у Flow:
$ flow js/source/components/Button.js
No errors!
Аннотация Object срабатывает, но можно конкретизировать происходящее, создав пользовательский тип:
type Props = {
href: ?string,
};
const Button = (props: Props) =>
props.href
? <a {...props} className={classNames('Button',
props.className)} />
: <button {...props} className={classNames('Button',
props.className)} />
export default Button
Как видите, переключение на пользовательский тип позволяет заменить определение свойства propTypes, имеющееся в React. Это означает:
• прекращение проверки соответствия типов в ходе выполнения приложения. В результате выполнение кода ускоряется;
• клиенту отправляется меньше кода (меньше байтов).
Позитивно также то, что типы свойств возвращаются в верхнюю часть компонента и служат в качестве более удобной локальной документации по компоненту.
Знак вопроса в href: ?string означает, что это свойство может иметь значение null.
Теперь, когда propTypes отсутствует, жалобы на переменную PropTypes у ESLint перестанут появляться. Следовательно:
import React, {PropTypes} from 'react';
становится:
import React from 'react';
Разве плохо иметь такие средства, как ESLint, отслеживающие незначительные досадные упущения вроде этого?
Запуск Flow выявит другую ошибку:
$ flow js/source/components/Button.js
js/source/components/Button.js:12
12: ? <a {...props} className={classNames('Button',
props.className)} />
^^^^^^^^^ property 'className'.
Property not found in
12: ? <a {...props} className={classNames('Button',
props.className)} />
^^^^^ object type
Проблема в том, что средство Flow не ожидало найти переменную className в объекте prop, который теперь имеет тип Prop. Для устранения проблемы добавьте атрибут className к новому типу:
type Props = {
href: ?string,
className: ?string,
};
При запуске Flow в отношении основного кода в app.js возникает следующая неприятность:
$ flow js/source/app.js
js/source/app.js:11
11: let data = JSON.parse(localStorage.getItem('data'));
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
call of method 'getItem'
11: let data = JSON.parse(localStorage.getItem('data'));
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
null. This type is
incompatible with
383: static parse(text: string, reviver?: (key: any,
value: any) => any):any;
^^^^^^ string. See lib: /private/tmp/
flow/flow lib_28f8ac7e/core.js:383
Средство Flow ожидает, что методу JSON.parse() будут передаваться только строки, и любезно предоставляет вам сигнатуру метода parse(). Поскольку от localStorage может быть получено значение null, такая ситуация неприемлема. Проще всего можно справиться с данной неприятностью, просто добавив значение по умолчанию:
let data = JSON.parse(localStorage.getItem('data') || '');
Но выражение JSON.parse('') выдаст ошибку в браузере (хотя с точки зрения проверки соответствия типов ничего нарушено не будет), поскольку пустая строка не является приемлемыми данными, закодированными в формате JSON. Чтобы не вызывать претензий у Flow и в то же время не сталкиваться с ошибками в браузере, код нужно немного переделать.
Вы могли заметить, насколько нудной может стать работа с типами, но преимущество в том, что Flow заставляет дважды подумать о раздаваемых значениях.
Соответствующей частью кода app.js является:
let data = JSON.parse(localStorage.getItem('data'));
// исходный пример данных, считываемых из схемы
if (!data) {
data = {};
schema.forEach((item) => data[item.id] = item.sample);
data = [data];
}
Еще одна проблема этого кода в том, что данные, которые ранее были массивом, затем превращаются в объект, после чего опять становятся массивом. У JavaScript проблем с этим нет, но то, что значение, имеющее сейчас один тип, позже превращается в значение другого типа, считается порочной практикой. Движки JavaScript, имеющиеся в браузерах, стремясь оптимизировать код, фактически выполняют назначения типов внутри себя. Следовательно, при смене типов на лету браузер может отбросить «оптимизирующий» режим, что неблагоприятно скажется на скорости его работы.
Устраним все эти проблемы.
Можно проявить особую требовательность и определить данные в качестве массива объектов:
let data: Array<Object>;
Затем попытаться считать любые сохраненные элементы в строку (или в null, поскольку использован символ ?) под названием storage:
const storage: ?string = localStorage.getItem('data');
Если в storage обнаружится строка, вы просто проанализируете ее, и дело в шляпе. Или же нужно хранить данные в массиве, первый элемент которого заполнить образцом значений:
if (!storage) {
data = [{}];
schema.forEach(item => data[0][item.id] = item.sample);
} else {
data = JSON.parse(storage);
}
Теперь два файла стали Flow-совместимыми. Сэкономим немного бумаги и не станем перечислять в данной главе весь набранный код, а сфокусируем внимание на более интересных особенностях Flow. Полную версию кода можно найти в хранилище, сопровождающем книгу.
Когда React-компонент создается с помощью функции без поддержки состояния, свойства можно аннотировать так, как это было показано ранее:
type Props = {/* ... */};
const Button = (props: Props) => {/* ... */};
Аналогичную аннотацию можно применить с конструктором класса:
type Props = {/* ... */};
class Rating extends Component {
constructor(props: Props) {/* ... */}
}
А если конструктор не нужен? Например, как в данном случае:
class Form extends Component {
getData(): Object {}
render() {}
}
На помощь придет еще одна особенность ECMAScript — свойство класса:
type Props = {/* ... */};
class Form extends Component {
props: Props;
getData(): Object {}
render() {}
}
На момент написания этих строк свойства класса еще не были приняты в качестве стандарта ECMAScript, но вы можете воспользоваться ими благодаря имеющейся в Babel самой передовой предустановке stage-0. Вам нужно установить NPM-пакет babel-preset-stage-0 и обновить раздел Babel в файле package.json следующим образом:
{
"babel": {
"presets": [
"es2015",
"react",
"stage-0"
]
}
}
Точно так же можно проаннотировать состояние вашего компонента. Кроме пользы от проверки типов, определение состояния в верхних строчках кода служит документацией для тех, кто охотится за ошибками в вашем компоненте. Рассмотрим пример:
type Props = {
defaultValue: number,
readonly: boolean,
max: number,
};
type State = {
rating: number,
tmpRating: number,
};
class Rating extends Component {
props: Props;
state: State;
constructor(props: Props) {
super(props);
this.state = {
rating: props.defaultValue,
tmpRating: props.defaultValue,
};
}
}
И конечно же, при каждом удобном случае вам следует применять свои собственные пользовательские типы:
componentWillReceiveProps(nextProps: Props) {
this.setRating(nextProps.defaultValue);
}
Посмотрим на компонент <FormInput>:
type FormInputFieldType = 'year' | 'suggest' | 'rating' | 'text' | 'input';
export type FormInputFieldValue = string | number;
export type FormInputField = {
type: FormInputFieldType,
defaultValue?: FormInputFieldValue,
id?: string,
options?: Array<string>,
label?: string,
};
class FormInput extends Component {
props: FormInputField;
getValue(): FormInputFieldValue {}
render() {}
}
Здесь показано, как можно проаннотировать использование списка допустимых значений подобно React-методу oneOf(), применяемому для типа свойств.
Также можно увидеть, как пользовательский тип (FormInputFieldType) применяется в качестве части другого пользовательского типа (FormInputField).
И наконец, экспорт типов. Когда другой компонент использует точно такой же тип, переопределение не требуется. Он может его импортировать при условии, что ваш компонент окажет любезность по его экспорту. Посмотрим, как компонент <Form> использует тип из компонента <FormInput>:
import type FormInputField from './FormInput';
type Props = {
fields: Array<FormInputField>,
initialData?: Object,
readonly?: boolean,
};
Вообще-то форме нужны оба типа из FormInput, и синтаксис будет иметь следующий вид:
import type {FormInputField, FormInputFieldValue} from './FormInput';
Flow позволяет указать, что конкретное значение не относится к типу, ожидаемому этим средством. Примером могут послужить обработчики событий, когда им передается объект события, а Flow воспринимает событие target не так, как вы рассчитывали. Рассмотрим следующий фрагмент кода из компонента Excel:
_showEditor(e: Event) {
const target = e.target;
this.setState({edit: {
row: parseInt(target.dataset.row, 10),
key: target.dataset.key,
}});
}
Flow не нравится это:
js/source/components/Excel.js:87
87: row: parseInt(target.dataset.row, 10),
^^^^^^^ property 'dataset'.
Property not found in
87: row: parseInt(target.dataset.row, 10),
^^^^^^ EventTarget
js/source/components/Excel.js:88
88: key: target.dataset.key,
^^^^^^^ property 'dataset'. Property
not found in
88: key: target.dataset.key,
^^^^^^ EventTarget
Found 2 errors
Если посмотреть на определения, находящиеся по адресу , то можно увидеть, что у объекта EventTarget нет свойства dataset. Но у объекта HTMLElement оно есть. Следовательно, на выручку придет приведение типов:
const target = ((e.target: any): HTMLElement);
Поначалу синтаксис может показаться немного странным, но, если его разобрать по частям, он обретет смысл: значение, двоеточие, тип и круглые скобки, охватывающие все три составляющие. Значение типа А становится типом Б. В данном случае объект любого типа становится таким же значением, но относится к типу HTMLElement.
Состояние в компоненте Excel использует два свойства, чтобы отслеживать редактирование пользователем поля и режим активного диалога:
this.state = {
// ...
edit: null, // {row index, schema.id},
dialog: null, // {type, idx}
};
Эти два свойства имеют либо значения null (редактирование не выполняется, диалог не ведется), либо значения в виде объектов, содержащих некую информацию о редактировании или диалоге. Тип этих двух свойств может быть следующим:
type EditState = {
row: number,
key: string,
};
type DialogState = {
idx: number,
type: string,
};
type State = {
data: Data,
sortby: ?string,
descending: boolean,
edit: ?EditState,
dialog: ?DialogState,
};
Теперь проблема в целом заключается в том, что иногда у свойств значения null, а иногда — нет. У Flow это вызывает вполне резонные подозрения. При попытке использования свойства this.state.edit.row или this.state.edit.key Flow выдает ошибку:
Property cannot be accessed on possibly null value (Свойство недоступно по причине возможного null-значения)
Вы используете эти свойства, только когда знаете об их доступности. Но Flow-то этого не знает. И никто не обещает, что по мере роста объема вашего приложения вы в конечном итоге не столкнетесь с неожиданным состоянием. А когда это произойдет, вам захочется узнать о случившемся. Чтобы не вызывать претензий у Flow и в то же время получать уведомления о неправильном поведении приложения, можно выполнить проверку на работу с пустым (null) значением.
До:
data[this.state.edit.row][this.state.edit.key] = value;
После:
if (!this.state.edit) {
throw new Error('В состоянии редактирования возникла
путаница');
}
data[this.state.edit.row][this.state.edit.key] = value;
Теперь все на своих местах. И когда фрагмент кода с условием на выдачу ошибки станет слишком часто повторяться, можно будет переключиться на использование функции invariant(). Такую функцию можно создать самостоятельно или позаимствовать из открытого исходного кода.
В этом вас поддержит NPM:
$ npm install --save-dev invariant
Добавьте в файл .flowconfig следующий код:
[include]
node_modules/react
node_modules/react-dom
node_modules/classnames
node_modules/invariant
А теперь обратитесь к вызову функции:
invariant(this.state.edit, 'В состоянии редактирования
возникла путаница');
data[this.state.edit.row][this.state.edit.key] = value;
Следующая остановка на пути к беспроблемному росту объема приложения называется автоматизированным тестированием. Когда дело доходит до тестирования, перед вами опять открывается широкий выбор вариантов. Для запуска тестов в React используется инструментальное средство Jest, поэтому посмотрим на деле, что это такое и как оно может нам помочь. В качестве вспомогательного средства в React предоставляется пакет под названием react-addons-test-utils.
Итак, настало время установки дополнительных средств.
Установите интерфейс командной строки Jest:
$ npm i -g jest-cli
Вам также понадобятся babel-jest (чтобы можно было создавать тесты в стиле ES6) и пакет утилит тестирования из коллекции React:
$ npm i --save-dev babel-jest react-addons-test-utils
Далее следует обновить содержимое файла package.json:
{
/* ... */
"eslintConfig": {
/* ... */
"env": {
"browser": true,
"jest": true
},
/* ... */
"scripts": {
"watch": "watch \"sh scripts/build.sh\"
js/source js/__tests__ css/",
"test": "jest"
},
"jest": {
"scriptPreprocessor": "node_modules/babel-jest",
"unmockedModulePathPatterns": [
"node_modules/react",
"node_modules/react-dom",
"node_modules/react-addons-test-utils",
"node_modules/fbjs"
]
}
}
Теперь можно запустить Jest с использованием следующей команды:
$ jest testname.js
Или с использованием npm:
$ npm test testname.js
Jest ищет тесты в каталоге __tests__, поэтому поместим их по путевому имени js/__tests__.
И наконец, обновим сценарий сборки, чтобы частью каждого ее процесса стали проверка кода и запуск:
# QA
eslint js/source js/__tests__
flow
npm test
Настроим также отслеживатель в файле watch.sh, чтобы он реагировал на все изменения в тестах (не забывайте, что эти функциональные возможности продублированы в package.json):
watch "sh scripts/build.sh" js/source js/__tests__ css/
Jest является надстройкой над популярной средой Jasmine, имеющей API-интерфейс, который звучит как разговорный английский. Сначала с помощью метода describe('набор', функция_обратного_вызова) дается определение набору тестов, одной или нескольким спецификациям тестов, для чего используется метод it('название теста', функция_обратного_вызова), а внутри каждой спецификации с помощью функции expect() задается утверждение.
Самый простой полноценный пример имеет следующий вид:
describe('Набор', () => {
it('спецификация', () => {
expect(1).toBe(1);
});
});
Запуск теста выглядит следующим образом:
$ npm test js/__tests__/dummy-test.js
> [email protected] test /Users/stoyanstefanov/reactbook/whinepad2
> jest "js/__tests__/dummy-test.js"
Using Jest CLI v0.8.2, jasmine1
PASS js/__tests__/dummy-test.js (0.206s)
1 test passed (1 total in 1 test suite, run time 0.602s)
Если в тесте указано неверное утверждение:
expect(1).toBeFalsy();
он при выполнении дает сбой и выводит сообщение (рис. 7.1).
Рис. 7.1. Запуск сбойного теста
Вооружившись знаниями о Jest в мире React, можно приступить к тестированию простой DOM-кнопки. Сначала выполним операции импорта:
import React from 'react';
import ReactDOM from 'react-dom';
import TestUtils from 'react-addons-test-utils';
Настроим набор тестов:
describe('Мы можем отобразить кнопку', () => {
it('кнопка изменяет текст после щелчка', () => {
// ...
});
});
Покончив с шаблоном, займемся отображением и тестированием. Отобразим ряд простых элементов JSX:
const button = TestUtils.renderIntoDocument(
<button
onClick={ev => ev.target.innerHTML = 'Bye'}>
Hello
</button>
);
Здесь для вывода элемента JSX (в данном случае кнопки, изменяющей текст, когда на ней производится щелчок) мы воспользовались библиотекой утилит тестирования, имеющейся в React.
После того как что-то отображено на экране, пора проверить, соответствует ли это вашим ожиданиям:
expect(ReactDOM.findDOMNode(button).textContent).toEqual('Hello');
Как видите, для получения доступа к DOM-узлу применяется метод ReactDOM.findDOMNode(). Таким образом, для проверки узла можно использовать общеизвестный API-интерфейс DOM-модели.
Зачастую нужно протестировать работу пользователя с вашим пользовательским интерфейсом. Для этого React любезно предоставляет вам метод TestUtils.simulate:
TestUtils.Simulate.click(button);
И последнее, что нужно проверить, — реагирует ли пользовательский интерфейс на работу с ним:
expect(ReactDOM.findDOMNode(button).textContent).toEqual('Bye');
Далее в главе представлены дополнительные примеры и API-интерфейсы, которыми можно воспользоваться, но основным инструментарием будут такие методы:
• TestUtils.renderIntoDocument(произвольный_JSX);
• TestUtils.Simulate.* для работы с интерфейсом;
• ReactDOM.findDOMNode() (или ряд других методов TestUtils) для получения ссылки на DOM-узел и проверки, имеет ли он должный вид.
Код компонента <Button> имеет следующий вид:
/* @flow */
import React from 'react';
import classNames from 'classnames';
type Props = {
href: ?string,
className: ?string,
};
const Button = (props: Props) =>
props.href
? <a {...props} className={classNames('Button',
props.className)} />
: <button {...props} className={classNames('Button',
props.className)} />
export default Button
Протестируем его по следующим характерным особенностям:
• выводит <a> или <button> — в зависимости от наличия свойства href (первая спецификация);
• допускает использование пользовательских имен классов (вторая спецификация).
Начнем создание нового теста:
jest
.dontMock('../source/components/Button')
.dontMock('classnames')
;
import React from 'react';
import ReactDOM from 'react-dom';
import TestUtils from 'react-addons-test-utils';
Инструкции import такие же, как и прежде, но теперь есть новые вызовы метода jest.dontMock().
Имитация (mock) представляет собой замену части функционального наполнения искусственным кодом-заглушкой, якобы выполняющим работу. Заглушки часто встречаются в блочном тестировании, поскольку от него требуется протестировать блок (небольшой фрагмент кода, находящийся в изоляции) и сократить побочные эффекты относительно всего остального содержимого системы. На создание заглушек тратится немало усилий, поэтому в Jest принят противоположный подход: по умолчанию глушатся все действия. И вам предоставляется возможность отказа от глушения с помощью функции dontMock(), поскольку требуется протестировать не заглушку, а реальный код.
В предыдущем примере объявляется, что вы не желаете глушить <Button> или используемую этим компонентом библиотеку classnames.
Затем наступает черед включения компонента <Button>:
const Button = require('../source/components/Button');
На момент написания этих строк, несмотря на имеющееся в Jest-документации описание, вызов require() не работал. Вместо него требовался следующий код:
const Button = require('../source/components/Button').default;
Также не работала инструкция import:
import Button from '../source/components/Button';
Хотя со следующим кодом было все нормально:
import _Button from '../source/components/Button';
const Button = _Button.default;
Еще один вариант предполагает использование в компоненте <Button> вместо кода export default Button кода export {Button}. А затем выполнение импорта с помощью инструкции import {Button} from '../source/component/Button'.
Надеюсь, что на момент вашего прочтения данной книги исходный импорт уже работает в соответствии с вашими ожиданиями.
Настроим набор (с помощью describe()) и первую спецификацию (с помощью it()):
describe('Отображение компонентов Button', () => {
it('отображает <a> вместо <button>', () => {
/* ... код, задающий отображение и ожидание (expect())
... */
});
});
А теперь отобразим простую кнопку, у которой нет свойства href, поэтому код должен вывести элемент <button>:
const button = TestUtils.renderIntoDocument(
<div>
<Button>
Hello
</Button>
</div>
);
Обратите внимание на необходимость заключения функциональных компонентов, не поддерживающих состояние, таких как <Button>, в еще один DOM-узел, чтобы позже их можно было найти с помощью метода ReactDOM.
Теперь вызов метода ReactDOM.findDOMNode(button) предоставит вам элемент-оболочку <div>; чтобы получить <button>, берется первый дочерний элемент и проверяется, что он действительно является кнопкой:
expect(ReactDOM.findDOMNode(button).children[0].nodeName).
toEqual('BUTTON');
По аналогии в качестве составной части той же спецификации теста задается проверка, определяющая, что при наличии свойства href используется узел гипертекстовой ссылки:
const a = TestUtils.renderIntoDocument(
<div>
<Button href="#">
Hello
</Button>
</div>
);
expect(ReactDOM.findDOMNode(a).children[0].nodeName).
toEqual('A');
Во второй спецификации добавляются имена пользовательских классов, а затем проверяется, можно ли их найти в нужном месте:
it('разрешает применять пользовательские классы CSS', () => {
const button = TestUtils.renderIntoDocument(
<div><Button className="good bye">Hello</Button></div>
);
const buttonNode = ReactDOM.findDOMNode(button).children[0];
expect(buttonNode.getAttribute('class')).toEqual('Button
good bye');
});
Здесь важно подчеркнуть факт, касающийся Jest-глушения. Порой написанный таким образом тест работает совершенно не так, как ожидалось. Такое может произойти, если забыли снять Jest-заглушку. Следовательно, если в самом начале теста находится следующий код:
jest
.dontMock('../source/components/Button')
// .dontMock('classnames')
;
то Jest глушит модуль classnames — и тест ничего не делает. Убедиться в этом можно, написав следующий код:
const button = TestUtils.renderIntoDocument(
<div><Button className="good bye">Hello</Button></div>
);
console.log(ReactDOM.findDOMNode(button).outerHTML);
Он запишет в консоль этот сгенерированный код HTML:
<div data-reactid=".2">
<button data-reactid=".2.0">Hello</button>
</div>
Как видите, никаких нужных имен классов вообще не наблюдается, поскольку в заглушенном состоянии метод classNames() ничего не делает.
Вернем на место вызов метода dontMock():
jest
.dontMock('../source/components/Button')
.dontMock('classnames')
;
и вы увидите, что вызов атрибута outerHTML показал следующий код:
<div data-reactid=".2">
<button class="Button good bye"
data-reactid=".2.0">Hello</button>
</div>
и тест был пройден успешно.
Когда тест ведет себя непонятно и вы интересуетесь, как выглядит созданная разметка, проще всего воспользоваться инструкцией console.log(node.outerHTML), показывающей сам код HTML.
<Actions> является еще одним компонентом, не поддерживающим состояние, следовательно, чтобы позже его можно было изучить, он нуждается в оболочке. Один из вариантов, как уже было показано на примере <Button>, предусматривает заключение его в div-контейнер и получение к нему доступа при помощи следующего кода:
const actions = TestUtils.renderIntoDocument(
<div><Actions /></div>
);
ReactDOM.findDOMNode(actions).children[0];
// Корневой узел <Actions>
Еще один вариант — использование элемента-оболочки от React, который затем позволит вам применять множество методов TestUtils для охоты на проверяемые узлы.
Оболочка не содержит сложного кода. Определить оболочку можно в ее собственном модуле, это дает возможность многократного использования:
import React from 'react';
class Wrap extends React.Component {
render() {
return <div>{this.props.children}</div>;
}
}
export default Wrap
Теперь шаблонная часть теста приобрела следующий вид:
jest
.dontMock('../source/components/Actions')
.dontMock('./Wrap')
;
import React from 'react';
import TestUtils from 'react-addons-test-utils';
const Actions = require('../source/components/Actions');
const Wrap = require('./Wrap');
describe('Щелчок на значке действия', () => {
it('вызывает определенную реакцию', () => {
/* отображение */
const actions = TestUtils.renderIntoDocument(
<Wrap><Actions /></Wrap>
);
/* ... поиск и проверка */
});
});
В компоненте действий <Actions> нет ничего необычного. Его код выглядит следующим образом:
const Actions = (props: Props) =>
<div className="Actions">
<span
tabIndex="0"
className="ActionsInfo"
title="More info"
onClick={props.onAction.bind(null,
'info')}>ℹ</span>
{/* ... еще два span-узла */}
</div>
При тестировании единственное, что нужно проверить, — это надлежащий вызов при щелчке на значках действий вашей функции обратного вызова onAction. Jest позволяет определять функции-имитаторы (или mock-функции) и проверять факт их вызова. При использовании функций обратного вызова этого вполне достаточно.
В теле теста создается новая mock-функция, которая передается компоненту Actions в качестве функции обратного вызова:
const callback = jest.genMockFunction();
const actions = TestUtils.renderIntoDocument(
<Wrap><Actions onAction={callback} /></Wrap>
);
Затем следует заняться щелчками на значках действий:
TestUtils
.scryRenderedDOMComponentsWithTag(actions, 'span')
.forEach(span => TestUtils.Simulate.click(span));
Обратите внимание на использование одного из методов TestUtils для поиска DOM-узлов. Он возвращает массив из трех <span>-узлов, и вы имитируете щелчок на каждом из них.
Теперь ваша mock-функция обратного вызова должна вызываться три раза. Подтвердите с помощью метода expect(), что именно это вам и нужно:
const calls = callback.mock.calls;
expect(calls.length).toEqual(3);
Как видите, свойство вызовов callback.mock.calls является массивом. У каждого вызова также имеется массив аргументов, переданных ему в процессе вызова.
Первое действие называется 'info', и оно вызывает onAction, передавая тип действия 'info' и используя для этого код props.onAction.bind(null, 'info'). Следовательно, первым аргументом (0) для первой mock-функции обратного вызова (0) должен быть 'info':
expect(calls[0][0]).toEqual('info');
Аналогично этому ожидаемые результаты двух других действий задаются следующим кодом:
expect(calls[1][0]).toEqual('edit');
expect(calls[2][0]).toEqual('delete');
Библиотека TestUtils предоставляет вам ряд функций для поиска DOM-узлов в дереве отображения React. Например, поиск узла по имени тега или имени класса. Один пример вы уже видели:
TestUtils.scryRenderedDOMComponentsWithTag(actions, 'span')
А вот еще один:
TestUtils.scryRenderedDOMComponentsWithClass(actions,
'ActionsInfo')
Наряду с методами scry* в вашем распоряжении есть соответствующие методы find*. Например:
TestUtils.findRenderedDOMComponentWithClass(actions,
'ActionsInfo')
Обратите внимание на использование в имени составляющей слова Component, а не Components. В отличие от методов scry*, которые дают массив совпадений (даже если совпадение всего одно или их вовсе нет), методы find* возвращают только одно совпадение. Если совпадений нет или их сразу несколько, возникает ошибка. Поэтому поиски с помощью методов find* всегда ведутся с полной уверенностью, что в дереве имеется всего лишь один искомый DOM-узел.
Протестируем виджет Rating. Он изменяет состояние при наступлении событий прохождения указателя мыши над элементом (mouseover), увода указателя с элемента (mouseout) и щелчка на элементе (click). Используемый шаблон имеет следующий вид:
jest
.dontMock('../source/components/Rating')
.dontMock('classnames')
;
import React from 'react';
import TestUtils from 'react-addons-test-utils';
const Rating = require('../source/components/Rating');
describe('работы', () => {
it('обрабатывает пользовательские действия', () => {
const input = TestUtils.renderIntoDocument(<Rating />);
/* изложите здесь в методе expect() ваши ожидания */
});
});
Обратите внимание, что заключать <Rating> при его отображении в какую-либо оболочку не нужно. Это не функциональный компонент, поддерживающий состояние, поэтому он вполне работоспособен и без оболочки.
У виджета имеется несколько звезд (по умолчанию пять), каждая из которых заключена в span-контейнер. Найдем их:
const stars = TestUtils.scryRenderedDOMComponentsWithTag(input, 'span');
Теперь тест имитирует действия, вызывающие наступление события mouseOver, затем mouseOut и следом click на четвертой звезде (span[3]). Когда это произойдет, звезды с первой по четвертую должны перейти в состояние «включено», иными словами, получить имя класса RatingOn, а пятая звезда должна оставаться «выключенной»:
TestUtils.Simulate.mouseOver(stars[3]);
expect(stars[0].className).toBe('RatingOn');
expect(stars[3].className).toBe('RatingOn');
expect(stars[4].className).toBeFalsy();
expect(input.state.rating).toBe(0);
expect(input.state.tmpRating).toBe(4);
TestUtils.Simulate.mouseOut(stars[3]);
expect(stars[0].className).toBeFalsy();
expect(stars[3].className).toBeFalsy();
expect(stars[4].className).toBeFalsy();
expect(input.state.rating).toBe(0);
expect(input.state.tmpRating).toBe(0);
TestUtils.Simulate.click(stars[3]);
expect(input.getValue()).toBe(4);
expect(stars[0].className).toBe('RatingOn');
expect(stars[3].className).toBe('RatingOn');
expect(stars[4].className).toBeFalsy();
expect(input.state.rating).toBe(4);
expect(input.state.tmpRating).toBe(4);
Обратите также внимание на то, как тест добирается до состояния компонента, чтобы проверить корректность значений state.rating и state.tmpRating. Возможно, это несколько бесцеремонно, но все же, если ожидаются «открытые» результаты, какая разница, какое внутреннее состояние компонент выбирает для управления? Но выяснить это, конечно же, возможно.
Напишем несколько тестов для компонента Excel. Все же он достаточно большой и способен серьезно нарушить поведение приложения, если что-нибудь пойдет не так. Для начала создадим следующий код:
jest.autoMockOff();
import React from 'react';
import TestUtils from 'react-addons-test-utils';
const Excel = require('../source/components/Excel');
const schema = require('../source/schema');
let data = [{}];
schema.forEach(item => data[0][item.id] = item.sample);
describe('Редактирование данных', () => {
it('сохраняет новые данные', () => {
/* ... отображение, взаимодействие, проверка */
});
});
В первую очередь обратите внимание на вызов метода jest.autoMockOff(); в самом начале кода. Вместо перечисления всех компонентов, используемых компонентом Excel (и компонентов, которые те, в свою очередь, применяют), можно одним махом выключить полностью все глушение.
Затем вам, очевидно, понадобятся схема и образцовые данные для инициализации компонента (аналогично app.js).
Теперь перейдем к отображению:
const callback = jest.genMockFunction();
const table = TestUtils.renderIntoDocument(
<Excel
schema={schema}
initialData={data}
onDataChange={callback} />
);
Все это, конечно, хорошо, но теперь изменим значение в первой ячейке первой строки. Зададим новое значение:
const newname = '$2.99 chuck';
Нас интересует следующая ячейка:
const cell = TestUtils.scryRenderedDOMComponentsWithTag(table,
'td')[0];
На момент написания книги для предоставления поддержки dataset, отсутствующей в используемой Jest реализации DOM-модели, требовался обходной вариант:
cell.dataset = { // обход недостатков поддержки DOM,
// имеющихся в Jest
row: cell.getAttribute('data-row'),
key: cell.getAttribute('data-key'),
};
Двойной щелчок на ячейке превращает ее содержимое в форму с полем ввода данных:
TestUtils.Simulate.doubleClick(cell);
Изменение значения поля ввода данных и отправка формы:
cell.getElementsByTagName('input')[0].value = newname;
TestUtils.Simulate.submit(cell.getElementsByTagName('form')[0]);
Теперь содержимое ячейки уже является не формой, а простым текстом:
expect(cell.textContent).toBe(newname);
И функция обратного вызова onDataChange была вызвана с массивом, содержащим объекты, состоящие из данных таблицы в виде пар «ключ — значение». Можно проверить, что mock-функция обратного вызова получает новые данные надлежащим образом:
expect(callback.mock.calls[0][0][0].name).toBe(newname);
Здесь [0][0][0] означает, что первым аргументом mock-функции выступает массив, в котором первый элемент является объектом (соответствующим записи в таблице) со свойством name, равным "$2.99 chuck".
Вместо использования TestUtils.Simulate.submit можно выбрать TestUtils.Simulate.keyDown и выдать событие нажатия клавиши Enter, при котором также осуществляется отправка данных формы.
В качестве второй спецификации теста удалим одну строку образцовых данных:
it('deletes data', () => {
// То же, что и раньше
const callback = jest.genMockFunction();
const table = TestUtils.renderIntoDocument(
<Excel
schema={schema}
initialData={data}
onDataChange={callback} />
);
TestUtils.Simulate.click( // значок x
TestUtils.findRenderedDOMComponentWithClass(table,
'ActionsDelete')
);
TestUtils.Simulate.click( // диалог подтверждения
TestUtils.findRenderedDOMComponentWithClass(table,
'Button')
);
expect(callback.mock.calls[0][0].length).toBe(0);
});
Как и в предыдущем примере, callback.mock.calls[0][0] является новым массивом данных после взаимодействия. Только на этот раз в нем ничего не осталось, поскольку одну запись тест удалил.
После того как вы изучите эти темы, ситуация упростится и может стать повторяющейся. Обеспечить охват тестированием всех возможных сценариев развития событий можете только вы. К примеру, щелкните на кнопке открытия информационного окна, отмените действие, щелкните на кнопке открытия окна удаления записи, отмените действие, щелкните еще раз и только после этого удалите запись.
Использование тестов — разумное решение, поскольку они помогают ускорить разработку, приобрести уверенность в своих действиях и проводить реорганизацию кода без лишних опасений за конечный результат. Тесты помогают поставить на место ваших соисполнителей, которые думают, что вносимые ими изменения носят изолированный характер, а на самом деле последствия распространяются гораздо шире. Один из способов придать процессу написания тестов форму некой игры заключается в использовании возможности охвата кода.
Можно применить следующую команду:
$ jest --coverage
запускающую все тесты, которые только могут быть найдены, и выводящую отчет о том, сколько строк, функций и тому подобного было протестировано (или охвачено тестами). Пример показан на рис. 7.2.
Рис. 7.2. Отчет об охвате тестированием всего кода
Как видите, не все прошло идеально и, несомненно, есть потенциальная возможность для написания дополнительных тестов.
Одной из полезных особенностей отчета об охватывающем тестировании является возможность выявления не прошедших тестирование строк кода. То есть, даже если вы протестировали компонент FormInput, строка 22 не была протестирована. Номера строк, вызывающих вопросы, возвращаются инструкцией return:
getValue(): FormInputFieldValue {
return 'value' in this.refs.input
? this.refs.input.value
: this.refs.input.getValue();
}
Оказывается, что эта функция никогда тестами не проверялась. Настало время исправить ситуацию, оперативно задав тестовую спецификацию:
it('возвращает введенное значение', () => {
let input = TestUtils.renderIntoDocument(<FormInput
type="year" />);
expect(input.getValue()).toBe(String(new
Date().getFullYear()));
input = TestUtils.renderIntoDocument(
<FormInput type="rating" defaultValue="3" />
);
expect(input.getValue()).toBe(3);
});
Первым вызовом expect() тестируется поле ввода, встроенное в DOM-модель, а вторым вызовом тестируется специализированное поле ввода. Теперь должны быть выполнены оба итога применения тернарного оператора в методе getValue().
Отчет об охвате кода вознаграждает вас результатом, показывающим, что теперь строка 22 также охвачена тестированием (рис. 7.3).
Рис. 7.3. Обновленный отчет об охвате тестированием всего кода