Однажды у нас появилась идея отправлять отчеты о выполнении автотестов в мессенджер Telegram. Постепенно эта идея переросла в сервис, который который позволяет запускать тесты, оповещать об ошибках и получать отчеты в любом мессенджере, где есть возможность интегрировать бота. Помимо доступа к результатам автотестов, бот получил интеграцию с Jira и другими инструментами, которые мы используем в РТЛабс.
В данной серии статей мы хотим рассказать нашу историю о том, с чего все начиналось, какие у нас были идеи, какие ошибки мы совершали и как мы их решили в итоговой реализации.
2. Внедрение оповещений в фреймворк
2.1. Выбор решения
2.2. Доработка фреймворка
3. Наш первый бот
3.1. Концепция
3.1.1. Ролевая модель
3.1.2. Команды бота
3.2. Реализация
4. Запуск тестов на проде
4.1. Концепция
4.2. Реализация
Введение
Привет! Пара слов о нас. Мы — Сергей Кондитеров и Сергей Бушмелев, ведущие инженеры по автоматизации тестирования в компании РТЛабс.
Нашу историю по интеграции мессенджеров в наши процессы можно условно поделить на четыре этапа развития:
внедрение оповещений в фреймворк автотестов;
написание бота для запуска и формирования отчетов для конкретной системы;
написание бота для управления автотестами на проде (кстати, о том, как мы тестируемся на проде, можно почитать в статье Дмитрия Пирумова);
бот с неограниченным количеством интеграций и набором функциональностей.
На каждом из этапов мы преследовали разные цели, но их всех объединяла одно — необходимость сделать наши решения или сервисы доступными в телефоне.
Мы надеемся, что наши идеи помогут улучшить ваши процессы или помогут избежать вам ошибок при внедрении собственных решений.
Внедрение оповещений в фреймворк
Несколько лет назад у нас появилась задача по покрытию автотестами одной из наших автоматизированных систем. Задача ничем не отличалась от других:
написал автотесты
получил доступ в Jenkins
настроил джоб
тест-менеджер получает отчеты в почту
PROFIT
Проект автотестов успешно развивался, расширялось тестовое покрытие, увеличивалось количество потребителей отчета. Начали поступать новые «хотелки» и проявились первые проблемы:
не у всех пользователей есть доступ к Jenkins и желание этот доступ получить
неудобно корректировать список рассылки в Jenkins
отчеты в почту превращались в спам
Выбор решения
В нашей компании мы используем 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);
}
}
...
}
Внимание!
Изначально репорты приходили на все тесты, включая пройденные. Никогда так не делайте, пользователи выйдут из чата после первого же запуска. Прилагаем скриншот
@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);
}
…
}
}
Благодаря этим простым доработкам у каждого пользователя автотестов есть вся необходимая информация о проведенном тестировании:
среда выполнения
теги запуска
количество прошедших/упавших тестов
детализация по упавшим кейсам
Этих данных достаточно для принятия решения об успешном тестировании релиза.
Данный вид отчетности хорошо прижился на нашем проекте. Однако он пережил множество изменений (например, все сообщения собираются в одно), но это уже другая история, как говорится в одном всем известном меме.
Наш первый бот
Ранее мы говорили, что не у всех пользователей автотестов было желание получать доступ в 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 отчет с детализацией по тестам.
Реализация
Для реализации такого проекта была решено использовать 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) {
... обрабатываем ошибки
}
}
}
Заключение
Таким образом, мы сделали бот для запуска автотестов и выдачи результатов в Telegram.
Ключевые особенности:
Имеет ролевую модель для разграничения доступа пользователя;
Имеет меню для запуска джобов в Jenkins;
Оповещает пользователя о выполнении запущенного им теста с подробным отчетом.
Однако реализованная концепция бота не является оптимальной. О том, как убрать зависимость от конкретного мессенджера и сделать систему масштабируемой, мы поговорим уже в следующей статье:) Stay tuned.