В ClassDojo наши интерфейсные веб-приложения представляют собой большие одностраничные приложения, работающие в React и Redux. Миллионы учителей, родителей и учеников используют эти приложения каждый день, и мы хотим обеспечить им качественный опыт. Однако эффективно тестировать интерфейсные приложения сложно.

У нас есть большое количество модульных тестов, но мы обнаружили, что, если не считать нескольких сложных компонентов с большим количеством взаимодействий с отслеживанием состояния, эти тесты утомительны для написания и обычно неэффективны для предотвращения ошибок. Это связано с тем, что большинство ошибок нашего интерфейса возникают на границе между компонентами или уровнями нашего приложения — например, между API и уровнем данных, уровнем данных и нашими компонентами или между родительским и дочерним компонентами. Эти границы явно не проверяются стандартными модульными тестами, которые имитируют внешние зависимости. Всегда очень неприятно обнаруживать, что изменение API или рефакторинг сломали наше приложение, когда все наши модульные тесты продолжают проходить.

Чтобы решить эту проблему, мы решили заняться интеграционным тестированием наших интерфейсных приложений. Стандартный способ сделать это с помощью инструмента автоматизации браузера, такого как Selenium. Однако об этих инструментах ходят легенды из-за их сложности использования и ненадежности. Тесты, написанные таким образом, хрупкие, ломаются при изменении имен классов или если вызовы API или рендеринг занимают слишком много времени. Мы хотели более простой и эффективный метод. К счастью, React и Redux предоставили нам инструменты для этого.

Трудности

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

Например, рассмотрим процесс тестирования процесса регистрации, чтобы убедиться, что на каждом этапе на экране отображается правильная информация. Чтобы правильно протестировать этот поток, мы хотели бы иметь возможность проверять то, что было отрисовано в нескольких точках: начальное состояние, состояние после ввода информации, состояние после того, как пользователь нажимает «зарегистрироваться», и состояние после успешного выполнения запроса. или терпит неудачу. Если единственный ввод, который мы получаем, — это то, что отображается на экране, трудно понять, какие утверждения мы должны делать и в какое время.

Наш метод

Наши интеграционные тесты состоят из нескольких компонентов: инициализация и рендеринг приложения, отслеживание запросов API, написание многоэтапных тестов и управление маршрутизатором. Я рассмотрю каждый из них по очереди.

Фермент, Мокко и JSDom

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

Мы создаем и монтируем новый экземпляр приложения перед каждым тестом, чтобы удалить любое накопленное состояние. К счастью, Redux делает это очень просто, поскольку создание нового хранилища является стандартной практикой при создании корневого компонента вашего приложения. Однако, если в вашем приложении есть какие-либо одноэлементные модули, вам нужно будет сбросить их вручную.

Метод Enzyme mount требует, чтобы объекты window и document были доступны в глобальной области видимости. Хотя это можно сделать, запустив тесты в реальном браузере (например, используя Karma и PhantomJS), для этого потребуется запускать Webpack для компиляции и сборки нашего приложения перед каждым запуском теста. Это хлопотно и требует много времени.

Вместо этого мы используем JSDom для создания фиктивного документа и запускаем наши тесты в Node, используя Mocha. Обратите внимание, что на самом деле мы ничего не монтируем в фиктивном документе; это просто для предоставления объектов document и window. А babel-register позволяет Mocha запускать тот же скомпилированный код, который Webpack встроит в наше приложение. Несмотря на то, что есть некоторые различия между узлом и браузером даже после прохождения через компилятор Babel, Webpack учитывает большинство из них, а разумное использование блоков if (__TESTING__) может справиться с остальными. До сих пор у нас не было серьезных проблем из-за несоответствия среды тестирования/браузера.

Ниже приведен пример кода инициализации для Enzyme и JSDom:

global.document = require("jsdom").jsdom("<!doctype html><html><body></body></html>");
global.window = document.defaultView;
global.navigator = global.window.navigator;
// Both these must be required after a DOM is initialized. const mount = require("enzyme").mount;
const React = require("react");

Отслеживание вызовов API

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

let callsInProgress = 0;
let callbacks = [];

// When making API calls, this function should be called instead of directly
// calling `window.fetch`, `superagent`, or whatever other library you use.
export function makeTrackedApiCall (params) {
  callsInProgress += 1;

  // assume this delegates to your API call library.
  return makeApiCall(params).finally(() => callsInProgress -= 1);
}

// This function can be called in your tests in order to wait for all API calls
// to finish before continuing. If no API calls are in progress, it immediately
// returns a resolved promise. Otherwise, it returns a promise that will be
// resolved after all calls have finished - see below.
export function waitForApiCallsToFinish () {
  if (callsInProgress === 0) {
    return Promise.resolve();
  } else {
    return new Promise((resolve) => callbacks.push(resolve));
  }
}

// In a standard redux app, the results of any API call will be dispatched
// in an action. Using a middleware similar to the one below, we can check
// after each action is dispatched to see if we've finished all API calls.
// We mount this middleware only in testing.
export function resolveAPICallbacksMiddleware () {
  return (next) => (action) => {
    // After this line, the store and views will have updated with any changes.
    // If we don't have any API calls outstanding at this point, it's safe to
    // say that we can continue with our tests.
    next(action);

    // `setTimeout` is in case the any code that runs as a result of this
    // action dispatches another API call.
    setTimeout(() => {
      if (callsInProgress === 0) {
        callbacks.forEach((callback) => callback());
        callbacks = [];
      }
    }, 0);
  }
}

мокко-шаги

Интеграционные тесты часто бывают длинными и требуют ряда шагов, каждый из которых зависит от предыдущих результатов. Чтобы упростить написание этих тестов, мы использовали библиотеку mocha-steps, которая позволяет вам писать тесты, которые останавливаются, если какой-либо шаг завершается неудачей. Пример теста будет выглядеть так:

// this imports `step` as a global, similar to `it` and `describe`
import "mocha-steps";

describe("My test", function() {

  step("My first step", function() {
    // Do stuff, assert stuff. Can return a promise for async steps.
  });

  step("Oops!", function() {
    throw new Error("Something went wrong.");
  });

  step("This step will never be run", function() {
    // ...nor will its output be displayed in the console.
  });

});

Маршрутизация

Если вы используете React-Router для управления навигацией вашего приложения, поздравляем! Переходы между страницами будут работать нормально. Если вы хотите установить страницу вручную (например, в начале теста), вам нужно сделать следующее:

import {browserHistory} from "react-router";

browserHistory.replace("/myPage");

Обратите внимание, что API React-Router существенно изменился между версиями; мы используем v3, поэтому, если вы используете другую версию, имена методов могут быть другими.

API

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

Собираем все вместе

Мы монтируем наше приложение с помощью Enzyme, Mocha и JSDom, устанавливаем начальный маршрут с помощью react-router и сбрасываем базу данных нашего API перед каждым тестом. Мы выполняем серию тестовых шагов, используя mocha-steps. Когда шагу нужно дождаться завершения вызова API, он просто вызывает функцию waitForApiCallsToFinish и ждет разрешения этого промиса. Мы используем Enzyme, чтобы инициировать события и делать утверждения о том, что показывается.

Пример теста

Вот пример теста — в данном случае для регистрирующегося пользователя. Код относительно длинный, но его довольно легко читать и писать.

import "expect.js";
import "mocha-steps";
import {browserHistory, getCurrentLocation} from "react-router"

import getAppRootInstance from "app/root";

describe("Signing up works", function () {

  step("Set page to signup", function() {
    // We do this before initializing the app, which simulates the user
    // entering this URL into the address bar.
    browserHistory.replace("/signup");
  });

  let appRoot;
  step("Initialize app", function() {
    // Assume this function initializes an instance of the app and returns
    // it to you. We can then make assertions about that instance
    return getAppRootInstance().then((root) => appRoot = root);
  });

  step("Assert submit button is disabled", function() {
    // We've found making assertions about the props of well-tested low-level
    // components is usually fine. However, we consider it bad practice to make
    // assertions about higher-level components - instead, make assertions
    // about the lower-level things that they render.
    const signupButton = appRoot.find("#signupButton");
    expect(signupButton.props().disabled).toBe(true);
  });

  step("Fill in email and password", function() {
    const emailInput = appRoot.find("#emailInput");
    const passwordInput = appRoot.find("#passwordInput");
    emailInput.simulate("change", {target: {value: "[email protected]"}});
    passwordInput.simulate("change", {target: {value: "hunter2"}});
  });

  step("Click the submit button", function() {
    const signupButton = appRoot.find("#signupButton");
    expect(signupButton.props().disabled).toBe(false);
    signupButton.simulate("click");
  })

  step("Loading state should be shown", function() {
    // At this time, the signup request has been submitted, but has not
    // returned yet. We can make assertions about the loading state.
    const loadingIndicator = appRoot.find("#loadingIndicator");
    expect(loadingIndicator.exists()).toBe(true);
  });

  step("Wait for signup request to succeed", function() {
    // This returns a promise, need to return it to mocha. Once it resolves,
    // the signup request will have finished.
    return waitForApiCallsToFinish();
  });

  step("Assert we are on the welcome page and can see the tour", function() {
    // The signup request has finished, and all state changes and rerenders
    // that result from this are completed. We can make assertions about the
    // success state.
    const currentRoute = getCurrentLocation().pathname;
    expect(currentRoute).toBe("/welcome");

    const tour = appRoot.find("#tour");
    expect(tour.exists()).toBe(true);
  });

});

Недостатки

Хотя наши интеграционные тесты добились большого успеха, мы обнаружили несколько недостатков, которые они действительно имеют.

Во-первых, поскольку мы используем JSDom в качестве нашего документа, у нас нет возможности тестировать в нескольких браузерах. К счастью, сгенерированный код Babel обычно работает во всех браузерах, которые мы поддерживаем (IE9+), и вы можете использовать eslint, чтобы не использовать неподдерживаемые методы браузера.

Во-вторых, такой подход к внешнему тестированию не выявляет визуальные ошибки в вашем CSS или HTML. Для проверки визуальных изменений необходим другой подход.

В-третьих, побочные эффекты, не связанные с API, могут представлять некоторые трудности. Наш обернутый модуль API позволяет тестам ожидать завершения вызовов. Однако для тестирования других асинхронных побочных эффектов — например, записи в локальное хранилище — нам потребуется написать аналогичную оболочку, позволяющую дождаться завершения эффекта.

Выводы

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

Первоначально опубликовано на сайте engineering.classdojo.com 12 января 2017 г.