Автор Мэттью Вуд

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

При кодировании приложения важно иметь тесты как часть базы кода. Мартин Фаулер предложил метафору, которая говорит нам о различных типах тестов и о том, сколько каждого из них должно быть в проекте. Это называется пирамида тестирования:

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

Тесты должны:

  1. Спешите бежать.
  2. Быть простым в реализации.
  3. Иметь возможность охватывать все приложение React, не полагаясь на сервер.

Нашим первым действием был краткий обзор лучших инструментов для использования, и, вдохновленные отличным постом в блоге под названием Интеграционное тестирование React с Enzyme, мы остановились на Enzyme. Enzyme — это утилита для тестирования Javascript для React, которая может отображать компоненты React в виртуальную модель DOM. Затем мы можем взаимодействовать с DOM и утверждать, что наши визуализированные компоненты работают должным образом. Как правило, Enzyme используется для модульного тестирования путем поверхностного рендеринга одного компонента в изоляции, но он также позволяет нам полностью рендерить компоненты, включая все вложенные компоненты, с помощью функции монтирования. Поскольку все в React является компонентом, ничто не мешает нам смонтировать компонент верхнего уровня и отрисовать все приложение в памяти. Затем мы можем использовать Enzyme API для взаимодействия с нашим приложением любым способом.

Выполнение

Давайте представим себе упрощенную версию процесса печати отгрузочных этикеток на eBay. Наше приложение будет состоять из двух страниц. На странице 1 мы загрузим с сервера два возможных сервиса-перевозчика и отобразим их пользователю. Пользователь выберет перевозчика, которым он хочет, чтобы его посылка была доставлена, введет адрес назначения и нажмет «Отправить». Это вызовет запрос к серверу, содержащий данные ввода пользователя. Затем мы вернем ссылку на этикетку доставки и отобразим ее на новой странице (страница 2). Затем пользователь может открыть транспортную этикетку, распечатать ее и наклеить на посылку.

Насмешка над сервером

Чтобы изолировать приложение React, нам нужно имитировать запросы и ответы с сервера. Используя библиотеку fetch-mock, мы смогли установить наши макеты следующим образом.

1  import fetchMock from 'fetch-mock'
2  
3  class MockApi {
4    mockGetCarriers () {
5      fetchMock.get('/', carrierResponse)
6    }
7  
8    mockSubmitDetails () {
9      fetchMock.post('/label', labelResponse)
10   }
11 }
12  
13 export const mockApi = new MockApi()
14
15 const labelResponse = {
16   labelUrl: 'www.example.com/123456789'
17 }
18 const carrierResponse = {
19   carrier: [
20     {
21       name: 'Carrier 1',
22       rating: '5/10'
23     },
24     {
25       name: 'Carrier 2',
26       rating: '7/10'
27     }
28   ]
29 }

Перед каждым тестом в нашем интеграционном тесте мы настраиваем наши фиктивные вызовы на сервер.

1 beforeEach(() => {
2   mockApi.mockGetCarriers()
3 })

Наше приложение может выполнять два возможных запроса. Один для получения перевозчиков при загрузке приложения, а другой для отправки данных пользователя и получения ссылки на ярлык. Блок beforeEach должен отвечать за каждый вызов, необходимый для перевода приложения в первое пригодное для использования состояние. В нашем случае это запрос на получение перевозчиков. Любые другие фиктивные вызовы, которые могут потребоваться оттуда (наш запрос на отправку сведений), должны пройти отдельный тест. Причина этого в том, что не всем тестам требуется запрос на отправку сведений (например, интеграционные тесты только на странице сведений), поэтому вызов не следует настраивать, если он не требуется.

После каждого теста мы хотим чистую среду. Это означает очистку любых имитированных запросов к серверу.

1 afterEach(async() => {
2   fetchMock.restore()
3 })

Настройка

Для контекста вот как будет выглядеть окончательный интеграционный тест:

1  integrationTest('should be able to click through app and get a link to a label', async (app) => {
2    mockApi.mockSubmitDetails()
3
4    const detailsPage = await app.detailsPage()
5    expect(detailsPage.getListOfCarriers().length).toEqual(2)
6    detailsPage.selectFirstCarrier()
7    detailsPage.enterDestinationAddress()
8    detailsPage.clickSubmitButton()
9
10   const labelPage = await app.labelPage()
11   expect(labelPage.getLabelLink().prop('href'))
12     .toEqual('www.example.com/123456789')
13 })

Чтобы сделать тест понятным с первого взгляда, мы разделили логику теста на три уровня: уровень 1 — это интеграционный тест, показанный выше, уровень 2 — это основная настройка теста, а уровень 3 — это прямое взаимодействие с Enzyme. API. Давайте сначала посмотрим на уровень 2, IntegrationTest.js.

1  const asyncFlush = () => new Promise(resolve => setTimeout(resolve, 0))
2  const MAX_FLUSHES_TO_WAIT_FOR = 10 // maximum number of callbacks to wait for chain to complete
3  export async function successFrom (checkerFunction) {
4    let flushCount = 0
5    while (!checkerFunction()) {
6      await asyncFlush()
7      if (flushCount++ > MAX_FLUSHES_TO_WAIT_FOR) {
8        throw new Error('Timeout awaiting async code')
9      }
10   }
11 }
12
13 export class TestHelper {
14   constructor () {
15     const dom = document.createElement('div')
16     const screen = mount(<App />, {attachTo: dom})
17     this.updatedScreen = () => screen.update()
18   }
19
20   async detailsPage () {
21     const detailsPageHelper = new DetailsPageHelper(this.updatedScreen())
22     await successFrom(() => detailsPageHelper.getListOfCarriers().length > 0)
23     return detailsPageHelper
24   }
25
26   async labelPage () {
27     const labelPageHelper = new LabelPageHelper(this.updatedScreen())
28     await successFrom(() => labelPageHelper.getLabelLink().length > 0)
29     return labelPageHelper
30   }
31 }
32
33 export default function integrationTest (name, callback) {
34   test(name, () => {
35     const app = new TestHelper()
36     return callback(app)
37   })
38 }

Здесь много всего происходит, так что давайте пройдёмся по ним. Точкой входа в этот файл является экспортированная функция IntegrationTest в строке 33 выше. Это создаст объект TestHelper. При создании этого объекта наше приложение монтируется с помощью Enzyme и связывается с экземпляром объекта. Одна вещь, которую мы заметили, это то, что Enzyme DOM не всегда представлял самое последнее состояние DOM. Это связано с обновлениями, которые не являются прямым результатом взаимодействия с Enzyme, например, обновление хранилища Redux из ответа сервера. В качестве решения, всякий раз, когда нам нужно получить доступ к DOM, мы всегда возвращаем обновленную версию, как показано в строке 17:

13 export class TestHelper {
14   constructor () {
15     const dom = document.createElement('div')
16     const screen = mount(<App />, {attachTo: dom})
17     this.updatedScreen = () => screen.update()
18   }

Это не очень дорогая операция, и она гарантирует, что мы всегда тестируем самое актуальное состояние DOM. В дополнение к монтированию приложения наш класс TestHelper также предоставляет доступ к нашим помощникам по тестированию страницы. Я коснусь их немного позже.

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

3  export async function successFrom (checkerFunction) {
4    let flushCount = 0
5    while (!checkerFunction()) {
6      await asyncFlush()
7      if (flushCount++ > MAX_FLUSHES_TO_WAIT_FOR) {
8        throw new Error('Timeout awaiting async code')
9      }
10   }
11 }

Функция asyncFlush:

1  export const asyncFlush = () => new Promise(resolve => setTimeout(resolve, 0))

Предпосылка заключается в том, что мы можем загрузить приложение, запустить начальные запросы и дождаться выполнения определенного условия на нашей первой странице. Условием для страницы сведений является то, что список перевозчиков содержит более 0 перевозчиков, показанных в строке 22, а условием для страницы этикетки является наличие ссылки на этикетку, показанной в строке 28:

20   async detailsPage () {
21     const detailsPageHelper = new DetailsPageHelper(this.updatedScreen())
22     await successFrom(() => detailsPageHelper.getListOfCarriers().length > 0)
23     return detailsPageHelper
24   }
25
26   async labelPage () {
27     const labelPageHelper = new LabelPageHelper(this.updatedScreen())
28     await successFrom(() => labelPageHelper.getLabelLink().length > 0)
29     return labelPageHelper
30   }
31 }

Функция SuccessFrom работает, постоянно добавляя и разрешая функции в очереди сообщений Javascript. Когда это возвращается, мы знаем, что цикл событий был запущен полностью один раз. Мы надеемся, что это будет означать, что мы выполнили обещание из поддельного запроса на выборку серверу. Если нет, мы будем продолжать сбрасывать до тех пор, пока переданный обратный вызов не вернет true, или мы не достигнем максимального количества сбросов и не потерпим неудачу.

Давайте теперь посмотрим на слой 3. До сих пор я показывал, как наш интеграционный тест монтирует наше приложение и ждет, пока оно перейдет в состояние, готовое к тестированию. Отсюда мы можем начать выполнять действия над нашим приложением, используя Enzyme API. Мы настраиваем это с помощью хелперов страниц. Помощник страницы просто переводит действие высокого уровня во взаимодействие с Enzyme API, сохраняя наши тесты, описывающие поведение, и освобождая от деталей реализации тестируемого приложения. Ниже показан наш файл DetailsPageHelper.js.

1  export class DetailsPageHelper {
2    constructor (screen) {
3      this.updatedScreen = () => screen.update()
4    }
5
6    getListOfCarriers () {
7      return this.updatedScreen().find('#carrierCheckbox')
8    }
9
10   selectFirstCarrier () {
11     this.getListOfCarriers().at(0).simulate('change', { target: { checked: true } })
12   }
13
14   enterDestinationAddress () {
15     this.updatedScreen().find('#destinationAddressInput').simulate('change', { target: { value: '123 Address Road' } })
16   }
17
18   clickSubmitButton () {
19     this.updatedScreen().find('#submit').simulate('click')
20   }
21 }

Интеграционное тестирование

Теперь мы знаем всю необходимую информацию для запуска интеграционного теста:

1  integrationTest('should be able to click through app and get a link to a label', async (app) => {
2    mockApi.mockSubmitDetails()
3
4    const detailsPage = await app.detailsPage()
5    expect(detailsPage.getListOfCarriers().length).toEqual(2)
6    detailsPage.selectFirstCarrier()
7    detailsPage.enterDestinationAddress()
8    detailsPage.clickSubmitButton()
9
10   const labelPage = await app.labelPage()
11   expect(labelPage.getLabelLink().prop('href'))
12     .toEqual('www.example.com/123456789')
13 })

Сначала мы настроим любые дополнительные фиктивные вызовы, необходимые для теста. В этом случае он издевается над вызовом отправки сведений. Затем мы ждем, пока загрузится страница, показанная в строке 4. Затем взаимодействия выполняются в строках 6, 7 и 8, а затем мы ждем, пока будет готова следующая страница (страница меток). Причина, по которой нам снова нужно ждать, заключается в том, что наше действие отправки на странице сведений отправляет асинхронный запрос на сервер и ожидает ответа. Если бы мы не ждали здесь, тест немедленно попытался бы получить ссылку на метку до того, как сервер ответил. Если бы между двумя страницами не было запроса к серверу, мы могли бы продолжить тест, не дожидаясь условия успеха. Затем мы делаем окончательное утверждение в строке 11, чтобы убедиться, что у нас есть ссылка на метку.

Вывод

Это основы, необходимые для создания интеграционных тестов, которые охватывают все приложение React с использованием Enzyme. Существуют ограничения, которые необходимо учитывать при использовании предлагаемого формата, одним из которых является способ настройки фиктивного API. Все ответы в одном файле. Если бы мы добавили второй тест, ответы сервера были бы такими же, как и в первом тесте. Если бы нам требовался другой ответ для каждого теста, нам нужно было бы подумать о масштабируемом способе управления всеми данными ответов и о том, как связать соответствующие ответы с соответствующими тестами. Запись ответов в сложном приложении также может быть довольно утомительной. Другим ограничением является структура помощника страницы. Большинство приложений React, над которыми мы работаем, состоят из 2–4 страниц. Для приложения со значительным количеством страниц представленная выше структура может оказаться неприемлемой.

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

Первоначально опубликовано на https://www.ebayinc.com 26 июня 2018 г.