вНовости программирования

Знакомство с фронтенд-тестированием. Часть третья. E2E-тестирование

Рассказывает Гил Тайяр, автор блога на Hackernoon


В прошлой части мы познакомились с юнит-тестированием: проверили основную логику приложения, содержащуюся в модуле calculator, используя Mocha и тестовый стенд.

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

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

Нужно ли проверять все перестановки, как мы делали это в юнит-тестах? Нет, ведь это уже проверено! В E2E-тестах мы проверяем работоспособность не отдельных юнитов, а всей системы сразу.

Сколько нужно E2E-тестов?

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

Вторая причина — они медленные. Если их будет сотня, как юнит-тестов и интеграционных, то тестирование будет проходить очень долго.

Третья причина — непредсказуемое поведение E2E-тестов. О таком явлении есть пост в блоге Google, посвященном тестированию. В юнит-тестах не наблюдается такого нестабильного поведения. Они могут то проходить, то падать — причем без видимых изменений, исключительно из-за I/O. Можно ли убрать непредсказуемость? Нет, но можно свести её к минимуму.

Чтобы избавиться от непредсказуемости, делайте как можно меньше E2E-тестов. Пишите один E2E-тест на десять других, и лишь тогда, когда они действительно необходимы.

Пишем E2E-тесты

Перейдём к написанию E2E-тестов. Нам нужны две вещи: браузер и сервер для нашего фронтенд-кода.

Для E2E-тестирования, как и для юнит-тестирования, мы будем использовать Mocha. Мы настроим браузер и веб-сервер, используя функцию before, и обнулим настройки при помощи функции after. Эти функции запускаются до и после выполнения всех тестов и настраивают окружение, которое могут использовать тестовые функции. Узнать о том, как они работают, можно в документации Mocha.

Сперва взглянем на настройку веб-сервера.

Настройка веб-сервера в Mocha

Веб-сервер на Node? На ум сразу же приходит express, давайте посмотрим код:

let server before((done) => { const app = express() app.use('/', express.static(path.resolve(__dirname, '../../dist'))) server = app.listen(8080, done) }) after(() => { server.close() })

В функции before мы создаем express-приложение, указываем ему папку dist и прописываем слушать порт 8080. В функции after мы «убиваем» сервер.

Папка dist — это то место, где мы храним наши JS-скрипты и куда копируем HTML- и CSS-файлы. Вы можете увидеть, что мы делаем это в сборочном скрипте npm в package.json:

{ "name": "frontend-testing", "scripts": { "build": "webpack && cp public/* dist", "test": "mocha 'test/**/test-*.js' && eslint test lib", ... },

Это значит, что для E2E-тестов нужно сначала выполнить npm run build, а потом npm test. Да, это неудобно. В случае юнит-тестов этого делать не нужно, так как они запускаются под Node и не требуют трансляции и сборки.

Для полноты картины давайте взглянем на webpack.config.js, где описано, как Webpack должен делать сборку файлов:

module.exports = { entry: './lib/app.js', output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist') }, ... }

Webpack будет читать наш app.js и собирать все необходимые файлы в bundle.js в папке dist.

Папка dist используется как в пользовательском окружении, так и в E2E-тестах. Это важно — запускать E2E-тесты нужно в средах, максимально похожих на «боевые».

Настройка браузера в Mocha

Наше приложение установлено на сервер — осталось лишь запустить для него браузер. Какую библиотеку мы будем использовать для автоматизации? Я обычно использую популярную selenium-webdriver.

Для начала давайте посмотрим, как мы используем её, прежде чем начнём разбираться с настройками:

const {prepareDriver, cleanupDriver} = require('../utils/browser-automation') //... describe('calculator app', function () { let driver ... before(async () => { driver = await prepareDriver() }) after(() => cleanupDriver(driver)) it('should work', async function () { await driver.get('http://localhost:8080') //... }) })

В функции before мы готовим драйвер, а в after — очищаем его. Подготовка драйвера будет запускать браузер, а очистка — закрывать его. Заметим, что настройка драйвера происходит асинхронно и мы можем использовать async/await, чтобы сделать код красивее.

В тестовой функции мы открываем адрес http://localhost:8080, снова используя await, учитывая, что driver.get — асинхронная функция.

Так как же выглядят prepareDriver и cleanupDriver?

const webdriver = require('selenium-webdriver') const chromeDriver = require('chromedriver') const path = require('path') const chromeDriverPathAddition = `:${path.dirname(chromeDriver.path)}` exports.prepareDriver = async () => { process.on('beforeExit', () => this.browser && this.browser.quit()) process.env.PATH += chromeDriverPathAddition return await new webdriver.Builder() .disableEnvironmentOverrides() .forBrowser('chrome') .setLoggingPrefs({browser: 'ALL', driver: 'ALL'}) .build() } exports.cleanupDriver = async (driver) => { if (driver) { driver.quit() } process.env.PATH = process.env.PATH.replace(chromeDriverPathAddition, '') }

Это сложная штука. И я должен кое-что признать: этот код был написан кровью (о, и он работает только в Unix-системах). Он был написан при помощи Google, Stack Overflow и документации webdriver и сильно модифицирован методом научного тыка. Но он работает!

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

Первые две строки подключают webdriver — драйвер для браузера. Принцип работы Selenium Webdriver заключается в наличии API (в модуле selenium-webdriver, который мы импортируем в строке 1), который работает с любым браузером, и он полагается на драйверы браузера, чтобы… управлять различными браузерами. Драйвер, который я использовал, — chromedriver, импортированный в строке 2.

Драйвер Chrome не нуждается в браузере на машине: он фактически устанавливает свой собственный исполняемый файл Chrome, когда вы выполняете npm install. К сожалению, по некоторым причинам, которые я не могу понять, он не может найти его, и каталог chromedriver должен быть добавлен в PATH (это именно то, что не работает в Windows). Это мы делаем в строке 9. Мы также удаляем его из PATH на этапе очистки, в строке 22.

Итак, мы настроили драйвер браузера. Теперь пришло время настроить (и вернуть) веб-драйвер, что мы и делаем в строках 11–15. А поскольку функция build асинхронна и возвращает promise (промис), мы ждём её при помощи await.

Почему мы делаем это в строках 11–15? Причины скрыты туманом опыта. Не стесняйтесь копипастить — никаких гарантий не прилагается, но я использовал этот код некоторое время, и проблем не возникало.

Приступим к тестам

Мы закончили настройку — пришло время взглянуть на код, который использует webdriver для управления браузером и тестирования нашего кода.

Разберём код по частям:

// ... const retry = require('promise-retry') // ... it('should work', async function () { await driver.get('http://localhost:8080') await retry(async () => { const title = await driver.getTitle() expect(title).to.equal('Calculator') }) //...

Пропустим установку, которую мы видели раньше, и перейдем к самой тестовой функции.

Код переходит к приложению и проверяет, что его название — «Calculator». Первую строку мы уже видели — мы открываем наше приложение с помощью драйвера. И не забываем дождаться окончания процесса.

Перейдём к строке 9. Здесь мы просим браузер вернуть нам заголовок (используем await для ответа, потому что это асинхронно), а в строке 10 мы проверяем, что заголовок title имеет корректное значение.

Так почему мы повторяем это, используя модуль promise-retry? Причина очень важна, мы увидим, что и в остальной части теста браузер, когда мы попросим его что-то сделать, например, перейти по URL-адресу, сделает это, но асинхронно. Не позволяйте await одурачить вас! Мы ждём, пока браузер скажет: «OK, я сделал это», — а не конца операции.

Поиск элементов

Дальше, к следующей части теста!

const {By} = require('selenium-webdriver') it('should work', async function () { await driver.get('http://localhost:8080') //... await retry(async () => { const displayElement = await driver.findElement(By.css('.display')) const displayText = await displayElement.getText() expect(displayText).to.equal('0') }) //...

Теперь мы проверим, что первоначально display равен 0. Для этого найдем элемент, который содержит display — в нашем случае это класс display. Это мы делаем в строке номер 7 с помощью функции findelement объекта класса webdriver. Мы можем искать элементы с помощью методов By.id, By.css или других. Я обычно использую By.css — он принимает селектор и очень гибок в использовании, хотя By.javascript, вероятно, самый гибкий из них.

Как вы могли заметить, By импортирован из selenium-webdriver.

В строке 10 с помощью метода getText() мы получаем содержимое элемента и проверяем его. Помните, что нужно дожидаться (await) выполнения всех методов!

Пользовательский интерфейс

Настало время тестировать наше приложение — нажимать на цифры и операторы и проверять результат операций:

const digit4Element = await driver.findElement(By.css('.digit-4')) const digit2Element = await driver.findElement(By.css('.digit-2')) const operatorMultiply = await driver.findElement(By.css('.operator-multiply')) const operatorEquals = await driver.findElement(By.css('.operator-equals')) await digit4Element.click() await digit2Element.click() await operatorMultiply.click() await digit2Element.click() await operatorEquals.click() await retry(async () => { const displayElement = await driver.findElement(By.css('.display')) const displayText = await displayElement.getText() expect(displayText).to.equal('84') })

Сначала мы находим элементы, на которые хотим нажать, в строках 2–4. Затем нажимаем на них в строках 6–7. В нашем тесте получилось выражение "42*2=". Затем мы повторяем процесс, пока не получим правильный результат, "84".

Выполнение всех тестов

Итак, у нас есть E2E-тесты и юнит-тесты, запустим их с помощью npm test:

e2e-and-unit-testing

Всё отлично!

Типичный программист.

Источник: Типичный программист