javascript

Selenium и Node.js: пишем надёжные браузерные тесты

  • суббота, 30 сентября 2017 г. в 12:09:34
https://habrahabr.ru/company/ruvds/blog/338984/
  • Node.JS
  • JavaScript
  • Блог компании RUVDS.com


Есть много хороших статей о том, как начать писать автоматизированные браузерные тесты, используя версию Selenium, предназначенную для Node.js.



В некоторых материалах говорится о том, как оборачивать тесты в Mocha или Jasmine, в некоторых всё автоматизируют с помощью npm, Grunt или Gulp. Во всех подобных публикациях можно найти сведения о том, как установить и настроить всё необходимое. Там же можно посмотреть простые примеры работающего кода. Всё это весьма полезно, так как, для новичка, собрать работающую среду тестирования, состоящую из множества компонентов, может оказаться не так уж и просто.

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

Сегодня мы начнём с того, на чём обычно заканчиваются другие материалы по автоматизации браузерных тестов с помощью Selenium для Node.js. А именно, мы расскажем о том, как повысить надёжность тестов и «отвязать» их от непредсказуемых явлений, которыми полны браузеры и веб-приложения.

Сон — это зло


Метод Selenium driver.sleep — худший враг разработчика тестов. Однако, несмотря на это, его используют повсюду. Возможно, это так из-за краткости документации для Node-версии Selenium, и из-за того, что она покрывает лишь синтаксис API. Ей недостаёт примеров из реальной жизни.

Возможно, причина в том, что этот метод используется во множестве примеров кода в блогах и на сайтах вопросов и ответов, вроде StackOverflow.

Для того, чтобы разобраться с особенностями метода driver.sleep, рассмотрим пример. Предположим, у нас есть анимированная панель, которая, в ходе появления на экране, меняет размеры и положение. Взглянем на неё.


Анимация панели

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


Замедленная анимация панели

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

Обычно эти анимации происходят так быстро, что у пользователя не возникает желания «ловить» меняющиеся кнопки. Люди просто ждут завершения анимации. Однако, это не относится к Selenium. Он настолько быстр, что может попытаться щёлкнуть по элементу, который всё ещё анимируется. Как результат — можно столкнуться с примерно таким сообщением об ошибке:

System.InvalidOperationException : Element is not clickable at point (326, 792.5)

В подобной ситуации многие программисты скажут: «Ага, мне нужно дождаться завершения анимации, поэтому просто использую driver.sleep(1000) для того, чтобы панель пришла в нормальное состояние». Похоже, задача решена? Однако, не всё так просто.

Проблемы driver.sleep


Команда driver.sleep(1000) выполняет именно то, чего от неё можно ожидать. Она останавливает выполнение теста на 1000 миллисекунд и позволяет браузеру продолжать работать: загружать страницы, размещать на них фрагменты документов, анимировать или плавно выводить на экран элементы, или делать что угодно другое.

Возвращаясь к нашему примеру, если предположить, что панель достигает нормального состояния за 800 миллисекунд, команда driver.sleep(1000) обычно помогает достичь того, ради чего её вызывают. Итак, почему бы ей не воспользоваться?

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

Почему же конструкции с driver.sleep не всегда работоспособны? Другими словами, почему это недетерминированный механизм?

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

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

Когда с веб-сайтом работает человек, он ждёт, пока элемент полностью появится, прежде чем по нему щёлкнуть. И когда появление элемента занимает меньше секунды, мы, вероятно, даже не заметим этого «ожидания». Selenium не только быстрее и требовательнее обычного пользователя. Тесты, в ходе работы со страницами, вынуждены сталкиваться с различными непредсказуемыми факторами. Рассмотрим некоторые из них:

  1. Дизайнер может изменить время анимации с 800 миллисекунд на 1200 миллисекунд. В результате тест с driver.sleep(1000) даст сбой.
  2. Браузеры не всегда делают именно то, что от них требуется. Из-за нагрузки на систему анимация может затормозиться и занять больше чем 800 миллисекунд. Возможно, даже больше, чем время ожидания, установленное на 1000 миллисекунд. Как результат — снова отказ теста.
  3. Различные браузеры имеют различные механизмы визуализации данных, назначают разные приоритеты операциям по размещению элементов на экране. Добавьте новый браузер в набор программ для тестирования и тест опять вылетит с ошибкой.
  4. Браузеры, которые контролируют страницы, JavaScript-вызовы, которые меняют их содержимое, по своей природе асинхронны. Если анимация в нашем примере применяется к блоку, который нуждается в информации с сервера, то перед запуском анимации придётся дождаться чего-то вроде результата AJAX-вызова. Теперь, кроме прочего, мы имеем дело с сетевыми задержками. Как результат, невозможно точно оценить время, необходимое для вывода панели на экран. Тест снова не сможет нормально работать.
  5. Конечно, есть и другие причины для сбоев тестов, о которых я не знаю. Даже сами браузеры, без учёта внешних факторов — сложные системы, в которых, к тому же есть ошибки. Разные ошибки в разных браузерах. В результате, пытаясь писать надёжные тесты, мы стремимся к тому, чтобы они работали в разных браузерах различных версий и в нескольких операционных системах различных выпусков. Недетерминированные тесты в подобных условиях рано или поздно дают сбои. Если учесть всё это — становится понятным, почему программисты отказываются от автоматизированных тестов и жалуются на то, как они ненадёжны.

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

Если вы пока ещё не убедились в том, что driver.sleep — это, во многих ситуациях, вредная команда, подумайте вот о чём. Без driver.sleep тесты будут работать гораздо быстрее. Например, мы надеемся, что анимация из нашего примера займёт всего 800 миллисекунд. В реальном тестовом наборе подобное предположение приведёт к использованию чего-то вроде driver.sleep(2000), опять же, в надежде на то, что 2-х секунд хватит на то, чтобы анимация успешно завершилась, какими бы ни были дополнительные факторы, влияющие на браузер и страницу.

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

Предлагаю вашему вниманию конкретные примеры избавления от driver.sleep и преобразования тестов в надёжные, полностью детерминированные конструкции.

Пара слов о промисах


JavaScript-API для Selenium интенсивно использует промисы. При этом детали скрыты от программиста благодаря использованию встроенного менеджера промисов. Ожидается, что этот функционал уберут, поэтому в будущем вам либо придётся разбираться с тем, как самостоятельно объединять промисы в цепочки, либо с тем, как пользоваться новым механизмом JavaScript async/await.

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

Пишем тесты


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

Как насчёт элемента, который динамически добавляется на страницу и не существует сразу после того, как страница завершит загрузку?

Ожидание появления элемента в DOM


Следующий код не будет работать, если элемент с CSS id my-button был добавлен в DOM после загрузки страницы:

// Код инициализации Selenium опущен для ясности
// Загрузка страницы.
driver.get('https:/foobar.baz');
// Найти элемент.
const button = driver.findElement(By.id('my-button'));
button.click();

Метод driver.findElement ожидает, что элемент уже присутствует в DOM. Он выдаст ошибку, если элемент невозможно немедленно найти. В данном случае «немедленно», из-за вызова driver.get, означает: «после того, как завершится загрузка страницы».

Помните о том, что текущая версия Selenium для JavaScript самостоятельно управляет промисами. Поэтому каждое выражение будет полностью завершено до перехода к следующему выражению.

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

Для начала взглянем на то, как не следует исправлять подобную ошибку. Предположим, нам известно, что добавление элемента в DOM может занять несколько секунд:

driver.get('https:/foobar.baz');
// Страница загружается, засыпаем на несколько секунд
driver.sleep(3000);
// Надеемся, что три секунды достаточно для того, чтобы по прошествии этого времени элемент можно было бы найти на странице.
const button = driver.findElement(By.id('my-button'));
button.click();

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

const button = driver.wait(
  until.elementLocated(By.id('my-button')), 
  20000
);
button.click();

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

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

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

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

Ожидание появления элемента на экране


Мы будем основываться на предыдущем примере, так как, прежде чем дождаться того, что элемент станет видимым, имеет смысл подождать момента его добавления в DOM. Кроме того, в нижеприведённом коде вы можете видеть первый пример использования цепочки промисов:

const button = driver.wait(
  until.elementLocated(By.id('my-button')), 
  20000
)
.then(element => {
   return driver.wait(
     until.elementIsVisible(element),
     20000
   );
});
button.click();

На этом, в общем-то, можно было бы и остановиться, так как мы уже рассмотрели достаточно для того, чтобы значительно улучшить тесты. С использованием этого кода вы сможете избежать множества ситуаций, которые, в противном случае, вызвали бы отказ теста из-за того, что элемента нет в DOM сразу после завершения загрузки страницы. Или из-за того, что он невидим сразу после загрузки страницы из-за чего-то вроде анимации. Или даже по обеим этим причинам.

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

Когда сложность задачи растёт, разработчики нередко сдают позиции и снова обращаются к driver.sleep. Рассмотрим ещё несколько примеров, которые помогут обойтись без driver.sleep в более сложных обстоятельствах.

Описание собственных условий


Благодаря методу until JavaScript API для Selenium уже имеет некоторое количество вспомогательных методов, которые можно использовать с driver.wait. Кроме того, можно организовать ожидание до тех пор, пока элемент не будет больше существовать, ожидать появления элемента, содержащего конкретный текст, ожидать показа уведомления, или использовать много других условий.

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

В соответствии с документацией, методу driver.wait можно предоставить функцию, которая возвращает true или false.

Скажем, нам нужно дождаться, чтобы свойство opacity некоего элемента стало бы равным единице:

// Получить элемент.
const element = driver.wait(
  until.elementLocated(By.id('some-id')),
  20000
);
// driver.wait всего лишь нужна функция, которая возвращает true или false.
driver.wait(() => { 
  return element.getCssValue('opacity')      
    .then(opacity => opacity === '1');
});

Такая конструкция кажется полезной и подходящей для повторного использования, поэтому поместим её в функцию:

const waitForOpacity = function(element) {
  return driver.wait(element => element.getCssValue('opacity')      
    .then(opacity => opacity === '1');
  );
};

Теперь воспользуемся этой функцией:

driver.wait(
  until.elementLocated(By.id('some-id')),
  20000
)
.then(waitForOpacity);

А вот тут мы сталкиваемся с проблемой. Что если вы хотите щёлкнуть по элементу, когда он станет полностью непрозрачным? Если мы попытаемся воспользоваться значением, возвращённым вышеприведённым кодом, ничего хорошего у нас не получится:

const element = driver.wait(
  until.elementLocated(By.id('some-id')),
  20000
)
.then(waitForOpacity);
// Вот незадача. Переменная element может быть true или false, это не элемент, у которого есть метод click().
element.click();

По той же причине мы не можем в подобной конструкции пользоваться объединением промисов в цепочки.

const element = driver.wait(
  until.elementLocated(By.id('some-id')),
  20000
)
.then(waitForOpacity)
.then(element => {
  // Так тоже не пойдёт, element и здесь является логическим значением.
  element.click();
}); 

Это всё, однако, легко исправить. Вот улучшенный метод:

const waitForOpacity = function(element) {
  return driver.wait(element => element.getCssValue('opacity')      
    .then(opacity => {
      if (opacity === '1') {
        return element;
      } else {
        return false;
    });
  );
};

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

Вот как можно это применить вместе с объединением промисов в цепочки:

driver.wait(
  until.elementLocated(By.id('some-id')),
  20000
)
.then(waitForOpacity)
.then(element => element.click());

Или даже так:

const element = driver.wait(
  until.elementLocated(By.id('some-id')),
  20000
)
.then(waitForOpacity);
element.click();

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

Уходим в минус


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

Предположим, элемент уже присутствует в DOM, но вы не должны с ним взаимодействовать до того, как некоторые данные будут загружены через AJAX. Элемент может быть перекрыт панелью с надписью «Загрузка...».

Если вы обратили пристальное внимание на условия, которые предлагает метод until, вы могли заметить методы вроде elementIsNotVisible или elementIsDisabled или на не столь очевидный метод stalenessOf.

Благодаря одному из этих методов можно организовать проверку на скрытие панели с индикатором загрузки:

// Элемент уже добавлен в DOM, отсюда сразу же произойдёт возврат.
const desiredElement = driver.wait(
  until.elementLocated(By.id('some-id')),
  20000
);
// Но с элементом нельзя взаимодействовать до тех пор, пока панель с индикатором загрузки
// не исчезнет.
driver.wait(
  until.elementIsNotVisible(By.id('loading-panel')),
  20000
);
// Панель с информацией о загрузке больше не видна, с элементом теперь можно взаимодействовать, не опасаясь ошибок.
desiredElement.click();

Исследуя вышеописанные методы, я обнаружил, что метод stalenessOf особенно полезен. Он ожидает, пока элемент не будет удалён из DOM, что, кроме прочих причин, может произойти из-за обновления страницы.

Вот пример ожидания обновления содержимого iframe для продолжения работы:

let iframeElem = driver.wait(
  until.elementLocated(By.className('result-iframe')),
  20000  
);
// Выполняем некое действие, которое приводит к обновлению iframe.
someElement.click();
// Ожидаем пока предыдущий iframe перестанет существовать:
driver.wait(
  until.stalenessOf(iframeElem),
  20000
);
// Переключаемся на новый iframe. 
driver.wait(
  until.ableToSwitchToFrame(By.className('result-iframe')),
  20000
);
// Всё, что будет написано здесь, относится уже к новому iframe.

Итоги


Главная рекомендация, которую можно дать тем, кто стремится писать надёжные тесты на Selenium, заключается в том, что всегда следует стремиться к детерминизму и отказаться от метода sleep. Полагаться на метод sleep — значит основываться на произвольных предположениях. А это, рано или поздно, приводит к сбоям.

Надеюсь, приведённые здесь примеры помогут вам продвинуться по пути создания качественных тестов на Selenium.

Уважаемые читатели! Используете ли вы Selenium для автоматизации тестов?