open atlas
↑ К треку
CI/CD-пайплайны CICD · 02 · 02

Юнит-тесты Vitest/Jest и e2e Playwright в CI

Юнит-тесты (Vitest/Jest) должны быть быстрыми, изолированными и детерминированными — запускай vitest run, не watch, и мокай границы. Playwright e2e гоняет реальный headless-браузер с авто-ожидающими локаторами, шардится по раннерам и выгружает trace при падении.

CICD Middle ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Пайплайн краснеет, но не потому что код сломан. У коллеги локальные часы сбились на пару часов, assertion на expires перевернулся, и юнит-сьют начал падать во вторник после обеда — а потом сам собой позеленел. Двумя джобами дальше Playwright e2e-тест «случайно» отваливается по таймауту примерно один прогон из пяти: кто-то добавил page.waitForTimeout(2000) после клика, поставив на то, что модалка откроется за две секунды. На нагруженном CI-раннере иногда не открывается. Тесты больше не ловят баги — они генерируют шум, и команда уже на рефлексе жмёт «re-run».

Юнит-тесты: быстрые, изолированные, детерминированные

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

Юнит-тест проверяет один кусок логики в памяти — без сети, без реальных часов, без общего состояния между тестами. Эта триада — быстрые, изолированные, детерминированные — и есть то, что позволяет гонять их тысячами на каждый пуш и доверять результату. У Vitest и Jest почти одинаковый API написания тестов: группируешь через describe, объявляешь кейсы через test (или it), проверяешь через expect. Разница — в движке под капотом. Jest — давний стандарт со своим трансформером и раннером. Vitest — Vite-native: он переиспользует Vite/esbuild-трансформ твоего проекта, поэтому ESM и TypeScript работают почти без лишнего конфига, а сьют стартует быстро. Его API намеренно совместим с Jest, так что миграция в основном сводится к смене раннера.

import { describe, test, expect, vi, beforeEach } from "vitest";
import { priceCart } from "../src/cart";

describe("priceCart", () => {
  beforeEach(() => {
    vi.useFakeTimers();              // заморозить «сейчас» — нет зависимости от часов
    vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
  });

  test("applies a 10% discount over $100", () => {
    expect(priceCart([{ price: 60 }, { price: 60 }])).toBe(108);
  });

  test("leaves small carts untouched", () => {
    expect(priceCart([{ price: 20 }])).toBe(20);
  });
});

Два врага надёжного юнит-сьюта — время и общее состояние. Баг с часами из хука — классика: assertion, зависящий от реального Date.now(), проходит на твоей машине и падает на раннере в другом часовом поясе или в полночь. Заморозь время через vi.useFakeTimers() (Jest: jest.useFakeTimers()), чтобы временем владел тест, а не хост. Общее состояние — второй враг: если тест A мутирует модульный синглтон, а тест B его читает, сьют проходит в одном порядке и падает в другом — а CI редко гоняет тот порядок, что ты проверял локально. Сбрасывай состояние между кейсами (beforeEach, свежие fixture) и не давай остаткам одного теста становиться входом другого.

Мокай границы, а не логику

Мок нужен, чтобы провести жёсткую черту вокруг тестируемого кода: сеть, база, файловая система, часы, сторонние SDK. Всё по ту сторону границы заменяется управляемым дублёром, чтобы тест был детерминированным и быстрым. Ошибка джунов — мокать внутрь: подменять ту самую функцию, которую собирались проверить, — тогда тест проходит, что бы код ни делал. Мокай границу, проверяй логику.

import { test, expect, vi } from "vitest";
import { getUser } from "../src/user";

test("getUser maps the API payload", async () => {
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ id: 7, full_name: "Ada" }),
  } as Response);

  const user = await getUser(7);

  expect(user).toEqual({ id: 7, name: "Ada" }); // наша логика маппинга, не fetch
  expect(fetch).toHaveBeenCalledWith("/api/users/7");
});

В CI сьют гоняется один раз, не в watch-режиме. Оба раннера по умолчанию делают один прогон на неинтерактивном терминале, но будь явным: vitest run (или jest --ci) делает один проход и выходит с ненулевым кодом при падении — именно это гейтит мердж. Добавь --coverage, чтобы получить отчёт, который можно проверить по порогу или выгрузить. Весь шаг — секунды по времени и не требует браузера, поэтому он должен быть первым гейтом в пайплайне: быстрая обратная связь, дёшево гонять на каждый пуш.

- name: Unit tests
  run: |
    npm ci
    npx vitest run --coverage   # один проход; ненулевой код завалит джобу
Почему это работает

Политика ретраев должна отличаться по типу теста. Юнит-тест детерминирован по построению, поэтому флакающий юнит-тест — это настоящий баг (в тесте или в коде), и ретрай лишь прячет дефект, пока он не укусит в проде. Ставь юнитам ноль ретраев. End-to-end-тесты пересекают реальный браузер, реальную сеть и тайминги, которые ты не полностью контролируешь, поэтому ограниченный ретрай (например, retries: process.env.CI ? 2 : 0) — разумная страховка от настоящего инфраструктурного флака — в паре с trace, чтобы отличить реальное падение от шума. Никогда не замазывай флакающий юнит-тест ретраем.

Playwright e2e: реальный браузер с авто-ожиданием

Почему стандартный совет Playwright — «никогда не используй waitForTimeout»? Потому что фиксированный sleep — это ставка, а нагруженный CI-раннер проигрывает её в одном прогоне из пяти. Авто-ожидающие локаторы заменяют ставку условием.

End-to-end-тесты доказывают, что собранная система работает так, как её видит пользователь: реальный браузер грузит твоё приложение, кликает, печатает и читает то, что отрендерилось. Playwright — это и драйвер браузера, и тест-раннер. Его центральная фича — авто-ожидающие локаторы. Локатор вроде page.getByRole("button", ...) — это не снимок DOM на момент вызова, а ленивый запрос, который Playwright переоценивает и ждёт, пока элемент не станет прикреплён, видим и кликабелен, прежде чем действовать, — вплоть до таймаута. Именно это убивает флак с waitForTimeout(2000) из хука: вместо ставки на фиксированный sleep локатор ждёт реального условия и идёт дальше в момент, когда оно истинно — быстрее на быстром прогоне, терпеливее на медленном.

import { test, expect } from "@playwright/test";

test("user can log in", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill("ada@example.com");
  await page.getByLabel("Password").fill("correct-horse");
  await page.getByRole("button", { name: "Sign in" }).click();

  // авто-ждёт заголовок дашборда — без ручного sleep
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});

В CI Playwright по умолчанию работает headless (без графического интерфейса, без display-сервера) — и сначала нужно поставить бинарники браузеров плюс их OS-библиотеки через npx playwright install --with-deps. Для скорости шарди сьют по раннерам через --shard=index/total, чтобы матрица из трёх джоб в GitHub Actions гнала по трети тестов параллельно. Выигрыш для отладки — в артефактах при падении: настрой trace: "on-first-retry", и Playwright запишет полный trace — DOM-снимки, сеть, консоль и таймлайн — только для тестов, которым пришлось ретраиться, и ты выгружаешь его, чтобы красный прогон CI был отлаживаем с ноутбука, а не игрой в угадайку.

- name: E2E tests
  run: |
    npm ci
    npx playwright install --with-deps        # браузеры + OS-библиотеки, headless
    npx playwright test --shard=${{ matrix.shard }}/3
- name: Upload trace on failure
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: playwright-trace-${{ matrix.shard }}
    path: playwright-report/

Unit vs e2e: когда какой бежит и что даёт

Два слоя отвечают на разные вопросы и стоят по-разному, поэтому стоят в разных точках пайплайна. Таблица делает контраст конкретным.

ИзмерениеUnit (Vitest / Jest)E2e (Playwright)
Что гоняетЧистую логику в памяти; границы замоканыРеальный headless-браузер против работающего приложения
СкоростьМиллисекунды на тест; весь сьют за секундыСекунды на тест; минуты — шарди для параллели
Когда в CIПервый гейт, каждый пуш (vitest run)После прохода юнитов; install —with-deps
РетраиНоль — флакающий юнит это настоящий багОграниченно и только на CI (например, 2)
Артефакты при паденииСтек-трейс + отчёт о покрытииTrace, видео, скриншот — воспроизводимый таймлайн
Викторина

Playwright-тест отваливается по таймауту примерно один прогон из пяти, всегда после клика, открывающего модалку. Самая вероятная причина и фикс?

Викторина

Юнит-тест, проверяющий истечение токена, проходит локально, но прерывисто падает в CI. Класс корневой причины и верный фикс?

Выбери лучший вариант

Твой e2e-сьют идёт 14 минут на одном раннере и становится медленным гейтом. Как срезать время по часам, не теряя покрытия?

Вспомните перед уходом
  1. 01
    Почему авто-ожидающие локаторы снижают флак e2e по сравнению с ручными sleep, и как это меняет то, что ты пишешь?
  2. 02
    Пройди по e2e-шагу CI: что должно бежать до тестов, как сделать его быстрее и что хранить при падении?
Итог

Тесты зарабатывают место в CI, только когда им можно доверять, и два слоя зарабатывают доверие по-разному. Юнит-тесты (Vitest или Jest, почти одинаковый API describe/test/expect) должны быть быстрыми, изолированными и детерминированными — гоняй чистую логику в памяти, мокай границы (часы, сеть, БД), а не тестируемую логику, замораживай время фейковыми таймерами и сбрасывай общее состояние между кейсами. Гоняй их один раз как первый, самый дешёвый гейт через vitest run (или jest —ci) плюс —coverage, и давай юнитам ноль ретраев, потому что флакающий юнит — это настоящий баг, а не шум, который надо переретраить. Преимущество Vitest над Jest — в том, что он Vite-native: ESM и TypeScript работают почти без конфига, а сьют стартует быстро. End-to-end-тесты (Playwright, и драйвер, и раннер) доказывают собранную систему с места пользователя в реальном headless-браузере; их главная защита от флака — авто-ожидающие локаторы, ждущие реального условия элемента вместо фиксированного waitForTimeout sleep. В CI ставь браузеры через —with-deps, шарди по раннерам для параллельной скорости, ограничивай ретраи только CI и выгружай trace (плюс видео/скриншот) при падении, чтобы красный прогон воспроизводился не на твоей машине. Дешёвый детерминированный гейт первым; дорогой интегрированный гейт вторым; артефакты, чтобы падения были отлаживаемы. Теперь, когда увидишь таймаут один прогон из пяти, первый вопрос будет: не спрятан ли там waitForTimeout — и какое условие локатора должно его заменить?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.