Юнит-тесты Vitest/Jest и e2e Playwright в CI
Юнит-тесты (Vitest/Jest) должны быть быстрыми, изолированными и детерминированными — запускай vitest run, не watch, и мокай границы. Playwright e2e гоняет реальный headless-браузер с авто-ожидающими локаторами, шардится по раннерам и выгружает trace при падении.
Пайплайн краснеет, но не потому что код сломан. У коллеги локальные часы сбились на пару часов, 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 минут на одном раннере и становится медленным гейтом. Как срезать время по часам, не теряя покрытия?
- 01Почему авто-ожидающие локаторы снижают флак e2e по сравнению с ручными sleep, и как это меняет то, что ты пишешь?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.