Z hodiny na šest a půl minuty: zrychlení frontend testů v CI
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áze | Pipeline |
|---|---|
| Výchozí stav — jeden 30–65min job + OOM retry | ≈ 60+ min |
| Fáze 1 — 5 shardů, start v t=0, flaky fixy | 14,2 min |
| Fáze 2 — sdílený browser context | 6,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řed | Po | |
|---|---|---|
| celá pipeline | 60+ min | 14,2 min |
| test-component | 1 job, 30–65 min | 5 shardů, 7–13 min |
| OOM retry | běž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
componentDidMountdopadají 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.

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.

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

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řed | Po | |
|---|---|---|
| shard test-component | 7,9–18,3 min | 3,6–6,1 min |
| celá pipeline | 19–25 min | 6,2–6,8 min |
| lokální celá suita | 20,8 min | 12 min |
| validace | — | diff 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 1 | Fáze 2 | |
|---|---|---|
| vstup | 15 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í | 587 | 463 |
| výstup | sharding + stabilizace v produkci | sdílený context v produkci |
Prompty, které to řídily
| Kdy | Prompt | Tokeny |
|---|---|---|
| 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 revert | 9,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/unstubGlobalsruší celou třídu leaků najednou. - Validuj množinou selhání, ne pocitem. Diff
soubor :: testproti 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íš.