Spec-Zone.ru › Enzyme

Руководство по миграции с enzyme v2.x на v3.x

Переход с enzyme v2.x на v3.x — это более значительное изменение, чем при предыдущих крупных выпусках, так как внутренняя реализация enzyme была практически полностью переписана.

Цель этой переработки заключалась в решении многих основных проблем, которые преследовали enzyme с момента его первоначального выпуска. Также она преследовала цель одновременного удаления многих зависимостей enzyme от внутренних компонентов React и повышения «подключаемости» enzyme, что проложило путь для использования enzyme с библиотеками, подобными React, такими как Preact и Inferno.

Мы приложили все усилия, чтобы сделать enzyme v3 совместимым с API v2.x, однако есть несколько критических изменений, которые мы сочли необходимыми для поддержки новой архитектуры и улучшения долгосрочной полезности библиотеки.

В Airbnb используется один из самых больших наборов тестов enzyme, насчитывающий около 30 000 юнит-тестов enzyme. После обновления enzyme до v3.x в кодовой базе Airbnb 99,6% из этих тестов прошли без каких-либо изменений. Большинство тестов, которые не прошли, были легко исправлены, и некоторые из них, как оказалось, зависели от того, что можно было бы считать ошибкой в v2.x, и разрыв в работе был фактически желаемым.

В этом руководстве мы рассмотрим несколько наиболее распространённых случаев разрыва работы и способы их исправления. Надеемся, что это упростит путь обновления. Если во время обновления вы столкнётесь с непонятным разрывом работы, не стесняйтесь создать вопрос.

Настройка адаптера

enzyme теперь имеет систему «адаптеров». Это означает, что теперь вам необходимо установить enzyme вместе с другим модулем, который предоставляет адаптер, указывающий enzyme, как работать с вашей версией React (или любой другой библиотекой, подобной React).

На момент написания этого документа enzyme публикует «официально поддерживаемые» адаптеры для React 0.13.x, 0.14.x, 15.x и 16.x. Эти адаптеры — npm-пакеты вида enzyme-adapter-react-{{version}}.

Перед использованием enzyme в своих тестах необходимо настроить enzyme с помощью адаптера, который вы хотите использовать. Это делается с помощью enzyme.configure(...). Например, если ваш проект зависит от React 16, вы должны настроить enzyme следующим образом:

import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });

Список npm-пакетов адаптеров для диапазонов semver React:

Пакет адаптера enzyme Совместимость с React semver
enzyme-adapter-react-16 ^16.4.0-0
enzyme-adapter-react-16.3 ~16.3.0-0
enzyme-adapter-react-16.2 ~16.2
enzyme-adapter-react-16.1 `~16.0.0-0 \ \ ~16.1`
enzyme-adapter-react-15 ^15.5.0
enzyme-adapter-react-15.4 15.0.0-0 - 15.4.x
enzyme-adapter-react-14 ^0.14.0
enzyme-adapter-react-13 ^0.13.0

Ссылочная идентичность элементов больше не сохраняется

Новая архитектура enzyme означает, что «дерево рендеринга» React преобразуется в промежуточное представление, общее для всех версий React, чтобы enzyme мог должным образом его проходить, независимо от внутренних представлений React. Побочным эффектом этого является то, что enzyme больше не имеет доступа к фактическим объектным ссылкам, которые возвращались из render в ваших компонентах React. Обычно это не проблема, но в некоторых случаях может привести к сбою тестов.

Например, рассмотрим следующий пример:

import React from 'react';
import Icon from './path/to/Icon';

const ICONS = {
  success: <Icon name="check-mark" />,
  failure: <Icon name="exclamation-mark" />,
};

const StatusLabel = ({ id, label }) => <div>{ICONS[id]}{label}{ICONS[id]}</div>;
import { shallow } from 'enzyme';
import StatusLabel from './path/to/StatusLabel';
import Icon from './path/to/Icon';

const wrapper = shallow(<StatusLabel id="success" label="Success" />);

const iconCount = wrapper.find(Icon).length;

В v2.x, iconCount будет равно 1. В v3.x — 2. Это происходит потому, что в v2.x он находил все элементы, соответствующие селектору, а затем удалял дубликаты. Так как ICONS.success включён дважды в дереве рендеринга, но это постоянная переиспользуемая константа, он будет отображаться как дубликат в глазах enzyme v2.x. В enzyme v3 элементы, которые проходятся, представляют собой преобразования базовых элементов React и, следовательно, являются разными ссылками, что приводит к нахождению двух элементов.

Хотя это критический момент, я считаю, что новое поведение ближе к тому, что люди действительно ожидают и хотят. Использование неизменяемых обёртков enzyme приводит к более детерминированным тестам, которые менее подвержены нестабильности от внешних факторов.

Вызов props() после изменения состояния

В enzyme v2 выполнение события, которое изменяет состояние компонента (и, в свою очередь, обновляет свойства), возвращало эти обновлённые свойства через метод .props.

Теперь, в enzyme v3, необходимо повторно найти компонент; например:

class Toggler extends React.Component {
  constructor(...args) {
    super(...args);
    this.state = { on: false };
  }

  toggle() {
    this.setState(({ on }) => ({ on: !on }));
  }

  render() {
    const { on } = this.state;
    return (<div id="root">{on ? 'on' : 'off'}</div>);
  }
}

it('passes in enzyme v2, fails in v3', () => {
  const wrapper = mount(<Toggler />);
  const root = wrapper.find('#root');
  expect(root.text()).to.equal('off');

  wrapper.instance().toggle();

  expect(root.text()).to.equal('on');
});

it('passes in v2 and v3', () => {
  const wrapper = mount(<Toggler />);
  expect(wrapper.find('#root').text()).to.equal('off');

  wrapper.instance().toggle();

  expect(wrapper.find('#root').text()).to.equal('on');
});

children() имеет несколько иное значение

enzyme имеет метод .children(), предназначенный для возврата отображаемых дочерних элементов обёртки.

При использовании mount(...), иногда неясно, что это должно означать. Рассмотрим, например, следующие компоненты React:

class Box extends React.Component {
  render() {
    const { children } = this.props;
    return <div className="box">{children}</div>;
  }
}

class Foo extends React.Component {
  render() {
    return (
      <Box bam>
        <div className="div" />
      </Box>
    );
  }
}

Теперь предположим, что у нас есть тест, который делает что-то вроде:

const wrapper = mount(<Foo />);

На данном этапе возникает неоднозначность в отношении того, что должно вернуть wrapper.find(Box).children(). Хотя у элемента <Box ... /> свойство children имеет значение <div className="div" />, фактические отображаемые дочерние элементы элемента, который рендерит компонент box, — это элемент <div className="box">...</div>.

Предыдущие версии enzyme v3 демонстрировали следующее поведение:

wrapper.find(Box).children().debug();
// => <div className="div" />

В enzyme v3 метод .children() возвращает отображаемые дочерние элементы. Другими словами, он возвращает элемент, возвращаемый функцией render этого компонента.

wrapper.find(Box).children().debug();
// =>
// <div className="box">
//   <div className="div" />
// </div>

Это может показаться незначительным отличием, но внесение этого изменения важно для будущих API, которые мы хотели бы внедрить.

find() теперь возвращает узлы хоста и узлы DOM

В некоторых случаях find возвращает узел хоста и узел DOM. Например:

const Foo = () => <div/>;
const wrapper = mount(
  <div>
    <Foo className="bar" />
    <div className="bar"/>
   </div>
);
console.log(wrapper.find('.bar').length); // 2

Поскольку у <Foo/> есть класс bar, он возвращается как hostNode. Как ожидалось, <div> с классом bar также возвращается.

Чтобы избежать этого, можно явно запросить узел DOM: wrapper.find('div.bar'). В качестве альтернативы, если вы хотите найти только узлы хоста, используйте hostNodes().

Для mount, обновления иногда требуются, когда они не требовались ранее

Приложения React динамичны. При тестировании компонентов React вы часто хотите протестировать их до и после определенных изменений состояния.

При использовании mount, любой экземпляр компонента React в целом дереве рендеринга может зарегистрировать код для инициирования изменения состояния в любое время.

Например, рассмотрим следующий пример:

import React from 'react';

class CurrentTime extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      now: Date.now(),
    };
  }

  componentDidMount() {
    this.tick();
  }

  componentWillUnmount() {
    clearTimeout(this.timer);
  }

  tick() {
    this.setState({ now: Date.now() });
    this.timer = setTimeout(tick, 0);
  }

  render() {
    const { now } = this.state;
    return <span>{now}</span>;
  }
}

В этом коде есть таймер, который непрерывно изменяет отображаемый вывод этого компонента. Это может быть разумно в вашем приложении. Дело в том, что у enzyme нет способа узнать, что эти изменения происходят, и нет способа автоматически обновить дерево рендеринга. В enzyme v2 enzyme работал непосредственно с представлением дерева рендеринга в оперативной памяти, которое имело сам React. Это означает, что даже если enzyme не мог знать, когда обновляется дерево рендеринга, обновления всё равно отображались, поскольку React знает.

В enzyme v3 архитектурно был создан слой, где React создаёт промежуточное представление дерева рендеринга в определённый момент времени и передаёт его enzyme для прохода и проверки. Это имеет много преимуществ, но одним из побочных эффектов является то, что промежуточное представление не получает автоматических обновлений.

enzyme пытается автоматически «обновить» корневую обёртку в большинстве обычных сценариев, но это только те изменения состояния, о которых он знает. Для всех остальных изменений состояния может потребоваться вызвать wrapper.update() самостоятельно.

Самое распространённое проявление этой проблемы показано на следующем примере:

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.increment = this.increment.bind(this);
    this.decrement = this.decrement.bind(this);
  }

  increment() {
    this.setState(({ count }) => ({ count: count + 1 }));
  }

  decrement() {
    this.setState(({ count }) => ({ count: count - 1 }));
  }

  render() {
    const { count } = this.state;
    return (
      <div>
        <div className="count">Count: {count}</div>
        <button type="button" className="inc" onClick={this.increment}>Increment</button>
        <button type="button" className="dec" onClick={this.decrement}>Decrement</button>
      </div>
    );
  }
}

Это базовый компонент «счётчика» в React. Здесь наш результат зависит от this.state.count, который может обновляться функциями increment и decrement. Посмотрим, как могут выглядеть некоторые тесты enzyme с этим компонентом и когда необходимо вызывать update().

const wrapper = shallow(<Counter />);
wrapper.find('.count').text(); // => "Count: 0"

Как мы видим, мы можем легко проверить текст и счёт этого компонента. Но мы ещё не вызвали никаких изменений состояния. Посмотрим, как это выглядит при имитации события click на кнопках увеличения и уменьшения:

const wrapper = shallow(<Counter />);
wrapper.find('.count').text(); // => "Count: 0"
wrapper.find('.inc').simulate('click');
wrapper.find('.count').text(); // => "Count: 1"
wrapper.find('.inc').simulate('click');
wrapper.find('.count').text(); // => "Count: 2"
wrapper.find('.dec').simulate('click');
wrapper.find('.count').text(); // => "Count: 1"

В этом случае enzyme автоматически проверяет обновления после имитации события, так как знает, что это очень распространённое место для изменений состояния. В этом случае нет разницы между v2 и v3.

Рассмотрим другой способ написания этого теста:

const wrapper = shallow(<Counter />);
wrapper.find('.count').text(); // => "Count: 0"
wrapper.instance().increment();
wrapper.find('.count').text(); // => "Count: 0" (would have been "Count: 1" in v2)
wrapper.instance().increment();
wrapper.find('.count').text(); // => "Count: 0" (would have been "Count: 2" in v2)
wrapper.instance().decrement();
wrapper.find('.count').text(); // => "Count: 0" (would have been "Count: 1" in v2)

Проблема здесь заключается в том, что после получения экземпляра с помощью wrapper.instance(), enzyme не знает, будете ли вы выполнять действия, вызывающие переход состояния, и, следовательно, не знает, когда запрашивать обновлённое дерево рендеринга от React. В результате значение .text() никогда не меняется.

Исправлением здесь является использование метода wrapper.update() enzyme после изменения состояния:

const wrapper = shallow(<Counter />);
wrapper.find('.count').text(); // => "Count: 0"
wrapper.instance().increment();
wrapper.update();
wrapper.find('.count').text(); // => "Count: 1"
wrapper.instance().increment();
wrapper.update();
wrapper.find('.count').text(); // => "Count: 2"
wrapper.instance().decrement();
wrapper.update();
wrapper.find('.count').text(); // => "Count: 1"

На практике это не часто требуется, и когда это необходимо, это не сложно добавить. Кроме того, автоматическое обновление обёртки enzyme вместе с реальным деревом рендеринга может привести к нестабильным тестам при написании асинхронных тестов. Это критически важное изменение стоило архитектурных преимуществ новой системы адаптеров в v3, и мы считаем, что это лучший выбор для библиотеки утверждений.

ref(refName) теперь возвращает фактическую ссылку, а не обёртку

В enzyme v2 обёртка, возвращаемая из mount(...), имела прототип метода ref(refName), который возвращал обёртку вокруг фактического элемента этой ссылки. Теперь это изменено на возврат фактической ссылки, что, по нашему мнению, является более интуитивным API.

Рассмотрим следующий простой компонент React:

class Box extends React.Component {
  render() {
    return <div ref="abc" className="box">Hello</div>;
  }
}

В этом случае мы можем вызвать .ref('abc') на обёртке Box. В этом случае он вернёт обёртку вокруг отображаемого div. Чтобы продемонстрировать, мы можем увидеть, что wrapper и результат ref(...) имеют один и тот же конструктор:

const wrapper = mount(<Box />);
// this is what would happen with enzyme v2
expect(wrapper.ref('abc')).toBeInstanceOf(wrapper.constructor);

В v3 контракт немного изменён. Ссылка — это именно то, что React присваивает как ссылку. В этом случае это элемент DOM:

const wrapper = mount(<Box />);
// this is what happens with enzyme v3
expect(wrapper.ref('abc')).toBeInstanceOf(Element);

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

class Bar extends React.Component {
  render() {
    return <Box ref="abc" />;
  }
}
const wrapper = mount(<Bar />);
expect(wrapper.ref('abc')).toBeInstanceOf(Box);

На наш опыт, это чаще всего то, что люди хотели бы и ожидали от метода .ref(...).

Чтобы получить обёртку, возвращённую enzyme 2:

const wrapper = mount(<Bar />);
const refWrapper = wrapper.findWhere((n) => n.instance() === wrapper.ref('abc'));

С mount, .instance() можно вызывать на любом уровне дерева

теперь enzyme позволяет получить instance() обёртки на любом уровне дерева рендеринга, а не только в корне. Это означает, что вы можете получить .find(...) конкретного компонента, затем получить его экземпляр и вызвать .setState(...) или любые другие методы на экземпляре, которые вам нужны.

С mount, .getNode() не следует использовать. .instance() делает то же, что и раньше.

Для mount обёрток метод .getNode() возвращал фактический экземпляр компонента. Этот метод больше не существует, но .instance() функционально эквивалентен тому, что .getNode() делал раньше.

С shallow, .getNode() следует заменить на getElement()

Для обёрток shallow, если вы ранее использовали .getNode(), вы должны заменить эти вызовы на .getElement(), который теперь функционально эквивалентен тому, что .getNode() делал раньше. Важное замечание: ранее .getNode() возвращал фактический экземпляр элемента, созданного в функции render тестируемого компонента, но теперь он будет структурно эквивалентным элементом React, но не ссылками. Вам необходимо обновить тесты, чтобы учесть это.

Удалены закрытые свойства и методы

Существует несколько свойств в "обёртке" enzyme, которые считались закрытыми и по этой причине не документировались. Несмотря на то, что они не документированы, люди могли полагаться на них. В целях предотвращения случайного нарушения изменений в будущем, мы решили сделать эти свойства по-настоящему "закрытыми". К свойствам, больше недоступным в экземплярах enzyme shallow или mount, относятся:

  • .node
  • .nodes
  • .renderer
  • .unrendered
  • .root
  • .options

Cheerio обновлён, следовательно, render(...) также обновлён

Верхнеуровневый API render enzyme возвращает объект Cheerio. Версия Cheerio, которую мы используем, обновлена до 1.0.0. Для устранения проблем при отладке между версиями enzyme v2.x и v3.x с API render, мы рекомендуем изучить Журнал изменений Cheerio и открыть вопрос на этом репозитории, а не на репозитории enzyme, если вы не считаете, что это ошибка в использовании библиотеки enzyme.

CSS селектор

enzyme v3 теперь использует реальный парсер CSS-селекторов, а не собственную неполную реализацию парсера. Это сделано с помощью rst-selector-parser, разветвления scalpel, который является парсером CSS, реализованным с помощью nearley. Мы не считаем, что это должно вызвать какие-либо разрывы между enzyme v2.x и v3.x, но если вы считаете, что обнаружили разрыв, пожалуйста, откройте вопрос.

Результаты CSS селектора и hostNodes()

enzyme v3 теперь возвращает все узлы в наборе результатов, а не только html-узлы. Рассмотрим пример:

const HelpLink = ({ text, ...rest }) => <a {...rest}>{text}</a>;

const HelpLinkContainer = ({ text, ...rest }) => (
  <HelpLink text={text} {...rest} />
);

const wrapper = mount(<HelpLinkContainer aria-expanded="true" text="foo" />);

В enzyme v3 выражение wrapper.find("[aria-expanded=true]").length) вернёт 3, а не 1, как в предыдущих версиях. Более внимательный взгляд с помощью debug показывает:

// console.log(wrapper.find('[aria-expanded="true"]').debug());

<HelpLinkContainer aria-expanded={true} text="foo">
  <HelpLink text="foo" aria-expanded="true">
    <a aria-expanded="true">
      foo
    </a>
  </HelpLink>
</HelpLinkContainer>

<HelpLink text="foo" aria-expanded="true">
  <a aria-expanded="true">
    foo
  </a>
</HelpLink>

<a aria-expanded="true">
  foo
</a>

Чтобы вернуть только html-узлы, используйте функцию hostNodes().

wrapper.find("[aria-expanded=true]").hostNodes().debug() теперь вернёт:

<a aria-expanded="true">foo</a>;

Равенство узлов теперь игнорирует undefined значения

Мы обновили enzyme, чтобы рассматривать "равенство" узлов в семантически идентичном способе, как React обрабатывает узлы. Более конкретно, мы обновили алгоритмы enzyme, чтобы обрабатывать undefined свойства как эквивалентные отсутствию свойства. Рассмотрим следующий пример:

class Foo extends React.Component {
  render() {
    const { foo, bar } = this.props;
    return <div className={foo} id={bar} />;
  }
}

В enzyme v2.x поведение было таким:

const wrapper = shallow(<Foo />);
wrapper.equals(<div />); // => false
wrapper.equals(<div className={undefined} id={undefined} />); // => true

В enzyme v3 поведение теперь таково:

const wrapper = shallow(<Foo />);
wrapper.equals(<div />); // => true
wrapper.equals(<div className={undefined} id={undefined} />); // => true

Методы жизненного цикла

enzyme v2.x имел необязательный флаг, который можно было передать во все вызовы shallow, что делало так, что вызывались больше методов жизненного цикла компонента (например, componentDidMount и componentDidUpdate).

В enzyme v3 этот режим включен по умолчанию, вместо того чтобы сделать его выборочным. Теперь вместо этого можно отключить его. Кроме того, теперь вы можете отключить его на глобальном уровне.

Если вы хотите отключить его глобально, вы можете выполнить следующее:

import Enzyme from 'enzyme';

Enzyme.configure({ disableLifecycleMethods: true });

Это вернёт enzyme к предыдущему поведению на глобальном уровне. Если вместо этого вы хотите отключить enzyme в определённом тесте, вы можете сделать следующее:

import { shallow } from 'enzyme';

// ...

const wrapper = shallow(<Component />, { disableLifecycleMethods: true });

© 2015 Airbnb, Inc.
Licensed under the MIT License.
https://enzymejs.github.io/enzyme/docs/guides/migration-from-2-to-3.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API