В данной статье речь пойдет об использовании фреймворка testNG, а конкретно — о реализованных в нем и довольно редко используемых интерфейсах: IRetryAnalyzer, ITestListener, IReporter. Но обо всем по порядку.
Вечной проблемой каждого тестировщика при запуске автотестов является “падение” отдельных сценариев от запуска к запуску рандомно. И речь идет не о падении наших тестов по объективным причинам (т.е. действительно имеет место ошибка в работе тестируемого функционала, или же сам тест написан не корректно), а как раз о тех случаях, когда после перезапуска ранее проваленные тесты чудом проходят. Причин такого рандомного падения может быть масса: отвалился интернет, перегрузка CPU / отсутствие свободной RAM на устройстве, таймаут и др. Вопрос — как исключить или хотя бы уменьшить количество таких не объективно проваленных тестов?
Для меня данный челлендж возник при следующих обстоятельствах:
1) текущее приложение автотестов было решено разместить на сервере (CI);
2) реализация мультипоточности в проекте превратилась из желания в mustHave (в виду необходимости сокращения времени регрессионного тестирования сервиса).
Второму пункту лично я был очень рад, так как считаю, что любой процесс, который может длиться меньшее количество времени — обязательно должен поступать именно таким образом (будь то прохождение автотеста или очередь на кассе в супермаркете: чем быстрее мы можем завершить эти процессы, тем больше времени у нас остается для занятий чем-то действительно интересным). Так вот, разместив наши тесты на сервере (тут нам помогли админы и их знание jenkins) и запустив их в потоках (тут уже помогла наша усидчивость и эксперименты с testng.xml), мы получили сокращение времени прохождения тестов из 100 минут до 18, но одновременно мы получили прирост в проваленных тестах >2 раза. Поэтому к первым двум пунктам добавился следующий (собственно, сам челлендж, которому и посвящена эта статья):
3) реализовать перезапуск проваленных тестов в процессе одной сборки.
Объем третьего пункта и требования к нему постепенно разрастались, но опять же, обо всем по порядку.
Реализовать перезапуск проваленного теста testng позволяет из коробки, благодаря интерфейсу IRetryAnalyzer. Данный интерфейс предоставляет нам boolean-метод retry, который и отвечает за перезапуск теста, в случае возврата им true, или же отсутствие перезапуска при false. Передать в данный метод нам нужно результат нашего теста (ITestResult result).
Теперь проваленные тесты начали перезапускаться, но была выявлена следующая неприятная особенность: все провальные попытки прохождения теста неминуемо попадают в отчетники (так как реально ваш тест, пусть один и тот же, проходит несколько раз подряд — по нему поступает такое же количество результатов, которые честно попадают в отчет). Возможно, некоторым тестировщикам данная проблема покажется надуманной (особенно, если отчет вы никому не показываете, не предоставляете его техлидам, менеджерам и заказчикам). В таком случае, действительно, можно пользоваться maven-surefire-report-plugin и периодически злиться, ломая глаза, чтобы понять, провален ваш тест или таки нет.
Мне явно не подходила перспектива кривого отчета, потому поиски решений были продолжены.
Рассматривались варианты парсинга html-отчета для удаления дублирующихся проваленных тестов. Также предлагали мержить результаты нескольких отчетов в 1 конечный. Подумав, что костыльные решения могут аукнуться нам, когда с очередным обновлением report-плагинов будет изменена структура их html/xml отчетов, было принято решение реализовать создание собственного кастомного отчета. Единственный минус такого решения — время на его разработку и тестирование. Плюсов я увидел гораздо больше, и главный из них — гибкость. Отчет можно сформировать так, как нужно или нравится вам. Всегда можно добавить дополнительные параметры, поля, метрики.
Итак, было понятно, в каком месте в отчет будут складываться проваленные тесты — это блок retry-метода, в котором количество попыток перезапуска тестов уже было исчерпано. Далее определились с тем, откуда складывать успешные. Интерфейс ITestListener. Из семи методов данного интерфейса нам идеально подошел onTestSuccess, т.к. успешные тесты всегда заходят в данный метод. Итого, у нас есть две точки в нашем приложении, откуда к нам в отчет будут складываться успешные и проваленные тесты.
Следующий вопрос: в какой момент дернуть наш отчет, чтобы к этому времени все тесты были завершены. Тут на помощь приходит следующий интерфейс — IReporter и его метод generateReport.
Итак, теперь у нас есть:
— метод, откуда мы будем укладывать в отчет успешные тесты;
— аналогичный метод, только под проваленные тесты;
— метод, который знает, когда все тесты завершены и может “дернуть” наш генератор самого отчета (которого пока нет).
Для работы с html в java была выбрана библиотека gagawa. Тут вы можете сверстать отчет так, как вам захочется, отталкиваясь как от имеющихся у вас параметров, так и от требуемых, на ваше усмотрение, метрик для отчета. После — подключить в проект простенькую css-ку для лучшей визуализации нашего отчета и работы со стилями.
Теперь непосредственно о реализации данных фич у меня (комментарии для читабельности).
Переменные retryCount и retryMaxCount позволяют управлять количеством требуемых перезапусков в случае провала теста. В остальном, считаю код вполне читабельным.
Тут ловим успешные тесты, как вы уже знаете.
Дергаем наш отчет, т.к. понимаем, что все тесты уже завершены.
Сам генератор нашего html-отчета.
Также нужно добавить в файл testng.xml следующий тег с указанием пути к классу Reporter:
Визуализация конечного результата остается полностью на ваше усмотрение. К примеру отчет, который вы видите в коде выше, выглядит вот так:
В заключение хочу сказать, что столкнувшись с довольно тривиальной, на первый взгляд, проблемой, решение на выходе мы получили уже совсем не тривиальное.
Возможно не достаточно изящное или простое — в таком приветствую критику в комментариях. Для себя главным плюсом данного набора я вижу универсальность: переиспользовать разработку можно будет на любом java+testng-проекте в дальнейшем.
Мой github с данным проектом.
Вечной проблемой каждого тестировщика при запуске автотестов является “падение” отдельных сценариев от запуска к запуску рандомно. И речь идет не о падении наших тестов по объективным причинам (т.е. действительно имеет место ошибка в работе тестируемого функционала, или же сам тест написан не корректно), а как раз о тех случаях, когда после перезапуска ранее проваленные тесты чудом проходят. Причин такого рандомного падения может быть масса: отвалился интернет, перегрузка CPU / отсутствие свободной RAM на устройстве, таймаут и др. Вопрос — как исключить или хотя бы уменьшить количество таких не объективно проваленных тестов?
Для меня данный челлендж возник при следующих обстоятельствах:
1) текущее приложение автотестов было решено разместить на сервере (CI);
2) реализация мультипоточности в проекте превратилась из желания в mustHave (в виду необходимости сокращения времени регрессионного тестирования сервиса).
Второму пункту лично я был очень рад, так как считаю, что любой процесс, который может длиться меньшее количество времени — обязательно должен поступать именно таким образом (будь то прохождение автотеста или очередь на кассе в супермаркете: чем быстрее мы можем завершить эти процессы, тем больше времени у нас остается для занятий чем-то действительно интересным). Так вот, разместив наши тесты на сервере (тут нам помогли админы и их знание jenkins) и запустив их в потоках (тут уже помогла наша усидчивость и эксперименты с testng.xml), мы получили сокращение времени прохождения тестов из 100 минут до 18, но одновременно мы получили прирост в проваленных тестах >2 раза. Поэтому к первым двум пунктам добавился следующий (собственно, сам челлендж, которому и посвящена эта статья):
3) реализовать перезапуск проваленных тестов в процессе одной сборки.
Объем третьего пункта и требования к нему постепенно разрастались, но опять же, обо всем по порядку.
Реализовать перезапуск проваленного теста testng позволяет из коробки, благодаря интерфейсу IRetryAnalyzer. Данный интерфейс предоставляет нам boolean-метод retry, который и отвечает за перезапуск теста, в случае возврата им true, или же отсутствие перезапуска при false. Передать в данный метод нам нужно результат нашего теста (ITestResult result).
Теперь проваленные тесты начали перезапускаться, но была выявлена следующая неприятная особенность: все провальные попытки прохождения теста неминуемо попадают в отчетники (так как реально ваш тест, пусть один и тот же, проходит несколько раз подряд — по нему поступает такое же количество результатов, которые честно попадают в отчет). Возможно, некоторым тестировщикам данная проблема покажется надуманной (особенно, если отчет вы никому не показываете, не предоставляете его техлидам, менеджерам и заказчикам). В таком случае, действительно, можно пользоваться maven-surefire-report-plugin и периодически злиться, ломая глаза, чтобы понять, провален ваш тест или таки нет.
Мне явно не подходила перспектива кривого отчета, потому поиски решений были продолжены.
Рассматривались варианты парсинга html-отчета для удаления дублирующихся проваленных тестов. Также предлагали мержить результаты нескольких отчетов в 1 конечный. Подумав, что костыльные решения могут аукнуться нам, когда с очередным обновлением report-плагинов будет изменена структура их html/xml отчетов, было принято решение реализовать создание собственного кастомного отчета. Единственный минус такого решения — время на его разработку и тестирование. Плюсов я увидел гораздо больше, и главный из них — гибкость. Отчет можно сформировать так, как нужно или нравится вам. Всегда можно добавить дополнительные параметры, поля, метрики.
Итак, было понятно, в каком месте в отчет будут складываться проваленные тесты — это блок retry-метода, в котором количество попыток перезапуска тестов уже было исчерпано. Далее определились с тем, откуда складывать успешные. Интерфейс ITestListener. Из семи методов данного интерфейса нам идеально подошел onTestSuccess, т.к. успешные тесты всегда заходят в данный метод. Итого, у нас есть две точки в нашем приложении, откуда к нам в отчет будут складываться успешные и проваленные тесты.
Следующий вопрос: в какой момент дернуть наш отчет, чтобы к этому времени все тесты были завершены. Тут на помощь приходит следующий интерфейс — IReporter и его метод generateReport.
Итак, теперь у нас есть:
— метод, откуда мы будем укладывать в отчет успешные тесты;
— аналогичный метод, только под проваленные тесты;
— метод, который знает, когда все тесты завершены и может “дернуть” наш генератор самого отчета (которого пока нет).
Для работы с html в java была выбрана библиотека gagawa. Тут вы можете сверстать отчет так, как вам захочется, отталкиваясь как от имеющихся у вас параметров, так и от требуемых, на ваше усмотрение, метрик для отчета. После — подключить в проект простенькую css-ку для лучшей визуализации нашего отчета и работы со стилями.
Теперь непосредственно о реализации данных фич у меня (комментарии для читабельности).
RetryAnalyzer:
Переменные retryCount и retryMaxCount позволяют управлять количеством требуемых перезапусков в случае провала теста. В остальном, считаю код вполне читабельным.
public class RetryAnalyzer implements IRetryAnalyzer {
private int retryCount = 0;
private int retryMaxCount = 3;
// решаем, требует ли тест перезапуска
@Override
public boolean retry(ITestResult testResult) {
boolean result = false;
if (testResult.getAttributeNames().contains("retry") == false) {
System.out.println("retry count = " + retryCount + "\n" +"max retry count = " + retryMaxCount);
if(retryCount < retryMaxCount){
System.out.println("Retrying " + testResult.getName() + " with status "
+ testResult.getStatus() + " for the try " + (retryCount+1) + " of "
+ retryMaxCount + " max times.");
retryCount++;
result = true;
}else if (retryCount == retryMaxCount){
// тут будем складывать в отчет неуспешные тесты
// получаем все необходимые параметры теста
String testName = testResult.getName();
String className = testResult.getTestClass().toString();
String resultOfTest = resultOfTest(testResult);
String stackTrace = testResult.getThrowable().fillInStackTrace().toString();
System.out.println(stackTrace);
// и записываем в массив тестов
ReportCreator.addTestInfo(testName, className, resultOfTest, stackTrace);
}
}
return result;
}
// простенький метод для записи в результат теста saccess / failure
public String resultOfTest (ITestResult testResult) {
int status = testResult.getStatus();
if (status == 1) {
String TR = "Success";
return TR;
}
if (status == 2) {
String TR = "Failure";
return TR;
}
else {
String unknownResult = "not interested for other results";
return unknownResult;
}
}
}
TestListener
Тут ловим успешные тесты, как вы уже знаете.
public class TestListener extends TestListenerAdapter {
// успешные всегда заходят в onSuccess юзаем его
@Override
public void onTestSuccess(ITestResult testResult) {
System.out.println("on success");
// в этом методе складываем в массив успешные тесты, определяем их параметры
String testName = testResult.getName();
String className = testResult.getTestClass().toString();
String resultOfTest = resultOfTest(testResult);
String stackTrace = "";
ReportCreator.addTestInfo(testName, className, resultOfTest, stackTrace);
}
// еще 1 простенький метод для записи в результат теста saccess / failure
public String resultOfTest (ITestResult testResult) {
int status = testResult.getStatus();
if (status == 1) {
String TR = "Success";
return TR;
}
if (status == 2) {
String TR = "Failure";
return TR;
}
else {
String unknownResult = "not interested for other results";
return unknownResult;
}
}
}
Reporter
Дергаем наш отчет, т.к. понимаем, что все тесты уже завершены.
public class Reporter implements IReporter {
// метод, который стартует после окончания всех тестов и дергает наш getReport для получения html в string
@Override
public void generateReport(List<XmlSuite> xmlSuites, List<ISuite> suites, String outputDirectory) {
PrintWriter saver = null;
try {
saver = new PrintWriter(new File("report.html"));
saver.write(ReportCreator.getReport());
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if (saver != null) {
saver.close();
}
}
}
}
ReportCreator
Сам генератор нашего html-отчета.
public class ReportCreator {
public static Document document;
public static Body body;
public static ArrayList<TestData> list = new ArrayList<TestData>();
// изображение для хедера отчета
public static void headerImage (){
Img headerImage = new Img("", "src/main/resources/baad.jpeg");
headerImage.setCSSClass("headerImage");
body.appendChild(headerImage);
}
// общий блок отчета (все запущенные тесты: успех + неуспех)
public static void addTestReport(String className, String testName, String status) {
if (status == "Failure"){
Div failedDiv = new Div().setCSSClass("AllTestsFailed");
Div classNameDiv = new Div().appendText(className);
Div testNameDiv = new Div().appendText(testName);
Div resultDiv = new Div().appendText(status);
failedDiv.appendChild(classNameDiv);
failedDiv.appendChild(testNameDiv);
failedDiv.appendChild(resultDiv);
body.appendChild(failedDiv);
}else{
Div successDiv = new Div().setCSSClass("AllTestsSuccess");
Div classNameDiv = new Div().appendText(className);
Div testNameDiv = new Div().appendText(testName);
Div resultDiv = new Div().appendText(status);
successDiv.appendChild(classNameDiv);
successDiv.appendChild(testNameDiv);
successDiv.appendChild(resultDiv);
body.appendChild(successDiv);
}
}
// тут записываем в отчет основные метрики рана (общее кол-во тестов, кол-во успешных и неуспешных тестов)
public static void addCommonRunMetrics (int totalCount, int successCount, int failureCount) {
Div total = new Div().setCSSClass("HeaderTable");
total.appendText("Total tests count: " + totalCount);
Div success = new Div().setCSSClass("HeaderTable");
success.appendText("Passed tests: " + successCount);
Div failure = new Div().setCSSClass("HeaderTable");
failure.appendText("Failed tests: " + failureCount);
body.appendChild(total);
body.appendChild(success);
body.appendChild(failure);
}
// тут формируем отдельный блок с упавшими тестами в хедер отчета для наглядности
public static void addFailedTestsBlock (String className, String testName, String status) {
Div failed = new Div().setCSSClass("AfterHeader");
Div classTestDiv = new Div().appendText(className);
Div testNameDiv = new Div().appendText(testName);
Div statusTestDiv = new Div().appendText(status);
failed.appendChild(classTestDiv);
failed.appendChild(testNameDiv);
failed.appendChild(statusTestDiv);
body.appendChild(failed);
}
// тут формируем отдельный блок в футтер отчета со стектрейсами зафейленных тестов
public static void addfailedWithStacktraces (String className, String testName, String status, String stackTrace) {
Div failedWithStackTraces = new Div().setCSSClass("Lowest");
failedWithStackTraces.appendText(className + " " + testName + " " + status + "\n");
Div stackTraceDiv = new Div();
stackTraceDiv.appendText(stackTrace);
body.appendChild(failedWithStackTraces);
body.appendChild(stackTraceDiv);
}
// тут складываем в arraylist наши тесты с нужными для отчета параметрами
public static void addTestInfo(String testName, String className, String status, String stackTrace) {
TestData testData = new TestData();
testData.setTestName(testName);
testData.setClassName(className);
testData.setTestResult(status);
testData.setStackTrace(stackTrace);
list.add(testData);
}
// итоговый метод, который вызывается после прохождения всех тестов для формирования html-отчета
public static String getReport() {
document = new Document(DocumentType.XHTMLTransitional);
Head head = document.head;
Link cssStyle= new Link().setType("text/css").setRel("stylesheet").setHref("src/main/resources/site.css");
head.appendChild(cssStyle);
body = document.body;
// тут будет общее кол-во тестов
int totalCount = list.size();
// тут формируем массив зафейленных тестов
ArrayList failedCountArray = new ArrayList();
for (int f=0; f < list.size(); f++) {
if (list.get(f).getTestResult() == "Failure") {
failedCountArray.add(f);
}
}
int failedCount = failedCountArray.size();
// получаем кол-во успешных тестов
int successCount = totalCount - failedCount;
// записываем в html нашу картинку в хедере
headerImage();
// записываем в html основные метрики
addCommonRunMetrics(totalCount, successCount, failedCount);
// записываем в html зафейленные тесты
for (int s = 0; s < list.size(); s++){
if (list.get(s).getTestResult() == "Failure"){
addFailedTestsBlock(list.get(s).getClassName(), list.get(s).getTestName(), list.get(s).getTestResult());
}
}
// проверяем, что массив с тестами всего рана не пуст
if(list.isEmpty()){
System.out.println("ERROR: TEST LIST IS EMPTY");
return "";
}
// сортируем в нашем массиве тесты по классам (для красивого отсортированного отчета) + записываем их в html
String currentTestClass = "";
ArrayList constructedClasses = new ArrayList();
for(int i=0; i < list.size();i++){
currentTestClass = list.get(i).getClassName();
//проверка создали ли мы хтмл для текущего класса
boolean isClassConstructed=false;
for(int j=0;j<constructedClasses.size();j++){
if(currentTestClass.equals(constructedClasses.get(j))){
isClassConstructed=true;
}
}
if(!isClassConstructed){
for (int k=0;k<list.size();k++){
if(currentTestClass.equals(list.get(k).getClassName())){
addTestReport(list.get(k).getClassName(), list.get(k).getTestName(),list.get(k).getTestResult());
}
}
constructedClasses.add(currentTestClass);
}
}
// получаем необходимые параметры зафейленных тестов + записываем их в html
for (int z = 0; z < list.size(); z++){
if (list.get(z).getTestResult() == "Failure"){
addfailedWithStacktraces(list.get(z).getClassName(), list.get(z).getTestName(), list.get(z).getTestResult(), list.get(z).getStackTrace());
}
}
return document.write();
}
// наш класс теста с необходимыми для отчета параметрами + getter'ы / setter'ы
public static class TestData{
String testName;
String className;
String testResult;
String stackTrace;
public TestData() {}
public String getTestName() {
return testName;
}
public String getClassName() {
return className;
}
public String getTestResult() {
return testResult;
}
public String getStackTrace() {
return stackTrace;
}
public void setTestName(String testName) {
this.testName = testName;
}
public void setClassName(String className) {
this.className = className;
}
public void setTestResult(String testResult) {
this.testResult = testResult;
}
public void setStackTrace(String stackTrace) {
this.stackTrace = stackTrace;
}
}
}
Сам класс с тестами
@Listeners(TestListener.class) // необходимо навесить данную аннотацию над классом тестов, чтобы они анализировались TestListener
public class Test {
private static WebDriver driver;
@BeforeClass
public static void init () {
driver = new FirefoxDriver();
driver.get("http://www.last.fm/ru/");
}
@AfterClass
public static void close () {
driver.close();
}
@org.testng.annotations.Test (retryAnalyzer = RetryAnalyzer.class) // данная аннотация необходима для подключения RetryAnalyzer к конкретному тесту
public void findLive () {
driver.findElement(By.cssSelector("[href=\"/ru/dashboard\"]")).click();
}
}
Также нужно добавить в файл testng.xml следующий тег с указанием пути к классу Reporter:
<listeners>
<listener class-name= "retry.Reporter" />
</listeners>
Визуализация конечного результата остается полностью на ваше усмотрение. К примеру отчет, который вы видите в коде выше, выглядит вот так:
В заключение хочу сказать, что столкнувшись с довольно тривиальной, на первый взгляд, проблемой, решение на выходе мы получили уже совсем не тривиальное.
Возможно не достаточно изящное или простое — в таком приветствую критику в комментариях. Для себя главным плюсом данного набора я вижу универсальность: переиспользовать разработку можно будет на любом java+testng-проекте в дальнейшем.
Мой github с данным проектом.