Делаем простые отчеты в CI с помощью GitLab Pages для Playwright
Привет, Хабр! Я Саша Алексеев, старший инженер по тестированию в Selectel. Некоторое время назад мы столкнулись с необходимостью реорганизовать отчеты end-to-end-тестов. Их прогон стал занимать слишком много времени, мы искали способы распределить их еще больше.
Под катом я поделюсь вариантом, к которому мы в итоге пришли. Сейчас у нас параллельное выполнение тестов в разных джобах с использованием shard=x/y
, а общий репорт мы научились склеивать из отчетов разных прогонов.
Хотел бы обсудить это решение в комментариях. Возможно, вы решаете подобную проблему иначе. Выбранный вариант описал в виде инструкции на тестовом стенде, так что сможете легко повторить его в собственных целях.
Введение
Долгое время для прогона тестов в GitLab CI нам хватало существующих публичных (shared) раннеров, которые нам предоставляли наши DevOps-специалисты. Для Playwright, с которым мы работаем, было доступно 5 vCPU и 8 ГБ RAM — для запуска 4–5 воркеров хватало.
Спустя время прогон тестов стал переваливать за 30 минут — мы начали искать способ параллелить тесты еще больше. И были альтернативные варианты, которые нам не подошли по нескольким причинам:
- Можно было выделить отдельные раннеры с большим лимитом, но на момент решения проблемы ресурсов не было, их пришлось бы ждать. Было удобнее использовать общие раннеры.
- Запускать меньшее количество тестов только по измененному функционалу было сложно. Playwright умеет запускать тесты по названию, но не по метаинформации. А у нас были очень длинные названия тестов, так мы их запускаем по компонентам (в одном тесте их может быть до 20).
Нашим решением стало параллельное выполнение тестов в разных джобах с использованием shard=x/y
. В статье я поделюсь не только схемой отчетов e2e-тестов на Playwright при параллельном выполнении на разных нодах. Но и покажу упрощенную версию нашего CI, а также способ объединять HTML-отчет от Playwright-теста в один большой. Последнее необходимо, поскольку переключаться каждый раз по артефактам каждой джобы неудобно.
Итак, наш план:
- Инициируем Playwright-проект с GitLab CI-конфигурацией.
- Делаем отправку сообщений в Rocket.Chat после прогона тестов отдельным скриптом.
- Добавляем единный HTML-отчет для параллельных запусков.
Начало проекта с простым CI
Тесты
Для начала создадим проект в Playwright. У него есть команда init
для примера:
npm init playwright@latest
На выходе получим папки tests и tests-example. В конфигурации по умолчанию тесты запускаются только в /tests, так что переместим тесты из test-example в tests и проверим, сколько у нас теперь тестов:
npx playwright test --list
…
Total: 78 tests in 2 files
Все работает — для примера этого достаточно. Теперь немного модифицируем конфигурацию наших тестов в playwright.config.ts. Если конкретно — добавим репорты list и junit, а также уберем проекты firefox и webkit.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 3 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [['html'], ['list'], ['junit', { outputFile: 'test_results.xml' }]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
…
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
});
Мы убрали проекты firefox и webkit, так как они могут привести к путанице в junit-отчетах. В Playwright названия тестов в репорте не включают проект, для которого они были запущены. И при парсинге junit-отчетов 78 тестов в подробном отчете превращаются в 26. Причина в том, что названия некоторых тестов совпадают — к слову, на общее число тестов это не влияет.
Вот так это будет будет выглядеть.
CI
Итак, у есть набор тестов с разными репортами и тремя воркерами на поток в будущем CI. Настало время описать наш yml-файл для GitLab CI:
stages:
- tests
e2e:
image: mcr.microsoft.com/playwright:latest
stage: tests
parallel: 5
allow_failure: true
tags:
- kube-dev
script:
- npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
artifacts:
paths:
- "**/test_results.xml"
- "**/playwright-report"
reports:
junit: "**/test_results.xml"
Для CI используем GitLab, так как это основной инструмент непрерывной интеграции в Selectel. Перед созданием файла gitlab-ci.yml не забудьте активировать CI/CD в настройках проекта (хотя по умолчанию он должен быть включен).
Несколько комментариев по поводу конфигурации CI:
- В поле
image
указана самая последняя версия образа, но советую «запинить» на текущую. Внезапные обновления Playwright могут что-нибудь сломать — от screenshot-тестов до ранее написанного кода. - В поле
parallels
указано число параллельных джоб. Число 5 выбрано для примера, в вашем случае оно может быть любым. tags
— тег для конфигурации. По нему GitLab определяет, на каких раннерах запускать тесты. Тегkube-dev
предоставлен нам для разных проектов и управляется отделом DevOps-инженеров Selectel.
На этом шаге мы организовали параллельный запуск наших тестов с результатами в GitLab (из junit) и HTML-отчетами в артефактах каждой джобы.
Теперь настроим отправку сообщений в Rocket.Chat в нужном нам формате.
Сделаем отправку сообщений в чат по webhook. Для примера будем использовать Rocket.Chat (в плане API он похож на Stack). Отдельный этап с репортом мы выбрали по двум причинам:
- Playwright делит тесты параметром
—shard
примерно на равные части, но по времени выполнения эти части могут сильно отличаться. А для репорта мы должны дождаться завершения всех параллельных запусков. - Если запускать скрипт на отдельном этапе, после всех тестов можно неплохо «покостылить» и добавить отправку репортов в Jira, проверку количества автотестов карантине. Или склейку HTML-отчетов, отправку сообщений с ссылками на репорт, закрытие тестрана в TMS (при наличии интеграции) и другое. Конечно, если ничего этого не нужно, то достаточно настроить интеграцию или отправку сообщения по webhook.
Всю настройку сообщение можно свести к плану:
- Добавляем webhook от чата и токен на чтение проекта в переменные окружения проекта в GitLab CI.
- Пишем скрипт отправки сообщения.
- Добавляем новый stage в файл gitlab-ci.yml с запуском скрипта.
Успех!
Добавляем токен и webhook
Токен добавляем как TOKEN
, а webhook от чата как ROCKET_WEBHOOK
в переменную окружения. Переменную окружения стоит добавить в проект целиком по инструкции от GitLab.
Отправка сообщения в Rocket.Chat
Скрипт решил написать на TypeScript и библиотеке got, запускать все будет с помощью ts-node.
npm i got ts-node
Просто так версии ts-node (10.9.1) и got (12.6.0) вместе не заработают, так что добавим в корень проект tscofnig.json и поменяем package.json.
1. Создаем файл tsconfig.json:
{
"compilerOptions": {
"module": "NodeNext", // or ES2015, ES2020
"moduleResolution": "no"
},
"ts-node": {
// Tell ts-node CLI to install the --loader automatically
"esm": true
},
}
2. Добавляем параметр в package.json:
{
….
"type": "module",
…
}
3. Создадим файл со скриптом:
import got from 'got';
predefined variables gitlab
const CI_API_V4_URL = process.env.CI_API_V4_URL
const CI_PROJECT_ID = process.env.CI_PROJECT_ID;
const CI_PIPELINE_URL = process.env.CI_PIPELINE_URL;
const CI_PIPELINE_ID = process.env.CI_PIPELINE_ID;
specific variable in CI
const ROCKET_WEBHOOK = process.env.ROCKET_WEBHOOK;
const TOKEN_FOR_GET_GITLAB_REPORT = process.env.TOKEN;
interface IGitlabResponse {
total_count: number;
failed_count: number;
skipped_count: number;
test_suites: object;
}
class Report implements IReport {
public total: number;
public failed: number;
public skipped: number;
constructor(response: IGitlabResponse) {
this.total = response.total_count;
this.failed = response.failed_count;
this.skipped = response.skipped_count;
}
}
interface IReport {
total: number;
failed: number;
skipped: number;
}
interface ISendRocketChat {
reportUrl: string,
report: IReport,
}
async function getGitlabReport(): Promise {
const headers = { 'PRIVATE-TOKEN': TOKEN_FOR_GET_GITLAB_REPORT };
const url = `${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/pipelines/${CI_PIPELINE_ID}/test_report`;
try {
const responseJson: IGitlabResponse = await got.get(url, { headers: headers }).json();
return new Report(responseJson);
} catch (error) {
throw new Error('Не удалось загрузить репорт с gitlab\n' + error);
}
};
async function sendRocketChatMessage(param: ISendRocketChat): Promise {
const resultMsg = param.report.failed === 0 ? 'Успешно :white_check_mark:' : 'Найдены ошибки :sos:'
const payload = {
text:
`Ссылка на [JUnit отчёт](${param.reportUrl})\n` +
`${resultMsg}\n` +
`\`\`\`\nTotal: ${param.report.total}\n` +
`Failed: ${param.report.failed}\n` +
`Skipped: ${param.report.skipped}\n\`\`\``
};
await got.post(ROCKET_WEBHOOK as string, { json: payload });
}
async function main() {
const test_report = await getGitlabReport();
try {
await sendRocketChatMessage({
reportUrl: `${CI_PIPELINE_URL}/test_report`,
report: test_report,
});
} catch (error) {
throw new Error('Не удалось отправить сообщение в Rocket.Chat\n' + error);
}
}
main();
Добавляем новый stage GitLab CI
Остается добавить запуск нашего скрипта с репортами в gitlab-ci.yml после выполнения всех тестов. Добавляем как отдельную джобу, что будет дожидаться завершения всех запущенных тестов:
reports:
image: mcr.microsoft.com/playwright:latest
stage: report
needs: [e2e]
tags:
- kube-dev
before_script:
- npm i ts-node got
script:
- npx ts-node helpers/reportToChat.ts
Теперь у нашего параллельного запуска есть репорты с количеством упавших и пройденных тестов. Но HTML-отчеты все еще у каждой части свои — давайте это исправим.
Единый HTML-отчет
Изначально склейка репортов была сделана с помощью библиотеки, playwright-merge-html-reports которую мы нашли благодаря issue #10437. Однако с версии Playwright 1.37.0 можно пользоваться встроенным и более удобным функционалом. В отличие от библиотеки тут решена проблема склейки произвольного числа репортов.
Все, что нам нужно сделать, можно разбить на несколько групп:
- обновляем playwright-test до 1.37.0 или выше. Добавляем blob-репорт
- добавляем в сообщения Rocket.Chat ссылку на итоговый репорт,
- дорабатываем наш CI для генерации общего отчета.
Сначала укажем новый репортер в playwright.config.ts вместо HTML и сделаем blob-репорт.
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [['blob'], ['list'], ['junit', { outputFile: 'test_results.xml' }]],
Доработаем скрипт отправки сообщения
Нам нужно доработать сообщение в rocket-chat, добавив в него ссылку на HTML-отчет. В ссылке для каждого прогона будет меняться только JOB_ID. Итоговый вид нашего скрипта reportToChat.ts — ниже.
import got from 'got';
// predefined variables gitlab
const CI_API_V4_URL = process.env.CI_API_V4_URL
const CI_PROJECT_ID = process.env.CI_PROJECT_ID;
const CI_PIPELINE_URL = process.env.CI_PIPELINE_URL;
const CI_PIPELINE_ID = process.env.CI_PIPELINE_ID;
const JOB_ID = process.env.CI_JOB_ID // JOB_ID для ссылки на html отчет
const PAGES_URL = process.env.CI_PAGES_URL?.replace(/(?<=[a-z])\//, '/-/') // PAGES_URL также для ссылки на html отчет
// specific variable in CI
const ROCKET_WEBHOOK = process.env.ROCKET_WEBHOOK;
const TOKEN_FOR_GET_GITLAB_REPORT = process.env.TOKEN;
interface IGitlabResponse {
total_count: number;
failed_count: number;
skipped_count: number;
test_suites: object;
}
class Report implements IReport {
public total: number;
public failed: number;
public skipped: number;
constructor(response: IGitlabResponse) {
this.total = response.total_count;
this.failed = response.failed_count;
this.skipped = response.skipped_count;
}
}
interface IReport {
total: number;
failed: number;
skipped: number;
}
interface ISendRocketChat {
reportUrl: string,
htmlURL: string, // добавляем в интерфейс ссылку на html отчет
report: IReport,
}
async function getGitlabReport(): Promise {
const headers = { 'PRIVATE-TOKEN': TOKEN_FOR_GET_GITLAB_REPORT };
const url = `${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/pipelines/${CI_PIPELINE_ID}/test_report`;
try {
const responseJson: IGitlabResponse = await got.get(url, { headers: headers }).json();
return new Report(responseJson);
} catch (error) {
throw new Error('Не удалось загрузить репорт с gitlab\n' + error);
}
};
async function sendRocketChatMessage(param: ISendRocketChat): Promise {
const resultMsg = param.report.failed === 0 ? 'Успешно :white_check_mark:' : 'Найдены ошибки :sos:'
const payload = {
text:
`Ссылка на [JUnit отчёт](${param.reportUrl}) на [HTML](${param.htmlURL})\n` + // добавляем ссылку на наш отчет в сообщение
`${resultMsg}\n` +
`\`\`\`\nTotal: ${param.report.total}\n` +
`Failed: ${param.report.failed}\n` +
`Skipped: ${param.report.skipped}\n\`\`\``
};
await got.post(ROCKET_WEBHOOK as string, { json: payload });
}
async function main() {
const test_report = await getGitlabReport();
try {
await sendRocketChatMessage({
reportUrl: `${CI_PIPELINE_URL}/test_report`,
htmlURL: `${PAGES_URL}/-/jobs/${JOB_ID}/artifacts/playwright-report/index.html`, // собираем ссылку на HTML отчет
report: test_report,
});
} catch (error) {
throw new Error('Не удалось отправить сообщение в Rocket.Chat\n' + error);
}
}
main();
Здесь все изменения рассчитаны на работу в CI. Так две переменные окружения (PAGES_URL
и JOB_ID
) нужны для составления URL и открытия HTML-репорта. Его также можно выгружать в стороннее хранилище и не хранить как артефакт сборки — тогда в htmlURL
стоит указать адрес отчета во внешнем хранилище
Правим конфигурацию CI
В конфигурации CI мы должны учесть новый артефакт — папку blob-report, а также добавить склейку HTML-отчета из набора blob-отчетов. Итоговый вид нашего файла gitlab-ci.yml:
stages:
- tests
- report
e2e:
image: mcr.microsoft.com/playwright:latest
stage: tests
parallel: 5
allow_failure: true
tags:
- kube-dev
before_script:
- npm i
script:
- npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
artifacts:
when: always
expire_in: 1 week
paths:
- "**/test_results.xml"
- "**/blob-report" # это еще один вариант отчета в playwright
reports:
junit: "**/test_results.xml"
reports:
image: mcr.microsoft.com/playwright:latest
stage: report
needs: [e2e]
tags:
- kube-dev
before_script:
- npm i ts-node got
script:
- npx playwright merge-reports --reporter=html ./blob-report # добавляем склейку отчета встроенной утилитой
- npx ts-node helpers/reportToChat.ts
artifacts:
when: always # т.к. теперь в артефактах хранится отчет
expire_in: 1 week # оставляем его на 1 неделю
paths:
- "**/blob-report"
- "**/playwright-report"
В рамках CI добавляется только склейка отчетов на последнем этапе. Это удобнее утилиты playwright-merge-html-reports. Для нее нужно было узнать кол-во параллельных выполнений тестов, передать это в stage "report"
и настроить сохранение отчетов в разные папки для каждой параллели.
И вот, на последнем этапе у нас появляется артефакт "**/playwright-report"
, внутри которого будет созданный единый отчет. А в сообщения Rocket.Chat теперь содержится ссылка на единый отчет:
Заключение
Исходники проекта доступны по ссылке. Делайте форк и предлагайте свои улучшения!
Примерно этот код лежит в основе тестов одного из наших проектов. За исключением того, что у нас описано больше тестов. Также мы добавили дополнительные функции для удобства — например, отображение репорта в Jira Issue и интеграцию с TMS Qase для создания гибридных тест-ранов, где часть тестов проводится автоматически, а часть — руками.
В планах — внедрить подобную параллелизацию с единым отчетом для тестов сайта и панели управления. Также хотим попробовать выгружать HTML-репорты во внешнее хранилище с отображением списка отчетов, которое не привязано к GitLab CI, — некий аналог Allure Server, но для playwright-html.
Мы точно дойдем до запуска только необходимых е2е-тестов в зависимости от изменений — вероятно, это будущая тема для статьи. Основная сложность — запуск по метаинформации тестов при использовании @playwright-test. Сейчас она доступна только в названии теста. Следите за обновлениями в нашем блоге на Хабре!