← Valtive QA Quiz quiz.valtive.io/notes/cypress valtive.io
📖 Про цей гайд📖 About this guide
Для когоWho it's for
Для QA інженерів, які пишуть або збираються писати e2e-тести на Cypress і готуються до технічної співбесіди. Гайд іде від того, як раннер живе в браузері поруч із застосунком, до cy.intercept, cy.session, компонентних тестів, паралелізації в CI і того, чим Cypress поступається Playwright. Факти звірені з Cypress 13 і новішими версіями.
For QA engineers who write, or are about to write, end-to-end tests in Cypress and are preparing for a technical interview. The guide runs from how the runner lives in the browser next to the app, through cy.intercept and cy.session, to component tests, parallel runs in CI and where Cypress loses to Playwright. The facts are checked against Cypress 13 and later.
Як користуватисьHow to use it
Фільтруй питання по рівню — Junior, Middle або Senior. Відкривай картку, читай концепт і код. Потім закрий код і прочитай вголос з блоку 🗣 Як читати вголос — це тренує відповідь в живій розмові, а не тільки розуміння очима.
Filter the questions by level — Junior, Middle or Senior. Open a card, read the concept and the code. Then hide the code and say the answer out loud from the 🗣 How to say it out loud block — that trains the answer as a live conversation, not just recognition on the page.
Три базові якорі — з них випливає більшість відповідейThree anchors — most of the answers follow from them
ЯКІР 1 · Черга команд і ретраїANCHOR 1 · Command queue and retries
Команди cy.* не виконуються одразу, а стають у чергу. Запити до DOM і асерти повторюються до тайм-ауту, тому явні паузи майже ніколи не потрібні.
cy.* commands do not run on the spot, they are queued. DOM queries and assertions are retried until a timeout, so explicit sleeps are almost never needed.
ЯКІР 2 · Мережа й мокиANCHOR 2 · Network and mocks
Увесь трафік застосунку йде через проксі Cypress. cy.intercept дає підглянути, підмінити або дочекатися запиту, а cy.request ходить в API напряму.
All app traffic goes through the Cypress proxy. cy.intercept lets you watch, replace or wait for a request, while cy.request talks to the API directly.
ЯКІР 3 · Ізоляція і CIANCHOR 3 · Isolation and CI
Кожен тест стартує з чистої сторінки, cookies і storage. Стан готуємо через API і cy.session, а в CI ділимо спеки між машинами.
Every test starts with a clean page, cookies and storage. State is prepared through the API and cy.session, and in CI the specs are split across machines.
—
питаньquestions
3
рівніlevels
—
voice-блокиvoice blocks
TypeScript
Cypress 13+
📚 7 РОЗДІЛІВ📚 7 SECTIONS
—
Junior
—
Middle
—
Senior
0 / 0 · розгорни картку, щоб зарахувати їїopen a card to count it
▶ 1 5
1 Що таке Cypress і які процеси працюють під час прогону тесту? What is Cypress, and which processes are at work during a test run?
Основи й архітектураCore and architectureJunior▶

Cypress — це фреймворк для end-to-end і компонентних тестів веб-застосунків на JavaScript або TypeScript. Головна відмінність від Selenium у тому, що тестовий код не надсилає команди браузеру ззовні, а сам виконується всередині браузера: у вкладці раннера є два iframe — один із твоїм спеком, другий із застосунком, який тестуємо (AUT). Тому тест бачить той самий window, DOM і localStorage, що й застосунок.

Cypress is a framework for end-to-end and component tests of web apps, written in JavaScript or TypeScript. The key difference from Selenium is that the test code does not send commands to the browser from outside: it runs inside the browser itself. The runner tab holds two iframes, one with your spec and one with the application under test (AUT), so the test sees the same window, DOM and localStorage as the app does.

Навіщо знати архітектуру: поруч із браузером працює Node-процес Cypress. Він запускає браузер, збирає спеки бандлером, проксіює весь HTTP-трафік застосунку, пише скріншоти й відео, читає файли і виконує код із setupNodeEvents. Саме через проксі працює cy.intercept, а через Node — cy.task, cy.readFile і cy.request.

Why the architecture matters: next to the browser there is a Cypress Node process. It launches the browser, bundles the specs, proxies all of the app's HTTP traffic, writes screenshots and videos, reads files and runs the code from setupNodeEvents. cy.intercept works thanks to that proxy, and cy.task, cy.readFile and cy.request work thanks to Node.

Приклад: тест читає токен із localStorage застосунку через cy.window(), а потім просить Node-процес прочитати файл на диску. Перша команда виконується в браузері, друга — на боці Node, і між ними Cypress сам передає дані.

Example: a test reads a token from the app's localStorage via cy.window() and then asks the Node process to read a file from disk. The first command runs in the browser, the second on the Node side, and Cypress passes the data between them for you.

Підводний камінь: код спека не може напряму імпортувати модулі Node на кшталт fs чи драйвер бази даних — він живе в браузері. Усе, що потребує файлової системи, мережі без CORS або бази, виноситься в cy.task.

Pitfall: spec code cannot import Node modules such as fs or a database driver directly, because it lives in the browser. Anything that needs the file system, CORS-free networking or a database goes into cy.task.

Якщо спитають далі: які браузери підтримуються? Chrome, Edge, Electron (вбудований, типовий для cypress run) і Firefox; WebKit доступний лише як експериментальна опція. Мов, окрім JS/TS, немає.

If they dig deeper: which browsers are supported? Chrome, Edge, Electron (bundled and the default for cypress run) and Firefox; WebKit is only an experimental option. There are no languages besides JS/TS.

Одна команда в браузері, друга — у Node
One command in the browser, the other in Node
it('reads app state and a file from disk', () => {
  cy.visit('/profile');
  // виконується в браузері, поруч із застосунком
  cy.window().its('localStorage').invoke('getItem', 'token').should('be.a', 'string');
  // виконується у Node-процесі Cypress
  cy.readFile('cypress/fixtures/profile.json').its('email').should('contain', '@');
});
it('reads app state and a file from disk', () => {
  cy.visit('/profile');
  // runs in the browser, next to the app
  cy.window().its('localStorage').invoke('getItem', 'token').should('be.a', 'string');
  // runs in the Cypress Node process
  cy.readFile('cypress/fixtures/profile.json').its('email').should('contain', '@');
});
🔧 Пояснення синтаксису: cy.window() повертає об'єкт window застосунку; .its('localStorage') дістає властивість; .invoke('getItem', 'token') викликає метод із аргументом; cy.readFile просить Node прочитати файл і віддати вміст як subject.
🔧 Syntax explained: cy.window() yields the app's window object; .its('localStorage') picks a property; .invoke('getItem', 'token') calls a method with an argument; cy.readFile asks Node to read a file and yields its content as the subject.
💡 Простими словами: Тест сидить у тій самій вкладці, що й сайт, а за спиною в нього помічник на Node, який уміє все, чого браузер не дозволяє.
💡 In plain words: The test sits in the same tab as the site, and behind it there is a Node helper that can do everything a browser is not allowed to.
🗣 Як читати вголос Я пояснюю Cypress як дві половини. Перша — сам тест, він крутиться у браузері в сусідньому айфреймі із застосунком, тому бачить той самий DOM і localStorage без жодного драйвера посередині. Друга — Node-процес, який запускає браузер, пропускає весь трафік через свій проксі й уміє працювати з файлами. З цього випливає і перевага, і обмеження: перехоплення мережі дуже зручне, а от напряму підключитися до бази зі спека не вийде, для цього є cy.task. І коли щось не працює, я спершу питаю себе, на якому боці це мало б виконатися.
🗣 How to say it out loud I describe Cypress as two halves. The first is the test itself: it runs in the browser, in an iframe next to the app, so it sees the same DOM and localStorage with no driver in between. The second is a Node process that launches the browser, routes all the traffic through its proxy and can work with files. Both the strength and the limit come from that: intercepting the network is really convenient, but you cannot connect to a database straight from a spec, that is what cy.task is for. And when something breaks, the first thing I ask myself is which side it was supposed to run on.
2 Що робить cy.visit() і коли Cypress вважає сторінку завантаженою? What does cy.visit() do, and when does Cypress consider the page loaded?
Основи й архітектураCore and architectureJunior▶

cy.visit(url) відкриває сторінку в айфреймі застосунку і чекає, поки спрацює подія load вікна. Лише після цього черга рухається до наступної команди. Якщо в конфігу задано baseUrl, достатньо відносного шляху: cy.visit('/login').

cy.visit(url) opens a page in the app iframe and waits for the window load event. Only then does the queue move on to the next command. If baseUrl is set in the config, a relative path is enough: cy.visit('/login').

Навіщо розуміти деталі: тайм-аут очікування задає pageLoadTimeout, типово 60 секунд. Якщо сервер повернув статус не 2xx і не 3xx, cy.visit падає одразу — це зручно, бо тест не продовжує працювати на сторінці з помилкою 500. Для навмисної перевірки сторінки 404 є опція failOnStatusCode: false.

Why the details matter: the wait is bounded by pageLoadTimeout, 60 seconds by default. If the server answers with anything other than 2xx or 3xx, cy.visit fails straight away, which is handy because the test does not carry on over a 500 page. To check a 404 page on purpose there is the failOnStatusCode: false option.

Приклад: через onBeforeLoad можна підготувати window до того, як виконається перший скрипт застосунку: підкласти значення в localStorage, застабити navigator.geolocation чи зафіксувати мову. Опція qs додає query-параметри без ручної склейки рядка.

Example: onBeforeLoad lets you prepare window before the app's first script runs: put a value into localStorage, stub navigator.geolocation or pin the language. The qs option adds query parameters without gluing the string together by hand.

Підводний камінь: подія load не означає, що дані вже підтягнулися. Односторінковий застосунок після завантаження ще робить запити до API і малює список. Тому після cy.visit перевіряють конкретний елемент або чекають на аліас мережевого запиту, а не ставлять паузу.

Pitfall: the load event does not mean the data has arrived. After loading, a single-page app still calls the API and renders the list. So after cy.visit you assert on a specific element or wait for a network alias, rather than adding a pause.

Якщо спитають далі: чи можна відвідати інший домен у тому ж тесті? Так, з Cypress 12 це дозволено, але команди на сторінці іншого походження треба загорнути в cy.origin.

If they dig deeper: can you visit another domain in the same test? Yes, since Cypress 12 that is allowed, but commands on a page of another origin have to be wrapped in cy.origin.

cy.visit з підготовкою window і перевіркою результату
cy.visit with window setup and a result check
it('opens the catalogue in Ukrainian', () => {
  cy.intercept('GET', '/api/products*').as('products');
  cy.visit('/catalogue', {
    qs: { page: 2 },
    onBeforeLoad(win) {
      win.localStorage.setItem('lang', 'uk');
    },
  });
  cy.wait('@products');
  cy.get('[data-cy=product-card]').should('have.length.greaterThan', 0);
});
🔧 Пояснення синтаксису: qs перетворюється на ?page=2; onBeforeLoad(win) отримує вікно застосунку до запуску його скриптів; cy.wait('@products') чекає, поки відбудеться перехоплений запит; have.length.greaterThan — асерт Chai, який повторюється до успіху.
🔧 Syntax explained: qs becomes ?page=2; onBeforeLoad(win) receives the app window before its scripts run; cy.wait('@products') waits until the intercepted request happens; have.length.greaterThan is a Chai assertion that is retried until it passes.
💡 Простими словами: cy.visit чекає, поки сторінка завантажиться, але не чекає, поки застосунок домалює дані — це вже твоя перевірка.
💡 In plain words: cy.visit waits for the page to load, but not for the app to finish rendering its data — that part is your assertion.
🗣 Як читати вголос Про cy.visit я кажу так: він відкриває сторінку і чекає події load, а якщо сервер віддав помилку, тест падає одразу, і це мені подобається. Але load для односторінкового застосунку мало що гарантує, дані ще їдуть. Тому одразу після visit я або чекаю аліас запиту, або перевіряю, що з'явився потрібний елемент. А коли треба щось підготувати ще до старту застосунку, наприклад мову чи прапорець у localStorage, я роблю це в onBeforeLoad. Так тест не залежить від того, як швидко відповів бекенд, і не потребує жодної паузи.
🗣 How to say it out loud What I say about cy.visit is this: it opens the page and waits for the load event, and if the server returns an error the test fails at once, which I like. But for a single-page app load guarantees very little, the data is still on its way. So right after the visit I either wait for a request alias or check that the element I need has appeared. And when something must be set up before the app even starts, say the language or a flag in localStorage, I do it in onBeforeLoad. That way the test does not depend on how quickly the backend happened to answer.
3 Що таке subject у Cypress і як він переходить від команди до команди? What is the subject in Cypress, and how does it pass from one command to the next?
Основи й архітектураCore and architectureJunior▶

Subject — це значення, яке команда «віддає» (yields) наступній команді в ланцюжку. cy.get('li') віддає jQuery-обгортку знайдених елементів, .first() отримує її і віддає вже один елемент, .click() клікає по ньому і віддає той самий елемент далі. Саме тому в Cypress пишуть ланцюжки, а не змінні.

The subject is the value a command yields to the next command in the chain. cy.get('li') yields a jQuery wrapper of the found elements, .first() takes it and yields a single element, .click() clicks it and yields the same element onward. That is why Cypress code is written as chains, not variables.

Навіщо це знати: кожна команда має правила щодо subject. Одні починають новий ланцюжок і ігнорують попередній (cy.get, cy.visit), інші вимагають subject певного типу (.type() працює лише з елементом, .its() — з будь-яким об'єктом). Частина команд змінює subject: .find, .its, .invoke; частина лишає його як є: .click, .should.

Why it matters: every command has rules about its subject. Some start a new chain and ignore the previous one (cy.get, cy.visit), some need a subject of a particular type (.type() only works on an element, .its() on any object). Some commands change the subject — .find, .its, .invoke — and some keep it as it is: .click, .should.

Приклад: cy.get('form').find('input').first().type('Kyiv') — тут subject тричі змінюється: форма, усі поля форми, перше поле. Асерт .should('have.value', 'Kyiv') отримає це поле і перевірить його значення.

Example: in cy.get('form').find('input').first().type('Kyiv') the subject changes three times: the form, all its inputs, the first input. The .should('have.value', 'Kyiv') assertion then receives that input and checks its value.

Підводний камінь: не всі команди віддають те, що очікуєш. cy.wait('@alias') віддає об'єкт перехоплення з request і response, cy.request — відповідь, а cy.log віддає null. Тому ланцюжок після cy.log не має сенсу. Друге — subject доступний лише всередині ланцюжка, у звичайну змінну його не збережеш.

Pitfall: not every command yields what you expect. cy.wait('@alias') yields an interception object with request and response, cy.request yields the response, and cy.log yields null, so chaining off cy.log makes no sense. And the subject only lives inside the chain; you cannot keep it in a plain variable.

Якщо спитають далі: як узяти subject у JS-код? Через .then(($el) => ...): колбек отримує значення, а те, що він поверне, стане новим subject.

If they dig deeper: how do you get the subject into plain JS? Through .then(($el) => ...): the callback receives the value, and whatever it returns becomes the new subject.

Як змінюється subject по ходу ланцюжка
How the subject changes along a chain
cy.get('[data-cy=cart]')          // subject: контейнер кошика
  .find('[data-cy=cart-item]')    // subject: усі рядки кошика
  .should('have.length', 3)       // subject не змінюється
  .last()                         // subject: останній рядок
  .find('[data-cy=price]')
  .invoke('text')                 // subject: рядок, наприклад '249 грн'
  .then((text) => parseInt(text, 10))
  .should('be.gte', 100);         // subject: число
cy.get('[data-cy=cart]')          // subject: the cart container
  .find('[data-cy=cart-item]')    // subject: all cart rows
  .should('have.length', 3)       // subject stays the same
  .last()                         // subject: the last row
  .find('[data-cy=price]')
  .invoke('text')                 // subject: a string such as '249 UAH'
  .then((text) => parseInt(text, 10))
  .should('be.gte', 100);         // subject: a number
🔧 Пояснення синтаксису: Кожен рядок ланцюжка отримує результат попереднього; .invoke('text') викликає jQuery-метод text(); значення, яке повертає колбек .then, стає новим subject, і до нього знову можна застосувати .should.
🔧 Syntax explained: Each link of the chain receives the previous result; .invoke('text') calls the jQuery text() method; the value returned from the .then callback becomes the new subject, and .should can be applied to it again.
💡 Простими словами: Subject — це естафетна паличка: кожна команда бере її в попередньої і передає наступній, іноді вже іншу.
💡 In plain words: The subject is a relay baton: each command takes it from the previous one and hands it on, sometimes swapped for another.
🗣 Як читати вголос Subject я пояснюю як естафету. Команда щось віддає, наступна це отримує. cy.get віддає знайдені елементи, find звужує їх, invoke text перетворює елемент на рядок, а then дозволяє зробити з рядка число. Коли розумієш, що саме віддає кожна команда, зникає бажання складати елементи в змінні, бо все, що тобі потрібно, і так їде ланцюжком. І ще я пам'ятаю, що cy.wait з аліасом віддає не елемент, а перехоплений запит, і з нього я вже дістаю статус чи тіло відповіді. Так і читається кожен ланцюжок: хто що віддав.
🗣 How to say it out loud I explain the subject as a relay race. A command yields something and the next one receives it. cy.get yields the found elements, find narrows them, invoke text turns an element into a string, and then lets me turn that string into a number. Once you know what each command yields, the urge to store elements in variables goes away, because everything you need already travels down the chain. I also keep in mind that cy.wait with an alias yields the intercepted request, not an element, and from it I pull the status or the response body.
4 Як Cypress працює з кількома доменами і навіщо потрібен cy.origin? How does Cypress deal with several domains, and why is cy.origin needed?
Основи й архітектураCore and architectureMiddle▶

Оскільки тест виконується в браузері поруч із застосунком, на нього діє політика same-origin: код спека може працювати лише зі сторінкою того ж походження (протокол, хост і порт). Раніше Cypress просто забороняв переходити на інший домен у тесті. cy.origin з'явився як експериментальна функція у версії 9.6 (разом із cy.session, під прапорцем experimentalSessionAndOrigin) і став стабільним у Cypress 12.

Because the test runs in the browser next to the app, the same-origin policy applies to it: spec code can only work with a page of the same origin (protocol, host and port). Cypress used to simply forbid moving to another domain within a test. cy.origin arrived as an experimental feature in 9.6 (together with cy.session, behind the experimentalSessionAndOrigin flag) and became stable in Cypress 12.

Навіщо: типовий сценарій — логін через сторонній провайдер (Auth0, Keycloak, Google), оплата на сторінці платіжного шлюзу, перехід у окремий субдомен адмінки. Команди, які мають виконатися на сторінці іншого походження, пишуть у колбеку cy.origin('https://auth.example.com', () => { ... }).

Why: the typical case is signing in through a third-party provider (Auth0, Keycloak, Google), paying on a payment gateway page, or moving to a separate admin subdomain. Commands that must run on a page of another origin go into the callback of cy.origin('https://auth.example.com', () => { ... }).

Приклад: тест відкриває застосунок, натискає «Увійти», потрапляє на сторінку провайдера, заповнює форму всередині cy.origin, і після редиректу назад продовжує перевірки вже звичайними командами.

Example: the test opens the app, clicks "Sign in", lands on the provider's page, fills in the form inside cy.origin, and after the redirect back carries on with ordinary commands.

Підводний камінь: колбек cy.origin серіалізується й виконується в іншому контексті, тому він не бачить змінних із зовнішньої області. Дані передаються явно через опцію args. Кастомні команди й імпорти з support-файлу всередині теж не доступні автоматично — їх треба підключати через Cypress.require. Субдомени того самого сайту (app.example.com і api.example.com) мають однаковий superdomain і часто не потребують cy.origin.

Pitfall: the cy.origin callback is serialised and runs in another context, so it cannot see variables from the outer scope. Data is passed explicitly through the args option. Custom commands and imports from the support file are not available inside automatically either — they have to be loaded with Cypress.require. Subdomains of one site (app.example.com and api.example.com) share a superdomain and often need no cy.origin at all.

Якщо спитають далі: а якщо логін через SSO на кожен тест повільний? Тоді cy.origin загортають у cy.session, або взагалі отримують токен через cy.request до API провайдера.

If they dig deeper: what if SSO login for every test is slow? Then cy.origin is wrapped in cy.session, or the token is fetched with cy.request against the provider's API instead.

Логін через сторонній провайдер
Signing in through a third-party provider
it('signs in through the identity provider', () => {
  const user = { email: 'qa@example.com', password: Cypress.env('SSO_PASSWORD') };

  cy.visit('/');
  cy.get('[data-cy=sign-in]').click();

  cy.origin('https://auth.example.com', { args: user }, ({ email, password }) => {
    cy.get('#username').type(email);
    cy.get('#password').type(password, { log: false });
    cy.get('button[type=submit]').click();
  });

  cy.location('pathname').should('eq', '/dashboard');
});
🔧 Пояснення синтаксису: Перший аргумент cy.origin — походження, на якому виконуватимуться команди; args серіалізується і приходить першим параметром колбека; деструктуризація ({ email, password }) дістає поля; log: false ховає пароль із Command Log; cy.location('pathname') повертає шлях після редиректу.
🔧 Syntax explained: The first argument of cy.origin is the origin the commands will run on; args is serialised and arrives as the callback's first parameter; destructuring ({ email, password }) picks the fields; log: false keeps the password out of the Command Log; cy.location('pathname') yields the path after the redirect.
💡 Простими словами: cy.origin — це дозвіл тесту попрацювати на чужому сайті, але речі туди треба передавати явно, у кишені args.
💡 In plain words: cy.origin is the test's permit to work on someone else's site, but anything it needs has to be carried in explicitly, in the args pocket.
🗣 Як читати вголос Проблема тут у тому, що тест живе в браузері, а отже підпадає під same-origin. Тому для сторінки іншого домену, скажімо логіну через Keycloak, я відкриваю блок cy.origin і всі дії на тій сторінці пишу всередині. Він з'явився експериментально ще у дев'ять шість, а стабільним став у дванадцятій версії. Головне, про що я попереджаю команду: колбек не бачить зовнішніх змінних, тож логін і пароль я передаю через args, а щоб не проходити цей логін у кожному тесті, загортаю його в cy.session. Один окремий тест усе ж проходить логін повністю, щоб ми знали, що сама форма працює.
🗣 How to say it out loud The problem here is that the test lives in the browser, so the same-origin rule applies to it. For a page on another domain, say a Keycloak login, I open a cy.origin block and write every action on that page inside it. It first appeared as experimental in nine point six and became stable in version twelve. The main thing I warn the team about is that the callback cannot see outer variables, so I pass the login and password through args, and to avoid going through that login in every test I wrap it in cy.session. One separate test still goes through the whole login, so we know the form itself works.
5 Коли команді варто вибрати Cypress, а коли Playwright чи Selenium? When should a team pick Cypress, and when Playwright or Selenium?
Основи й архітектураCore and architectureSenior▶

Це питання про компроміси, а не про «кращий інструмент». Cypress сильний досвідом розробника: інтерактивний раннер із time travel, зручне перехоплення мережі, вбудоване компонентне тестування для React, Vue, Angular і Svelte, низький поріг входу для фронтенд-команди. Плата за це — архітектурні межі: лише JS/TS, одна вкладка, робота з іншими доменами через cy.origin, айфрейми без вбудованої команди, WebKit лише експериментально.

This is a question about trade-offs, not about "the best tool". Cypress is strong on developer experience: an interactive runner with time travel, convenient network interception, built-in component testing for React, Vue, Angular and Svelte, and a low entry barrier for a frontend team. The price is architectural limits: JS/TS only, a single tab, other domains via cy.origin, no built-in iframe command, WebKit only as an experiment.

Playwright керує браузером ззовні через протокол, тому має кілька вкладок і контекстів у тесті, повноцінні Chromium, Firefox і WebKit, клієнти для JS/TS, Python, Java і .NET, а паралелізація й шардинг безкоштовні й вбудовані в раннер. Для Cypress вбудованого паралельного запуску немає: його дає платний Cypress Cloud або ручне розбиття спеків у CI.

Playwright drives the browser from outside over a protocol, so a test can have several tabs and contexts, full Chromium, Firefox and WebKit, clients for JS/TS, Python, Java and .NET, and parallel runs and sharding are free and built into the runner. Cypress has no built-in parallel runs: you get them from the paid Cypress Cloud or by splitting specs manually in CI.

Selenium — стандарт W3C WebDriver, найширша підтримка мов і реальних браузерів, Selenium Grid і хмарні ферми. Його вибирають, коли автоматизацію пишуть Java- чи Python-команди, коли потрібні старі або специфічні браузери, або коли вже є велика інфраструктура.

Selenium is the W3C WebDriver standard, with the widest support for languages and real browsers, Selenium Grid and cloud farms. It is picked when automation is written by Java or Python teams, when old or unusual browsers are needed, or when a large infrastructure already exists.

Приклад рішення: фронтенд-команда на React хоче, щоб розробники самі писали компонентні і прості e2e-тести — Cypress дасть найшвидший старт. Продукт із мультивкладковими сценаріями, Safari-користувачами і QA на Python — Playwright. Банк із Java-командою і Grid — Selenium або Selenide.

Decision example: a React frontend team wants developers to write component tests and simple e2e tests themselves — Cypress gives the fastest start. A product with multi-tab flows, Safari users and QA engineers in Python — Playwright. A bank with a Java team and a Grid — Selenium or Selenide.

Підводний камінь: порівнювати швидкість «у вакуумі». На практиці стабільність і час прогону визначає архітектура тестів — підготовка стану через API, моки, ізоляція — а не сам інструмент. Якщо спитають далі: як мігрувати? Поступово, новими тестами на новий фреймворк, без переписування всього одразу.

Pitfall: comparing speed "in a vacuum". In practice stability and run time depend on how the tests are built — state set up via the API, mocks, isolation — not on the tool itself. If they dig deeper: how to migrate? Gradually, with new tests on the new framework, without rewriting everything at once.

Той самий сценарій у Cypress: без await, з ланцюжком
The same scenario in Cypress: no await, a chain instead
// Cypress: команди стають у чергу, чекання вбудоване
it('adds a product to the cart', () => {
  cy.visit('/products/42');
  cy.get('[data-cy=add-to-cart]').click();
  cy.get('[data-cy=cart-count]').should('have.text', '1');
});

// Playwright: кожна дія — проміс, який треба await
// test('adds a product to the cart', async ({ page }) => {
//   await page.goto('/products/42');
//   await page.getByTestId('add-to-cart').click();
//   await expect(page.getByTestId('cart-count')).toHaveText('1');
// });
// Cypress: commands are queued, waiting is built in
it('adds a product to the cart', () => {
  cy.visit('/products/42');
  cy.get('[data-cy=add-to-cart]').click();
  cy.get('[data-cy=cart-count]').should('have.text', '1');
});

// Playwright: every action is a promise you await
// test('adds a product to the cart', async ({ page }) => {
//   await page.goto('/products/42');
//   await page.getByTestId('add-to-cart').click();
//   await expect(page.getByTestId('cart-count')).toHaveText('1');
// });
🔧 Пояснення синтаксису: У Cypress команди cy.* синхронні на вигляд і стають у чергу, асерт .should сам повторюється; у Playwright кожна дія повертає Promise, тому потрібні async/await, а повторюваний асерт — це expect(locator).
🔧 Syntax explained: In Cypress the cy.* commands look synchronous and are queued, and .should retries by itself; in Playwright every action returns a Promise, so async/await are needed, and the retrying assertion is expect(locator).
💡 Простими словами: Cypress — зручний інструмент для фронтенд-команди в межах одного вікна; Playwright — ширші можливості з коробки; Selenium — стандарт для будь-якої мови й браузера.
💡 In plain words: Cypress is the comfortable tool for a frontend team inside one window; Playwright has wider capabilities out of the box; Selenium is the standard for any language and browser.
🗣 Як читати вголос Я не відповідаю, що якийсь інструмент кращий, я питаю, хто писатиме тести і що треба покрити. Якщо це фронтенд-команда на React і їй важливі компонентні тести й зручний дебаг, Cypress дає найшвидший старт. Якщо є кілька вкладок, Safari і тестувальники на Python, я б узяв Playwright, бо там це все вбудоване і паралелізація безкоштовна. Selenium лишається там, де Java, Grid і великий спадок. А стабільність у всіх трьох однаково залежить від того, як ми готуємо дані і чи ізольовані тести. Тому в розмові я завжди прив'язую вибір до конкретної команди і продукту.
🗣 How to say it out loud I do not answer that one tool is better, I ask who will write the tests and what needs covering. If it is a React frontend team that cares about component tests and easy debugging, Cypress gives the quickest start. If there are several tabs, Safari and testers who write Python, I would take Playwright, because all of that is built in and parallel runs are free. Selenium stays where there is Java, a Grid and a big legacy. And stability in all three depends equally on how we prepare data and whether the tests are isolated. So in the conversation I always tie the choice to a specific team and product.
▶ 2 7
6 Як Cypress чекає на елементи і що таке retry-ability? How does Cypress wait for elements, and what is retry-ability?
Час і асинхронністьTime and asynchronyJunior▶

Retry-ability — це вбудована здатність Cypress повторювати запити до DOM і асерти, доки перевірка не пройде або не мине тайм-аут. Коли ти пишеш cy.get('[data-cy=toast]').should('be.visible'), Cypress не робить одну спробу: він знову і знову шукає елемент і перевіряє видимість, типово до 4 секунд (defaultCommandTimeout = 4000 мс).

Retry-ability is Cypress's built-in ability to repeat DOM queries and assertions until the check passes or a timeout runs out. When you write cy.get('[data-cy=toast]').should('be.visible'), Cypress does not make a single attempt: it searches for the element and checks visibility again and again, by default for up to 4 seconds (defaultCommandTimeout = 4000 ms).

Навіщо: сучасний інтерфейс асинхронний — дані приходять із затримкою, компоненти перемальовуються. Повтори прибирають потребу в ручних паузах і роблять тест стійким до нормальних коливань швидкості. Для окремої повільної дії тайм-аут можна підняти локально: cy.get('.report', { timeout: 15000 }).

Why: a modern UI is asynchronous — data arrives late and components re-render. Retries remove the need for manual pauses and make the test tolerant of normal speed swings. For one slow action the timeout can be raised locally: cy.get('.report', { timeout: 15000 }).

Приклад: після кліку «Зберегти» спершу крутиться спінер, потім з'являється повідомлення. Асерт .should('contain', 'Збережено') просто дочекається тексту, без жодного cy.wait.

Example: after clicking "Save" a spinner turns for a while and then a message appears. The assertion .should('contain', 'Saved') simply waits for the text, with no cy.wait at all.

Підводний камінь: повторюються лише запити (queries: get, find, contains, its, invoke) і асерти. Дії click і type не повторюються — вони виконуються один раз, коли елемент пройшов перевірки actionability. Від Cypress 12 при невдалому асерті перезапускається весь ланцюжок запитів, а не лише остання команда, тому ланцюжок get().find().first() знаходить свіжий елемент навіть після перемальовування.

Pitfall: only queries (get, find, contains, its, invoke) and assertions are retried. The click and type actions are not — they run once, when the element passes the actionability checks. Since Cypress 12 a failing assertion re-runs the whole query chain, not just the last command, so get().find().first() finds a fresh element even after a re-render.

Якщо спитають далі: чи варто глобально ставити тайм-аут 30 секунд? Ні: це маскує справжні затримки і робить падіння дуже повільними. Краще точково збільшити тайм-аут там, де повільність очікувана.

If they dig deeper: should you set a global 30-second timeout? No: it hides real delays and makes failures very slow. It is better to raise the timeout exactly where slowness is expected.

Повтори замість пауз
Retries instead of pauses
it('shows a confirmation after saving', () => {
  cy.visit('/settings');
  cy.get('[data-cy=display-name]').clear().type('Olena');
  cy.get('[data-cy=save]').click();

  // повторюється до 4 с, поки текст не з'явиться
  cy.get('[data-cy=toast]').should('be.visible').and('contain', 'Saved');

  // звіт будується довго — тайм-аут лише для цього запиту
  cy.get('[data-cy=report-table]', { timeout: 15000 }).should('exist');
});
it('shows a confirmation after saving', () => {
  cy.visit('/settings');
  cy.get('[data-cy=display-name]').clear().type('Olena');
  cy.get('[data-cy=save]').click();

  // retried for up to 4 s until the text appears
  cy.get('[data-cy=toast]').should('be.visible').and('contain', 'Saved');

  // the report is slow — a timeout for this query only
  cy.get('[data-cy=report-table]', { timeout: 15000 }).should('exist');
});
🔧 Пояснення синтаксису: .should('be.visible') і .and(...) — асерти, які Cypress повторює разом із попереднім запитом; опція timeout: 15000 в об'єкті другого аргументу cy.get змінює тайм-аут лише для цієї команди і її асертів.
🔧 Syntax explained: .should('be.visible') and .and(...) are assertions that Cypress retries together with the preceding query; the timeout: 15000 option in the second-argument object of cy.get changes the timeout for this command and its assertions only.
💡 Простими словами: Cypress не дивиться на сторінку один раз — він дивиться, доки не побачить потрібне або доки не скінчиться час.
💡 In plain words: Cypress does not look at the page once — it keeps looking until it sees what it needs or runs out of time.
🗣 Як читати вголос Я кажу, що в Cypress вбудоване очікування: запити до DOM і асерти повторюються, типово до чотирьох секунд. Тому я не ставлю паузу після кліку, а просто перевіряю, що з'явився тост із текстом. Важливо розрізняти, що саме повторюється: get, find чи contains повторюються, а click і type ні, вони виконуються один раз, коли елемент готовий. Якщо конкретний звіт будується довго, я збільшую тайм-аут саме для цього запиту, а глобальний не чіпаю, бо інакше кожне падіння тягнеться пів хвилини і ховає справжні проблеми зі швидкістю.
🗣 How to say it out loud I say that waiting is built into Cypress: DOM queries and assertions are retried, by default for up to four seconds. So I do not put a pause after a click, I just check that a toast with the text has appeared. It matters to know what exactly gets retried: get, find and contains are retried, but click and type are not, they run once when the element is ready. If one particular report takes a while to build, I raise the timeout for that query alone and leave the global one alone, otherwise every failure drags on for half a minute.
7 Що таке черга команд і чому в тестах Cypress не пишуть async/await? What is the command queue, and why are Cypress tests not written with async/await?
Час і асинхронністьTime and asynchronyMiddle▶

Команди cy.* не виконуються в момент виклику. Коли функція тесту працює, кожен виклик cy.get чи cy.click лише додає команду в чергу і повертає об'єкт Chainable. Після того як функція тесту завершилася, Cypress виконує чергу по одній команді, чекаючи на кожну і повторюючи запити. Тобто код синхронний на вигляд, а виконується асинхронно й послідовно.

cy.* commands do not run at the moment they are called. While the test function runs, each call to cy.get or cy.click only adds a command to the queue and returns a Chainable object. Once the test function has finished, Cypress runs the queue one command at a time, waiting on each and retrying queries. So the code looks synchronous but runs asynchronously and in order.

Чому не async/await: Chainable — не проміс. У нього є метод .then, але він не відповідає специфікації Promise: не можна зробити await cy.get(...) і отримати елемент, а змішування власних промісів із командами Cypress дає помилки або непередбачуваний порядок. Черга дає Cypress контроль над таймінгом: повтори, снапшоти для time travel, тайм-аути для кожної команди.

Why not async/await: a Chainable is not a promise. It has a .then method, but it does not follow the Promise spec: you cannot await cy.get(...) and get an element back, and mixing your own promises with Cypress commands produces errors or an unpredictable order. The queue gives Cypress control over timing: retries, snapshots for time travel, a timeout per command.

Приклад: console.log між двома командами виведеться раніше за обидві, бо спрацює ще під час побудови черги. Щоб виконати звичайний JS у потрібний момент, його кладуть у .then() — колбек стане частиною черги.

Example: a console.log between two commands prints before both of them, because it fires while the queue is still being built. To run plain JS at the right moment you put it inside .then() — the callback becomes part of the queue.

Підводний камінь: умова if або цикл, що залежать від результату команди, не спрацюють «зовні», бо результату ще немає. Логіку, яка залежить від значення, пишуть усередині .then. Друга пастка — повертати проміс із тесту і водночас викликати cy.*: Cypress кине помилку про змішування.

Pitfall: an if or a loop that depends on a command's result will not work "outside", because the result does not exist yet. Logic that depends on a value goes inside .then. The second trap is returning a promise from a test while also calling cy.*: Cypress throws an error about mixing them.

Якщо спитають далі: а як працювати з власним асинхронним кодом, наприклад генератором даних? Обгорнути в cy.wrap(promise) — Cypress дочекається проміса і віддасть результат як subject — або винести в cy.task.

If they dig deeper: how do you handle your own async code, say a data generator? Wrap it in cy.wrap(promise) — Cypress waits for the promise and yields the result as the subject — or move it into cy.task.

Порядок виконання: черга проти звичайного JS
Execution order: the queue versus plain JS
it('shows why order matters', () => {
  console.log('1: побудова черги');
  cy.visit('/orders');
  cy.get('[data-cy=order-row]').its('length').then((count) => {
    console.log('3: рядків ' + count);   // виконується в черзі
  });
  console.log('2: функція тесту вже завершилась');
});

// так не працює: Chainable — не Promise
// const rows = await cy.get('[data-cy=order-row]');
it('shows why order matters', () => {
  console.log('1: building the queue');
  cy.visit('/orders');
  cy.get('[data-cy=order-row]').its('length').then((count) => {
    console.log('3: rows ' + count);   // runs inside the queue
  });
  console.log('2: the test function has already returned');
});

// this does not work: a Chainable is not a Promise
// const rows = await cy.get('[data-cy=order-row]');
🔧 Пояснення синтаксису: its('length') дістає кількість елементів як новий subject; колбек .then((count) => ...) потрапляє в чергу і виконується після cy.visit і cy.get; обидва звичайні console.log поза ланцюжком виконуються одразу, під час побудови черги.
🔧 Syntax explained: its('length') yields the number of elements as the new subject; the .then((count) => ...) callback is queued and runs after cy.visit and cy.get; both plain console.log calls outside the chain run immediately, while the queue is being built.
💡 Простими словами: Тест спочатку записує список справ, а потім Cypress виконує його пункт за пунктом — тому результат команди доступний лише всередині then.
💡 In plain words: The test first writes a to-do list, then Cypress works through it item by item — which is why a command's result is only available inside then.
🗣 Як читати вголос Я пояснюю, що функція тесту в Cypress лише складає чергу команд, а виконує її раннер уже після того, як функція завершилася. Тому await тут не допоможе: cy.get повертає не проміс, а ланцюжок, і елемента в момент виклику ще просто немає. Класичний приклад — console.log, який виводиться раніше за всі команди. Якщо мені потрібна логіка, що залежить від значення, я пишу її в then, а свій асинхронний код загортаю в cy.wrap або виношу в cy.task. Зате черга дає повтори, тайм-аути і time travel без жодних зусиль.
🗣 How to say it out loud I explain that a Cypress test function only builds a queue of commands, and the runner executes it after the function has returned. So await does not help here: cy.get returns a chain rather than a promise, and at the moment of the call the element simply does not exist yet. The classic example is a console.log that prints before all the commands. When I need logic that depends on a value, I write it inside then, and my own async code goes into cy.wrap or into cy.task. In return the queue gives me retries, timeouts and time travel for free.
8 Чим .should() відрізняється від .then() і коли писати expect? How does .should() differ from .then(), and when do you write expect?
Час і асинхронністьTime and asynchronyMiddle▶

Обидва методи дають доступ до subject, але поводяться протилежно щодо повторів. .should() — асерт: Cypress повторює і попередній запит, і перевірку, доки вона не пройде або не мине тайм-аут. .then() виконує колбек один раз, зі значенням, яке є саме зараз, і нічого не повторює.

Both methods give access to the subject, but they behave in opposite ways with respect to retries. .should() is an assertion: Cypress retries both the preceding query and the check until it passes or the timeout runs out. .then() runs its callback once, with the value that exists right now, and retries nothing.

Навіщо розрізняти: якщо написати .then(($el) => expect($el).to.have.text('Paid')), а статус оновлюється через секунду, тест упаде, бо перевірка відбулася один раз. Той самий expect усередині .should(($el) => { ... }) повторюватиметься, доки текст не зміниться. Колбек-форма .should зручна для складних перевірок, коли потрібно кілька умов або обчислення.

Why it matters: if you write .then(($el) => expect($el).to.have.text('Paid')) and the status updates a second later, the test fails, because the check happened once. The same expect inside .should(($el) => { ... }) keeps being retried until the text changes. The callback form of .should is handy for complex checks that need several conditions or some computation.

Приклад: перевірити, що в таблиці рівно три рядки і перший містить «Olena» — одним колбеком .should; а взяти текст ціни, перетворити на число й передати далі — через .then, бо це не перевірка, а перетворення.

Example: checking that the table has exactly three rows and the first contains "Olena" — one .should callback; taking the price text, turning it into a number and passing it on — .then, because that is a transformation, not a check.

Підводний камінь: колбек .should може виконатися десятки разів, тож у ньому не повинно бути побічних ефектів і команд cy.* — Cypress заборонить їх. Інша пастка: .then, який нічого не повертає, передає далі старий subject, а якщо повертає значення — воно стане новим subject.

Pitfall: a .should callback may run dozens of times, so it must have no side effects and no cy.* commands — Cypress forbids them. Another trap: a .then that returns nothing passes the old subject on, while one that returns a value makes it the new subject.

Якщо спитають далі: де тоді звичайний expect? Він доречний усередині .then для значень, які вже не зміняться: відповідь cy.request, вміст фікстури, аргументи перехопленого запиту після cy.wait('@alias').

If they dig deeper: so where does plain expect belong? Inside .then, for values that will not change any more: a cy.request response, fixture content, the arguments of an intercepted request after cy.wait('@alias').

Повторювана перевірка проти одноразового перетворення
A retried check versus a one-off transformation
// повторюється, доки всі умови не стануть істинними
cy.get('[data-cy=invoice-row]').should(($rows) => {
  expect($rows).to.have.length(3);
  expect($rows.first()).to.contain('Olena');
});

// виконується один раз: перетворення, а не перевірка
cy.get('[data-cy=total]')
  .invoke('text')
  .then((text) => Number(text.replace(/[^0-9.]/g, '')))
  .should('be.greaterThan', 0);

// значення вже не зміниться — expect у then доречний
cy.wait('@createInvoice').then(({ request }) => {
  expect(request.body).to.have.property('currency', 'UAH');
});
// retried until every condition holds
cy.get('[data-cy=invoice-row]').should(($rows) => {
  expect($rows).to.have.length(3);
  expect($rows.first()).to.contain('Olena');
});

// runs once: a transformation, not a check
cy.get('[data-cy=total]')
  .invoke('text')
  .then((text) => Number(text.replace(/[^0-9.]/g, '')))
  .should('be.greaterThan', 0);

// the value will not change — expect inside then is fine
cy.wait('@createInvoice').then(({ request }) => {
  expect(request.body).to.have.property('currency', 'UAH');
});
🔧 Пояснення синтаксису: $rows — jQuery-колекція; expect(...).to.have.length(3) — синтаксис Chai BDD; регулярний вираз /[^0-9.]/g прибирає все, крім цифр і крапки; деструктуризація ({ request }) дістає запит з об'єкта перехоплення.
🔧 Syntax explained: $rows is a jQuery collection; expect(...).to.have.length(3) is Chai BDD syntax; the regular expression /[^0-9.]/g strips everything but digits and the dot; destructuring ({ request }) pulls the request out of the interception object.
💡 Простими словами: should — «перевіряй, доки не стане правдою», then — «візьми, що є зараз, і зроби з цим щось».
💡 In plain words: should means "keep checking until it is true"; then means "take what is there right now and do something with it".
🗣 Як читати вголос Для мене різниця в одному слові: повтори. Should повторює перевірку разом із пошуком елемента, доки вона не пройде, а then виконується один раз із тим, що є зараз. Тому стан, який може змінитися, я завжди перевіряю через should, навіть складний, у колбек-формі з кількома expect. А then беру, коли треба щось перетворити, наприклад текст ціни в число, або коли значення вже зафіксоване, як тіло перехопленого запиту. І пам'ятаю, що в колбеку should не можна викликати команди, бо він крутиться багато разів.
🗣 How to say it out loud For me the difference comes down to one word: retries. Should repeats the check together with the element lookup until it passes, while then runs once with whatever is there right now. So I always check state that may still change through should, even a complex check, in the callback form with several expects. I use then when I need to transform something, like a price text into a number, or when the value is already fixed, like the body of an intercepted request. And I remember that a should callback cannot call commands, because it runs many times.
9 Чому результат cy.get() не можна зберегти у звичайну змінну і як тут допомагає cy.wrap? Why can the result of cy.get() not be kept in a plain variable, and how does cy.wrap help?
Час і асинхронністьTime and asynchronyMiddle▶

const button = cy.get('button') не кладе в змінну елемент. Там опиниться об'єкт Chainable — посилання на команду в черзі, яка ще навіть не виконалась. Спроба взяти в нього .text() чи порівняти зі значенням дає undefined або помилку. Так само не працює let total; cy.get(...).then(t => total = t) з подальшим використанням total на наступному рядку — рядок виконається раніше, ніж колбек.

const button = cy.get('button') does not put an element into the variable. What ends up there is a Chainable — a handle to a queued command that has not even run yet. Trying to read .text() from it or compare it with a value gives undefined or an error. Likewise let total; cy.get(...).then(t => total = t) followed by using total on the next line does not work — that line runs before the callback does.

Навіщо знати: це найчастіша помилка людей, що прийшли із Selenium чи Playwright, де await віддає значення одразу. Правильні способи — продовжити ланцюжок, вкласти залежну логіку в .then, або зберегти значення через аліас .as('total') і дістати його пізніше через cy.get('@total') або this.total.

Why it matters: this is the most common mistake of people coming from Selenium or Playwright, where await hands the value back at once. The correct options are to continue the chain, to nest the dependent logic in .then, or to store the value with an alias .as('total') and read it later through cy.get('@total') or this.total.

Де тут cy.wrap: cy.wrap(value) робить із будь-якого значення, об'єкта, jQuery-елемента чи проміса subject Cypress. Це потрібно, коли всередині .then ти отримав сирий jQuery-елемент і хочеш знову працювати з ним командами: cy.wrap($row).find('button').click(). Для проміса cy.wrap дочекається його виконання.

Where cy.wrap comes in: cy.wrap(value) turns any value, object, jQuery element or promise into a Cypress subject. You need it when inside .then you got a raw jQuery element and want to work with it through commands again: cy.wrap($row).find('button').click(). For a promise, cy.wrap waits for it to settle.

Підводний камінь: cy.wrap для елемента не повторює пошук, він тримає конкретний DOM-вузол. Якщо застосунок його перемалював, вузол «протух» і отримаєш помилку про від'єднаний елемент. Тому всередині циклів краще шукати заново через cy.get за стабільним селектором.

Pitfall: cy.wrap on an element does not repeat the lookup; it holds a specific DOM node. If the app has re-rendered it, the node has gone stale and you get a detached-element error. So inside loops it is safer to search again with cy.get by a stable selector.

Якщо спитають далі: аліас на DOM-елемент у Cypress 12+ зберігає не вузол, а запит, і при зверненні cy.get('@row') знаходить елемент заново — саме тому аліас надійніший за змінну.

If they dig deeper: in Cypress 12+ an alias on a DOM element stores the query rather than the node, and cy.get('@row') finds the element afresh — that is exactly why an alias is more reliable than a variable.

Пастка змінної і три правильні варіанти
The variable trap and three correct options
// помилка: у змінній Chainable, а не елемент
// const title = cy.get('h1'); if (title.text() === 'Cart') { ... }

// 1. продовжити ланцюжок
cy.get('h1').should('have.text', 'Cart');

// 2. аліас і читання пізніше
cy.get('[data-cy=total]').invoke('text').as('totalBefore');
cy.get('[data-cy=apply-coupon]').click();
cy.get('@totalBefore').then((before) => {
  cy.get('[data-cy=total]').invoke('text').should('not.eq', before);
});

// 3. повернути сирий jQuery-елемент у ланцюжок
cy.get('[data-cy=order-row]').first().then(($row) => {
  cy.wrap($row).find('[data-cy=cancel]').click();
});
// wrong: the variable holds a Chainable, not an element
// const title = cy.get('h1'); if (title.text() === 'Cart') { ... }

// 1. continue the chain
cy.get('h1').should('have.text', 'Cart');

// 2. an alias, read later
cy.get('[data-cy=total]').invoke('text').as('totalBefore');
cy.get('[data-cy=apply-coupon]').click();
cy.get('@totalBefore').then((before) => {
  cy.get('[data-cy=total]').invoke('text').should('not.eq', before);
});

// 3. bring a raw jQuery element back into the chain
cy.get('[data-cy=order-row]').first().then(($row) => {
  cy.wrap($row).find('[data-cy=cancel]').click();
});
🔧 Пояснення синтаксису: .as('totalBefore') зберігає subject під іменем; cy.get('@totalBefore') віддає його пізніше в цьому ж тесті; cy.wrap($row) перетворює jQuery-об'єкт на subject, до якого знову застосовні find і click.
🔧 Syntax explained: .as('totalBefore') stores the subject under a name; cy.get('@totalBefore') yields it later in the same test; cy.wrap($row) turns a jQuery object into a subject that find and click work on again.
💡 Простими словами: У змінній опиняється квитанція на майбутній результат, а не сам результат; аліас — це підписаний контейнер, а cy.wrap повертає річ назад на конвеєр.
💡 In plain words: The variable holds a receipt for a future result, not the result itself; an alias is a labelled box, and cy.wrap puts a thing back on the conveyor.
🗣 Як читати вголос Я одразу кажу, що cy.get повертає не елемент, а ланцюжок, який ще не виконався, тож змінна там нічого корисного не тримає. Коли мені треба запам'ятати значення, наприклад суму до застосування купона, я роблю аліас і дістаю його пізніше через cy.get з собачкою. Якщо ж у then мені прийшов сирий jQuery-елемент, я загортаю його в cy.wrap, щоб знову клікати командами Cypress. Але для елементів, які застосунок може перемалювати, я краще шукаю їх наново, бо загорнутий вузол може вже бути від'єднаним від сторінки.
🗣 How to say it out loud I say straight away that cy.get returns not an element but a chain that has not run yet, so a variable holds nothing useful there. When I need to remember a value, like the total before a coupon is applied, I make an alias and read it later with cy.get and the at sign. If a raw jQuery element comes into my then, I wrap it in cy.wrap so I can click it with Cypress commands again. But for elements the app may re-render, I would rather look them up afresh, because a wrapped node may already be detached from the page.
10 Чому cy.wait(1000) — антипатерн і чим замінити паузу після дії? Why is cy.wait(1000) an anti-pattern, and what replaces a pause after an action?
Час і асинхронністьTime and asynchronySenior▶

cy.wait(ms) зупиняє тест на фіксований час незалежно від того, що відбувається в застосунку. Якщо бекенд відповів за 200 мс, ти втратив 800. Якщо на завантаженому CI він відповів за 1500 — тест падає. Тобто пауза водночас сповільнює сюїту і не гарантує стабільності: на тисячі тестів це десятки хвилин і постійні «випадкові» падіння.

cy.wait(ms) halts the test for a fixed time no matter what the app is doing. If the backend replied in 200 ms, you lost 800. If on a busy CI it replied in 1500, the test fails. So the pause both slows the suite down and guarantees no stability: across a thousand tests that is tens of minutes and constant "random" failures.

Чим замінити: очікуванням конкретної події. Для мережі — cy.intercept(...).as('save') до дії і cy.wait('@save') після: Cypress дочекається саме цього запиту і відповіді, а ще можна перевірити статус і тіло. Для інтерфейсу — асертом на результат дії: зник спінер, з'явився рядок, змінився текст. Retry-ability зробить решту.

What to use instead: waiting for a specific event. For the network — cy.intercept(...).as('save') before the action and cy.wait('@save') after it: Cypress waits for exactly that request and its response, and you can check the status and body too. For the UI — an assertion on the outcome of the action: the spinner has gone, a row has appeared, a text has changed. Retry-ability does the rest.

Приклад із практики: тест кліком запускає експорт, фронтенд опитує /api/export/status, доки статус не стане ready. Замість cy.wait(5000) перевіряємо, що кнопка «Завантажити» стала активною, з підвищеним тайм-аутом саме для цієї перевірки.

Real example: a click starts an export and the frontend polls /api/export/status until the status is ready. Instead of cy.wait(5000), check that the "Download" button has become enabled, with a raised timeout for that check only.

Підводний камінь: cy.wait('@alias') теж можна зловживати — чекати запит, який застосунок робить не завжди, або чекати аліас, оголошений після дії, що вже встигла відправити запит. Інтерсепт завжди реєструється до дії. Також cy.wait('@alias') чекає лише наступний збіг: для трьох однакових запитів потрібно три очікування.

Pitfall: cy.wait('@alias') can be misused too — waiting for a request the app does not always make, or waiting on an alias declared after the action has already sent the request. The intercept is always registered before the action. Also cy.wait('@alias') waits for the next match only: three identical requests need three waits.

Якщо спитають далі: чи бувають виправдані фіксовані паузи? Рідко: троттлінг зовнішнього сервісу чи анімація Canvas, яку не видно в DOM. І навіть тоді краще керувати часом через cy.clock, ніж спати.

If they dig deeper: are fixed pauses ever justified? Rarely: throttling by an external service, or a Canvas animation that is not visible in the DOM. And even then it is better to control time with cy.clock than to sleep.

Очікування події замість паузи
Waiting for an event instead of a pause
it('saves a profile without sleeping', () => {
  cy.intercept('PUT', '/api/profile').as('saveProfile');   // до дії
  cy.visit('/profile');

  cy.get('[data-cy=phone]').clear().type('+380501234567');
  cy.get('[data-cy=save]').click();
  // cy.wait(1000);   // антипатерн

  cy.wait('@saveProfile').its('response.statusCode').should('eq', 200);
  cy.get('[data-cy=spinner]').should('not.exist');
  cy.get('[data-cy=phone]').should('have.value', '+380501234567');
});
it('saves a profile without sleeping', () => {
  cy.intercept('PUT', '/api/profile').as('saveProfile');   // before the action
  cy.visit('/profile');

  cy.get('[data-cy=phone]').clear().type('+380501234567');
  cy.get('[data-cy=save]').click();
  // cy.wait(1000);   // anti-pattern

  cy.wait('@saveProfile').its('response.statusCode').should('eq', 200);
  cy.get('[data-cy=spinner]').should('not.exist');
  cy.get('[data-cy=phone]').should('have.value', '+380501234567');
});
🔧 Пояснення синтаксису: cy.intercept('PUT', '/api/profile') без обробника лише стежить за запитом; .as('saveProfile') дає йому ім'я; cy.wait('@saveProfile') чекає наступний збіг і віддає перехоплення; its('response.statusCode') дістає вкладене поле за шляхом через крапку.
🔧 Syntax explained: cy.intercept('PUT', '/api/profile') without a handler only watches the request; .as('saveProfile') gives it a name; cy.wait('@saveProfile') waits for the next match and yields the interception; its('response.statusCode') reads a nested field by a dotted path.
💡 Простими словами: Не чекай годинник — чекай подію: відповідь сервера або зміну на сторінці.
💡 In plain words: Do not wait for the clock — wait for the event: the server's reply or a change on the page.
🗣 Як читати вголос Мій аргумент проти cy.wait з мілісекундами простий: пауза одночасно і задовга, і закоротка. Локально вона марнує час, а на завантаженому CI її не вистачає, і тест падає нібито випадково. Тому я чекаю подію: реєструю інтерсепт до кліку, після кліку чекаю аліас і заодно перевіряю статус відповіді, а потім асертом дивлюся на результат в інтерфейсі. Якщо процес довгий, як експорт звіту, я не сплю п'ять секунд, а піднімаю тайм-аут для однієї конкретної перевірки. Фіксовану паузу в код-рев'ю я прошу пояснити коментарем.
🗣 How to say it out loud My case against cy.wait with milliseconds is simple: a pause is too long and too short at the same time. Locally it wastes time, and on a busy CI it is not enough, so the test fails seemingly at random. So I wait for an event: I register the intercept before the click, after the click I wait for the alias and check the response status along the way, and then an assertion looks at the result in the UI. If the process is long, like a report export, I do not sleep for five seconds, I raise the timeout for one specific check. In code review I ask for a comment explaining any fixed pause.
11 Як тестувати таймери, дебаунс і дати за допомогою cy.clock і cy.tick? How do you test timers, debounce and dates with cy.clock and cy.tick?
Час і асинхронністьTime and asynchronyMiddle▶

cy.clock() підміняє в застосунку функції часу — setTimeout, setInterval, clearTimeout, clearInterval і Date — на керовані версії. Після цього час у застосунку стоїть, доки ти не просунеш його командою cy.tick(ms). Годинник автоматично скидається між тестами.

cy.clock() replaces the app's time functions — setTimeout, setInterval, clearTimeout, clearInterval and Date — with controllable versions. From then on time in the app stands still until you move it forward with cy.tick(ms). The clock is reset automatically between tests.

Навіщо: щоб перевірити поведінку, яка залежить від часу, без реального очікування. Автологаут через 15 хвилин неактивності, дебаунс пошуку на 300 мс, банер «акція закінчиться опівночі», таймер повторного надсилання SMS — усе це тестується за мілісекунди.

Why: to check time-dependent behaviour without actually waiting. Auto-logout after 15 minutes of inactivity, a 300 ms search debounce, a "sale ends at midnight" banner, a countdown before an SMS can be resent — all of it is tested in milliseconds.

Приклад: щоб перевірити дебаунс, вводимо текст, робимо cy.tick(299) і переконуємося, що запиту ще не було, потім cy.tick(1) — і чекаємо аліас. Для фіксованої дати передаємо мітку часу: cy.clock(new Date(2025, 11, 31, 23, 59).getTime()) — і застосунок «живе» 31 грудня.

Example: to check a debounce, type the text, call cy.tick(299) and make sure no request has gone out yet, then cy.tick(1) and wait for the alias. For a fixed date, pass a timestamp: cy.clock(new Date(2025, 11, 31, 23, 59).getTime()) — and the app "lives" on 31 December.

Підводний камінь: годинник треба ставити до cy.visit, інакше застосунок уже запустив свої таймери зі справжнім часом. Друге — заморожений setTimeout може зламати анімації чи бібліотеки, які на нього покладаються; тоді другим аргументом передають лише потрібні функції: cy.clock(now, ['Date']). Третє — cy.clock не впливає на час сервера: бекенд, який перевіряє термін дії токена, живе у своєму часі.

Pitfall: the clock must be installed before cy.visit, otherwise the app has already started its timers on real time. Second, a frozen setTimeout can break animations or libraries that rely on it; then pass only the functions you need as the second argument: cy.clock(now, ['Date']). Third, cy.clock has no effect on server time: a backend checking token expiry lives on its own clock.

Якщо спитають далі: як змінити дату посеред тесту? Через об'єкт годинника: cy.clock().then((clock) => clock.setSystemTime(...)).

If they dig deeper: how do you change the date mid-test? Through the clock object: cy.clock().then((clock) => clock.setSystemTime(...)).

Дебаунс пошуку і автологаут без реального очікування
Search debounce and auto-logout without real waiting
it('debounces the search input', () => {
  let calls = 0;
  cy.intercept('GET', '/api/search*', (req) => {
    calls += 1;
    req.reply({ items: [] });
  }).as('search');

  cy.clock();                    // до cy.visit
  cy.visit('/catalogue');
  cy.get('[data-cy=search]').type('cypress');

  cy.tick(299);
  cy.then(() => expect(calls, 'no request yet').to.eq(0));
  cy.tick(1);
  cy.wait('@search').its('request.url').should('include', 'q=cypress');
});

it('logs the user out after 15 idle minutes', () => {
  cy.clock();
  cy.visit('/dashboard');
  cy.tick(15 * 60 * 1000);
  cy.get('[data-cy=session-expired]').should('be.visible');
});
it('debounces the search input', () => {
  let calls = 0;
  cy.intercept('GET', '/api/search*', (req) => {
    calls += 1;
    req.reply({ items: [] });
  }).as('search');

  cy.clock();                    // before cy.visit
  cy.visit('/catalogue');
  cy.get('[data-cy=search]').type('cypress');

  cy.tick(299);
  cy.then(() => expect(calls, 'no request yet').to.eq(0));
  cy.tick(1);
  cy.wait('@search').its('request.url').should('include', 'q=cypress');
});

it('logs the user out after 15 idle minutes', () => {
  cy.clock();
  cy.visit('/dashboard');
  cy.tick(15 * 60 * 1000);
  cy.get('[data-cy=session-expired]').should('be.visible');
});
🔧 Пояснення синтаксису: Обробник у cy.intercept рахує виклики і відповідає порожнім списком; cy.tick(299) просуває заморожений час на 299 мс; cy.then(() => ...) кладе перевірку лічильника в чергу у правильний момент; 15 * 60 * 1000 — п'ятнадцять хвилин у мілісекундах.
🔧 Syntax explained: The handler in cy.intercept counts calls and replies with an empty list; cy.tick(299) moves the frozen time forward by 299 ms; cy.then(() => ...) queues the counter check at the right moment; 15 * 60 * 1000 is fifteen minutes in milliseconds.
💡 Простими словами: cy.clock зупиняє годинник застосунку, а cy.tick — це кнопка перемотки вперед.
💡 In plain words: cy.clock stops the app's clock, and cy.tick is the fast-forward button.
🗣 Як читати вголос Коли бачу функцію, прив'язану до часу, наприклад автологаут через п'ятнадцять хвилин, я не чекаю ці хвилини, а ставлю cy.clock перед cy.visit і перемотую час через cy.tick. Для дебаунсу я перевіряю обидві межі: за двісті дев'яносто дев'ять мілісекунд запиту ще немає, а через одну мілісекунду він з'являється. Якщо заморожені таймери ламають якусь бібліотеку, я підміняю лише Date. І окремо нагадую, що годинник керує лише браузером, а сервер свій час рахує сам, тож термін дії токена так не перевіриш, для цього потрібен тестовий бекенд.
🗣 How to say it out loud When I see a feature tied to time, like auto-logout after fifteen minutes, I do not wait those minutes, I put cy.clock before cy.visit and fast-forward with cy.tick. For a debounce I check both edges: at two hundred and ninety-nine milliseconds there is no request yet, and one millisecond later it appears. If frozen timers break some library, I replace only Date. And I point out separately that the clock only controls the browser, the server keeps its own time, so token expiry cannot be checked this way, that needs a test backend.
12 Чому анімації роблять тести Cypress нестабільними і як це виправити системно? Why do animations make Cypress tests flaky, and how do you fix it systematically?
Час і асинхронністьTime and asynchronySenior▶

Перед кліком Cypress перевіряє, чи елемент не анімується: порівнює його позицію в кількох кадрах і чекає, доки зсув не стане меншим за animationDistanceThreshold (типово 5 пікселів). Це працює для переміщення, але не для всього. Модалка, що з'являється через opacity, уже «видима» для Cypress, хоча ще прозора; елемент під нею, що виїжджає, може отримати клік не туди; дропдаун закривається, поки курсор «доїжджає».

Before a click Cypress checks whether the element is animating: it compares its position across several frames and waits until the movement drops below animationDistanceThreshold (5 pixels by default). That works for movement, but not for everything. A modal that fades in through opacity is already "visible" to Cypress while still transparent; an element sliding in underneath can take a click meant for something else; a dropdown closes while the pointer is still "on its way".

Чому це важливо на рівні сюїти: анімації дають класичні флейки, які проходять локально і падають на повільному CI, де кадри рендеряться інакше. Точкові force: true і паузи лише ховають проблему і множаться по коду.

Why it matters at suite level: animations cause classic flakes that pass locally and fail on a slow CI where frames render differently. Scattered force: true options and pauses only hide the problem and multiply across the code.

Системне рішення: у тестовому оточенні анімації вимикають централізовано. Найчастіше — у support-файлі на подію window:before:load додають у документ стиль, що обнуляє transition і animation. Кращий варіант, якщо команда погодиться, — прапорець у самому застосунку (наприклад, змінна оточення, що відключає анімації бібліотеки UI). Для перевірки самої анімації пишуть окремий, свідомо повільніший тест.

Systematic fix: in the test environment animations are switched off centrally. Most often a support file listens for window:before:load and adds a style to the document that zeroes transition and animation. Better still, if the team agrees, is a flag in the app itself (say, an environment variable that turns off the UI library's animations). To test an animation itself you write a separate, deliberately slower test.

Підводний камінь: не відключай перевірку глобально через waitForAnimations: false — Cypress почне клікати по елементах, що рухаються. І пам'ятай, що вимкнення CSS-анімацій не зупиняє JS-анімації (наприклад, бібліотеки на requestAnimationFrame), для них потрібен прапорець у застосунку.

Pitfall: do not turn the check off globally with waitForAnimations: false — Cypress will start clicking moving elements. And remember that switching off CSS animations does not stop JS animations (for example, libraries built on requestAnimationFrame); those need an app-level flag.

Якщо спитають далі: як довести, що флейк саме від анімації? Відкрити Test Replay чи відео падіння, подивитися снапшоти до і після кліку, і прогнати спек у циклі до і після вимкнення анімацій.

If they dig deeper: how do you prove that a flake comes from an animation? Open Test Replay or the failure video, compare the snapshots before and after the click, and run the spec in a loop before and after disabling animations.

cypress/support/e2e.ts — вимикаємо CSS-анімації для всіх тестів
cypress/support/e2e.ts — switching off CSS animations for every test
// спрацьовує перед завантаженням кожної сторінки застосунку
Cypress.on('window:before:load', (win) => {
  const style = win.document.createElement('style');
  style.setAttribute('data-cy', 'no-animations');
  style.innerHTML =
    '*, *::before, *::after {' +
    '  transition: none !important;' +
    '  animation: none !important;' +
    '  scroll-behavior: auto !important;' +
    '}';
  win.document.head.appendChild(style);
});
// fires before every app page loads
Cypress.on('window:before:load', (win) => {
  const style = win.document.createElement('style');
  style.setAttribute('data-cy', 'no-animations');
  style.innerHTML =
    '*, *::before, *::after {' +
    '  transition: none !important;' +
    '  animation: none !important;' +
    '  scroll-behavior: auto !important;' +
    '}';
  win.document.head.appendChild(style);
});
🔧 Пояснення синтаксису: Cypress.on('window:before:load', ...) — глобальна подія раннера з вікном застосунку як аргументом; ми створюємо елемент style і додаємо його в head; !important перебиває стилі бібліотеки; конкатенація рядків замість шаблонних рядків тримає CSS читабельним.
🔧 Syntax explained: Cypress.on('window:before:load', ...) is a global runner event that receives the app window; we create a style element and append it to head; !important overrides the library styles; string concatenation instead of template literals keeps the CSS readable.
💡 Простими словами: Анімація — це рух, а тест любить нерухомі цілі; простіше зупинити рух для всіх тестів одразу, ніж лікувати кожен клік окремо.
💡 In plain words: An animation is movement, and a test likes still targets; it is easier to stop the movement for every test at once than to patch each click separately.
🗣 Як читати вголос Я розповідаю, що Cypress сам чекає, поки елемент перестане рухатися, але він дивиться лише на зміщення, а плавна поява через прозорість чи JS-анімація його обманює. Тому я не розкидаю force і паузи по тестах, а вимикаю анімації централізовано: у support-файлі вставляю стиль, що обнуляє transition і animation, а для анімацій на JS домовляюся з розробниками про прапорець у застосунку. Саму анімацію, якщо вона важлива, перевіряю окремим тестом. І перед тим як лікувати, доводжу причину через відео або прогін у циклі.
🗣 How to say it out loud I explain that Cypress itself waits until an element stops moving, but it only looks at displacement, so a smooth fade-in or a JS animation fools it. So I do not scatter force and pauses across the tests, I switch animations off centrally: in the support file I inject a style that zeroes transition and animation, and for JS animations I agree a flag in the app with the developers. If the animation itself matters, I check it in a separate test. And before fixing anything I prove the cause with a video or a run in a loop.
▶ 3 10
13 Які селектори в Cypress стабільні, навіщо атрибут data-cy і коли доречний cy.contains? Which selectors are stable in Cypress, why the data-cy attribute, and when is cy.contains the right choice?
Локатори й взаємодіяLocators and interactionJunior▶

Стабільний селектор не змінюється, коли дизайнер перефарбував кнопку або розробник переписав верстку. Тому рекомендація Cypress — окремий тестовий атрибут: data-cy, data-test або data-testid. Він існує лише для тестів, і всі в команді знають: якщо його видалити, тести зламаються.

A stable selector does not change when a designer recolours a button or a developer rewrites the markup. So the Cypress recommendation is a dedicated test attribute: data-cy, data-test or data-testid. It exists only for tests, and everyone on the team knows that removing it will break them.

Чому не класи й не XPath: класи на кшталт .btn-primary або згенеровані .css-1x2y3z змінюються разом зі стилями, а ланцюжки div > div:nth-child(3) > span ламаються від будь-якої обгортки. id кращий, але часто генерується або використовується для JS. XPath Cypress взагалі не підтримує з коробки — лише через сторонній плагін.

Why not classes or XPath: classes like .btn-primary or generated ones like .css-1x2y3z change along with the styles, and chains such as div > div:nth-child(3) > span break with any extra wrapper. An id is better, but it is often generated or used by JS. Cypress does not support XPath out of the box at all — only through a third-party plugin.

Де cy.contains: коли текст — частина вимоги. Кнопка «Оформити замовлення» має називатися саме так, і якщо текст змінили без узгодження, тест має впасти. cy.contains('[data-cy=menu] button', 'Налаштування') поєднує обидва підходи: обмежує пошук контейнером і перевіряє текст. cy.contains повертає найглибший елемент із таким текстом, але віддає перевагу кнопкам, посиланням і лейблам.

Where cy.contains fits: when the text is part of the requirement. The "Place order" button must be called exactly that, and if the text changes without agreement the test should fail. cy.contains('[data-cy=menu] button', 'Settings') combines both approaches: it narrows the search to a container and checks the text. cy.contains yields the deepest element with that text, but prefers buttons, links and labels.

Підводний камінь: текст ламається від локалізації та дрібних правок копірайтера; якщо сайт має кілька мов, текстові селектори варто прив'язувати до однієї тестової локалі. cy.contains чутливий до регістру — для нечутливого пошуку є опція matchCase: false.

Pitfall: text breaks with localisation and small copywriting edits; if the site has several languages, text-based selectors should be tied to one test locale. cy.contains is case-sensitive — for a case-insensitive search there is the matchCase: false option.

Якщо спитають далі: як домовитися з розробниками про data-cy? Показати, що атрибут дешевий, не впливає на користувача, а прибирає цілі класи падінь; часто його додають у спільні компоненти один раз.

If they dig deeper: how do you get developers to add data-cy? Show that the attribute is cheap, invisible to users and removes whole classes of failures; it is often added once to shared components.

Від крихкого до стабільного селектора
From a brittle to a stable selector
// крихко: залежить від стилів і структури
cy.get('.btn.btn-primary.mt-2').click();
cy.get('#root > div > main > form > div:nth-child(3) > input').type('Kyiv');

// стабільно: тестовий атрибут
cy.get('[data-cy=submit-order]').click();
cy.get('[data-cy=city]').type('Kyiv');

// текст як частина вимоги, пошук обмежено контейнером
cy.contains('[data-cy=main-menu] a', 'Settings').click();
cy.contains('button', /place order/i).should('be.enabled');
// brittle: tied to styles and structure
cy.get('.btn.btn-primary.mt-2').click();
cy.get('#root > div > main > form > div:nth-child(3) > input').type('Kyiv');

// stable: a test attribute
cy.get('[data-cy=submit-order]').click();
cy.get('[data-cy=city]').type('Kyiv');

// text as part of the requirement, scoped to a container
cy.contains('[data-cy=main-menu] a', 'Settings').click();
cy.contains('button', /place order/i).should('be.enabled');
🔧 Пояснення синтаксису: [data-cy=submit-order] — CSS-селектор за атрибутом; cy.contains(selector, text) шукає елемент, що відповідає селектору і містить текст; регулярний вираз /place order/i з прапорцем i ігнорує регістр.
🔧 Syntax explained: [data-cy=submit-order] is a CSS attribute selector; cy.contains(selector, text) finds an element that matches the selector and contains the text; the regular expression /place order/i with the i flag ignores case.
💡 Простими словами: data-cy — це табличка «для тестів» на елементі: дизайн може змінюватись як завгодно, табличка лишається.
💡 In plain words: data-cy is a "for tests" label on the element: the design can change any way it likes, the label stays.
🗣 Як читати вголос Я одразу кажу, що в нас основний селектор — атрибут data-cy, бо він не залежить ні від стилів, ні від структури верстки, і розробники знають, що його не можна прибирати. Класи й довгі CSS-ланцюжки я використовую лише як тимчасове рішення. cy.contains я беру тоді, коли текст сам є вимогою, як назва кнопки оформлення замовлення, і обмежую його контейнером, щоб не знайти випадковий збіг. Якщо сайт багатомовний, тести в мене працюють на одній локалі, і тоді текстові перевірки не ламаються від перекладів.
🗣 How to say it out loud I say up front that our main selector is the data-cy attribute, because it depends neither on styles nor on the markup structure, and developers know it must not be removed. Classes and long CSS chains I use only as a stopgap. I pick cy.contains when the text itself is a requirement, like the name of the checkout button, and I scope it to a container so it does not hit a random match. If the site is multilingual, my tests run on one locale, and then text checks do not break because of translations.
14 Як обмежити пошук частиною сторінки і чим .within() відрізняється від .find() і .filter()? How do you scope a search to part of the page, and how does .within() differ from .find() and .filter()?
Локатори й взаємодіяLocators and interactionMiddle▶

Усі три методи звужують пошук, але по-різному. .find(selector) шукає нащадків поточного subject і віддає їх як новий subject. .filter(selector) не спускається вниз, а залишає з поточного набору лише ті елементи, що відповідають умові. .within(callback) змінює область видимості: усі cy.get і cy.contains усередині колбека шукають лише в межах цього елемента.

All three methods narrow a search, but in different ways. .find(selector) looks for descendants of the current subject and yields them as the new subject. .filter(selector) does not go down; it keeps only the elements of the current set that match. .within(callback) changes the scope: every cy.get and cy.contains inside the callback searches only within that element.

Навіщо: на реальних сторінках однакові елементи повторюються — кнопка «Видалити» в кожному рядку таблиці, поле «Ім'я» у формі доставки і у формі отримувача. Звуження області прибирає неоднозначність без складних селекторів і робить тест читабельним: «у картці замовлення №42 натисни Скасувати».

Why: on real pages identical elements repeat — a "Delete" button in every table row, a "Name" field in both the delivery form and the recipient form. Narrowing the scope removes the ambiguity without complicated selectors and keeps the test readable: "in the card of order 42, click Cancel".

Приклад: cy.get('[data-cy=shipping-form]').within(() => { cy.get('[name=city]').type('Lviv') }) заповнить місто саме у формі доставки. cy.get('tr').filter(':contains("Overdue")') залишить лише прострочені рядки, а .find('button') знайде кнопки всередині них.

Example: cy.get('[data-cy=shipping-form]').within(() => { cy.get('[name=city]').type('Lviv') }) fills in the city in the delivery form specifically. cy.get('tr').filter(':contains("Overdue")') keeps only the overdue rows, and .find('button') then finds the buttons inside them.

Підводний камінь: від Cypress 12 .within() вимагає, щоб subject був рівно одним елементом, інакше падає з помилкою — раніше він мовчки брав перший. Ще одна пастка: .within() віддає далі свій вихідний subject, а не останній знайдений усередині елемент, тож ланцюжок після нього працює з контейнером.

Pitfall: since Cypress 12, .within() requires the subject to be exactly one element and fails otherwise — earlier it silently took the first one. Another trap: .within() yields its original subject, not the last element found inside, so a chain after it works with the container.

Якщо спитають далі: як вийти з within, щоб знайти елемент поза контейнером, наприклад модалку в body? Через cy.root() не вийдеш — він повертає корінь поточної області; для цього завершують блок within і шукають уже після нього.

If they dig deeper: how do you step out of within to find something outside the container, like a modal in body? cy.root() will not do it — it yields the root of the current scope; you end the within block and search after it.

Три способи звузити пошук
Three ways to narrow a search
// within: усі пошуки всередині форми доставки
cy.get('[data-cy=shipping-form]').within(() => {
  cy.get('[name=city]').type('Lviv');
  cy.get('[name=zip]').type('79000');
});

// filter: залишити з набору лише прострочені рядки
cy.get('[data-cy=invoice-row]')
  .filter(':contains("Overdue")')
  .should('have.length', 2)
  // find: спуститися до кнопок у цих рядках
  .find('[data-cy=send-reminder]')
  .should('have.length', 2);
// within: every lookup stays inside the delivery form
cy.get('[data-cy=shipping-form]').within(() => {
  cy.get('[name=city]').type('Lviv');
  cy.get('[name=zip]').type('79000');
});

// filter: keep only the overdue rows of the set
cy.get('[data-cy=invoice-row]')
  .filter(':contains("Overdue")')
  .should('have.length', 2)
  // find: go down to the buttons in those rows
  .find('[data-cy=send-reminder]')
  .should('have.length', 2);
🔧 Пояснення синтаксису: within приймає колбек без аргументів і змінює область для cy.get; :contains("Overdue") — jQuery-псевдоселектор за текстом; have.length перевіряє кількість елементів у поточному subject.
🔧 Syntax explained: within takes a callback with no arguments and changes the scope for cy.get; :contains("Overdue") is a jQuery pseudo-selector by text; have.length checks how many elements the current subject holds.
💡 Простими словами: find — іди глибше, filter — відсій із того, що маєш, within — працюй тільки в цій кімнаті.
💡 In plain words: find means go deeper, filter means sift what you already have, within means work only inside this room.
🗣 Як читати вголос Я пояснюю це на таблиці рахунків. Якщо мені треба лише прострочені рядки, я беру всі рядки і роблю filter, бо він відсіює поточний набір і не лізе всередину. Коли вже потрібні кнопки в цих рядках, я роблю find, бо він спускається до нащадків. А within зручний для форм: я кажу «працюю тільки у формі доставки» і далі пишу звичайні get без довгих селекторів. Із дванадцятої версії within падає, якщо в нього потрапило кілька елементів, і я вважаю це правильним, бо раніше такі тести тихо клікали не туди.
🗣 How to say it out loud I explain it with an invoices table. If I only need the overdue rows, I take all rows and use filter, because it sifts the current set and does not go inside. When I then need the buttons in those rows, I use find, because it goes down to the descendants. And within is handy for forms: I say "I am working only in the delivery form" and then write plain get calls without long selectors. Since version twelve within fails if it receives several elements, and I think that is right, because before that such tests quietly clicked the wrong thing.
15 Як пройтися по списку елементів через .each() і не зламати повтори? How do you iterate over a list of elements with .each() without breaking retries?
Локатори й взаємодіяLocators and interactionMiddle▶

.each(($el, index, $list) => { ... }) викликає колбек для кожного елемента поточного subject. Усередині ти отримуєш сирий jQuery-елемент, його індекс і весь список. Щоб знову працювати з елементом командами Cypress, його загортають у cy.wrap($el). Повернення false із колбека зупиняє перебір достроково.

.each(($el, index, $list) => { ... }) calls the callback for every element of the current subject. Inside you get the raw jQuery element, its index and the whole list. To work with the element through Cypress commands again, you wrap it in cy.wrap($el). Returning false from the callback stops the iteration early.

Навіщо: перевірити однакову властивість у кожного елемента — у кожної картки товару є ціна і кнопка «Купити», усі посилання меню ведуть не на 404, усі дати в таблиці у форматі ДД.ММ.РРРР. Також .each використовують, щоб знайти елемент за складною умовою, яку не виразиш селектором.

Why: to check the same property on every element — every product card has a price and a "Buy" button, no menu link leads to a 404, every date in a table is in DD.MM.YYYY format. .each is also used to find an element by a complex condition that a selector cannot express.

Приклад: беремо всі рядки кошика, для кожного читаємо ціну і кількість, накопичуємо суму, а в кінці порівнюємо з полем «Разом». Це перевіряє реальну бізнес-логіку, а не лише наявність елементів.

Example: take all cart rows, read the price and quantity from each, accumulate the sum, and at the end compare it with the "Total" field. That checks real business logic, not just the presence of elements.

Підводний камінь: .each не повторюється. Він бере список у той момент, коли до нього дійшла черга: якщо дані ще вантажаться, пройде по двох рядках замість десяти, і тест «зелено» перевірить не все. Тому перед .each ставлять асерт на очікувану кількість або на кінець завантаження. Друге — якщо всередині колбека дія змінює список (видалення рядка), подальші $el стають від'єднаними; для таких випадків краще щоразу шукати перший елемент заново.

Pitfall: .each is not retried. It takes the list at the moment its turn in the queue comes: if the data is still loading, it walks two rows instead of ten, and the test "greenly" checks only part of it. So before .each you put an assertion on the expected count or on the loading having finished. Second, if an action inside the callback changes the list (deleting a row), the later $el values become detached; for such cases it is safer to look up the first element afresh each time.

Якщо спитають далі: коли .each не потрібен? Коли перевірку можна зробити одним асертом над колекцією: .should(($items) => ...) з expect для масиву текстів повторюється і читається коротше.

If they dig deeper: when is .each unnecessary? When the check can be one assertion over the collection: .should(($items) => ...) with expect over an array of texts is retried and reads shorter.

Сума кошика через .each і альтернатива з повторами
Cart total via .each and a retried alternative
const toNumber = (text: string) => Number(text.replace(/[^0-9.]/g, ''));

cy.get('[data-cy=cart-row]').should('have.length', 3);   // спершу дочекатися всіх рядків

let sum = 0;
cy.get('[data-cy=cart-row]').each(($row) => {
  const price = toNumber($row.find('[data-cy=price]').text());
  const qty = toNumber($row.find('[data-cy=qty]').text());
  sum += price * qty;
});
cy.get('[data-cy=total]').invoke('text').then((total) => {
  expect(toNumber(total)).to.eq(sum);
});

// альтернатива: один асерт, який повторюється
cy.get('[data-cy=price]').should(($prices) => {
  const values = [...$prices].map((el) => toNumber(el.innerText));
  expect(values.every((v) => v > 0)).to.be.true;
});
const toNumber = (text: string) => Number(text.replace(/[^0-9.]/g, ''));

cy.get('[data-cy=cart-row]').should('have.length', 3);   // wait for every row first

let sum = 0;
cy.get('[data-cy=cart-row]').each(($row) => {
  const price = toNumber($row.find('[data-cy=price]').text());
  const qty = toNumber($row.find('[data-cy=qty]').text());
  sum += price * qty;
});
cy.get('[data-cy=total]').invoke('text').then((total) => {
  expect(toNumber(total)).to.eq(sum);
});

// alternative: one assertion that is retried
cy.get('[data-cy=price]').should(($prices) => {
  const values = [...$prices].map((el) => toNumber(el.innerText));
  expect(values.every((v) => v > 0)).to.be.true;
});
🔧 Пояснення синтаксису: $row.find(...).text() — це jQuery-методи, вони виконуються одразу всередині колбека; змінна sum заповнюється в черзі, тому читаємо її лише в наступному .then; [...$prices] перетворює jQuery-колекцію на масив DOM-елементів.
🔧 Syntax explained: $row.find(...).text() are jQuery methods that run immediately inside the callback; the sum variable is filled in the queue, so it is read only in the next .then; [...$prices] turns the jQuery collection into an array of DOM elements.
💡 Простими словами: each проходить по списку один раз і не чекає — тому спершу переконайся, що список уже повний.
💡 In plain words: each walks the list once and does not wait — so first make sure the list is already complete.
🗣 Як читати вголос Each я використовую, коли для кожного елемента треба зробити однакову перевірку або порахувати щось сумарне, наприклад суму кошика. Але я пам'ятаю, що each не повторюється: він бере той список, який є в момент виконання. Тому перед ним завжди ставлю асерт на кількість рядків, інакше тест може перевірити два рядки з десяти і бути зеленим. А якщо перевірка проста, як «усі ціни більші за нуль», я пишу один should із колбеком над усією колекцією, бо він повторюється і читається коротше. Так я уникаю тестів, що перевіряють половину списку.
🗣 How to say it out loud I use each when every element needs the same check or when I need to add something up, like the cart total. But I remember that each is not retried: it takes the list that exists at the moment it runs. So I always put an assertion on the row count in front of it, otherwise the test may check two rows out of ten and still be green. And if the check is simple, like "every price is above zero", I write one should with a callback over the whole collection, because it is retried and reads shorter. That way I avoid tests that check half a list.
16 Для чого потрібні .invoke() і .its() і де з ними можна помилитися? What are .invoke() and .its() for, and where can they go wrong?
Локатори й взаємодіяLocators and interactionMiddle▶

.its(name) читає властивість subject: cy.get('li').its('length'), cy.window().its('store'), cy.wait('@api').its('response.body.items') — шлях через крапку дістає вкладені поля. .invoke(method, ...args) викликає функцію subject і віддає її результат: invoke('text'), invoke('attr', 'href'), invoke('val'), або метод об'єкта застосунку.

.its(name) reads a property of the subject: cy.get('li').its('length'), cy.window().its('store'), cy.wait('@api').its('response.body.items') — a dotted path reaches nested fields. .invoke(method, ...args) calls a function on the subject and yields its result: invoke('text'), invoke('attr', 'href'), invoke('val'), or a method of an app object.

Навіщо: це міст між командами Cypress і звичайними JS-об'єктами. Через них перевіряють атрибути, значення полів, стан застосунку, збережений у window, і тіла перехоплених відповідей — без виходу в .then. Від Cypress 12 обидва методи — запити (queries), тому разом з асертом повторюються, доки значення не підійде.

Why: they bridge Cypress commands and plain JS objects. With them you check attributes, field values, app state stored on window and intercepted response bodies — without dropping into .then. Since Cypress 12 both are queries, so together with an assertion they are retried until the value fits.

Приклад: посилання «Політика конфіденційності» відкривається в новій вкладці — перевіряємо invoke('attr', 'target') і invoke('attr', 'href'), або прибираємо target через invoke('removeAttr', 'target') і клікаємо в тій самій вкладці. Ще приклад — invoke('show') для елемента, який з'являється лише при наведенні миші.

Example: the "Privacy policy" link opens in a new tab — check invoke('attr', 'target') and invoke('attr', 'href'), or strip target with invoke('removeAttr', 'target') and click in the same tab. Another example is invoke('show') for an element that only appears on hover.

Підводний камінь: оскільки .invoke повторюється, метод із побічним ефектом (наприклад, store.dispatch чи click() DOM-елемента) може викликатися кілька разів. Для таких дій краще .then((obj) => obj.method()) — він виконається рівно один раз. Друге — invoke('show') і подібні трюки обходять реальну поведінку користувача, тож не використовуй їх там, де саме наведення миші і є предметом перевірки.

Pitfall: because .invoke is retried, a method with a side effect (say, store.dispatch or a DOM element's click()) may be called several times. For such actions .then((obj) => obj.method()) is better — it runs exactly once. Second, invoke('show') and similar tricks bypass real user behaviour, so do not use them where the hover itself is what you are testing.

Якщо спитають далі: як дістатися до стану Redux чи Pinia? Застосунок у тестовому режимі кладе store у window, а тест читає cy.window().its('store').invoke('getState').

If they dig deeper: how do you reach Redux or Pinia state? The app, in test mode, puts the store on window, and the test reads cy.window().its('store').invoke('getState').

its і invoke у типових задачах
its and invoke in everyday tasks
// атрибути посилання, що відкривається в новій вкладці
cy.get('[data-cy=privacy-link]')
  .should('have.attr', 'target', '_blank')
  .invoke('attr', 'href')
  .should('match', /\/privacy$/);

// значення поля й кількість елементів
cy.get('[data-cy=email]').invoke('val').should('eq', 'qa@example.com');
cy.get('[data-cy=notification]').its('length').should('be.lte', 5);

// стан застосунку, який тестова збірка кладе у window
cy.window().its('store').invoke('getState').its('cart.items').should('have.length', 2);

// побічний ефект — лише один раз, через then
cy.window().its('store').then((store) => store.dispatch({ type: 'cart/clear' }));
// attributes of a link that opens a new tab
cy.get('[data-cy=privacy-link]')
  .should('have.attr', 'target', '_blank')
  .invoke('attr', 'href')
  .should('match', /\/privacy$/);

// a field value and an element count
cy.get('[data-cy=email]').invoke('val').should('eq', 'qa@example.com');
cy.get('[data-cy=notification]').its('length').should('be.lte', 5);

// app state that the test build puts on window
cy.window().its('store').invoke('getState').its('cart.items').should('have.length', 2);

// a side effect — exactly once, through then
cy.window().its('store').then((store) => store.dispatch({ type: 'cart/clear' }));
🔧 Пояснення синтаксису: have.attr з трьома аргументами перевіряє назву й значення атрибута і віддає значення далі; invoke('attr', 'href') викликає jQuery attr('href'); its('cart.items') іде вкладеним шляхом; у регулярному виразі \/ екранує слеш.
🔧 Syntax explained: have.attr with three arguments checks the attribute name and value and yields the value onward; invoke('attr', 'href') calls jQuery attr('href'); its('cart.items') follows a nested path; in the regular expression \/ escapes the slash.
💡 Простими словами: its — «прочитай поле», invoke — «виклич метод»; обидва повторюються, тому побічні дії роби через then.
💡 In plain words: its means "read a field", invoke means "call a method"; both are retried, so side effects go through then.
🗣 Як читати вголос Its і invoke для мене — спосіб дістати дані з об'єкта, не виходячи з ланцюжка. Its читає поле, навіть вкладене, наприклад тіло відповіді з перехопленого запиту, а invoke викликає метод, як attr чи val. Я знаю, що з дванадцятої версії це запити, тобто вони повторюються разом з асертом, і тому не викликаю через invoke нічого, що змінює стан, наприклад dispatch у store, бо воно може спрацювати кілька разів. Для таких дій я пишу then, і там метод виконується рівно один раз. Це маленька деталь, але на ній люди часто спотикаються.
🗣 How to say it out loud For me its and invoke are a way to get data out of an object without leaving the chain. Its reads a field, even a nested one, like the body of an intercepted response, and invoke calls a method such as attr or val. I know that since version twelve they are queries, meaning they are retried together with the assertion, so I never call anything that changes state through invoke, like a dispatch to the store, because it could fire several times. For such actions I write then, and there the method runs exactly once.
17 Що таке actionability-перевірки перед click() і type()? What are the actionability checks before click() and type()?
Локатори й взаємодіяLocators and interactionMiddle▶

Перш ніж виконати дію, Cypress перевіряє, чи міг би її виконати справжній користувач. Він прокручує елемент у видиму область і чекає, доки той буде видимим, не вимкненим (disabled), приєднаним до DOM, не лише для читання (для type), не анімується і не перекритий іншим елементом. Перекриття перевіряється по точці, куди буде клік: хто там насправді зверху.

Before performing an action, Cypress checks whether a real user could perform it. It scrolls the element into view and waits until it is visible, not disabled, attached to the DOM, not read-only (for type), not animating and not covered by another element. Coverage is checked at the point where the click will land: what is actually on top there.

Навіщо: це захищає від «зелених» тестів, які клікають по кнопці під модалкою або вводять текст у заблоковане поле. Якщо перевірка не проходить до тайм-ауту, тест падає з конкретним повідомленням: element is being covered by another element, element is disabled, element has CSS pointer-events: none.

Why: it protects against "green" tests that click a button under a modal or type into a locked field. If the checks do not pass before the timeout, the test fails with a specific message: element is being covered by another element, element is disabled, element has CSS pointer-events: none.

Приклад: після відправки форми на кнопку «Далі» наповзає банер cookie. Cypress не клікне, а чесно скаже, що кнопку перекрив банер. Це реальний баг або проблема тестового оточення, яку треба вирішити — закрити банер через підготовку стану (cookie згоди), а не обходити.

Example: after a form is submitted, a cookie banner slides over the "Next" button. Cypress will not click; it honestly reports that the banner covers the button. That is a real bug or a test-environment problem to solve — dismiss the banner by preparing state (a consent cookie), not by working around it.

Підводний камінь: перевірки стосуються дій, а не асертів. should('be.visible') вважає елемент видимим, навіть якщо його перекриває інший; перекриття помітить лише наступна дія. Також липкий хедер часто перекриває елемент після прокрутки — для цього є опція scrollBehavior ('center' замість типового 'top') у конфігурації або в самій дії.

Pitfall: the checks apply to actions, not assertions. should('be.visible') treats an element as visible even if another one covers it; only the next action will notice the overlap. Also a sticky header often covers the element after scrolling — that is what the scrollBehavior option is for ('center' instead of the default 'top'), in the config or on the action itself.

Якщо спитають далі: як діяти з елементом у overflow: hidden контейнері або з кастомним селектом, де справжній select прихований? Для кастомного — клікати по видимій обгортці, як користувач; для прихованого нативного input — selectFile чи check з усвідомленим force: true.

If they dig deeper: how do you act on an element inside an overflow: hidden container, or on a custom select where the real select is hidden? For the custom one — click the visible wrapper, like a user would; for a hidden native input — selectFile or check with a deliberate force: true.

Прибрати причину перекриття замість обходу
Remove the cause of the overlap instead of bypassing it
beforeEach(() => {
  // згода на cookie заздалегідь — банер не з'явиться й не перекриє кнопки
  cy.setCookie('cookie_consent', 'accepted');
});

it('moves to the payment step', () => {
  cy.visit('/checkout');
  cy.get('[data-cy=next-step]', { timeout: 8000 })
    .click({ scrollBehavior: 'center' });   // липкий хедер не заважає
  cy.location('pathname').should('eq', '/checkout/payment');
});
beforeEach(() => {
  // consent cookie up front — the banner never appears to cover buttons
  cy.setCookie('cookie_consent', 'accepted');
});

it('moves to the payment step', () => {
  cy.visit('/checkout');
  cy.get('[data-cy=next-step]', { timeout: 8000 })
    .click({ scrollBehavior: 'center' });   // the sticky header stays out of the way
  cy.location('pathname').should('eq', '/checkout/payment');
});
🔧 Пояснення синтаксису: cy.setCookie(name, value) ставить cookie до відвідування сторінки; опція scrollBehavior: 'center' прокручує елемент у центр вікна перед кліком; cy.location('pathname') віддає шлях поточної сторінки для перевірки переходу.
🔧 Syntax explained: cy.setCookie(name, value) sets a cookie before the page is visited; the scrollBehavior: 'center' option scrolls the element to the middle of the window before the click; cy.location('pathname') yields the current page path to check the navigation.
💡 Простими словами: Cypress клікає тільки туди, куди міг би клікнути живий користувач; якщо не може — це підказка, а не перешкода.
💡 In plain words: Cypress only clicks where a real person could click; when it cannot, that is a hint, not an obstacle.
🗣 Як читати вголос Я описую actionability як перевірку «а чи зміг би це зробити користувач». Перед кліком Cypress прокручує елемент, перевіряє, що він видимий, не вимкнений, не рухається і не перекритий, і лише тоді клікає. Коли тест падає з повідомленням, що кнопку перекрив інший елемент, я не додаю force, а шукаю, хто перекрив. Найчастіше це банер cookie або липкий хедер, і тоді я або ставлю cookie згоди заздалегідь, або прокручую елемент у центр. А іноді це справжній баг верстки, і його треба завести в трекер, а не ховати.
🗣 How to say it out loud I describe actionability as a check of "could a user actually do this". Before a click Cypress scrolls to the element, checks that it is visible, not disabled, not moving and not covered, and only then clicks. When a test fails saying the button is covered by another element, I do not add force, I look for what is covering it. Most often it is a cookie banner or a sticky header, and then I either set the consent cookie up front or scroll the element to the centre. And sometimes it is a real layout bug that needs reporting.
18 Чому {force: true} небезпечний і коли він усе ж виправданий? Why is {force: true} dangerous, and when is it still justified?
Локатори й взаємодіяLocators and interactionSenior▶

Опція force: true у click, type, check чи select вимикає перевірки actionability: Cypress не чекає видимості, не перевіряє перекриття й анімацію, а одразу надсилає події елементу. Тест перестає питати «чи міг би це зробити користувач» — і тому може пройти там, де для людини функція зламана.

The force: true option on click, type, check or select turns off the actionability checks: Cypress does not wait for visibility, does not check for overlap or animation, and fires the events at the element straight away. The test stops asking "could a user do this" — and so it can pass where the feature is broken for a human.

Чим це небезпечно: кнопка під модалкою, прихований пункт меню, поле, яке ще не з'явилося, — з force тест клікне по них і стане зеленим. У коді сюїти force: true часто стає «ліками» від флейків: хтось додав його, щоб прибрати падіння, і разом із падінням прибрав перевірку реальної проблеми. Через пів року половина тестів має force, і сюїта більше не ловить баги інтерфейсу.

Why it is dangerous: a button under a modal, a hidden menu item, a field that has not appeared yet — with force the test clicks them and turns green. In a suite, force: true often becomes a "cure" for flakes: someone added it to make a failure go away, and removed the check of a real problem along with the failure. Six months later half the tests have force, and the suite no longer catches UI bugs.

Коли виправдано: нативний input type=file або чекбокс, прихований за кастомною стилізованою обгорткою — користувач клікає по обгортці, але подія все одно потрапляє в цей input; стороння бібліотека, яка тримає елемент під прозорим шаром за дизайном; технічні дії налаштування, що не є предметом тесту. Кожен такий випадок має коментар «чому», і його бачить рев'юер.

When it is justified: a native input type=file or checkbox hidden behind a styled custom wrapper — the user clicks the wrapper, but the event still lands in that input; a third-party library that keeps an element under a transparent layer by design; technical setup steps that are not what the test is about. Every such case carries a comment saying why, and the reviewer sees it.

Підводний камінь: force не робить елемент доступним — якщо обробник у застосунку перевіряє, чи елемент видимий або активний, дія все одно нічого не змінить, і тест упаде пізніше з незрозумілою причиною. Друге: force не прибирає перевірку в асертах.

Pitfall: force does not make an element usable — if the app's handler checks whether the element is visible or enabled, the action changes nothing, and the test fails later for an obscure reason. And force does not remove checks from assertions.

Якщо спитають далі: як тримати це під контролем у великій команді? Правило в код-рев'ю або лінтер: force: true без коментаря не проходить; раз на спринт — звіт, скільки таких місць і чому.

If they dig deeper: how do you keep it under control in a big team? A code-review rule or a lint rule: force: true without a comment does not pass; once a sprint, a report on how many such places there are and why.

Виправданий і невиправданий force
Justified and unjustified force
// погано: ховає справжню проблему — кнопку перекриває модалка
cy.get('[data-cy=delete-account]').click({ force: true });

// добре: спершу прибрати причину, потім звичайний клік
cy.get('[data-cy=promo-modal] [data-cy=close]').click();
cy.get('[data-cy=promo-modal]').should('not.exist');
cy.get('[data-cy=delete-account]').click();

// виправдано: нативний чекбокс схований за стилізованою обгорткою
// користувач клікає по обгортці; перевіряємо підсумковий стан
cy.get('[data-cy=terms-checkbox]').check({ force: true });
cy.get('[data-cy=terms-checkbox]').should('be.checked');
cy.get('[data-cy=submit]').should('be.enabled');
// bad: hides a real problem — a modal covers the button
cy.get('[data-cy=delete-account]').click({ force: true });

// good: remove the cause first, then an ordinary click
cy.get('[data-cy=promo-modal] [data-cy=close]').click();
cy.get('[data-cy=promo-modal]').should('not.exist');
cy.get('[data-cy=delete-account]').click();

// justified: a native checkbox hidden behind a styled wrapper
// the user clicks the wrapper; we assert the resulting state
cy.get('[data-cy=terms-checkbox]').check({ force: true });
cy.get('[data-cy=terms-checkbox]').should('be.checked');
cy.get('[data-cy=submit]').should('be.enabled');
🔧 Пояснення синтаксису: click({ force: true }) надсилає події без перевірок actionability; should('not.exist') чекає, поки модалка зникне з DOM; check() працює з чекбоксами і радіокнопками, а be.checked і be.enabled перевіряють підсумковий стан.
🔧 Syntax explained: click({ force: true }) fires the events without actionability checks; should('not.exist') waits until the modal is gone from the DOM; check() works with checkboxes and radio buttons, and be.checked and be.enabled verify the resulting state.
💡 Простими словами: force — це «клікни, навіть якщо людина не змогла б»; тому кожне використання має пояснювати, чому людина тут усе ж змогла б.
💡 In plain words: force means "click even if a human could not"; so every use has to explain why a human actually could here.
🗣 Як читати вголос Для мене force — це сигнал подивитися уважніше. Він вимикає всі перевірки, і тест може клікнути кнопку під модалкою та пройти, хоча користувач би не зміг. Я бачив сюїти, де force додавали від флейків, і за пів року вони перестали ловити баги інтерфейсу. Тому в рев'ю я прошу коментар до кожного force. Виправданим вважаю, наприклад, нативний чекбокс під стилізованою обгорткою, і тоді я обов'язково перевіряю наслідок: чекбокс відмічено, кнопка стала активною. А якщо елемент перекриває банер, я прибираю банер.
🗣 How to say it out loud To me force is a signal to look more closely. It turns every check off, so a test can click a button under a modal and pass even though a user could not. I have seen suites where force was added against flakes, and within six months they stopped catching UI bugs. So in review I ask for a comment on every force. I consider it justified for, say, a native checkbox under a styled wrapper, and then I always check the outcome: the box is ticked, the button is enabled. And if a banner covers the element, I remove the banner.
19 Як працювати з iframe і посиланнями, що відкривають нову вкладку? How do you work with iframes and links that open a new tab?
Локатори й взаємодіяLocators and interactionMiddle▶

Обидва випадки впираються в архітектуру: тест живе в одній вкладці браузера, поруч із застосунком. Окремої команди для iframe в Cypress немає — щоб отримати вміст айфрейма, треба дістати його contentDocument.body і загорнути в cy.wrap. Кількох вкладок Cypress не підтримує взагалі і, за словами самої команди Cypress, не планує.

Both cases run into the architecture: the test lives in one browser tab, next to the app. Cypress has no dedicated iframe command — to reach an iframe's content you take its contentDocument.body and wrap it in cy.wrap. Multiple tabs are not supported at all and, according to the Cypress team itself, are not planned.

Iframe того самого походження: cy.get('iframe').its('0.contentDocument.body').should('not.be.empty').then(cy.wrap) — після цього працюють звичайні find, type, click. Асерт not.be.empty потрібен, бо айфрейм вантажиться окремо, і без нього тест схопить порожнє тіло. Цю конструкцію зазвичай виносять у кастомну команду getIframeBody.

Same-origin iframe: cy.get('iframe').its('0.contentDocument.body').should('not.be.empty').then(cy.wrap) — after that the usual find, type and click work. The not.be.empty assertion is needed because the iframe loads separately, and without it the test grabs an empty body. This construction is usually moved into a getIframeBody custom command.

Iframe іншого походження (Stripe, reCAPTCHA, YouTube) недоступний через політику безпеки браузера, і cy.origin тут не допомагає — він працює для навігації верхнього рівня. Варіанти: chromeWebSecurity: false (лише Chromium-браузери, і це вже не зовсім реальна поведінка), тестовий режим провайдера, стаб його API або перевірка цього шматка окремим тестом на рівні інтеграції.

Cross-origin iframe (Stripe, reCAPTCHA, YouTube) is off limits because of the browser's security policy, and cy.origin does not help here — it works for top-level navigation. Options: chromeWebSecurity: false (Chromium browsers only, and no longer quite real behaviour), the provider's test mode, a stub of its API, or checking that piece in a separate integration-level test.

Нова вкладка: перевіряють, що посилання правильне (href, target=_blank, rel=noopener), а сам перехід — у тій самій вкладці: прибрати target через invoke('removeAttr', 'target') і клікнути, або зробити cy.visit за href. Якщо вкладку відкриває JS через window.open, його стаблять: cy.stub(win, 'open').as('open') і перевіряють аргументи.

New tab: check that the link is right (href, target=_blank, rel=noopener) and do the navigation itself in the same tab: strip target with invoke('removeAttr', 'target') and click, or cy.visit the href. If JS opens the tab via window.open, stub it: cy.stub(win, 'open').as('open') and check the arguments.

Підводний камінь: для айфрейма не працюють автоматичні снапшоти і частина повторів, тож перевірки всередині варто робити з явним очікуванням стану. Якщо спитають далі: є плагін cypress-iframe, але логіку краще розуміти самому.

Pitfall: automatic snapshots and part of the retrying do not work inside an iframe, so checks there should wait for state explicitly. If they dig deeper: there is a cypress-iframe plugin, but it is better to understand the logic yourself.

Айфрейм того самого походження і window.open
A same-origin iframe and window.open
const getIframeBody = (selector: string) =>
  cy.get(selector)
    .its('0.contentDocument.body')
    .should('not.be.empty')        // чекаємо, поки айфрейм завантажиться
    .then(cy.wrap);

it('fills the embedded feedback form', () => {
  cy.visit('/help');
  getIframeBody('[data-cy=feedback-frame]').within(() => {
    cy.get('[name=message]').type('Checkout button does not respond');
    cy.contains('button', 'Send').click();
  });
});

it('opens the invoice in a new window', () => {
  cy.visit('/orders/42', {
    onBeforeLoad(win) {
      cy.stub(win, 'open').as('windowOpen');
    },
  });
  cy.get('[data-cy=print-invoice]').click();
  cy.get('@windowOpen').should('have.been.calledWithMatch', '/invoices/42.pdf');
});
const getIframeBody = (selector: string) =>
  cy.get(selector)
    .its('0.contentDocument.body')
    .should('not.be.empty')        // wait until the iframe has loaded
    .then(cy.wrap);

it('fills the embedded feedback form', () => {
  cy.visit('/help');
  getIframeBody('[data-cy=feedback-frame]').within(() => {
    cy.get('[name=message]').type('Checkout button does not respond');
    cy.contains('button', 'Send').click();
  });
});

it('opens the invoice in a new window', () => {
  cy.visit('/orders/42', {
    onBeforeLoad(win) {
      cy.stub(win, 'open').as('windowOpen');
    },
  });
  cy.get('[data-cy=print-invoice]').click();
  cy.get('@windowOpen').should('have.been.calledWithMatch', '/invoices/42.pdf');
});
🔧 Пояснення синтаксису: its('0.contentDocument.body') бере перший DOM-елемент із jQuery-колекції і його документ; .then(cy.wrap) передає тіло в cy.wrap як subject; cy.stub(win, 'open') підміняє метод на шпигуна Sinon; have.been.calledWithMatch — асерт Sinon-Chai на аргументи виклику.
🔧 Syntax explained: its('0.contentDocument.body') takes the first DOM element of the jQuery collection and its document; .then(cy.wrap) hands the body to cy.wrap as the subject; cy.stub(win, 'open') replaces the method with a Sinon stub; have.been.calledWithMatch is a Sinon-Chai assertion on the call arguments.
💡 Простими словами: Cypress живе в одній вкладці: айфрейм відкривають вручну, як коробку в коробці, а нову вкладку перетворюють на перехід у тій самій.
💡 In plain words: Cypress lives in one tab: an iframe is opened by hand, like a box inside a box, and a new tab is turned into navigation within the same one.
🗣 Як читати вголос Я одразу пояснюю, що обидві проблеми випливають з того, що Cypress працює в одній вкладці. Для айфрейма того самого домену я дістаю contentDocument, чекаю, що тіло не порожнє, і загортаю його в cy.wrap, зазвичай через кастомну команду. Айфрейм чужого домену, наприклад платіжний, я не намагаюся протягнути через тест, а використовую тестовий режим провайдера або стаб. Для нової вкладки перевіряю атрибути посилання і відкриваю сторінку в тій самій вкладці, а window.open підміняю стабом і дивлюся, з якою адресою його викликали.
🗣 How to say it out loud I explain straight away that both problems come from Cypress working in a single tab. For a same-domain iframe I take contentDocument, wait for the body to be non-empty and wrap it in cy.wrap, usually through a custom command. A cross-domain iframe, like a payment one, I do not try to drag through the test; I use the provider's test mode or a stub. For a new tab I check the link attributes and open the page in the same tab, and I replace window.open with a stub and check which address it was called with.
20 Як протестувати завантаження файлу на сервер через cy.selectFile? How do you test uploading a file to the server with cy.selectFile?
Локатори й взаємодіяLocators and interactionJunior▶

.selectFile() — вбудована команда Cypress (з версії 9.3) для вибору файлу в input type=file або перетягування файлу на область drop-зони. До її появи користувалися плагіном cypress-file-upload, тепер він не потрібен. Команда приймає шлях до файлу відносно кореня проєкту, масив шляхів, аліас фікстури або об'єкт із вмістом, створеним прямо в тесті.

.selectFile() is a built-in Cypress command (since 9.3) for choosing a file in an input type=file or dragging a file onto a drop zone. Before it existed people used the cypress-file-upload plugin; now it is not needed. The command takes a file path relative to the project root, an array of paths, a fixture alias, or an object with contents created right in the test.

Навіщо знати варіанти: форма резюме приймає лише PDF до 5 МБ — треба перевірити і правильний файл, і відмову для .exe чи завеликого файлу. Для негативних кейсів зручно створювати файл на льоту: об'єкт із полями contents, fileName і mimeType, де вміст дає Cypress.Buffer.from('...'), щоб не тримати в репозиторії десяток тестових файлів.

Why know the options: a CV form accepts only PDFs up to 5 MB — you need to check both a valid file and the rejection of an .exe or an oversized one. For negative cases it is convenient to build the file on the fly: an object with contents, fileName and mimeType fields, where Cypress.Buffer.from('...') provides the contents, so the repository does not have to hold a dozen test files.

Приклад: обираємо файл, перевіряємо, що в інтерфейсі з'явилася назва, чекаємо аліас запиту завантаження і дивимося, що сервер відповів 201. Для drop-зони використовують опцію action: 'drag-drop' — Cypress згенерує події перетягування.

Example: choose the file, check that its name appears in the UI, wait for the upload request alias and confirm that the server replied 201. For a drop zone use the action: 'drag-drop' option — Cypress generates the drag events.

Підводний камінь: стилізовані форми часто ховають нативний input за гарною кнопкою, і selectFile падає, бо елемент невидимий. Тут force: true виправданий, бо користувач теж обирає файл через цей input. Друге — перевіряти лише появу назви файлу замало: треба переконатися, що запит справді пішов і файл збережено.

Pitfall: styled forms often hide the native input behind a nice button, and selectFile fails because the element is invisible. Here force: true is justified, because the user also picks the file through that input. Second, checking only that the file name appeared is not enough: make sure the request actually went out and the file was stored.

Якщо спитають далі: як перевірити обмеження розміру без 5-мегабайтного файлу в git? Згенерувати буфер потрібного розміру в тесті або перевірити валідацію на рівні API через cy.request.

If they dig deeper: how do you test the size limit without a 5 MB file in git? Generate a buffer of the right size in the test, or check the validation at API level with cy.request.

Позитивний і негативний сценарій завантаження
A positive and a negative upload scenario
it('uploads a CV in PDF', () => {
  cy.intercept('POST', '/api/cv').as('upload');
  cy.visit('/careers/apply');

  cy.get('[data-cy=cv-input]').selectFile('cypress/fixtures/cv.pdf', { force: true });
  cy.get('[data-cy=file-name]').should('have.text', 'cv.pdf');
  cy.get('[data-cy=submit]').click();
  cy.wait('@upload').its('response.statusCode').should('eq', 201);
});

it('rejects an executable file dropped on the zone', () => {
  cy.visit('/careers/apply');
  cy.get('[data-cy=drop-zone]').selectFile(
    { contents: Cypress.Buffer.from('MZ'), fileName: 'setup.exe', mimeType: 'application/octet-stream' },
    { action: 'drag-drop' },
  );
  cy.get('[data-cy=upload-error]').should('contain', 'PDF');
});
🔧 Пояснення синтаксису: Перший аргумент selectFile — шлях від кореня проєкту або об'єкт contents/fileName/mimeType; Cypress.Buffer.from створює вміст файлу з рядка; action: 'drag-drop' імітує перетягування замість вибору через діалог.
🔧 Syntax explained: The first argument of selectFile is a path from the project root or a contents/fileName/mimeType object; Cypress.Buffer.from builds file contents from a string; action: 'drag-drop' simulates dragging instead of choosing through a dialog.
💡 Простими словами: selectFile кладе файл у поле так, ніби користувач вибрав його в діалозі або перетягнув мишею.
💡 In plain words: selectFile drops a file into the field as if the user had picked it in the dialog or dragged it with the mouse.
🗣 Як читати вголос Для завантаження файлів я використовую вбудований selectFile, плагін уже не потрібен. Перевіряю не лише, що назва файлу з'явилася на сторінці, а й що пішов запит і сервер відповів успіхом. Для негативних кейсів, наприклад exe-файлу чи неправильного формату, я не тримаю файли в репозиторії, а створюю вміст прямо в тесті через Cypress.Buffer. Якщо нативний input схований за стилізованою кнопкою, я додаю force і пишу коментар, чому це тут доречно. Для drop-зони передаю опцію drag-drop, щоб перевірити саме перетягування, як це робить людина.
🗣 How to say it out loud For file uploads I use the built-in selectFile; the plugin is no longer needed. I check not only that the file name appeared on the page but also that the request went out and the server replied with success. For negative cases, like an exe file or a wrong format, I do not keep files in the repository, I create the contents right in the test with Cypress.Buffer. If the native input is hidden behind a styled button, I add force and write a comment on why it is appropriate there. For a drop zone I pass the drag-drop option.
21 Як перевірити, що файл справді завантажився на комп'ютер користувача? How do you check that a file was really downloaded to the user's machine?
Локатори й взаємодіяLocators and interactionMiddle▶

Коли застосунок віддає файл (CSV-звіт, PDF-рахунок), браузер під керуванням Cypress зберігає його в теку downloadsFolder, типово cypress/downloads. Тест не бачить діалогу збереження, але може прочитати файл із диска через cy.readFile — ця команда виконується в Node і повторює спробу, доки файл не з'явиться або не мине тайм-аут.

When the app serves a file (a CSV report, a PDF invoice), the browser controlled by Cypress saves it into downloadsFolder, cypress/downloads by default. The test does not see a save dialog, but it can read the file from disk with cy.readFile — this command runs in Node and keeps retrying until the file appears or the timeout runs out.

Навіщо перевіряти саме файл: кнопка «Експорт» може спрацювати, а файл бути порожнім, з неправильним кодуванням, без заголовків колонок або з даними іншого користувача. Перевірка вмісту ловить ці баги; перевірка лише кліку — ні.

Why check the file itself: the "Export" button may work while the file is empty, in the wrong encoding, missing the column headers or holding another user's data. Checking the content catches those bugs; checking only the click does not.

Приклад: після кліку «Експорт у CSV» читаємо cypress/downloads/orders.csv, перевіряємо заголовок і що рядків стільки, скільки замовлень у тестових даних. Для PDF чи XLSX вміст розбирають у Node: реєструють cy.task('parsePdf', path) з бібліотекою для парсингу і повертають текст у тест.

Example: after clicking "Export to CSV", read cypress/downloads/orders.csv, check the header and that there are as many rows as orders in the test data. For PDF or XLSX the content is parsed in Node: register cy.task('parsePdf', path) with a parsing library and return the text to the test.

Підводний камінь: якщо ім'я файлу містить дату чи ID, треба або знати його, або отримати список файлів теки через cy.task. Між тестами теку краще очищати самому — trashAssetsBeforeRuns чистить її лише на старті прогону. Ще одна пастка — великі файли: cy.readFile читає все в пам'ять, тож для гігабайтних експортів краще перевіряти розмір і кілька рядків через задачу в Node.

Pitfall: if the file name includes a date or an ID, you either need to know it or list the folder via cy.task. Clean the folder between tests yourself — trashAssetsBeforeRuns only clears it at the start of a run. Another trap is large files: cy.readFile reads everything into memory, so for gigabyte exports it is better to check the size and a few rows through a Node task.

Якщо спитають далі: а можна без реального завантаження? Так: взяти href посилання і зробити cy.request, перевіривши статус, content-type і content-disposition. Це швидше, але не перевіряє, що браузер справді зберігає файл.

If they dig deeper: can you skip the real download? Yes: take the link's href and do cy.request, checking the status, content-type and content-disposition. That is faster, but it does not prove the browser really saves the file.

Перевірка завантаженого CSV
Checking a downloaded CSV
it('exports orders to CSV', () => {
  const file = Cypress.config('downloadsFolder') + '/orders.csv';

  cy.visit('/orders');
  cy.get('[data-cy=export-csv]').click();

  // readFile повторює спробу, поки файл не з'явиться
  cy.readFile(file, { timeout: 15000 }).then((csv: string) => {
    const lines = csv.trim().split('\n');
    expect(lines[0]).to.eq('id,customer,total,status');
    expect(lines).to.have.length(1 + 3);   // заголовок і три тестові замовлення
    expect(csv).not.to.contain('undefined');
  });
});
it('exports orders to CSV', () => {
  const file = Cypress.config('downloadsFolder') + '/orders.csv';

  cy.visit('/orders');
  cy.get('[data-cy=export-csv]').click();

  // readFile keeps retrying until the file appears
  cy.readFile(file, { timeout: 15000 }).then((csv: string) => {
    const lines = csv.trim().split('\n');
    expect(lines[0]).to.eq('id,customer,total,status');
    expect(lines).to.have.length(1 + 3);   // the header plus three seeded orders
    expect(csv).not.to.contain('undefined');
  });
});
🔧 Пояснення синтаксису: Cypress.config('downloadsFolder') читає шлях до теки з конфігу; cy.readFile із тайм-аутом чекає появи файлу; csv.trim().split('\n') ділить текст на рядки; перший рядок — заголовок CSV.
🔧 Syntax explained: Cypress.config('downloadsFolder') reads the folder path from the config; cy.readFile with a timeout waits for the file to appear; csv.trim().split('\n') splits the text into lines; the first line is the CSV header.
💡 Простими словами: Клік по «Експорт» нічого не доводить — доводить вміст файлу, який лежить у теці завантажень.
💡 In plain words: A click on "Export" proves nothing — the proof is the content of the file sitting in the downloads folder.
🗣 Як читати вголос Я перевіряю не клік по кнопці експорту, а сам файл. Cypress зберігає завантаження в теку cypress/downloads, і я читаю файл через readFile, який сам чекає, поки файл з'явиться. Далі дивлюся заголовок, кількість рядків відповідно до тестових даних і що немає undefined чи чужих записів, бо саме такі баги в експорті трапляються найчастіше. Для PDF або Excel я розбираю вміст у Node через cy.task. А якщо важливіший сам ендпоінт, я просто роблю cy.request за посиланням і перевіряю тип вмісту і заголовок content-disposition.
🗣 How to say it out loud I do not check the click on the export button, I check the file itself. Cypress saves downloads into the cypress/downloads folder, and I read the file with readFile, which waits on its own until the file appears. Then I look at the header, the number of rows matching the test data, and that there is no undefined or somebody else's record. For PDF or Excel I parse the content in Node through cy.task. And if the endpoint itself matters more, I simply cy.request the link and check the content type and the content-disposition header.
22 Як перевірити адаптивну верстку за допомогою cy.viewport? How do you check a responsive layout with cy.viewport?
Локатори й взаємодіяLocators and interactionJunior▶

cy.viewport(width, height) змінює розмір області, в якій рендериться застосунок. Можна передати числа (cy.viewport(390, 844)) або назву пресету ('iphone-x', 'ipad-2', 'macbook-15') і орієнтацію 'landscape'. Типовий розмір задають у конфігу через viewportWidth і viewportHeight — без налаштувань це 1000 на 660 пікселів.

cy.viewport(width, height) changes the size of the area the app renders in. You can pass numbers (cy.viewport(390, 844)) or a preset name ('iphone-x', 'ipad-2', 'macbook-15') plus a 'landscape' orientation. The default size is set in the config through viewportWidth and viewportHeight — without settings it is 1000 by 660 pixels.

Навіщо: на мобільній ширині інтерфейс часто інший — меню ховається під «бургер», таблиця перетворюється на картки, кнопки переїжджають. Тести мають перевірити, що ключові сценарії доступні на кожній цільовій ширині, а не лише на десктопі.

Why: at mobile width the interface is often different — the menu hides behind a "burger", a table becomes cards, buttons move around. Tests should check that the key flows work at every target width, not only on desktop.

Приклад: один і той самий тест навігації проганяють для кількох розмірів у циклі: на 1280 меню видно одразу, на 390 спершу треба відкрити бургер. Розмір задають у beforeEach, бо перед кожним тестом Cypress повертає розмір до типового з конфігу.

Example: the same navigation test runs in a loop for several sizes: at 1280 the menu is visible straight away, at 390 you first open the burger. The size is set in beforeEach, because before each test Cypress resets the size to the config default.

Підводний камінь: cy.viewport змінює лише розміри вікна. Він не підміняє user agent, не вмикає сенсорні події і не емулює щільність пікселів, тож логіка, яка визначає мобільний пристрій за user agent або ontouchstart, не спрацює. Для цього задають userAgent у конфігурації окремого прогону або тестують на реальних пристроях.

Pitfall: cy.viewport changes only the window dimensions. It does not swap the user agent, enable touch events or emulate pixel density, so logic that detects a mobile device by user agent or ontouchstart will not kick in. For that you set userAgent in the config of a separate run, or test on real devices.

Якщо спитають далі: як прогнати всю сюїту в мобільному розмірі? Передати в CI --config viewportWidth=390,viewportHeight=844 або завести окремий конфіг для мобільного прогону.

If they dig deeper: how do you run the whole suite at mobile size? Pass --config viewportWidth=390,viewportHeight=844 in CI, or keep a separate config for the mobile run.

Один сценарій на кількох ширинах
One scenario at several widths
const sizes: Array<[number, number]> = [[1280, 800], [768, 1024], [390, 844]];

sizes.forEach(([width, height]) => {
  describe('main navigation at ' + width + 'px', () => {
    beforeEach(() => {
      cy.viewport(width, height);
      cy.visit('/');
    });

    it('reaches the pricing page', () => {
      if (width < 768) {
        cy.get('[data-cy=burger]').click();   // на телефоні меню сховане
      }
      cy.contains('[data-cy=nav] a', 'Pricing').click();
      cy.location('pathname').should('eq', '/pricing');
    });
  });
});
const sizes: Array<[number, number]> = [[1280, 800], [768, 1024], [390, 844]];

sizes.forEach(([width, height]) => {
  describe('main navigation at ' + width + 'px', () => {
    beforeEach(() => {
      cy.viewport(width, height);
      cy.visit('/');
    });

    it('reaches the pricing page', () => {
      if (width < 768) {
        cy.get('[data-cy=burger]').click();   // on a phone the menu is hidden
      }
      cy.contains('[data-cy=nav] a', 'Pricing').click();
      cy.location('pathname').should('eq', '/pricing');
    });
  });
});
🔧 Пояснення синтаксису: Масив кортежів Array<[number, number]> описує розміри; forEach генерує окремий describe для кожної ширини ще під час завантаження спека; умова if (width < 768) залежить від відомого наперед параметра, а не від стану сторінки, тому це не умовне тестування.
🔧 Syntax explained: The tuple array Array<[number, number]> lists the sizes; forEach generates a separate describe per width while the spec is being loaded; the if (width < 768) condition depends on a parameter known in advance, not on page state, so it is not conditional testing.
💡 Простими словами: cy.viewport змінює лише ширину й висоту вікна; телефоном браузер від цього не стає.
💡 In plain words: cy.viewport changes only the window's width and height; it does not turn the browser into a phone.
🗣 Як читати вголос Для адаптивності я проганяю ключові сценарії на кількох ширинах, які реально використовують наші користувачі, зазвичай десктоп, планшет і телефон. Розмір задаю в beforeEach, бо Cypress перед кожним тестом повертає його до типового з конфігу. Умова «якщо ширина менша за планшетну, відкрий бургер» тут нормальна, бо вона залежить від параметра тесту, а не від того, що випадково відмалювалося. І я чесно кажу, що viewport не емулює телефон повністю: user agent і сенсорні події лишаються десктопними, тому частину речей треба перевіряти на реальних пристроях.
🗣 How to say it out loud For responsiveness I run the key flows at a few widths our users actually have, usually desktop, tablet and phone. I set the size in beforeEach, because before each test Cypress resets it to the config default. A condition like "if the width is below tablet, open the burger" is fine here, because it depends on a test parameter, not on whatever happened to render. And I say honestly that viewport does not fully emulate a phone: the user agent and touch events stay desktop ones, so some things have to be checked on real devices.
▶ 4 7
23 Що робить cy.intercept і чим spy відрізняється від stub? What does cy.intercept do, and how does a spy differ from a stub?
Мережа й мокиNetwork and mockingMiddle▶

cy.intercept реєструє правило для HTTP-запитів, які робить застосунок у браузері. Оскільки весь трафік іде через проксі Cypress, правило може просто стежити за запитом, змінювати його по дорозі або повністю підмінити відповідь. cy.intercept з'явився в Cypress 6 і замінив старі cy.server і cy.route, які прибрали у версії 12.

cy.intercept registers a rule for the HTTP requests the app makes in the browser. Since all traffic goes through the Cypress proxy, the rule can just watch a request, change it on the way, or replace the response entirely. cy.intercept arrived in Cypress 6 and replaced the old cy.server and cy.route, which were removed in version 12.

Spy і stub: якщо правило не задає відповіді (cy.intercept('POST', '/api/orders').as('create')), запит іде на справжній сервер, а Cypress лише записує запит і відповідь — це spy. Якщо третім аргументом передати відповідь або викликати req.reply(), до сервера запит не дійде — це stub. Проміжний варіант — req.continue((res) => { ... }): запит іде на сервер, а справжню відповідь змінюють перед тим, як вона потрапить у застосунок.

Spy and stub: if the rule sets no response (cy.intercept('POST', '/api/orders').as('create')), the request goes to the real server and Cypress only records the request and response — that is a spy. If you pass a response as the third argument or call req.reply(), the request never reaches the server — that is a stub. In between sits req.continue((res) => { ... }): the request goes to the server, and the real response is modified before it reaches the app.

Навіщо: spy дає синхронізацію (дочекатися запиту) і перевірку контракту фронтенду (що саме він відправив). Stub дає детермінованість і стани, які важко відтворити на бекенді: порожній список, 500-та помилка, 10 000 записів, повільна відповідь.

Why: a spy gives you synchronisation (wait for the request) and a check of the frontend's side of the contract (what exactly it sent). A stub gives you determinism and states that are hard to produce on the backend: an empty list, a 500 error, 10,000 records, a slow response.

Приклад: в одному тесті шпигуємо за POST /api/orders і перевіряємо тіло запиту — що фронтенд передав правильну валюту. В іншому стабимо GET /api/orders порожнім масивом і перевіряємо заглушку «У вас ще немає замовлень».

Example: in one test we spy on POST /api/orders and check the request body — that the frontend sent the right currency. In another we stub GET /api/orders with an empty array and check the "You have no orders yet" empty state.

Підводний камінь: cy.intercept бачить лише запити браузера — не cy.request і не серверні виклики між мікросервісами. Правило треба реєструвати до дії, яка породжує запит. І якщо кілька правил збігаються, першим спрацьовує те, що оголошене пізніше. Якщо спитають далі: як змінити запит, а не відповідь? Змінити req.body або req.headers в обробнику, а потім викликати req.continue().

Pitfall: cy.intercept sees only browser requests — not cy.request, and not server-side calls between microservices. The rule has to be registered before the action that triggers the request. And when several rules match, the one declared later runs first. If they dig deeper: how do you change the request rather than the response? Modify req.body or req.headers in the handler and then call req.continue().

Spy, stub і зміна справжньої відповіді
A spy, a stub and a modified real response
// spy: запит іде на сервер, ми лише спостерігаємо
cy.intercept('POST', '/api/orders').as('createOrder');
cy.get('[data-cy=place-order]').click();
cy.wait('@createOrder').its('request.body.currency').should('eq', 'UAH');

// stub: сервер не викликається взагалі
cy.intercept('GET', '/api/orders*', { statusCode: 200, body: [] }).as('noOrders');

// справжня відповідь, змінена по дорозі
cy.intercept('GET', '/api/profile', (req) => {
  req.continue((res) => {
    res.body.plan = 'premium';
  });
}).as('profile');
// spy: the request reaches the server, we only watch
cy.intercept('POST', '/api/orders').as('createOrder');
cy.get('[data-cy=place-order]').click();
cy.wait('@createOrder').its('request.body.currency').should('eq', 'UAH');

// stub: the server is not called at all
cy.intercept('GET', '/api/orders*', { statusCode: 200, body: [] }).as('noOrders');

// a real response, changed on the way
cy.intercept('GET', '/api/profile', (req) => {
  req.continue((res) => {
    res.body.plan = 'premium';
  });
}).as('profile');
🔧 Пояснення синтаксису: Без третього аргументу cy.intercept лише шпигує; об'єкт із полями statusCode і body — статична відповідь; обробник (req) => ... отримує запит, а req.continue(cb) пропускає його на сервер і дає змінити res.body до того, як відповідь побачить застосунок.
🔧 Syntax explained: Without a third argument cy.intercept only spies; the statusCode and body object is a static response; the (req) => ... handler receives the request, and req.continue(cb) lets it through to the server and allows res.body to be changed before the app sees the response.
💡 Простими словами: Spy — камера на дорозі, stub — підставний сервер, req.continue — справжній сервер, але з підправленою відповіддю.
💡 In plain words: A spy is a camera on the road, a stub is a stand-in server, and req.continue is the real server with a touched-up reply.
🗣 Як читати вголос Я розповідаю про intercept як про правило для трафіку, який іде через проксі Cypress. Якщо я не задаю відповідь, це шпигун: запит іде на справжній бекенд, а я можу його дочекатися і перевірити, що фронтенд відправив. Якщо задаю відповідь, це стаб, і я отримую будь-який стан, наприклад порожній список чи п'ятисоту помилку. Є і середній варіант, коли відповідь справжня, але я міняю одне поле. І я завжди нагадую: правило ставиться до дії, а cy.request воно не бачить, бо той іде з Node, а не з вікна застосунку.
🗣 How to say it out loud I talk about intercept as a rule for the traffic passing through the Cypress proxy. If I set no response, it is a spy: the request goes to the real backend, and I can wait for it and check what the frontend sent. If I set a response, it is a stub, and I can get any state, like an empty list or a five hundred error. There is a middle option too, where the response is real but I change one field. And I always point out that the rule goes before the action, and that it does not see cy.request, because that one comes from Node, not from the app window.
24 Як працює RouteMatcher і чому інтерсепт іноді не спрацьовує? How does a RouteMatcher work, and why does an intercept sometimes not fire?
Мережа й мокиNetwork and mockingMiddle▶

RouteMatcher — це умова, за якою cy.intercept вирішує, чи стосується правило запиту. Найпростіша форма — метод і URL рядком: cy.intercept('GET', '/api/users*'). Повна форма — об'єкт з полями method, url, pathname, query, headers, hostname, port, а також times, щоб правило спрацювало лише задану кількість разів, і middleware.

A RouteMatcher is the condition cy.intercept uses to decide whether a rule applies to a request. The simplest form is a method and a URL string: cy.intercept('GET', '/api/users*'). The full form is an object with method, url, pathname, query, headers, hostname, port, plus times to make the rule fire only a set number of times, and middleware.

Як порівнюється URL: рядок збігається або точно, або як glob-шаблон (*, **), тож '/api/users' не зловить /api/users?page=2 — рядок запиту є частиною URL. Тут допомагають '/api/users*', регулярний вираз або pathname: '/api/users' у формі об'єкта, де параметри перевіряються окремо через query.

How the URL is compared: a string matches either exactly or as a glob pattern (*, **), so '/api/users' will not catch /api/users?page=2 — the query string is part of the URL. What helps is '/api/users*', a regular expression, or pathname: '/api/users' in object form, where parameters are checked separately through query.

Приклад: GraphQL відправляє все на один POST /graphql, і правило за URL ловить усі запити підряд. Правильніше перевіряти поле operationName у тілі в обробнику і давати аліас лише потрібній операції через req.alias.

Example: GraphQL sends everything to a single POST /graphql, and a URL rule catches every request in a row. It is better to check the operationName field of the body in a handler and alias only the operation you need via req.alias.

Підводний камінь: правило не спрацьовує з кількох причин — зареєстроване після запиту; не збігся метод (OPTIONS замість GET при CORS); URL із query без *; запит іде з Service Worker або з іншого процесу, а не з вікна застосунку. Діагностика проста: у Command Log кожен запит позначено, чи зловило його якесь правило, а в панелі Routes видно, скільки разів спрацювало кожне.

Pitfall: a rule does not fire for a few reasons — it was registered after the request; the method did not match (OPTIONS instead of GET with CORS); the URL has a query and no *; the request comes from a Service Worker or another process rather than the app window. Diagnosis is simple: in the Command Log every request is marked with whether a rule caught it, and the Routes panel shows how many times each rule fired.

Якщо спитають далі: як поводяться кілька правил на той самий запит? Обробники викликаються від останнього оголошеного до першого; як тільки один відповів (req.reply), решта не викликаються. Правила з middleware: true виконуються першими — зручно для спільних заголовків.

If they dig deeper: how do several rules on the same request behave? Handlers are called from the last declared to the first; as soon as one replies (req.reply), the rest are skipped. Rules with middleware: true run first — handy for shared headers.

Різні форми RouteMatcher
Different RouteMatcher forms
// glob: зловить /api/users?page=2
cy.intercept('GET', '/api/users*').as('users');

// об'єкт: шлях і параметри окремо, лише один раз
cy.intercept({ method: 'GET', pathname: '/api/users', query: { role: 'admin' }, times: 1 }).as('admins');

// регулярний вираз для id у шляху
cy.intercept('DELETE', /\/api\/users\/\d+$/).as('deleteUser');

// GraphQL: аліас за назвою операції
cy.intercept('POST', '/graphql', (req) => {
  if (req.body.operationName === 'GetCart') {
    req.alias = 'gqlGetCart';
  }
});
// glob: catches /api/users?page=2
cy.intercept('GET', '/api/users*').as('users');

// object: path and params separately, once only
cy.intercept({ method: 'GET', pathname: '/api/users', query: { role: 'admin' }, times: 1 }).as('admins');

// a regular expression for an id in the path
cy.intercept('DELETE', /\/api\/users\/\d+$/).as('deleteUser');

// GraphQL: an alias by operation name
cy.intercept('POST', '/graphql', (req) => {
  if (req.body.operationName === 'GetCart') {
    req.alias = 'gqlGetCart';
  }
});
🔧 Пояснення синтаксису: * у рядку — glob-шаблон; поле query порівнюється з параметрами запиту, а times: 1 вимикає правило після першого збігу; у регулярному виразі \d+$ означає число в кінці шляху; req.alias призначає аліас динамічно всередині обробника.
🔧 Syntax explained: * in a string is a glob pattern; the query field is compared with the request parameters, and times: 1 turns the rule off after the first match; in the regular expression \d+$ means a number at the end of the path; req.alias assigns an alias dynamically inside the handler.
💡 Простими словами: RouteMatcher — фільтр на вході: якщо запит не проходить за методом, шляхом чи параметрами, правило його просто не помічає.
💡 In plain words: A RouteMatcher is a filter at the door: if a request does not pass on method, path or parameters, the rule simply does not notice it.
🗣 Як читати вголос Коли інтерсепт не спрацьовує, я перевіряю чотири речі. Чи правило стоїть до дії. Чи збігається метод. Чи є в URL рядок запиту, бо без зірочки рядок його не зловить. І чи запит узагалі йде з вікна застосунку. У Command Log це видно одразу, там позначено, яке правило зловило запит. Для складних випадків я використовую об'єктну форму з pathname і query або регулярний вираз, а для GraphQL даю аліас за назвою операції в обробнику, бо за URL там усе однакове, і без цього аліас ловив би будь-який запит.
🗣 How to say it out loud When an intercept does not fire, I check four things. Whether the rule comes before the action. Whether the method matches. Whether the URL has a query string, because without an asterisk a string will not catch it. And whether the request comes from the app window at all. The Command Log shows it straight away, it marks which rule caught each request. For tricky cases I use the object form with pathname and query, or a regular expression, and for GraphQL I set an alias by operation name in a handler, because the URL is the same for everything there.
25 Що таке StaticResponse і як віддати фікстуру, помилку чи затримку у відповідь? What is a StaticResponse, and how do you reply with a fixture, an error or a delay?
Мережа й мокиNetwork and mockingMiddle▶

StaticResponse — це об'єкт, який описує підставну відповідь для cy.intercept або req.reply(). Основні поля: statusCode, body, headers, fixture (ім'я файлу з теки cypress/fixtures), delay у мілісекундах, throttleKbps для імітації повільного каналу і forceNetworkError, щоб розірвати з'єднання взагалі.

A StaticResponse is an object that describes a stand-in response for cy.intercept or req.reply(). The main fields are statusCode, body, headers, fixture (a file name from cypress/fixtures), delay in milliseconds, throttleKbps to imitate a slow link, and forceNetworkError to drop the connection altogether.

Навіщо: це найшвидший спосіб поставити інтерфейс у потрібний стан. Великі відповіді тримають у фікстурах, щоб тест лишався читабельним; помилки й затримки описують прямо в тесті, бо вони і є суттю перевірки.

Why: it is the quickest way to put the UI into the state you need. Large responses live in fixtures to keep the test readable; errors and delays are described right in the test, because they are the point of the check.

Приклад: cy.intercept('GET', '/api/users*', { fixture: 'users.json' }).as('users') віддасть список із файлу. Для помилки — відповідь зі statusCode: 500 і тілом з описом помилки, для обриву мережі — forceNetworkError: true, для перевірки спінера — відповідь із delay: 1500.

Example: cy.intercept('GET', '/api/users*', { fixture: 'users.json' }).as('users') returns the list from the file. For an error — a statusCode: 500 response with an error body, for a dropped network — forceNetworkError: true, to check a spinner — a response with delay: 1500.

Підводний камінь: delay — це не пауза в тесті, а затримка відповіді сервера; тест при цьому не спить і може перевірити спінер. Друге: фікстура не перевіряється на відповідність справжньому API — якщо бекенд перейменував поле, тест і далі зелений. Третє: для forceNetworkError застосунок бачить помилку мережі, а не HTTP-статус, і це інший обробник у коді фронтенду, тож обидва кейси варто перевірити окремо.

Pitfall: delay is not a pause in the test but a delay of the server's reply; the test does not sleep and can check the spinner meanwhile. Second, a fixture is not checked against the real API — if the backend renames a field, the test stays green. Third, with forceNetworkError the app sees a network failure rather than an HTTP status, which is a different handler in the frontend code, so both cases deserve separate tests.

Якщо спитають далі: як віддати різні відповіді на перший і другий запит? Два правила з times: 1 — останнє оголошене спрацює першим — або обробник із лічильником, що викликає req.reply з різними даними.

If they dig deeper: how do you return different responses to the first and second request? Two rules with times: 1 — the one declared last fires first — or a handler with a counter that calls req.reply with different data.

Фікстура, помилка, обрив і повільна відповідь
A fixture, an error, a dropped connection and a slow reply
it('renders users from a fixture', () => {
  cy.intercept('GET', '/api/users*', { fixture: 'users.json' }).as('users');
  cy.visit('/admin/users');
  cy.wait('@users');
  cy.get('[data-cy=user-row]').should('have.length', 3);
});

it('shows an error banner on 500', () => {
  cy.intercept('GET', '/api/users*', { statusCode: 500, body: { message: 'Internal error' } });
  cy.visit('/admin/users');
  cy.get('[data-cy=error-banner]').should('contain', 'Try again');
});

it('shows offline state when the network drops', () => {
  cy.intercept('GET', '/api/users*', { forceNetworkError: true });
  cy.visit('/admin/users');
  cy.get('[data-cy=offline]').should('be.visible');
});

it('shows a spinner while the list loads', () => {
  cy.intercept('GET', '/api/users*', { fixture: 'users.json', delay: 1500 });
  cy.visit('/admin/users');
  cy.get('[data-cy=spinner]').should('be.visible');
  cy.get('[data-cy=spinner]').should('not.exist');
});
🔧 Пояснення синтаксису: Третій аргумент cy.intercept — StaticResponse; fixture: 'users.json' читає файл із теки фікстур; statusCode і body задають помилку; forceNetworkError обриває з'єднання; delay затримує відповідь, поки тест перевіряє спінер.
🔧 Syntax explained: The third argument of cy.intercept is a StaticResponse; fixture: 'users.json' reads the file from the fixtures folder; statusCode and body define an error; forceNetworkError drops the connection; delay holds the reply while the test checks the spinner.
💡 Простими словами: StaticResponse — готова відповідь «з полиці»: дані з файлу, код помилки, обрив або затримка, без жодного бекенду.
💡 In plain words: A StaticResponse is an off-the-shelf reply: data from a file, an error code, a dropped connection or a delay, with no backend involved.
🗣 Як читати вголос StaticResponse я використовую, щоб швидко поставити інтерфейс у потрібний стан. Великий список я кладу у фікстуру і віддаю через поле fixture, а помилку п'ятсот чи обрив мережі описую прямо в тесті, бо саме це і перевіряю. Delay дуже зручний для спінера: відповідь затримується, а тест тим часом бачить, що індикатор завантаження з'явився, і потім зник. Мінус один, і я про нього кажу чесно: фікстура може розійтися з реальним API, тому я підтримую її актуальність окремо і перевіряю схемою з контракту.
🗣 How to say it out loud I use a StaticResponse to put the UI into the state I need quickly. A large list goes into a fixture and is served through the fixture field, while a five hundred error or a dropped network I describe right in the test, because that is exactly what I am checking. Delay is really handy for a spinner: the reply is held back, and meanwhile the test sees the loading indicator appear and then go away. There is one downside, and I say it honestly: a fixture can drift from the real API, so I keep it up to date separately.
26 Навіщо аліаси через .as() і як дочекатися запиту через cy.wait('@alias')? Why use aliases via .as(), and how do you wait for a request with cy.wait('@alias')?
Мережа й мокиNetwork and mockingJunior▶

.as('name') дає ім'я тому, що стоїть перед ним у ланцюжку: елементу, значенню або правилу cy.intercept. Пізніше до нього звертаються через собачку: cy.get('@name') для елемента чи значення і cy.wait('@name') для мережевого запиту.

.as('name') gives a name to whatever comes before it in the chain: an element, a value or a cy.intercept rule. Later you refer to it with the at sign: cy.get('@name') for an element or a value, and cy.wait('@name') for a network request.

Навіщо мережевий аліас: cy.wait('@users') чекає, поки застосунок надішле запит, що підпадає під правило, і поки прийде відповідь. Спершу діє requestTimeout (типово 5 с) на те, щоб запит пішов, потім responseTimeout (типово 30 с) на відповідь. Команда віддає об'єкт перехоплення з request і response, тож можна одразу перевірити статус, тіло і заголовки.

Why a network alias: cy.wait('@users') waits until the app sends a request that matches the rule, and until the response arrives. First requestTimeout (5 s by default) applies to the request going out, then responseTimeout (30 s by default) to the response. The command yields the interception object with request and response, so you can check the status, body and headers right away.

Приклад: реєструємо cy.intercept('POST', '/api/login').as('login'), натискаємо «Увійти», чекаємо cy.wait('@login') і перевіряємо, що статус 200, а в тілі запиту немає пароля у відкритому вигляді в query. Для кількох запитів — масив: cy.wait(['@profile', '@settings']).

Example: register cy.intercept('POST', '/api/login').as('login'), click "Sign in", wait with cy.wait('@login') and check that the status is 200 and that the password is not sitting in plain text in the query. For several requests, use an array: cy.wait(['@profile', '@settings']).

Підводний камінь: кожен cy.wait('@alias') чекає наступний збіг. Якщо сторінка робить два однакові запити, а тест хоче другий, потрібні два очікування. Аліаси очищаються перед кожним тестом, тож аліас із before у наступному тесті вже не існує — оголошуй його в beforeEach або в самому тесті. А ще для доступу через this.name функція тесту має бути звичайною function (), не стрілочною.

Pitfall: each cy.wait('@alias') waits for the next match. If the page makes two identical requests and the test wants the second, two waits are needed. Aliases are cleared before every test, so an alias from before no longer exists in the next test — declare it in beforeEach or in the test itself. And to reach it through this.name, the test function must be a regular function (), not an arrow function.

Якщо спитають далі: як отримати всі запити за аліасом? cy.get('@users.all') віддає масив усіх перехоплень, наприклад, щоб перевірити, що пошук не зробив зайвих викликів.

If they dig deeper: how do you get every request under an alias? cy.get('@users.all') yields an array of all interceptions — for example, to check that search made no extra calls.

Аліас запиту: очікування й перевірка
A request alias: waiting and checking
it('logs in and loads the dashboard', () => {
  cy.intercept('POST', '/api/login').as('login');
  cy.intercept('GET', '/api/dashboard').as('dashboard');

  cy.visit('/login');
  cy.get('[data-cy=email]').type('qa@example.com');
  cy.get('[data-cy=password]').type('secret', { log: false });
  cy.get('[data-cy=submit]').click();

  cy.wait('@login').then(({ request, response }) => {
    expect(response?.statusCode).to.eq(200);
    expect(request.url).not.to.contain('password');
  });
  cy.wait('@dashboard').its('response.body.widgets').should('have.length.at.least', 1);
  cy.get('@dashboard.all').should('have.length', 1);   // жодного зайвого виклику
});
it('logs in and loads the dashboard', () => {
  cy.intercept('POST', '/api/login').as('login');
  cy.intercept('GET', '/api/dashboard').as('dashboard');

  cy.visit('/login');
  cy.get('[data-cy=email]').type('qa@example.com');
  cy.get('[data-cy=password]').type('secret', { log: false });
  cy.get('[data-cy=submit]').click();

  cy.wait('@login').then(({ request, response }) => {
    expect(response?.statusCode).to.eq(200);
    expect(request.url).not.to.contain('password');
  });
  cy.wait('@dashboard').its('response.body.widgets').should('have.length.at.least', 1);
  cy.get('@dashboard.all').should('have.length', 1);   // no extra calls
});
🔧 Пояснення синтаксису: .as('login') іменує правило; cy.wait('@login') віддає перехоплення, яке деструктуризуємо на request і response; response?.statusCode — опціональний доступ у TypeScript, бо відповіді формально може не бути; '@dashboard.all' повертає всі перехоплення за аліасом.
🔧 Syntax explained: .as('login') names the rule; cy.wait('@login') yields the interception, which we destructure into request and response; response?.statusCode is TypeScript optional access, because formally there may be no response; '@dashboard.all' yields every interception under the alias.
💡 Простими словами: Аліас — це бірка з ім'ям: повісив на запит, а потім кажеш «чекай, поки прийде ось цей».
💡 In plain words: An alias is a name tag: you hang it on a request and later say "wait until this one arrives".
🗣 Як читати вголос Аліас для мене — це спосіб назвати запит і потім саме його дочекатися. Я ставлю intercept із as до кліку, а після кліку роблю cy.wait з собачкою. Він чекає, поки запит піде і поки прийде відповідь, і віддає мені і запит, і відповідь, тож я одразу перевіряю статус і тіло. Важливо, що кожен wait чекає наступний збіг, тож для двох однакових запитів треба два очікування. І аліаси живуть у межах одного тесту, тому я оголошую їх у beforeEach, а не в before. Це дрібниця, яку на співбесіді часто перевіряють.
🗣 How to say it out loud For me an alias is a way to name a request and then wait for exactly that one. I put the intercept with as before the click, and after the click I do cy.wait with the at sign. It waits for the request to go out and for the response to come back, and it gives me both, so I check the status and body right away. What matters is that every wait waits for the next match, so two identical requests need two waits. And aliases live within a single test, so I declare them in beforeEach, not in before. It is a small detail, but interviewers like to check it.
27 Що таке cy.request і чим він відрізняється від cy.intercept? What is cy.request, and how does it differ from cy.intercept?
Мережа й мокиNetwork and mockingJunior▶

cy.request сам відправляє HTTP-запит — не з вікна застосунку, а з Node-процесу Cypress. Тому на нього не діють обмеження CORS, він не з'являється у вкладці Network застосунку і його не бачить cy.intercept. Cookies, які повертає сервер, Cypress записує в браузер, а свої cookies браузера додає в запит — тож після API-логіну застосунок уже «залогінений».

cy.request sends an HTTP request itself — not from the app window, but from the Cypress Node process. So CORS restrictions do not apply to it, it does not show up in the app's Network tab, and cy.intercept does not see it. Cookies returned by the server are written into the browser, and the browser's cookies are added to the request — so after an API login the app is already "signed in".

Різниця з cy.intercept: cy.intercept не робить запитів, він лише перехоплює те, що відправив застосунок. cy.request — це ти в ролі клієнта API. Перший — для керування поведінкою UI, другий — для підготовки даних, логіну, очищення і прямих перевірок API.

The difference from cy.intercept: cy.intercept makes no requests; it only intercepts what the app sent. cy.request is you acting as an API client. The first is for controlling UI behaviour, the second for setting up data, logging in, cleaning up and checking the API directly.

Приклад: перед тестом кошика створюємо товар через POST /api/products, логінимося через POST /api/login, а потім відкриваємо сторінку і перевіряємо UI. Це в десятки разів швидше, ніж робити все через інтерфейс, і стабільніше. Відносний URL підставляється до baseUrl.

Example: before a cart test, create a product through POST /api/products, log in through POST /api/login, then open the page and check the UI. That is dozens of times faster than doing everything through the interface, and more stable. A relative URL is resolved against baseUrl.

Підводний камінь: за замовчуванням cy.request падає на статусах, відмінних від 2xx і 3xx. Для негативних перевірок API (401, 422) потрібна опція failOnStatusCode: false, інакше тест упаде раніше, ніж ти перевіриш статус. Друге — cy.request не повторюється: він робить запит один раз, і асерт після нього теж перевіряє вже отриману відповідь.

Pitfall: by default cy.request fails on any status other than 2xx or 3xx. Negative API checks (401, 422) need the failOnStatusCode: false option, otherwise the test fails before you get to check the status. Also, cy.request is not retried: it makes the request once, and the assertion after it checks the response it already has.

Якщо спитають далі: чи підходить Cypress для API-тестування? Для простих перевірок — так, і є плагін cypress-plugin-api із зручним виглядом у раннері. Для великої API-сюїти краще окремий інструмент, який не піднімає браузер.

If they dig deeper: is Cypress a good fit for API testing? For simple checks, yes, and the cypress-plugin-api plugin gives a nice view in the runner. For a large API suite a separate tool that does not start a browser is better.

Підготовка даних і негативна перевірка API
Preparing data and a negative API check
beforeEach(() => {
  cy.request('POST', '/api/test/products', { name: 'Test mug', price: 250 })
    .its('body.id')
    .as('productId');
});

it('rejects a product without a name', () => {
  cy.request({
    method: 'POST',
    url: '/api/products',
    body: { price: 100 },
    failOnStatusCode: false,
  }).then((res) => {
    expect(res.status).to.eq(422);
    expect(res.body.errors).to.have.property('name');
  });
});

it('shows the created product', function () {
  cy.visit('/products/' + this.productId);
  cy.get('h1').should('have.text', 'Test mug');
});
🔧 Пояснення синтаксису: Коротка форма cy.request(method, url, body) і повна з об'єктом опцій; failOnStatusCode: false дозволяє перевірити помилковий статус; .its('body.id').as('productId') зберігає id, а this.productId читає його в тесті, оголошеному через function ().
🔧 Syntax explained: The short form cy.request(method, url, body) and the full form with an options object; failOnStatusCode: false lets you check an error status; .its('body.id').as('productId') stores the id, and this.productId reads it in a test declared with function ().
💡 Простими словами: cy.request — це ти сам дзвониш в API, а cy.intercept — ти слухаєш, як дзвонить застосунок.
💡 In plain words: cy.request is you calling the API yourself; cy.intercept is you listening in while the app makes the call.
🗣 Як читати вголос Я пояснюю так: cy.intercept слухає, що робить застосунок, а cy.request сам іде в API, причому з Node, тож CORS йому не заважає. Я використовую його передусім для підготовки: створити товар, залогінитися, почистити дані після тесту. Це набагато швидше за кліки в інтерфейсі і стабільніше. Для негативних перевірок пам'ятаю про failOnStatusCode false, бо інакше Cypress упаде на чотириста двадцять другій ще до мого асерта. А велику API-сюїту я б усе ж писав окремим інструментом без браузера, бо так вона працює швидше.
🗣 How to say it out loud I explain it like this: cy.intercept listens to what the app does, while cy.request goes to the API itself, and from Node, so CORS does not get in its way. I use it first of all for setup: create a product, sign in, clean up data after the test. It is far faster than clicking through the UI, and more stable. For negative checks I remember failOnStatusCode false, otherwise Cypress fails on the four twenty-two before my assertion even runs. But a large API suite I would still write with a separate tool that has no browser.
28 Наскільки реалістичними мають бути стаби і як не дати їм розійтися з бекендом? How realistic should stubs be, and how do you keep them from drifting away from the backend?
Мережа й мокиNetwork and mockingSenior▶

Стаб корисний, поки він говорить правду про API. Щойно бекенд перейменував поле, додав обов'язковий атрибут чи змінив формат дати, фікстура стає вигадкою: UI-тест лишається зеленим, а в продакшені сторінка ламається. Це головний ризик моків, і на співбесіді senior-рівня від тебе чекають не «мокаємо все», а стратегію, як тримати моки чесними.

A stub is useful as long as it tells the truth about the API. The moment the backend renames a field, adds a required attribute or changes a date format, the fixture becomes fiction: the UI test stays green while the page breaks in production. That is the main risk of mocks, and at senior level the interviewer expects not "we mock everything" but a strategy for keeping mocks honest.

Що робить стаби реалістичними: фікстури записують зі справжніх відповідей тестового оточення, а не пишуть вручну; типи фікстур беруть із тих самих TypeScript-інтерфейсів або згенерованих з OpenAPI, що й фронтенд; фікстури валідують JSON-схемою з контракту, наприклад у cy.task з Ajv або окремим юніт-тестом у CI. Якщо схема змінилась — падає перевірка фікстур, а не прод.

What keeps stubs realistic: fixtures are recorded from real responses of a test environment rather than written by hand; fixture types come from the same TypeScript interfaces, or ones generated from OpenAPI, that the frontend uses; fixtures are validated against the contract's JSON schema, for example in cy.task with Ajv or in a separate unit test in CI. If the schema changes, the fixture check fails, not production.

Приклад стратегії: для більшості станів використовують req.continue і змінюють лише одне поле в справжній відповіді — решта даних завжди актуальна. Повністю статичні стаби лишають для станів, які неможливо отримати від бекенду: 503, обрив, 10 000 записів. Над цим — контрактні тести (наприклад, Pact) між фронтендом і сервісом.

Strategy example: for most states use req.continue and change a single field in the real response — the rest of the data is always current. Fully static stubs are kept for states the backend cannot produce on demand: a 503, a dropped connection, 10,000 records. On top of that sit contract tests (Pact, for example) between the frontend and the service.

Підводний камінь: надмірна точність теж шкодить — фікстура на 2 МБ із реальними персональними даними, яку ніхто не може прочитати. Фікстура має містити мінімум, потрібний для сценарію, і бути вільною від реальних персональних даних. Друге — стаби, що відповідають миттєво, ховають гонки; інколи варто додати delay, щоб наблизитися до реальності.

Pitfall: too much realism hurts as well — a 2 MB fixture full of real personal data that nobody can read. A fixture should hold the minimum the scenario needs and contain no real personal data. Also, stubs that answer instantly hide race conditions; sometimes it is worth adding a delay to get closer to reality.

Якщо спитають далі: хто відповідає за оновлення фікстур? Найкраще — автоматичне перезаписування за розкладом і рев'ю диффу, як для будь-якого коду.

If they dig deeper: who keeps the fixtures up to date? Ideally an automatic re-record on a schedule, with the diff reviewed like any other code.

Фікстура з перевіркою схеми і типами з контракту
A fixture checked against a schema and typed from the contract
// cypress.config.ts — задача валідації в Node
import Ajv from 'ajv';
import schema from './contracts/users.schema.json';

const validate = new Ajv({ allErrors: true }).compile(schema);

// усередині defineConfig({ e2e: { ... } })
setupNodeEvents(on) {
  on('task', {
    validateUsers(body: unknown) {
      return validate(body) ? null : JSON.stringify(validate.errors);
    },
  });
},

// users.cy.ts
import type { User } from '../../src/api/types';

it('fixture still matches the contract', () => {
  cy.fixture<User[]>('users.json').then((users) => {
    cy.task('validateUsers', users).should('be.null');
  });
});
// cypress.config.ts — a validation task in Node
import Ajv from 'ajv';
import schema from './contracts/users.schema.json';

const validate = new Ajv({ allErrors: true }).compile(schema);

// inside defineConfig({ e2e: { ... } })
setupNodeEvents(on) {
  on('task', {
    validateUsers(body: unknown) {
      return validate(body) ? null : JSON.stringify(validate.errors);
    },
  });
},

// users.cy.ts
import type { User } from '../../src/api/types';

it('fixture still matches the contract', () => {
  cy.fixture<User[]>('users.json').then((users) => {
    cy.task('validateUsers', users).should('be.null');
  });
});
🔧 Пояснення синтаксису: Ajv компілює JSON-схему у функцію validate; задача повертає null, якщо все гаразд, або текст помилок; cy.fixture<User[]> типізує фікстуру тим самим інтерфейсом, що й фронтенд; should('be.null') падає з переліком розбіжностей.
🔧 Syntax explained: Ajv compiles the JSON schema into a validate function; the task returns null when all is well, or the error text; cy.fixture<User[]> types the fixture with the same interface the frontend uses; should('be.null') fails with the list of mismatches.
💡 Простими словами: Стаб — це обіцянка, що API відповідає саме так; якщо обіцянку ніхто не перевіряє, вона швидко стає неправдою.
💡 In plain words: A stub is a promise that the API answers exactly like this; if nobody checks the promise, it soon stops being true.
🗣 Як читати вголос Моя позиція: стаби потрібні, але їх треба тримати чесними, інакше UI-тести зелені, а прод червоний. Я записую фікстури зі справжніх відповідей, типізую їх тими самими інтерфейсами, що й фронтенд, і валідую схемою з контракту, щоб зміна поля на бекенді ламала перевірку фікстур, а не користувачів. Там, де можна, я не підміняю відповідь повністю, а через req.continue міняю одне поле. Статичні стаби лишаю для станів, які бекенд не вміє відтворити на замовлення, як п'ятсот третя чи обрив мережі. І фікстури я тримаю маленькими, без реальних персональних даних.
🗣 How to say it out loud My position is that stubs are needed, but they have to be kept honest, otherwise the UI tests are green while production is red. I record fixtures from real responses, type them with the same interfaces the frontend uses, and validate them against the contract schema, so that a renamed field on the backend breaks the fixture check rather than the users. Where I can, I do not replace the whole response, I change one field through req.continue. I keep static stubs for states the backend cannot reproduce on demand, like a five oh three or a dropped network.
29 Як поєднати тести з моками і перевірку живого API, щоб не втратити реальні інтеграційні баги? How do you combine mocked tests with checks against the live API so that real integration bugs are not missed?
Мережа й мокиNetwork and mockingSenior▶

Сюїта, де всі UI-тести застаблені, швидка і стабільна, але не перевіряє головного — що фронтенд і бекенд працюють разом. Сюїта, де все йде на живий бекенд, перевіряє інтеграцію, але повільна і падає від чужих проблем: лежить сторонній сервіс, хтось змінив тестові дані. Senior-відповідь — свідомий розподіл, де кожен рівень ловить свій клас багів.

A suite where every UI test is stubbed is fast and stable, but it does not check the main thing — that the frontend and backend work together. A suite where everything hits the live backend checks integration, but it is slow and fails because of other people's problems: a third-party service is down, someone changed the test data. The senior answer is a deliberate split, where each level catches its own class of bugs.

Типовий розподіл: більшість UI-сценаріїв — зі стабами, вони перевіряють логіку інтерфейсу і всі краї: помилки, порожні стани, пагінацію. Тонкий шар наскрізних тестів іде на живий бекенд і покриває критичні шляхи — реєстрацію, оплату, оформлення замовлення — без жодного cy.intercept-стаба, лише зі шпигунами для синхронізації. Окремо — API-тести через cy.request або окремий фреймворк і контрактні тести між сервісами.

A typical split: most UI scenarios are stubbed; they check the interface logic and all the edges: errors, empty states, pagination. A thin layer of end-to-end tests goes to the live backend and covers the critical paths — sign-up, payment, checkout — with no stubbing cy.intercept at all, only spies for synchronisation. Separately, API tests through cy.request or a dedicated framework, and contract tests between services.

Приклад організації: теги в назвах або окремі теки (e2e/mocked, e2e/live), два прогони в CI — застаблені на кожен PR, живі на кожне злиття в main і перед релізом на staging. Дані для живих тестів створюються через API на старті і прибираються в кінці, щоб тести не залежали від ручного стану оточення.

How it is organised: tags in test names or separate folders (e2e/mocked, e2e/live), and two runs in CI — stubbed on every PR, live on every merge to main and before a release on staging. Data for the live tests is created through the API at the start and removed at the end, so the tests do not depend on the manual state of the environment.

Підводний камінь: «живий» тест, який усе ж стабить один запит «бо він повільний», перестає бути інтеграційним — рівно в цьому місці і сховається баг. Друге — живі тести на спільному оточенні конфліктують між собою, якщо дані не унікальні; кожен тест створює власного користувача або замовлення з унікальним префіксом.

Pitfall: a "live" test that still stubs one request "because it is slow" is no longer an integration test — and that is exactly where the bug will hide. Also, live tests on a shared environment clash with each other if the data is not unique; each test creates its own user or order with a unique prefix.

Якщо спитають далі: як зрозуміти, що балансу досягнуто? Дивитися, де ловляться баги з продакшену: якщо вони інтеграційні і не ловилися ні контрактами, ні живими тестами — додавати живий сценарій саме на цей шлях.

If they dig deeper: how do you know the balance is right? Look at where production bugs get caught: if they are integration bugs that neither the contracts nor the live tests caught, add a live scenario for exactly that path.

Два прогони: застаблений і живий
Two runs: stubbed and live
// cypress/e2e/live/checkout.cy.ts — жодних стабів, лише шпигуни
describe('checkout against the real backend', () => {
  beforeEach(() => {
    const email = 'live-' + Date.now() + '@example.com';
    cy.request('POST', '/api/test/users', { email }).its('body').as('user');
  });

  it('pays for an order end to end', function () {
    cy.intercept('POST', '/api/payments').as('pay');   // spy
    cy.loginByApi(this.user.email);
    cy.visit('/cart');
    cy.get('[data-cy=checkout]').click();
    cy.wait('@pay').its('response.statusCode').should('eq', 201);
    cy.contains('[data-cy=order-status]', 'Paid');
  });

  afterEach(function () {
    cy.request('DELETE', '/api/test/users/' + this.user.id);
  });
});

// package.json scripts
// "cy:mocked": "cypress run --spec 'cypress/e2e/mocked/**'",
// "cy:live":   "cypress run --spec 'cypress/e2e/live/**' --config baseUrl=https://staging.example.com"
// cypress/e2e/live/checkout.cy.ts — no stubs, spies only
describe('checkout against the real backend', () => {
  beforeEach(() => {
    const email = 'live-' + Date.now() + '@example.com';
    cy.request('POST', '/api/test/users', { email }).its('body').as('user');
  });

  it('pays for an order end to end', function () {
    cy.intercept('POST', '/api/payments').as('pay');   // spy
    cy.loginByApi(this.user.email);
    cy.visit('/cart');
    cy.get('[data-cy=checkout]').click();
    cy.wait('@pay').its('response.statusCode').should('eq', 201);
    cy.contains('[data-cy=order-status]', 'Paid');
  });

  afterEach(function () {
    cy.request('DELETE', '/api/test/users/' + this.user.id);
  });
});

// package.json scripts
// "cy:mocked": "cypress run --spec 'cypress/e2e/mocked/**'",
// "cy:live":   "cypress run --spec 'cypress/e2e/live/**' --config baseUrl=https://staging.example.com"
🔧 Пояснення синтаксису: Унікальний email через Date.now() не дає живим тестам конфліктувати; cy.loginByApi — кастомна команда логіну через API; --spec вибирає теку спеків, а --config baseUrl=... спрямовує живий прогін на staging.
🔧 Syntax explained: A unique email via Date.now() keeps live tests from clashing; cy.loginByApi is a custom command that logs in through the API; --spec selects a folder of specs, and --config baseUrl=... points the live run at staging.
💡 Простими словами: Моки перевіряють, що інтерфейс правильно реагує на відповіді; живі тести перевіряють, що відповіді взагалі такі. Потрібні обидва.
💡 In plain words: Mocks check that the UI reacts correctly to responses; live tests check that the responses really look like that. You need both.
🗣 Як читати вголос Я будую це як піраміду всередині e2e. Більшість UI-тестів у мене зі стабами, вони швидкі і покривають усі краї, від помилок до порожніх станів. Поверх — невеликий набір живих тестів на критичні шляхи, як оплата і реєстрація, де я не стаблю нічого, лише шпигую для синхронізації. Ці тести йдуть на staging після злиття і перед релізом, а дані створюють собі самі через API з унікальними іменами. І якщо баг із продакшену пройшов повз усі рівні, я додаю живий сценарій саме на цей шлях. Так баланс підлаштовується під реальні дефекти, а не під відчуття.
🗣 How to say it out loud I build it as a pyramid within e2e. Most of my UI tests use stubs; they are fast and cover every edge, from errors to empty states. On top sits a small set of live tests for critical paths such as payment and sign-up, where I stub nothing and only spy for synchronisation. Those tests run on staging after a merge and before a release, and they create their own data through the API with unique names. And if a production bug slipped past every level, I add a live scenario for exactly that path. That way the balance follows real defects rather than gut feeling.
▶ 5 5
30 Як cy.session кешує логін між тестами і що треба знати про validate? How does cy.session cache a login between tests, and what do you need to know about validate?
Авторизація і станAuth and stateMiddle▶

cy.session(id, setup, options) виконує функцію setup один раз, зберігає після неї cookies, localStorage і sessionStorage усіх доменів, а в наступних тестах з тим самим id просто відновлює цей стан. Логін через форму чи API відбувається один раз на спек (або на весь прогін з cacheAcrossSpecs: true), а не перед кожним тестом. Команда стала стабільною в Cypress 12.

cy.session(id, setup, options) runs the setup function once, saves the cookies, localStorage and sessionStorage of all domains afterwards, and in later tests with the same id simply restores that state. The login through the form or the API happens once per spec (or once per run with cacheAcrossSpecs: true), not before every test. The command became stable in Cypress 12.

Навіщо validate: збережена сесія може протухнути — токен закінчився, сервер перезапустили, тест вийшов із системи. Функція validate запускається після відновлення, і якщо вона падає або повертає false, Cypress очищає стан і виконує setup заново. Типова перевірка — cy.request('/api/me') зі статусом 200 або наявність потрібної cookie.

Why validate: a saved session can go stale — the token expired, the server restarted, a test logged out. The validate function runs after the restore, and if it fails or returns false, Cypress clears the state and runs setup again. A typical check is cy.request('/api/me') returning 200, or the presence of the right cookie.

Приклад: кастомна команда cy.login(role) загортає cy.session з id, що містить роль. Тести адміна й звичайного користувача отримують різні кешовані сесії, а всередині setup логін іде через API, тож навіть перше створення сесії швидке.

Example: a custom command cy.login(role) wraps cy.session with an id that contains the role. Admin tests and regular-user tests get different cached sessions, and inside setup the login goes through the API, so even the first creation of a session is fast.

Підводний камінь: після cy.session сторінка порожня — при увімкненій ізоляції Cypress очищає її перед кожним тестом, тому обов'язково cy.visit після логіну. Друге — id має враховувати все, що впливає на стан: роль, мову, тенант. Якщо два різні користувачі отримали однаковий id, другий тест потрапить у сесію першого. Третє — тест, який виходить із системи, інвалідує токен на сервері; без validate наступні тести отримають «мертву» сесію.

Pitfall: after cy.session the page is blank — with isolation on, Cypress clears it before every test, so always cy.visit after logging in. Second, the id must include everything that shapes the state: role, language, tenant. If two different users end up with the same id, the second test lands in the first one's session. Third, a test that logs out invalidates the token on the server; without validate the following tests get a "dead" session.

Якщо спитають далі: як скинути всі сесії під час дебагу? У раннері є кнопка очищення сесій, а в коді — Cypress.session.clearAllSavedSessions().

If they dig deeper: how do you drop every session while debugging? The runner has a button to clear sessions, and in code there is Cypress.session.clearAllSavedSessions().

cypress/support/commands.ts — логін з кешем і перевіркою
cypress/support/commands.ts — a cached, validated login
Cypress.Commands.add('login', (role: 'admin' | 'customer' = 'customer') => {
  cy.session(
    ['login', role],
    () => {
      cy.request('POST', '/api/login', {
        email: role + '@example.com',
        password: Cypress.env('TEST_PASSWORD'),
      }).its('status').should('eq', 200);
    },
    {
      validate() {
        cy.request('/api/me').its('status').should('eq', 200);
      },
      cacheAcrossSpecs: true,
    },
  );
});

// у тесті: після сесії сторінка порожня, тому visit
beforeEach(() => {
  cy.login('admin');
  cy.visit('/admin');
});
Cypress.Commands.add('login', (role: 'admin' | 'customer' = 'customer') => {
  cy.session(
    ['login', role],
    () => {
      cy.request('POST', '/api/login', {
        email: role + '@example.com',
        password: Cypress.env('TEST_PASSWORD'),
      }).its('status').should('eq', 200);
    },
    {
      validate() {
        cy.request('/api/me').its('status').should('eq', 200);
      },
      cacheAcrossSpecs: true,
    },
  );
});

// in a test: the page is blank after the session, hence visit
beforeEach(() => {
  cy.login('admin');
  cy.visit('/admin');
});
🔧 Пояснення синтаксису: Масив ['login', role] — ключ сесії, окремий для кожної ролі; друга функція — setup; validate перевіряє відновлену сесію запитом до /api/me; cacheAcrossSpecs ділить сесію між файлами спеків в одному прогоні.
🔧 Syntax explained: The ['login', role] array is the session key, separate per role; the second function is setup; validate checks the restored session with a request to /api/me; cacheAcrossSpecs shares the session across spec files in one run.
💡 Простими словами: cy.session — це «збережена гра»: пройшов логін один раз, а далі кожен тест завантажується з того самого місця.
💡 In plain words: cy.session is a "saved game": you get through the login once, and every later test loads from that same point.
🗣 Як читати вголос Я загортаю логін у cy.session всередині кастомної команди, і ключем роблю роль, щоб адмін і звичайний користувач мали різні збережені сесії. Сам логін усередині йде через API, тож навіть перший раз це швидко. Обов'язково додаю validate, наприклад запит до ендпоінта поточного користувача, бо інакше після тесту з виходом із системи всі наступні отримають мертву сесію. І нагадую, що після відновлення сесії сторінка порожня, тому одразу роблю visit на потрібну сторінку. Саме про цей крок забувають найчастіше.
🗣 How to say it out loud I wrap the login in cy.session inside a custom command, and I make the role the key, so the admin and a regular user get separate saved sessions. The login itself goes through the API, so even the first time is fast. I always add validate, for instance a request to the current-user endpoint, because otherwise after a test that logs out every following test gets a dead session. And I point out that after the session is restored the page is blank, so I immediately visit the page I need. That is the step people forget most often.
31 Як побудувати швидкий логін для сотень тестів і не втратити перевірку самої форми? How do you build a fast login for hundreds of tests without losing coverage of the login form itself?
Авторизація і станAuth and stateSenior▶

Логін через інтерфейс займає кілька секунд: відкрити сторінку, заповнити поля, дочекатися редиректу. На 500 тестах це хвилини прогону і сотні зайвих точок відмови, які не мають стосунку до того, що тест перевіряє. Тому логін розділяють: форму входу перевіряє кілька окремих тестів, а решта сюїти отримує автентифікацію найкоротшим шляхом.

Logging in through the UI takes several seconds: open the page, fill in the fields, wait for the redirect. Across 500 tests that is minutes of run time and hundreds of extra points of failure that have nothing to do with what each test checks. So the login is split: a few dedicated tests check the sign-in form, and the rest of the suite gets authenticated by the shortest route.

Рівні прискорення: перший — програмний логін через cy.request на той самий ендпоінт, що використовує форма: сервер ставить cookie, і застосунок уже залогінений. Другий — кешування цього стану через cy.session з cacheAcrossSpecs, щоб логін виконувався раз на роль за весь прогін. Третій, для JWT у localStorage — отримати токен через API і покласти його у onBeforeLoad або через cy.window().

Levels of speed-up: first, a programmatic login with cy.request to the same endpoint the form uses: the server sets the cookie, and the app is already signed in. Second, caching that state with cy.session and cacheAcrossSpecs, so the login runs once per role for the whole run. Third, for a JWT in localStorage, fetch the token through the API and put it in place in onBeforeLoad or through cy.window().

SSO і сторонні провайдери: якщо провайдер дозволяє, використовують його API для отримання токена (наприклад, Resource Owner Password Grant на тестовому тенанті) або тестовий ендпоінт бекенду, який видає сесію для тестового користувача. Повний редирект через cy.origin лишають для одного smoke-тесту інтеграції.

SSO and third-party providers: if the provider allows it, use its API to get a token (for example, a Resource Owner Password Grant on a test tenant), or a test endpoint on the backend that issues a session for a test user. The full redirect through cy.origin is kept for one integration smoke test.

Підводний камінь: тестовий бекдор для логіну не повинен потрапити в продакшн — його вмикають лише змінною оточення тестового стенда і покривають перевіркою, що на проді він відповідає 404. Друге — «швидкий логін» не має обходити важливу логіку: якщо при звичайному вході виконується згода з умовами чи вибір тенанта, програмний логін мусить приводити до такого самого стану.

Pitfall: a test backdoor for login must never reach production — it is enabled only by an environment variable on the test stand and covered by a check that it returns 404 in production. Second, a "fast login" must not skip important logic: if the regular sign-in accepts the terms or selects a tenant, the programmatic login has to end in the same state.

Якщо спитають далі: як бути з 2FA? Тестовий користувач із відомим TOTP-секретом, код генерують у cy.task бібліотекою для TOTP, або на тестовому стенді 2FA для службових акаунтів вимикають конфігурацією.

If they dig deeper: what about 2FA? A test user with a known TOTP secret whose code is generated in cy.task with a TOTP library, or 2FA switched off by configuration for service accounts on the test stand.

JWT-логін через API з кешем сесії
A JWT login through the API with a cached session
Cypress.Commands.add('loginByApi', (email: string) => {
  cy.session(['jwt', email], () => {
    cy.request('POST', Cypress.env('AUTH_URL') + '/token', {
      username: email,
      password: Cypress.env('TEST_PASSWORD'),
      grant_type: 'password',
    }).then(({ body }) => {
      window.localStorage.setItem('access_token', body.access_token);
    });
  }, {
    validate() {
      cy.window().its('localStorage').invoke('getItem', 'access_token').should('be.a', 'string');
    },
  });
});

// форму входу перевіряє окремий тест, решта логіниться через API
describe('sign-in form', () => {
  it('logs in through the UI', () => {
    cy.visit('/login');
    cy.get('[data-cy=email]').type('customer@example.com');
    cy.get('[data-cy=password]').type(Cypress.env('TEST_PASSWORD'), { log: false });
    cy.get('[data-cy=submit]').click();
    cy.location('pathname').should('eq', '/dashboard');
  });
});
Cypress.Commands.add('loginByApi', (email: string) => {
  cy.session(['jwt', email], () => {
    cy.request('POST', Cypress.env('AUTH_URL') + '/token', {
      username: email,
      password: Cypress.env('TEST_PASSWORD'),
      grant_type: 'password',
    }).then(({ body }) => {
      window.localStorage.setItem('access_token', body.access_token);
    });
  }, {
    validate() {
      cy.window().its('localStorage').invoke('getItem', 'access_token').should('be.a', 'string');
    },
  });
});

// the form is covered by a dedicated test, everything else logs in via the API
describe('sign-in form', () => {
  it('logs in through the UI', () => {
    cy.visit('/login');
    cy.get('[data-cy=email]').type('customer@example.com');
    cy.get('[data-cy=password]').type(Cypress.env('TEST_PASSWORD'), { log: false });
    cy.get('[data-cy=submit]').click();
    cy.location('pathname').should('eq', '/dashboard');
  });
});
🔧 Пояснення синтаксису: Токен отримуємо через cy.request і кладемо в localStorage всередині setup, тож cy.session збереже його разом з іншим станом; validate перевіряє, що після відновлення токен на місці; Cypress.env тримає адресу провайдера і пароль поза кодом.
🔧 Syntax explained: The token is fetched with cy.request and put into localStorage inside setup, so cy.session saves it with the rest of the state; validate checks that the token is still there after the restore; Cypress.env keeps the provider address and the password out of the code.
💡 Простими словами: Форму входу перевіряють кілька тестів чесно, а всі інші заходять через службовий вхід — швидко й однаково.
💡 In plain words: A few tests check the sign-in form honestly; all the others come in through the service entrance — quickly and the same way every time.
🗣 Як читати вголос Я розділяю дві задачі. Перевірити форму входу — це кілька окремих тестів, які проходять її як користувач. А всім іншим тестам потрібен лише залогінений стан, і вони отримують його через API, а cy.session кешує цей стан один раз на роль за весь прогін. Для SSO я домовляюся про тестовий тенант або службовий ендпоінт, а повний редирект лишаю одному smoke-тесту. Важливо, щоб службовий вхід приводив рівно до того самого стану, що й звичайний, і щоб його не було на проді, це я теж перевіряю тестом. З 2FA так само: тестовий секрет і генерація коду в Node.
🗣 How to say it out loud I split it into two jobs. Checking the sign-in form is a handful of dedicated tests that go through it like a user. All the other tests only need a signed-in state, and they get it through the API, while cy.session caches that state once per role for the whole run. For SSO I agree on a test tenant or a service endpoint, and I leave the full redirect to one smoke test. What matters is that the service login leads to exactly the same state as the normal one, and that it does not exist in production, which I also check with a test.
32 Що таке testIsolation і що саме Cypress очищає між тестами? What is testIsolation, and what exactly does Cypress clear between tests?
Авторизація і станAuth and stateMiddle▶

testIsolation — параметр конфігурації, що з'явився в Cypress 12 і типово увімкнений (true) для e2e-тестів. Перед кожним тестом Cypress очищає сторінку (переходить на about:blank), видаляє cookies, localStorage і sessionStorage в усіх доменах. Кожен тест починається з чистого браузера, незалежно від того, що робив попередній.

testIsolation is a configuration option introduced in Cypress 12 and on by default (true) for e2e tests. Before each test Cypress clears the page (navigates to about:blank) and removes cookies, localStorage and sessionStorage across all domains. Every test starts in a clean browser, whatever the previous one did.

Навіщо: без ізоляції тести неявно залежать один від одного. Другий тест проходить лише тому, що перший залишив користувача залогіненим і товар у кошику. Запустиш другий окремо або в іншому порядку — впаде. Ізоляція робить цю залежність видимою одразу, а не через місяць у CI.

Why: without isolation tests depend on each other implicitly. The second test passes only because the first left the user signed in and an item in the cart. Run the second one alone or in another order and it fails. Isolation makes that dependency visible immediately rather than a month later in CI.

Приклад: до Cypress 12 поширеною була практика «логін у before, далі десять тестів на тій самій сторінці». З ізоляцією вона не працює — після першого тесту сторінка порожня і сесії немає. Правильна заміна — cy.session у beforeEach плюс cy.visit, що майже так само швидко.

Example: before Cypress 12 a common practice was "log in inside before, then ten tests on the same page". With isolation this no longer works — after the first test the page is blank and there is no session. The right replacement is cy.session in beforeEach plus cy.visit, which is almost as fast.

Підводний камінь: testIsolation: false можна задати глобально або для окремого describe, але не для окремого it. Вимикати ізоляцію варто лише свідомо, наприклад для довгого послідовного сценарію, де кроки справді залежать один від одного, — і тоді такий блок не можна запускати частинами. Також ізоляція не очищає дані на сервері: створені через API замовлення залишаються, їх прибирає сам тест або seed-скрипт.

Pitfall: testIsolation: false can be set globally or for a single describe, but not for a single it. Switch isolation off only deliberately, for example for a long sequential scenario where the steps truly depend on each other — and then that block cannot be run in parts. Also, isolation does not clear server data: orders created through the API stay, and the test itself or a seed script cleans them up.

Якщо спитають далі: чи очищаються аліаси й інтерсепти? Так, аліаси, правила cy.intercept, стаби і годинник скидаються перед кожним тестом незалежно від testIsolation.

If they dig deeper: are aliases and intercepts cleared as well? Yes, aliases, cy.intercept rules, stubs and the clock are reset before every test regardless of testIsolation.

Глобальна ізоляція і свідомий виняток для одного блоку
Global isolation and a deliberate exception for one block
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    testIsolation: true,   // типове значення, пишемо явно для читача
  },
});

// wizard.cy.ts — кроки майстра залежать один від одного
describe('onboarding wizard', { testIsolation: false }, () => {
  before(() => {
    cy.login('customer');
    cy.visit('/onboarding');
  });

  it('step 1: company details', () => {
    cy.get('[data-cy=company]').type('Valtive');
    cy.contains('button', 'Next').click();
  });

  it('step 2: invite a teammate', () => {
    cy.get('[data-cy=invite-email]').type('teammate@example.com');
    cy.contains('button', 'Finish').click();
    cy.contains('Welcome aboard').should('be.visible');
  });
});
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    testIsolation: true,   // the default, written out for the reader
  },
});

// wizard.cy.ts — the wizard steps depend on each other
describe('onboarding wizard', { testIsolation: false }, () => {
  before(() => {
    cy.login('customer');
    cy.visit('/onboarding');
  });

  it('step 1: company details', () => {
    cy.get('[data-cy=company]').type('Valtive');
    cy.contains('button', 'Next').click();
  });

  it('step 2: invite a teammate', () => {
    cy.get('[data-cy=invite-email]').type('teammate@example.com');
    cy.contains('button', 'Finish').click();
    cy.contains('Welcome aboard').should('be.visible');
  });
});
🔧 Пояснення синтаксису: defineConfig дає типізацію конфігу; другий аргумент describe — об'єкт конфігурації для блоку, тут testIsolation: false; before виконується один раз для всього блоку, і лише тому, що ізоляцію вимкнено, стан переживає перехід між тестами.
🔧 Syntax explained: defineConfig gives the config its types; the second argument of describe is a config object for the block, here testIsolation: false; before runs once for the whole block, and only because isolation is off does the state survive from one test to the next.
💡 Простими словами: Ізоляція — це чистий аркуш перед кожним тестом; вимикати її — все одно що писати наступний тест поверх попереднього.
💡 In plain words: Isolation is a clean sheet before every test; switching it off is like writing the next test on top of the previous one.
🗣 Як читати вголос Я кажу, що з дванадцятої версії кожен e2e-тест за замовчуванням стартує з чистого браузера: сторінка порожня, cookies і сховища очищені в усіх доменах. Через це старий підхід «залогінитися в before і далі десять тестів» перестав працювати, і правильна заміна — cy.session у beforeEach. Вимикаю ізоляцію я лише для блоку, де кроки справді залежать один від одного, наприклад майстер онбордингу, і тоді цей блок запускається лише цілком. І нагадую, що дані на сервері ізоляція не чистить, це вже відповідальність самого тесту.
🗣 How to say it out loud I say that since version twelve every e2e test starts from a clean browser by default: the page is blank, and cookies and storage are cleared across all domains. Because of that the old approach of logging in inside before and then running ten tests stopped working, and the right replacement is cy.session in beforeEach. I switch isolation off only for a block where the steps truly depend on each other, like an onboarding wizard, and then that block only runs as a whole. And I remind people that isolation does not clean server data.
33 Чим before() відрізняється від beforeEach() і як зробити тести незалежними? How does before() differ from beforeEach(), and how do you make tests independent?
Авторизація і станAuth and stateMiddle▶

before виконується один раз перед усіма тестами блоку describe, beforeEach — перед кожним тестом. Аналогічно after і afterEach. Хуки з support-файлу діють на всі спеки. Незалежний тест — такий, що проходить сам, у будь-якому порядку і в будь-якій комбінації з іншими, у тому числі коли його перезапускає механізм повторів.

before runs once before all the tests in a describe block, beforeEach before every test. The same goes for after and afterEach. Hooks in the support file apply to every spec. An independent test is one that passes on its own, in any order and in any combination with others, including when the retry mechanism re-runs it.

Навіщо розрізняти: у before кладуть те, що не змінюється тестами і дороге: наприклад, seed довідників через cy.task. У beforeEach — усе, що тест може змінити: логін через cy.session, створення власного замовлення, реєстрацію інтерсептів, cy.visit. Змінний стан, підготовлений у before, — головне джерело взаємозалежності.

Why the difference matters: before holds things tests do not change and that are expensive: seeding reference data through cy.task, for example. beforeEach holds everything a test can change: logging in with cy.session, creating its own order, registering intercepts, cy.visit. Mutable state prepared in before is the main source of tests depending on one another.

Очищення на початку, а не в кінці: Cypress рекомендує прибирати стан перед тестом, а не після нього. Якщо тест упав посередині або прогін перервали, after може не виконатися, і наступний прогін стартує з «брудних» даних. Очищення в beforeEach гарантує, що кожен тест бачить відомий стан.

Clean up at the start, not the end: Cypress recommends clearing state before a test rather than after it. If a test fails halfway or the run is interrupted, after may never run, and the next run starts on "dirty" data. Cleaning in beforeEach guarantees that every test sees a known state.

Підводний камінь: ділити дані через змінні модуля (let orderId, присвоєну в одному тесті й прочитану в іншому) — пряма залежність від порядку. Правильний шлях — кожен тест створює свої дані або бере їх з аліаса, оголошеного в beforeEach. Друге — it.only, забутий у коді, приховує проблему: тест проходить сам, а в сюїті падає, або навпаки.

Pitfall: sharing data through module variables (let orderId set in one test and read in another) is a direct dependency on order. The right way is for each test to create its own data, or take it from an alias declared in beforeEach. Also, a forgotten it.only hides the problem: a test passes alone and fails in the suite, or the other way round.

Якщо спитають далі: як перевірити, що тести справді незалежні? Запускати окремі тести по одному, вмикати ретраї (повтор тесту — перевірка незалежності сама по собі), а в CI можна перемішувати порядок спеків.

If they dig deeper: how do you check that tests really are independent? Run single tests one at a time, switch retries on (a retried test is an independence check in itself), and in CI you can shuffle the spec order.

Спільна підготовка і власні дані кожного тесту
Shared setup and each test's own data
describe('order history', () => {
  before(() => {
    cy.task('db:seed', 'reference-data');   // довідники, тести їх не змінюють
  });

  beforeEach(() => {
    cy.task('db:reset', 'orders');          // очищення на початку, а не в after
    cy.login('customer');
    cy.request('POST', '/api/test/orders', { items: 2 }).its('body.id').as('orderId');
  });

  it('shows the order in the list', function () {
    cy.visit('/orders');
    cy.get('[data-cy=order-' + this.orderId + ']').should('be.visible');
  });

  it('cancels the order', function () {
    cy.visit('/orders/' + this.orderId);
    cy.contains('button', 'Cancel').click();
    cy.get('[data-cy=status]').should('have.text', 'Cancelled');
  });
});
describe('order history', () => {
  before(() => {
    cy.task('db:seed', 'reference-data');   // reference data that tests never change
  });

  beforeEach(() => {
    cy.task('db:reset', 'orders');          // clean up at the start, not in after
    cy.login('customer');
    cy.request('POST', '/api/test/orders', { items: 2 }).its('body.id').as('orderId');
  });

  it('shows the order in the list', function () {
    cy.visit('/orders');
    cy.get('[data-cy=order-' + this.orderId + ']').should('be.visible');
  });

  it('cancels the order', function () {
    cy.visit('/orders/' + this.orderId);
    cy.contains('button', 'Cancel').click();
    cy.get('[data-cy=status]').should('have.text', 'Cancelled');
  });
});
🔧 Пояснення синтаксису: cy.task('db:seed', ...) і cy.task('db:reset', ...) — задачі в Node, зареєстровані в setupNodeEvents; .as('orderId') у beforeEach створює аліас заново для кожного тесту; this.orderId доступний, бо тести оголошені через function ().
🔧 Syntax explained: cy.task('db:seed', ...) and cy.task('db:reset', ...) are Node tasks registered in setupNodeEvents; .as('orderId') in beforeEach creates the alias afresh for every test; this.orderId is available because the tests are declared with function ().
💡 Простими словами: before — для того, що всі лише читають; beforeEach — для всього, що хтось може зіпсувати.
💡 In plain words: before is for things everyone only reads; beforeEach is for anything someone might mess up.
🗣 Як читати вголос Я формулюю правило так: у before лише те, що тести не змінюють, наприклад довідники, а все змінне — логін, власне замовлення, інтерсепти й visit — у beforeEach. Очищення даних я роблю на початку тесту, а не в after, бо after не виконається, якщо прогін обірвався, і наступний стартує з брудних даних. Спільні змінні модуля між тестами для мене червоний прапорець: кожен тест створює свої дані сам. А перевіряю незалежність просто, запускаючи тести поодинці і з увімкненими ретраями. Якщо тест не проходить сам по собі, він залежний.
🗣 How to say it out loud I put the rule like this: before holds only what the tests do not change, such as reference data, and everything mutable — login, the test's own order, intercepts and the visit — goes into beforeEach. I clean data at the start of a test, not in after, because after will not run if the run was cut short, and the next one starts on dirty data. Module variables shared between tests are a red flag to me: each test creates its own data. And I check independence simply, by running tests one at a time and with retries switched on. If a test cannot pass on its own, it depends on something.
34 Чому умовне тестування («якщо є банер — закрий його») вважається антипатерном у Cypress? Why is conditional testing ("if there is a banner, close it") considered an anti-pattern in Cypress?
Авторизація і станAuth and stateSenior▶

Умовне тестування — це код, який дивиться на сторінку і вирішує, що робити: if є банер — закрити, if є кнопка «Продовжити» — натиснути. Проблема в тому, що DOM змінюється в часі. У момент перевірки банера ще немає, бо він з'являється через 300 мс, тест іде далі, і банер перекриває наступну кнопку. Результат залежить від швидкості машини — класичний флейк.

Conditional testing is code that looks at the page and decides what to do: if there is a banner, close it; if there is a "Continue" button, click it. The problem is that the DOM changes over time. When the check runs, the banner is not there yet because it appears after 300 ms, the test moves on, and the banner covers the next button. The outcome depends on the machine's speed — a classic flake.

Чому в Cypress це особливо видно: retry-ability тут не допомагає. Перевірка $body.find('.banner').length усередині .then виконується один раз і відразу — Cypress не може «чекати, що елемент з'явиться або не з'явиться». Документація Cypress прямо каже: умовне тестування надійне лише тоді, коли стан уже гарантовано стабільний у момент перевірки.

Why it shows especially in Cypress: retry-ability does not help here. A $body.find('.banner').length check inside .then runs once and immediately — Cypress cannot "wait for an element to appear or not appear". The Cypress documentation says it plainly: conditional testing is reliable only when the state is already guaranteed to be stable at the moment of the check.

Що робити замість: керувати станом, а не реагувати на нього. Банер cookie — поставити cookie згоди до cy.visit. A/B-експеримент або фіче-флаг — застабити відповідь сервісу флагів через cy.intercept або передати параметр. Онбординг для нового користувача — створювати користувача з уже пройденим онбордингом через API. Кожен тест точно знає, в якому стані стартує.

What to do instead: control the state rather than react to it. A cookie banner — set the consent cookie before cy.visit. An A/B experiment or a feature flag — stub the flag service response with cy.intercept or pass a parameter. Onboarding for a new user — create a user who has already finished onboarding through the API. Every test knows exactly which state it starts in.

Підводний камінь: «але в нас на staging іноді є банер, а іноді немає» — це проблема тестового оточення, а не тесту, і її вирішують конфігурацією стенда. Якщо ж умову ніяк не прибрати, спершу дочекатися стабільного стану, від якого можна відштовхнутися (наприклад, відповіді API з прапорцями), і лише потім розгалужуватися.

Pitfall: "but on staging the banner is sometimes there and sometimes not" is a test-environment problem, not a test problem, and it is solved by configuring the environment. If the condition truly cannot be removed, first wait for a stable state you can rely on (such as the API response with the flags), and only then branch.

Якщо спитають далі: а if за параметром тесту, як ширина вікна чи роль? Це не умовне тестування: умова відома заздалегідь і не залежить від того, що встигло відмалюватися.

If they dig deeper: what about an if on a test parameter, such as window width or role? That is not conditional testing: the condition is known in advance and does not depend on what happened to render.

Замість if — керований стан
Controlled state instead of an if
// антипатерн: результат залежить від того, чи встиг банер з'явитися
cy.get('body').then(($body) => {
  if ($body.find('[data-cy=promo-banner]').length) {
    cy.get('[data-cy=promo-banner] [data-cy=close]').click();
  }
});

// краще: тест сам задає стан до завантаження сторінки
beforeEach(() => {
  cy.setCookie('cookie_consent', 'accepted');
  cy.intercept('GET', '/api/feature-flags', {
    body: { promoBanner: false, newCheckout: true },
  }).as('flags');
  cy.visit('/');
  cy.wait('@flags');
});

it('goes straight to the new checkout', () => {
  cy.get('[data-cy=promo-banner]').should('not.exist');
  cy.get('[data-cy=cart]').click();
  cy.location('pathname').should('eq', '/checkout/v2');
});
// anti-pattern: the result depends on whether the banner has appeared yet
cy.get('body').then(($body) => {
  if ($body.find('[data-cy=promo-banner]').length) {
    cy.get('[data-cy=promo-banner] [data-cy=close]').click();
  }
});

// better: the test sets the state before the page loads
beforeEach(() => {
  cy.setCookie('cookie_consent', 'accepted');
  cy.intercept('GET', '/api/feature-flags', {
    body: { promoBanner: false, newCheckout: true },
  }).as('flags');
  cy.visit('/');
  cy.wait('@flags');
});

it('goes straight to the new checkout', () => {
  cy.get('[data-cy=promo-banner]').should('not.exist');
  cy.get('[data-cy=cart]').click();
  cy.location('pathname').should('eq', '/checkout/v2');
});
🔧 Пояснення синтаксису: $body.find(...).length — одноразова jQuery-перевірка без повторів; cy.setCookie і стаб /api/feature-flags задають стан до cy.visit; should('not.exist') перевіряє, що банера справді немає, а не пропускає його мовчки.
🔧 Syntax explained: $body.find(...).length is a one-off jQuery check with no retries; cy.setCookie and the /api/feature-flags stub set the state before cy.visit; should('not.exist') verifies that the banner really is absent instead of silently skipping it.
💡 Простими словами: Не питай сторінку, в якому вона стані — скажи їй, в якому стані бути.
💡 In plain words: Do not ask the page which state it is in — tell it which state to be in.
🗣 Як читати вголос Я пояснюю, що if по DOM у Cypress — це ставка на швидкість машини. Перевірка виконується один раз, без повторів, і якщо банер з'являється на триста мілісекунд пізніше, тест його не бачить, а потім він перекриває кнопку. Тому я не реагую на стан, а задаю його: ставлю cookie згоди до visit, стаблю сервіс фіче-флагів, створюю користувача з уже пройденим онбордингом. Якщо на staging банер то є, то немає, це задача налаштувати стенд. А if за параметром тесту, наприклад за шириною вікна, я умовним тестуванням не вважаю.
🗣 How to say it out loud I explain that an if on the DOM in Cypress is a bet on the machine's speed. The check runs once, with no retries, and if the banner shows up three hundred milliseconds later, the test does not see it, and then it covers a button. So I do not react to state, I set it: I put the consent cookie in place before the visit, stub the feature flag service, create a user who has already finished onboarding. If the banner comes and goes on staging, that is a job for whoever configures the environment. And an if on a test parameter, like window width, I do not count as conditional testing.
▶ 6 9
35 Що лежить у cypress.config.ts і навіщо задавати baseUrl? What goes into cypress.config.ts, and why set baseUrl?
Конфіг і раннерConfig and runnerJunior▶

cypress.config.ts (або .js) — єдиний файл конфігурації, який з Cypress 10 замінив старий cypress.json. У ньому є глобальні параметри (розмір вікна, тайм-аути, ретраї, відео) і окремі блоки e2e та component зі своїми specPattern, supportFile і setupNodeEvents — функцією, де реєструють задачі й плагіни, що працюють у Node.

cypress.config.ts (or .js) is the single configuration file that replaced the old cypress.json in Cypress 10. It holds global options (window size, timeouts, retries, video) and separate e2e and component blocks with their own specPattern, supportFile and setupNodeEvents — the function where tasks and plugins that run in Node are registered.

Навіщо baseUrl: це адреса застосунку, до якої Cypress додає відносні шляхи в cy.visit і cy.request. Тести не містять жорстко прописаного localhost:3000, і одну сюїту можна запускати проти локального сервера, staging чи preview-оточення, лише змінивши одне значення. Крім того, з baseUrl Cypress на старті перевіряє, що сервер відповідає, і не перезавантажує сторінку під час першого cy.visit.

Why baseUrl: it is the app address that Cypress prefixes to relative paths in cy.visit and cy.request. The tests contain no hard-coded localhost:3000, and one suite can run against a local server, staging or a preview environment by changing a single value. On top of that, with baseUrl Cypress checks at start-up that the server responds, and does not reload the page on the first cy.visit.

Приклад: локально baseUrl — http://localhost:3000, у CI його перевизначають прапорцем --config baseUrl=https://staging.example.com або змінною оточення CYPRESS_BASE_URL. Код тестів при цьому не змінюється.

Example: locally baseUrl is http://localhost:3000; in CI it is overridden with the --config baseUrl=https://staging.example.com flag or the CYPRESS_BASE_URL environment variable. The test code stays the same.

Підводний камінь: у конфіг часто складають усе підряд, включно з паролями. Конфіг лежить у репозиторії, тож секрети туди не кладуть — для них є змінні оточення. Друге — setupNodeEvents виконується в Node, а не в браузері: console.log звідти видно в терміналі, а не в DevTools, і змінні з нього не потрапляють у спеки автоматично.

Pitfall: people tend to pile everything into the config, passwords included. The config lives in the repository, so secrets do not go there — that is what environment variables are for. Second, setupNodeEvents runs in Node, not in the browser: a console.log from there shows up in the terminal, not in DevTools, and its variables do not reach the specs automatically.

Якщо спитають далі: як мати кілька конфігурацій? Окремий файл через прапорець --config-file, або в setupNodeEvents читати назву оточення зі змінної і повертати змінений config.

If they dig deeper: how do you keep several configurations? A separate file via the --config-file flag, or read the environment name from a variable inside setupNodeEvents and return a modified config.

Мінімальний cypress.config.ts для e2e і компонентів
A minimal cypress.config.ts for e2e and components
import { defineConfig } from 'cypress';

export default defineConfig({
  viewportWidth: 1280,
  viewportHeight: 800,
  defaultCommandTimeout: 6000,
  retries: { runMode: 2, openMode: 0 },
  e2e: {
    baseUrl: 'http://localhost:3000',
    specPattern: 'cypress/e2e/**/*.cy.ts',
    setupNodeEvents(on, config) {
      // задачі й плагіни, що працюють у Node
      return config;
    },
  },
  component: {
    devServer: { framework: 'react', bundler: 'vite' },
  },
});
import { defineConfig } from 'cypress';

export default defineConfig({
  viewportWidth: 1280,
  viewportHeight: 800,
  defaultCommandTimeout: 6000,
  retries: { runMode: 2, openMode: 0 },
  e2e: {
    baseUrl: 'http://localhost:3000',
    specPattern: 'cypress/e2e/**/*.cy.ts',
    setupNodeEvents(on, config) {
      // tasks and plugins that run in Node
      return config;
    },
  },
  component: {
    devServer: { framework: 'react', bundler: 'vite' },
  },
});
🔧 Пояснення синтаксису: defineConfig дає автодоповнення й перевірку типів; параметри верхнього рівня діють на обидва типи тестів; setupNodeEvents(on, config) отримує функцію підписки на події та поточний конфіг і має повернути конфіг, якщо змінює його.
🔧 Syntax explained: defineConfig provides autocompletion and type checking; top-level options apply to both kinds of tests; setupNodeEvents(on, config) receives the event subscription function and the current config, and must return the config if it changes it.
💡 Простими словами: Конфіг — пульт керування раннером, а baseUrl — адреса, куди цей пульт спрямовано; змінив адресу — та сама сюїта пішла на інше оточення.
💡 In plain words: The config is the runner's remote control, and baseUrl is where it points; change the address and the same suite runs against another environment.
🗣 Як читати вголос Я описую конфіг як одне місце, де зібрано все про раннер: розмір вікна, тайм-аути, ретраї, а далі окремі блоки для e2e і компонентних тестів. baseUrl я завжди задаю, бо тоді в тестах лише відносні шляхи, і ту саму сюїту я спрямовую на локальний сервер, staging або preview, змінивши одне значення в CI. Ще приємний бонус: Cypress на старті перевіряє, що сервер живий. Секретів у конфігу я не тримаю, для них є змінні оточення, а setupNodeEvents пам'ятаю як код, що виконується в Node, тож його логи шукаю в терміналі.
🗣 How to say it out loud I describe the config as the one place holding everything about the runner: window size, timeouts, retries, and then separate blocks for e2e and component tests. I always set baseUrl, because then the tests use only relative paths, and I point the same suite at a local server, staging or a preview by changing one value in CI. A nice bonus is that Cypress checks at start-up that the server is alive. I keep no secrets in the config, environment variables are for that, and I remember that setupNodeEvents is code that runs in Node, so I look for its logs in the terminal.
36 Як передавати змінні оточення і секрети в Cypress? How do you pass environment variables and secrets into Cypress?
Конфіг і раннерConfig and runnerMiddle▶

У спеках значення читають через Cypress.env('NAME'). Задати їх можна кількома способами: блок env у cypress.config.ts, файл cypress.env.json у корені проєкту, змінні оточення ОС із префіксом CYPRESS_ (CYPRESS_TEST_PASSWORD стає Cypress.env('TEST_PASSWORD')), прапорець --env у командному рядку і конфігурація окремого describe чи it. Значення з командного рядка і змінних ОС перекривають значення з конфігу.

In specs values are read through Cypress.env('NAME'). They can be set in several ways: the env block in cypress.config.ts, a cypress.env.json file in the project root, OS environment variables with the CYPRESS_ prefix (CYPRESS_TEST_PASSWORD becomes Cypress.env('TEST_PASSWORD')), the --env command-line flag, and the config of an individual describe or it. Values from the command line and OS variables override those from the config.

Навіщо стільки способів: значення за замовчуванням для всіх лежать у конфігу; локальні особисті — у cypress.env.json, доданому в .gitignore; секрети в CI — у змінних оточення з сейфа CI (secrets.* у GitHub Actions), куди Cypress підхопить їх за префіксом. Код тестів однаковий на всіх машинах.

Why so many ways: defaults shared by everyone live in the config; personal local values live in cypress.env.json, added to .gitignore; secrets in CI come from environment variables filled from the CI vault (secrets.* in GitHub Actions), which Cypress picks up by prefix. The test code is the same on every machine.

Приклад: у setupNodeEvents можна прочитати process.env (наприклад, після dotenv), вибрати набір значень для оточення staging чи local і записати їх у config.env — так в одному місці видно, що звідки береться.

Example: in setupNodeEvents you can read process.env (for instance after dotenv), choose a set of values for the staging or local environment and write them into config.env — then one place shows where everything comes from.

Підводний камінь: усе, що в Cypress.env, передається в браузер разом зі спеком і може потрапити в Command Log, скріншоти, відео або записи Test Replay. Паролі вводять з опцією log: false, а по-справжньому чутливі дії (токени адміністратора, доступ до БД) тримають у Node і викликають через cy.task, щоб секрет узагалі не потрапляв у браузер. Друге — Cypress.env() не читає process.env напряму: змінна без префікса CYPRESS_ сама в спек не потрапить.

Pitfall: everything in Cypress.env is sent to the browser together with the spec and can end up in the Command Log, screenshots, videos or Test Replay recordings. Passwords are typed with the log: false option, and truly sensitive actions (admin tokens, database access) are kept in Node and called through cy.task, so the secret never reaches the browser at all. Also, Cypress.env() does not read process.env directly: a variable without the CYPRESS_ prefix will not reach the spec on its own.

Якщо спитають далі: чим Cypress.env відрізняється від Cypress.config? Перше — довільні дані для тестів, друге — параметри раннера: baseUrl, тайм-аути, теки.

If they dig deeper: how does Cypress.env differ from Cypress.config? The first holds arbitrary test data, the second holds runner options: baseUrl, timeouts, folders.

Вибір оточення в setupNodeEvents і безпечне введення пароля
Choosing an environment in setupNodeEvents and typing a password safely
// cypress.config.ts
import { defineConfig } from 'cypress';
import 'dotenv/config';

const targets = {
  local: { baseUrl: 'http://localhost:3000', apiUrl: 'http://localhost:4000' },
  staging: { baseUrl: 'https://staging.example.com', apiUrl: 'https://api.staging.example.com' },
};

export default defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      const name = (process.env.TARGET ?? 'local') as keyof typeof targets;
      config.baseUrl = targets[name].baseUrl;
      config.env.apiUrl = targets[name].apiUrl;
      config.env.TEST_PASSWORD = process.env.TEST_PASSWORD;   // із .env або секретів CI
      return config;
    },
  },
});

// login.cy.ts
cy.get('[data-cy=password]').type(Cypress.env('TEST_PASSWORD'), { log: false });
// cypress.config.ts
import { defineConfig } from 'cypress';
import 'dotenv/config';

const targets = {
  local: { baseUrl: 'http://localhost:3000', apiUrl: 'http://localhost:4000' },
  staging: { baseUrl: 'https://staging.example.com', apiUrl: 'https://api.staging.example.com' },
};

export default defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      const name = (process.env.TARGET ?? 'local') as keyof typeof targets;
      config.baseUrl = targets[name].baseUrl;
      config.env.apiUrl = targets[name].apiUrl;
      config.env.TEST_PASSWORD = process.env.TEST_PASSWORD;   // from .env or CI secrets
      return config;
    },
  },
});

// login.cy.ts
cy.get('[data-cy=password]').type(Cypress.env('TEST_PASSWORD'), { log: false });
🔧 Пояснення синтаксису: import 'dotenv/config' завантажує .env у process.env; keyof typeof targets обмежує назву оточення відомими ключами; зміни в config.baseUrl і config.env діють, лише якщо функція повертає config; log: false не показує введений текст у Command Log.
🔧 Syntax explained: import 'dotenv/config' loads .env into process.env; keyof typeof targets limits the environment name to known keys; changes to config.baseUrl and config.env take effect only if the function returns config; log: false keeps the typed text out of the Command Log.
💡 Простими словами: Cypress.env — кишеня з даними для тестів; усе, що туди поклав, видно в браузері, тож справжні ключі краще тримати в Node.
💡 In plain words: Cypress.env is a pocket of test data; whatever you put there is visible in the browser, so real keys are better kept in Node.
🗣 Як читати вголос Я розповідаю, що в тесті значення читаються через Cypress.env, а джерел кілька: конфіг для спільних значень, cypress.env.json у gitignore для локальних і змінні з префіксом CYPRESS_ у CI, куди вони приходять із сейфа секретів. Мені подобається зводити все в setupNodeEvents: читаю назву оточення, підставляю baseUrl і адресу API, і в одному місці видно, звідки що береться. Про безпеку кажу окремо: усе з Cypress.env потрапляє в браузер, тому паролі вводжу з log false, а найчутливіше лишаю в Node за cy.task.
🗣 How to say it out loud I explain that tests read values through Cypress.env, and that there are several sources: the config for shared values, cypress.env.json in gitignore for local ones, and CYPRESS_-prefixed variables in CI, filled from the secrets vault. I like to bring it all together in setupNodeEvents: I read the environment name, set baseUrl and the API address, and one place shows where everything comes from. I mention security separately: everything in Cypress.env reaches the browser, so I type passwords with log false and keep the most sensitive things in Node behind cy.task.
37 Як працюють фікстури і cy.fixture і коли краще генерувати дані? How do fixtures and cy.fixture work, and when is it better to generate data?
Конфіг і раннерConfig and runnerJunior▶

Фікстури — це файли з тестовими даними в теці cypress/fixtures: JSON, текст, зображення, CSV. cy.fixture('users.json') читає файл і віддає вміст як subject — JSON уже розібраний в об'єкт. Найчастіше фікстури використовують двома способами: як тіло підставної відповіді в cy.intercept(..., { fixture: 'users.json' }) і як вхідні дані для заповнення форм.

Fixtures are files with test data in the cypress/fixtures folder: JSON, text, images, CSV. cy.fixture('users.json') reads the file and yields its content as the subject — JSON already parsed into an object. Fixtures are used mostly in two ways: as the body of a stand-in response in cy.intercept(..., { fixture: 'users.json' }), and as input data for filling in forms.

Навіщо: великі структури даних не захаращують код тесту, і їх легко переглянути в рев'ю. Одна фікстура може обслуговувати кілька тестів: список користувачів для таблиці, пагінації й пошуку. Бінарні файли — PDF, зображення — теж кладуть у фікстури для selectFile.

Why: large data structures do not clutter the test code, and they are easy to look through in review. One fixture can serve several tests: a user list for the table, the pagination and the search. Binary files — PDFs, images — also go into fixtures for selectFile.

Приклад: cy.fixture('checkout/address.json').as('address') у beforeEach, а в тесті this.address.city для заповнення форми. У TypeScript зручніше типізований варіант: cy.fixture<Address>('checkout/address.json').then((a) => ...).

Example: cy.fixture('checkout/address.json').as('address') in beforeEach, and in the test this.address.city to fill in the form. In TypeScript the typed version is handier: cy.fixture<Address>('checkout/address.json').then((a) => ...).

Підводний камінь: статичні дані конфліктують із реальним бекендом. Якщо тест реєструє користувача з email із фікстури, другий прогін впаде з «email уже зайнятий». Для даних, які створюються на сервері, унікальні поля генерують у тесті (часова мітка, UUID, бібліотека на кшталт Faker), а фікстура задає лише стабільну частину. Друге — фікстура для стабу може застаріти відносно API, тому її перевіряють схемою або регулярно перезаписують зі справжньої відповіді.

Pitfall: static data clashes with a real backend. If a test registers a user with an email from a fixture, the second run fails with "email already taken". For data created on the server, unique fields are generated in the test (a timestamp, a UUID, a library like Faker), and the fixture holds only the stable part. Second, a fixture used for a stub can go stale relative to the API, so it is checked against a schema or regularly re-recorded from a real response.

Якщо спитають далі: чи можна просто імпортувати JSON? Так, import users from '../fixtures/users.json' працює через бандлер і дає типи з файлу; різниця в тому, що cy.fixture читається в черзі й підтримує кодування та бінарні файли.

If they dig deeper: can you simply import the JSON? Yes, import users from '../fixtures/users.json' works through the bundler and gives you types from the file; the difference is that cy.fixture is read inside the queue and supports encodings and binary files.

Фікстура плюс унікальні поля, згенеровані в тесті
A fixture plus unique fields generated in the test
type Customer = { firstName: string; lastName: string; city: string; email?: string };

it('registers a customer from a fixture', () => {
  cy.fixture<Customer>('customers/base.json').then((base) => {
    const customer = { ...base, email: 'qa+' + Date.now() + '@example.com' };

    cy.visit('/register');
    cy.get('[data-cy=first-name]').type(customer.firstName);
    cy.get('[data-cy=last-name]').type(customer.lastName);
    cy.get('[data-cy=city]').type(customer.city);
    cy.get('[data-cy=email]').type(customer.email);
    cy.get('[data-cy=submit]').click();
    cy.contains('[data-cy=greeting]', customer.firstName);
  });
});
🔧 Пояснення синтаксису: type Customer описує форму даних; cy.fixture<Customer> типізує результат; spread-оператор ...base копіює поля фікстури, а email перезаписується унікальним значенням через Date.now(), тож повторний прогін не зіткнеться з уже зареєстрованим користувачем.
🔧 Syntax explained: type Customer describes the shape of the data; cy.fixture<Customer> types the result; the spread of base copies the fixture fields, and email is overwritten with a unique value from Date.now(), so a repeat run does not collide with an already registered user.
💡 Простими словами: Фікстура — заготовка з незмінною частиною даних; усе, що має бути унікальним, дописуй у самому тесті.
💡 In plain words: A fixture is a template holding the part of the data that never changes; anything that must be unique is added in the test itself.
🗣 Як читати вголос Фікстури я використовую для двох речей: як тіло підставної відповіді в intercept і як дані для форм. Великі структури в окремому файлі тримають тест читабельним, а рев'юеру зручно їх переглядати. Але я розрізняю стабільні й унікальні дані. Якщо тест щось створює на сервері, наприклад реєструє користувача, email я генерую в тесті, бо інакше другий прогін упаде на «вже зайнято». А фікстури, які йдуть у стаби, я періодично звіряю зі справжнім API, бо вони тихо старіють і перестають відповідати бекенду.
🗣 How to say it out loud I use fixtures for two things: as the body of a stand-in response in intercept, and as data for forms. Large structures in a separate file keep the test readable, and a reviewer can easily look through them. But I separate stable data from unique data. If a test creates something on the server, say registers a user, I generate the email in the test, otherwise the second run fails on "already taken". And the fixtures that feed stubs I check against the real API from time to time, because they quietly go stale.
38 Що таке cy.task і коли потрібен код, що виконується в Node? What is cy.task, and when do you need code that runs in Node?
Конфіг і раннерConfig and runnerMiddle▶

cy.task(name, arg) викликає функцію, зареєстровану в setupNodeEvents через on('task', { ... }), і віддає її результат у тест. Функція виконується в Node-процесі Cypress, тож має доступ до всього, чого немає в браузері: файлової системи, бази даних, зовнішніх бібліотек, змінних process.env, мережі без CORS.

cy.task(name, arg) calls a function registered in setupNodeEvents through on('task', { ... }) and yields its result to the test. The function runs in the Cypress Node process, so it has access to everything the browser lacks: the file system, a database, external libraries, process.env variables, CORS-free networking.

Навіщо: типові задачі — seed і очищення бази перед тестом, читання списку файлів у теці завантажень, генерація TOTP-коду для 2FA, розбір PDF чи XLSX, перевірка листа в тестовій поштовій скриньці, вивід логів у термінал CI (бо cy.log видно лише в Command Log).

Why: typical tasks are seeding and cleaning the database before a test, listing the files in the downloads folder, generating a TOTP code for 2FA, parsing a PDF or XLSX, checking an email in a test mailbox, and printing logs to the CI terminal (because cy.log shows only in the Command Log).

Приклад: задача db:createOrder вставляє замовлення напряму в тестову базу і повертає його id, тест відкриває сторінку замовлення. Задача log друкує повідомлення в термінал, щоб у логах CI було видно ключові кроки.

Example: a db:createOrder task inserts an order straight into the test database and returns its id, and the test opens the order page. A log task prints a message to the terminal so the key steps are visible in the CI logs.

Підводний камінь: задача мусить повернути значення, null або проміс — повернення undefined Cypress вважає помилкою, бо не може відрізнити «забули повернути» від «нічого немає». Аргумент і результат серіалізуються, тож функції, класи й циклічні об'єкти передати не вийде. Задача обмежена taskTimeout (типово 60 секунд). І задача не повторюється: асерт після неї перевіряє вже отримане значення.

Pitfall: a task must return a value, null or a promise — returning undefined is treated as an error by Cypress, because it cannot tell "forgot to return" from "there is nothing". The argument and result are serialised, so functions, classes and circular objects cannot be passed. A task is bounded by taskTimeout (60 seconds by default). And a task is not retried: an assertion after it checks the value already received.

Якщо спитають далі: чим cy.task відрізняється від cy.exec? cy.exec запускає shell-команду і повертає stdout і код виходу; cy.task викликає JS-функцію з типізованими аргументами — це зручніше і портативніше між ОС.

If they dig deeper: how does cy.task differ from cy.exec? cy.exec runs a shell command and returns stdout and the exit code; cy.task calls a JS function with typed arguments — more convenient and more portable across operating systems.

Задачі в setupNodeEvents і їх виклик зі спека
Tasks in setupNodeEvents and calling them from a spec
// cypress.config.ts
import { defineConfig } from 'cypress';
import { Pool } from 'pg';

const db = new Pool({ connectionString: process.env.TEST_DB_URL });

export default defineConfig({
  e2e: {
    setupNodeEvents(on) {
      on('task', {
        async 'db:createOrder'(items: number) {
          const { rows } = await db.query(
            'INSERT INTO orders (items, status) VALUES ($1, $2) RETURNING id', [items, 'new']);
          return rows[0].id;
        },
        log(message: string) {
          console.log('[e2e] ' + message);
          return null;   // не undefined
        },
      });
    },
  },
});

// order.cy.ts
it('opens an order created in the database', () => {
  cy.task<number>('db:createOrder', 3).then((id) => {
    cy.task('log', 'created order ' + id);
    cy.visit('/orders/' + id);
    cy.get('[data-cy=items-count]').should('have.text', '3');
  });
});
// cypress.config.ts
import { defineConfig } from 'cypress';
import { Pool } from 'pg';

const db = new Pool({ connectionString: process.env.TEST_DB_URL });

export default defineConfig({
  e2e: {
    setupNodeEvents(on) {
      on('task', {
        async 'db:createOrder'(items: number) {
          const { rows } = await db.query(
            'INSERT INTO orders (items, status) VALUES ($1, $2) RETURNING id', [items, 'new']);
          return rows[0].id;
        },
        log(message: string) {
          console.log('[e2e] ' + message);
          return null;   // never undefined
        },
      });
    },
  },
});

// order.cy.ts
it('opens an order created in the database', () => {
  cy.task<number>('db:createOrder', 3).then((id) => {
    cy.task('log', 'created order ' + id);
    cy.visit('/orders/' + id);
    cy.get('[data-cy=items-count]').should('have.text', '3');
  });
});
🔧 Пояснення синтаксису: Ключ об'єкта в on('task', ...) — ім'я задачі, значення — функція з одним аргументом; async-функція повертає проміс, якого Cypress дочекається; $1, $2 — параметри SQL-запиту замість склеювання рядків; cy.task<number> типізує результат у спеку.
🔧 Syntax explained: The object key in on('task', ...) is the task name and the value is a one-argument function; an async function returns a promise that Cypress waits for; $1, $2 are SQL query parameters instead of string concatenation; cy.task<number> types the result in the spec.
💡 Простими словами: cy.task — дзвінок помічнику в Node: браузер не має доступу до бази й диска, а помічник має, і повертає тобі відповідь.
💡 In plain words: cy.task is a call to a helper in Node: the browser cannot reach the database or the disk, but the helper can, and it sends the answer back.
🗣 Як читати вголос Я кажу, що cy.task — це міст із браузера в Node. Реєструю задачі в setupNodeEvents, а в тесті викликаю їх за назвою. Використовую для того, чого браузер не вміє: seed бази, читання файлів, генерація коду для двофакторної автентифікації, друк логів у термінал CI. Дві речі, про які я попереджаю: задача мусить повернути значення або null, бо undefined Cypress вважає помилкою, і аргументи серіалізуються, тож функцію чи клас туди не передаш. І SQL у задачах я пишу з параметрами, а не склеюю рядки, навіть у тестовому коді.
🗣 How to say it out loud I say that cy.task is a bridge from the browser into Node. I register tasks in setupNodeEvents and call them by name from the test. I use them for what the browser cannot do: seeding the database, reading files, generating a two-factor code, printing logs to the CI terminal. There are two things I warn about: a task must return a value or null, because Cypress treats undefined as an error, and the arguments are serialised, so you cannot pass a function or a class. And I write SQL in tasks with parameters, never by gluing strings.
39 Як налаштувати ретраї тестів у Cypress і чому вони не лікують флейки? How do you configure test retries in Cypress, and why do they not cure flakes?
Конфіг і раннерConfig and runnerMiddle▶

Ретраї тестів — це повторний запуск цілого тесту, якщо він упав, разом з beforeEach і afterEach. Це не те саме, що retry-ability команд, яка повторює окремі запити до DOM. Налаштовують у конфігу об'єктом retries з полями runMode: 2 і openMode: 0: у cypress run (CI) до двох повторів, в інтерактивному cypress open жодного, щоб під час розробки флейк був видимий одразу. Можна задати і для окремого тесту чи блоку: it('...', { retries: 3 }, ...).

Test retries re-run a whole test when it fails, together with beforeEach and afterEach. That is not the same as command retry-ability, which repeats individual DOM queries. They are configured in the config with a retries object holding runMode: 2 and openMode: 0, which means up to two retries in cypress run (CI) and none in the interactive cypress open, so that a flake is visible immediately during development. They can also be set for a single test or block: it('...', { retries: 3 }, ...).

Навіщо: у великій сюїті на спільній інфраструктурі частина падінь — шум: повільний стенд, мережевий збій, конкуренція за ресурси. Ретраї не дають такому шуму блокувати злиття PR. Кожна спроба зберігає свій скріншот, а Cypress Cloud позначає тест, який пройшов не з першої спроби, як flaky.

Why: in a large suite on shared infrastructure some failures are noise: a slow environment, a network glitch, competition for resources. Retries keep that noise from blocking PR merges. Each attempt keeps its own screenshot, and Cypress Cloud marks a test that did not pass on the first try as flaky.

Приклад: тест оплати впав на першій спробі через тайм-аут платіжного стабу і пройшов на другій. Прогін зелений, але в звіті тест позначено як нестабільний, і його беруть у роботу, а не забувають.

Example: the payment test failed on the first attempt because the payment stub timed out, and passed on the second. The run is green, but the report marks the test as unstable, and it gets picked up rather than forgotten.

Підводний камінь: ретраї ховають і справжні баги — гонку в застосунку, яка трапляється в 30% випадків, тест «проскочить» із другої спроби. Тому ретраї — це подушка безпеки, яка працює лише в парі з моніторингом нестабільних тестів: список flaky, власник, термін виправлення або карантин. Друге — тест, що залежить від стану, лишеного попередньою спробою, на повторі поведеться інакше; ретраї якраз добре виявляють такі залежності.

Pitfall: retries hide real bugs too — a race in the app that happens 30% of the time will "slip through" on the second try. So retries are a safety cushion that only works together with monitoring of flaky tests: a flaky list, an owner, a deadline or quarantine. Also, a test that depends on state left by the previous attempt will behave differently on a retry; retries are actually good at exposing such dependencies.

Якщо спитають далі: чи можна вимагати кілька успіхів? У Cypress 13 додано експериментальні стратегії ретраїв (experimentalStrategy): наприклад, зарахувати тест лише після кількох успішних спроб, або завжди вважати його проваленим, навіть якщо повтор пройшов, але позначати як flaky.

If they dig deeper: can you require several passes? Cypress 13 added experimental retry strategies (experimentalStrategy): for example, count a test as passed only after several successful attempts, or always treat it as failed even if a retry passed, while still marking it as flaky.

Глобальні ретраї і точкове налаштування
Global retries and a targeted override
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  retries: {
    runMode: 2,    // cypress run / CI
    openMode: 0,   // під час розробки флейк видно одразу
  },
  e2e: { baseUrl: 'http://localhost:3000' },
});

// payments.cy.ts
describe('payments', { retries: { runMode: 3, openMode: 0 } }, () => {
  it('charges a saved card', () => {
    cy.login('customer');
    cy.visit('/checkout');
    cy.get('[data-cy=pay-saved-card]').click();
    cy.contains('[data-cy=order-status]', 'Paid');
  });

  it('never retries the refund flow', { retries: 0 }, () => {
    // повтор тут міг би повернути гроші двічі
  });
});
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  retries: {
    runMode: 2,    // cypress run / CI
    openMode: 0,   // during development a flake shows up at once
  },
  e2e: { baseUrl: 'http://localhost:3000' },
});

// payments.cy.ts
describe('payments', { retries: { runMode: 3, openMode: 0 } }, () => {
  it('charges a saved card', () => {
    cy.login('customer');
    cy.visit('/checkout');
    cy.get('[data-cy=pay-saved-card]').click();
    cy.contains('[data-cy=order-status]', 'Paid');
  });

  it('never retries the refund flow', { retries: 0 }, () => {
    // a retry here could refund twice
  });
});
🔧 Пояснення синтаксису: runMode діє для cypress run, openMode — для cypress open; об'єкт конфігурації другим аргументом describe або it перевизначає ретраї для блоку чи окремого тесту; retries: 0 вимикає повтори там, де повтор небезпечний.
🔧 Syntax explained: runMode applies to cypress run, openMode to cypress open; a config object as the second argument of describe or it overrides retries for a block or a single test; retries: 0 switches retries off where a retry is dangerous.
💡 Простими словами: Ретрай — друга спроба на іспиті: допомагає, якщо просто не пощастило, але не вчить матеріал за тебе.
💡 In plain words: A retry is a second go at an exam: it helps if you were simply unlucky, but it does not learn the material for you.
🗣 Як читати вголос Ретраї я вмикаю лише для run mode, зазвичай два повтори, а в open mode ставлю нуль, щоб під час розробки нестабільність було видно одразу. Але я чітко кажу, що ретраї не лікують флейки, вони лише не дають шуму блокувати злиття. Кожен тест, що пройшов не з першої спроби, потрапляє в список нестабільних, у нього з'являється власник і термін. Для небезпечних сценаріїв, як повернення коштів, я ретраї вимикаю зовсім. І зазначу, що в Cypress 13 з'явилися експериментальні стратегії, які вимагають кількох успішних спроб.
🗣 How to say it out loud I enable retries only for run mode, usually two, and set zero for open mode so that instability is visible straight away during development. But I say clearly that retries do not cure flakes, they only stop the noise from blocking merges. Every test that passes on a later attempt goes onto a flaky list and gets an owner and a deadline. For dangerous scenarios, like a refund, I switch retries off completely. And I would mention that Cypress 13 brought experimental strategies that require several successful attempts.
40 Як Cypress робить скріншоти і відео і що змінилося в Cypress 13? How does Cypress take screenshots and videos, and what changed in Cypress 13?
Конфіг і раннерConfig and runnerJunior▶

У режимі cypress run Cypress автоматично робить скріншот у момент падіння тесту (screenshotOnRunFailure: true) і зберігає його в cypress/screenshots у підтеці з назвою спека. Вручну знімок робить cy.screenshot('name') — усієї сторінки, лише видимої області або конкретного елемента: cy.get('[data-cy=invoice]').screenshot().

In cypress run mode Cypress automatically takes a screenshot at the moment a test fails (screenshotOnRunFailure: true) and saves it under cypress/screenshots in a subfolder named after the spec. A manual shot is taken with cy.screenshot('name') — of the whole page, the visible area only, or a specific element: cy.get('[data-cy=invoice]').screenshot().

Відео: з Cypress 13 запис відео вимкнено за замовчуванням (video: false), а стиснення відео також вимкнене типово. Щоб отримати відео прогону, треба явно задати video: true; записується один файл на спек у cypress/videos. Причина зміни — запис і стиснення помітно сповільнювали прогони, а для дебагу в Cypress Cloud з'явився Test Replay, що відтворює DOM, мережу і консоль у момент падіння.

Videos: since Cypress 13 video recording is off by default (video: false), and video compression is off by default as well. To get a video of a run you have to set video: true explicitly; one file per spec is recorded into cypress/videos. The reason for the change is that recording and compressing slowed runs down noticeably, and for debugging Cypress Cloud gained Test Replay, which replays the DOM, network and console at the moment of failure.

Приклад налаштування для CI: скріншоти падінь увімкнені, відео — лише в нічному прогоні, а тека cypress/screenshots вивантажується як артефакт job-а, щоб розробник відкрив знімок, не перезапускаючи тест. Параметр trashAssetsBeforeRuns (типово true) очищає старі скріншоти й відео перед новим прогоном.

A CI setup example: failure screenshots on, video only in the nightly run, and the cypress/screenshots folder uploaded as a job artefact so a developer can open the image without re-running the test. The trashAssetsBeforeRuns option (true by default) clears old screenshots and videos before a new run.

Підводний камінь: у скріншоти і відео потрапляє все, що було на екрані, включно з персональними даними й токенами в інтерфейсі; артефакти CI мають обмежений доступ і строк зберігання. Друге — cy.screenshot не порівнює зображення; для візуальних перевірок потрібні окремі інструменти.

Pitfall: screenshots and videos capture everything on screen, including personal data and tokens shown in the UI; CI artefacts need restricted access and a retention limit. Second, cy.screenshot does not compare images; visual checks need separate tools.

Якщо спитають далі: як отримати скріншот у cypress open? Автоматичний скріншот при падінні робиться лише в run mode; в інтерактивному режимі замість нього є time travel по снапшотах у Command Log.

If they dig deeper: how do you get a screenshot in cypress open? The automatic failure screenshot is taken only in run mode; in interactive mode you have time travel through the snapshots in the Command Log instead.

Налаштування артефактів і ручний знімок елемента
Artefact settings and a manual element screenshot
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  screenshotOnRunFailure: true,
  video: Boolean(process.env.RECORD_VIDEO),   // з Cypress 13 типово false
  trashAssetsBeforeRuns: true,
  e2e: { baseUrl: 'http://localhost:3000' },
});

// invoice.cy.ts
it('renders the invoice header', () => {
  cy.visit('/invoices/42');
  cy.get('[data-cy=invoice-header]').screenshot('invoice-header', { overwrite: true });
});
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  screenshotOnRunFailure: true,
  video: Boolean(process.env.RECORD_VIDEO),   // false by default since Cypress 13
  trashAssetsBeforeRuns: true,
  e2e: { baseUrl: 'http://localhost:3000' },
});

// invoice.cy.ts
it('renders the invoice header', () => {
  cy.visit('/invoices/42');
  cy.get('[data-cy=invoice-header]').screenshot('invoice-header', { overwrite: true });
});
🔧 Пояснення синтаксису: Boolean(process.env.RECORD_VIDEO) вмикає відео лише тоді, коли змінну задано в CI; .screenshot(name, options) на елементі знімає тільки його; overwrite: true перезаписує файл із тим самим ім'ям замість створення копії з номером.
🔧 Syntax explained: Boolean(process.env.RECORD_VIDEO) turns video on only when the variable is set in CI; .screenshot(name, options) on an element captures just that element; overwrite: true replaces a file with the same name instead of creating a numbered copy.
💡 Простими словами: Скріншот при падінні робиться сам, відео з Cypress 13 треба вмикати явно, а для глибокого дебагу є Test Replay.
💡 In plain words: The failure screenshot is automatic, video has to be switched on explicitly since Cypress 13, and for deep debugging there is Test Replay.
🗣 Як читати вголос Я розповідаю, що в run mode Cypress сам знімає скріншот при падінні, і я вивантажую цю теку як артефакт CI, щоб розробник одразу бачив, що було на екрані. Про відео важливий свіжий факт: з тринадцятої версії воно вимкнене за замовчуванням, бо сповільнювало прогони, тож якщо хочеш відео, треба явно поставити video true. Я вмикаю його лише в нічних прогонах. І нагадую, що в артефакти потрапляють персональні дані з екрана, тож доступ до них і строк зберігання треба обмежити, як для будь-яких чутливих даних.
🗣 How to say it out loud I explain that in run mode Cypress takes a screenshot on failure by itself, and I upload that folder as a CI artefact so a developer immediately sees what was on screen. On video there is an important recent fact: since version thirteen it is off by default because it slowed runs down, so if you want video you have to set video true explicitly. I switch it on only for nightly runs. And I remind people that personal data from the screen ends up in the artefacts, so access to them and their retention have to be limited.
41 Як дебажити тест у Cypress: time travel, cy.pause(), .debug() і cy.log()? How do you debug a Cypress test: time travel, cy.pause(), .debug() and cy.log()?
Конфіг і раннерConfig and runnerJunior▶

В інтерактивному режимі (cypress open) ліворуч є Command Log — список усіх виконаних команд. Клік по команді показує снапшот DOM у той момент, для дій — стан до і після; це і є time travel. У консолі DevTools при цьому видно, що саме команда отримала і віддала: знайдені елементи, тіло відповіді, значення.

In interactive mode (cypress open) the left side shows the Command Log — a list of every command that ran. Clicking a command shows the DOM snapshot at that moment, and for actions the state before and after; that is time travel. The DevTools console then shows exactly what the command received and yielded: the found elements, the response body, the value.

Інструменти зупинки: cy.pause() зупиняє виконання черги в заданому місці, далі можна йти по одній команді кнопкою «Next» або продовжити. .debug() у ланцюжку виводить поточний subject у консоль і викликає debugger, якщо DevTools відкрито. Також можна поставити debugger усередині .then() — там уже є реальні значення.

Ways to stop: cy.pause() halts the queue at a given point, after which you can step one command at a time with the "Next" button or resume. .debug() in a chain prints the current subject to the console and calls debugger if DevTools is open. You can also put a debugger inside .then() — real values exist there.

Логи: cy.log('текст') додає рядок у Command Log — зручно для позначення кроків довгого сценарію. У терміналі CI cy.log не видно; щоб бачити повідомлення в логах CI, їх виводять через cy.task('log', ...). Для падінь у CI найкорисніші скріншоти, відео, якщо увімкнене, і Test Replay у Cypress Cloud, що дозволяє переглянути DOM, мережу і консоль так само, як у локальному раннері.

Logs: cy.log('text') adds a line to the Command Log — handy for marking the steps of a long scenario. cy.log is not visible in the CI terminal; to see messages in the CI logs, print them through cy.task('log', ...). For CI failures the most useful things are screenshots, video if it is on, and Test Replay in Cypress Cloud, which lets you inspect the DOM, network and console just like the local runner.

Підводний камінь: ставити debugger просто в тілі тесту поза ланцюжком марно — він спрацює під час побудови черги, коли ще жодна команда не виконалась. Друге — cy.pause(), забутий у коді, у cypress run ігнорується, але засмічує спек; його прибирають перед комітом, як і it.only.

Pitfall: putting debugger straight into the test body outside the chain is pointless — it fires while the queue is being built, before any command has run. Second, a cy.pause() left in the code is ignored in cypress run but clutters the spec; remove it before committing, like it.only.

Якщо спитають далі: чому в CI тест падає, а локально ні? Порівняти швидкість і розмір вікна, прогнати з тим самим браузером у headless-режимі, подивитися Test Replay або відео і перевірити, чи тест не залежить від порядку.

If they dig deeper: why does a test fail in CI but not locally? Compare speed and window size, run with the same browser in headless mode, look at Test Replay or the video, and check whether the test depends on order.

Точки зупинки в ланцюжку
Breakpoints inside the chain
it('applies a discount code', () => {
  cy.log('**step 1**: open the cart');
  cy.visit('/cart');

  cy.get('[data-cy=promo-input]').type('SPRING10');
  cy.pause();                                  // зупинитися і пройти далі по кроках
  cy.get('[data-cy=apply-promo]').click();

  cy.get('[data-cy=total]')
    .debug()                                   // subject у консоль і debugger
    .invoke('text')
    .then((text) => {
      debugger;                                // тут уже є справжнє значення
      expect(text).to.contain('10%');
    });

  cy.task('log', 'discount applied');          // видно в терміналі CI
});
it('applies a discount code', () => {
  cy.log('**step 1**: open the cart');
  cy.visit('/cart');

  cy.get('[data-cy=promo-input]').type('SPRING10');
  cy.pause();                                  // stop here and step through
  cy.get('[data-cy=apply-promo]').click();

  cy.get('[data-cy=total]')
    .debug()                                   // subject to the console plus debugger
    .invoke('text')
    .then((text) => {
      debugger;                                // the real value exists here
      expect(text).to.contain('10%');
    });

  cy.task('log', 'discount applied');          // visible in the CI terminal
});
🔧 Пояснення синтаксису: Зірочки в cy.log('**step 1**') роблять текст жирним у Command Log; cy.pause() зупиняє чергу; .debug() не змінює subject і передає його далі; debugger усередині .then спрацьовує вже під час виконання черги, з реальним значенням.
🔧 Syntax explained: The asterisks in cy.log('**step 1**') make the text bold in the Command Log; cy.pause() stops the queue; .debug() does not change the subject and passes it on; a debugger inside .then fires while the queue runs, with the real value.
💡 Простими словами: Command Log — це запис тесту, який можна перемотати назад; pause і debug — кнопки «стоп» у потрібному кадрі.
💡 In plain words: The Command Log is a recording of the test you can rewind; pause and debug are the stop buttons at the frame you need.
🗣 Як читати вголос Спершу я відкриваю тест у cypress open і користуюся time travel: клікаю по команді в Command Log і бачу сторінку в той момент, а в консолі — що саме команда знайшла. Якщо треба зупинитися, ставлю cy.pause і йду далі по кроках, або додаю debug у ланцюжок. Debugger пишу лише всередині then, бо поза ланцюжком він спрацює ще до виконання команд. Для CI у мене скріншоти, Test Replay, а важливі кроки я логую через cy.task, щоб вони з'явилися в терміналі, бо cy.log там не видно, він живе лише в раннері.
🗣 How to say it out loud First I open the test in cypress open and use time travel: I click a command in the Command Log and see the page at that moment, and in the console exactly what the command found. If I need to stop, I add cy.pause and step on from there, or I put debug into the chain. I only write debugger inside then, because outside the chain it fires before the commands have run. For CI I have screenshots and Test Replay, and I log the important steps through cy.task so they show up in the terminal, because cy.log is not visible there.
42 Як писати кастомні команди і правильно типізувати їх у TypeScript? How do you write custom commands and type them properly in TypeScript?
Конфіг і раннерConfig and runnerMiddle▶

Кастомна команда — власний метод на cy, який додають через Cypress.Commands.add(name, fn) у cypress/support/commands.ts. Команди бувають батьківські (починають ланцюжок: cy.login()), дочірні (працюють із subject попередньої команди: cy.get('form').fillForm(data), оголошуються з опцією prevSubject: 'element') і перевизначення вбудованих через Cypress.Commands.overwrite.

A custom command is your own method on cy, added with Cypress.Commands.add(name, fn) in cypress/support/commands.ts. Commands can be parent commands (starting a chain: cy.login()), child commands (working on the previous command's subject: cy.get('form').fillForm(data), declared with the prevSubject: 'element' option), or overrides of built-in ones via Cypress.Commands.overwrite.

Навіщо: щоб повторювані дії — логін, створення сутності через API, пошук за data-cy — мали одне ім'я і одне місце для змін. Тест читається як сценарій: cy.login('admin'), cy.createProduct(...), cy.getByCy('save').click().

Why: so that repeated actions — logging in, creating an entity through the API, finding by data-cy — have one name and one place to change. The test reads like a scenario: cy.login('admin'), cy.createProduct(...), cy.getByCy('save').click().

Типізація: TypeScript не знає про нові методи, доки їх не оголосити в інтерфейсі Cypress.Chainable через declare global. Тоді є автодоповнення, перевірка аргументів і правильний тип subject після команди. Файл із declare global має бути модулем — звідси export {} , якщо в ньому немає інших імпортів чи експортів.

Typing: TypeScript does not know about the new methods until they are declared on the Cypress.Chainable interface via declare global. Then you get autocompletion, argument checks and the right subject type after the command. The file with declare global must be a module — hence export {} if it has no other imports or exports.

Підводний камінь: команди, що лише обгортають одну вбудовану (cy.clickButton замість cy.get(...).click()), додають шар без користі. Друге — звичайна команда, створена через Commands.add, не повторюється як запит: якщо всередині cy.get і .then з логікою, повтору не буде. Для власних повторюваних запитів від Cypress 12 є Cypress.Commands.addQuery. Третє — переписувати type чи click через overwrite можна, але це змінює поведінку для всієї сюїти, тож робити це треба обережно і з документацією.

Pitfall: commands that merely wrap one built-in (cy.clickButton instead of cy.get(...).click()) add a layer with no benefit. Second, a regular command made with Commands.add is not retried like a query: if it contains cy.get and a .then with logic, there will be no retry. For your own retryable queries Cypress 12 added Cypress.Commands.addQuery. Third, overriding type or click with overwrite is possible, but it changes behaviour for the whole suite, so do it carefully and document it.

Якщо спитають далі: команда чи звичайна функція-хелпер? Хелпер простіший і не потребує типізації Chainable; команда дає красивий ланцюжок і логування в Command Log.

If they dig deeper: a command or a plain helper function? A helper is simpler and needs no Chainable typing; a command gives a nice chain and logging in the Command Log.

cypress/support/commands.ts з типами
cypress/support/commands.ts with types
export {};

declare global {
  namespace Cypress {
    interface Chainable {
      getByCy(id: string): Chainable<JQuery<HTMLElement>>;
      createProduct(name: string, price: number): Chainable<number>;
      fillForm(data: Record<string, string>): Chainable<JQuery<HTMLElement>>;
    }
  }
}

Cypress.Commands.add('getByCy', (id: string) => cy.get('[data-cy=' + id + ']'));

Cypress.Commands.add('createProduct', (name: string, price: number) =>
  cy.request('POST', '/api/test/products', { name, price }).its('body.id'),
);

Cypress.Commands.add('fillForm', { prevSubject: 'element' }, (form, data: Record<string, string>) => {
  Object.entries(data).forEach(([field, value]) => {
    cy.wrap(form).find('[name=' + field + ']').clear().type(value);
  });
  return cy.wrap(form);
});

// у тесті
cy.createProduct('Mug', 250).then((id) => cy.visit('/products/' + id));
cy.getByCy('checkout-form').fillForm({ city: 'Lviv', zip: '79000' });
export {};

declare global {
  namespace Cypress {
    interface Chainable {
      getByCy(id: string): Chainable<JQuery<HTMLElement>>;
      createProduct(name: string, price: number): Chainable<number>;
      fillForm(data: Record<string, string>): Chainable<JQuery<HTMLElement>>;
    }
  }
}

Cypress.Commands.add('getByCy', (id: string) => cy.get('[data-cy=' + id + ']'));

Cypress.Commands.add('createProduct', (name: string, price: number) =>
  cy.request('POST', '/api/test/products', { name, price }).its('body.id'),
);

Cypress.Commands.add('fillForm', { prevSubject: 'element' }, (form, data: Record<string, string>) => {
  Object.entries(data).forEach(([field, value]) => {
    cy.wrap(form).find('[name=' + field + ']').clear().type(value);
  });
  return cy.wrap(form);
});

// in a test
cy.createProduct('Mug', 250).then((id) => cy.visit('/products/' + id));
cy.getByCy('checkout-form').fillForm({ city: 'Lviv', zip: '79000' });
🔧 Пояснення синтаксису: declare global розширює глобальний простір імен Cypress; тип Chainable<number> каже, що після команди subject — число; prevSubject: 'element' робить команду дочірньою і передає subject першим аргументом; Object.entries перебирає пари поле-значення.
🔧 Syntax explained: declare global extends the global Cypress namespace; the Chainable<number> type says the subject after the command is a number; prevSubject: 'element' makes the command a child command and passes the subject as the first argument; Object.entries walks the field-value pairs.
💡 Простими словами: Кастомна команда — власне слово в словнику Cypress; тип у declare global — це запис цього слова в словник, щоб редактор його знав.
💡 In plain words: A custom command is your own word in the Cypress vocabulary; the type in declare global is the dictionary entry, so the editor knows the word.
🗣 Як читати вголос Кастомні команди я пишу для дій, що повторюються і мають сенс для бізнесу: логін, створення товару через API, пошук за data-cy. Обгортки над одним кліком не роблю, вони лише додають шар. У TypeScript обов'язково оголошую команду в інтерфейсі Chainable через declare global, тоді є автодоповнення і правильний тип subject після команди. Пам'ятаю, що звичайна команда не повторюється як запит, і для власного повторюваного пошуку з дванадцятої версії є addQuery. А перевизначення вбудованих команд роблю дуже обережно.
🗣 How to say it out loud I write custom commands for actions that repeat and mean something to the business: logging in, creating a product through the API, finding by data-cy. I do not wrap a single click, that only adds a layer. In TypeScript I always declare the command on the Chainable interface via declare global, then there is autocompletion and the right subject type after the command. I remember that a regular command is not retried like a query, and for my own retryable lookup there is addQuery since version twelve. And I override built-in commands only very carefully.
43 Що таке компонентне тестування в Cypress і чим воно відрізняється від e2e? What is component testing in Cypress, and how does it differ from e2e?
Конфіг і раннерConfig and runnerMiddle▶

Компонентний тест монтує один компонент — кнопку, форму, модалку — у справжньому браузері без решти застосунку, сервера і маршрутизації. Замість cy.visit використовують cy.mount(<Component />), а далі працюють ті самі команди: cy.get, click, should, cy.intercept. Cypress підтримує React, Vue, Angular і Svelte; збирає компонент той самий dev-сервер, що й проєкт (Vite або webpack), тому стилі й поведінка — як у застосунку.

A component test mounts a single component — a button, a form, a modal — in a real browser without the rest of the app, the server or routing. Instead of cy.visit you use cy.mount(<Component />), and then the same commands work: cy.get, click, should, cy.intercept. Cypress supports React, Vue, Angular and Svelte; the component is built by the same dev server as the project (Vite or webpack), so styles and behaviour match the app.

Чим відрізняється від e2e: e2e перевіряє користувацький сценарій через увесь стек — від сторінки до бази. Компонентний тест перевіряє контракт компонента: які пропси він приймає, що рендерить і які події віддає. Він запускається за секунди, не потребує стенда і даних, і добре покриває стани, яких важко досягти в e2e: помилки валідації, крайні значення, десятки варіантів пропсів.

How it differs from e2e: e2e checks a user journey through the whole stack — from the page to the database. A component test checks the component's contract: which props it accepts, what it renders and which events it emits. It runs in seconds, needs no environment or data, and covers states that are hard to reach in e2e: validation errors, edge values, dozens of prop combinations.

Приклад: форма оплати перевіряється компонентно на всі комбінації помилок валідації і на те, що onSubmit викликається з правильними даними, — через cy.stub(). А один e2e-тест перевіряє, що справжня оплата проходить від кошика до сторінки «Дякуємо».

Example: a payment form is tested at component level for every combination of validation errors and for onSubmit being called with the right data — through cy.stub(). And one e2e test checks that a real payment goes through from the cart to the "Thank you" page.

Підводний камінь: компонент, який залежить від глобального стану (store, роутер, i18n, тема), не змонтується без провайдерів. Їх додають у власну команду mount у cypress/support/component.ts, а не повторюють у кожному тесті. Друге — компонентний тест не замінює e2e: інтеграцію з бекендом і роутинг він не перевіряє. Третє — testIsolation і cy.session тут не застосовують так, як в e2e; кожен тест і так монтує компонент заново.

Pitfall: a component that depends on global state (a store, the router, i18n, a theme) will not mount without its providers. They are added in a custom mount command in cypress/support/component.ts rather than repeated in every test. Second, a component test does not replace e2e: it does not check backend integration or routing. Third, testIsolation and cy.session do not play the role they do in e2e; each test mounts the component afresh anyway.

Якщо спитають далі: чим відрізняється від юніт-тестів у Jest чи Vitest з jsdom? Тут справжній браузер, справжній CSS і взаємодії, а також інтерактивний раннер із time travel, тож видно, як компонент виглядає.

If they dig deeper: how is this different from unit tests in Jest or Vitest with jsdom? Here you have a real browser, real CSS and real interactions, plus the interactive runner with time travel, so you can see what the component looks like.

Компонентний тест форми на React
A component test of a React form
// cypress/support/component.ts
import { mount } from 'cypress/react';

declare global {
  namespace Cypress {
    interface Chainable {
      mount: typeof mount;
    }
  }
}
Cypress.Commands.add('mount', mount);

// src/components/PaymentForm.cy.tsx
import { PaymentForm } from './PaymentForm';

describe('<PaymentForm />', () => {
  it('shows an error for an expired card', () => {
    cy.mount(<PaymentForm onSubmit={cy.stub().as('submit')} />);
    cy.get('[data-cy=card-number]').type('4242424242424242');
    cy.get('[data-cy=expiry]').type('01/20');
    cy.get('[data-cy=pay]').click();
    cy.get('[data-cy=expiry-error]').should('be.visible');
    cy.get('@submit').should('not.have.been.called');
  });

  it('submits valid card data', () => {
    cy.mount(<PaymentForm onSubmit={cy.stub().as('submit')} />);
    cy.get('[data-cy=card-number]').type('4242424242424242');
    cy.get('[data-cy=expiry]').type('12/30');
    cy.get('[data-cy=pay]').click();
    cy.get('@submit').should('have.been.calledOnceWith', { card: '4242424242424242', expiry: '12/30' });
  });
});
🔧 Пояснення синтаксису: mount з пакета cypress/react реєструється як команда cy.mount; тип typeof mount переносить сигнатуру в Chainable; cy.stub().as('submit') створює функцію-шпигуна для пропса, а calledOnceWith перевіряє аргументи виклику; файли мають розширення .cy.tsx, запуск — npx cypress run --component.
🔧 Syntax explained: mount from the cypress/react package is registered as the cy.mount command; the typeof mount type carries the signature onto Chainable; cy.stub().as('submit') creates a spy function for the prop, and calledOnceWith checks the call arguments; the files use the .cy.tsx extension and run with npx cypress run --component.
💡 Простими словами: e2e перевіряє всю виставу, компонентний тест — одного актора на порожній сцені, зате з усіх ракурсів.
💡 In plain words: e2e checks the whole play; a component test checks one actor on an empty stage, but from every angle.
🗣 Як читати вголос Я пояснюю, що компонентний тест монтує один компонент у справжньому браузері, без сервера і без решти застосунку, і далі я працюю тими самими командами Cypress. Він швидкий, тому ним зручно перебирати десятки станів, наприклад усі помилки валідації форми оплати, а колбеки я підміняю стабом і перевіряю, з якими даними їх викликали. E2E при цьому лишається для наскрізного сценарію від кошика до підтвердження. Провайдери, як store чи роутер, я додаю один раз у власну команду mount, щоб не дублювати їх у кожному тесті.
🗣 How to say it out loud I explain that a component test mounts one component in a real browser, with no server and none of the rest of the app, and then I work with the same Cypress commands. It is fast, so it is handy for going through dozens of states, like every validation error of a payment form, and I replace callbacks with a stub and check which data they were called with. E2E still covers the end-to-end journey from the cart to the confirmation. Providers like the store or the router I add once in my own mount command, so I do not repeat them in every test.
▶ 7 5
44 Page Object, кастомні команди чи App Actions: як організувати код великої сюїти на Cypress? Page Objects, custom commands or App Actions: how do you organise the code of a large Cypress suite?
Патерни й CIPatterns and CISenior▶

Це три інструменти для різних задач, а не взаємовиключні школи. Page Object — клас або модуль, що тримає селектори й складені дії однієї сторінки чи компонента. Кастомні команди — наскрізні дії, потрібні будь-якій сторінці: логін, створення даних через API, пошук за data-cy. App Actions — ідея з блогу Cypress: готувати стан, викликаючи внутрішні функції застосунку через window або API, а не проклацуючи інтерфейс.

These are three tools for different jobs, not rival schools. A Page Object is a class or module holding the selectors and composite actions of one page or component. Custom commands are cross-cutting actions any page needs: logging in, creating data through the API, finding by data-cy. App Actions are an idea from the Cypress blog: prepare state by calling the app's internal functions through window or the API, instead of clicking through the UI.

Чому в Cypress Page Object виглядає інакше: класичний Selenium-підхід з ієрархією класів, що зберігають елементи в полях, тут не працює — елемент не можна зберегти, він живе лише в ланцюжку. Тому Page Object у Cypress — це методи, що повертають Chainable або виконують дію, без стану. Глибокі ієрархії наслідування (BasePage → AuthPage → LoginPage) ускладнюють читання і зазвичай не окупаються.

Why a Page Object looks different in Cypress: the classic Selenium approach, a class hierarchy that stores elements in fields, does not work here — an element cannot be stored, it lives only in the chain. So a Cypress Page Object is a set of methods that return a Chainable or perform an action, with no state. Deep inheritance hierarchies (BasePage → AuthPage → LoginPage) make the code harder to read and rarely pay off.

Практичний розподіл: через UI тест проходить лише ту частину, яку перевіряє; усе, що до неї веде, готується через API або App Actions. Селектори й дії конкретного екрана живуть у легкому модулі сторінки, наскрізне — у командах. Так тест кошика не ламається, коли змінилася форма реєстрації, бо він не реєструється через форму.

A practical split: a test goes through the UI only for the part it checks; everything leading up to it is prepared through the API or App Actions. The selectors and actions of a specific screen live in a light page module, and cross-cutting things in commands. That way the cart test does not break when the sign-up form changes, because it does not sign up through the form.

Підводний камінь: App Actions пов'язують тести з внутрішньою реалізацією фронтенду — перейменували метод store, і сотня тестів падає, хоча користувач нічого не помітив. Тому їх використовують лише для підготовки, а тестовий доступ до window вмикають тільки в тестовій збірці. Друга крайність — «команда на все»: 80 кастомних команд без структури стають такою самою кашею, як Page Object на 2000 рядків.

Pitfall: App Actions tie the tests to the frontend's internals — rename a store method and a hundred tests fail although the user noticed nothing. So they are used only for setup, and test access through window is enabled only in the test build. The opposite extreme is "a command for everything": 80 custom commands with no structure end up as messy as a 2,000-line Page Object.

Якщо спитають далі: як це масштабувати на кілька команд розробки? Спільна бібліотека команд і утиліт у пакеті або монорепо, власник у кожного модуля сторінки і правило «новий селектор — лише data-cy» у код-рев'ю.

If they dig deeper: how do you scale this across several development teams? A shared library of commands and utilities in a package or a monorepo, an owner for each page module, and a code-review rule that new selectors must be data-cy.

Легкий модуль сторінки плюс підготовка через API
A light page module plus setup through the API
// cypress/pages/cart.ts — без стану, лише селектори й дії
export const cartPage = {
  visit: () => cy.visit('/cart'),
  rows: () => cy.get('[data-cy=cart-row]'),
  total: () => cy.get('[data-cy=cart-total]'),
  applyPromo(code: string) {
    cy.get('[data-cy=promo-input]').type(code);
    cy.get('[data-cy=apply-promo]').click();
  },
};

// cypress/e2e/cart/promo.cy.ts
import { cartPage } from '../../pages/cart';

it('applies a 10% promo code', () => {
  cy.login('customer');                                   // команда: наскрізна дія
  cy.request('POST', '/api/test/cart', { sku: 'MUG-1', qty: 2, price: 250 });  // підготовка через API

  cartPage.visit();
  cartPage.applyPromo('SPRING10');
  cartPage.total().should('have.text', '450.00');
});
// cypress/pages/cart.ts — no state, just selectors and actions
export const cartPage = {
  visit: () => cy.visit('/cart'),
  rows: () => cy.get('[data-cy=cart-row]'),
  total: () => cy.get('[data-cy=cart-total]'),
  applyPromo(code: string) {
    cy.get('[data-cy=promo-input]').type(code);
    cy.get('[data-cy=apply-promo]').click();
  },
};

// cypress/e2e/cart/promo.cy.ts
import { cartPage } from '../../pages/cart';

it('applies a 10% promo code', () => {
  cy.login('customer');                                   // command: a cross-cutting action
  cy.request('POST', '/api/test/cart', { sku: 'MUG-1', qty: 2, price: 250 });  // setup through the API

  cartPage.visit();
  cartPage.applyPromo('SPRING10');
  cartPage.total().should('have.text', '450.00');
});
🔧 Пояснення синтаксису: Об'єкт cartPage з функціями, що повертають Chainable, — Page Object без полів з елементами; cy.login — кастомна команда; кошик наповнюється через cy.request, тож UI тест проходить лише в тій частині, яку перевіряє.
🔧 Syntax explained: The cartPage object with functions that return a Chainable is a Page Object with no element fields; cy.login is a custom command; the cart is filled through cy.request, so the test walks the UI only for the part it checks.
💡 Простими словами: Page Object — адреса й інструкція до однієї кімнати, команди — ключі, що підходять до всіх дверей, App Actions — службовий вхід для підготовки.
💡 In plain words: A Page Object is the address and manual for one room, commands are keys that fit every door, and App Actions are the service entrance used for setup.
🗣 Як читати вголос Я не протиставляю ці підходи. Легкі модулі сторінок у мене тримають селектори й дії одного екрана, без жодного збереженого стану, бо в Cypress елемент усе одно не збережеш. Кастомні команди — для наскрізного, як логін чи створення даних через API. А головний принцип такий: через інтерфейс тест проходить лише ту частину, яку перевіряє, решту готую через API. App Actions використовую обережно і лише для підготовки, бо вони прив'язують тести до внутрішнього коду фронтенду, і перейменування методу може зламати сотню тестів.
🗣 How to say it out loud I do not set these approaches against each other. My light page modules hold the selectors and actions of one screen, with no saved state, because in Cypress you cannot store an element anyway. Custom commands are for cross-cutting things like logging in or creating data through the API. And the main principle is that a test goes through the UI only for the part it checks, and the rest I prepare through the API. I use App Actions carefully and only for setup, because they tie tests to the frontend's internal code, and renaming one method can break a hundred tests.
45 Як системно боротися з нестабільними тестами в сюїті на Cypress? How do you tackle flaky tests in a Cypress suite systematically?
Патерни й CIPatterns and CISenior▶

Нестабільний тест — той, що на тому самому коді то проходить, то падає. Боротьба з ним — це процес, а не окремі виправлення: виявити, ізолювати, знайти корінь, виправити і не допустити нових. Найдорожче у флейках не час прогону, а втрата довіри: команда звикає перезапускати червоний CI і пропускає справжній баг.

A flaky test is one that passes and fails on the same code. Dealing with flakes is a process, not a set of one-off fixes: detect, isolate, find the root cause, fix, and prevent new ones. The most expensive part of a flake is not run time but lost trust: the team gets used to re-running a red CI and misses a real bug.

Виявлення: ретраї в run mode з позначкою тестів, що пройшли не з першої спроби; аналітика нестабільності в Cypress Cloud або власний збір результатів у JUnit-звітах; burn-in для нових і змінених спеків — прогнати їх 10–20 разів поспіль перед злиттям (наприклад, через @cypress/grep з опцією burn).

Detection: retries in run mode that flag tests which passed on a later attempt; flakiness analytics in Cypress Cloud, or your own collection of results from JUnit reports; burn-in for new and changed specs — run them 10–20 times in a row before merging (for example, through @cypress/grep with its burn option).

Типові корені в Cypress: фіксовані паузи й відсутність очікування на мережу; анімації; залежність від порядку й спільних даних; умовне тестування; нестабільні дані на спільному стенді; справжні гонки в застосунку. Для кожного є шаблонний фікс: аліас замість cy.wait(ms), централізоване вимкнення анімацій, дані через API в beforeEach, керований стан замість if, унікальні сутності на тест.

Typical root causes in Cypress: fixed pauses and not waiting on the network; animations; dependence on order and shared data; conditional testing; unstable data on a shared environment; real races in the app. Each has a standard fix: an alias instead of cy.wait(ms), animations switched off centrally, data created through the API in beforeEach, controlled state instead of an if, unique entities per test.

Процес: флейк отримує тікет і власника; якщо його не виправити швидко, тест переносять у карантин — тегом виключають із блокувального прогону, але продовжують запускати в окремому, щоб бачити динаміку. Карантин має строк, інакше він стає кладовищем. Метрика — частка нестабільних прогонів за тиждень, яку показують команді.

Process: a flake gets a ticket and an owner; if it cannot be fixed quickly, the test goes into quarantine — a tag excludes it from the blocking run, but it keeps running in a separate job so the trend stays visible. Quarantine has a deadline, otherwise it becomes a graveyard. The metric is the share of flaky runs per week, shown to the team.

Підводний камінь: звинувачувати інструмент і масово піднімати тайм-аути та ретраї. Це маскує симптом і часто ховає реальні гонки в застосунку, які користувачі теж бачать. Якщо спитають далі: як відрізнити флейк тесту від флейку застосунку? Test Replay чи відео падіння, логи бекенду з тим самим request-id і відтворення в циклі локально з обмеженим CPU.

Pitfall: blaming the tool and raising timeouts and retries across the board. That masks the symptom and often hides real races in the app that users see too. If they dig deeper: how do you tell a test flake from an app flake? Test Replay or the failure video, backend logs for the same request-id, and reproducing it in a loop locally with a throttled CPU.

Burn-in нового спека і карантин тегом
A burn-in for a new spec and a quarantine tag
// тест у карантині: тег виключає його з блокувального прогону
it('syncs the cart between tabs', { tags: ['@quarantine'] }, () => {
  // QA-1423: гонка в оновленні кошика, власник — команда Checkout
});

// package.json scripts
// блокувальний прогін без карантину
// "cy:ci":         "cypress run --env grepTags=-@quarantine"
// карантин окремо, не блокує злиття
// "cy:quarantine": "cypress run --env grepTags=@quarantine"
// burn-in: змінений спек 15 разів поспіль
// "cy:burn":       "cypress run --spec cypress/e2e/cart/sync.cy.ts --env burn=15"
// a quarantined test: the tag excludes it from the blocking run
it('syncs the cart between tabs', { tags: ['@quarantine'] }, () => {
  // QA-1423: race in the cart update, owned by the Checkout team
});

// package.json scripts
// the blocking run without quarantine
// "cy:ci":         "cypress run --env grepTags=-@quarantine"
// quarantine on its own, never blocks a merge
// "cy:quarantine": "cypress run --env grepTags=@quarantine"
// burn-in: the changed spec 15 times in a row
// "cy:burn":       "cypress run --spec cypress/e2e/cart/sync.cy.ts --env burn=15"
🔧 Пояснення синтаксису: Поле tags у конфігурації тесту читає плагін @cypress/grep; grepTags=-@quarantine виключає тег, grepTags=@quarantine запускає лише його; burn=15 повторює вибрані тести 15 разів, щоб виявити нестабільність до злиття.
🔧 Syntax explained: The tags field of the test config is read by the @cypress/grep plugin; grepTags=-@quarantine excludes the tag and grepTags=@quarantine runs only that tag; burn=15 repeats the selected tests 15 times to expose flakiness before merging.
💡 Простими словами: Флейк — це не невдача, а незакритий тікет: його знаходять, дають власника, ізолюють і виправляють корінь, а не піднімають тайм-аут.
💡 In plain words: A flake is not bad luck but an open ticket: you find it, give it an owner, isolate it and fix the root cause instead of raising a timeout.
🗣 Як читати вголос Я описую це як процес із чотирьох кроків. Виявити: ретраї позначають тести, що пройшли не з першої спроби, а нові спеки я проганяю burn-in по п'ятнадцять разів. Ізолювати: у флейка є тікет і власник, а якщо швидко не виправити, тег карантину виключає його з блокувального прогону, але він крутиться окремо. Виправити корінь: у Cypress це майже завжди пауза замість аліаса, анімації, спільні дані або умовна логіка. І стежити за метрикою. Чого я не роблю, то це масово не піднімаю тайм-аути, бо так ховаються справжні гонки.
🗣 How to say it out loud I describe it as a four-step process. Detect: retries flag tests that passed on a later attempt, and I burn in new specs fifteen times. Isolate: a flake gets a ticket and an owner, and if it cannot be fixed quickly, a quarantine tag takes it out of the blocking run while it keeps running separately. Fix the root cause: in Cypress that is almost always a pause instead of an alias, animations, shared data or conditional logic. And watch the metric. What I do not do is raise timeouts across the board, because that is how real race conditions get hidden.
46 Як запускати Cypress у GitHub Actions через cypress-io/github-action? How do you run Cypress in GitHub Actions with cypress-io/github-action?
Патерни й CIPatterns and CIMiddle▶

cypress-io/github-action — офіційна дія від команди Cypress. Вона встановлює залежності з кешуванням npm і бінарника Cypress, за потреби збирає застосунок (build), запускає його у фоні (start), чекає, поки він відповідатиме (wait-on), і виконує cypress run. Без неї все це доводиться писати вручну кількома кроками.

cypress-io/github-action is the official action from the Cypress team. It installs dependencies with caching of npm and the Cypress binary, builds the app if needed (build), starts it in the background (start), waits until it responds (wait-on), and runs cypress run. Without it you have to write all of that by hand in several steps.

Навіщо знати параметри: browser вибирає браузер (типово Electron), spec обмежує набір спеків, component: true запускає компонентні тести, config перевизначає параметри конфігу, record і parallel вмикають запис у Cypress Cloud і паралельний запуск. Секрети передають через env зі сховища секретів репозиторію.

Why know the inputs: browser picks the browser (Electron by default), spec limits the set of specs, component: true runs component tests, config overrides config options, and record and parallel turn on recording to Cypress Cloud and parallel runs. Secrets are passed through env from the repository's secrets store.

Приклад: job на кожен PR збирає фронтенд, піднімає його на localhost:3000, чекає відповіді й проганяє e2e у Chrome. Якщо тести впали, тека cypress/screenshots вивантажується як артефакт через actions/upload-artifact, щоб розробник подивився знімки падіння.

Example: a job on every PR builds the frontend, serves it on localhost:3000, waits for a response and runs the e2e tests in Chrome. If tests fail, the cypress/screenshots folder is uploaded as an artefact through actions/upload-artifact, so a developer can look at the failure screenshots.

Підводний камінь: найчастіше CI падає не через тести, а через старт застосунку — wait-on не дочекався сервера за типові 60 секунд, або застосунок слухає інший хост чи порт. Друге — завантаження артефактів треба ставити з умовою if: failure(), інакше після падіння крок буде пропущено. Третє — версію дії фіксують на мажорній (@v6) і оновлюють свідомо, звіряючись із README.

Pitfall: CI most often fails not because of the tests but because of the app start-up — wait-on did not see the server within the default 60 seconds, or the app listens on another host or port. Second, the artefact upload step needs an if: failure() condition, otherwise it is skipped after a failure. Third, pin the action to a major version (@v6) and upgrade deliberately, checking the README.

Якщо спитають далі: як прискорити? Кешування вже є в дії; далі — паралелізація через матрицю (Cypress Cloud або ручне розбиття спеків), окремі job-и для компонентних і e2e-тестів, і запуск лише змінених спеків на PR із повним прогоном на main.

If they dig deeper: how do you speed it up? Caching is already in the action; beyond that — parallel runs through a matrix (Cypress Cloud or manual spec splitting), separate jobs for component and e2e tests, and running only the changed specs on a PR with a full run on main.

.github/workflows/e2e.yml
.github/workflows/e2e.yml
name: e2e
on: [pull_request]

jobs:
  cypress:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: cypress-io/github-action@v6
        with:
          build: npm run build
          start: npm run preview -- --port 3000
          wait-on: 'http://localhost:3000'
          wait-on-timeout: 120
          browser: chrome
        env:
          CYPRESS_TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
      - uses: actions/upload-artifact@v4
        if: failure()    # інакше крок пропуститься після падіння
        with:
          name: cypress-screenshots
          path: cypress/screenshots
name: e2e
on: [pull_request]

jobs:
  cypress:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: cypress-io/github-action@v6
        with:
          build: npm run build
          start: npm run preview -- --port 3000
          wait-on: 'http://localhost:3000'
          wait-on-timeout: 120
          browser: chrome
        env:
          CYPRESS_TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
      - uses: actions/upload-artifact@v4
        if: failure()    # otherwise the step is skipped after a failure
        with:
          name: cypress-screenshots
          path: cypress/screenshots
🔧 Пояснення синтаксису: build, start і wait-on — вхідні параметри дії, що збирають, запускають і чекають застосунок; wait-on-timeout задається в секундах; змінна з префіксом CYPRESS_ потрапляє в Cypress.env; if: failure() виконує крок лише після падіння попередніх.
🔧 Syntax explained: build, start and wait-on are action inputs that build, start and wait for the app; wait-on-timeout is in seconds; a variable with the CYPRESS_ prefix ends up in Cypress.env; if: failure() runs the step only after an earlier one has failed.
💡 Простими словами: Офіційна дія робить за тебе рутину CI — встановити, зібрати, запустити, дочекатися і прогнати — лишається налаштувати параметри.
💡 In plain words: The official action does the CI routine for you — install, build, start, wait and run — and you only tune the inputs.
🗣 Як читати вголос Для GitHub Actions я беру офіційну дію cypress-io/github-action. Вона кешує залежності і сам бінарник, збирає застосунок, запускає його у фоні, чекає через wait-on і проганяє тести, тож мій workflow виходить на кілька рядків. Секрети передаю через env зі сховища, з префіксом CYPRESS_. Скріншоти падінь вивантажую артефактом з умовою if failure, бо без неї крок просто пропуститься. А коли CI падає ще до тестів, я першим ділом дивлюся на старт застосунку і таймаут wait-on, бо це найчастіша причина.
🗣 How to say it out loud For GitHub Actions I take the official cypress-io/github-action. It caches dependencies and the binary itself, builds the app, starts it in the background, waits with wait-on and runs the tests, so my workflow comes to a few lines. I pass secrets through env from the secrets store, with the CYPRESS_ prefix. I upload failure screenshots as an artefact with the if failure condition, because without it the step is simply skipped. And when CI fails before the tests even run, the first thing I look at is the app start-up and the wait-on timeout, because that is the most common cause.
47 Як паралелити Cypress: Cypress Cloud, Currents, Sorry Cypress чи розбиття спеків у CI? How do you parallelise Cypress: Cypress Cloud, Currents, Sorry Cypress or splitting specs in CI?
Патерни й CIPatterns and CISenior▶

Сам раннер Cypress виконує спеки послідовно в одному процесі, вбудованої паралелізації, як у Playwright, у нього немає. Паралельний запуск означає кілька CI-машин (контейнерів), кожна з яких бере свою частину спеків. Питання лише в тому, хто вирішує, яка машина що запускає.

The Cypress runner itself executes specs one after another in a single process; it has no built-in parallelisation like Playwright does. Running in parallel means several CI machines (containers), each taking its share of specs. The only question is who decides which machine runs what.

Cypress Cloud — офіційний платний сервіс: cypress run --record --parallel на кількох машинах, і Cloud роздає спеки динамічно за історичною тривалістю, щоб усі машини закінчили приблизно одночасно. Разом із цим — Test Replay, аналітика нестабільних тестів, пріоритезація спеків, що падали, автоматичне скасування прогону. Є безкоштовний план з обмеженням кількості результатів тестів на місяць.

Cypress Cloud is the official paid service: cypress run --record --parallel on several machines, and Cloud hands out specs dynamically based on their past durations so all machines finish at about the same time. Along with that come Test Replay, flaky-test analytics, prioritising specs that failed before, and automatic cancellation of a run. There is a free plan with a monthly limit on test results.

Важливий факт про сторонні дашборди: Sorry Cypress і Currents колись працювали, підміняючи адресу API Cypress Cloud. Починаючи з Cypress 13, раннер блокує запис у такі сторонні сервіси. Sorry Cypress із новими версіями фактично не працює, і «безкоштовна альтернатива» означає фіксацію на старому Cypress 12 без нових можливостей, виправлень і підтримки браузерів — це ризик, а не економія. Currents зробив власну інтеграцію (свій npm-пакет, що запускає Cypress через Module API і сам розподіляє спеки), тож працює з актуальними версіями, але це теж платний сервіс. Модифікувати бінарник Cypress, щоб обійти блокування, не варто — це крихко і суперечить умовам використання.

An important fact about third-party dashboards: Sorry Cypress and Currents used to work by substituting the Cypress Cloud API address. Starting with Cypress 13, the runner blocks recording to such third-party services. Sorry Cypress effectively does not work with the new versions, and the "free alternative" means being stuck on an old Cypress 12 without new features, fixes or browser support — that is a risk, not a saving. Currents built its own integration (its own npm package that launches Cypress through the Module API and distributes specs itself), so it works with current versions, but it is a paid service too. Patching the Cypress binary to get round the block is not worth it — it is fragile and goes against the terms of use.

Без сервісу: спеки ділять самі — матриця GitHub Actions, де кожен job отримує свою групу спеків через параметр spec, або плагін cypress-split, що ділить спеки за індексом машини і вміє враховувати тривалість. Мінуси: ручне балансування, окреме злиття звітів (наприклад, JUnit або mochawesome), жодного Test Replay.

Without a service: you split the specs yourself — a GitHub Actions matrix where each job gets its own group of specs through the spec input, or the cypress-split plugin, which divides specs by machine index and can take durations into account. The downsides: manual balancing, merging reports separately (JUnit or mochawesome, say), and no Test Replay.

Підводний камінь: паралелізація ламає тести, що ділять стан — спільного користувача чи спільний запис у базі; кожна машина має працювати на своїх даних. Якщо спитають далі: як вибрати? Порахувати вартість хвилин CI і часу розробників на дебаг проти ціни Cloud; для малої сюїти досить матриці.

Pitfall: parallel runs break tests that share state — a shared user or a shared database record; each machine must work on its own data. If they dig deeper: how do you choose? Weigh the cost of CI minutes and developer debugging time against the price of Cloud; for a small suite a matrix is enough.

Розбиття спеків матрицею GitHub Actions без стороннього сервісу
Splitting specs with a GitHub Actions matrix and no third-party service
name: e2e-split
on: [pull_request]

jobs:
  cypress:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false        # інші групи доганяються, навіть якщо одна впала
      matrix:
        group:
          - { name: auth,     spec: 'cypress/e2e/auth/**/*.cy.ts' }
          - { name: cart,     spec: 'cypress/e2e/cart/**/*.cy.ts' }
          - { name: checkout, spec: 'cypress/e2e/checkout/**/*.cy.ts' }
          - { name: admin,    spec: 'cypress/e2e/admin/**/*.cy.ts' }
    steps:
      - uses: actions/checkout@v4
      - uses: cypress-io/github-action@v6
        with:
          start: npm start
          wait-on: 'http://localhost:3000'
          spec: ${{ matrix.group.spec }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: results-${{ matrix.group.name }}
          path: cypress/results

# з Cypress Cloud замість груп: однакові контейнери і динамічний розподіл
#   matrix: { containers: [1, 2, 3, 4] }
#   with: { record: true, parallel: true }
#   env:  { CYPRESS_RECORD_KEY: <secret> }
name: e2e-split
on: [pull_request]

jobs:
  cypress:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false        # other groups finish even if one fails
      matrix:
        group:
          - { name: auth,     spec: 'cypress/e2e/auth/**/*.cy.ts' }
          - { name: cart,     spec: 'cypress/e2e/cart/**/*.cy.ts' }
          - { name: checkout, spec: 'cypress/e2e/checkout/**/*.cy.ts' }
          - { name: admin,    spec: 'cypress/e2e/admin/**/*.cy.ts' }
    steps:
      - uses: actions/checkout@v4
      - uses: cypress-io/github-action@v6
        with:
          start: npm start
          wait-on: 'http://localhost:3000'
          spec: ${{ matrix.group.spec }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: results-${{ matrix.group.name }}
          path: cypress/results

# with Cypress Cloud instead of groups: identical containers and dynamic balancing
#   matrix: { containers: [1, 2, 3, 4] }
#   with: { record: true, parallel: true }
#   env:  { CYPRESS_RECORD_KEY: <secret> }
🔧 Пояснення синтаксису: strategy.matrix.group створює чотири паралельні job-и, кожен зі своїм spec-шаблоном; fail-fast: false не скасовує решту груп після першого падіння; if: always() вивантажує результати за будь-якого статусу для подальшого злиття звітів; закоментований блок показує, як виглядає той самий job із Cypress Cloud.
🔧 Syntax explained: strategy.matrix.group creates four parallel jobs, each with its own spec pattern; fail-fast: false does not cancel the other groups after the first failure; if: always() uploads the results whatever the status, so the reports can be merged later; the commented block shows how the same job looks with Cypress Cloud.
💡 Простими словами: Паралелізація в Cypress — це кілька машин і хтось, хто роздає їм спеки: або платний сервіс, який робить це розумно, або ти сам через матрицю.
💡 In plain words: Parallelism in Cypress means several machines plus someone handing out specs: either a paid service that does it smartly, or you yourself through a matrix.
🗣 Як читати вголос Я одразу кажу, що в Cypress немає вбудованої паралелізації, це завжди кілька машин і розподіл спеків між ними. Офіційний шлях — Cypress Cloud, він балансує спеки за тривалістю і дає Test Replay. Про Sorry Cypress я чесно попереджаю: з тринадцятої версії Cypress блокує такі сторонні дашборди, тож це означає застрягти на старій версії. Currents зробив власну інтеграцію і працює з новими версіями, але він теж платний. Без сервісу я ділю спеки матрицею GitHub Actions або плагіном cypress-split і окремо зливаю звіти. І стежу, щоб паралельні машини не ділили одних і тих самих тестових даних.
🗣 How to say it out loud I say up front that Cypress has no built-in parallelism; it is always several machines plus a way to split specs between them. The official route is Cypress Cloud, which balances specs by duration and gives you Test Replay. About Sorry Cypress I warn honestly: since version thirteen Cypress blocks third-party dashboards like that, so using it means being stuck on an old version. Currents built its own integration and works with new versions, but it is paid too. Without a service I split specs with a GitHub Actions matrix or the cypress-split plugin and merge the reports separately. And I make sure parallel machines do not share the same test data.
48 Які є варіанти візуального тестування з Cypress і на чому вони ламаються? What are the options for visual testing with Cypress, and where do they break?
Патерни й CIPatterns and CIMiddle▶

Візуальний тест порівнює знімок сторінки або компонента з еталонним і падає, якщо різниця перевищує поріг. Він ловить те, чого не бачать функціональні асерти: з'їхав відступ, зник іконковий шрифт, текст налазить на кнопку, змінився колір. Вбудованого порівняння зображень у Cypress немає: cy.screenshot лише знімає.

A visual test compares a snapshot of a page or component with a baseline and fails if the difference exceeds a threshold. It catches what functional assertions miss: a shifted margin, a missing icon font, text overlapping a button, a changed colour. Cypress has no built-in image comparison: cy.screenshot only takes the picture.

Варіанти: хмарні сервіси — Percy (@percy/cypress, команда cy.percySnapshot), Applitools Eyes з «розумним» порівнянням, Chromatic з інтеграцією для Cypress; вони рендерять знімки в стабільному середовищі, мають інтерфейс рев'ю і схвалення змін. Open-source плагіни — cypress-image-diff-js, cypress-visual-regression — порівнюють cy.screenshot з еталоном у репозиторії попіксельно, безкоштовно, але з усією інфраструктурою на тобі.

Options: cloud services — Percy (@percy/cypress, the cy.percySnapshot command), Applitools Eyes with "smart" comparison, Chromatic with a Cypress integration; they render snapshots in a stable environment and offer a UI for reviewing and approving changes. Open-source plugins — cypress-image-diff-js, cypress-visual-regression — compare cy.screenshot with a baseline kept in the repository pixel by pixel, for free, but with all of the infrastructure on you.

Приклад: для сторінки тарифів робимо знімок на двох ширинах після того, як застабили ціни фікстурою і зафіксували дату через cy.clock. У PR сервіс показує дифф, і дизайнер або QA схвалює зміну як очікувану або відхиляє як регресію.

Example: for the pricing page, take a snapshot at two widths after stubbing the prices with a fixture and pinning the date with cy.clock. In the PR the service shows the diff, and a designer or QA approves the change as expected or rejects it as a regression.

Підводний камінь: найчастіша причина хибних падінь — нестабільний рендер: інший шрифт на macOS і Linux, згладжування, анімації, динамічні дані (дата, аватар, реклама), не довантажені зображення. Лікується однаковим середовищем (Docker-образ або хмара), стабами і годинником, вимкненими анімаціями, прихованими динамічними ділянками. Друге — знімати всю сторінку на кожному тесті дорого і шумно; краще точкові знімки ключових станів.

Pitfall: the most common cause of false failures is unstable rendering: different fonts on macOS and Linux, anti-aliasing, animations, dynamic data (a date, an avatar, ads), images that have not finished loading. The cure is an identical environment (a Docker image or the cloud), stubs and the clock, disabled animations and hidden dynamic regions. Second, snapshotting the whole page in every test is expensive and noisy; targeted snapshots of key states work better.

Якщо спитають далі: хто оновлює еталони? Лише через рев'ю: локальне «прийняти всі зміни» без перегляду зводить візуальні тести нанівець.

If they dig deeper: who updates the baselines? Only through review: a local "accept all changes" without looking defeats the purpose of visual tests.

Стабільний візуальний знімок у Percy
A stable visual snapshot in Percy
// cypress/support/e2e.ts
import '@percy/cypress';

// pricing.visual.cy.ts
it('pricing page looks right', () => {
  cy.clock(new Date(2025, 0, 15).getTime(), ['Date']);            // дата не «пливе»
  cy.intercept('GET', '/api/plans', { fixture: 'plans.json' }).as('plans');
  cy.visit('/pricing');
  cy.wait('@plans');
  cy.get('[data-cy=plan-card]').should('have.length', 3);          // сторінка домальована
  cy.percySnapshot('Pricing', {
    widths: [390, 1280],
    percyCSS: '[data-cy=live-chat] { visibility: hidden; }',     // сховати динамічну ділянку
  });
});

// запуск: npx percy exec -- cypress run
// cypress/support/e2e.ts
import '@percy/cypress';

// pricing.visual.cy.ts
it('pricing page looks right', () => {
  cy.clock(new Date(2025, 0, 15).getTime(), ['Date']);            // the date does not drift
  cy.intercept('GET', '/api/plans', { fixture: 'plans.json' }).as('plans');
  cy.visit('/pricing');
  cy.wait('@plans');
  cy.get('[data-cy=plan-card]').should('have.length', 3);          // the page has finished rendering
  cy.percySnapshot('Pricing', {
    widths: [390, 1280],
    percyCSS: '[data-cy=live-chat] { visibility: hidden; }',     // hide a dynamic region
  });
});

// run with: npx percy exec -- cypress run
🔧 Пояснення синтаксису: Імпорт @percy/cypress реєструє команду cy.percySnapshot; cy.clock(..., ['Date']) фіксує лише дату, не зупиняючи таймери; асерт на кількість карток гарантує, що знімок зроблено після рендеру; widths задає ширини рендеру, а percyCSS додає стиль лише для знімка.
🔧 Syntax explained: Importing @percy/cypress registers the cy.percySnapshot command; cy.clock(..., ['Date']) pins only the date without stopping timers; the assertion on the number of cards ensures the snapshot is taken after rendering; widths sets the render widths, and percyCSS adds a style for the snapshot only.
💡 Простими словами: Візуальний тест — фото «до» і «після»; якщо світло щоразу різне, порівнювати нема чого, тому спершу стабілізують сцену.
💡 In plain words: A visual test is a before-and-after photo; if the lighting changes every time there is nothing to compare, so the scene is stabilised first.
🗣 Як читати вголос Я кажу, що в самому Cypress порівняння зображень немає, лише скріншот, тому для візуальних тестів я беру або хмарний сервіс, як Percy чи Applitools, або open-source плагін із еталонами в репозиторії. Хмара дорожча, але рендер стабільний і є зручне рев'ю змін. Головна робота — прибрати шум: однакове середовище, стаби даних, зафіксована дата, вимкнені анімації і приховані живі блоки, як чат чи реклама. Знімаю не кожну сторінку в кожному тесті, а ключові стани. І еталони оновлюю тільки через рев'ю, а не кнопкою «прийняти все».
🗣 How to say it out loud I say that Cypress itself has no image comparison, only screenshots, so for visual tests I take either a cloud service like Percy or Applitools, or an open-source plugin with baselines in the repository. The cloud costs more, but the rendering is stable and reviewing changes is convenient. The main work is removing noise: an identical environment, stubbed data, a pinned date, disabled animations and hidden live blocks like chat or ads. I do not snapshot every page in every test, only the key states. And I update baselines only through review, never with an "accept all" button.