Přeskočit na obsah

Z hodiny na šest a půl minuty: zrychlení frontend testů v CI

Publikováno 15 min čtení

Jak jsme ve dvou fázích zrychlili CI pipeline frontendových komponentových testů skoro 10× — přes sharding, Kubernetes limity, OOM kills, flaky testy a nakonec sdílený browser context, který napoprvé neprošel a podruhé odhalil čtyři třídy leaků, o kterých jsme nevěděli.

FázePipeline
Výchozí stav — jeden 30–65min job + OOM retry≈ 60+ min
Fáze 1 — 5 shardů, start v t=0, flaky fixy14,2 min
Fáze 2 — sdílený browser context6,2–6,8 min

Napsáno s Claude Fable 5 v Claude Code + Superpowers · 2 fáze, 3 merge requesty · ~110 M tokenů · ~6 h aktivní práce rozložených do dvou dnů.

Deset minut testů, padesát minut režie

Frontend má ~2 500 komponentových testů v ~800 souborech — Vitest Browser Mode, skutečné Chromium přes Playwright, MSW jako mock backendu, pixelové screenshot baselines. Kvalitní setup, ale v CI běžel jako jeden job 30–65 minut a často se celý opakoval, protože pod skončil na OOM (out-of-memory — job přerostl paměťový limit svého Kubernetes podu a kernel ho zabil). Reálně tak pipeline běžně trvala přes hodinu.

Přitom čistý čas testů je ~10 minut. Zbytek byla režie s jednou dominantní položkou: každý testovací soubor dostával čerstvý iframe, který si znovu vyhodnotil celý setup graf — Redux Store, TinyMCE, theme CSS. To je ~4–5 s krát ~800 souborů, tedy 70–87 % celého běhu:

Duration  1246.9s  (setup 4739.9s, import 1606.8s, tests 6282.6s)
          ^^^^^^^ wall-clock       ^^^^^ tohle platíme za iframe-per-file

Den jednoho browser workeru v izolovaném režimu: 4–5 sekund setup grafu (Store, TinyMCE, theme), pár sekund testů souboru, zahodit — a znovu, ×800 souborů. Poměr je z reálného měření: 4 740 s kumulativního setupu na ~10 min čistých testů. Většinu CPU času browser tráví přípravou prostředí, které pak po pár sekundách zahodí.

Jak soubory vypadaly na startu

Pro kontext k diffům níže — takhle vypadaly klíčové soubory, než jsme na ně sáhli. CI job byl jeden, bez shardingu a bez limitu na workery. Nejvýmluvnější je komentář u retry: OOM se neřešil, obcházel se opakováním celého jobu:

.gitlab-ci.yml · původní stav

test-component:
  stage: test
  variables:
    KUBERNETES_CPU_REQUEST: "2"
    KUBERNETES_CPU_LIMIT: "3"
    KUBERNETES_MEMORY_REQUEST: "10Gi"
    KUBERNETES_MEMORY_LIMIT: "14Gi"
  retry:
    max: 2
    # script_failure added: Vitest Browser Mode + heavy memory usage causes OOM kills and
    # Playwright browser context crashes that appear as script_failure on first attempt.
    when:
      - runner_system_failure
      - stuck_or_timeout_failure
      - script_failure
  script:
    - npm ci
    - npm run test:component        # celá suita, jeden pod, 30–65 minut

vite.config.ts · původní test blok

  plugins: [
    react({ ... }),
    svgr(),
    checker({ typescript: true }),   // tsc běžel i uvnitř test serverů
  ],
  test: {
    testTimeout: 30000,
    include: ['src/**/*.test.{ts,tsx}'],   // jeden projekt, všechno izolovaně
    setupFiles: ['./vitest.setup.tsx'],
    browser: {
      enabled: true,
      provider: playwright(),
      headless: true,                 // isolate: neuvedeno = true, iframe na soubor
      instances: [{ browser: 'chromium', viewport: { width: 1280, height: 720 } }],
      ...
    },
  },

vitest.setup.tsx · původní stav

// Všechno na top-levelu modulu — v izolovaném světě se soubor stejně
// vyhodnotil jednou na iframe, takže „jednou“ platilo samo od sebe.
const layerOrderStyle = document.createElement('style');   // @layer pořadí
document.head.prepend(layerOrderStyle);
tinymce.baseURL = `${import.meta.env.BASE_URL}js/tinymce`;
i18n.init({ ... });
document.head.appendChild(style);       // vypnutí animací
console.error = (...args) => { ... };   // filtr ResizeObserver šumu

beforeAll(async () => {
  await server.start({ onUnhandledRequest: 'bypass', quiet: true });
});
afterAll(() => {
  server.stop();     // MSW worker se registroval a rušil pro KAŽDÝ soubor
});

beforeEach(async () => {
  await userEvent.click(document.body);
  history.push('/');
  TestStoreSeeder.seedCache();
});                  // žádné čištění axios cache ani web storage

afterEach(() => {
  server.resetHandlers();
  cleanup();
});                  // žádný úklid DOM zbytků v body

Fáze 1: sharding, Kubernetes a dva flaky testy

První fáze nesahala na testy samotné — rozdělila práci a stabilizovala prostředí. Job se rozdělil na 5 shardů přes GitLab parallel a přestal čekat na build stage, protože je self-contained (vlastní npm ci):

.gitlab-ci.yml · dopad: 1 job × 30–65 min → 5 × ~10 min, start v t=0

 test-component:
   stage: test
+  # Sharded via GitLab `parallel`: wall-clock drops ~linearly with the shard count and
+  # each pod hosts fewer live browser contexts, which is what used to OOM the single
+  # 30-60min job into whole-job retries.
+  parallel: 5
+  # The job is self-contained (own npm ci, no build artifacts) — start it at pipeline
+  # creation instead of waiting for the build stage; it is the critical path.
+  needs: []
   variables:
     KUBERNETES_MEMORY_LIMIT: "14Gi"
+    # Pin the worker count to the CPU limit: os.availableParallelism() may see the
+    # node's cores instead of the cgroup quota, spawning more browser pages than
+    # 3 CPUs can drive and ballooning memory.
+    VITEST_MAX_WORKERS: "3"
   script:
+    - npx vitest run --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL ...

Faul hned v první pipeline: parallel bez shardu

První verze nasadila GitLab parallel: 3 — ale do vitestu se nikdy nedostal --shard flag. Výsledek: tři pody svědomitě běžely každý celou suitu. Pipeline vypadala „paralelně“, ale ve skutečnosti dělala 3× stejnou práci a doběhla za stejně dlouho jako předtím. Odhalil to až pohled na počty testů v jednotlivých shardech; oprava byl jeden řádek:

   script:
     - npm ci
-    - npm run test:component
+    - npm run test:component -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL

Mimochodem: vitest rozděluje shardy podle pořadí souborů, ne podle jejich ceny — shardy proto nejsou stejně rychlé a nejpomalejší shard určuje délku pipeline.

Ten komentář u VITEST_MAX_WORKERS je celý příběh OOM kills v jedné větě: Vitest odvozoval počet browser workerů z os.availableParallelism(), které v Kubernetes vidí jádra celého nodu, ne cgroup kvótu podu. Pod s limitem 3 CPU tak spouštěl browserů jako na 32jádrovém stroji, paměť rostla, kernel zabíjel — a GitLab celý 40minutový job spouštěl znovu.

K tomu drobnosti, které se sčítaly: typecheck plugin uvnitř test serverů (má vlastní CI job, tady jen ubíral CPU browserům), modulová axios cache, která uměla mezi testy servírovat odpověď z minulého testu a obejít tak MSW mock, a MSW service worker, který se registroval a rušil pro každý soubor zvlášť:

vite.config.ts · vitest.setup.tsx

 plugins: [
-  checker({ typescript: true }),
+  // No typecheck inside test servers: CI has a dedicated typecheck job.
+  ...(process.env.VITEST ? [] : [checker({ typescript: true })]),
 ],

 beforeEach(async () => {
+  // Module-level axios cache survives across tests within a file — a hit from an
+  // earlier test's identical request bypasses MSW entirely.
+  httpCacheStorage.data.clear();
 });

 beforeAll(async () => {
+  // The worker is registered on the origin, so it survives across files;
+  // re-registering it per file costs real time.
+  if (!window.__mswWorkerStarted) {
     await server.start({ onUnhandledRequest: 'bypass', quiet: true });
+    window.__mswWorkerStarted = true;
+  }
 });
-afterAll(() => { server.stop(); });

A jedna windowsí specialita pro vývojáře: výchozí port vitest browser API (63315) spadá na některých strojích do TCP rozsahu, který si rezervuje Hyper-V — testy pak lokálně vůbec nenastartují a spadnou na listen EACCES. Nízké porty vyloučené nebývají:

     browser: {
+      // The stock port (63315) falls into a Hyper-V/WSL excluded TCP range on some
+      // Windows machines, failing the whole run with `listen EACCES`.
+      api: { port: 5123 },
       ...

Dva flaky testy, které stály 10 minut na každý retry

Po shardingu zbývaly dva testy, které náhodně padaly a přes CI retry prodlužovaly běh o ~10 minut. Oba měly stejný tvar problému — fixní časový předpoklad tam, kde běží asynchronní práce:

  • Výběr elementů v diagramu — klik dispatchnutý během asynchronního ELK relayoutu se tiše ztratil. Fix: klikat opakovaně, dokud model nepotvrdí selectionRect (opakovaný klik je idempotentní re-select).
  • TinyMCE init — 1s timeout na inicializaci editoru pod zátěží nestačil; zvednuto na 15 s s pollingem.

Vzorec na příště: nečekat fixní čas, ale pollovat signál podložený modelem — a opakovat jen idempotentní interakce.

PředPo
celá pipeline60+ min14,2 min
test-component1 job, 30–65 min5 shardů, 7–13 min
OOM retryběžně0

Mezihra: první pokus o sdílený context — zelený na Windows, rudý na Linuxu

Už ve fázi 1 jsme zkusili to největší zrychlení: browser.isolate: false, tedy jeden sdílený browser context na worker místo iframe na soubor. Lokálně na Windows prošla validace čistě — a na linuxovém CI spadlo ~30 testů. Změnu jsme revertli a odnesli si inventář leaků:

  • DOM zbytky se ve sdíleném <body> hromadí, až vyroste scrollbar → viewport se zúží → ~26 screenshotů posunutých o pár px,
  • localStorage přeteče mezi soubory (vyhledávání renderovalo cizí „nedávné dotazy“),
  • stav modulů mění layout word-cloudu,
  • opožděné fetche z componentDidMount dopadají 404 až po teardownu a fajlují zelené soubory.

Lekce č. 1 — Validace na vývojářském OS nestačí. Renderovací leaky (scrollbar, fonty) se projeví jen na CI platformě — každá kandidátní konfigurace musí projít skutečnou pipeline, než se prohlásí za hotovou.

Fáze 2: sdílený browser context, tentokrát s plánem

Druhý pokus začal jinak: nejdřív vznikl implementační plán s kompletním inventářem leaků z revertnutého pokusu, ke každému úkol s fixem, a validační metodou, která umí odlišit skutečnou regresi od šumu. Teprve pak se sahalo na kód.

Jádro změny: dva vitest projekty, které se liší jen izolací. main sdílí context, isolated nechává iframe-per-file souborům, které to opravdu potřebují — a o zařazení rozhoduje content scan, takže nový „jedovatý“ soubor nikdy tiše nesklouzne do sdíleného světa:

vite.config.ts · dopad: setup 4 740 s → ~380 s kumulativně

-    include: ['src/**/*.test.{ts,tsx}'],
+    projects: [
+      {
+        test: {
+          name: 'main',
+          include: ['src/**/*.test.tsx'],
+          exclude: isolationRequiredTestFiles,   // content scan, viz níž
+          browser: { isolate: false },
+          sequence: { groupOrder: 0 },
+        },
+      },
+      {
+        test: {
+          name: 'isolated',
+          // Unit testy modulových singletonů (hydratační cache, debounce
+          // timery, „once per page-load“) potřebují panenské moduly.
+          include: ['src/**/*.test.ts', ...isolationRequiredTestFiles],
+          sequence: { groupOrder: 1 },
+          maxWorkers: 4,
+        },
+      },
+    ],

Moduly se teď vyhodnotí jednou na worker a zůstávají v cache; mezi testy se resetuje jen stav (store, MSW handlery, DOM, storage). Kumulativní setup: 4 740 s → ~380 s. Cena: cokoli test neuklidí, zdědí všichni po něm — proto zbytek téhle kapitoly.

Známé leaky z inventáře dostaly každý svůj fix v setup hoocích:

vitest.setup.tsx

 beforeEach(async () => {
+  // Web storage je origin-scoped — ve sdíleném contextu přeteče mezi soubory.
+  localStorage.clear();
+  sessionStorage.clear();
 });

 afterEach(() => {
   server.resetHandlers();
   cleanup();
+  // cleanup() odmountuje React rooty, ale ne ne-React zbytky (TinyMCE aux
+  // elementy, osiřelé portály). Nahromaděné zbytky vyrobí scrollbar, ten
+  // zúží viewport a posune každý vycentrovaný screenshot proti baseline.
+  document.body.replaceChildren();
+  window.scrollTo(0, 0);
 });

Zbytek inventáře

Opožděné fetche z componentDidMount, které dopadnou 404 až po teardownu testu (MSW handlery už jsou pryč → request propadne na vite server), přestaly fajlovat zelené soubory cíleně — potlačí se jen 404, skutečné chyby zůstávají vidět:

     onUnhandledError(error) {
+      // Late component fetches settle with a 404 after the test tore down its
+      // MSW handlers. Only suppress 404s: real request bugs (500s, network)
+      // must stay visible.
+      if (error.name === 'AxiosError' && error.message?.includes('status code 404')) {
+        return false;
+      }
     },

Dvě menší poučení ze stejného kola:

  • Word-cloud měl seed, a stejně driftoval. Komponenta předává vizualizační knihovně fixní random generátor už od jara — přesto se layout ve sdíleném contextu lišil od baseline. Zdroj nedeterminismu nebyl PRNG, ale zahřátý stav modulů měnící měření textu. Když seed nepomůže, zbývá pragmatická cesta: soubory běží v izolovaném projektu.
  • Dva projekty nesmí běžet naráz. Paralelně si každý spustí vlastní Vite server + plný worker pool a přetížené CPU začne ztrácet module fetche („Failed to fetch dynamically imported module“). Proto sequence.groupOrder řadí projekty za sebe a izolovaný projekt má maxWorkers: 4.

Validace proběhla metodou diffu množin selhání: celá suita jednou s vynucenou izolací (baseline) a jednou s novou konfigurací; porovnává se přesná množina soubor :: test. Screenshot selhání jsou na Windows očekávaný šum (baselines jsou chromium-linux) — na obou stranách stejný, takže se v diffu vyruší. Výsledek musel být prázdný v obou směrech, dvakrát po sobě: 738 = 738. Pak teprve šel kód na CI. A tam začala ta zajímavější část.

Pasti ve validaci samotné — Dvě věci se pokusily validaci tiše rozbít. S CI režimem zapnutým vitest opakuje selhání a k řádku přidává suffix (retry x2) — regex parsující množinu selhání na něm přestal matchovat a diff by vyšel „čistý“, protože by obě množiny byly prázdné. A v repu se našlo pět omylem commitnutých windowsích screenshot baselines (proklouzly s dřívější změnou do jinak linuxové sady) — ty na vývojářských strojích tiše „zezelenaly“ pět screenshot testů, které měly padat. Regex se opravil, baselines vyletěly.

Případ #1: syntetický beforeunload zabije mocky všem po něm

(pipeline 96237, shard 1/5)

Lokálně diff odhalil 8 selhání v jednom souboru — hook s „once per page-load“ sémantikou najednou nikdy nedostal odpověď z API. Bisekce dovedla k deterministické dvojici: stačilo, aby dřív ve workeru běžel test overlay komponenty, který dispatchuje window.dispatchEvent(new Event('beforeunload')).

Mechanismus: MSW klient na beforeunload pošle service workeru zprávu CLIENT_CLOSED — „stránka se zavírá, přestaň pro mě mockovat“. V izolovaném světě iframe stejně umíral, takže to nikomu nevadilo. Ve sdíleném contextu se tím odregistruje dlouhožijící iframe a každý další soubor ve workeru posílá requesty naostro do vite serveru: 404.

vite.config.ts · content scan

       if (
         content.includes('vi.mock(') ||
         content.includes('vi.doMock(') ||
+        // Syntetický beforeunload = MSW CLIENT_CLOSED = konec mocků pro
+        // celý sdílený context. Takové soubory běží izolovaně.
+        content.includes('beforeunload')
       ) {

Případ #2: putující screenshoty a dva druhy písma

(pipeline 96237 → 96244)

Na CI zbylo 5 screenshot mismatchů. Regenerovali jsme baselines z artefaktů — a další pipeline spadla zas, jen na jiných souborech, a jeden z regenerovaných padal znova. Diffy ukazovaly „duchy“: stejný obsah posunutý o ~1 px.

Diff snímek testu avataru: řádky se jmény a časovými razítky jsou zvýrazněné červeně, protože text je oproti baseline posunutý o zhruba jeden pixel

Výřez diffu jednoho z mismatchů: obsah posunutý o ~1 px kvůli jiným metrikám písma zanechá v diffu červené „duchy“ textu.

Průlom přišel z porovnání dvou „actual“ snímků téhož testu z různých pipeline: jeden byl vysázený patkovým písmem, druhý bezpatkovým. Testy nenačítají produkční index.html, takže font Inter v testech vůbec neexistuje a všechny baselines vznikly s fallback písmem. Jenže jeden test si do document.head přidával <link> na /css/index.css — i s @font-face pro Inter — a nikdy ho neodebral. Hlavička sdílený context přežívá: každý soubor, který běžel po něm, se vykreslil Interem a neodpovídal své baseline. Podle toho, komu ten soubor zrovna přistál ve workeru, mismatche „putovaly“ mezi běhy.

Výřez actual snímku testu checkboxu z pipeline 96237: nadpisy Enabled a Disabled vysázené bezpatkovým písmem

Pipeline 96237 · bezpatkový render — souboru ve workeru předcházel test s uniklým stylesheetem (Inter).

Výřez actual snímku téhož testu checkboxu z pipeline 96244: stejný obsah, nadpisy vysázené patkovým fallback písmem a posunuté výš

Pipeline 96244 · čistý render — fallback patkové písmo, se kterým vznikly původní baselines. Tentýž test, dvě po sobě jdoucí pipeline; výřezy 3× zvětšené. Kromě písma se liší i vertikální geometrie — proto „duchy“ posunuté o pixel.

Ze stejné rodiny: ModelViewer testy špehovaly CoreApiService.post přes vi.spyOn(...).mockResolvedValue({ data: [] }) bez restore — a soubory po nich měly prázdné tabulky.

vite.config.ts · RichTextEditorLinkStyles.test.tsx

+    // Spies na service singletonech jinak přežijí soubor, který je vytvořil.
+    restoreMocks: true,
+    // Totéž pro vi.stubGlobal (window.Prism, navigator, ...).
+    unstubGlobals: true,

+  afterEach(() => {
+    // index.css nese Inter @font-face + globální typografii; head přežívá
+    // soubor, takže ponechaný link přebarví screenshoty všem po nás.
+    indexCssLink.remove();
+  });

Regenerované baselines se vrátily zpět (honily otrávený render) a s fixem u zdroje byly další tři pipeline po sobě zelené.

PředPo
shard test-component7,9–18,3 min3,6–6,1 min
celá pipeline19–25 min6,2–6,8 min
lokální celá suita20,8 min12 min
validacediff 738 = 738 · 3× zelená CI

Za oponou: jak se to stavělo s AI

Obě fáze vznikly v Claude Code s modelem Claude Fable 5 a pluginem Superpowers (workflow brainstorm → plán → exekuce s validačními bránami). Fáze 1 byla interaktivní — 15 promptů, průběžné řízení. Fáze 2 běžela z jediného promptu nad připraveným plánem; člověk vstoupil až u git operací a na konci.

Fáze 1Fáze 2
vstup15 promptů, interaktivně1 prompt + plán z fáze 1
aktivní čas práce s AI~3,4 h~2,4 h
tokeny~67 M~41 M
API volání587463
výstupsharding + stabilizace v produkcisdílený context v produkci

Prompty, které to řídily

KdyPromptTokeny
9. 7. 11:29„Dostal jsem feedback, že frontend testy běží hodinu, to mi přijde hrozně moc dlouho…“kickoff
9. 7. 11:40„zkusme udelat vsechno“25,6 M
9. 7. 14:28„uz to bezi nejak dlouho“ — v tu chvíli běžely 3 pody, každý s celou suitou (chybějící --shard)8,7 M
9. 7. 14:50„it failed“ — z toho vznikl celý inventář leaků a revert9,4 M
10. 7. 6:57„…mě zajímá celá pipeline. Klidně ten paralel můžeme zvednout i víc.“13,4 M
10. 7. 8:55„mergnul jsem to, můžeš mi udělat větev na ten follow-up a nějakou spec, abych mohl clear context?“2,8 M
10. 7. 13:11„pokračuj podle docs/superpowers/plans/2026-07-10-component-tests-shared-context.md“41,4 M

Jak vypadal plán pro fázi 2

To „spec“ z posledního promptu fáze 1 je markdown soubor: cíl, architektura, inventář leaků z reálného CI běhu (každý musel být adresován) a úkoly s příkazy k ověření. Výňatek:

**Goal:** Enable `browser.isolate: false` for the bulk of the vitest suite,
cutting CI shard times from ~10–15 min to ~5 min.

| # | Leak                        | Symptom on Linux CI          | Status  |
|---|-----------------------------|------------------------------|---------|
| 1 | DOM residue in shared body  | scrollbar → ~26 screenshots  | Task 3  |
| 2 | localStorage leaks          | wrong dropdown branch        | Task 2  |
| 4 | late componentDidMount 404s | fail otherwise-green files   | Task 4  |

- Windows validation is NOT sufficient — every candidate config must go
  through a branch pipeline before being called done.
### Task 6: Local full-suite validation (failure-set diff)
### Task 7: Linux CI validation (the gate Windows cannot provide)

Za zmínku stojí, že plán dva nové leaky (beforeunload/MSW, spy a stylesheet pollution) nepředvídal — ale předepsal validační metodu, která je spolehlivě chytila, a bisekční postup, kterým se dohledaly. Plán nemusí znát všechny odpovědi; musí umět poznat, že něco nesedí.

Co si odnášíme

  • Nejdřív změř, kde čas opravdu je. „Pomalé testy“ byly z ~85 % režie prostředí, ne testy.
  • Kubernetes limity ≠ to, co vidí Node. os.availableParallelism() vrací jádra nodu; worker pool se musí pinovat na cgroup kvótu, jinak přijde OOM.
  • Sdílený context = každý neuklizený vedlejší efekt je globální. Storage, DOM, hlavička dokumentu, spies, service worker — všechno potřebuje deterministický úklid mezi testy.
  • Vynucuj třídy problémů konfigurací, ne disciplínou. Content scan zařazuje jedovaté soubory do izolace automaticky; restoreMocks/unstubGlobals ruší celou třídu leaků najednou.
  • Validuj množinou selhání, ne pocitem. Diff soubor :: test proti baseline odliší regresi od šumu i při stovkách očekávaných selhání.
  • Flaky test je skoro vždy fixní čas vs. asynchronní realita. Polluj signál podložený modelem, opakuj jen idempotentní akce.
  • Retry v CI ano, lokálně ne. CI retry absorbuje vzácné timing flaky (a vitest je označí), lokálně musí selhání zůstat syrová, jinak leaky nikdy neuvidíš.
← Zpět na blog