Реалізація автоматичного перезапуску failed-тестів в поточній збірці і подолання супутніх бід

У даній статті мова піде про використання фреймворку testNG, а конкретно — про реалізовані в ньому і досить рідко використовуються інтерфейси: IRetryAnalyzer, ITestListener, IReporter. Але про все по порядку.

Вічною проблемою кожного тестувальника при запуску автотестів є «падіння» окремих сценаріїв від запуску до запуску рандомно. І мова йде не про падіння наших тестів з об'єктивних причин (тобто дійсно має місце помилка в роботі досліджуваного функціоналу, або ж сам тест написаний не коректно), а саме про тих випадках, коли після перезапуску раніше провалені тести дивом проходять. Причин такого рандомного падіння може бути маса: відвалився інтернет, перевантаження CPU / відсутність вільної ПАМ'ЯТІ на пристрої, таймаут та ін. Питання — як виключити або хоча б зменшити кількість таких не об'єктивно провалених тестів?

Для мене цей челендж виник при наступних обставинах:

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>

Візуалізація кінцевого результату залишається повністю на ваш розсуд. Приміром звіт, який ви бачите в коді вище, виглядає ось так:

image

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

Можливо не досить витончене або просте — в такому вітаю критику в коментарях. Для себе головним плюсом даного набору я бачу універсальність: переиспользовать розробку можна буде на будь-якому java+testng-проекті в подальшому.

Мій github з даним проектом.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.