Мессенджеры на работе — это не прокрастинация, или как мы сделали сервис для автотестирования

Мессенджеры на работе — это не прокрастинация, или как мы сделали сервис для автотестирования, image #1

Однажды у нас появилась идея отправлять отчеты о выполнении автотестов в мессенджер Telegram. Постепенно эта идея переросла в сервис, который который позволяет запускать тесты, оповещать об ошибках и получать отчеты в любом мессенджере, где есть возможность интегрировать бота. Помимо доступа к результатам автотестов, бот получил интеграцию с Jira и другими инструментами, которые мы используем в РТЛабс.

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

Введение

Привет! Пара слов о нас. Мы — Сергей Кондитеров и Сергей Бушмелев, ведущие инженеры по автоматизации тестирования в компании РТЛабс.

Нашу историю по интеграции мессенджеров в наши процессы можно условно поделить на четыре этапа развития:

  • внедрение оповещений в фреймворк автотестов;
  • написание бота для запуска и формирования отчетов для конкретной системы;
  • написание бота для управления автотестами на проде (кстати, о том, как мы тестируемся на проде, можно почитать в статье Дмитрия Пирумова);
  • бот с неограниченным количеством интеграций и набором функциональностей.

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

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

Мессенджеры на работе — это не прокрастинация, или как мы сделали сервис для автотестирования, image #2

Внедрение оповещений в фреймворк

Несколько лет назад у нас появилась задача по покрытию автотестами одной из наших автоматизированных систем. Задача ничем не отличалась от других:

  • написал автотесты
  • получил доступ в Jenkins
  • настроил джоб
  • тест-менеджер получает отчеты в почту
  • PROFIT

Проект автотестов успешно развивался, расширялось тестовое покрытие, увеличивалось количество потребителей отчета. Начали поступать новые «хотелки» и проявились первые проблемы:

  • не у всех пользователей есть доступ к Jenkins и желание этот доступ получить
  • неудобно корректировать список рассылки в Jenkins
  • отчеты в почту превращались в спам
Мессенджеры на работе — это не прокрастинация, или как мы сделали сервис для автотестирования, image #3

Выбор решения

В нашей компании мы используем Telegram в качестве основного инструмента общения, поэтому отправка оповещений в Telegram показалось наиболее интересным вариантом.

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

  • много времени нужно было потратить на согласование его установки
  • если бы мы переехали на другой Jenkins, то плагин придется настраивать заново
  • после настройки плагина необходимо изменять пайплайн джоба
  • было желание подстроить решение под свои нужды
  • нет гарантии, что готовые решения будут поддерживаться и дорабатываться (последнее обновление плагина выходило 3 года назад)

Мы приняли решение идти другим путем — сделать отправку сообщений прямо из фреймворка автотестирования.

Доработка фреймворка

На самом деле доработка оказалась простой и удобной в для внедрения.

Нам понадобилось только создать бота в Telegram, добавить его в общий чат (где планировали отправлять результаты) и добавить несколько методов в фреймворк (Java + Junit 4 + Cucumber).

Был реализован метод для отправки сообщения в Telegram через API:

public static void report(String text) throws IOException{

        String urlString = "https://api.telegram.org/bot%s/sendMessage?parse_mode=HTML&chat_id=%s&text=%s";
        urlString = String.format(urlString, TOKEN, CHAT, text);
        //прокси
  //      Proxy proxy = new Proxy(Proxy.Type.SOCKS, new InetSocketAddress(PROXY_HOST, PROXY_PORT));
        //авторизация в прокси
        Authenticator.setDefault(new MyAuthenticator());

        URL url = new URL(urlString);
  //      URLConnection conn = url.openConnection(proxy);
        URLConnection conn = url.openConnection();

        ...
    }

В нашем фреймворке мы сделали несколько новых методов, которые отправляли сообщения через REST API Telegram:

  • @After, который отправлял результат после каждого теста в чат
@After
    public void tearDown(Scenario scenario) throws IOException {
        
        ... выполняем какие-то действия после теста

        String result;
        if (scenario.isFailed()) {
            
            ... формируем строку для отправки и отправляем, если отправка нужна (иначе просто логируем)
            
            if (System.getProperty("tg") != null) {
                report(result);
            }
        }
        ...
    }

Внимание!

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

Мессенджеры на работе — это не прокрастинация, или как мы сделали сервис для автотестирования, image #4
  • @BeforeClass и @AfterClass в Junit RunnerClass, чтобы оповестить о начале тестов и их окончании
@RunWith(Cucumber.class)

@CucumberOptions(
       …настройки огурца
)


public class RunnerTest {
    …
    @BeforeClass
    public static void start_report() throws Exception {
        …
            //необходимость выполнения репорта в тг
        if (System.getProperty("tg") == null){
            //не нужны репорты в тг
        } else {
           String text;
           … Формируем текст сообщения 
           report(text);
        }
       …
    }

    @AfterClass
    public static void finish_report() throws Exception {
        …
            //необходимость выполнения репорта в тг
        if (System.getProperty("tg") == null){
            //не нужны репорты в тг
        } else {
           String text;
           … Формируем текст сообщения 
           report(text);
        }
       …
    }
}

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

  • среда выполнения
  • теги запуска
  • количество прошедших/упавших тестов
  • детализация по упавшим кейсам

Этих данных достаточно для принятия решения об успешном тестировании релиза.

Мессенджеры на работе — это не прокрастинация, или как мы сделали сервис для автотестирования, image #5

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

Наш первый бот

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

Изучив документацию, статьи на habr и примеры готовых решений, было решено сделать простого бота в Telegram.

Концепция

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

Ролевая модель

Модель доступа планировалась очень простой. Предполагалось, что каждый пользователь, написавший боту, будет добавлен в базу данных и получит роль - неавторизованный пользователь. Далее администратор может добавить ему права. Модель включала в себя следующие роли:

  • UNAUTHORIZED — доступ к командам отсутствует
  • USER — имеет доступ к командам получения отчетов и справочной информации
  • JENKINS_USER — включает в себя права USER и имеет возможность запускать тесты
  • ADMIN — имеет доступ ко всем командам

Команды бота

В боте мы планировали реализовать следующие команды:

  • /test — запуск тестов, принимает в качестве параметров название среды и теги для запуска
  • /reports — генерация excel отчета о тестировании, который прикладывается к отчету о тестировании
  • /links — полезные ссылки
  • /help — справка по командам
  • /anekdot — прислать случайны анекдот в чат (немного юмора никогда не повредит)
  • /access — команда для администратора бота, чтобы давать людям доступ к остальным командам

Реализация

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

Бота реализовывали на Java, для хранения информации о пользователях подняли БД PostgreSQL. Основная библиотека:

<dependency>
     <groupId>org.telegram</groupId>
     <artifactId>telegrambots</artifactId>
     <version>4.9.1</version>
 </dependency>

Было необходимо написать класс, который унаследован от TelegramLongPollingCommandBot. В этом классе мы указываем имя и токен нашего бота, далее регистрируем команды и настраиваем обработку сообщений без команд:

public final class RunnerBot extends TelegramLongPollingCommandBot {
    // имя бота, которое мы указали при создании аккаунта у BotFather
    // и токен, который получили в результате
    private static final String BOT_NAME = "имя_вашего_бота";
    private static final String BOT_TOKEN = "токен_вашего_бота";


    public RunnerBot(DefaultBotOptions botOptions) {

        super(botOptions, BOT_NAME);

        // регистрация всех кастомных команд
        System.out.println("Registering '/test'...");
        register(new TestCommand());
        ... регистрируем остальные команды

        // обработка неизвестной команды
        System.out.println("Registering default action'...");
        registerDefaultAction(((absSender, message) -> {
            ... пишем, что делать с неизвестной командой
        }));
    }

    @Override
    public String getBotToken() {
        return BOT_TOKEN;
    }

    // обработка сообщения не начинающегося с '/'
    @Override
    public void processNonCommandUpdate(Update update) {
        System.out.println("Processing non-command update...");
        if (!update.hasMessage()) {
           System.out.println("Update doesn't have a body!");
            throw new IllegalStateException("Update doesn't have a body!");
        }
    }
}

Далее бота необходимо инициализировать в Main-Class:

public class BotInitializer {

    private static final String PROXY_HOST = "XX.XX.XX.XX";
    private static final int PROXY_PORT = 8080;
//    private static final String PROXY_USER = "bot";
//    private static final String PROXY_PASSWORD = "bot";

    public static void main(String[] args) {

        try {

            Authenticator.setDefault(new MyAuthenticator());

            System.out.println("Initializing API context...");
            ApiContextInitializer.init();

            TelegramBotsApi botsApi = new TelegramBotsApi();

            System.out.println("Configuring bot options...");
            DefaultBotOptions botOptions = ApiContext.getInstance(DefaultBotOptions.class);

            botOptions.setProxyHost(PROXY_HOST);
            botOptions.setProxyPort(PROXY_PORT);
            botOptions.setProxyType(DefaultBotOptions.ProxyType.HTTP);


            System.out.println("Registering ...");
            botsApi.registerBot(new RunnerBot(botOptions));

            System.out.println("bot is ready for work!");

        } catch (TelegramApiRequestException e) {
            System.out.println("Error while initializing bot!" + e);
        }
    }
}

Команды, которые мы регистрируем в боте должны быть унаследованы от класса BotCommand, так, например, выглядела TestCommand:

public final class TestCommand extends BotCommand {


    public TestCommand() {
        super("test", "Команда для запуска тестов синтаксис: /test СРЕДА ТЭГ комментарий(опционально)\n");
    }

    @Override
    public void execute(AbsSender absSender, User user, Chat chat, String[] strings) {
            ... авторизируем пользователя - сверяем id пользователя и его роль в БД
            if (isValid(strings)) {
                String comment = "";

                testMessageBuilder = new StringBuilder("<b>Запускаем job в Jenkins</b>\n");
                testMessageBuilder.append("Cреда выполнения: <b>" + strings[0].toUpperCase() + "</b>\n");
                testMessageBuilder.append("Вы указали теги: <b>" + strings[1] + "</b>\n");

                String userId = jdbc.FindUserId(user);
                //запускаем джоб в Jenkins
                Jenkins j = new Jenkins();
                j.runJenkinsJobWithParams(strings[0].toUpperCase(), strings[1],userId);
                
                testMessage = new SendMessage();
                testMessage.setChatId(chat.getId().toString());
                testMessage.enableHtml(true);
                testMessage.setText(testMessageBuilder.toString());

                execute(absSender, testMessage, user);
            } else {
                //запускаем вспомогательную команду
                KonturCommand konturCommand = new KonturCommand();
                konturCommand.execute(absSender, user, chat, strings);
            }
        
    }
    
    ...
    
    private boolean isValid(String[] params) {
        ... валидация, что введенные параметры верны
    }    
}

Сам запуск джоба был реализован через вызов rest API Jenkins:

URL url = new URL("http://XX.XX.XX.XX/job/AUTOTESTS.XXXX/buildWithParameters?token=remote_starter"); // Jenkins URL 
String user = "XYZ"; // username
String pass = "remote_token"; // password or API token
String authStr = user + ":" + pass;
String encoding = Base64.getEncoder().encodeToString(authStr.getBytes(StandardCharsets.UTF_8));
 
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Authorization", "Basic " + encoding);

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

Запуск тестов на проде

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

Концепция

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

Для возможности запуска различных джобов мы вводим понятие пресет — заранее сформированный набор параметров для запуска тестов. Далее при помощи кнопок бота мы отрисовываем меню для запуска тестов, на первом уровне меню пользователь выбирает какую систему тестирует, на втором — какой набор тестов запускает.

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

Мессенджеры на работе — это не прокрастинация, или как мы сделали сервис для автотестирования, image #6

Реализация

Для реализации такого проекта была решено использовать Spring Boot и подключено 2 библиотеки:

Для работы бота:

<dependency>
    <groupId>org.telegram</groupId>
    <artifactId>telegrambots-spring-boot-starter</artifactId>
    <version>5.7.1</version>
</dependency>

Для работы с Jenkins:

<dependency>
    <groupId>com.offbytwo.jenkins</groupId>
    <artifactId>jenkins-client</artifactId>
    <version>0.3.8</version>
</dependency>

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

Мы предусмотрели возможность запуска бота через прокси или без использования. Для этого мы использовали аннотацию @ConditionalOnProperty:

 @Bean
    @ConditionalOnProperty(name = "bot.proxy.enabled", havingValue = "false", matchIfMissing = true)
    public Bot defaultTelegramBot() {
        log.debug("defaultAlertsTelegramBot bean creation");
        log.debug("defaultAlertsTelegramBot is created");
        this.bot = new Bot(new DefaultBotOptions(), botSettings, updateReceiver,userService,notificationService, menuService);
        return this.bot;
    }

    @Bean
    @ConditionalOnProperty(name = "bot.proxy.enabled", havingValue = "true")
    public Bot proxyTelegramBot() {
        log.debug("proxyAlertsTelegramBot bean creation");
        log.debug("setting: {}", botSettings);
        DefaultBotOptions defaultBotOptions = new DefaultBotOptions();
        defaultBotOptions.setProxyType(botSettings.getProxy().getType());
        defaultBotOptions.setProxyHost(botSettings.getProxy().getHost());
        defaultBotOptions.setProxyPort(botSettings.getProxy().getPort());
        log.debug("proxyAlertsTelegramBot is created");
        this.bot = new Bot(defaultBotOptions, botSettings, updateReceiver,userService,notificationService, menuService);
        return this.bot;
    }

Сам же класс бота унаследован от TelegramLongPollingBot, в котором нас интересует метод onUpdateReceived(Update update). В данном методе мы передаем полученное сообщение в специальный бин — обработчик команд HandlerService (про устройство обработчиков команд подробно расскажем в следующей главе), в котором запрос парсится и обрабатывается:

 @Override
    public void onUpdateReceived(Update update) {

        HandlerResponse handlerResponse = handlerService.handle(update);

        if (handlerResponse.getContent() == null) {
            log.info("Обработчик не вернул сообщений для отправки");
        } else if (!handlerResponse.getContent().getMessagesToExecute().isEmpty()) {
            Content content = handlerResponse.getContent();
            content.getMessagesToExecute().forEach(botMessage -> {
                executeWithExceptionCheck(botMessage);
            });
        }
    }

Один из множества обработчиков команд отвечал за запуск джобов в Jenkins. Для возможности запуска был разработан метод startBuild(), в который было необходимо передать джоб и параметры пресета:

public Long startBuild(JobEntity job, Iterable<PresetParamEntity> params){
        Long queue_id = 0L;
        Jenkins jobJenkins =  job.getJenkins();
        try {
            JenkinsServer jenkinsServer = new JenkinsServer(new URI(jobJenkins.getUrl()), jobJenkins.getUser(), jobJenkins.getToken());
            JobWithDetails jenkinsJob = jenkinsServer.getJob(job.getJob());

            Map<String, String> paramMap = new HashMap<>();
            for (PresetParamEntity paramEntity: params) {
                paramMap.put(paramEntity.getParam(),paramEntity.getValue());
            }

            QueueReference queueReference;

            if (paramMap.isEmpty()){
                queueReference = jenkinsJob.build();
            } else {
                queueReference = jenkinsJob.build(paramMap);
            }
          
           try {
                String urlPart = queueReference.getQueueItemUrlPart();
                Pattern regex = Pattern.compile("https://([^']*?)/queue/item/(\\d*)/");
                Matcher m = regex.matcher(urlPart);
                if (m.find()) {
                    log.debug("id новой очереди {}", m.group(2));
                    queue_id = Long.valueOf(m.group(2));
                }
            } catch (Exception e) {
                log.error("Ошибка при получени id очереди");
            }

        } catch (Exception e){
            ... обрабатываем ошибку
        }
        return queue_id;
    }

К нашему сожалению, в библиотеке мы не смогли найти метода для работы с очередью запросов, поэтому для отслеживания результатов запущенного нами джоба мы применяем «костыль» - через rest API вытягиваем id QueueItem. Эта и другая метаинформация записывается в объект TrackEntity, который хранится в БД. Далее по шедулеру пытаемся найти статус нашего TrackEntity:

@Scheduled(fixedDelay = 5000)
    protected void checkTracked() {
        log.info("Запущена проверка отслеживаемых сущностей");
        Iterable<TrackEntity> allTrackedJobs = trackService.getAllQueued();
        procedures.checkBuilds(allTrackedJobs);
        log.info("Проверка завершена");
    }

Поиск происходит так:

  /**
     * проверка выполнения джоба, который был запущен из бота
     * @param trackedJobs отслеживаемые джобы
     * */
    @Async
    public void checkBuilds (Iterable<TrackEntity> trackedJobs){

        for (TrackEntity trackEntity: trackedJobs) {
            log.debug("Проверяем изменения в отслеживаемом джобе {}", trackEntity.toString());
            //получаем информацию о jenkins, в котором запущена сборка
            Jenkins jenkins = trackEntity.getJob().getJenkins();
            //получаем сущность джоба
            JobEntity job = trackEntity.getJob();
            //пытаемся проверить статус сборки
            try {
                //подключаемся к дженкинс
                JenkinsServer jenkinsServer = new JenkinsServer(new URI(jenkins.getUrl()),
                        jenkins.getUser(),
                        jenkins.getToken());

                //проверка, что билд еще в очереди
                boolean isInQueue = false;
                Queue queue = jenkinsServer.getQueue();
                //перебираем очередь в поиске нужного id
                for (QueueItem queueItem : queue.getItems()) {
                    if (queueItem.getId().equals(trackEntity.getQueueId())) {
                        isInQueue = true;
                        log.debug("Билд еще в очереди. {}", trackEntity);
                        break;
                    }
                }

                log.debug("Билд в очереди? {}", isInQueue);

                if (!isInQueue) {
                    log.debug("Билд уже выполняется. {}", trackEntity);
                    //получаем джоб
                    JobWithDetails jobWithDetails = jenkinsServer.getJob(trackEntity.getJob().getJob());

                    if (trackEntity.getBuildId() == null) {
                        for (Build build : jobWithDetails.getAllBuilds()) {
                            log.debug("Сравнение id queue item: {} в билде - {} в отслеживаемой сущности", build.details().getQueueId(), trackEntity.getQueueId());
                            if (build.details().getQueueId() == trackEntity.getQueueId()) {
                                ... оповещаем пользователя, что билд в очереди
                            }
                        }
                    } else {
                        Build buildNoDetails = jobWithDetails.getBuildByNumber(trackEntity.getBuildId());
                        BuildWithDetails build = buildNoDetails.details();
                        if (build.isBuilding()) {
                            log.debug("Билд все еще собирается. {}", trackEntity);
                        } else {
                            //обновляем запись о том, что билд уже не собирается
                            trackEntity.setIsBuilding(false);
                            trackService.updateTrack(trackEntity);
                            //отправляем разные отчеты
                            List<Artifact> artifactList = build.getArtifacts();
                            int reportsSendedCounter = 0;
                            for (Artifact artifact :
                                    artifactList) {
                                if (artifact.getFileName().toLowerCase().matches(".*tests.html.*")){
                                    try {
                                        ... обрабатываем файл для формирования html отчета в нужном формате
                                        ... отправляем сообщение в чат
                                    } catch (Exception e) {
                                        log.error("Возникла ошибки при обработке файла {}.", artifact.getFileName(), e);                            
                                    }
                                } else if (artifact.getFileName().contains("allure-report.zip")) {
                                    ... парсинг результатов выполнения allure для формирования сообщения
                                    ... отправляем сообщение в чат
                                }
                            }
                            //если нет отправленных отчетов, то отправляем отписку
                            if (reportsSendedCounter == 0) {
                                ... отправляем сообщение пустышку
                            }

                        }
                    }

                }
            } catch (URISyntaxException | IOException e) {
                ... обрабатываем ошибки
            }
        }
    }
Мессенджеры на работе — это не прокрастинация, или как мы сделали сервис для автотестирования, image #7

Заключение

Таким образом, мы сделали бот для запуска автотестов и выдачи результатов в Telegram.
Ключевые особенности:

  • Имеет ролевую модель для разграничения доступа пользователя;
  • Имеет меню для запуска джобов в Jenkins;
  • Оповещает пользователя о выполнении запущенного им теста с подробным отчетом.

Однако реализованная концепция бота не является оптимальной. О том, как убрать зависимость от конкретного мессенджера и сделать систему масштабируемой, мы поговорим уже в следующей статье:) Stay tuned.

Источник

335 views·22 shares