Автоматизация функционального тестирования веб-приложений

tl;dr

ScalaTest FreeSpec, Selenium2 WebDriver, HtmlUnit без CSS везде где можно, встроенный прокси-сервер для аутентификации, ThreadLocal + ShutdownHook для запуска и остановки браузеров.

То же самое, но очень длинно

Конечная цель изысканий

На самом деле нужно было:

  1. Составить и согласовать с заказчиком сценарий приёмочного тестирования проекта.
  2. Отрабатывать его при каждом обновлении софта на боевых серверах.  По возможности, автоматически.

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

Инструментарий

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

Когда мы пишем на скале, для модульного и интеграционного тестирования используем ScalaTest.  Для своих целей весьма удобный инструмент, поводов его на что-либо менять пока не возникало. Поддерживает кучу методологий тестирования (TDD, BDD и т.д.) и при необходимости интегрируется с кучей библиотек (например, мы использовали через скалатестовскую обёртку EasyMock, это было действительно easy).  Кроме того, в последнее время он неплохо поддерживается идеей, что есть безусловный плюс.

WebDriver – сравнительно недавнее дополнение проекта Selenium.  Если первый селениум выполнял скрипты в собственном формате, записанные с действий пользователя в браузере, то второй селениум даёт возможность управлять браузером из сторонних программ через вебдрайвер. К нему есть библиотеки на разных языках, жабью вполне можно использовать из скалы.  У нас в конторе раньше применялся первый селениум, так что плюсы и минусы того подхода были известны, и то, что вместо этого стоит использовать вебдрайвер, было понятно сразу.

Выбор TestSuite

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

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

По смыслу к этим требованиям близки спецификации в BDD стиле.  Скалатест умеет несколько их разновидностей (FunSpec, FlatSpec, FreeSpec, WordSpec), тонкости их отличий оставим для истинных ценителей, для этой задачи важно только то, что часть из них использует сочетания волшебных BDD’шных слов should, when и then, как на уровне определения спецификации, так и при выдаче результатов.  Что нас не устраивает, поскольку нас интересует протокол на русском языке.  Поэтому выбираем FreeSpec, где синтаксис DSL сведён до абсолютного минимума и вложенность описывается самым естественным образом:

"группа проверок" -
{
	"что проверить" in { /* код проверки */ }
}

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

"[RUS]" - performTest("rus")
"[KAZ]" - performTest("kaz")

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

Выбор WebDriver’а

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

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

Более того, имеет смысл даже отключить некоторые реализованные в HtmlUnit возможности, например жаваскрипт (он отключен по умолчанию) и поддержку CSS – просто потому, что понимает он их всё равно в ограниченном объёме (например, jQuery у него отработать не получается), а ресурсы на попытки тем не менее тратит. Разбирать современные таблицы стилей в несколько тысяч строк тоже довольно накладно, а по факту они используются только для попытки определения видимости элементов, и самое страшное, чем грозит их отключение – то, что эрзац-браузер будет соглашаться кликать на невидимые элементы, в то время как настоящий браузер отказывается.  Проверить совпадение результатов написанных тестов под разными драйверами полезно в любом случае, так что такие различия выявляются сразу же и проблем не создают.

Наскальная специфика

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

type WebDriverFull = WebDriver with JavascriptExecutor
	with FindsById with FindsByLinkText with FindsByXPath
	with FindsByName with FindsByCssSelector with FindsByTagName
	with HasCapabilities with HasInputDevices

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

def waitFor[T](probe: WebDriver => T):T =
	new WebDriverWait(driver, 10).until(
		new ExpectedCondition[T]
		{
			def apply(d:WebDriver):T = probe(d)
		}
	);

И использовать её станет гораздо проще:

val myDynamicElement =
	waitFor(_.findElement(By.id("myDynamicElement")))

Ещё селениумовским сообществом наработаны свои приёмы, в частности паттерн Page Object, который предполагает вынесение в отдельный класс методов, обращающихся с конкретной страницей, и описание логики остальных тестов уже в терминах этих методов.  Следуя при разработке принципу DRY, к подобной схеме можно прийти эволюционно: методы выделяются по мере повторного обращения, а когда возникает необходимость использования из других классов, выносятся в отдельный трейт.  В результате получается один трейт с методами для доступа к присутствующим на всех страницах полям общего шаблона оформления и трейты для отдельных  страниц. К конкретному TestSuite подмешиваются нужные из них.

Практические наблюдения

Имея экземпляр вебдрайвера, можно приступать к запрашиванию страниц и кликанию по ссылкам:

driver.get("http://localhost:8080/")
driver.findElementByPartialLinkText("Home").click()

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

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

Перебор элементов

Один из первых вопросов, которые возникают: как получить элемент по номеру?  Скажем, третий пункт меню, которое сделано в виде вложенных списков ссылок.

У вебдрайвера масса методов для поиска элементов разными способами: есть простые – по id, классу, имени тэга, тексту ссылки; а есть сложные – по CSS селектору и по XPath выражению.

В общем случае, сложные запросы рекомендуется делать CSS селекторами, потому как браузерам их отрабатывать проще.  Здесь был бы уместен псевдокласс :eq() как в jQuery, но в CSS его нет.  В CSS3 есть :nth-child() и :nth-of-type(), но у них другая семантика: аналог $(“#menu ul a:eq(2)”) – это скорее $(“#menu ul li:nth-child(3) a”), не каждый селектор можно переписать подобным образом.  И, что хуже всего, HtmlUnit не поддерживает CSS3 селекторы.

Можно написать это в виде XPath:

driver.findElementByXPath("id('menu')/ul/li[3]/a")

XPath – это вообще мощная штука. Им можно успешно задавать очень сложные условия, но некоторые из них тяжело задать правильно.  Например, отфильтровать элемент по заданному классу, если классов у него несколько и имя нужного класса полностью входит в другое имя, скажем, checked/unchecked.

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

driver.findElementsByCssSelector("#menu ul.target a")(2)

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

"У фасетов все ссылки различаются" in
{
	val links = driver.findElementsByCssSelector(".facets li a")
	val hrefs = links.map(_.getAttribute("href")).toSet
	assert(links.size === hrefs.size)
	assert(links.size > 0)
}

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

val jsHrefs = driver.executeScript(
	""" return $.map( $('.main_facets ul li a'),
		function(e){ return $(e).attr('href') }); """
).asInstanceOf[java.util.List[String]].toSeq

HTTP-аутентификация

Самая большая проблема, с которой на данный момент столкнулись.

Перед выкатыванием сборки на боевой сервер, она разворачивается на staging сервере, и сначала тесты прогоняются на нём.  Staging сервер тоже в интернете – он используется для согласования изменений с заказчиком, но, по понятным причинам, не является общедоступным, и закрыт простейшим паролем. Базовой http-аутентификацией.

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

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

new HtmlUnitDriver
{
	val client = getWebClient
	val provider = new DefaultCredentialsProvider();
	provider.addCredentials(login, password);
	client.setCredentialsProvider(provider);
}

Для настоящих браузеров, того же фаерфокса, нормальных решений нет.  Народ предлагает обходные пути, основная масса которых сводится к дописыванию логина с паролем к каждой ссылке в стиле http://user:pass@domain.com. У некоторых это даже работает.  У остальных там срабатывают антифишинговые фильтры, которые пытаются хитро отключать, не работает аякс, и самое гадкое: это происходит через раз, то есть работает нестабильно.  Что совершенно неприемлемо для механизма, предназначенного стабильность обеспечивать.  Не говоря уже о необходимости учитывать наличие или отсутствие этой части при сравнении урлов в тестах.

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

В комментариях к той проблеме на их трекере один товарищ это недавно предлагал. Правда, сервер он использовал слишком суровый на наш взгляд – с тяжёлыми зависимостями и так далее.  Встраиваемых http прокси-серверов для жабы в природе существует несколько, разной степени продвинутости и заброшенности: littleproxy который был у него, exproxy, wpg-proxy.   Может и ещё есть.

Самым простым в использовании навскидку показался wpg-proxy. Со своей задачей он отлично справился, хотя и создал неочевидную проблему с перенаправлениями, но об этом позже. Запускаем его примерно так:

ProxyRegistry.addRequestProcessor(new HttpMessageProcessor
{
	def doContinue(msg: HttpMessage) = true
	def doSend(msg: HttpMessage) = true
	def process(msg: HttpMessage) =
	{
		val encoded = Base64.encodeBase64String(
				(login + ":" +password).getBytes)
			.replace("\n", "").replace("\r", "")
		msg.addHeader("Authorization", "Basic "+encoded)
		msg
	}
})
val proxy = new com.wpg.proxy.Proxy(
	InetAddress.getByName("127.0.0.1"), proxyPort, 50)
proxy.start()

При создании фаерфоксовского вебдрайвера ему передаём параметром пожелание иметь подключение через прокси-сервер:

val proxy = new org.openqa.selenium.Proxy();
proxy.setHttpProxy("localhost:"+proxyPort)
val cap = new DesiredCapabilities()
cap.setCapability(CapabilityType.PROXY, proxy);
new FirefoxDriver(cap)

Обращаю внимание: класс Proxy в этом случае другой.

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

Скачивание файлов

Найти и щёлкнуть ссылку «download» можно запросто.  А вот объяснить браузеру куда следует сохранять скачиваемый файл через вебдрайвер нельзя – у многих браузеров там нативные диалоги, до которых скрипты вебдрайвера добраться просто не могут.  Народ с этим борется настройкой профилей браузерам, чтобы они сохраняли сразу туда, куда надо, и без лишних вопросов.  Потом ещё нужно будет определить момент завершения закачки.

Всех этих действий можно избежать, если скачивать файлы самостоятельно.

В простом случае достаточно получить href у ссылки и запросить.  Например, встроенными средствами JRE– new URL(href).openStream(), и так далее.   Либо с комфортом использовать какую-нибудь библиотеку. Мы взяли Resty – довольно удобно, аутентификация производится буквально одним вызовом метода.

У нас случай был не самый простой.  Запрос от клиентского браузера на скачивание приходил на наш обработчик, который проверял наличие готового файла в хранилище, при отсутствии генерировал и записывал его туда, затем перенаправлял браузер на адрес этого файла, после чего сам файл, как и остальной статический контент, спокойно отдавался nginx’ом.

Через HtmlUnit всё работало замечательно, а через настоящий браузер с прокси-сервером – нет. Вместо файла скачивалась страница с сообщением о перенаправлении, причём как с помощью Resty, так и вручную.  В конченом итоге выяснилось, что наш встроенный прокси-сервер переопределил стратегию отработки перенаправлений по умолчанию – сделал где-то у себя внутри HttpURLConnection.setFollowRedirects(false).

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

new Resty(new Resty.Option
{
	override def apply(conn: URLConnection)
	{
		conn match
		{
			case c: HttpURLConnection => 
				c.setInstanceFollowRedirects(true)
			case _ =>
		}
	}
})

Когда запускать и останавливать вебдрайверы

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

Останавливать его тоже надо корректно: если вебдрайверу не вызвать метод close(), он оставит запущенный браузер открытым.  И при следующем запуске ещё один, и так далее.

У скалатеста имеются трейты BeforeAndAfter и BeforeAndAfterAll которые можно подмешать к Suite и получать управление до и после запуска отдельных тестов и всего Suite соответственно, но штатного способа ловить границы всей сессии тестирования, вроде бы, нет.  Во всяком случае, без написания собственной запускалки тестов, чего делать совершенно не хочется, поскольку сравнимого с идейской запускалкой удобства всё равно не достичь.

Сама сессия тестирования может варьироваться в широких пределах: это может быть отдельный тест, набор тестов (Suite), несколько тестов из разных наборов (такое часто происходит при запуске упавших в прошлый раз тестов), все наборы тестов в каком-нибудь пакете, несколько наборов, объединённых в группу (Suites).  Причём наборы в группе могут быть запущены параллельно (с ключом -c), и каждому потребуется собственный экземпляр драйвера. И закончиться тестирование может штатно, а может аварийно.

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

Заводим ThreadLocal  для каждого типа драйвера, и создаём экземпляры драйвера в методе initialValue.

new ThreadLocal[WebDriverFull]
{
	override def initialValue() = createBrowser()
}

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

Регистрируем у жабьего рантайма ShutdownHook.  Закрытие драйверов и остановку прокси-сервера производим в нём.

Runtime.getRuntime.addShutdownHook(new Thread()
{
	override def run()
	{
		createdBrowsers.foreach(_.close())
		proxyHolder.foreach(_.shutdown())
	}
})

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

Результат

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

Выглядит примерно так:

Главная страница

На русском языке

  • Страница загружается
  • Присутствуют ключевые элементы страницы
    • Большая картинка в шапке
    • Форма полнотекстового поиска
    • Горизонтальный блок фасетов
  • Подсвечен пункт меню №1
  • Подсвечен нужный язык
  • Для всех локализуемых строк имеются строковые ресурсы
  • При смене языка выдаётся страница того же типа
  • Корректный заголовок окна

И дальше ещё много в том же духе.

Вроде бы всех устраивает.

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>