В этой статье мы подробно рассмотрим создание настройки тестирования, которая:

  1. Работает каждый раз «с чистого листа», но все же работает быстро за счет параллельного выполнения тестов.
  2. Тестирует приложение, моделируя действия пользователя, вместо тестирования вывода отдельных функций и методов с фиктивными данными.
  3. Достаточно мощный, чтобы протестировать каждый уголок приложения, включая части, требующие входа в систему.
  4. Расширяемый, но достаточно лаконичный для облегчения обслуживания или адаптации новых разработчиков.

А также:

  1. Напишите класс-оболочку и фабричные функции, серьезно относясь к принципу DRY.
  2. Изучите законный пример использования ES6 Proxy / Reflect в реальной жизни.
  3. Создайте простое массовое тестирование маршрутов, которое будет выполняться параллельно с Promise.all

Используемые пакеты:

  • Jest - как тестовая библиотека для запуска наборов тестов.
  • Кукловод - как способ запустить браузер Chrome без головы.
  • Keygrip - для подписи некоторых файлов cookie.

Приложение, которое мы тестируем:

  • «Частный блог» - стековое приложение MERN, созданное с помощью Mongo / Mongoose, Express, React, Node.
  • Паспорт и Google OAuth как средство аутентификации / авторизации.

Полноценное рабочее приложение можно найти на моем GutHub.

Базовая настройка Jest

Начнем с обычного

npm install jest puppeteer

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

Создайте два файла, которые будут содержать конфигурацию и установку jest. Нам нужно будет создать несколько записей БД. Мы поговорим об этом позже. Для нашей текущей цели нам понадобится доступ к соединению с БД. Содержимое установочного файла будет запущено до начала тестирования, и мы можем запустить любой произвольный код, который нам нужен для подготовки. Это делает его идеальным местом для получения подключений к БД и переменных окружения. Кроме того, здесь можно использовать любые шутливые способы настройки. jest будет автоматически импортирован как глобальный объект.

Чтобы указать jest сначала загрузить эти файлы, а затем запустить тесты, нам нужно будет добавить новый скрипт в package.json.

"test": "jest --config=./tests/jest-config.js --forceExit"

Последний параметр, --forceExit, включен сюда, чтобы гарантировать, что jest завершится, когда тесты будут завершены, а процесс простаивает. В этом случае это может не произойти само по себе, потому что мы будем использовать соединение с БД во время теста, и оно может отображаться как зависшее после завершения всех наших тестов, что может привести к тому, что процесс не завершится. Альтернативным решением было бы закрыть соединение, но я считаю, что в этом случае проще использовать параметр. Правильный выход из процесса не имеет большого значения, когда мы запускаем тестирование вручную на локальном компьютере, но если мы используем непрерывную интеграцию, то это заставит CI-сервер ждать, пока не истечет время ожидания процесса. Это заставит CI подождать несколько минут без причины, прежде чем сообщить нам, что сборка прошла.

После этой быстрой настройки мы готовы написать наш первый тест. Jest автоматически найдет файлы с расширением .test.js и попытается запустить их. Поэтому нам здесь больше ничего делать не нужно, и мы должны ожидать, что тестирование начнется после npm run test

Модульные тесты против интеграционных тестов

Прежде чем мы двинемся дальше, я хотел бы отметить, что существуют разные типы тестов. Одними из самых известных являются модульные тесты, интеграционные тесты и сквозные (e2e) тесты.

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

Для этого мы обычно:

  • Создайте фиктивные входные данные и ожидаемые результаты
  • Передайте данные функции или компоненту
  • Убедитесь, что результат соответствует нашим ожиданиям

У этого способа тестирования есть свои достоинства:

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

Как обычно, это компромисс, и у него есть недостатки:

  • Модульные тесты не предназначены для тестирования функциональности приложения или функции в целом. Хотя важно, чтобы отдельные части работали правильно, также важно проверить, хорошо ли они взаимодействуют друг с другом.
  • Данные тестирования не связаны с реальностью. Если ответ сервера, макет приложения, процесс входа в систему или любой другой движущийся элемент изменяются, пользователь не сможет завершить действие; приложение может сломаться, но тесты пройдут, потому что они работают в замкнутой искусственной среде со статическими жестко закодированными данными. В конце концов, если приложение ломается из-за изменения формы ответа API, мы, вероятно, все еще хотим это уловить.

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

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

Пожалуйста, обратитесь к репозиторию GitHub за полными примерами реальных тестов.

Знакомьтесь, кукольник: запуск безголового браузера

Puppeteer - мощная библиотека для запуска экземпляра браузера и программного управления им. Он может делать практически то же, что и обычный браузер: посещать страницы, нажимать кнопки, искать элемент DOM и читать его содержимое. Он позволяет использовать множество приложений, от парсинга веб-страниц до интеграционного тестирования приложения. По умолчанию мы запускаем его в режиме headless - в обычном браузере Chrome, но без графического пользовательского интерфейса, чтобы сделать его быстрее.

Самый простой пример:

Здесь мы запускаем экземпляр браузера; затем откройте новую страницу и перейдите к нашему приложению, которое работает по адресу localhost:3000. После этого мы ищем элемент DOM и проверяем, соответствует ли контент тому, что мы ожидаем.

При работе с кукловодом следует помнить о двух вещах:

  • Браузеру требуется некоторое время, чтобы перейти к адресу, загрузить DOM и выполнить большинство других действий. Вот почему почти все операции, в которых мы используем puppeteer, будут асинхронными и требуют синтаксиса async/await или .then.
  • Браузер - это совершенно отдельный процесс, который никак не запускается внутри нашего приложения. Вот почему мы не можем легко отправить туда объект или функцию, как в другую часть нашего собственного сервера. Каждую функцию, которую мы хотим запустить в браузере, нужно будет превратить в строку → передать в браузер → проанализировать обратно в исполняемый код → запустить браузер → результат превратить в строку → отправить обратно в наше приложение → проанализировать в объект JS. Некоторые из простых вещей могут выглядеть немного сложнее, чем в противном случае. Мы рассмотрим это позже в этой статье, создав своего рода класс-оболочку, упрощая чтение и понимание часто используемых частей.
  • Обратите внимание, что здесь мы используем полный адрес с http://. В моем случае я видел ошибку каждый раз, когда пытался использовать localhost:3000 без нее.

Работа с логином

Пока это кажется пустяком. Но в этом разделе у нас есть несколько проблем:

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

Давайте займемся этим по очереди.

Во-первых, как войти в систему нашего виртуального тестового пользователя? Что представляет собой вход в систему? Есть много способов авторизации, но в этом конкретном приложении мы используем довольно популярный. Паспорт - это пакет, который может обрабатывать самые разные авторизации и имеет почти 800K загрузок в неделю; он должен служить хорошим примером. В этом конкретном приложении мы используем авторизацию с помощью Google OAuth и cookie-session. Это многостраничный поток, и мы не можем легко программно создавать новую учетную запись Google каждый раз, когда нам нужно запускать некоторые тесты.

При более внимательном рассмотрении мы видим, что после того, как все сказано и сделано, у пользователя есть файл cookie с двумя значениями session и session.sig. Эти значения отправляются вместе с каждым последующим запросом, и сервер считает, что пользователь вошел в систему.

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

Значение session - это просто user.id из базы данных, закодированной как base64. У нас есть полный контроль над этими аспектами приложения, поэтому получение идентификатора не проблема. Нам просто нужно создать нового тестового пользователя и получить от него идентификатор.

session.sig немного сложнее. Это значение, которое Passport генерирует, чтобы гарантировать, что значения файлов cookie не были изменены. Мы можем использовать Keygrip, чтобы воссоздать этот процесс. Если мы выполним поиск по запросу Keygrip in package-lock.json, мы увидим, что его не нужно устанавливать явно, потому что он уже является зависимостью от некоторых других пакетов. Затем мы подписываем session ключом cookie, который является одной из секретных переменных среды в приложении.

Помните, что мы хотим создать несколько отдельных файлов с тестами. Это позволит нам воспользоваться классной функцией jest - запускать тесты параллельно.

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

Все это будет выполняться в локальном экземпляре БД на сервере CI во время развертывания; или на локальном сервере во время разработки. Нет потраченных впустую сетевых запросов при обращении к БД и создании новых пользователей.

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

Как и раньше, мы должны каждый раз писать какой-то шаблонный код для создания экземпляра браузера, а затем страницы. Но теперь у нас есть .login() метод, который инкапсулирует все шаги, которые нам нужно предпринять для входа в систему.

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

Убедитесь, что вы получили методы setCookie и reload от this, потому что здесь мы хотим сослаться на класс страницы кукловода.

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

Вот интересная штука. Мы используем одну из малоизвестных функций ES6 JavaScript под названием Proxy / Reflect. Объект Proxy позволяет нам создать прокси для другого объекта. Он может перехватывать и переопределять основные операции для этого объекта, такие как get, set, has, ownKeys и так далее. В этом случае мы перехватываем только операцию получения. Затем мы проверяем, является ли имя запрошенного свойства login, как в случае, когда был вызван page.login(). Если это так, мы выполняем нашу специальную функцию; если это не так, мы продолжаем обычное поведение, используя Reflect.get

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

Давайте кратко проанализируем их здесь:

  1. Мы можем добавить этот метод в page.prototype. Это неплохо, но изменение класса внешней библиотеки нельзя назвать очень чистым решением. Это не будет очевидно для тех, кто может работать с этим кодом позже. Может быть, в какой-то момент кукловод добавит свой собственный метод .login(), и тогда возникнут коллизии и ошибки. Позже у нас может возникнуть необходимость добавить дополнительные настраиваемые функции. Продолжать добавлять вещи в page.prototype просто не так приятно.
  2. Мы можем создать собственный класс и расширить класс страницы кукловода. Это, конечно, первое, что пришло мне в голову. Но помните, как мы создаем экземпляр page. Это не new Page, а вызывается browser.newPage(). Здесь нам, возможно, придется «обезьяно исправить» класс браузера, чтобы попытаться проинструктировать кукловода создать страницу из нашего настраиваемого класса, вместо того, чтобы использовать его собственный. Это кажется слишком сложным и подверженным ошибкам.
  3. Создайте класс-оболочку с использованием JS Proxy, который будет содержать всю логику для входа в систему, весь длинный синтаксис, связанный с выполнением запросов и отправкой строковых команд в браузер. Класс, который просто предоставит нам красивый page экземпляр со всеми надстройками и позволит нам писать краткие, легко читаемые тесты, не беспокоясь ни о чем из вышеперечисленного.

Добавление класса-оболочки с JS Proxy для абстрагирования всей добавленной логики

Помните весь этот шаблонный код для создания браузера и страницы, которые нам нужно было скопировать и вставить из одного тестового файла в другой? Теперь его можно поместить в метод класса static. Нам просто нужно вызвать .build(), чтобы получить объект со всеми настраиваемыми методами, которые мы хотим добавить к нему, оригинальные методы страницы кукловода и браузера, все доступные из одного и того же объекта.

Еще один небольшой, но важный момент - это порядок, в котором прокси ищет свойства. Сначала мы проверяем, есть ли у нас собственный метод, затем просматриваем объект browser, а затем по умолчанию используем исходный объект кукловода page. Это пригодится позже, когда мы сможем вызвать page.close(), что означает закрытие браузера сразу после каждого теста.

Сама функция входа в систему осталась прежней, и мы просто переместили ее в класс CustomPage.

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

Маршрутное тестирование

Когда дело доходит до количества маршрутов в REST API, мы можем ожидать, что в приложении среднего размера легко найдутся десятки или даже сотни маршрутов. Возможно, нам понадобится хороший способ их протестировать.

Однако взаимодействие с браузером по-прежнему требует написания большого количества повторяющегося кода для функции кукольника page.evaluate. Нам нужно предоставить ему обратный вызов, использовать особый способ передачи аргументов - не говоря уже о написании fetch параметров и повторном синтаксическом анализе ответа на JSON.

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

Но поскольку теперь у нас есть хороший класс-оболочка, мы можем написать эту логику только один раз.

В этом примере намерение состоит в том, чтобы проверить, что некоторые маршруты доступны только зарегистрированным пользователям, и в противном случае вернуть правильную ошибку.

Здесь мы можем протестировать столько из них, сколько нам нужно, просто добавив объект с method, path, body в массив действий. Это также будет работать быстрее, потому что мы используем Promise.all под капотом, следя за тем, чтобы эти обещания выполнялись параллельно.

Заключение

В этой статье мы прошли путь:

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

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

Мы коснулись действительно мощной и малоизвестной функции современного JavaScript - Proxy / Reflect. Обязательно ознакомьтесь с полной документацией, чтобы увидеть полный список операций, которые могут быть перехвачены; это довольно длинный список.

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

Как всегда, спасибо за чтение.

Протестируйте свои приложения и обеспечьте безошибочное взаимодействие с пользователем! Удачного кодирования!