Привет, Хабр! Я Саша Алексеев, старший инженер по тестированию в 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-теста в один большой. Последнее необходимо, поскольку переключаться каждый раз по артефактам каждой джобы неудобно.

Итак, наш план:

  1. Инициируем Playwright-проект с GitLab CI-конфигурацией.
  2. Делаем отправку сообщений в Rocket.Chat после прогона тестов отдельным скриптом.
  3. Добавляем единный 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 в нужном нам формате.


Сделаем отправку сообщений в чат с помощью вебхуков. Для примера будем использовать Rocket.Chat (в плане API он похож на Slack). Отдельный этап с репортом мы выбрали по двум причинам:

  1. Playwright делит тесты параметром —shard примерно на равные части, но по времени выполнения эти части могут сильно отличаться. А для репорта мы должны дождаться завершения всех параллельных запусков.
  2. Если запускать скрипт на отдельном этапе, после всех тестов можно неплохо «покостылить» и добавить отправку репортов в Jira, проверку количества автотестов карантине. Или склейку HTML-отчетов, отправку сообщений с ссылками на репорт, закрытие тестрана в TMS (при наличии интеграции) и другое. Конечно, если ничего этого не нужно, то достаточно настроить интеграцию или отправку сообщения с помощью вебхуков.

Всю настройку сообщение можно свести к плану:

  1. Добавляем вебхук от чата и токен на чтение проекта в переменные окружения проекта в GitLab CI.
  2. Пишем скрипт отправки сообщения.
  3. Добавляем новый stage в файл gitlab-ci.yml с запуском скрипта.

Успех!

Добавляем токен и вебхук


Токен добавляем как TOKEN, а вебхук от чата как 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. Создадим файл со скриптом:

Скрипт reportToChat.ts
import got from 'got';

<h3> predefined variables gitlab</h3>
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;

<h3> specific variable in CI</h3>
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<IReport> {
  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<void> {
  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 можно пользоваться встроенным и более удобным функционалом. В отличие от библиотеки тут решена проблема склейки произвольного числа репортов.

Все, что нам нужно сделать, можно разбить на несколько групп:

  1. обновляем playwright-test до 1.37.0 или выше. Добавляем blob-репорт
  2. добавляем в сообщения Rocket.Chat ссылку на итоговый репорт,
  3. дорабатываем наш 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<IReport> {
  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<void> {
  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. Сейчас она доступна только в названии теста. Следите за обновлениями в нашем блоге на Хабре!

Другие полезные материалы


Комментарии (0)