Тестирование, покрытие и отчёты
Встраиваем тесты в пайплайн так, чтобы результаты были видны прямо в Merge Request.
JUnit-отчёт — стандартизированный XML-формат результатов тестов, который GitLab разбирает и показывает в интерфейсе MR.
Тесты как ворота качества
Главная ценность CI — не дать сломанному коду проехать дальше. Джоба тестов прогоняет автоматические проверки на каждом изменении; красная джоба блокирует слияние MR. Это превращает «надеюсь, я ничего не сломал» в «машина проверила».
Стоит проговорить, почему именно автоматизация важнее самих тестов. Тесты можно гонять и руками на ноутбуке, но человек ленив, забывчив и спешит к пятнице: он прогонит их на «своём» сценарии, забудет про граничные случаи и не заметит, что сломал соседний модуль. CI же запускает один и тот же набор на каждый push, в чистом окружении, без «у меня локально работает». Чистое окружение — отдельная ценность: оно ловит забытую зависимость в requirements.txt или хардкод пути, которые на захламлённой машине не проявляются. Поэтому зелёный пайплайн — это не «тесты прошли», а «тесты прошли у любого, кто соберёт проект с нуля».
unit-tests:
stage: test
image: python:3.12
script:
- pip install -r requirements.txt
- pytest --junitxml=report.xml
artifacts:
when: always
reports:
junit: report.xmlКлюч reports: junit говорит GitLab: разбери этот XML и покажи в MR. Тогда в Merge Request появится вкладка с упавшими тестами — не нужно листать логи, видно сразу, что именно сломалось.
Особое внимание — на when: always. По умолчанию артефакты сохраняются только при успешной джобе. Но отчёт о тестах ценнее всего как раз когда тесты падают: именно тогда вы хотите увидеть в MR список упавших кейсов. Без when: always падение тестов уронит джобу, артефакт не загрузится, и виджет в MR останется пустым — вы потеряете ровно ту информацию, ради которой всё затевалось. Это контринтуитивно и потому попадает в топ ошибок новичков.
Полезно различать два «формата результата». Сам pytest печатает человекочитаемый вывод в лог — его удобно читать глазами. А флаг --junitxml=report.xml дополнительно пишет машиночитаемый XML по стандарту JUnit, который понимают десятки инструментов. GitLab парсит именно XML: из лога он не сможет надёжно вытащить структуру «тест → статус → сообщение». Поэтому одного вывода в логе мало — нужен формальный отчёт.
Измерение покрытия
Покрытие показывает, какая доля кода исполняется тестами. GitLab умеет вытаскивать процент покрытия из вывода джобы по регулярному выражению и показывать его в MR и значке проекта.
unit-tests:
script:
- pytest --cov=app --cov-report=term
coverage: '/TOTAL.+?(\d+\%)$/'Ключ coverage с регуляркой ищет в логе число процента. Можно пойти дальше и отдать coverage_report в формате Cobertura — тогда GitLab подсветит покрытые и непокрытые строки прямо в diff Merge Request.
Важно понимать, что измеряет покрытие, а что — нет. Покрытие фиксирует, какие строки кода были исполнены во время прогона тестов. Оно ничего не говорит о том, проверил ли тест результат: можно вызвать функцию, не сделать ни одного assert и получить 100% покрытия строки, которая на деле не протестирована. Поэтому покрытие — это метрика «что мы вообще не трогали», а не «что мы хорошо проверили». Высокий процент полезен как сигнал о слепых зонах, но гнаться за 100% ради числа — ловушка: появляются пустые тесты ради метрики. Разумнее следить, чтобы покрытие не падало в новом MR, чем штурмовать абсолютную цифру.
Два механизма стоит держать в голове отдельно. Регулярка в coverage вытаскивает одно число — общий процент — и нужна для бейджа и виджета MR. Cobertura-отчёт устроен богаче: это XML со строчной детализацией, и GitLab сопоставляет его с diff, показывая прямо в изменённых строках, какие из них покрыты, а какие нет. Для ревьюера это мощно: видно не «покрытие 78%», а «вот эти три новые строки в MR никто не тестирует».
Параллельные тесты
Большие наборы тестов шардируют через parallel: N из прошлого раздела: тест-раннер делит набор на части по CI_NODE_INDEX, и каждая часть гонится параллельно, сокращая время в разы.
Механика проста: GitLab запускает джобу N раз, передавая в каждую копию переменные CI_NODE_INDEX (номер копии) и CI_NODE_TOTAL (сколько всего). Тест-раннер, который умеет шардировать (например, через плагины к pytest), по этим числам берёт свою долю тестов. Главный нюанс: JUnit-отчёты со всех копий GitLab объединяет в один виджет MR автоматически — склеивать вручную не нужно. С покрытием сложнее: каждая копия видит лишь свою часть кода, поэтому отчёты покрытия собирают в отдельной джобе и сливают, иначе цифра занижена.
Сравнение с GitHub Actions
В Actions результаты тестов и покрытие обычно показывают через сторонние actions и сервисы (Codecov и т.п.). GitLab отображает JUnit-отчёты и покрытие встроенно в MR — это естественное следствие единой платформы и одна из её сильных сторон.
Разница глубже, чем «удобнее кнопка». В GitHub Actions исторически нет встроенного понятия «отчёт о тестах в PR»: результаты живут в логах job, а чтобы увидеть упавшие кейсы виджетом, подключают сторонний action (dorny/test-reporter) или внешний сервис. Покрытие почти всегда уходит во внешний Codecov или Coveralls, которым нужен токен и ещё один сервис в цепочке. У GitLab сервер CI и сервер ревью — одна система, поэтому отчёт, загруженный как артефакт, тут же становится частью MR без интеграций и токенов. Платой за цельность служит меньшая гибкость: каталог сторонних reporter-ов у Actions богаче.
Как работает под капотом
После джобы раннер загружает артефакт-отчёт на сервер. GitLab парсит JUnit XML, сравнивает с базовой веткой и формирует список новых/исправленных/упавших тестов для виджета MR. Покрытие он либо извлекает регуляркой из лога джобы, либо разбирает Cobertura-отчёт и сопоставляет строки с diff. Всё это происходит на стороне сервера после завершения джобы.
Сравнение с базовой веткой заслуживает уточнения, потому что именно оно делает виджет полезным. GitLab берёт отчёт целевой ветки (например, последний прогон на main) и отчёт текущей ветки и считает разницу по именам тестов. Так появляются три категории: новые упавшие (этот MR сломал), исправленные (раньше падали, теперь зелёные) и всё ещё упавшие. Ревьюер смотрит в первую очередь на «новые упавшие» — это прямое следствие изменений в MR, а не унаследованный долг. Без сравнения с базой пришлось бы вручную вспоминать, что падало и до этого MR.
Частые ошибки
- Не указать
when: alwaysу артефактов отчёта — при падении тестов отчёт не сохранится, и в MR будет пусто. - Неправильная регулярка в
coverage— процент не извлечётся, значок останется пустым. - Считать, что зелёная джоба = хорошее покрытие. Тесты могут проходить, покрывая малую часть кода.
- Путать покрытие строк с качеством тестов — исполнение строки без
assertдаёт покрытие, но ничего не проверяет. - При параллельных тестах брать покрытие из одной копии — она видит лишь свою долю кода, цифра занижена.
Итоги
- Тесты в CI блокируют слияние сломанного кода; красная джоба тестов валит MR.
reports: junitпоказывает результаты тестов прямо в Merge Request.- Покрытие извлекается регуляркой
coverageили Cobertura-отчётом с подсветкой строк. - Покрытие — метрика «что не трогали», а не доказательство качества тестов.
- GitLab показывает тесты и покрытие встроенно; Actions полагается на сторонние сервисы.