Знакомство с фронтенд-тестированием. Часть третья. 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

Всё отлично!

Перевод статьи «Testing Your Frontend Code: Part III (E2E Testing)»