Тестування в React

image

Кожен JS-розробник рано чи пізно починає писати тести і шкодувати, що не став робити цього раніше. Оскільки за останні пару років все поступово перейшли до компонентної розробки на основі React, Angular або, наприклад, Vue, це дало черговий поштовх для популяризації тестування, так як компоненти зазвичай малі і тестувати їх набагато простіше. У даній статті ми розглянемо компонентне тестування в React.

Заздалегідь приношу вибачення за те, що в даній статті змушений використовувати англійські терміни. Переклад деяких фраз, які стали стандартом в області тестування, призвів би до втрати розуміння і ускладнення пошуку додаткової інформації.

Почнемо з розгляду утиліт, які необхідні для організації тестування в JS-проекті:

  • Test Runner – утиліта, яка бере файли з нашими тестами, запускає їх і виводить результати тестування. Найбільш популярні утиліти в цій області – Mocha і Karma.
  • Assertion library – бібліотеки з набором функцій для перевірки умов ваших тестів. Chai і Expect – найбільш використовувані бібліотеки з даної галузі.
  • Mock library – бібліотека, яка використовується для створення «заглушок» (mock) при тестуванні. Утиліта дозволяє замінювати пов'язані частині досліджуваного компонента «заглушками», що імітують потрібну поведінку. Тут найбільш популярний вибір – Sinon.
Розглянемо існуючі інструменти тестування для React, і наведемо приклади нескладних тестів з використанням цих інструментів. Відразу скажу, що не буду описувати, як налаштувати збірку проекту, «транспалинг» ES6 та інше: все це при бажанні ви можете вивчити самостійно або знайти потрібні статті на «Хабре». В крайньому випадку — пишіть, і я постараюся вам допомогти.

Також в рамках даної статті ми не підемо по поширеній в галузі тестування шляхом «сліпого» використання найбільш популярних бібліотек, а подивимося на ті, що з'явилися недавно, і постараємося зрозуміти, чи варті вони уваги.

Акт 1
Перше, що нам необхідно – це TestRunner для наших майбутніх тестів. Як і обіцяв, в даному огляді не будуть розглядатися популярні утиліти, такі як Karma або Mocha. Розглянемо новий інструмент: Jest від Facebook. На відміну від Mocha, Jest досить простий в налаштуванні, інтеграції в проект і при цьому досить функціональний. Це молодий проект, який ще рік тому був досить «сумний» у використанні: у ньому відсутні багато чого з необхідною для тестів функціональності, наприклад, не було тестування асинхронних функцій або watch-режиму, який стежив би за змінними файлами. Зараз цей продукт вже неабияк «пожирнел» і може змагатися з такими монстрами, як Mocha або Karma. Крім того, мейнтейнери почали оперативно виправляти дефекти, чого зовсім не вистачало кілька років тому. Отже, давайте поглянемо на те, що вміє Jest:

  • Дивно простий в інтеграції в проект
    Не потрібно ставити десяток дрібних бібліотек та налаштовувати їх взаємодія між собою, так як Jest вже містить у собі все необхідне. Мене зрозуміють ті, хто хоч раз використав популярну зв'язку Karma + Mocha + Sinon для тестів, іншим доведеться повірити мені на слово.

  • Запуск тестів і вивід результатів тестування
    Jest містить досить параметрів для налаштування пошуку і запуску тестів, так що ви завжди зможете налаштувати його для вашого проекту і завдань.

  • Містить assert-бібліотеку, яку, тим не менш, можна замінити будь-який інший
    Jest базується на другий версії бібліотеки Jasmine, так що якщо ви колись працювали з нею, то синтаксис покажеться знайомим. Якщо ж вам не подобається Jasmine, ви можете використовувати свою улюблену бібліотеку.

  • Уміє запускати кожен тест в окремому процесі, прискорюючи тим самим виконання тестів

  • Вміє працювати з асинхронним кодом і тестувати код, який використовує таймери

  • Вміє автоматично створювати «заглушки» для імпортованих компонентів

    По суті, це одна з killer-фіч Jest, але досить складна для налаштування. Саме через неї багато в свій час відмовилися від використання Jest, і в нових версіях вона тепер відключена за замовчуванням.
  • Вміє працювати в інтерактивному watch-режимі

    Jest має досить крутий інтерактивний режим, який дозволяє вам запускати не тільки тести на нові компоненти, але і, наприклад, з останнього коміта в git, останні «провалилися» тести або ж з використанням «патерну» для пошуку по імені.
  • Вміє збирати покриття проекту тестами (coverage)

  • Містить jsdom і, як наслідок, вміє запускати тести без браузера

  • Вміє тестувати компоненти з використанням зліпків (snapshot)
Це далеко не все, що вміє Jest. Більш детально про цю утиліту ви можете прочитати на їх офіційному сайті — facebook.github.io/jest. Тим не менш, Jest також містить і деякі мінуси, які я відзначив для себе:

  • Документація Jest досить «убога», часто доводиться шукати відповідь самому, риючись на github або на stack overflow.

  • Не вміє запускати тести в браузерах
    Так, його плюс є і його ж мінусом. Правда, для мене це зовсім не критичний мінус, т. к. я намагаюся уникати ситуацій, коли відображення може відрізнятися в різних браузерах.

  • Повільно стартує запуск тестів.
Щодо повільного запуску. Розробляє Jest команда постійно вносить поліпшення, прискорюють запуск тестів. Після того, як до них приєднався Dmitrii Abramov, ситуація сильно покращилася, цьому є підтвердження . Тим не менш, за моїми особистими відчуттями, тести, які я писав з використанням Karma + Mocha, все ж стартували і відпрацьовували швидше, ніж написані з використанням Jest. Сподіваюся, з часом хлопці усунуть і цей недолік.

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

function sum(a, b) {
return a + b;
}

Тест для даної функції буде виглядати наступним чином:

describe('function tests', () => {
it('should return 3 arguments for 1 and 2', () => {
expect(sum(1, 2)).toBe(3);
});
});


image

Все просто і знайоме. Давайте тепер ускладнимо нашу функцію, додавши в неї інший виклик функції:

function initial() {
return 1;
}

function sum(a, b) {
return initial() + a + b;
}

Правильно побудований тест для атомарного елемента повинен виключати всі залежно від решти коду. Тому нам треба виключити можливу некоректну роботу функції initial з нашого тесту функції sum. Для цього ми зробимо «заглушку» для функції initial, яка буде повертати потрібне нам значення. Тест у нас вийде наступним:

describe('function tests', () => {
it('should return 4 arguments for 1 and 2', () => {
initial = jest.fn((cb) => 1);
expect(sum(1, 2)).toBe(4);
});
}); 

Тепер давайте ще ускладнимо нашу функцію і припустимо, що, по-перше, наша функція sum повинна бути асинхронної, а по-друге, вона повинна подумати, перш ніж повернути нам потрібний результат:

function initial(salt) {
return 1;
}

function sum(a, b) {
return new Promise((resolve, reject) => {
setTimeout(function(){
resolve({
value: initial(1) + a + b,
param1: a,
param2: b,
});
}, 100);
});
}

Доопрацюємо тест, щоб він враховував зміни:

describe('function tests', () => {
beforeAll(() => {
jest.useFakeTimers();
});

afterAll(() => {
jest.useRealTimers();
});

it('should return 4 arguments for 1 and 2', () => {
initial = jest.fn((cb) => 1);
const result = sum(1, 2);

result.then((result) => {
expect(result).not.toEqual({
value:3,
param1: 1,
param2: 2,
});

expect(initial).toHaveBeCalledWith(1);
});

jest.runTimersToTime(100);

return result;
})
});

У даному тесті ми застосували кілька нових можливостей Jest:

  • По-перше, ми попросили його використовувати fake-таймери, щоб ми самі могли керувати часом
  • По-друге, ми побачили, як тестувати асинхронні функції
  • -третє, ми побачили, як використовувати заперечення і порівняння об'єктів у перевірці тестів
  • В-четвертих, ми побачили, як протестувати, що наша mock-функція викликалася і з потрібними параметрами
Давайте подивимося, як же тестувати React-компоненти. Припустимо, у нас є нескладний компонент, який виводить привітання користувачеві і відловлює клік по винесеному тексту:

export default class Wellcome extends React.Component {
onClick() {
this.props.someFunction(this.props.username);
}

render() {
return (
<div>
<span onClick={this.onClick.bind(this)}>Wellcome {this.props.username}</span>
</div>
);
}
}

Давайте його протестуємо:

import React from 'react';
import TestUtils from 'react-addons-test-utils'
import Wellcome from './welcome.jsx';

describe('<Welcome />', () => {
it('Renders welcome message to user', () => {
const onClickSpy = jest.fn();
const ім'я = 'Alice';

const component = ReactTestUtils.renderIntoDocument(
<Wellcome username= {username} someFunction={onClickSpy} />
);
const span = TestUtils.findRenderedDOMComponentWithTag(
component, 'u'
);

TestUtils.Simulate.click(span);

expect(span.textContent).toBe('Wellcome Alice');
expect(onClickSpy).toBeCalledWith(username);
});
});

Як ми бачимо, нічого складного тут немає. Ми використовуємо React Test Utils для візуалізації нашого компонента і пошуку Dom-вузлів. В іншому, тест нічим не відрізняється від звичайного тесту на Jest.

Отже, ми розглянули, як можна використовувати Jest для створення і запуску тестів, але перш ніж піти далі, давайте трохи зупинимося ще на одній його фиче, а саме – тестуванні на основі зліпків (snapshot). Snapshot-тестування – це можливість зберігати зліпок React-дерева у вигляді JSON-об'єкта і порівнювати його при наступних запусках тесту з цією структурою.

Грубо кажучи, перший раз ви запускаєте тест, щоб сформувати такий зліпок, перевіряєте його валідність руками і коммитите його в репозиторій коду. І – вуаля – все подальші запуски тесту будуть порівнювати ваш зліпок з репозиторію з тим, що вийшло.

Ця фіча з'явилася в Jest зовсім недавно, особисто у мене нововведення викликало змішані почуття. З одного боку, я знайшов їй корисне застосування – деякі тести дійсно стали простіше (там, де немає ніякої інтерактивності і потрібно, по суті, просто перевірити структуру), мені тепер не треба дублювати код в тестах. З іншого боку, я побачив і мінус: тести для мене – це документація мого коду, а тестування на основі зліпків, по суті, дає мені можливість «схалявить» і, не замислюючись про assert-ах, просто порівняти два дерева компонента. Крім цього, даний підхід позбавляє мене можливості класичного TDD, коли я спочатку пишу тести компонента, а потім пишу сам код. Але я думаю, що ця фіча однозначно знайде своїх шанувальників.

Давайте подивимося, як вона працює для нашого компонента:

import React from 'react';
import renderer from 'react-test-renderer';
import Wellcome from './welcome.jsx';

describe('<Welcome />', () => {
it('Renders welcome message to user', () => {
const onClickSpy = jest.fn();
const ім'я = 'Alice';

const component = renderer.create(
<Wellcome username={username} someFunction={onClickSpy} />
);
const json = component.toJSON();

expect(json).toMatchSnapshot();
expect(onClickSpy).toHaveBeCalledWith(username);
});
});

Зазначимо, що конкретно наш тест спростився не сильно (він у нас і так був простий). Для більш об'ємного по структурі компонента тест може скоротитися наполовину і спроститися в рази. Давайте запустимо наш тест і подивимося, що станеться:

image

Отже, jest створив для нас зліпок. Ось що всередині зліпка:

exports[<Welcome /> Renders welcome message to user 1] = `
<div>
<span
onClick={[Function]}>
Wellcome
Alice
</span>
</div>
`;

Зліпок являє собою html-структуру компонента і зручний для валідації «на око».

Крім зазначених вище мінусів, я натрапив на ще один недолік тестування на основі зліпків. Якщо ви використовуєте HOC-компоненти (наприклад, redux-form), зліпок буде містити не досліджуваний вами компонент, а обгортку від redux-form. Тому для тестування саме компонента мені доводиться експортувати його, а також імітувати контракт, який вимагає redux-form.

В принципі, нічого страшного, якщо HOC-компонент у вас один. Але, наприклад, у мене їх може бути в деяких випадках три: один – від react-redux, другий – від redux-from і третій – від react-intl. З останнім, до речі, досить важко тестувати код, так як просто обкласти «заглушками» компонент не вийде, потрібно підкласти компоненту чесний API локалізації. Як це робиться, ви можете побачити тут.

Підіб'ємо підсумок. Тепер у нас є все, що потрібно для запуску тестів наших компонентів. Але давайте ще раз подивимося, як можна спростити і поліпшити наші тести.

Акт 2
Вперше замислившись про тестування компонентів React і почавши шукати інформацію про те, як це зробити, ви, швидше за все, натрапите на пакет тестових утиліт React Test Utilites. Даний пакет розроблений командою Facebook і дозволяє писати тести компонентів. Цей пакунок надає наступні можливості:

  • Рендеринг компонента в DOM
  • Симуляція подій для DOM-елементів
  • «Mock-інг» компонентів
  • Пошук елементів в DOM
  • Поверхневий (shallow) рендеринг компонента
Як ми бачимо, досить широкий набір можливостей, достатній для написання тестів для будь-яких компонентів. Приклад того, як виглядав би наш тест з використанням React Test Utilites, ми розбирали в попередньому розділі:

import React from 'react';
import TestUtils from 'react-addons-test-utils'
import Wellcome from './welcome.jsx';

describe('<Welcome />', () => {
it('Renders welcome message to user', () => {
const onClickSpy = jest.fn();
const ім'я = 'Alice';

const component = ReactTestUtils.renderIntoDocument(
<Wellcome username= {username} someFunction={onClickSpy} />
);
const span = TestUtils.findRenderedDOMComponentWithTag(
component, 'u'
);

TestUtils.Simulate.click(span);

expect(span.textContent).toBe('Wellcome Alice');
expect(onClickSpy).toBeCalledWith(username);
});
});

Але ми не підемо «стандартним шляхом» і не станемо використовувати для наших тестів React Test Utilites з кількох причин. По-перше, у даної бібліотеки дуже мізерна документація, і для того, щоб розібратися з нею, новачкові доведеться активно скористатися пошуком відповідей в Інтернеті. По-друге, сама «смачна» для нас фіча shallow візуалізації компонентів вже давно знаходиться в експериментальній стадії і ніяк з неї не виходить. Замість цього ми скористаємося чудовою бібліотекою Enzyme, яка була розроблена командою Arbnb і вже стала досить популярною при тестуванні React-додатків. По суті, Enzyme – це бібліотека, яка є надбудовою над трьома іншими бібліотеками: React TestUtils, JSDOM і CheerIO:

  • TestUtils – бібліотека, створена Facebook для тестування React-компонентів
  • JSDOM – це JS-реалізація DOM. Дозволяє емулювати браузер
  • CheerIO – аналог Jquery для роботи з DOM-елементами
Об'єднавши всі разом і трохи доповнивши, Enzyme дозволяє просто і зрозуміло будувати тести для React-компонентів і, крім функціональності Test Utilites, також дає нам:

  • Три варіанти візуалізації компонента: shallow, mount і render
  • Jquery-подібний синтаксис для пошуку компонентів
  • Пошук компонента, використовуючи ім'я компонента (тільки якщо ви задали це ім'я через параметр DisplayName)
  • Пошук компонента, використовуючи значення його параметрів (props)
Так, Enzyme не містить в собі TestRunner, а також не вміє робити «заглушки» для компонентів, але для цього у нас вже є Jest.

Давайте детальніше розглянемо три варіанти візуалізації компонента і те, що нам це дає. Отже, в Enzyme є три методи, які рендерят компонент і повертають схожі обгортки з набором методів в стилі Jquery:

  • Поверхневий (shallow) рендеринг – Enzyme отрендерит тільки сам компонент, ігноруючи рендеринг вкладених в нього компонентів
  • Full Dom Rendering – повний рендеринг компонента і всіх його структурі компонентів
  • Static рендеринг – рендерить статичні HTML для переданого компонента
Я не стану наводити весь перелік методів, які дає нам Enzyme, скажу лише, що з його допомогою ви зможете:

  • Знаходити компоненти або DOM-елементи
  • Порівнювати вміст компонента з JSX-розміткою
  • Перевіряти властивості компонента
  • Оновлювати стан компонента
  • Емулювати події
Це далеко не всі можливості Enzyme. Повний список ви зможете знайти в документації бібліотеки, а ми сконцентруємо увагу на відмінностях між трьома видами рендеринга.

Що ж дає нам shallow-рендеринг і в чому його принади? А він дає нам можливість сконцентруватися при тестуванні тільки на самому компоненті і не думати про вкладених компонентах. Нам абсолютно неважливо, як буде змінюватися структура, яка видається вкладеними компонентами: це ніяк не повинно зламати нам наші тести. Таким чином, ми можемо тестувати наш компонент ізольовано від інших компонентів. Але це не означає, що ми зовсім не тестуємо вкладені компоненти. Ні, ми можемо перевірити в тестах, що правильно передаємо властивості під вкладені компоненти. Крім цього, ще один плюс такого тестування полягає в тому, що швидкість виконання таких тестів набагато вище, ніж при використанні Full Dom-візуалізації, так як не вимагає наявності DOM. Але на жаль, не завжди ми можемо використовувати тільки поверхневий рендеринг. Наприклад, нехай крім використовуваного нами компонента Wellcome у нас є ще компонент Home з наступним вмістом:

import React, { PropTypes, Component } from 'react'
import Wellcome from './Wellcome'

class Home extends Component {
onChangeUsername(e) {
this.props.changeUsername(e.target.value);
}

render() {
return (
<section className='home'>
<h1>Home</h1>
<Wellcome username={this.props.username} />
<input
type="text"
name="username"
value={this.props.username}
onChange={this.onChangeUsername.bind(this)}
/>
</section>
)
}
}

Home.propTypes = {
changeUsername: PropTypes.func.isRequired
}

export default Home

Давайте напишемо тепер тест для даної функції:

import React from 'react'
import { shallow } from 'enzyme'
import Home from './Home'
import Wellcome from './Wellcome';

describe('<Home />', () => {
it('should render self and Wellcome', () => {
const renderedComponent = shallow(
<Home username={'Alice'} changeUsername={jest.fn()} />
);

// Виведемо отрендеренный компонент
console.log(renderedComponent.debug());

expect(renderedComponent.find('section').hasClass('home')).toBe(true);
expect(renderedComponent.find('h1').text()).toBe('Home');
expect(renderedComponent.find('input').length).toBe(1);

expect(renderedComponent.find(Wellcome).props().username).toBeDefined();
expect(renderedComponent.contains(<Wellcome username={'Alice'} />)).toBe(true);
});

it('should call changeUsername on input changes', () => {
const changeUsernameSpy = jest.fn();

const renderedComponent = shallow(
<Home username={'Alice'} changeUsername={changeUsernameSpy}
);

renderedComponent.find('input').simulate('change', { target: { value: 'Test' } });
expect(changeUsernameSpy).toBeCalledWith('Test');
});
});

Для того, щоб побачити, як в Enzyme працює shallow-рендеринг, скористаємося функцією debug і подивимося, що нам виведе наступний код.

<section className="home">
<h1>
Home
</h1>
<Wellcome username="Alice" />
<input type="text" name="username" value="Alice" onChange={[Function]} />
</section>

Як ми бачимо, Enzyme отрендерил наш компонент, але не став рендери вкладені компоненти. Тим не менш, він сформував правильні параметри для них і ми можемо їх перевіряти при необхідності.

Тепер давайте розберемо варіант, коли нам не підходить поверхневий рендеринг і може знадобитися використовувати повний рендеринг через виклик методу mount. А поверхневий рендеринг не підійде нам, якщо:

  • наш компонент містить логіку в Lifecycle-методах, наприклад таких, як componentWillMount і componentDidMount. Ми, звичайно, можемо викликати їх вручну, але не впевнений, що це завжди хороший спосіб;
  • наш компонент повинен взаємодіяти з DOM. Справа в тому, що поверхневий рендеринг не використовує JSDOM і всі взаємодія з DOM просто не буде працювати;
  • нам потрібно перевірити інтеграцію декількох компонентів між собою – наприклад, ми використовуємо компонент створення ToDo і компонент списку ToDo на одній сторінці
У цих випадках нам доведеться використовувати mount замість shallow, який, на жаль, зробить наші тести повільніше, так як йому вже необхідні DOM і завантаження бібліотеки Jsdom. Отже, наводжу приклад, коли нам потрібен повний рендеринг:

import React, { PropTypes, Component } from 'react'
import Wellcome from './Wellcome'

class Home extends Component {
componentWillMount() {
this.props.fetchUsername();
}

onChangeUsername(e) {
this.props.changeUsername(e.target.value);
}

render() {
return (
<section className='home'>
<h1>Home</h1>
<Wellcome username={this.props.username} />
<input
type="text"
name="username"
value={this.props.username}
onChange={this.onChangeUsername.bind(this)}
/>
</section>
);
}
}

Home.propTypes = {
changeUsername: PropTypes.func.isRequired,
fetchUsername: PropTypes.func,
};

export default Home;

І наш тест:

import React from 'react'
import { mount } from 'enzyme'
import Home from './Home'
import Wellcome from './Wellcome';

describe('<Home />', () => {
it('should fetch username on mount', () => {
const fetchUsernameSpy = jest.fn(cb => 'Aliсe');
const renderedComponent = mount(
<Home
username={'Aliсe'}
changeUsername={jest.fn()}
/>
);

// Виведемо отрендеренный комонент
console.log(renderedComponent.debug());

expect(fetchUsernameSpy).toBeCalled();
})
})

Давайте подивимося, що нам повернув виклик debug:

<Home username="Alise" changeUsername={[Function]} fetchUsername={[Function]}>
<section className="home">
<h1>
Home
</h1>
<Wellcome username="Aliсe">
<div>
<span onClick={[Function]}>
Wellcome
Alise
</span>
</div>
</Wellcome>
<input type="text" name="username" value="Aliсe" onChange={[Function]} />
</section>
</Home>

Як ми бачимо, при повному рендерінгу Enzyme отрендерил ще і вкладені компоненти, а також запустилися всі методи LifeCycle-компонента.

Нам залишилося розглянути останній тип візуалізації, який є в Enzyme: static-рендеринг. Він рендерить компонент в HTML-рядок, використовуючи бібліотеку Cherio, і повертає нас назад об'єкт, який схожий на той, що нам віддають shallow і mount-методи. Тест буде відрізнятися від попереднього тільки заміною виклику mount на render, тому його, якщо що, ви зможете написати самі.

Я бачу тільки одне застосування даного методу, коли необхідно проаналізувати тільки HTML-структуру компонента і важлива швидкість роботи тесту. При використанні статичного візуалізації тест відпрацює швидше, ніж при використанні повного рендеринга. Інших застосувань даного типу візуалізації я не знайшов.

Антракт
Отже, в даній статті ми розглянули тестування React-компонентів, «помацав» нові утиліти, використовувані для тестування, і зібрали готовий «комбайн» для створення тестів. Якщо дана тема буде цікава, то в наступних статтях ми спробуємо серйозно протестувати більш складний додаток, що використовує redux, redux-saga, react-intl, модальні віконця і інші елементи, що ускладнюють тестування.

Зелених вам тестів і попутного 100%-го покриття!
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.