Как стать автором
Обновить

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

Время на прочтение10 мин
Количество просмотров9.9K
В данной статье речь пойдет об использовании фреймворка 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-ку для лучшей визуализации нашего отчета и работы со стилями.

Теперь непосредственно о реализации данных фич у меня (комментарии для читабельности).

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 с данным проектом.
Теги:
Хабы:
Всего голосов 9: ↑9 и ↓0+9
Комментарии5

Публикации

Истории

Работа

Java разработчик
361 вакансия

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань