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.
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.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.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.
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, який повторюється до успіху.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.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.
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..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.Оскільки тест виконується в браузері поруч із застосунком, на нього діє політика 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.
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') повертає шлях після редиректу.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.Це питання про компроміси, а не про «кращий інструмент». 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: команди стають у чергу, чекання вбудоване
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');
// });
cy.* синхронні на вигляд і стають у чергу, асерт .should сам повторюється; у Playwright кожна дія повертає Promise, тому потрібні async/await, а повторюваний асерт — це expect(locator).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).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.
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 змінює тайм-аут лише для цієї команди і її асертів..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.Команди 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.
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 поза ланцюжком виконуються одразу, під час побудови черги.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.Обидва методи дають доступ до 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').
// повторюється, доки всі умови не стануть істинними
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 }) дістає запит з об'єкта перехоплення.$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.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.
// помилка: у змінній 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..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.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.
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') дістає вкладене поле за шляхом через крапку.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.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(...)).
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 — п'ятнадцять хвилин у мілісекундах.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.Перед кліком 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.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 читабельним.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.Стабільний селектор не змінюється, коли дизайнер перефарбував кнопку або розробник переписав верстку. Тому рекомендація 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.
// крихко: залежить від стилів і структури
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 ігнорує регістр.[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.Усі три методи звужують пошук, але по-різному. .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.
// 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.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..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.
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-елементів.$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..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').
// атрибути посилання, що відкривається в новій вкладці
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') іде вкладеним шляхом; у регулярному виразі \/ екранує слеш.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.Перш ніж виконати дію, 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.
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') віддає шлях поточної сторінки для перевірки переходу.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.Опція 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.
// погано: ховає справжню проблему — кнопку перекриває модалка
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 перевіряють підсумковий стан.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.Обидва випадки впираються в архітектуру: тест живе в одній вкладці браузера, поруч із застосунком. Окремої команди для 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.
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 на аргументи виклику.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..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.
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' імітує перетягування замість вибору через діалог.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.Коли застосунок віддає файл (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.
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.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.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.
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) залежить від відомого наперед параметра, а не від стану сторінки, тому це не умовне тестування.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.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: запит іде на сервер, ми лише спостерігаємо
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 до того, як відповідь побачить застосунок.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.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.
// 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 призначає аліас динамічно всередині обробника.* 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.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.
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 затримує відповідь, поки тест перевіряє спінер.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..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.
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' повертає всі перехоплення за аліасом..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.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.
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 ().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 ().Стаб корисний, поки він говорить правду про 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.
// 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');
});
});
validate; задача повертає null, якщо все гаразд, або текст помилок; cy.fixture<User[]> типізує фікстуру тим самим інтерфейсом, що й фронтенд; should('be.null') падає з переліком розбіжностей.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.Сюїта, де всі 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.
// 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"
Date.now() не дає живим тестам конфліктувати; cy.loginByApi — кастомна команда логіну через API; --spec вибирає теку спеків, а --config baseUrl=... спрямовує живий прогін на staging.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.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.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 ділить сесію між файлами спеків в одному прогоні.['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.Логін через інтерфейс займає кілька секунд: відкрити сторінку, заповнити поля, дочекатися редиректу. На 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.
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 тримає адресу провайдера і пароль поза кодом.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.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.
// 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 виконується один раз для всього блоку, і лише тому, що ізоляцію вимкнено, стан переживає перехід між тестами.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.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.
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 ().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 ().Умовне тестування — це код, який дивиться на сторінку і вирішує, що робити: 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.
// антипатерн: результат залежить від того, чи встиг банер з'явитися
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') перевіряє, що банера справді немає, а не пропускає його мовчки.$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.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.
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) отримує функцію підписки на події та поточний конфіг і має повернути конфіг, якщо змінює його.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.У спеках значення читають через 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.
// 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.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/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.
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(), тож повторний прогін не зіткнеться з уже зареєстрованим користувачем.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.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.
// 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> типізує результат у спеку.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.Ретраї тестів — це повторний запуск цілого тесту, якщо він упав, разом з 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.
// 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 вимикає повтори там, де повтор небезпечний.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.У режимі 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.
// 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 перезаписує файл із тим самим ім'ям замість створення копії з номером.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 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.
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 спрацьовує вже під час виконання черги, з реальним значенням.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.Кастомна команда — власний метод на 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.
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 перебирає пари поле-значення.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.Компонентний тест монтує один компонент — кнопку, форму, модалку — у справжньому браузері без решти застосунку, сервера і маршрутизації. Замість 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.
// 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.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.Це три інструменти для різних задач, а не взаємовиключні школи. 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.
// 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 тест проходить лише в тій частині, яку перевіряє.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.Нестабільний тест — той, що на тому самому коді то проходить, то падає. Боротьба з ним — це процес, а не окремі виправлення: виявити, ізолювати, знайти корінь, виправити і не допустити нових. Найдорожче у флейках не час прогону, а втрата довіри: команда звикає перезапускати червоний 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.
// тест у карантині: тег виключає його з блокувального прогону
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 разів, щоб виявити нестабільність до злиття.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.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.
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() виконує крок лише після падіння попередніх.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.Сам раннер 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.
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.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 немає: 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.
// 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 додає стиль лише для знімка.@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.