Що це — Піраміда тестування — це класична концептуальна модель у сфері забезпечення якості та розробки програмного забезпечення, яка визначає ідеальне співвідношення різних рівнів тестів у проєкті. Вона зазвичай має форму трикутника і поділяється на три основні шари: основу (юніт-тести), середню частину (інтеграційні тести) та верхівку (сквозні або E2E/UI-тести). Головна ідея моделі полягає в тому, що більшість перевірок має виконуватися на рівні окремих компонентів коду, оскільки вони швидкі, дешеві у написанні та стабільні, тоді як кількість повільних, дорогих і вразливих інтерфейсних тестів має бути зведена до мінімуму.
What is it — The Test Pyramid is a classic conceptual model in QA and software development that defines the ideal ratio of different test levels in a project. It typically takes the shape of a triangle and is divided into three main layers: the base (unit tests), the middle (integration tests), and the peak (end-to-end or E2E/UI tests). The core idea of the model is that the majority of checks should be performed at the individual component code level because they are fast, cheap to write, and stable, while the number of slow, expensive, and fragile UI tests should be kept to a minimum.
Навіщо/коли — Механізм роботи піраміди ґрунтується на економіці розробки та швидкості зворотного зв'язку (feedback loop). Юніт-тести ізольовано й за мілісекунди перевіряють окремі функції чи класи, дозволяючи розробникам запускати їх локально після кожного рядка коду. Інтеграційні тести перевіряють взаємодію між модулями — наприклад, чи правильно сервіс спілкується з базою даних або стороннім API. У той же час, UI-тести імутують реальні дії користувача у браузері, але вони виконуються хвилинами, потребують складного налаштування середовища та часто ламаються через найдрібніші зміни в дизайн-макеті (так звані flaky tests).
Why/when — The mechanism of the pyramid is based on development economics and feedback loop speed. Unit tests check individual functions or classes in isolation in milliseconds, allowing developers to run them locally after every line of code. Integration tests verify interaction between modules—for example, whether a service communicates correctly with a database or a third-party API. At the same time, UI tests simulate real user actions in a browser, but they take minutes to run, require complex environment setup, and frequently break due to minor changes in design layouts (so-called flaky tests).
Приклад з проєкту — У реальному проєкті (наприклад, у розробці фінтех-додатка) ми використовували піраміду як стратегічний орієнтир для автоматизації. Вся бізнес-логіка розрахунку відсотків за кредитом була покрита надійними юніт-тестами (близько 70% від загальної маси тестів). Інтеграційними тестами ми перевіряли коректність запитів до платіжного шлюзу (близько 20%). А на вершині піраміди ми залишили лише 5-7 ключових E2E-сценаріїв — наприклад, повний цикл авторизації, оформлення та успішної оплати кредиту. Такий підхід гарантував нам швидкий запуск CI/CD пайплайну та високу впевненість у релізі.
Project example — In a real project (such as developing a fintech application), we used the pyramid as a strategic guideline for automation. All the business logic for calculating loan interest was covered by robust unit tests (about 70% of the total test mass). With integration tests, we verified the correctness of requests to the payment gateway (about 20%). And at the peak of the pyramid, we left only 5-7 key E2E scenarios—for example, the complete cycle of authorization, application, and successful loan payment. This approach guaranteed a fast CI/CD pipeline run and high confidence in our releases.
Підводні камені — Найпоширенішою помилкою на практиці є побудова «перевернутої піраміди» або «морозильного ріжка», коли команда не пише юніт-тести взагалі, натомість намагаючись покрити всі фічі через важкі UI-тести на Selenium чи Cypress. Це призводить до катастрофічно повільних білдів, які тривають годинами, величезних витрат часу на підтримку тестів і постійних хибно-негативних результатів. Важливо розуміти, що піраміда не є жорсткою догмою, але її фундаментальний економічний принцип — «дешеві тести знизу, дорогі зверху» — залишається незмінним для успішного проєкту.
Pitfalls — The most common mistake in practice is building an "inverted pyramid" or an "ice cream cone," where a team writes no unit tests at all, instead trying to cover every feature through heavy UI tests in Selenium or Cypress. This leads to catastrophically slow builds that take hours, enormous amounts of time spent on test maintenance, and constant false-negative results. It is important to understand that the pyramid is not a rigid dogma, but its fundamental economic principle—"cheap tests at the bottom, expensive at the top"—remains unchanged for a successful project.
Уточнення на співбесіді — Що робити, якщо в проєкті немає unit-тестів, а є лише UI? Почати з API-шару для критичних сценаріїв і домовитися з розробниками, що новий код приходить з unit-тестами.
Interview follow-up — What if the project has no unit tests, only UI ones? Start with an API layer for the critical flows and agree with developers that new code comes with unit tests.
+-------------------------------------------------------+
| [ E2E / UI Tests ] | <- Верхівка: 5-10% обсягу
| - Інструменти: Cypress, Playwright, Selenium | Повільні, дорогі, охоплюють весь додаток
+-------------------------------------------------------+
| [ Integration Tests ] | <- Середина: 20-30% обсягу
| - Інструменти: Supertest, JUnit / PyTest (з БД) | Перевіряють взаємодію між сервісами/API
+-------------------------------------------------------+
| [ Unit Tests ] | <- Основа: 60-75% обсягу
| - Інструменти: Jest, PyTest, JUnit, Moq | Швидкі, ізольовані, тестують бізнес-логіку
+-------------------------------------------------------+
+-------------------------------------------------------+
| [ E2E / UI Tests ] | <- Top: 5-10% of the suite
| - Tools: Cypress, Playwright, Selenium | Slow, expensive, cover the whole app
+-------------------------------------------------------+
| [ Integration Tests ] | <- Middle: 20-30% of the suite
| - Tools: Supertest, JUnit / PyTest (+ DB) | Check how services/APIs work together
+-------------------------------------------------------+
| [ Unit Tests ] | <- Base: 60-75% of the suite
| - Tools: Jest, PyTest, JUnit, Moq | Fast, isolated, test business logic
+-------------------------------------------------------+
Що це — Антипатерн «Крижана вафля» (Ice Cream Cone) — це інверсія класичної піраміди тестування, де більшість зусиль та тестів зосереджено на кінцевому ручному тестуванні та повільних сквозних (E2E) UI-тестах, тоді як базових модульних (Unit) тестів майже немає. У такій системі перевернута структура нагадує перевернуте морозиво на вафельному ріжку, де важка, нестабільна верхівка тримається на надзвичайно слабкому і вразливому фундаменті.
What is it — The "Ice Cream Cone" anti-pattern is an inversion of the classical test pyramid where the majority of testing effort and tests are concentrated on manual testing and slow, brittle end-to-end (E2E) UI tests, while foundational unit tests are virtually non-existent. In such a system, the inverted structure resembles an ice cream cone resting on a fragile base, where a heavy, unstable top-heavy structure balances on weak foundations.
Навіщо/коли — Головна причина виникнення цього антипатерну — хибна впевненість команди в тому, що тестувати бізнес-логіку через користувацький інтерфейс надійніше, або ж відсутність інженерної культури написання юніт-тестів серед розробників. Механізм руйнування полягає в тому, що чим більше тестів виконується на верхньому рівні, тим довше триває зворотний зв'язок (feedback loop), тим дорожче обходиться виправлення дефектів і тим вищий рівень крихкості (flakiness) всієї системи через залежність від зовнішніх факторів, мережі та стану середовища.
Why/when — The primary root cause of this anti-pattern is a team's false confidence that testing business logic through the user interface is more reliable, or a lack of an engineering culture for writing unit tests among developers. The destructive mechanism lies in the fact that the more tests are executed at the upper level, the longer the feedback loop, the more expensive bug fixes become, and the higher the level of system flakiness due to dependencies on external factors, networks, and environment states.
Приклад з проєкту — Типовий сценарій на проєкті з цим антипатерном виглядає так: команда розробників майже не пише модульні тести, повністю покладаючись на QA-інженерів, які намагаються покрити всі сценарії за допомогою важких Selenium- чи Cypress-скриптів або довгих мануальних чекап-листів. Перед кожним релізом запускається величезний сьют E2E-тестів, який постійно падає через непов'язані з бізнес-логікою проблеми (таймаути, мінливу верстку, збої стабілізації оточення), що змушує команду витрачати дні на аналіз «хибно-негативних» (false positive) результатів замість випуску фіч.
Project example — A typical scenario in a project with this anti-pattern looks like this: the development team writes almost no unit tests, relying entirely on QA engineers who try to cover all scenarios using heavy Selenium or Cypress scripts, or long manual check-lists. Before every release, a massive E2E test suite runs, constantly failing due to issues unrelated to business logic (timeouts, layout changes, environment instability), forcing the team to spend days analyzing "false positive" results instead of delivering features.
Підводні камені — Наслідками «крижаної вафлі» стають критично високий час виходу на ринок (Time-to-Market), вигорання команди через рутинне гасіння пожеж перед релізами та величезна вартість підтримки тестової бази. Стратегія виправлення вимагає поступового зміщення акцентів «ліворуч» (Shift-Left): залучення розробників до написання юніт- та інтеграційних тестів, перенесення перевірки бізнес-логіки на рівень сервісів та API, і залишення на рівні E2E лише критичних користувацьких шляхів (Critical Path / Smoke tests).
Pitfalls — The consequences of the "Ice Cream Cone" include critically high Time-to-Market, team burnout due to routine fire-fighting before releases, and immense test maintenance costs. The remediation strategy requires a gradual shift of focus to the left (Shift-Left): engaging developers in writing unit and integration tests, moving business logic verification to the service and API layers, and leaving only critical user journeys (Critical Path / Smoke tests) at the E2E level.
Уточнення на співбесіді — Як переконати команду перевернути конус назад у піраміду? Показати час прогону і частку flaky-падінь UI-набору та переносити перевірки на API-рівень по одній фічі.
Interview follow-up — How do you convince a team to turn the cone back into a pyramid? Show the UI suite's run time and flaky failure rate, and move checks to the API level one feature at a time.
[TEST STRATEGY AUDIT REPORT - PROJECT X]
Timestamp: 2026-03-30 | Status: CRITICAL WARNING
TEST DISTRIBUTION BREAKDOWN:
--------------------------------------------------
[UI / E2E Tests] ████████████████████ (65%) - 1,200 tests | Avg execution: 4.5h | Flakiness: 22%
[Integration Tests] ████ (15%) - 300 tests | Avg execution: 25m | Flakiness: 5%
[Unit Tests] ██ (20%) - 400 tests | Avg execution: 2m | Flakiness: <1%
--------------------------------------------------
DIAGNOSTIC METRICS:
- Feedback Loop Time: 5+ hours (Blocks CI/CD pipeline)
- Maintenance Cost: 40 hours/week spent fixing broken UI selectors
- Bug Leakage to Prod: High (Edge cases missed due to lack of unit testing)
- Verdict: ICE CREAM CONE ANTI-PATTERN DETECTED. Immediate Shift-Left required.
Що це — Smoke тестування (або «димове тестування») — це поверхнева перевірка найважливішого функціоналу програми, яка виконується одразу після отримання нової збірки (build) від розробників. Його головна мета — переконатися, що білд у принципі працездатний і критичні компоненти (авторизація, головна сторінка, базові операції) не «зламалися» настільки, що тестувати далі немає сенсу.
What is it — Smoke testing is a preliminary, shallow check of the core functionality of an application, executed immediately after receiving a new build from developers. Its main goal is to ensure the build is basically stable and that critical components (authorization, main page, core operations) are not broken to the point where further testing is a waste of time.
Навіщо/коли — Механізм цього тесту базується на використанні невеликого, але критично важливого набору тест-кейсів (smoke suite), які охоплюють ключові шляхи користувача (Critical Path). Якщо хоча б один критичний тест (наприклад, неможливість зайти в акаунт) провалюється, збірка одразу відхиляється (rejected) і повертається розробникам без проведення повного регресійного чи функціонального тестування, що економить години робочого часу QA-команди.
Why/when — The mechanism relies on a small yet crucial set of test cases (the smoke suite) covering the key user journeys (Critical Path). If even one critical test fails (e.g., inability to log into an account), the build is immediately rejected and sent back to developers without running full regression or functional testing, saving hours of QA working time.
Приклад з проєкту — Smoke тестування обов'язково застосовується на етапі постачання (delivery) нової версії на середовища QA, Staging або Production (після hotfix). Проте його не варто використовувати для пошуку глибоких багів, перевірки валідації полів, зручності інтерфейсу чи граничних значень — для цього існують інші види тестування, такі як функціональний чи граничний аналіз.
Project example — Smoke testing must be applied whenever a new version is delivered to QA, Staging, or Production environments (after a hotfix). However, it should not be used to hunt for deep bugs, verify field validations, check UI polish, or test edge cases—other types of testing exist for those purposes, such as functional or boundary analysis.
Підводні камені — Уявіть реальний проєкт — інтернет-магазин одягу. Розробники викатили на тестове середовище черговий реліз із купою оновлень. Перед тим як тестувати фільтри пошуку чи складні комбінації знижок, QA-інженер запускає smoke-набір: відкриває сайт (чи завантажується головна сторінка?), пробує залогінитися (чи працює авторизація?), додає тестовий товар у кошик (чи додається?). Якщо ці три базові кроки пройшли успішно, збірка приймається в роботу; якщо ні — тестування зупиняється на старті.
Pitfalls — Imagine a real project—an e-commerce clothing store. Developers deployed a new release with many updates to the test environment. Before testing search filters or complex discount combinations, the QA engineer runs the smoke suite: opens the site (does the home page load?), tries to log in (does authorization work?), adds a test item to the cart (does it add successfully?). If these three basic steps pass, the build is accepted for testing; if not, testing is halted right at the start.
Уточнююче питання — Найпоширеніші помилки новачків включають перетворення smoke-тестів на повноцінне регресійне тестування (включення туди сотень кейсів замість 10-20 критичних), ігнорування smoke-перевірки через спеку зі словами «розробники сказали, що все працює», а також виконання таких перевірок вручну там, বিরোধі їх давно можна автоматизувати в рамках CI/CD пайплайну.
Follow-up question — Common mistakes made by beginners include turning smoke tests into full regression testing (adding hundreds of cases instead of 10-20 critical ones), skipping the smoke check due to rush because "developers said everything works," and performing these checks manually where they could easily be automated within a CI/CD pipeline.
+----+----------------------------+------------------------------------------+------------------+
| ID | Module / Feature | Smoke Test Description | Expected Result |
+----+----------------------------+------------------------------------------+------------------+
| SM-01| Application Health | Open base URL and verify HTTP status | Status 200 OK, |
| | | | Home page loads |
| SM-02| Authentication | Login with valid registered user credentials | User successfully|
| | | | logs into dashboard|
| SM-03| Core Search | Enter "T-shirt" in search bar and press enter | Search results page|
| | | | shows items |
| SM-04| Checkout Core Path | Add item to cart and open checkout screen| Checkout page is |
| | | | accessible |
+----+----------------------------+------------------------------------------+------------------+
Що це — Регресійне тестування — це перевірка вже існуючого функціоналу програми після внесення будь-яких змін у кодову базу (виправлення багів, додавання нових фіч чи оновлення бібліотек). Його головна мета — переконатися, що нові зміни не зламали те, що раніше працювало коректно.
What is it — Regression testing is the verification of existing application functionality after any changes have been made to the codebase (such as bug fixes, new features, or library updates). Its main goal is to ensure that new changes haven't broken what was previously working correctly.
Навіщо/коли — Механізм регресії полягає у взаємозв’язку компонентів програмного забезпечення. Зміна в одному місці (наприклад, оптимізація запитів до бази даних) може непередбачувано вплинути на суміжні або навіть віддалені модулі через спільні залежності чи глобальні змінні.
Why/when — The mechanism of regression lies in the interconnectedness of software components. A change in one place (e.g., database query optimization) can unpredictably affect adjacent or even distant modules due to shared dependencies or global variables.
Приклад з проєкту — Такий вид тестування застосовують після кожного деплою, виправлення багів або релізу нової ітерації. Повний цикл регресії може бути занадто довгим, тому на практиці використовують підмножину тестів (селективну регресію) або пріоритезовані сценарії на основі критичності бізнес-логіки.
Project example — This type of testing is applied after every deployment, bug fix, or release of a new iteration. A full regression cycle can be too time-consuming, so in practice, a subset of tests (selective regression) or prioritized scenarios based on business logic criticality are used.
Підводні камені — Уявіть проєкт інтернет-магазину, де розробники виправили баг із відображенням знижок у кошику. Під час регресійного тестування QA-інженер перевіряє не лише роботу кошика, а й супутні процеси: чи формується правильний рахунок в особистому кабінеті, чи проходить оплата через платіжний шлюз і чи надходить коректне сповіщення на пошту.
Pitfalls — Imagine an e-commerce project where developers fixed a bug with discount displays in the cart. During regression testing, the QA engineer checks not only the cart functionality but also related processes: whether the correct invoice is generated in the user profile, whether payment goes through the payment gateway, and whether a correct email notification is sent.
Уточнююче питання — Найпоширеніша помилка новачків — фокусуватися виключно на виправленому базі й ігнорувати суміжні області, або намагатися пройти вручну весь масив тест-кейсів проєкту без автоматизації, що призводить до пропуску дефектів через втому.
Follow-up question — The most common mistake for beginners is focusing exclusively on the fixed bug and ignoring adjacent areas, or trying to manually run the entire test suite of the project without automation, which leads to missing defects due to fatigue.
[ ] REG-AUTH-01: Login with valid credentials (Happy Path)
[ ] REG-AUTH-02: Login with invalid password (Error handling)
[ ] REG-AUTH-03: Password recovery flow via email
[ ] REG-AUTH-04: Session persistence after page reload
[ ] REG-AUTH-05: Logout functionality & session termination
Що це — Sanity тестування (санітарне тестування) — це поверхнева і дуже спрямована перевірка, яку проводять після внесення незначних змін до коду (наприклад, виправлення конкретного багу чи мікропатчу), щоб переконатися, що змінена функціональність працює коректно і на перший погляд нічого не зламалося.
What is it — Sanity testing is a very focused and superficial verification conducted after minor code changes—such as a specific bug fix or micro-patch—to ensure that the modified functionality works correctly and that surface-level logic remains intact.
Навіщо/коли — Механіка цього процесу полягає у швидкому, нескриптованому виконанні невеликого набору тестів саме навколо зміненого модуля, без занурення у глибокі залежності системи. Це працює як швидкий "контрольний огляд", який економить час QA-інженера: якщо баг не виправлено або виліз очевидний супутній дефект, білд відхиляється одразу, ще до запуску повноцінного регресійного тестування.
Why/when — The mechanism relies on a quick, unscripted execution of a small subset of tests strictly around the modified module, without diving deep into system dependencies. This acts as a rapid "health check" that saves QA time: if the bug isn't fixed or an obvious regression surfaces immediately, the build is rejected right away before running a full regression suite.
Приклад з проєкту — Цей підхід ідеально застосовувати у ситуаціях, коли розробники випускають терміновий гарячий фікс (hotfix) і потрібно швидко перевірити критичну точку. Проте Sanity тестування абсолютно непридатне для великих релізів чи нових фіч, де потрібне повне покриття вимог, оскільки воно пропускає більшість архітектурних та інтеграційних перевірок.
Project example — This approach is ideal when developers release an urgent hotfix and you need to quickly verify a critical fix point. However, sanity testing is entirely unsuitable for major releases or brand-new features requiring full requirement coverage, as it skips most architectural and integration checks.
Підводні камені — Уявіть проєкт електронної комерції, де виник критичний дефект: застосування купона на знижку іноді призводило до зависання кошика. Розробник випускає виправлення лише для цієї проблеми. Замість того щоб проходити увесь великий сценарій оформлення замовлення, я виконую Sanity тестування: вводжу купон, перевіряю оновлення суми в реальному часі та переконуюся, що кнопка оплати активна — це займає хвилину і дає зелене світло для подальших кроків.
Pitfalls — Imagine an e-commerce project with a critical defect where applying a discount coupon occasionally froze the cart. The developer releases a fix exclusively for this issue. Instead of running through the entire checkout flow, I perform a sanity test: apply the coupon, verify real-time price updates, and ensure the pay button is active—taking just a minute to green-light further testing.
Уточнююче питання — Найпоширеніша помилка новачків — плутати Sanity тестування зі Smoke тестуванням або вважати його повноцінною заміною регресії. Smoke перевіряє працездатність усього додатку загалом після нового збирання, тоді як Sanity зосереджується на вузькій ділянці після зміни; крім того, успішний Sanity-тест не гарантує відсутності прихованих багів у суміжних модулях.
Follow-up question — A common beginner mistake is confusing sanity testing with smoke testing or treating it as a full substitute for regression. Smoke verifies overall application health after a new build, whereas sanity targets a specific module post-fix; moreover, a passing sanity test does not guarantee the absence of hidden bugs in adjacent modules.
[Sanity Check: Cart Discount Bugfix - Build #v1.4.2-hotfix]
- [ ] 1. Apply valid promo code "SAVE20" -> Verify 20% discount applied instantly.
- [ ] 2. Apply expired promo code "OLD10" -> Verify error message appears, cart doesn't crash.
- [ ] 3. Remove promo code -> Verify total price reverts to original correctly.
- [ ] 4. Click "Proceed to Checkout" button -> Verify redirection to payment page works.
Що це — Експлораторне (дослідницьке) тестування — це підхід до забезпечення якості, де етапи проектування тестів та їх виконання відбуваються одночасно. Тестувальник не використовує передстворені детальні сценарії з покроковими інструкціями, а досліджує систему динамічно, спираючись на власний досвід, інтуїцію, знання домену та результати попередніх кроків.
What is it — Exploratory testing is a QA approach where test design and test execution are performed simultaneously. Instead of following pre-scripted, detailed test cases with step-by-step instructions, the tester dynamically investigates the system based on personal experience, intuition, domain knowledge, and feedback from previous actions.
Навіщо/коли — Механізм цього підходу базується на безперервному циклі навчання: «спостереження — гіпотеза — експеримент — аналіз». Тестувальник будує ментальні моделі роботи фічі, швидко формулює припущення про те, де можуть ховатися дефекти (наприклад, у валідації полів, асинхронних запитах чи крайніх значеннях), і одразу перевіряє їх наживо, адаптуючи подальші дії під отриманий результат.
Why/when — The mechanism of this approach relies on a continuous learning loop: "observe, hypothesize, experiment, and analyze." The tester builds mental models of how a feature works, quickly forms assumptions about where defects might hide (e.g., in field validation, async requests, or edge values), and immediately tests them live, adapting subsequent actions based on the outcomes.
Приклад з проєкту — Цей метод є надзвичайно ефективним на ранніх етапах розробки, коли специфікації відсутні або постійно змінюються, при жорстких дедлайнах для регресійного тестування, а також для виявлення неочікуваних UI/UX та логічних багів. Проте він не замінює формалізовані скриптовані тести і не підходить для суворих регульованих аудитів, де вимагається 100% трасованість вимог та документальне підтвердження кожного кроку.
Project example — This method is extremely effective during early development stages when specifications are missing or constantly changing, under tight deadlines for regression testing, and for uncovering unexpected UI/UX and logical bugs. However, it does not replace formal scripted tests and is not suitable for strict regulated audits that require 100% requirement traceability and documented proof of every single step.
Підводні камені — У реальному проєкті ми часто використовуємо сесійне тестування (Session-Based Testing) із прив'язкою до чартерів. Наприклад, перед релізом оновлення кошика інтернет-магазину я проводжу 45-хвилинну сесію з фокусом на сценарії швидкого чекауту при нестабільному з'єднанні, фіксуючи нові знахідки, ризики та потенційні вузькі місця «на льоту» в спеціальному звіті.
Pitfalls — In a real project, we frequently use session-based testing bound to specific charters. For example, before releasing an e-commerce cart update, I run a 45-minute session focused on quick checkout scenarios under unstable network connectivity, logging new findings, risks, and potential bottlenecks on the fly in a session sheet.
Уточнююче питання — Типова помилка початківців — сприймати експлораторне тестування як хаотичне «тицяння по екрану» без мети та аналізу. Насправді це дисциплінована пізнавальна діяльність, яка вимагає глибокого розуміння бізнес-логіки, ведення нотаток та постійного критичного мислення.
Follow-up question — A common mistake among juniors is treating exploratory testing as chaotic, random clicking on the screen with no goal or analysis. In reality, it is a disciplined cognitive activity that requires a deep understanding of business logic, structured note-taking, and continuous critical thinking.
Session ID: EXP-CART-01
Duration: 45m
Charter: Explore cart calculations and checkout edge cases under simulated network throttling.
Areas to cover: Promo code application, cart item removal, offline mode recovery.
Bugs found:
- BUG-104: Cart total fails to update when promo code is applied via Enter key.
- BUG-105: Infinite spinner on checkout button if network drops during payload submission.
Notes: Checked integration with payment gateway sandbox; looks stable under 3G throttling.
Що це — End-to-End (E2E) тестування — це метод перевірки цілісності та працездатності всієї програмної системи від початку й до кінця. Воно імітує реальні дії користувача в умовах, максимально наближених до бойових (Production), перевіряючи взаємодію між усіма компонентами: фронтендом, бекендом, базами даних, сторонніми API та платіжними шлюзами.
What is it — End-to-End (E2E) testing is a methodology used to test whether the flow of an application is behaving as designed from start to finish. It simulates real user behavior in an environment closely resembling production, validating the integration and communication between all components like frontend, backend, databases, third-party APIs, and payment gateways.
Навіщо/коли — Механізм E2E полягає в тому, що тест запускається через користувацький інтерфейс (браузер чи мобільний додаток) і простежує весь життєвий цикл даних. Система запитує інформацію з бази даних, обробляє бізнес-логіку на сервері, надсилає зовнішній запит до сторонніх сервісів і повертає оновлений UI. На відміну від юніт-тестів чи інтеграційних перевірок, E2E розглядає систему як «чорну скриньку», фокусуючись виключно на сквозних бізнес-сценаріях.
Why/when — The mechanism of E2E relies on running tests through the user interface (a browser or mobile app) and tracking the entire data lifecycle. The system queries the database, processes business logic on the server, triggers external requests to third-party services, and updates the UI. Unlike unit or integration tests, E2E treats the system as a "black box," focusing purely on end-to-end business workflows.
Приклад з проєкту — Стратегічно E2E-тести варто застосовувати для найкритичніших бізнес-процесів (checkout, реєстрація, авторизація, транзакції), де збій безпосередньо шкодить фінансам компанії. Водночас їх не слід використовувати для покриття кожної дрібної валідації форми чи низькорівневої логіки, оскільки E2E-тести повільні у виконанні, чутливі до змін навколишнього середовища та складні у підтримці (знаходяться на верхівці Тестової Піраміди).
Project example — Strategically, E2E tests should be applied to the most critical business paths (such as checkout, registration, authorization, and transactions) where failures directly impact company revenue. However, they should not be used to cover every minor UI validation or low-level logic, as E2E tests are slow to execute, sensitive to environment changes, and expensive to maintain, sitting at the very top of the Test Pyramid.
Підводні камені — Уявімо типовий проєкт електронної комерції. Реальний E2E-сценарій починається з того, що автоматизований скрипт відкриває сайт, виконує логін користувача, додає товар до кошика, переходить на сторінку чекауту, симулює введення реквізитів картки, підтверджує транзакцію через платіжний шлюз і врешті перевіряє, чи змінився статус замовлення в базі даних та чи надійшов лист підтвердження.
Pitfalls — Imagine a typical e-commerce project. A real-world E2E scenario starts with an automated script opening the website, logging in a user, adding a product to the cart, proceeding to checkout, simulating credit card details entry, confirming the transaction via a payment gateway, and finally verifying that the order status updated in the database and a confirmation email was dispatched.
Уточнююче питання — Найпоширеніша помилка початківців — намагатися покрити 100% функціональності за допомогою E2E-тестів (створення антипатерну «морозиво» замість піраміди), а також жорстке зашивання тестових даних і використання нестабільних локаторів верстки. Здоровий підхід передбачає написання стабільних тестів із використанням унікальних атрибутів (data-testid) та динамічної генерації даних для кожного запуску.
Follow-up question — A common mistake among junior testers is trying to cover 100% of functionality using E2E tests—creating an "ice cream cone" anti-pattern instead of a pyramid—along with hardcoded test data and brittle UI selectors. A sound approach involves writing resilient tests by utilizing dedicated attributes (data-testid) and dynamic data generation for each run.
import { test, expect } from '@playwright/test';
test('User can complete checkout successfully', async ({ page }) => {
// 1. Open app and login
await page.goto('https://shop.example.com/login');
await page.fill('[data-testid="email-input"]', 'user@example.com');
await page.fill('[data-testid="password-input"]', 'SecurePass123!');
await page.click('[data-testid="login-btn"]');
// 2. Add product and go to cart
await page.click('[data-testid="product-item"]:first-child');
await page.click('[data-testid="add-to-cart-btn"]');
await page.goto('https://shop.example.com/cart');
// 3. Checkout process
await page.click('[data-testid="checkout-btn"]');
await page.fill('[data-testid="address-input"]', '123 Test St');
await page.click('[data-testid="place-order-btn"]');
// 4. Assert success message and DB state via UI
await expect(page.locator('[data-testid="success-banner"]')).toBeVisible();
await expect(page.locator('[data-testid="order-number"]')).not.toBeEmpty();
});
Що це — Flaky test (нестабільний тест) — це такий тест, який може як успішно пройти, так і впасти за однакових умов виконання на тому самому коді, без будь-яких змін у самій програмі чи у файлах тестів. Іншими словами, він демонструє непередбачувану поведінку, видаючи «хибнопозитивні» або «хибнонегативні» результати при повторних запусках.
What is it — A flaky test (unstable test) is a test that can either pass successfully or fail under the exact same execution conditions on the same code, without any changes to the application or test files. In other words, it exhibits unpredictable behavior, producing false positives or false negatives upon re-runs.
Навіщо/коли — Головна причина появи flaky тестів криється у зовнішніх факторах та проблемах синхронізації. Це можуть бути мережеві затримки (network latency), асинхронні запити, стани гонки (race conditions), спільні ресурси бази даних або жорстко зашиті таймаути (hardcoded timeouts), які не встигають спрацювати при повільнішому завантаженні середовища виконання на CI-сервері.
Why/when — The main root cause of flaky tests lies in external factors and synchronization issues. These can include network latency, asynchronous requests, race conditions, shared database resources, or hardcoded timeouts that fail to trigger when the execution environment is running slower on the CI server.
Приклад з проєкту — На реальному проєкті з автоматизації UI-тестування (наприклад, за допомогою Cypress чи Playwright) я часто стикався з ситуацією, коли тест на перевірку появи модального вікна падав на CI приблизно в 20% випадків. Причиною було те, що бекенд-відповідь на мікросекунду затримувалася довше, ніж тривав фіксований час очікування (cy.wait(500)), хоча локально на потужному ноутбуці все працювало ідеально.
Project example — In a real UI automation project (e.g., using Cypress or Playwright), I frequently encountered a situation where a test verifying a modal window opening would fail on CI in about 20% of runs. The reason was that the backend response took a microsecond longer than the fixed wait time (cy.wait(500)), even though everything worked perfectly locally on a powerful laptop.
Підводні камені — Нестабільні тести є критичною проблемою для команди, оскільки вони підривають довіру до всієї системи автоматизації та результатів збірки (CI/CD). Поширена помилка новачків — просто масово вмикати вбудовані механізми повтору (retries) замість того, щоб шукати та усувати першопричину: переходити на явні очікування (explicit waits), стабілізувати тестові дані або ізолювати середовище виконання.
Pitfalls — Flaky tests are a critical problem for a team because they undermine trust in the entire automation suite and CI/CD build results. A common mistake made by newcomers is to simply mass-enable built-in retry mechanisms instead of investigating and fixing the root cause: switching to explicit waits, stabilizing test data, or isolating the execution environment.
Уточнення на співбесіді — Як довести, що тест саме flaky, а не ловить реальний баг? Прогнати його 20–50 разів на тому ж коміті: якщо результат змінюється без змін коду, це нестабільність.
Interview follow-up — How do you prove a test is flaky rather than catching a real bug? Run it 20–50 times on the same commit: if the result changes without code changes, it is instability.
// ❌ Flaky test: залежить від жорсткого таймауту та швидкості мережі
it('should load user profile', () => {
cy.get('#load-profile').click();
cy.wait(1000); // Погано: на CI-сервері запит може тривати довше
cy.get('.profile-name').should('be.visible');
});
// ✅ Стабільний тест: використовує явне очікування (explicit wait)
it('should load user profile', () => {
cy.get('#load-profile').click();
cy.get('.profile-name', { timeout: 10000 }).should('be.visible');
});
// ❌ Flaky test: depends on a hard timeout and network speed
it('should load user profile', () => {
cy.get('#load-profile').click();
cy.wait(1000); // Bad: on the CI server the request may take longer
cy.get('.profile-name').should('be.visible');
});
// ✅ Stable test: uses an explicit wait
it('should load user profile', () => {
cy.get('#load-profile').click();
cy.get('.profile-name', { timeout: 10000 }).should('be.visible');
});
Що це — Test Data Management (TDM) — це комплекс стратегій, інструментів та процесів для створення, забезпечення, маскування, безпеки та життєвого циклу тестових даних у середовищах розробки й тестування. На рівні Senior-спеціаліста TDM виходить за межі простого завантаження дампа продакшену; це автоматизована система, яка гарантує наявність валідних, унікальних та безпечних даних саме тоді, коли це потрібно пайплайну тестування.
What is it — Test Data Management (TDM) is a comprehensive set of strategies, tools, and processes for creating, provisioning, masking, securing, and managing the lifecycle of test data across development and testing environments. At a senior level, TDM goes far beyond simply restoring a production database dump; it is an automated pipeline that ensures valid, unique, and secure data is available precisely when the testing workflow requires it.
Навіщо/коли — Механізм роботи TDM будується навколо трьох основних компонентів: генерація синтетичних даних, маскування чутливої інформації (PII/GDPR-комплаєнс) та віртуалізація чи клонування баз даних. Автоматизація тут критична: замість ручного створення користувачів через UI, TDM-інструменти (або кастомні скрипти) інжектують необхідні набори даних через API чи безпосередньо в БД перед стартом тестових прогонів, мінімізуючи накладні витрати на підготовку середовища.
Why/when — The TDM mechanism revolves around three core components: synthetic data generation, masking of sensitive information (PII/GDPR compliance), and database virtualization or cloning. Automation is critical here: instead of manual UI data creation, TDM tools (or custom scripts) inject required data sets via APIs or directly into the database before test runs begin, minimizing test environment setup overhead.
Приклад з проєкту — TDM життєво необхідний у масштабних ентерпрайз-системах, фінансовому секторі та е-комерції, де розгортання тестів вимагає мільйонів рядків пов'язаних транзакційних даних, а правила безпеки забороняють використання реальних даних користувачів. Водночас, для маленьких стартапів чи простих мікросервісів з мінімальною кількістю сутностей повномасштабне впровадження важких ентерпрайз-платформ TDM є надлишковим — тут достатньо легких SQL-скриптів ініціалізації та фабрик тестових даних.
Project example — TDM is vital in large-scale enterprise systems, the financial sector, and e-commerce where test execution requires millions of rows of relational transactional data, and security policies strictly forbid using real user data. Conversely, for small startups or simple microservices with minimal entity relationships, implementing heavy enterprise TDM platforms is overkill; lightweight SQL initialization scripts and test data factories are sufficient.
Підводні камені — У реальному проєкті для фінансової платформи ми зіткнулися з проблемою, коли QA-автоматизатори чекали годинами на оновлення тестової БД або падали через конфлікти унікальних ключів під час паралельного запуску тисяч тестів. Впровадження стратегії TDM із динамічним наповненням даних на рівні API та регулярним мікроклонуванням чистих станів БД за допомогою контейнеризації знизило час підготовки середовища на 75% і повністю усунуло флейкі-тести, пов'язані з брудним станом даних.
Pitfalls — In a real project for a financial platform, we faced an issue where QA automation engineers waited hours for test DB refreshes or failed due to Duplicate Key Errors during parallel execution of thousands of tests. Implementing a TDM strategy with dynamic API-level data provisioning and regular micro-cloning of clean DB states using containerization reduced environment setup time by 75% and completely eliminated flaky tests caused by dirty data states.
Уточнююче питання — Поширеною помилкою є копіювання production-баз без належного маскування, що створює серйозні ризики безпеки та порушує регуляторні вимоги на кшталт GDPR, а також відсутність процедур очищення (teardown), що призводить до розростання баз даних «сміттєвими» записами. Інший антипаттерн — покладати всю відповідальність за управління даними виключно на ручне тестування замість інтеграції TDM-процесів безпосередньо у CI/CD-пайплайн.
Follow-up question — A common mistake is copying production databases without proper masking, posing severe security risks and violating regulatory requirements like GDPR, as well as lacking teardown procedures, which leads to database bloat with garbage records. Another anti-pattern is placing sole responsibility for data management exclusively on manual testing instead of integrating TDM processes directly into the CI/CD pipeline.
{
"dataset": "user_billing_profile",
"version": "1.4.2",
"strategies": [
{
"column": "email",
"action": "mask",
"method": "format_preserving_pseudonymization",
"domain": "test.qa.enterprise.internal"
},
{
"column": "credit_card_number",
"action": "synthesize",
"generator": "luhn_valid_visa",
"preserve_nulls": false
},
{
"column": "ssn_tax_id",
"action": "obfuscate",
"method": "randomize_pattern",
"pattern": "###-##-####"
}
],
"provisioning": {
"target_env": "staging-qa-03",
"cloning_mode": "snapshot",
"teardown_on_complete": true
}
}
Що це — Патерн AAA (Arrange-Act-Assert) — це загальноприйнятий підхід до структурування модульних (unit) та інтеграційних тестів, який розділяє кожну тестову функцію на три чіткі логічні фази. Фаза Arrange відповідає за підготовку всього необхідного середовища (створення об'єктів, налаштування моків та ініціалізацію початкових даних). Фаза Act виконує саму тестовану дію — наприклад, викликає функцію чи метод, який ми перевіряємо. Нарешті, фаза Assert порівнює отриманий результат із нашими очікуваннями.
What is it — The AAA (Arrange-Act-Assert) pattern is a universally accepted approach to structuring unit and integration tests, dividing each test function into three distinct logical phases. The Arrange phase handles setting up the necessary environment (creating objects, configuring mocks, and initializing initial data). The Act phase performs the action under test — for instance, calling the function or method we are checking. Finally, the Assert phase compares the actual result against our expectations.
Навіщо/коли — Цей підхід працює завдяки принципу єдиної відповідальності для тесту, забезпечуючи високу читабельність та передбачуваність. Коли розробник або QA-інженер відкриває тестовий файл, він одразу бачить чітку межу між тим, *що* готується для перевірки, *що саме* виконується та *що саме* верифікується. Це знижує когнітивне навантаження та дозволяє миттєво локалізувати проблему в разі падіння тесту: якщо помилка в секції Assert — проблема у бізнес-логіці результату, а якщо в Arrange — у підготовці тестового оточення.
Why/when — This approach works thanks to the single-responsibility principle for tests, ensuring high readability and predictability. When a developer or QA engineer opens a test file, they instantly see a clear boundary between *what* is being prepared for the check, *what* is being executed, and *what* is being verified. This reduces cognitive load and allows for immediate problem localization if a test fails: if the failure is in the Assert section, the bug is in the result business logic; if in Arrange, it's in the test environment setup.
Приклад з проєкту — На реальному проекті електронної комерції цей патерн застосовується постійно. Наприклад, створюючи тест для перевірки застосування промокоду, у блоці *Arrange* ми ініціалізуємо об'єкт користувача з певним балансом та активний промокод зі знижкою. У блоці *Act* ми викликаємо метод applyPromoCode(user, promo), який додає знижку до кошика. У блоці *Assert* ми перевіряємо, що кінцева сума замовлення зменшилася на правий відсоток, а статус промокоду в базі даних змінився на використаний.
Project example — On a real e-commerce project, this pattern is applied constantly. For example, when creating a test to check promo code application, in the *Arrange* block we initialize a user object with a specific balance and an active discount promo code. In the *Act* block, we call the applyPromoCode(user, promo) method, which applies the discount to the cart. In the *Assert* block, we verify that the final order total decreased by the correct percentage and the promo code status in the database changed to used.
Підводні камені — Найпоширенішими помилками при використанні AAA є розмиття меж між фазами, змішування викликів дій із перевірками або наявність кількох блоків Act в одному тесті. Ще одна проблема — занадто роздутий блок Arrange без використання хелперів чи фабрик тестових даних (Data Factories), через що тести стають крихкими, захаращеними та складними для підтримки при зміні структури даних.
Pitfalls — The most common mistakes when using AAA include blurring the boundaries between phases, mixing action calls with assertions, or having multiple Act blocks in a single test. Another issue is an overly bloated Arrange block without using helper methods or test data factories, which makes tests brittle, cluttered, and difficult to maintain when data structures change.
Уточнення на співбесіді — Чи можна мати кілька assert в секції Assert? Так, якщо вони перевіряють один результат дії; дві різні дії в одному тесті — вже сигнал розділити його.
Interview follow-up — Can the Assert section hold several asserts? Yes, if they check the outcome of one action; two different actions in one test is a signal to split it.
test('should calculate discount correctly for VIP user', () => {
// Arrange
const user = { id: 1, type: 'VIP', balance: 1000 };
const cart = [{ id: 101, price: 200, quantity: 2 }];
const discountService = new DiscountService();
// Act
const finalPrice = discountService.calculateTotal(user, cart);
// Assert
expect(finalPrice).toBe(360); // 400 with 10% VIP discount applied
});
Що це — В основі фреймворку JUnit 5 (модуль Jupiter) лежить анотація @Test, яка використовується для позначення методу як виконуваного тестового кейсу. На відміну від попередніх версій, де використовувався пакет org.junit.Test, у JUnit 5 ця анотація імпортується з пакета org.junit.jupiter.api. Вона чітко сигналізує тестовому рушію (test runner), що даний метод є незалежною перевіркою певної функціональності або бізнес-правила.
What is it — At the core of the JUnit 5 framework (Jupiter module) lies the @Test annotation, which is used to mark a method as an executable test case. Unlike previous versions that used the org.junit.Test package, in JUnit 5 this annotation is imported from the org.junit.jupiter.api package. It clearly signals to the test runner that the given method is an independent verification of a specific feature or business rule.
Навіщо/коли — Механізм роботи @Test базується на скануванні класів під час виконання (runtime) за допомогою рефлексії. Коли фреймворк запускає тестовий клас, він шукає всі методи, позначені цією анотацією, створює новий екземпляр тестового класу (за замовчуванням за життєвим циклом PER_METHOD) і послідовно виконує їх. Будь-які твердження (assertions) або винятки, перехоплені під час виконання цього методу, безпосередньо впливають на загальний статус звіту про проходження тестів.
Why/when — The mechanism behind @Test is based on runtime scanning using reflection. When the framework executes a test class, it scans for all methods annotated with this annotation, creates a new instance of the test class (by default per method lifecycle), and invokes them sequentially. Any assertions or exceptions caught during the execution of this method directly determine the overall pass/fail status in the test report.
Приклад з проєкту — Цю анотацію варто використовувати для кожного окремого юніт- чи інтеграційного тесту, який перевіряє конкретний стан або перехід. Проте існують обмеження: метод із @Test зазвичай повинен мати типом повернення void та не приймати традиційних параметрів (якщо не використовуються спеціальні розширення параметрів, такі як TestInfo або RepetitionInfo), оскільки фреймворк не знає, які саме значення передавати у звичайні аргументи.
Project example — You should use this annotation for every single unit or integration test that verifies a specific state or transition. However, there are restrictions: a method marked with @Test must typically have a void return type and cannot accept traditional parameters (unless special parameter resolvers like TestInfo or RepetitionInfo are used), as the framework does not know what values to pass into standard arguments.
Підводні камені — У реальному проєкті автоматизації я пишу тести для сервісного шару електронної комерції, де анотація @Test застосовується до методів на кшталт shouldCalculateDiscountForVipUserCorrectly(). Це дозволяє чітко ізолювати бізнес-логіку знижок від решти системи та швидко локалізувати проблему в разі регресії. Кожен такий метод зосереджений лише на одній перевірці, що робить кодову базу тестів підтримуваною та читабельною.
Pitfalls — In a real automation project, I write tests for an e-commerce service layer where the @Test annotation is applied to methods like shouldCalculateDiscountForVipUserCorrectly(). This allows for clear isolation of discount business logic from the rest of the system and quick problem localization during a regression. Each such method focuses on a single check, making the test codebase maintainable and readable.
Поширеною помилкою серед початківців є випадковий імпорт анотації @Test із пакетів JUnit 4 замість JUnit 5, через що тестовий рушій просто ігнорує метод і не запускає його. Інша типова помилка — оголошення тестового методу з модифікатором доступу private; хоча JUnit 5 у деяких конфігураціях може це допускати через рефлексію, найкращою практикою є використання пакункового (package-private) або public доступу.
Follow-up question — A common mistake among beginners is accidentally importing the @Test annotation from JUnit 4 packages instead of JUnit 5, causing the test runner to silently ignore and skip the method. Another typical error is declaring the test method with a private access modifier; while JUnit 5 may allow this via reflection in certain configurations, best practice dictates using package-private or public access.
Уточнення на співбесіді — Чим @Test з JUnit5 відрізняється від @Test у TestNG? У JUnit5 він не має атрибутів на кшталт priority чи groups — для цього є @Order і @Tag.
Interview follow-up — How does JUnit5 @Test differ from TestNG @Test? In JUnit5 it has no attributes like priority or groups — @Order and @Tag cover that.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class CalculatorTest {
@Test
void shouldAddTwoPositiveNumbersCorrectly() {
Calculator calculator = new Calculator();
int result = calculator.add(5, 7);
assertEquals(12, result, "5 + 7 should equal 12");
}
}
@Test — це основний маячок для JUnit 5, який говорить фреймворку: «Це мій тест, запусти його під час перевірки». Без цієї анотації середовище розробки та збирач просто проігнорують ваш метод, вважаючи його звичайною допоміжною функцією.@Test annotation is a core flag for JUnit 5 that tells the framework, "This is my test, run it during execution." Without this annotation, the build tool and test runner will simply ignore your method, treating it as an ordinary utility function.@Test, яка імпортується з пакета org.junit.jupiter.api. Вона вказує рушію Jupiter, що даний метод є повноцінним тестовим кейсом і має бути виконаний автоматично. Я завжди звертаю увагу на те, щоб метод повертав void і не мав зайвих параметрів, окрім випадків використання спеціальних розширень. Також важливо слідкувати за правильним імпортом, адже сплутавши його з JUnit 4, можна витратити час на пошук проблеми, чому тести не запускаються. На практиці я щодня використовую цю анотацію для покриття бізнес-логіки моїх автоматизованих тестів.@Test, which is imported from the org.junit.jupiter.api package. It tells the Jupiter engine that the given method is a fully functional test case and should be executed automatically. I always make sure the method returns void and has no extra parameters, unless special extensions are being used. It's also vital to track the imports correctly, because confusing it with JUnit 4 can lead to wasting time figuring out why tests aren't running. In practice, I use this annotation daily to cover the business logic in my automated tests.Що це — У фреймворку JUnit5 для виконання певного блоку коду перед кожним окремим тестовим методом використовується анотація @BeforeEach. Вона є прямим аналогом анотації @Before з попередньої версії JUnit4, проте має більш гнучку архітектуру та розширені можливості в рамках платформи JUnit 5 (JUnit Jupiter).
What is it — In the JUnit5 framework, the @BeforeEach annotation is used to execute a specific block of code before every single test method. It is the direct equivalent of the @Before annotation from the previous JUnit4 version, but with a more flexible architecture and enhanced capabilities within the JUnit 5 platform (JUnit Jupiter).
Навіщо/коли — Механізм роботи цієї анотації базується на життєвому циклі тестового класу. За замовчуванням JUnit створює новий екземпляр тестового класу для кожного окремого методу (режим життєвого циклу PER_METHOD). Перед запуском будь-якого методу з анотацією @Test фреймворк автоматично знаходить метод, позначений @BeforeEach, і виконує його. Це гарантує належну ізоляцію тестів: стан об'єктів створюється або скидається наново, запобігаючи впливу побічних ефектів попередніх тестів на поточний.
Why/when — The mechanism of this annotation is based on the test class lifecycle. By default, JUnit creates a new instance of the test class for each individual method (PER_METHOD lifecycle mode). Before running any method annotated with @Test, the framework automatically finds the method marked with @BeforeEach and executes it. This guarantees proper test isolation: the object state is created or reset anew, preventing side effects from previous tests from affecting the current one.
Приклад з проєкту — На практиці @BeforeEach активно застосовується для ініціалізації загальних тестових даних, створення екземплярів тестованих класів, налаштування моків (mock objects) або відкриття сесій у веб-драйвері для UI-тестів. Проте її не слід використовувати для виконання важких, ресурсомістких операцій, які не змінюються під час перевірок (наприклад, запуск контейнерів бази даних чи ініціалізація важких клієнтів) — для таких цілей краще підходить анотація @BeforeAll.
Project example — In practice, @BeforeEach is actively used to initialize common test data, create instances of classes under test, set up mock objects, or open sessions in a web driver for UI tests. However, it should not be used for heavy, resource-intensive operations that do not change during verifications (such as starting database containers or initializing heavy clients)—for such purposes, the @BeforeAll annotation is a better fit.
Підводні камені — Найпоширенішою помилкою серед початківців є спроба зробити метод @BeforeEach статичним (static). На відміну від JUnit4, де методи налаштування часто вимагали статичності у певних сценаріях, у JUnit5 методи @BeforeEach зазвичай не повинні бути статичними, оскільки вони виконуються на рівні екземпляра конкретного тесту. Також помилкою є створення прихованих залежностей між самими тестовими методами замість того, щоб повністю покладатися на @BeforeEach для підтримання чистоти середовища.
Pitfalls — The most common mistake among beginners is trying to make the @BeforeEach method static. Unlike JUnit4, where setup methods often required static modifiers in certain scenarios, in JUnit5, @BeforeEach methods generally should not be static because they execute at the level of the specific test instance. Another mistake is creating hidden dependencies between test methods themselves instead of fully relying on @BeforeEach to maintain a clean environment.
Уточнення на співбесіді — Що буде, якщо @BeforeEach кине виняток? Тест не виконається і буде позначений як failed, а @AfterEach усе одно запуститься для прибирання.
Interview follow-up — What happens if @BeforeEach throws? The test does not run and is marked failed, while @AfterEach still runs to clean up.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ShoppingCartTest {
private ShoppingCart cart;
@BeforeEach
void setUp() {
// Виконується перед кожним тестовим методом
cart = new ShoppingCart();
cart.addItem("Item1", 100.0);
}
@Test
void testCalculateTotal() {
assertEquals(100.0, cart.getTotal());
}
@Test
void testClearCart() {
cart.clear();
assertTrue(cart.isEmpty());
}
}
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ShoppingCartTest {
private ShoppingCart cart;
@BeforeEach
void setUp() {
// Runs before every test method
cart = new ShoppingCart();
cart.addItem("Item1", 100.0);
}
@Test
void testCalculateTotal() {
assertEquals(100.0, cart.getTotal());
}
@Test
void testClearCart() {
cart.clear();
assertTrue(cart.isEmpty());
}
}
@BeforeEach працює як особистий помічник перед кожним екзаменом — вона щоразу готує для вас чистий стіл та необхідні інструменти, щоб результати попереднього завдання жодним чином не вплинули на поточне.@BeforeEach annotation works like a personal assistant before every exam—it cleans the desk and sets up the necessary tools every single time so that the results of the previous task never affect the current one.@BeforeEach. Вона гарантує, що допоміжний код — наприклад, ініціалізація об'єктів або створення тестового середовища — виконується ізольовано перед кожним окремим запуском тесту. Це критично важливо для забезпечення незалежності перевірок, щоб побічні ефекти від попереднього тесту не зламали наступний. На практиці я найчастіше застошую її для створення свіжого екземпляра тестованого класу або скидання моків. Головне пам'ятати, що такий метод не повинен бути статичним, адже він працює на рівні екземпляра конкретного тесту.@BeforeEach annotation. It guarantees that helper code—such as object initialization or test environment setup—runs in isolation before each individual test execution. This is critical for ensuring test independence so that side effects from a previous test do not break the next one. In practice, I most often apply it to create a fresh instance of the class under test or reset mocks. The main thing to remember is that such a method should not be static, as it operates at the level of the specific test instance.Що це — У JUnit 5 анотація @BeforeAll використовується для виконання методу рівно один раз перед запуском усіх тестових методів у конкретному класі. Це ідеальне місце для глобальної ініціалізації середовища тестування або підготовки ресурсу, який спільно використовується всіма тестами.
What is it — In JUnit 5, the @BeforeAll annotation is used to execute a method exactly once before any test methods in a specific class are run. It is the ideal place for global test environment initialization or preparing a resource shared across all tests.
Навіщо/коли — Технічно за замовчуванням JUnit створює новий екземпляр тестового класу для кожного окремого методу (життєвий цикл PER_METHOD), щоб гарантувати повну ізоляцію тестів. Через це метод @BeforeAll обов'язково має бути оголошеним як static, оскільки він виконується ще до створення будь-якого об'єкта класу.
Why/when — Technically, by default JUnit creates a new instance of the test class for each individual method (the PER_METHOD lifecycle) to ensure complete test isolation. Because of this, the @BeforeAll method must be declared as static since it executes before any class object is instantiated.
Приклад з проєкту — На практиці в автоматизації тестування (наприклад, із Selenium, Playwright чи RestAssured) цю анотацію найчастіше застосовують для виконання "важких" операцій: запуску локального сервера, підключення до тестової бази даних, ініціалізації конфігураційних файлів чи налаштування глобальних клієнтів API. Це дозволяє уникнути дублювання ресурсів і значно прискорює загальний час виконання тестового набору.
Project example — In practice within test automation (such as with Selenium, Playwright, or RestAssured), this annotation is most commonly used for heavy operations: starting a local server, connecting to a test database, initializing configuration files, or setting up global API clients. This avoids resource duplication and significantly speeds up the overall test suite execution time.
Підводні камені — Поширеною помилкою є спроба змінити стан даних або налаштувати специфічні параметри конкретного тесту всередині @BeforeAll методу, що може призвести до тісного зв'язування тестів та появи "flaky tests". Також типовим багом є забуте ключове слово static, через яке JUnit під час старту викине помилку конфігурації.
Pitfalls — A common mistake is trying to modify data state or configure specific parameters for a particular test inside a @BeforeAll method, which can lead to tightly coupled tests and flaky test behavior. Another typical bug is forgetting the static keyword, causing JUnit to throw a configuration error at startup.
Уточнення на співбесіді — Чому @BeforeAll має бути static? Бо JUnit5 за замовчуванням створює новий екземпляр класу на кожен тест; з @TestInstance(PER_CLASS) static не обов'язковий.
Interview follow-up — Why must @BeforeAll be static? Because JUnit5 creates a new class instance per test by default; with @TestInstance(PER_CLASS) static is not required.
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
public class DatabaseIntegrationTest {
@BeforeAll
public static void setUpClass() {
// Виконується один раз перед усіма тестами класу
// Наприклад, підключення до тестової БД або старт середовища
System.out.println("--> Global setup: Database connection established.");
}
@Test
public void testFirstQuery() {
System.out.println("Executing testFirstQuery...");
}
@Test
public void testSecondQuery() {
System.out.println("Executing testSecondQuery...");
}
}
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
public class DatabaseIntegrationTest {
@BeforeAll
public static void setUpClass() {
// Runs once before all tests in the class
// For example, connecting to the test DB or starting the environment
System.out.println("--> Global setup: Database connection established.");
}
@Test
public void testFirstQuery() {
System.out.println("Executing testFirstQuery...");
}
@Test
public void testSecondQuery() {
System.out.println("Executing testSecondQuery...");
}
}
@BeforeAll працює як кнопка глобального ввімкнення важкого обладнання в лабораторії перед початком серії експериментів. Вона запускає базові налаштування лише один раз на весь клас, економлячи час виконання тестів і захищаючи систему від зайвих перезавантажень.@BeforeAll annotation acts like a master power switch for heavy lab equipment before starting a series of experiments. It runs setup routines just once per entire class, saving execution time and protecting the system from redundant reloads.@BeforeAll. Зазвичай я застосовую її для ресурсомістких операцій, таких як підключення до бази даних чи ініціалізація драйвера, щоб не витрачати час перед кожним окремим тестом. Важливо пам'ятати, що за замовчуванням метод із цією анотацією має обов'язково бути статичним, оскільки він виконується ще до створення екземпляра самого класу. Якщо мені потрібно змінити цю поведінку, я можу використати життєвий цикл PER_CLASS, але з @BeforeAll усе одно слід діяти обережно. Головне правило тут — не використовувати цей метод для зміни глобального тестового стану, щоб тести залишалися повністю незалежними.@BeforeAll annotation. I typically apply it for resource-intensive operations, such as database connections or driver initialization, so I don't waste time repeating it before every single test. It's important to remember that by default, methods with this annotation must be static because they execute before any instance of the class itself is created. If I need to change this behavior, I can use the PER_CLASS lifecycle, but using it with @BeforeAll still requires caution. The golden rule here is not to use this method to modify global test state so that tests remain completely independent.Що це — У JUnit 5 для пропуску тестів використовується анотація @Disabled. Вона сигналізує тест-рушію (JUnit 5 Jupiter engine), що відповідний тест або цілий клас тестів слід пропустити під час виконання. Механізм працює на етапі виявлення тестів (test discovery), тому пропущені тести навіть не намагаються виконуватися, а фіксуються в звітах як заблоковані або пропущені (skipped), а не як такі, що впали.
What is it — In JUnit 5, tests can be skipped using the @Disabled annotation. It signals the JUnit 5 Jupiter engine to bypass the annotated test method or an entire test class during execution. This mechanism operates during the test discovery phase, meaning skipped tests are not even attempted; instead, they are recorded in execution reports as skipped rather than failed.
Навіщо/коли — Хорошою практикою є передача рядкового аргументу в анотацію, наприклад @Disabled("Pending bug fix in JIRA-1234"), щоб інші члени команди розуміли причину. Окрім безумовного пропуску, JUnit 5 пропонує потужні механізми умовного виконання, такі як @EnabledOnOs, @DisabledIfEnvironmentVariable або кастомні перевірки через @EnabledIf, що дозволяє пропускати тести залежно від середовища чи конфігурації.
Why/when — It is a best practice to pass a string argument into the annotation, such as @Disabled("Pending bug fix in JIRA-1234"), so team members understand the rationale. Beyond unconditional skipping, JUnit 5 offers powerful conditional execution mechanisms like @EnabledOnOs, @DisabledIfEnvironmentVariable, or custom evaluations via @EnabledIf, allowing tests to be skipped dynamically based on the environment or configuration.
Приклад з проєкту — На реальних проєктах @Disabled найчастіше застосовують під час рефакторингу, коли функціональність тимчасово зламана, або для тестів, що залежать від сторонніх сервісів, які наразі недоступні (наприклад, тестовий шлюз оплати лежить). Уявіть ситуацію: реліз за хвилину, а шлюз платежів на стейджинзі впав через деплой; замість падіння всього білд-пайплайну QA може тимчасово задизаблити цей конкретний інтеграційний тест.
Project example — In real-world projects, @Disabled is most commonly applied during refactoring when a feature is temporarily broken, or for tests dependent on external services that are currently down (such as a temporarily offline payment gateway). Imagine a scenario: a release is imminent, but the staging payment gateway goes down due to deployment; instead of failing the entire build pipeline, a QA engineer can temporarily disable this specific integration test.
Підводні камені — Найпоширеніша помилка новачків — використання @Disabled як постійного методу боротьби з «плаваючими» (flaky) тестами. Замість того, щоб шукати першопричину непадіння (наприклад, проблеми з таймінгами чи синхронізацією), тести просто вимикають, що створює хибне відчуття покриття і накопичує технічний борг в автоматизації.
Pitfalls — The most common mistake among beginners is using @Disabled as a permanent fix for flaky tests. Instead of investigating the root cause (such as timing or synchronization issues), tests are simply turned off, creating a false sense of coverage and accumulating technical debt in the automation suite.
Уточнення на співбесіді — Чим @Disabled відрізняється від Assumptions.assumeTrue? @Disabled вимикає тест завжди, а assumption пропускає його лише за умови під час виконання, наприклад на певному середовищі.
Interview follow-up — How does @Disabled differ from Assumptions.assumeTrue? @Disabled always turns a test off, while an assumption skips it only when a runtime condition holds, e.g. on a given environment.
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.condition.EnabledOnOs;
import org.junit.jupiter.api.condition.OS;
public class CheckoutTests {
@Test
@Disabled("Temporary disabled due to UI redesign in JIRA-5678")
void testExpressCheckoutButton() {
// Test logic for checkout
}
@Test
@EnabledOnOs(OS.LINUX)
void testLinuxSpecificFeature() {
// Runs only on Linux, skipped on Windows/Mac
}
}
@Disabled разом із поясненням причини. Це рятує білд від падінь через тимчасові проблеми в середовищі, але не замінює виправлення реальних багів чи «плаваючих» тестів.@Disabled annotation above it along with an explanatory message. This saves builds from failing due to temporary environmental issues, but it should never replace fixing actual bugs or flaky tests.Що це — Параметризація тестів у JUnit 5 — це підхід, який дозволяє виконувати один і той самий тестовий метод кілька разів із різними наборами вхідних даних, використовуючи модуль junit-jupiter-params. Замість класичної анотації @Test застосовується @ParameterizedTest, яка працює в парі з джерелами даних (sources), такими як @ValueSource, @CsvSource, @MethodSource чи @ArgumentsSource. Механізм влаштований так, що тестовий рушій (Test Engine) перед запуском розгортає потік даних і створює окреме виконання тестового методу для кожного елемента масиву чи колекції, повністю повторюючи для кожного з них життєвий цикл тесту (наприклад, виклики @BeforeEach).
What is it — Test parameterization in JUnit 5 is an approach that allows executing the same test method multiple times with different sets of input data, leveraging the junit-jupiter-params module. Instead of the classic @Test annotation, it uses @ParameterizedTest paired with data sources such as @ValueSource, @CsvSource, @MethodSource, or @ArgumentsSource. Under the hood, the test engine unpacks the data stream before execution and spins up a distinct test invocation for each item in the array or collection, cleanly repeating the test lifecycle (such as @BeforeEach hooks) for every individual data set.
Навіщо/коли — Цей підхід є критично важливим для реалізації Data-Driven Testing, особливо коли потрібно протестувати однакову бізнес-логіку з різними граничними значеннями або валідними/невалідними даними. Наприклад, у проєкті електронної комерції я використовував параметризацію для перевірки форми реєстрації користувача, де потрібно було перевірити два десятки різних форматів електронної пошти та вимоги до паролів. Замість того щоб писати 20 копій одного й того самого тесту, я застосував @CsvSource для передачі пари «email — очікуваний результат валідації», що скоротило кількість коду в рази та покращило його підтримку.
Why/when — This approach is critical for implementing Data-Driven Testing, especially when you need to test identical business logic against various boundary values or valid/invalid inputs. For instance, in an e-commerce project, I used parameterization to validate a user registration form where we needed to test two dozen different email formats and password security requirements. Instead of writing 20 copies of the exact same test, I utilized @CsvSource to pass pairs of "email — expected validation result," which dramatically reduced boilerplate code and improved maintainability.
Приклад з проєкту — Проте параметризацію не варто використовувати, якщо сценарії вимагають кардинально різного налаштування тестового середовища (mock-об'єктів, попереднього стану бази даних) або містять геть інші бізнес-кроки. Головними пастками при роботі з JUnit5 є забуте ключове слово static для методів-провайдерів у @MethodSource (що призводить до помилок компіляції або виключень у рантаймі) та неінформативні імена тестів у звітах. Щоб останнього уникнути, завжди варто налаштовувати параметр name в анотації @ParameterizedTest, аби в Allure-звітах чи консолі чітко бачити, який саме набір даних виконувався.
Project example — However, parameterization should be avoided if the scenarios require radically different context setups (mock objects, database pre-states) or involve completely distinct business workflows. Major pitfalls when working with JUnit5 include forgetting the static keyword for data provider methods in @MethodSource (which leads to runtime errors or compilation issues) and uninformative test names in reports. To avoid the latter, you should always configure the name attribute in the @ParameterizedTest annotation, ensuring that Allure reports or console logs clearly show which specific dataset was executed.
Підводні камені: Недостатня ізоляція тестів може призвести до залежності один від одного, що робить автоматизацію flakey та непередбачуваною.
Уточнення на співбесіді — Коли @CsvSource вже не вистачає? Коли потрібні складні об'єкти — тоді @MethodSource, що повертає Stream<Arguments>, або @CsvFileSource для великих таблиць.
Підводні камені (Pitfalls): Insufficient test isolation can lead to inter-dependencies, rendering the automation flaky and unpredictable.
Interview follow-up — When is @CsvSource no longer enough? When you need complex objects — then @MethodSource returning Stream<Arguments>, or @CsvFileSource for large tables.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;
public class UserRegistrationTest {
@ParameterizedTest(name = "Run #{index}: email={0}, isValid={1}")
@CsvSource({
"test.user@example.com, true",
"invalid-email-format, false",
"@missing-username.com, false",
"user.name@domain.co.uk, true"
})
void testEmailValidation(String email, boolean expectedResult) {
boolean actualResult = UserValidator.isValidEmail(email);
assertEquals(expectedResult, actualResult, "Email validation failed for: " + email);
}
}
junit-jupiter-params разом із анотацією @ParameterizedTest. Замість звичайного @Test ми вказуємо цю анотацію та підключаємо джерело даних, наприклад, @ValueSource для простих масивів, @CsvSource для набору пар значень або @MethodSource для складніших об'єктів. Під капотом рушій JUnit перед запуском розгортає цей потік даних і виконує тест окремо для кожного елемента чи рядка. У реальних проєктах це надзвичайно зручно для перевірки валідаційних форм, розрахунків чи граничних значень, оскільки позбавляє від копіювання однакового коду. Головне — не забувати робити методи-провайдери для @MethodSource статичними та гарно налаштовувати назви тестів через параметр name, щоб звіти виглядали інформативно.junit-jupiter-params module along with the @ParameterizedTest annotation. Instead of a regular @Test, we apply this annotation and link a data source, such as @ValueSource for simple arrays, @CsvSource for value pairs, or @MethodSource for more complex objects. Under the hood, the JUnit engine unpacks this data stream before execution and runs the test independently for each item or row. In real projects, this is extremely useful for checking validation forms, calculations, or boundary values, as it prevents duplicating identical code. The main things to keep in mind are making provider methods for @MethodSource static and properly configuring test names using the name attribute so that reports remain clear and informative.Що це — Анотація @ExtendWith у JUnit 5 є ключовим елементом сучасної моделі розширень (Extension Model), яка використовується для декларативного підключення кастомної поведінки до тестів або тестових класів. Вона повністю замінює застарілі механізми @RunWith та @Rule з JUnit 4, роблячи архітектуру фреймворку більш модульною та дозволяючи комбінувати кілька незалежних розширень для одного тестового класу.
What is it — The @ExtendWith annotation in JUnit 5 is a core element of the modern Extension Model, used to declaratively attach custom behavior to tests or test classes. It completely replaces the legacy @RunWith and @Rule mechanisms from JUnit 4, making the framework architecture more modular and allowing multiple independent extensions to be combined for a single test class.
Навіщо/коли — Механізм роботи @ExtendWith базується на спеціальних інтерфейсах-колбеках (Extension APIs), таких як BeforeEachCallback, AfterTestExecutionCallback чи ParameterResolver. Коли JUnit 5 сканує тестовий клас із цією анотацією, він створює екземпляр вказаного класу розширення та автоматично викликає його методи у чітко визначених точках життєвого циклу тестів (Test Lifecycle), забезпечуючи гнучке керування середовищем без жорсткого зв’язування через спадкування.
Why/when — The mechanism behind @ExtendWith is based on specialized callback interfaces (Extension APIs) such as BeforeEachCallback, AfterTestExecutionCallback, or ParameterResolver. When JUnit 5 scans a test class with this annotation, it instantiates the specified extension class and automatically invokes its methods at precisely defined points in the test lifecycle, ensuring flexible environment management without tight coupling via inheritance.
Приклад з проєкту — На реальних проєктах автоматизації тестування @ExtendWith найчастіше застосовується для вирішення крос-доменних завдань: інтеграції з веб-драйвером, управління станом бази даних, кастомного логування або автоматичного створення скріншотів чи збору логів при падінні тестів. Наприклад, ви можете створити розширення ScreenshotOnFailureExtension, яке реалізує інтерфейс TestExecutionExceptionHandler, і просто додати його анотацією над базовим класом вашого фреймворку.
Project example — In real test automation projects, @ExtendWith is most commonly applied for cross-cutting concerns: WebDriver integration, database state management, custom logging, or automatically capturing screenshots and logs upon test failures. For instance, you can create a ScreenshotOnFailureExtension implementing the TestExecutionExceptionHandler interface and simply attach it via annotation above your framework's base class.
Підводні камені — Головною помилкою при роботі з розширеннями є порушення безпеки потоків (Thread Safety) під час паралельного запуску тестів (Parallel Execution). Оскільки екземпляри розширень часто керуються самим фреймворком або існують в єдиному екземплярі, збереження мутабельного стану тесту в полях класу розширення призводить до непередбачуваних падінь і «флейкі» тестів. Крім того, варто уникати надмірного ускладнення логіки через створення занадто довгих ланцюжків розширень там, де можна обійтися звичайними викликами методів.
Pitfalls — A major mistake when working with extensions is violating thread safety during parallel test execution. Since extension instances are often managed by the framework or shared, storing mutable test state inside extension class fields leads to unpredictable failures and flaky tests. Additionally, you should avoid overcomplicating logic by creating excessively long extension chains where simple method calls would suffice.
Уточнення на співбесіді — Чим @ExtendWith відрізняється від @RegisterExtension? Перше реєструє розширення декларативно, друге — через поле, коли розширення треба налаштувати в коді.
Interview follow-up — How does @ExtendWith differ from @RegisterExtension? The first registers an extension declaratively, the second through a field when the extension must be configured in code.
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestExecutionExceptionHandler;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.Test;
public class ScreenshotExtension implements TestExecutionExceptionHandler {
@Override
public void handleTestExecutionException(ExtensionContext context, Throwable throwable) throws Throwable {
String testName = context.getRequiredTestMethod().getName();
System.out.println("Test failed: " + testName + ". Taking screenshot...");
// Логіка збереження скріншота у сховище артефактів
throw throwable; // Обов'язково прокидаємо помилку далі
}
}
@ExtendWith(ScreenshotExtension.class)
public class LoginTests {
@Test
public void testInvalidCredentials() {
// Тестовий сценарій, який завершиться помилкою
org.junit.jupiter.api.Assertions.fail("Forced failure");
}
}
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestExecutionExceptionHandler;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.Test;
public class ScreenshotExtension implements TestExecutionExceptionHandler {
@Override
public void handleTestExecutionException(ExtensionContext context, Throwable throwable) throws Throwable {
String testName = context.getRequiredTestMethod().getName();
System.out.println("Test failed: " + testName + ". Taking screenshot...");
// Logic that saves a screenshot to artifact storage
throw throwable; // Always rethrow the error
}
}
@ExtendWith(ScreenshotExtension.class)
public class LoginTests {
@Test
public void testInvalidCredentials() {
// A test scenario that will fail
org.junit.jupiter.api.Assertions.fail("Forced failure");
}
}
@ExtendWith — це зручний «плагін» для ваших тестів у JUnit 5. Замість того, щоб писати однакову допоміжну логіку налаштування чи обробки помилок у кожному класі, ви виносите її в окремий клас-розширення та підключаєте однією компактною анотацією.@ExtendWith acts as a convenient plug-in for your tests in JUnit 5. Instead of writing boilerplate setup or error-handling logic in every single class, you encapsulate it in a separate extension class and attach it using a single compact annotation.@ExtendWith використовується для підключення кастомних розширень і повністю замінює застарілі механізми @RunWith та @Rule з четвертої версії. Вона дозволяє декларативно додавати до тестових класів різноманітну крос-доменну поведінку, наприклад, інтеграцію з веб-драйвером, управління базою даних чи обробку виключень. Технічно це реалізовано через спеціальні інтерфейси-колбеки, які фреймворк викликає у чітко визначені моменти життєвого циклу тесту. На практиці я активно використовую це для автоматичного створення скріншотів при падінні тестів або ін'єкції параметрів. Головне, про що важливо пам'ятати — це забезпечення потокобезпеки при паралельному запуску тестів, аби розширення не зберігало спільний мутабельний стан.@ExtendWith annotation is used to plug in custom extensions and completely replaces the legacy @RunWith and @Rule mechanisms from version four. It allows us to declaratively add cross-cutting behavior to test classes, such as WebDriver integration, database management, or exception handling. Technically, this is implemented through special callback interfaces that the framework triggers at precise points in the test lifecycle. In practice, I actively use this for tasks like automatically taking screenshots on test failures or parameter injection. The main thing to keep in mind is ensuring thread safety during parallel test execution so that the extension doesn't store shared mutable state.Що це — За замовчуванням JUnit 5 виконує тестові методи у випадковому, хоча й детермінованому порядку, заснованому на хеш-кодах методів. Це зроблено навмисно, щоб запобігти появі прихованих залежностей між тестами та змусити розробників писати повністю ізольовані юніт-тести.
What is it — By default, JUnit 5 executes test methods in a randomized yet deterministic order based on their method hash codes. This is done intentionally to prevent hidden dependencies between tests and force developers to write fully isolated unit tests.
Навіщо/коли — Проте на практиці іноді виникає необхідність керувати порядком виконання. Для цього використовується аннотація класу @TestMethodOrder, куди передається стратегія сортування (наприклад, MethodOrderer.OrderAnnotation.class). Після цього кожен метод можна пронумерувати за допомогою аннотації @Order(1), @Order(2) і так далі. Існують й інші стратегії, такі як MethodName (сортування за алфавітом) чи Random (рандомізація).
Why/when — However, in practice, there is sometimes a need to control execution order. This is achieved using the class-level annotation @TestMethodOrder, which accepts a sorting strategy (e.g., MethodOrderer.OrderAnnotation.class). You can then explicitly number each method using the @Order(1), @Order(2) annotations. Other strategies include MethodName (alphabetical sorting) or Random.
Приклад з проєкту — Цей підхід критично важливий під час написання інтеграційних тестів, багатокрокових сценаріїв (End-to-End) або міграційних тестів бази даних, де кожен наступний крок спирається на результат попереднього. Проте його категорично не варто використовувати для звичайних юніт-тестів, оскільки жорсткий порядок руйнує концепцію незалежності тестів і ускладнює їх паралельний запуск.
Project example — This approach is critical when writing integration tests, multi-step end-to-end scenarios, or database migration tests where each subsequent step relies on the previous one's outcome. However, it should strictly be avoided for standard unit tests because fixed ordering breaks test independence and prevents efficient parallel execution.
Підводні камені — У реальному проєкті я стикався з цим під час тестування модуля оформлення замовлення в інтернет-магазині. Тестовий клас перевіряв чітку послідовність: додавання товару в кошик -> застосування промокоду -> введення даних доставки -> фінальна оплата. Використання @TestMethodOrder дозволило вишикувати ці кроки в логічний ланцюжок усередині одного інтеграційного класу без створення громіздких мега-тестів.
Pitfalls — In a real project, I encountered this when testing an e-commerce checkout module. The test class verified a strict sequence: adding an item to the cart -> applying a promo code -> entering shipping details -> final payment. Using @TestMethodOrder allowed me to chain these steps logically inside a single integration test class without resorting to massive, monolithic test methods.
Уточнююче питання — Найпоширеніша помилка при цьому — створення «крихких» тестів через передачу стану між ними. Наприклад, коли перший тест створює запис у базі даних, а другий тест очікує цей запис, але падає, якщо перший тест пропущено або завершився помилкою. Навіть при впорядкуванні краще використовувати спільні ініціалізатори (@BeforeEach) або незалежні фабрики тестових даних, щоб мінімізувати зв'язність.
Follow-up question — The most common mistake when doing this is creating "brittle" tests by passing mutable state between them. For instance, Test 1 creates a database record, and Test 2 relies on it, failing if Test 1 is skipped or fails. Even with ordering, it is best to use shared initializers (@BeforeEach) or independent data factories to minimize tight coupling.
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class CheckoutIntegrationTest {
@Test
@Order(1)
void testAddItemToCart() {
// Логіка додавання товару
}
@Test
@Order(2)
void testApplyPromoCode() {
// Логіка застосування промокоду
}
@Test
@Order(3)
void testCompleteCheckout() {
// Логіка оплати та завершення замовлення
}
}
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class CheckoutIntegrationTest {
@Test
@Order(1)
void testAddItemToCart() {
// Add-to-cart logic
}
@Test
@Order(2)
void testApplyPromoCode() {
// Promo code logic
}
@Test
@Order(3)
void testCompleteCheckout() {
// Payment and checkout logic
}
}
@TestMethodOrder та @Order.@TestMethodOrder and @Order annotation combination.Що це — Анотація @DisplayName у JUnit 5 дозволяє розробникам та тестувальникам задавати власні, читабельні назви для тестових класів та методів, які відображаються у звітах та середовищі розробки (IDE). За замовчуванням тестові фреймворки використовують технічні назви методів (наприклад, shouldThrowExceptionWhenAgeIsNegative), але @DisplayName дозволяє перевизначити їх за допомогою звичайного тексту, пробілів, спецсимволів чи навіть емодзі, роблячи звітність зрозумілою для всієї команди.
What is it — The @DisplayName annotation in JUnit 5 allows developers and testers to declare custom, human-readable display names for test classes and methods, which appear in test reports and IDEs. By default, test frameworks rely on technical method names (e.g., shouldThrowExceptionWhenAgeIsNegative), but @DisplayName lets you override them using plain text, spaces, special characters, or even emojis, making reporting accessible to the entire team.
Навіщо/коли — Цей механізм працює на рівні метаданих JUnit 5 під час виявлення тестів (Test Discovery). Коли запускається звітність (наприклад, Surefire у Maven чи інструменти на кшталт Allure), тестовий рушій зчитує цю анотацію та підмінює стандартний рядок ідентифікатора методу на кастомний опис у звіті. На практиці це незамінно під час демонстрації результатів тестування бізнес-аналітикам або Product Owner-ам, коли ми перевіряємо складні сценарії електронної комерції чи фінансових транзакцій і хочемо бачити чіткий опис бізнес-правила замість ламаного camelCase.
Why/when — This mechanism works via JUnit 5 metadata during Test Discovery. When reporting tools (like Maven Surefire or Allure) run, the test engine reads this annotation and swaps the standard method identifier with the custom description in the report. In practice, this is indispensable when demonstrating test results to Business Analysts or Product Owners, especially when verifying complex e-commerce or financial transaction scenarios where a clear business rule description is preferred over broken camelCase.
Приклад з проєкту — Проте важливо дотримуватися балансу та уникати типових помилок. Поширена помилка новачків — дублювати назву методу простою заміною літер на пробіли або писати занадто довгі, розмиті речення, які перевантажують звіт. Крім того, не варто зловживати @DisplayName для кожного маленького юніт-тесту службового класу (наприклад, гетерів/сетерів), адже стандартні технічні назви там є достатніми й швидшими у підтримці. Головне правило — використовувати її там, де бізнес-логіка потребує гарного опису очікуваного результату для стейкхолдерів.
Project example — However, it is important to strike a balance and avoid common pitfalls. A frequent beginner mistake is simply duplicating the method name into words or writing overly long, vague sentences that clutter the report. Furthermore, avoid overusing @DisplayName for every tiny unit test of utility classes (like getters/setters), as standard technical names are sufficient and easier to maintain there. The golden rule is to use it where business logic demands a nice expected outcome description for stakeholders.
Підводні камені: Недостатня ізоляція тестів може призвести до залежності один від одного, що робить автоматизацію flakey та непередбачуваною.
Уточнення на співбесіді — Чи впливає @DisplayName на запуск тестів з командного рядка? Ні, фільтри Maven Surefire працюють з іменами класів і методів, а @DisplayName лише для звітів.
Підводні камені (Pitfalls): Insufficient test isolation can lead to inter-dependencies, rendering the automation flaky and unpredictable.
Interview follow-up — Does @DisplayName affect running tests from the command line? No, Maven Surefire filters use class and method names; @DisplayName is only for reports.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@DisplayName("Shopping Cart Checkout Service Tests")
class CartCheckoutTest {
@Test
@DisplayName("🚫 Should reject checkout when the cart is completely empty")
void testEmptyCartCheckout() {
// Test implementation
}
@Test
@DisplayName("💳 Should successfully process payment with a valid promo code")
void testCheckoutWithValidPromo() {
// Test implementation
}
}
Що це — У JUnit 5 групування тестів реалізовано за допомогою анотації @Tag, яка стала сучасною та гнучкішою заміною концепції груп із TestNG. Замість успадкування від складних базових класів чи громіздких XML-файлів конфігурації, розробники та QA-інженери можуть маркувати окремі тестові методи або цілі класи однією чи кількома мітками, що дозволяє легко керувати вибором тестів для виконання.
What is it — In JUnit 5, test grouping is implemented using the @Tag annotation, which serves as a modern and more flexible replacement for the TestNG group concept. Instead of inheriting from complex base classes or relying on bulky XML configuration files, developers and QA engineers can mark individual test methods or entire classes with one or more labels, making it easy to manage test selection for execution.
Навіщо/коли — Цей механізм працює через передачу умов фільтрації безпосередньо у тестовий рушій (JUnit Platform). Під час запуску через середовище розробки (IDE) або системи збирання на кшталт Maven (Surefire плагін) чи Gradle, рушій сканує анотації та виконує лише ті тести, які відповідають заданому виразу включення чи виключення (наприклад, через параметри groups чи includeTags), суттєво оптимізуючи час прогону набору тестів.
Why/when — This mechanism works by passing filtering conditions directly to the test engine (JUnit Platform). When executed via an IDE or build systems like Maven (Surefire plugin) or Gradle, the engine scans the annotations and runs only those tests that match the specified include or exclusion expressions (for example, via includeTags parameters), significantly optimizing test suite execution time.
Приклад з проєкту — У реальних проєктах цей підхід критично важливий для побудови ефективних CI/CD пайплайнів. Наприклад, ми маркуємо швидкі модульні тести тегом @Tag("unit"), а важкі взаємодії з базою даних — @Tag("integration") або @Tag("smoke"), що дозволяє запускати швидкі перевірки на кожен коміт, а повну регресію та інтеграційні сценарії залишати для нічних білдів.
Project example — In real-world projects, this approach is critical for building efficient CI/CD pipelines. For example, we mark fast unit tests with @Tag("unit") and heavy database interactions with @Tag("integration") or @Tag("smoke"), allowing us to run quick checks on every commit while leaving full regression and integration scenarios for nightly builds.
Підводні камені — Цю практик варто застосовувати у будь-яких проєктах середнього та великого масштабу, проте слід уникати надмірного дроблення — створення унікального тегу для кожного окремого методу перетворює конфігурацію на хаос. Найпоширенішою помилкою є припущення друкарських помилок у назвах тегів між кодом тестів та налаштуваннями Maven/Gradle, через що частина критичних тестів може мовчки випадати з прогону.
Pitfalls — This practice should be applied in any medium-to-large-scale project, but you should avoid excessive fragmentation—creating a unique tag for every single method turns the configuration into chaos. A common mistake is making typos in tag names between the test code and Maven/Gradle configurations, which can cause critical tests to silently be omitted from execution.
Уточнення на співбесіді — Як запустити лише тести з тегом smoke у Maven? Через властивість groups у Surefire, наприклад mvn test -Dgroups=smoke, або excludedGroups для виключення.
Interview follow-up — How do you run only smoke-tagged tests in Maven? Through the groups property of Surefire, e.g. mvn test -Dgroups=smoke, or excludedGroups to exclude.
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("fast")
public class UserServiceTest {
@Test
@Tag("unit")
void testUserCreation() {
assertTrue(true);
}
@Test
@Tag("database")
@Tag("integration")
void testUserDatabaseSave() {
assertTrue(true);
}
}
@Tag. Ви просто ставите мітку на тест чи клас (наприклад, @Tag("smoke")), а потім у налаштуваннях Maven чи Gradle вказуєте, які саме мітки потрібно запускати. Це рятує, коли потрібно швидко перевірити основний функціонал без запуску всіх важких тестів.@Tag annotation. You simply add a label to a test or class (e.g., @Tag("smoke")), and then specify in your Maven or Gradle settings which exact tags should be executed. This is a lifesaver when you need to quickly check core functionality without running all heavy tests.@Tag, яка є гнучкою заміною старим групам із TestNG. Я можу ставити цю анотацію як на окремі тестові методи, так і на цілі класи, позначаючи їх тегами на кшталт "unit", "integration" чи "smoke". Потім під час запуску через Maven, Gradle чи IDE я налаштовую фільтрацію, щоб виконувати виключно потрібні категорії тестів. Це надзвичайно зручно для налаштування CI/CD пайплайнів, наприклад, коли на кожен пуш ми запускаємо лише швидкі unit-тести, а важкі інтеграційні перевірки залишаємо для нічних збірок. Головне — стежити за консистентністю назв тегів у коді та конфігураціях збирача, щоб уникнути ситуацій, коли через звичайну опечатку важлива частина тестів не запускається.@Tag annotation, which serves as a flexible replacement for the old TestNG groups. I can apply this annotation to individual test methods or entire classes, labeling them as "unit", "integration", or "smoke". Then, when running tests via Maven, Gradle, or an IDE, I configure filtering to execute only the required categories of tests. This is extremely convenient for setting up CI/CD pipelines—for instance, running only fast unit tests on every push while leaving heavy integration checks for nightly builds. The main thing is to maintain consistency in tag names between the code and build configurations to avoid situations where important tests are skipped due to a simple typo.Що це — assertThrows у JUnit5 — це спеціальний метод-утвердження (assertion), який використовується для перевірки того, чи викидає певний шматок коду (зазвичай лямбда-вираз) очікуваний тип винятку (exception). Це ключовий інструмент для тестування негативних сценаріїв, коли програма повинна завадити подальшому виконанню через невалідні вхідні дані, порушення бізнес-правил або помилки стану.
What is it — assertThrows in JUnit5 is a dedicated assertion method used to verify that a specific block of code (typically a lambda expression) throws an expected type of exception. It is a key tool for testing negative scenarios where the program must abort execution due to invalid input data, business rule violations, or state errors.
Навіщо/коли — Механізм роботи базується на перехопленні винятку всередині виконання методу assertThrows. Метод приймає два основних аргументи: клас очікуваного винятку (наприклад, IllegalArgumentException.class) та функціональний інтерфейс Executable (найчастіше у вигляді лямбди () -> service.doSomething()). Якщо код усередині лямбди викидає потрібний виняток, assertThrows успішно завершує тест і навіть повертає об'єкт цього винятку, що дозволяє додатково перевірити його текст (getMessage()). Якщо ж виняток не викидається взагалі або викидається інший тип — тест падає.
Why/when — Its mechanism is based on catching the exception during the execution wrapper of assertThrows. The method takes two main arguments: the expected exception class (e.g., IllegalArgumentException.class) and an Executable functional interface (usually as a lambda () -> service.doSomething()). If the code inside the lambda throws the required exception, assertThrows passes successfully and even returns the exception object, allowing you to further assert its text (getMessage()). If no exception or a different type is thrown, the test fails.
Приклад з проєкту — У реальному проєкті автоматизації assertThrows активно застосовується під час тестування валідації даних та бізнес-логіки. Наприклад, коли ми перевіряємо, чи викине метод BankAccount.withdraw() виняток InsufficientFundsException, якщо користувач намагається зняти суму, що перевищує його баланс. Без цього методу довелося б писати громіздкі конструкції try-catch із ручним викликом fail(), що робить код тестів менш читабельним та складнішим у підтриманні.
Project example — In a real automation project, assertThrows is heavily used when testing data validation and business logic. For instance, we verify whether the BankAccount.withdraw() method throws an InsufficientFundsException when a user tries to withdraw an amount exceeding their balance. Without this method, developers would have to write cumbersome try-catch blocks with manual fail() calls, making the test code less readable and harder to maintain.
Підводні камені — Поширеною помилкою серед початківців є перевірка занадто загальних класів винятків, таких як загальний Exception.class або Throwable.class, замість конкретних кастомних класів помилок. Це знижує надійність тесту, оскільки тест може успішно пройти через непередбачуваний баг (наприклад, NullPointerException), а не через очікувану бізнес-перевірку. Також часто забувають використати повернутий об'єкт винятку, щоб заасертити точне повідомлення про помилку.
Pitfalls — A common mistake among beginners is checking for overly broad exception classes like a general Exception.class or Throwable.class instead of specific custom error classes. This reduces test reliability because the test might pass due to an unexpected bug (such as a NullPointerException) rather than the expected business validation. Another common oversight is forgetting to use the returned exception object to assert the exact error message.
Уточнення на співбесіді — Чим assertThrows відрізняється від assertThrowsExactly? Перший приймає і підкласи очікуваного винятку, другий вимагає точного типу.
Interview follow-up — How does assertThrows differ from assertThrowsExactly? The first accepts subclasses of the expected exception, the second demands the exact type.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class UserServiceTest {
@Test
void shouldThrowExceptionWhenAgeIsInvalid() {
UserService userService = new UserService();
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.registerUser("John", -5),
"Expected exception to be thrown, but it wasn't"
);
assertEquals("Age cannot be negative", exception.getMessage());
}
}
assertThrows — це спосіб сказати тестовому фреймворку: «Я очікую, що цей шматок коду зламається з конкретною помилкою. Якщо потрібна помилка сталася — тест пройшов успішно». Це стандартний і найелегантніший підхід для перевірки негативних сценаріїв у тестах.assertThrows is a way to tell the testing framework: "I expect this piece of code to crash with a specific error. If the expected error happens, the test passes successfully." It is the standard and most elegant approach for verifying negative scenarios in tests.assertThrows у JUnit5 використовується спеціально для тестування негативних сценаріїв, коли нам потрібно переконатися, що код правильно реагує на невалідні дані і викидає очікуваний виняток. Він приймає клас винятку та лямбда-вираз із самим кодом, що виконується. Особисто я часто застосовую його на проєкті, наприклад, при валідації вхідних параметрів у сервісах чи контролерах. Головна перевага цього підходу порівняно зі старими конструкціями try-catch полягає в тому, що код тестів стає набагато чистішим і лаконічнішим. Крім того, assertThrows повертає об'єкт самого винятку, що дозволяє мені легко перевірити текст повідомлення про помилку через метод getMessage().assertThrows method in JUnit5 is specifically used for testing negative scenarios when we need to ensure that the code responds correctly to invalid data and throws the expected exception. It takes the exception class and a lambda expression containing the code under execution. Personally, I frequently use it on my projects, for example, when validating input parameters in services or controllers. The main advantage of this approach compared to old try-catch constructs is that the test code becomes much cleaner and more concise. Moreover, assertThrows returns the exception object itself, which allows me to easily verify the error message text using the getMessage() method.Що це — Анотація @Test у фреймворку TestNG є головним маркером, який повідомляє тест-раннеру, що конкретний Java-метод є тестовим сценарієм і підлягає виконанню. Без цієї анотації TestNG просто проігнорує метод під час запуску звітів чи тест-сьюту (test suite), оскільки сприйматиме його як звичайну службову або допоміжну функцію класу.
What is it — The @Test annotation in the TestNG framework is the primary marker that tells the test runner a specific Java method is a test scenario and should be executed. Without this annotation, TestNG will simply ignore the method during execution of a test suite, treating it as an ordinary utility or helper function.
Навіщо/коли — Під капотом TestNG використовує механізм рефлексії (Reflection API) для сканування класів під час старту тестів. Коли фреймворк знаходить методи, позначені @Test, він автоматично додає їх до черги виконання, керуючи їхнім життєвим циклом у поєднанні з іншими анотаціями на кшталт @BeforeMethod чи @AfterMethod.
Why/when — Under the hood, TestNG uses Java Reflection API to scan classes during test startup. When the framework encounters methods marked with @Test, it automatically queues them for execution, managing their lifecycle in conjunction with setup/teardown annotations like @BeforeMethod or @AfterMethod.
Приклад з проєкту — Цю анотацію варто використовувати на будь-яких публічних методах, які перевіряють певну бізнес-логіку або функціональність продукту. Вона також надає потужні додаткові атрибути, такі як priority (пріоритет виконання), dependsOnMethods (залежність від інших тестів), enabled = false (тимчасове вимкнення) та expectedExceptions (очікувані винятки).
Project example — This annotation should be used on any public methods that verify specific business logic or product functionality. It also provides powerful optional attributes such as priority (execution order), dependsOnMethods (test dependencies), enabled = false (temporary disabling), and expectedExceptions (handling expected errors).
Підводні камені — У реальному проєкті з автоматизації (наприклад, із використанням Selenium WebDriver) ми створюємо класи на зразок CheckoutTests, де кожен окремий сценарій покупки — від додавання товару до оплати — оформлюється як окремий метод із @Test. Це дозволяє легко запускати тести паралельно, групувати їх та отримувати детальні звіти у форматі TestNG Reports або Allure.
Pitfalls — In a real automation project (for instance, using Selenium WebDriver), we create classes like CheckoutTests, where each distinct purchasing scenario—from adding an item to payment—is structured as a separate method annotated with @Test. This allows for easy parallel execution, grouping, and generating detailed reports via TestNG or Allure.
Уточнююче питання — Типовою помилкою новачків є спроба зробити тестові методи private або забуття того, що TestNG вимагає від методів з @Test бути публічними (public), аби механізм рефлексії міг успішно їх викликати. Інша помилка — плутати атрибути TestNG з аналогами з JUnit 5, що призводить до помилок компіляції.
Follow-up question — A common beginner mistake is making test methods private or forgetting that TestNG requires methods marked with @Test to be public so that the reflection mechanism can successfully invoke them. Another mistake is mixing up TestNG attributes with JUnit 5 equivalents, which leads to compilation errors.
import org.testng.annotations.Test;
import org.testng.Assert;
public class LoginTests {
@Test(priority = 1, description = "Verify successful login with valid credentials")
public void testValidLogin() {
boolean isDashboardDisplayed = true; // automation logic
Assert.assertTrue(isDashboardDisplayed, "Dashboard should be visible after login");
}
@Test(priority = 2, expectedExceptions = IllegalArgumentException.class)
public void testInvalidLoginThrowsException() {
throw new IllegalArgumentException("Invalid username or password");
}
}
@Test — це своєрідний «зелений світ» для фреймворку TestNG. Побачивши її над методом, TestNG розуміє, що це самостійний тест, який треба виконати, перевірити та зафіксувати у загальному звіті про тестування.@Test annotation acts as a "green light" for the TestNG framework. Upon seeing it above a method, TestNG understands that this is an independent test to be executed, verified, and recorded in the final test report.Що це — Групи тестів у TestNG задаються за допомогою параметра groups в аннотації @Test на рівнях класів або окремих методів, а також визначаються у файлі конфігурації testng.xml. Механізм працює завдяки тому, що TestNG сканує метадані тестів під час виконання та фільтрує їх, запускаючи лише ті методи, які належать до явно вказаних у конфігурації груп чи їх комбінацій. Це дозволяє гнучко керувати обсягом тестового покриття без необхідності змінювати код самих тестів або створювати складну ієрархію папок.
What is it — Test groups in TestNG are defined using the groups parameter in the @Test annotation at the class or method level, and are configured inside the testng.xml file. The mechanism works by having TestNG scan test metadata at runtime and filter them, executing only those methods that belong to the explicitly specified groups or their combinations. This allows for flexible management of test coverage without having to alter the test code itself or create a complex folder hierarchy.
Навіщо/коли — Їх доцільно використовувати для логічного поділу тестів на функціональні категорії, такі як smoke, regression, sanity, або за середовищами виконання (наприклад, staging чи production). Проте не варто зловживати занадто дрібними групами для кожного мікромодуля, оскільки це призводить до роздутого та важкопідтримуваного testng.xml файлу. Краще дотримуватися чіткої та стандартизованої ієрархії міток.
Why/when — They are best used to logically categorize tests into functional groups such as smoke, regression, sanity, or by execution environments (e.g., staging or production). However, you should avoid overusing overly granular groups for every single micro-module, as this leads to a bloated and unmaintainable testng.xml file. It is better to stick to a clear and standardized tagging hierarchy.
Приклад з проєкту — На реальному проєкті ми використовуємо групи тестів для налаштування пайплайнів у CI/CD. Наприклад, швидкі smoke-тести з міткою "smoke" запускаються автоматично на кожен Pull Request і тривають до п'яти хвилин, тоді як повний регресійний набір із міткою "regression" запускається лише щоночі або перед викачуванням на реліз. Це суттєво економить ресурси серверів та прискорює отримання зворотного зв'язку розробниками.
Project example — In a real project, we use test groups to configure pipelines in CI/CD. For instance, quick smoke tests tagged with "smoke" run automatically on every Pull Request and take up to five minutes, whereas the full regression suite tagged with "regression" runs only nightly or before a release deployment. This significantly saves server resources and accelerates feedback for developers.
Підводні камені — Поширена помилка серед автоматизаторів — забувати включати батьківські групи або некоректно налаштовувати композицію груп (group-of-groups) у testng.xml, через що частина критичних тестів мовру пропускається під час прогону. Також інколи трапляються опечатки в назвах груп або розбіжності в регістрі між аннотацією та конфігурацією, що призводить до того, що TestNG виконує порожній набір тестів.
Pitfalls — A common mistake among automation engineers is forgetting to include parent groups or incorrectly configuring group-of-groups composition in testng.xml, which causes some critical tests to be silently skipped during execution. Another issue is occasional typos in group names or case mismatches between the annotation and configuration, resulting in TestNG executing an empty test set.
Уточнення на співбесіді — Як виключити групу з прогону в testng.xml? Через елемент exclude всередині run у блоці groups — наприклад, щоб не запускати slow на PR.
Interview follow-up — How do you exclude a group from a run in testng.xml? With an exclude element inside run in the groups block — for example, to skip slow tests on a PR.
public class AuthTests {
@Test(groups = {"smoke", "ui"})
public void testSuccessfulLogin() {
// Test implementation
}
@Test(groups = {"regression", "database"})
public void testUserTokenGeneration() {
// Test implementation
}
}
<!-- Фрагмент testng.xml -->
<suite name="Smoke Suite">
<test name="Quick Validation">
<groups>
<run>
<include name="smoke"/>
</run>
</groups>
<classes>
<class name="com.example.tests.AuthTests"/>
</classes>
</test>
</suite>
public class AuthTests {
@Test(groups = {"smoke", "ui"})
public void testSuccessfulLogin() {
// Test implementation
}
@Test(groups = {"regression", "database"})
public void testUserTokenGeneration() {
// Test implementation
}
}
<!-- testng.xml fragment -->
<suite name="Smoke Suite">
<test name="Quick Validation">
<groups>
<run>
<include name="smoke"/>
</run>
</groups>
<classes>
<class name="com.example.tests.AuthTests"/>
</classes>
</test>
</suite>
smoke чи regression, а потім у конфігураційному файлі вказуєте фреймворку, які саме мітки потрібно запустити сьогодні.smoke or regression and then instruct the framework in the configuration file which specific tags to run today.Що це — У TestNG залежності між тестами задаються за допомогою атрибутів dependsOnMethods або dependsOnGroups усередині анотації @Test. Це дозволяє чітко вказувати, що певний тест може виконуватися лише після успішного завершення іншого методу чи цілої групи методів.
What is it — In TestNG, dependencies between tests are defined using the dependsOnMethods or dependsOnGroups attributes inside the @Test annotation. This allows you to explicitly specify that a certain test can only execute after the successful completion of another method or a whole group of methods.
Навіщо/коли — Механізм працює на основі побудови графа виконання: якщо базовий (батьківський) тест падає (fails), усі залежні від нього тести автоматично отримують статус SKIP і не виконуються марно. За замовчуванням TestNG гарантує порядок виконання, але цю поведінку можна гнучко налаштовувати, наприклад, використовуючи параметр alwaysRun = true, якщо залежний метод має спрацювати навіть у разі падіння попередника (наприклад, для очищення даних чи закриття сеансу).
Why/when — The mechanism operates by building an execution graph: if the base (parent) test fails, all dependent tests automatically receive the SKIP status and are not executed in vain. By default, TestNG guarantees execution order, but this behavior can be customized, for instance, by using the alwaysRun = true parameter if the dependent method must run even if the predecessor fails (e.g., for data cleanup or session teardown).
Приклад з проєкту — На практиці це найчастіше використовується в інтеграційному або енд-ту-енд (E2E) тестуванні, де кожен наступний бізнес-крок спирається на результат попереднього. Класичний приклад — сценарій в інтернет-магазині: тест реєстрації користувача (testUserRegistration) є обов'язковим попередником для тесту додавання товару в кошик (testAddToCart), який своєю чергою веде до тесту оформлення замовлення (testCheckout).
Project example — In practice, this is most commonly used in integration or end-to-end (E2E) testing, where each subsequent business step relies on the outcome of the previous one. A classic example is an e-commerce scenario: the user registration test (testUserRegistration) is a mandatory prerequisite for adding an item to the cart (testAddToCart), which in turn leads to the checkout test (testCheckout).
Підводні камені — Проте зловживати цією фічею не варто. Головна помилка новачків — вибудовувати довгі ланцюжки залежностей там, де краще застосувати стандартні хуки на кшталт @BeforeMethod або писати повністю ізольовані модульні тести. Жорсткі залежності ускладнюють паралельне виконання тестів у кілька потоків, оскільки порушують принцип незалежності тестових кейсів і можуть призводити до неочікуваних пропусків великих блоків перевірок.
Pitfalls — However, this feature should not be overused. A common mistake among beginners is building long chains of dependencies where it is better to use standard hooks like @BeforeMethod or write fully isolated unit tests. Rigid dependencies complicate parallel test execution across multiple threads because they violate the principle of test case independence and can lead to unexpected skipping of large blocks of checks.
Уточнення на співбесіді — Чому залежності між тестами вважають ризиком? Падіння одного тесту каскадом пропускає інші і ламає паралельний запуск; краще готувати стан через API в setup.
Interview follow-up — Why are dependencies between tests considered a risk? One failure cascades into skipped tests and breaks parallel runs; it is better to prepare state via the API in setup.
public class CheckoutFlowTest {
@Test
public void testUserRegistration() {
// Логіка реєстрації користувача / User registration logic
Assert.assertTrue(true, "User registered successfully");
}
@Test(dependsOnMethods = {"testUserRegistration"})
public void testAddToCart() {
// Виконується тільки якщо реєстрація успішна / Runs only if registration succeeds
Assert.assertTrue(true, "Item added to cart");
}
@Test(dependsOnMethods = {"testAddToCart"}, alwaysRun = true)
public void testCleanupSession() {
// Виконається навіть при падінні попередніх тестів / Runs even if previous tests fail
Assert.assertTrue(true, "Session closed");
}
}
public class CheckoutFlowTest {
@Test
public void testUserRegistration() {
// User registration logic
Assert.assertTrue(true, "User registered successfully");
}
@Test(dependsOnMethods = {"testUserRegistration"})
public void testAddToCart() {
// Runs only if registration succeeds
Assert.assertTrue(true, "Item added to cart");
}
@Test(dependsOnMethods = {"testAddToCart"}, alwaysRun = true)
public void testCleanupSession() {
// Runs even if previous tests fail
Assert.assertTrue(true, "Session closed");
}
}
Що це — DataProvider у TestNG — це спеціальна анотація та механізм, який дозволяє передавати набори тестових даних безпосередньо у тестові методи. Це ключовий інструмент для реалізації Data-Driven Testing (тестування на основі даних), що дає змогу виконувати один і той самий тестовий сценарій багаторазово з різними вхідними параметрами без дублювання коду.
What is it — DataProvider in TestNG is a special annotation and mechanism that allows passing sets of test data directly into test methods. This is a key tool for implementing Data-Driven Testing, allowing the execution of the same test scenario multiple times with different input parameters without code duplication.
Навіщо/коли — Механізм роботи полягає у створенні методу, позначеного анотацією @DataProvider, який зазвичай повертає двовимірний масив об'єктів (Object[][]) або ітератор (Iterator<Object[]>). Тестовий метод прив'язується до цього провайдера через атрибут dataProvider = "імені_провайдера". Під час виконання TestNG автоматично викликає тестовий метод окремо для кожного рядка масиву даних, підставляючи значення у відповідні параметри методу.
Why/when — The mechanism involves creating a method marked with the @DataProvider annotation, which typically returns a two-dimensional array of objects (Object[][]) or an iterator (Iterator<Object[]>). The test method binds to this provider using the dataProvider = "provider_name" attribute. During execution, TestNG automatically invokes the test method separately for each row of the data array, passing the values into the corresponding method parameters.
Приклад з проєкту — Цей підхід ідеально підходить для тестування форм із великою кількістю валідацій (реєстрація, авторизація, фільтри пошуку, калькулятори кредитів), де потрібно перевірити десятки комбінацій валідних і невалідних даних. Проте DataProvider не варто використовувати, коли обсяг даних надзвичайно великий (наприклад, тисячі рядків з БД) або коли тестові дані динамічно залежать від результатів виконання інших тестів — у таких випадках краще читати дані з зовнішніх файлів (JSON, CSV, БД) або застосовувати інші паттерни.
Project example — This approach is ideal for testing forms with numerous validations (registration, authorization, search filters, loan calculators) where you need to check dozens of combinations of valid and invalid data. However, DataProvider should not be used when the dataset is extremely large (e.g., thousands of rows from a database) or when test data dynamically depends on the execution results of other tests—in such cases, it is better to read data from external files (JSON, CSV, DB) or use other patterns.
Підводні камені — У реальному проєкті я часто використовую DataProvider для автоматизації тестування форми оформлення замовлення (Checkout). Наприклад, один тестовий метод перевіряє комбінації полів "Країна", "Поштовий індекс" та "Спосіб оплати", щоб гарантувати, що для кожної країни система коректно застосовує податки та блокує невалідні індекси. Завдяки цьому замість написання 15 окремих тестів я маю один гнучкий метод із таблицею тестових сценаріїв.
Pitfalls — In a real project, I frequently use DataProvider to automate checkout form testing. For instance, one test method checks combinations of "Country", "Postal Code", and "Payment Method" fields to ensure that the system correctly applies taxes and blocks invalid postal codes for each country. As a result, instead of writing 15 separate tests, I have a single flexible method with a table of test scenarios.
Уточнююче питання — Серед найпоширеніших помилок при використанні DataProvider можна виділити невідповідність кількості параметрів у тестовому методі та кількості елементів у масиві даних (що призводить до TestNGException), захаращення тестового класу жорстко закодованими масивами великого розміру, а також забування про ввімкнення паралельного виконання (parallel = true), через що великі набори даних виконуються послідовно і сповільнюють загальний час прогону збірки.
Follow-up question — Common mistakes when using DataProvider include a mismatch between the number of parameters in the test method and the number of elements in the data array (leading to a TestNGException), cluttering the test class with large hardcoded arrays, and forgetting to enable parallel execution (parallel = true), causing large datasets to run sequentially and slow down the overall pipeline execution time.
@DataProvider(name = "loginDataProvider")
public Object[][] getLoginData() {
return new Object[][] {
{"standard_user", "secret_sauce", true},
{"locked_out_user", "secret_sauce", false},
{"invalid_username", "secret_sauce", false},
{"standard_user", "wrong_password", false}
};
}
@Test(dataProvider = "loginDataProvider")
public void testUserLogin(String username, String password, boolean expectedSuccess) {
LoginPage loginPage = new LoginPage(driver);
loginPage.enterUsername(username);
loginPage.enterPassword(password);
loginPage.clickLoginButton();
if (expectedSuccess) {
Assert.assertTrue(driver.getCurrentUrl().contains("/inventory.html"), "Login should succeed");
} else {
Assert.assertTrue(loginPage.isErrorMessageDisplayed(), "Error message should be displayed");
}
}
Що це — Для підключення DataProvider до тестового методу у TestNG використовується однойменна анотація @DataProvider, яка зв'язується з тест-методом через параметр dataProvider в анотації @Test. Механізм полягає в тому, що метод-постачальник даних повертає двовимірний масив об'єктів (Object[][]) або ітератор (Iterator
What is it — To attach a DataProvider to a test method in TestNG, you use the @DataProvider annotation along with the dataProvider attribute inside the @Test annotation. The underlying mechanism works by having the data provider method return a two-dimensional array of objects (Object[][]) or an Iterator
Навіщо/коли — Цей підхід є галузевим стандартом для реалізації Data-Driven Testing (DDT), коли один і той самий сценарій потрібно перевірити на десятках різних наборів вхідних даних. Наприклад, у проєкті електронної комерції ви можете написати єдиний тест для перевірки форми реєстрації користувача, передаючи до нього комбінації валідних та невалідних електронних адрес, паролів і граничних значень, що суттєво економить час порівняно з написанням дублюючого коду.
Why/when — This approach is the industry standard for implementing Data-Driven Testing (DDT), where a single test scenario needs to be validated against dozens of different input data combinations. For instance, in an e-commerce project, you can write a single test to verify a user registration form by feeding it various combinations of valid and invalid emails, passwords, and boundary values, which saves significant time compared to writing duplicate code.
Приклад з проєкту — Якщо DataProvider знаходиться в тому ж тестовому класі, достатньо просто вказати його ім'я. Проте у великих автоматизованих фреймворках постачальники даних зазвичай виносяться в окремі класи-утиліти для повторного використання. У такому разі в анотації @Test потрібно додатково передати атрибут dataProviderClass = ExternalDataClass.class, щоб TestNG знав, де саме шукати джерело даних.
Project example — If the DataProvider is in the same test class, you simply specify its name. However, in large automation frameworks, data providers are typically extracted into separate utility classes for reusability. In such cases, you must also pass the dataProviderClass = ExternalDataClass.class attribute in the @Test annotation so TestNG knows where to locate the data source.
Підводні камені — Найпоширеніша помилка автоматизаторів-початківців — невідповідність кількості та типів параметрів у методі DataProvider і тест-методі, що призводить до TestNGException під час запуску тестів. Інша типова проблема полягає у спробі передати змінні некоректних типів без належного приведення (casting), або некоректному управлінні станом даних при паралельному запуску тестів (parallel = true), якщо постачальник використовує спільні змінні.
Pitfalls — A very common mistake among beginner automation engineers is a mismatch in the number and types of parameters between the DataProvider method and the test method, which results in a TestNGException at runtime. Another typical issue involves trying to pass incorrect data types without proper casting, or improperly managing data state when parallel execution is enabled (parallel = true) if the provider relies on shared state variables.
Уточнення на співбесіді — Чи можна взяти DataProvider з іншого класу? Так, через атрибут dataProviderClass, але сам метод провайдера тоді має бути static.
Interview follow-up — Can you take a DataProvider from another class? Yes, with the dataProviderClass attribute, but the provider method must then be static.
public class RegistrationTests {
@DataProvider(name = "registrationData")
public Object[][] getRegData() {
return new Object[][] {
{ "valid@email.com", "Password123!", true },
{ "invalid-email", "short", false },
{ "", "", false }
};
}
@Test(dataProvider = "registrationData")
public void testUserRegistration(String email, String password, boolean expectedResult) {
RegistrationPage page = new RegistrationPage();
page.enterEmail(email);
page.enterPassword(password);
page.submit();
Assert.assertEquals(page.isRegistrationSuccessful(), expectedResult);
}
}
Що це — testng.xml — це конфігураційний файл у форматі XML, який використовується у фреймворку TestNG для організації, групування та запуску тестів у вигляді єдиного набору (suite). Він дозволяє керувати порядком виконання тестів, передавати глобальні параметри в середовище тестування, підключати слухачі (listeners) та налаштовувати паралельне виконання без необхідності змінювати вихідний код тестів.
What is it — testng.xml is a configuration file in XML format used in the TestNG framework to organize, group, and execute tests as a single suite. It allows you to manage test execution order, pass global parameters to the test environment, attach listeners, and configure parallel execution without modifying the source code of the tests.
Навіщо/коли — Механізм роботи базується на тому, що TestNG зчитує цей файл під час ініціалізації та будує динамічний план виконання (Execution Plan). Усередині файлу можна ієрархічно структурувати тести: від рівня suite до окремих test, classes та methods. Такий підхід дає змогу повністю відокремити бізнес-логіку автотестів від їхньої конфігурації та запускати різні комбінації наборів даних однією командою.
Why/when — The mechanism works by TestNG reading this file during initialization and building a dynamic execution plan. Inside the file, you can structure tests hierarchically: from the suite level down to individual test, classes, and methods. This approach completely separates test business logic from its configuration and allows launching different test suite combinations with a single command.
Приклад з проєкту — Цей файл є обов'язковим стандартом для середніх та великих проєктів з автоматизації, особливо під час інтеграції тестів у CI/CD-пайплайни (Jenkins, GitLab CI) для запуску регресійних чи крос-браузерних перевірок. Проте його не варто використовувати для локального запуску одиничних юніт-тестів, оскільки в такому разі набагато швидше і простіше запустити окремий клас чи метод безпосередньо з інтегрованого середовища розробки (IDE).
Project example — This file is an essential standard for medium and large automation projects, especially when integrating tests into CI/CD pipelines (Jenkins, GitLab CI) to run regression or cross-browser checks. However, it shouldn't be used for local execution of individual unit tests, as it is much quicker and simpler to run a single class or method directly from the IDE in those cases.
Підводні камені — У реальному проєкті з автоматизації UI-тестів за допомогою Selenium WebDriver testng.xml використовується для конфігурації паралельного запуску тестів у кількох потоках із передачею параметрів браузера через тег <parameter>. Окрім цього, у ньому прописуються кастомні слухачі (Listeners) для автоматичного створення скриншотів у разі падіння тестів та інтеграції зі звітами Allure або TestNG Reports.
Pitfalls — In a real UI automation project using Selenium WebDriver, testng.xml is used to configure parallel test execution across multiple threads while passing browser parameters via the <parameter> tag. Additionally, it specifies custom listeners for automatically capturing screenshots on test failures and integrating with Allure or TestNG reports.
Уточнююче питання — Типовою помилкою серед початківців є дублювання шляхів до класів або пакетів, через що тести можуть запускатися двічі або пропускатися взагалі. Інша критична проблема — ігнорування принципів потокобезпеки (thread safety) при паралельному запуску, коли неізольовані екземпляри драйверів чи сервісів спільно використовуються потоками, що призводить до спорадичних та непередбачуваних падінь тестів (flaky tests).
Follow-up question — A common mistake among beginners is duplicating class or package paths, which can cause tests to run twice or get skipped entirely. Another critical issue is ignoring thread safety principles during parallel execution, where non-isolated driver or service instances are shared across threads, leading to flaky and unpredictable test failures.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="Regression Suite" parallel="tests" thread-count="3">
<parameter name="browser" value="chrome"/>
<listeners>
<listener class-name="com.example.utils.AllureListener"/>
<listener class-name="com.example.utils.AnnotationTransformer"/>
</listeners>
<test name="UI Regression Tests">
<classes>
<class name="com.example.tests.LoginTest"/>
<class name="com.example.tests.CheckoutTest"/>
</classes>
</test>
</suite>
testng.xml — це пульт керування вашими автотестами. Замість того щоб запускати кожен клас вручну, ви один раз описуєте структуру у цьому файлі і можете запускати цілі пакети тестів паралельно, міняти браузери або підключати звіти однією командою.testng.xml is the remote control for your automated tests. Instead of running each class manually, you describe the structure once in this file and can run entire test packages in parallel, switch browsers, or attach reporting with a single command.testng.xml — це головний конфігураційний файл у TestNG, який дозволяє керувати тим, як, коли і в якому порядку запускатимуться наші автотести. Я активно використовую його в проєктах для організації регресійних наборів, налаштування паралельного виконання тестів у кілька потоків, а також для передачі глобальних параметрів, таких як середовище чи браузер. Наприклад, у моєму поточному проєкті цей файл допомагає запускати UI-тести паралельно для різних браузерів та підключати Allure-слухачі для звітів. Головне під час роботи з ним — пам'ятати про потокобезпеку, щоб тести не конфліктували між собою при паралельному запуску.testng.xml is the main configuration file in TestNG that allows us to control how, when, and in what order our automated tests run. I actively use it in projects to organize regression suites, configure parallel test execution across multiple threads, and pass global parameters like the environment or browser. For example, in my current project, this file helps run UI tests in parallel for different browsers and attaches Allure listeners for reports. The main thing to remember when working with it is thread safety so that tests don't conflict with each other during parallel execution.Що це — ITestListener у TestNG — це спеціалізований інтерфейс, який дозволяє перехоплювати події життєвого циклу тестів під час їх виконання. Реалізуючи його методи (такі як onTestStart, onTestSuccess, onTestFailure тощо), автоматизатор може втручатися в процес запуску тестів без зміни самих тестових класів. Це робить його ідеальним інструментом для реалізації сквозної функціональності (cross-cutting concerns), як-от звітування, логування або кастомна обробка винятків.
What is it — ITestListener in TestNG is a specialized interface that allows you to hook into the test execution lifecycle events. By implementing its callback methods—such as onTestStart, onTestSuccess, and onTestFailure—automation engineers can intercept test execution without modifying the test classes themselves. This makes it an ideal tool for handling cross-cutting concerns like reporting, logging, or custom exception handling across the entire test suite.
Навіщо/коли — Механізм роботи ґрунтується на патерні "Спостерігач" (Observer): рушій TestNG викликає відповідні методи слухача у момент переходу тестів з одного стану в інший. Зареєструвати ITestListener можна кількома способами: за допомогою анотації @Listeners(MyListener.class) на рівні класу, через конфігураційний файл testng.xml, або програмно за допомогою класу TestNG. Важливо розуміти, що слухачі працюють у контексті потоку виконання тестів, тому при паралельному запуску слід уважно ставитися до потокобезпеки (thread safety).
Why/when — The mechanism relies on the Observer pattern, where the TestNG runner invokes the listener's callback methods as tests transition between states. You can register an ITestListener in several ways: using the @Listeners(MyListener.class) annotation at the class level, via the testng.xml configuration file, or programmatically using the TestNG API. It's crucial to understand that listeners execute within the test thread context, meaning thread safety must be carefully managed when running tests in parallel.
Приклад з проєкту — У реальному проєкті з автоматизації UI-тестів ITestListener є ключовим елементом для інтеграції зі звітами та CI/CD. Наприклад, у методі onTestFailure ми можемо отримати поточний екземпляр вебдрайвера, зробити знімок екрана (screenshot) у форматі Base64, прикріпити його до звіту Allure чи ExtentReports за допомогою виклику Allure.addAttachment(), а також надіслати сповіщення про падіння. Це дозволяє централізовано керувати артефактами збоїв без дублювання коду в кожному з сотень тестів.
Project example — In a real-world UI automation project, an ITestListener is a core element for reporting and CI/CD integration. For instance, inside the onTestFailure method, we can extract the active WebDriver instance, capture a Base64 screenshot, attach it to an Allure or ExtentReports dashboard using Allure.addAttachment(), and even push failure alerts. This enables centralized management of failure artifacts without cluttering hundreds of individual test scripts with repetitive try-catch blocks.
Підводні камені — Найпоширеніша помилка при використанні ITestListener — це збереження стану у не-потокобезпечних змінних (non-thread-safe fields) усередині класу слухача під час паралельного запуску, що призводить до «змішування» даних між потоками та хибних звітів. Крім того, не варто використовувати слухачі для бізнес-логіки тестів або налаштування середовища (setup/teardown) — для цього існують анотації @BeforeMethod та @AfterMethod. Слухачі призначені виключно для спостереження та побічних ефектів.
Pitfalls — A common mistake when using ITestListener is maintaining state in non-thread-safe class fields during parallel execution, which causes data races, cross-contamination, and flaky reporting. Additionally, you should not use listeners for core test business logic, data setup, or teardown tasks—that is the responsibility of @BeforeMethod and @AfterMethod annotations. Listeners are strictly meant for observation and side-effects.
Уточнення на співбесіді — Чим ITestListener відрізняється від IRetryAnalyzer? Listener лише реагує на події тесту, а retry analyzer вирішує, чи перезапускати тест після падіння.
Interview follow-up — How does ITestListener differ from IRetryAnalyzer? A listener only reacts to test events, while a retry analyzer decides whether to rerun a test after a failure.
public class CustomTestListener implements ITestListener {
@Override
public void onTestStart(ITestResult result) {
System.out.println("Test started: " + result.getName());
}
@Override
public void onTestFailure(ITestResult result) {
System.err.println("Test failed: " + result.getName());
// Example: capturing screenshot and attaching to Allure on failure
Object currentClass = result.getInstance();
WebDriver driver = ((BaseTest) currentClass).getDriver();
if (driver != null) {
byte[] screenshot = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
Allure.addAttachment("Failure Screenshot", new ByteArrayInputStream(screenshot));
}
}
@Override
public void onTestSkipped(ITestResult result) {
System.out.println("Test skipped: " + result.getName());
}
}
ITestListener — це «слухач», який автоматично слідкує за життєвим циклом ваших тестів. Він дозволяє виконувати певні дії (наприклад, робити скриншот чи логувати помилку) саме в той момент, коли тест починається, падає чи успішно завершується, позбавляючи вас від необхідності писати однаковий код у кожному тесті.ITestListener is an observer that automatically tracks your test lifecycle events. It lets you trigger specific actions—like capturing a screenshot or logging a failure—precisely when a test starts, fails, or succeeds, saving you from writing repetitive boilerplate code inside the tests themselves.ITestListener у TestNG — це спеціальний інтерфейс, який дозволяє перехоплювати події виконання тестів, такі як початок, успіх, падіння чи пропуск. Я активно використовую його у своїх фреймворках для налаштування інтеграції зі звітами, наприклад, для автоматичного створення скриншотів та прикріплення їх до Allure, коли якийсь тест падає. Реєструвати слухача можна через анотацію @Listeners або через файл testng.xml. Головне, про що я завжди пам'ятаю на практиці — це потокобезпека, адже при паралельному запуску тестів усі змінні всередині слухача мають бути thread-safe, щоб уникнути конфліктів даних.ITestListener in TestNG is a powerful interface used to hook into test execution lifecycle events like test start, success, failure, or skip. I actively use it in my frameworks for reporting integration—for instance, to automatically capture screenshots and attach them to Allure reports whenever a test fails. You can register a listener easily via the @Listeners annotation or directly inside the testng.xml configuration file. The key thing I always keep in mind during implementation is thread-safety, because when tests run in parallel, any shared state inside the listener can lead to race conditions and corrupted test reports.Що це — У TestNG стандартні твердження (Hard Assertions через клас Assert) переривають виконання тесту одразу після першої ж помилки. Натомість soft assertions (м'які перевірки) реалізовані через спеціальний клас SoftAssert, який дозволяє тесту продовжити виконання навіть у разі падіння окремих перевірок, збираючи всі збої в пам'яті до кінця тестового методу.
What is it — In TestNG, standard assertions (Hard Assertions via the Assert class) abort test execution immediately upon the first failure. Conversely, soft assertions are implemented using a dedicated SoftAssert class, which allows the test to continue executing even when individual checks fail, collecting all failures in memory until the end of the test method.
Навіщо/коли — Механізм роботи базується на збереженні станів невдалих перевірок всередині інстансу SoftAssert. Щоб тест зрештою впав і повідомив про всі накопичені помилки, ви зобов'язані викликати метод softAssert.assertAll() наприкінці тестового методу. Якщо забути викликати цей метод, тест завершиться зі статусом PASSED, навіть якщо половина перевірок всередині провалилася.
Why/when — The mechanism relies on storing the states of failed checks inside a SoftAssert instance. For the test to ultimately fail and report all accumulated errors, you are required to call the softAssert.assertAll() method at the end of the test method. If you forget to invoke this method, the test will finish with a PASSED status even if half of the internal checks failed.
Приклад з проєкту — У реальних проєктах soft assertions найчастіше застосовуються для перевірки складних UI-форм, сутності профілю користувача або комплексних JSON-відповідей API, де є безліч незалежних полів. Наприклад, перевіряючи сторінку оформлення замовлення, ми хочемо одразу побачити статус індикатора, назву продукту, ціну та знижку одночасно, щоб отримати повну картину дефектів за один прогін.
Project example — In real-world projects, soft assertions are most commonly applied when verifying complex UI forms, user profile entities, or complex JSON API responses containing multiple independent fields. For instance, when verifying a checkout page, we want to instantly see the status of badges, product name, price, and discount simultaneously to get a complete picture of defects in a single run.
Підводні камені — Проте не варто зловживати soft assertions для критичних залежних кроків. Якщо крок авторизації в системі завершився невдачею, подальші перевірки елементів особистого кабінету втрачають будь-який сенс, і тут потрібен саме звичайний hard assert. Головна помилка початківців — масово використовувати soft assert усюди і забувати про фінальний виклик assertAll(), що створює хибне відчуття стабільності у звітах CI/CD.
Pitfalls — However, you should not abuse soft assertions for critical dependent steps. If the authorization step in the system fails, subsequent checks on dashboard elements lose all meaning, and a standard hard assert is required there instead. A major beginner mistake is overusing soft asserts everywhere and forgetting the final assertAll() call, which creates a false sense of stability in CI/CD reports.
Уточнення на співбесіді — Що буде, якщо забути викликати assertAll у SoftAssert? Усі зібрані помилки мовчки загубляться, і тест стане зеленим — класичний хибний pass.
Interview follow-up — What happens if you forget to call assertAll on SoftAssert? All collected failures are silently lost and the test goes green — a classic false pass.
import org.testng.annotations.Test;
import org.testng.asserts.SoftAssert;
public class UserProfileTest {
@Test
public void testUserProfileFields() {
SoftAssert softAssert = new SoftAssert();
String actualName = "John Doe";
String actualEmail = "john.doe@example.com";
boolean isAvatarDisplayed = false; // Suppose this check fails
// Soft assertions collect failures instead of stopping execution immediately
softAssert.assertEquals(actualName, "John Doe", "Name mismatch!");
softAssert.assertEquals(actualEmail, "john.doe@example.com", "Email mismatch!");
softAssert.assertTrue(isAvatarDisplayed, "Avatar is missing!");
// CRITICAL: Must be called at the end to evaluate all accumulated errors
softAssert.assertAll();
}
}
assertAll(), інакше тест пройде успішно попри всі помилки всередині.assertAll() at the end, otherwise the test will pass successfully despite any inner errors.SoftAssert, роблю з його допомогою звичні перевірки на кшталт assertEquals чи assertTrue, а в кінці методу обов'язково викликаю assertAll(). Цей метод збирає всі падіння разом і кидає загальний AssertionError, якщо хоча б одна перевірка не пройшла. Це надзвичайно зручно для перевірки великих об'єктів або сторінок із безліччю полів, бо дає повну картину дефектів за один запуск. Але я намагаюся не використовувати це там, де подальші кроки критично залежать від успішності попередніх.SoftAssert class, perform standard checks like assertEquals or assertTrue through it, and at the end of the method, I must call assertAll(). This method collects all failures together and throws a combined AssertionError if at least one check fails. It's extremely convenient for verifying large objects or pages with many fields because it provides a complete picture of defects in a single run. However, I avoid using it where subsequent steps critically depend on the success of prior ones.Що це — Основною командою Maven для запуску тестів є mvn test. Вона викликає відповідну фазу життєвого циклу Maven (lifecycle phase), яка відповідає за компіляцію та виконання модульних тестів у проєкті. За замовчуванням ця команда компілює основний код, компілює тестовий код і запускає всі юніт-тести, знайдені у проєкті, використовуючи зазвичай плагін Maven Surefire Plugin.
What is it — The main Maven command for running tests is mvn test. It triggers the corresponding phase of the Maven build lifecycle, which is responsible for compiling and executing unit tests in the project. By default, this command compiles the main source code, compiles the test code, and executes all unit tests found in the project, typically using the Maven Surefire Plugin.
Навіщо/коли — Механізм цієї команди базується на послідовному виконанні фаз збірки Maven. Коли ви вводите mvn test, Maven спочатку виконує фази компіляції (compile та test-compile), а потім запускає фазу test. Плагін Surefire сканує директорію src/test/java на наявність класів із назвами, що відповідають певним шаблонам (наприклад, закінчуються на Test), і виконує їх через інтегрований тестовий фреймворк, такий як JUnit або TestNG.
Why/when — The mechanism of this command is based on the sequential execution of Maven build phases. When you enter mvn test, Maven first executes the compilation phases (compile and test-compile) and then triggers the test phase. The Surefire plugin scans the src/test/java directory for classes matching specific naming patterns (such as ending with Test) and executes them via an integrated test framework like JUnit or TestNG.
Приклад з проєкту — Цю команду варто використовувати під час локальної розробки для швидкої перевірки працездатності написаного коду перед створенням коміту або пулл-реквесту. Проте її не варто використовувати для запуску інтеграційних тестів, оскільки для цього у Maven передбачена інша фаза та інший плагін (Failsafe Plugin). Також розробники часто використовують додаткові параметри, наприклад, -Dtest=ClassName, щоб запустити лише один конкретний тест замість усього набору.
Project example — This command should be used during local development for a quick sanity check of your code before creating a commit or pull request. However, it should not be used to run integration tests, as Maven provides a separate phase and plugin (the Failsafe Plugin) for that purpose. Developers also frequently use additional flags, such as -Dtest=ClassName, to run a single specific test instead of the entire suite.
Підводні камені — У реальному проєкті з автоматизації тестування на Java ви працюєте з цією командою щодня. Наприклад, після написання нових UI-тестів за допомогою Selenium WebDriver ви відкриваєте термінал у кореневій папці проєкту, де розташований файл pom.xml, і вводите mvn test. У консолі ви бачите процес виконання, кількість успішних тестів, помилки (якщо вони є), а результати та звіти автоматично зберігаються в папці target/surefire-reports.
Pitfalls — In a real-world Java test automation project, you work with this command daily. For example, after writing new UI tests using Selenium WebDriver, you open the terminal in the project's root folder where the pom.xml file is located and type mvn test. In the console, you see the execution progress, the number of passed tests, any failures, and the results and reports are automatically saved in the target/surefire-reports folder.
Уточнююче питання — Поширеною помилкою початківців є спроба запускати інтеграційні тести (які перевіряють взаємодію з БД чи API та часто мають суфікс IT) за допомогою команди mvn test. Оскільки Surefire ігнорує класи з суфіксом IT за замовчуванням, такі тести просто не виконаються, що може ввести розробника в оману. Інша поширена помилка — запуск команди поза кореневою директорією проєкту, що призводить до помилки відсутності файлу pom.xml.
Follow-up question — A common mistake for beginners is attempting to run integration tests (which verify DB or API interactions and often have the IT suffix) using the mvn test command. Since Surefire ignores classes with the IT suffix by default, such tests will simply not execute, which can mislead the developer. Another common mistake is running the command outside the project's root directory, resulting in a missing pom.xml file error.
mvn test -Dtest=LoginTest
[INFO] Scanning for projects...
[INFO] ------------------------------------------------------------------------
[INFO] Building ui-automation-framework 1.0.0
[INFO] ------------------------------------------------------------------------
[INFO] --- maven-compiler-plugin:3.8.1:compile (default-compile) @ ui-automation-framework ---
[INFO] --- maven-surefire-plugin:2.22.2:test (default-test) @ ui-automation-framework ---
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.qa.tests.LoginTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.45 s
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
mvn test — це стандартний спосіб сказати Maven: «Збери мій проєкт і запусти всі модульні тести». Вона економить час QA-інженера, автоматично знаходячи та виконуючи тестові сценарії через плагін Surefire без потреби налаштовувати запуск вручну.mvn test command is the standard way to tell Maven: "Build my project and run all unit tests." It saves a QA engineer's time by automatically discovering and executing test scenarios via the Surefire plugin without manual setup.mvn test. Вона запускає відповідну фазу життєвого циклу Maven, під час якої компілюється код і виконуються юніт-тести через плагін Surefire. Зазвичай я застосовую цю команду локально перед тим, як зробити коміт, щоб швидко перевірити працездатність коду. Якщо ж мені потрібно зекономити час і запустити лише один конкретний тест, я додаю спеціальний параметр -Dtest=ClassName. Важливо пам'ятати, що mvn test запускає саме модульні тести, тоді як для інтеграційних тестів використовується плагін Failsafe та команда mvn verify.mvn test command. It triggers the corresponding Maven lifecycle phase, which compiles the code and executes unit tests via the Surefire plugin. Typically, I run this command locally before making a commit to quickly verify code functionality. If I need to save time and run only one specific test, I add the special parameter -Dtest=ClassName. It's important to remember that mvn test runs unit tests specifically, whereas integration tests require the Failsafe plugin and the mvn verify command.Що це — Основним Maven-плагіном, який відповідає за запуск модульних (unit) та компонентних тестів на платформах JUnit і TestNG, є maven-surefire-plugin. Цей плагін глибоко інтегрований у стандартний життєвий цикл збірки Maven і автоматично активується на фазі test, скануючи проєкт на наявність тестових класів за замовчуванням або за явно вказаними шаблонами назв (наприклад, *Test.java).
What is it — The primary Maven plugin responsible for executing unit and component tests using JUnit and TestNG frameworks is the maven-surefire-plugin. This plugin is deeply integrated into the standard Maven build lifecycle and automatically triggers during the test phase, scanning the project for test classes based on default naming conventions or explicit patterns (e.g., *Test.java).
Навіщо/коли — Механізм роботи плагіна базується на створенні окремого дочірнього процесу JVM (forking), в якому ізольовано виконуються всі знайдені тести. Така ізоляція захищає процес збірки від неочікуваних падінь, критичних помилок чи витоків пам'яті всередині тестів. Після завершення прогону Surefire формує детальні звіти у форматі XML та текстових файлів у директорії target/surefire-reports, які згодом зчитуються системами CI/CD або інструментами на кшталт Allure.
Why/when — The plugin operates by forking a separate JVM process where all discovered tests run in strict isolation. This isolation protects the main build process from unexpected crashes, critical failures, or memory leaks originating from tests. Once execution finishes, Surefire generates detailed XML and plain text reports inside the target/surefire-reports directory, which are subsequently consumed by CI/CD systems or reporting tools like Allure.
Приклад з проєкту — У реальних проєктах з автоматизації цей плагін використовується щоразу, коли розробники чи QA-інженери запускають команду mvn test локально або в пайплайні. Наприклад, ми налаштовуємо maven-surefire-plugin у файлі pom.xml для передачі спеціальних параметрів, таких як запуск конкретних сюїт через <suiteXmlFiles>, фільтрація за групами TestNG або увімкнення паралельного виконання тестів задля оптимізації часу прогону регресії.
Project example — In real automation projects, this plugin is invoked whenever developers or QA engineers run the mvn test command locally or within a pipeline. For instance, we configure maven-surefire-plugin inside the pom.xml file to pass specific parameters, such as executing specific XML test suites via <suiteXmlFiles>, filtering by TestNG groups, or enabling parallel test execution to optimize regression run time.
Підводні камені — Типовою помилкою є спроба запускати важкі інтеграційні або системні тести (наприклад, ті, що вимагають повністю піднятого вебсервера чи взаємодії з реальною базою даних) безпосередньо через Surefire. Оскільки Surefire обриває життєвий цикл збірки щойно падає хоча б один тест на фазі test, для таких задач краще використовувати сусідній плагін — maven-failsafe-plugin, який призначений саме для фази інтеграційного тестування та гарантує виконання етапу очищення середовища (teardown).
Pitfalls — A common mistake is attempting to run heavy integration or system tests (such as those requiring a fully running web server or real database interactions) directly through Surefire. Because Surefire halts the build lifecycle immediately if any test fails during the test phase, such scenarios require its counterpart—the maven-failsafe-plugin, which is specifically designed for integration testing and ensures proper environment teardown even if tests fail.
Уточнення на співбесіді — Як Surefire вирішує, які класи запускати? За шаблонами імен за замовчуванням: Test*, *Test, *Tests і *TestCase; інші класи потрібно додати через includes.
Interview follow-up — How does Surefire decide which classes to run? By default name patterns: Test*, *Test, *Tests and *TestCase; other classes need to be added through includes.
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<suiteXmlFiles>
<suiteXmlFile>src/test/resources/testng-smoke.xml</suiteXmlFile>
</suiteXmlFiles>
<parallel>methods</parallel>
<threadCount>4</threadCount>
</configuration>
</plugin>
</plugins>
</build>
mvn test. Він запускає їх в ізольованому середовищі та зберігає результати перевірок у вигляді звітів, які потім аналізує система CI/CD.mvn test command. It runs them in an isolated environment and saves the test outcomes as reports for CI/CD analysis.Що це — Для запуску інтеграційних тестів у проєктах на Maven використовується плагін Maven Failsafe Plugin (maven-failsafe-plugin). На відміну від maven-surefire-plugin, який відповідає виключно за юніт-тести, Failsafe розроблений спеціально для життєвого циклу інтеграційного тестування, де часто потрібні зовнішні ресурси, такі як реляційні бази даних, тестові контейнери чи підняті вебсервери.
What is it — To run integration tests in Maven projects, the Maven Failsafe Plugin (maven-failsafe-plugin) is used. Unlike the maven-surefire-plugin, which is exclusively responsible for unit tests, Failsafe is specifically designed for the integration testing lifecycle where external resources such as relational databases, test containers, or running web servers are often required.
Навіщо/коли — Головна технічна особливість Failsafe полягає в тому, як він взаємодіє з фазами життєвого циклу збірки Maven. Він розбиває процес на три послідовні етапи: pre-integration-test (підготовка оточення, наприклад, запуск Docker-контейнерів через Testcontainers), integration-test (безпосереднє виконання тестів із суфіксом *IT.java) та post-integration-test (очищення ресурсів). Завдяки такій архітектурі та фазі verify, навіть у разі падіння самих тестів етап очищення середовища гарантовано виконується, що запобігає зависанню процесів та займанню портів у пайплайнах CI/CD.
Why/when — The key technical feature of Failsafe is how it interacts with the Maven build lifecycle phases. It splits the process into three sequential stages: pre-integration-test (environment preparation, such as starting Docker containers via Testcontainers), integration-test (actual execution of tests with the *IT.java suffix), and post-integration-test (resource cleanup). Thanks to this architecture and the verify phase, even if the tests themselves fail, the environment cleanup stage is guaranteed to run, preventing hanging processes and occupied ports in CI/CD pipelines.
Приклад з проєкту — У реальному проєкті автоматизації на Java та Spring Boot ми зазвичай конфігуруємо цей плагін у файлі pom.xml для запуску тестів, що перевіряють інтеграцію репозиторіїв із реальним PostgreSQL. Виконання команди mvn verify запускає спочатку юніт-тести на етапі test (через Surefire), потім піднімає базу даних, виконує інтеграційні тести за допомогою Failsafe, а після цього обов'язково гасить контейнери незалежно від результатів перевірки.
Project example — In a real Java and Spring Boot automation project, we typically configure this plugin in the pom.xml file to run tests that verify repository integration with a real PostgreSQL. Running the mvn verify command first executes unit tests during the test phase (via Surefire), then spins up the database, runs integration tests using Failsafe, and finally guarantees container shutdown regardless of the test outcomes.
Підводні камені — Поширена помилка новачків — спроба запускати інтеграційні тести через maven-surefire-plugin, називаючи їх класи за стандартним патерном *Test.java, або ж запуск лише цілі integration-test без подальшої фази verify. Це призводить до того, що тести або запускаються не в той момент часу і сповільнюють звичайну збірку, або залишають незакриті підключення до БД та завислі процеси, оскільки Surefire завершує збірку помилкою та пропускає етап очищення середовища.
Pitfalls — A common beginner mistake is trying to run integration tests using the maven-surefire-plugin by naming their classes with the standard *Test.java pattern, or running only the integration-test goal without the subsequent verify phase. This causes tests to either run at the wrong time and slow down standard builds, or leave open DB connections and hanging processes because Surefire fails the build and skips the environment cleanup stage.
Уточнення на співбесіді — Чому Failsafe не падає одразу на фазі integration-test? Щоб фаза post-integration-test встигла зупинити середовище; результат перевіряє ціль verify.
Interview follow-up — Why doesn't Failsafe fail right at the integration-test phase? So that post-integration-test can stop the environment; the verify goal checks the result.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.2.5</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
test), а для інтеграційних тестів, яким потрібні зовнішні сервери чи бази даних — Failsafe (фаза verify). Головна перевага Failsafe полягає в тому, що він гарантовано очистить середовище та зупинить контейнери навіть у разі падіння самих тестів.test phase), while Failsafe is used for integration tests that require external servers or databases (the verify phase). The key advantage of Failsafe is that it guarantees cleanup of the environment and stops containers even if the tests themselves fail.
🗣 Як читати вголос Для запуску інтеграційних тестів у Maven ми використовуємо плагін maven-failsafe-plugin. Він принципово відрізняється від surefire тим, що створений спеціально для роботи із зовнішніми ресурсами та забезпечує безпечний життєвий цикл з обов'язковим очищенням середовища. Failsafe міцно прив'язаний до фази verify, тому навіть якщо якісь інтеграційні тести впадуть через баги, плагін спочатку виконає пост-тестові процедури та зупинить тестові контейнери чи сервери. Зазвичай ми налаштовуємо його на пошук класів із суфіксом IT, що дозволяє чітко розмежувати швидкі юніти та важкі інтеграційні тести у наших CI/CD пайплайнах.
🗣 How to say it out loud To run integration tests in Maven, we use the maven-failsafe-plugin. It fundamentally differs from Surefire because it is built specifically to work with external resources and provides a safe lifecycle with mandatory environment cleanup. Failsafe is tightly bound to the verify phase, so even if some integration tests fail due to bugs, the plugin will first execute post-test procedures and shut down test containers or servers. Usually, we configure it to look for classes with the IT suffix, which allows us to clearly separate fast unit tests and heavy integration tests in our CI/CD pipelines.Що це — Щоб пропустити виконання тестів під час збірки проєкту за допомогою Maven, найчастіше використовують прапорець командного рядка -DskipTests або його альтернативу -Dmaven.test.skip=true. Основна різниця між ними полягає в тому, що -DskipTests компілює тестові класи, але пропускає їх безпосереднє виконання (фазу test), тоді як -Dmaven.test.skip=true повністю виключає і компіляцію, і виконання тестів.
What is it — To skip test execution during a Maven build, the most common approach is using the command-line flag -DskipTests or its alternative -Dmaven.test.skip=true. The main difference between them is that -DskipTests compiles test classes but skips their actual execution (the test phase), whereas -Dmaven.test.skip=true completely bypasses both compilation and execution of tests.
Навіщо/коли — Цей механізм працює на рівні плагіна збірки Maven Surefire Plugin (для юніт-тестів) та Maven Failsafe Plugin (для інтеграційних тестів). Передаючи цей параметр, ми змінюємо конфігурацію плагіна «на льоту», наказуючи йому проігнорувати стандартні життєві цикли тестування. Maven продовжує збирати основний код та пакувати його у JAR/WAR-артефакт, суттєво прискорюючи процес збірки.
Why/when — This mechanism works at the level of the Maven Surefire Plugin (for unit tests) and Maven Failsafe Plugin (for integration tests). By passing this parameter, we override the plugin configuration on the fly, instructing it to ignore the standard testing life cycles. Maven continues to build the main code and package it into a JAR/WAR artifact, significantly speeding up the build process.
Приклад з проєкту — Цей підхід вкрай корисний під час локальної розробки, коли розробник чи QA-інженер робить швидкі зміни у конфігурації, перевіряє збірку або хоче перевірити працездатність артефакту без очікування довготривалого юніт-тест-сьюту. Водночас це категорично заборонено використовувати у CI/CD пайплайнах для збирання артефактів, що йдуть на розгортання в середовища тестування чи продакшну.
Project example — This approach is extremely useful during local development when a developer or QA engineer makes quick configuration changes, verifies the build, or wants to check artifact functionality without waiting for a long unit test suite. At the same time, it is strictly forbidden to use this in CI/CD pipelines for building artifacts destined for deployment to test or production environments.
Підводні камені — Типовим сценарієм на проєкті є ситуація, коли локальний набір з 500+ тестів виконується понад 10 хвилин. QA-автоматизатор змінює невеликий фрагмент коду інфраструктури та хоче перевірити, чи збирається проєкт без помилок компіляції. Замість того щоби щоразу чекати виконання всіх тестів, він запускає швидку збірку з параметром пропуску, а повну перевірку залишає для нічного або гілкового пайплайну.
Pitfalls — A typical project scenario occurs when a local suite of 500+ tests takes over 10 minutes to execute. A QA automation engineer modifies a small piece of infrastructure code and wants to verify if the project compiles without errors. Instead of waiting for all tests to run every time, they trigger a quick build with the skip parameter, leaving full verification for the nightly or branch pipeline.
Уточнююче питання — Поширеною помилкою є використання лише -DskipTests у проєктах, де активно використовуються інтеграційні тести через Maven Failsafe Plugin. Оскільки Surefire та Failsafe — це різні плагіни, стандартний прапорець не пропустить інтеграційні тести (фазу integration-test). Для них потрібно додатково вказувати -DskipITs=true або використовувати загальний -Dmaven.test.skip=true.
Follow-up question — A common mistake is relying solely on -DskipTests in projects that heavily use integration tests via the Maven Failsafe Plugin. Since Surefire and Failsafe are separate plugins, the standard flag will not skip integration tests (the integration-test phase). For those, you must additionally specify -DskipITs=true or use the global -Dmaven.test.skip=true.
# Швидка збірка проєкту з пропуском юніт-тестів у командному рядку
mvn clean package -DskipTests
# Пропуск як юніт, так і інтеграційних тестів
mvn clean install -DskipTests -DskipITs
# Або фіксація пропуску безпосередньо у pom.xml в секції plugins
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0</version>
<configuration>
<skipTests>true</skipTests>
</configuration>
</plugin>
# Quick build that skips unit tests from the command line
mvn clean package -DskipTests
# Skip both unit and integration tests
mvn clean install -DskipTests -DskipITs
# Or set the skip directly in pom.xml under plugins
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0</version>
<configuration>
<skipTests>true</skipTests>
</configuration>
</plugin>
-DskipTests у командному рядку або конфігурації Surefire плагіна. Це економить час під час локальної розробки, але ніколи не має замінювати запуск тестів у CI/CD пайплайнах перед злиттям коду.-DskipTests parameter in the command line or via the Surefire plugin configuration. This saves time during local development, but should never replace running tests in CI/CD pipelines before merging code.-DskipTests у команді Maven, наприклад mvn clean package -DskipTests. Важливо розуміти різницю між skipTests та maven.test.skip: перший компілює тестові класи, але не запускає їх, тоді як другий повністю пропускає і компіляцію, і виконання. Також варто пам'ятати, що якщо в проєкті є інтеграційні тести через Failsafe plugin, для їх пропуску потрібен окремий прапорець -DskipITs. Я ніколи не використовую цей трюк у CI/CD для зборки артефактів, які йдуть на тестування чи прод, щоб випадково не задеплоїти зламаний білд.-DskipTests flag in the Maven command, like mvn clean package -DskipTests. It's important to understand the difference between skipTests and maven.test.skip: the former compiles test classes but doesn't run them, whereas the latter completely bypasses both compilation and execution. I also keep in mind that if the project has integration tests managed by the Failsafe plugin, I need a separate -DskipITs flag to skip them. I never use this shortcut in CI/CD for artifacts going to testing or production environments, to avoid accidentally deploying a broken build.Що це — pom.xml (Project Object Model) — це головний файл конфігурації та «паспорт» проєкту в системі управління збиранням Maven. Він описує всю структуру проєкту, його унікальні координати, зовнішні залежності, плагіни та життєвий цикл збирання у форматі XML.
What is it — pom.xml (Project Object Model) is the core configuration file and "passport" of a project in the Maven build management system. It describes the entire project structure, its unique coordinates, external dependencies, plugins, and the build lifecycle in XML format.
Навіщо/коли — Цей файл працює за принципом декларативного опису: ви вказуєте «що» саме потрібно вашому проєкту (наприклад, певна версія Selenium чи TestNG), а Maven самостійно вирішує «як» це отримати. Він автоматично завантажує необхідні бібліотеки з центрального репозиторію та керує транзитивними залежностями — тобто підтягує всі супутні модулі, від яких залежить основна бібліотека.
Why/when — This file works on the principle of declarative description: you specify "what" your project needs (e.g., a specific version of Selenium or TestNG), and Maven figures out "how" to get it. It automatically downloads the required libraries from the central repository and manages transitive dependencies—pulling in all companion modules that the main library relies on.
Приклад з проєкту — У реальній практиці QA-автоматизатора pom.xml є основою для побудови тест-фреймворку. Саме сюди додаються бібліотеки для роботи з браузерами, API (RestAssured), звітності (Allure) та управління тестовим середовищем. Крім того, через спеціальні плагіни (наприклад, Maven Surefire Plugin) у pom-файлі налаштовується запуск тестів під час збирання проєкту або інтеграції в CI/CD пайплайни на зразок Jenkins чи GitHub Actions.
Project example — In a QA automation engineer's real practice, pom.xml is the foundation for building a test framework. This is where libraries for browser interaction, API testing (RestAssured), reporting (Allure), and environment management are added. Furthermore, through special plugins (such as the Maven Surefire Plugin), the pom file configures test execution during the build process or integration into CI/CD pipelines like Jenkins or GitHub Actions.
Підводні камені — Найпоширенішими помилками при роботі з pom.xml є жорстке хардкодження версій бібліотек без використання тегу <properties>, що веде до конфліктів версій, а також накопичення зайвих (невикористаних) залежностей. Також критично важливо слідкувати за синтаксисом XML, адже навіть одна пропущена закриваюча тег-дужка призведе до повної зупинки збирання проєкту.
Pitfalls — The most common mistakes when working with pom.xml include hardcoding library versions without using the <properties> tag, which leads to version conflicts, as well as accumulating redundant (unused) dependencies. It is also critically important to monitor XML syntax, because even a single missed closing tag will cause the project build to fail completely.
Уточнення на співбесіді — Чим dependencyManagement відрізняється від dependencies? Перший лише фіксує версії для модулів, а другий реально підключає бібліотеки до збірки.
Interview follow-up — How does dependencyManagement differ from dependencies? The first only pins versions for modules, the second actually adds the libraries to the build.
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.qa.automation</groupId>
<artifactId>ui-test-framework</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<selenium.version>4.18.0</selenium.version>
<testng.version>7.9.0</testng.version>
</properties>
<dependencies>
<!-- Selenium WebDriver -->
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>${selenium.version}</version>
</dependency>
<!-- TestNG Framework -->
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>${testng.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
pom.xml — це головний «список покупок» та інструкція для вашого Java-проєкту. У ньому ви просто прописуєте назви та версії потрібних бібліотек (наприклад, Selenium), а Maven сам їх знаходить, завантажує і налаштовує, позбавляючи вас потреби вручну шукати та закидати файли в проєкт.pom.xml is the main "shopping list" and instruction manual for your Java project. You simply declare the names and versions of the libraries you need (like Selenium), and Maven automatically finds, downloads, and configures them, saving you from manually hunting for files and dropping them into the project.Що це — Основною командою для запуску тестів у проєкті, що використовує систему збірки Gradle, є gradle test, проте на практиці завжди використовується її безпечний аналог через обгортку — ./gradlew test. Ця команда ініціює процес компіляції як основного коду програми, так і коду тестів, після чого передає керування тестовому фреймворку (наприклад, JUnit або TestNG) для виконання сценаріїв.
What is it — The primary command for running tests in a project utilizing the Gradle build system is gradle test, though in practice, its secure wrapper equivalent ./gradlew test is always used. This command initiates the compilation process for both the main application code and test code, and then hands over execution control to the testing framework (such as JUnit or TestNG) to run the scenarios.
Навіщо/коли — Під капотом Gradle спирається на концепцію тасків (tasks) та граф залежностей. Коли ви викликаєте таск test, Gradle спочатку автоматично перевіряє та виконує попередні залежні задачі — такі як compileJava та compileTestJava. Після завершення тестів Gradle формує детальні звіти у форматі XML та HTML, які автоматично зберігаються в директорії проєкту build/reports/tests/test/index.html для зручного аналізу результатів.
Why/when — Under the hood, Gradle relies on the concept of tasks and a dependency graph. When you invoke the test task, Gradle automatically checks and executes preceding dependent tasks first—such as compileJava and compileTestJava. Once the tests finish, Gradle generates detailed reports in XML and HTML formats, which are automatically saved in the project directory at build/reports/tests/test/index.html for convenient result analysis.
Приклад з проєкту — Автоматизатори часто використовують розширені варіації цієї команди залежно від поточних завдань. Наприклад, під час локальної розробки та дебагу недоцільно запускати весь набір тестів; для цього застосовують фільтрацію за допомогою прапорця --tests, наприклад: ./gradlew test --tests "AuthTest.loginSuccess". Натомість повний запуск команди ./gradlew test зазвичай виконується на етапі регресійного тестування, перед створенням Pull Request або безпосередньо в CI/CD пайплайнах (наприклад, у Jenkins чи GitHub Actions).
Project example — Automation engineers frequently use extended variations of this command depending on their current tasks. For example, during local development and debugging, running the entire test suite is inefficient; instead, filtering is applied using the --tests flag, such as ./gradlew test --tests "AuthTest.loginSuccess". Conversely, a full execution of ./gradlew test is typically run during regression testing, before creating a Pull Request, or directly within CI/CD pipelines (such as Jenkins or GitHub Actions).
Підводні камені — Типовою помилкою серед початківців є запуск глобальної команди gradle test замість використання локального Gradle Wrapper (./gradlew), що призводить до конфліктів версій Gradle на комп'ютері розробника та на білд-сервері. Інша поширена помилка — очікування, що стандартний таск test за замовчуванням виконуватиме UI або інтеграційні тести, хоча у багатьох конфігураціях вони винесені в окремі кастомні source sets та вимагають виклику інших тасків (наприклад, ./gradlew integrationTest).
Pitfalls — A common mistake among juniors is running the global gradle test command instead of using the local Gradle Wrapper (./gradlew), which leads to Gradle version conflicts between the developer's machine and the build server. Another widespread mistake is assuming that the standard test task will execute UI or integration tests by default, whereas in many configurations, these are moved to separate custom source sets and require invoking other tasks (e.g., ./gradlew integrationTest).
Уточнення на співбесіді — Як запустити один тестовий клас у Gradle? Через фільтр --tests, наприклад ./gradlew test --tests LoginTest, і так само за шаблоном пакета.
Interview follow-up — How do you run a single test class in Gradle? With the --tests filter, e.g. ./gradlew test --tests LoginTest, and the same works with a package pattern.
$ ./gradlew test
> Task :compileJava UP-TO-DATE
> Task :compileTestJava
> Task :test
BUILD SUCCESSFUL in 4s
3 actionable tasks: 3 executed
./gradlew test. Завжди використовуйте її через обгортку (з крапкою і слешем на початку), щоб гарантувати запуск правильної версії Gradle, зашитої в проєкт../gradlew test. Always use it via the wrapper (with a dot-slash prefix) to guarantee the execution of the exact Gradle version configured for the project../gradlew test. Я завжди наголошую на важливості використання саме Gradle Wrapper, тобто з крапкою і слешем на початку, щоб уникнути розсинхронізації версій на локальній машині та в CI/CD. Ця команда послідовно компілює код, виконує юніт-тести та генерує детальний HTML-звіт у папці build. Якщо мені потрібно перевірити лише один конкретний тест під час налагодження, я додаю фільтр --tests. Варто також пам'ятати, що стандартний таск test зазвичай відповідає за юніт-тести, тоді як інтеграційні чи UI-тести найчастіше вимагають окремих кастомних тасків../gradlew test command. I always emphasize using the Gradle Wrapper with a dot and slash prefix to prevent version mismatches between the local machine and the CI/CD pipeline. This command sequentially compiles the code, executes unit tests, and generates a detailed HTML report inside the build folder. If I need to debug a single specific test, I append the --tests filter. It's also worth keeping in mind that the standard test task usually handles unit tests, while integration or UI tests most often require separate custom tasks.Що це — Gradle wrapper (gradlew для Linux/macOS та gradlew.bat для Windows) — це спеціальний набір скриптів і файлів конфігурації, який постачається разом із проєктом та дозволяє запускати збірку й тести без попереднього встановлення Gradle у самій операційній системі.
What is it — The Gradle wrapper (gradlew for Linux/macOS and gradlew.bat for Windows) is a specific set of scripts and configuration files bundled with the project that allows you to run builds and tests without having Gradle pre-installed on the operating system.
Навіщо/коли — Механізм обгортки базується на файлі gradle-wrapper.properties, де чітко вказана конкретна версія Gradle, необхідна для цього проєкту. Коли ви вперше запускаєте команду gradlew, спеціальний файл gradle-wrapper.jar автоматично перевіряє наявність потрібної версії в системі та, за потреби, завантажує її з офіційного репозиторію в локальний кеш розробника чи CI-агента.
Why/when — The wrapper mechanism relies on the gradle-wrapper.properties file, which explicitly defines the specific Gradle version required for the project. When you first execute a gradlew command, the gradle-wrapper.jar automatically checks if the required version is present locally and, if not, downloads it from the official repository into the developer's or CI agent's local cache.
Приклад з проєкту — Для QA-інженерів автоматизації Gradle wrapper є критично важливим елементом інфраструктури, оскільки він гарантує повну відтворюваність середовища (reproducibility). Завдяки йому і локальні комп'ютери команди, і сервери неперервної інтеграції (Jenkins, GitLab CI, GitHub Actions) використовують абсолютно ідентичну версію білд-системи, що повністю усуває класичну проблему «у мене все працює локально, а на пайплайні падає через невідповідність версій».
Project example — For QA automation engineers, the Gradle wrapper is a critical infrastructure element because it guarantees full environment reproducibility. Thanks to it, both the team's local machines and continuous integration servers (Jenkins, GitLab CI, GitHub Actions) use the exact same version of the build system, completely eliminating the classic "it works locally on my machine, but fails in the pipeline due to version mismatch" issue.
Підводні камені — Типовою помилкою під час роботи з Gradle wrapper є спроба завантажити та закомітити в Git увесь важкий кеш дистрибутивів (наприклад, папку dists), замість того щоб зберігати виключно легкі скрипти та конфігураційні файли. Крім того, на Unix-системах поширеною проблемою є втрата прав на виконання для файлу gradlew, через що перед запуском тестів у CI обов'язково застосовують команду chmod +x gradlew.
Pitfalls — A common mistake when working with the Gradle wrapper is attempting to commit the heavy directory of downloaded distributions (such as the dists folder) into Git, instead of tracking exclusively the lightweight scripts and configuration files. Additionally, on Unix-based systems, a frequent issue is the loss of execution permissions for the gradlew file, which is why the chmod +x gradlew command must be applied before running tests in CI.
Уточнення на співбесіді — Чи треба комітити файли wrapper у репозиторій? Так: gradlew, gradlew.bat і каталог gradle/wrapper мають бути в Git, інакше CI не зможе відтворити версію Gradle.
Interview follow-up — Should the wrapper files be committed? Yes: gradlew, gradlew.bat and the gradle/wrapper directory belong in Git, otherwise CI cannot reproduce the Gradle version.
# Локальний запуск регресійного набору тестів через обгортку Gradle
./gradlew clean test -Denv=qa -Dbrowser=chrome
# Фрагмент конфігурації кроку в GitHub Actions / GitLab CI
- name: Run UI Automation Tests
run: |
chmod +x gradlew
./gradlew runRegressionSuite --info
# Run the regression suite locally through the Gradle wrapper
./gradlew clean test -Denv=qa -Dbrowser=chrome
# A step config fragment for GitHub Actions / GitLab CI
- name: Run UI Automation Tests
run: |
chmod +x gradlew
./gradlew runRegressionSuite --info
gradlew, який самостійно завантажить і застосує саме ту версію інструменту, яка закладена розробниками.gradlew script will automatically download and apply the exact version of the tool intended by the developers.Що це — Основною командою для створення нової гілки та одночасного переходу на неї є git checkout -b <branch_name> або її сучасніший і безпечніший аналог git switch -c <branch_name>. Ця команда є комбінацією двох базових операцій: створення нового вказівника гілки від поточного стану репозиторію та миттєвого перемикання робочого середовища на неї.
What is it — The primary command to create a new branch and switch to it simultaneously is git checkout -b <branch_name> or its more modern and safer alternative git switch -c <branch_name>. This command combines two basic operations: creating a new branch pointer from the current repository state and instantly switching the working environment to it.
Навіщо/коли — Механізм цієї команди працює «під капотом» так само, як послідовне виконання двох окремих команд: спочатку створюється гілка за допомогою git branch <branch_name>, а потім активується через git checkout <branch_name> або git switch <branch_name>. Використання єдиного прапорця -b чи -c суттєво економить час розробників та тестувальників, запобігаючи зайвим ручним крокам у щоденній роботі з контролем версій.
Why/when — Under the hood, the mechanism of this command works the same as sequentially executing two separate commands: first creating the branch using git branch <branch_name>, and then activating it via git checkout <branch_name> or git switch <branch_name>. Using a single flag like -b or -c significantly saves time for developers and testers by preventing unnecessary manual steps in daily version control work.
Приклад з проєкту — У повсякденній роботі QA-інженера цю команду використовують для локального тестування нових фіч, перевірки виправлених багів перед злиттям у загальний репозиторій, або коли потрібно створити ізольоване середовище для розробки та запуску автотестів. Вона незамінна для проведення регресійного тестування окремих гілок або виконання локального аналізу проблеми без ризику зіпсувати стабільний код.
Project example — In the daily routine of a QA engineer, this command is used for local feature testing, verifying bug fixes before merging into the main repository, or when an isolated environment is needed for developing and running automated tests. It is indispensable for performing regression testing on specific branches or running local troubleshooting without risking damage to stable code.
Підводні камені — Уявіть типовий проєктний сценарій: розробник надіслав виправлення критичного бага для задачі у гілку develop, і вам потрібно перевірити цей код на своїй машині. Ви оновлюєте локальний develop, після чого створюєте робочу гілку командою git checkout -b qa/bugfix-PROJ-456. Це дозволяє зосередитися на перевірці конкретного завдання в ізольованому просторі, зберігаючи чистим основний робочий простір.
Pitfalls — Imagine a typical project scenario: a developer has submitted a fix for a critical bug to the develop branch, and you need to verify this code on your machine. You update your local develop, and then create a working branch with the command git checkout -b qa/bugfix-PROJ-456. This allows you to focus on verifying the specific task in an isolated space while keeping your main workspace clean.
Уточнююче питання — Серед поширених помилок новачків варто виділити створення гілки від застарілого стану репозиторію через відсутність попереднього оновлення (забули виконати git pull), а також спробу створити нову гілку при наявності незакомічених локальних змін. Останнє часто призводить до перенесення цих незбережених файлів у нову гілку або конфліктів. Крім того, варто пам'ятати, що команда git switch є сучаснішою і менш перевантаженою додатковим функціоналом порівняно зі старою командою checkout.
Follow-up question — Among common beginner mistakes are creating a branch from an outdated repository state due to missing a prior update (forgetting to run git pull), and attempting to create a new branch while having uncommitted local changes. The latter often leads to carrying those unsaved files over to the new branch or causing conflicts. Additionally, it is worth remembering that the git switch command is more modern and less overloaded with extra functionality compared to the legacy checkout command.
# 1. Переходимо на актуальну базову гілку та оновлюємо її
git checkout develop
git pull origin develop
# 2. Створюємо нову гілку для тестування задачі та одразу переходимо на неї
git checkout -b qa/feature-payment-validation
# Або за допомогою сучасного синтаксису switch:
git switch -c qa/feature-payment-validation
# 1. Switch to the up-to-date base branch and pull it
git checkout develop
git pull origin develop
# 2. Create a new branch for the task and switch to it at once
git checkout -b qa/feature-payment-validation
# Or with the newer switch syntax:
git switch -c qa/feature-payment-validation
git checkout -b або її сучасніший аналог git switch -c. Вона дуже зручна, тому що об'єднує дві дії в одну: створення гілки та миттєвий перехід на неї. Наприклад, перед цим я обов'язково оновлюю актуальний стан репозиторію через git pull, а потім створюю свою гілку на кшталт qa/task-number. Головне — робити це за чистого робочого стану, щоб мої локальні незбережені зміни випадково не перейшли на нову гілку.git checkout -b command or its more modern alternative git switch -c. It is very convenient because it combines two actions into one: creating the branch and switching to it instantly. For example, before doing this, I always update the current state of the repository via git pull, and then create my branch like qa/task-number. The main thing is to do this from a clean working state so my local uncommitted changes don't accidentally carry over to the new branch.Що це — Основна відмінність між git merge та git rebase полягає в тому, як вони керують та об'єднують історію коміктів. Обидві команди слугують одній меті — інтеграції змін з однієї гілки в іншу, проте роблять це принципово різними методами, що безпосередньо впливає на читабельність історії проєкту та командну роботу.
What is it — The core difference between git merge and git rebase lies in how they manage and combine commit history. Both commands serve the same purpose—integrating changes from one branch into another—yet they do so using fundamentally different methods, which directly affects project history readability and team collaboration.
Навіщо/коли — Механізм роботи merge: Команда git merge бере дві гілки та їхній спільний попередній комікт (common ancestor), після чого створює новий спільний «комікт злиття» (merge commit). Цей новий комікт має два батьківські елементи та повністю зберігає всю хронологію розгалуження та паралельної розробки у вигляді незмінного дерева.
Why/when — Merge mechanism: The git merge command takes two branches and their common ancestor, then creates a new «merge commit». This new commit has two parent elements and fully preserves the entire timeline of branching and parallel development as an immutable tree.
Приклад з проєкту — Механізм роботи rebase: Команда git rebase діє інакше: вона тимчасово знімає ваші локальні комікти з поточної гілки, перемикається на верхівку цільової гілки (наприклад, main) і послідовно «накочує» (replays) ваші комікти один за одним зверху. При цьому створюються нові комікти з новими хешами, а історія стає повністю лінійною, ніби ви писали код одразу на актуальній версії головної гілки.
Project example — Rebase mechanism: The git rebase command works differently: it temporarily removes your local commits from the current branch, switches to the tip of the target branch (e.g., main), and sequentially replays your commits one by one on top. This generates new commits with new hashes, making the history completely linear as if you wrote code directly on the latest version of the main branch.
Підводні камені — Коли що використовувати в реальних проєктах: git merge є безпечним стандартом для спільних або публічних гілок (наприклад, main, develop), оскільки він не змінює минуле і не ламає синхронізацію у колег. git rebase найкраще використовувати локально на власних фіча-гілках перед створенням Pull Request, щоб прибрати зайві проміжні комікти на кшталт «fix typo» чи «wip» і подарувати код-рев'юерам чисту лінійну історію.
Pitfalls — When to use what in real projects: git merge is a safe standard for shared or public branches (like main, develop) because it does not alter the past and doesn't break synchronization for teammates. git rebase is best used locally on your own feature branches before creating a Pull Request to remove messy intermediate commits like «fix typo» or «wip» and provide code reviewers with a clean linear history.
Уточнююче питання — Типові помилки: Головна небезпека — використання git rebase на спільних гілках з подальшим примусовим пушем (git push --force). Це повністю перезаписує історію на віддаленому сервері, через що інші члени команди стикаються з нерозв'язними конфліктами синхронізації та втратою робочого контексту.
Follow-up question — Common mistakes: The primary danger is using git rebase on shared branches followed by a force push (git push --force). This completely rewrites history on the remote server, causing other team members to face intractable sync conflicts and loss of working context.
Git Merge Graph:
A---B---C (main)
\
D---E (feature) -> merge -> A---B---C---M (main, feature merged with commit M)
Git Rebase Graph:
A---B---C (main)
\
D---E (feature) -> rebase -> A---B---C---D'---E' (feature rebased on top of main)
git merge об'єднує дві гілки, створюючи окремий комікт злиття і зберігаючи історію розгалужень незмінною. Тим часом git rebase переносить ваші комікти на верхівку іншої гілки, роблячи історію лінійною, але змінюючи хеші коміктів.git merge combines two branches, creating a dedicated merge commit while keeping the branching history intact. Meanwhile, git rebase transplants your commits onto the tip of another branch, making the history linear, though it alters commit hashes.git merge, Git створює спеціальний комікт злиття, який поєднує дві гілки, зберігаючи повну хронологію того, хто і коли вносив зміни. Це ідеально підходить для спільних гілок на зразок main, оскільки ніхто у команді не втрачає контекст розробки. З іншого боку, git rebase переносить мої локальні комікти на саму верхівку цільової гілки, створюючи чисту лінійну історію без зайвих коміктів злиття. Я зазвичай застошую rebase локально під час роботи над фічею, щоб прибрати локальне «сміття» перед створенням Pull Request. Проте я ніколи не роблю rebase на спільних гілках, бо це перезаписує історію та створює серйозні проблеми для решти команди.git merge, Git creates a special merge commit that combines two branches while preserving the full timeline of who made changes and when. This is ideal for shared branches like main because no one on the team loses the development context. On the other hand, git rebase replays my local commits on top of the target branch, creating a clean linear history without extra merge commits. I typically use rebase locally while working on a feature to clean up local clutter before opening a Pull Request. However, I never rebase shared branches because it rewrites history and creates serious issues for the rest of the team.Що це — git stash — це вбудована команда в Git, яка дозволяє тимчасово зберегти (заховати) незавершені зміни у вашій робочій директорії (working directory) та в зоні індексування (staging area), повернувши репозиторій до чистого стану останнього коміту. Це надзвичайно корисно, коли ви працюєте над завданням або проводите локальне тестування, і раптово виникає потреба переключитися на іншу гілку (наприклад, щоб терміново перевірити гарячий фікс у prod-гілці) без необхідності робити сирий або незавершений коміт.
What is it — git stash is a built-in Git command that allows you to temporarily save (stash) uncommitted changes in your working directory and staging area, returning your repository to a clean state matching the latest commit. This is extremely useful when you are working on a task or running local testing and suddenly need to switch to another branch (for example, to urgently check a hotfix on the prod branch) without having to make a raw or unfinished commit.
Навіщо/коли — Механізм git stash працює за принципом стеку (LIFO — Last In, First Out). Коли ви виконуєте команду, Git створює спеціальні внутрішні коміти, які ізолюють ваші незавершені зміни, а потім очищує робочу зону. Ви можете зберігати кілька "стешів" одночасно, і вони будуть нумеруватися в порядку черги (наприклад, stash@{0}, stash@{1}). Щоб повернути зміни назад, ви використовуєте команду git stash pop (яка застосовує зміни та видаляє їх зі стеку) або git stash apply (яка застосовує зміни, але залишає їх у стеку на випадок повторного використання).
Why/when — The git stash mechanism works on a stack principle (LIFO — Last In, First Out). When you run the command, Git creates special internal commits that isolate your uncommitted changes and then cleans your working directory. You can save multiple stashes at the same time, and they are indexed in order (e.g., stash@{0}, stash@{1}). To bring the changes back, you use git stash pop (which applies the changes and removes them from the stack) or git stash apply (which applies the changes but keeps them in the stack for reuse).
Приклад з проєкту — Цю команду варто використовувати для швидких і короткострокових перемикань контексту: наприклад, коли треба стягнути останні зміни з віддаленого репозиторію через git pull, а ваші локальні файли конфігурації конфліктують із ними. Проте git stash — це не заміна постійним комітам. Не варто тримати робочий стан у схованці тижнями чи накопичувати десятки стешів, оскільки ви легко можете заплутатися в тому, що саме в них зберігається, і втратити важливі напрацювання через випадкове очищення сховища.
Project example — This command should be used for quick, short-term context switches: for example, when you need to pull the latest changes from a remote repository via git pull, and your local configuration files conflict with them. However, git stash is not a replacement for regular commits. You should not keep your workspace in a stashed state for weeks or accumulate dozens of stashes, as you can easily get confused about what is stored in them and lose important work due to accidental clearing of the stash.
Підводні камені — Уявіть ситуацію в реальному QA-проєкті: ви ретельно налаштовуєте локальне середовище автоматизації тестування на Python/Selenium і змінюєте тестові дані. Раптово тімлід просить вас терміново перевірити критичний біг на іншій гілці (main), де стався збій збірки. Якщо ви спробуєте перемкнутися за допомогою git checkout main, Git видасть помилку через незбережені зміни. Замість того, щоб робити беззмістовний коміт на зразок "temp changes", ви виконуєте git stash, спокійно переходите на main, проводите перевірку, повертаєтеся на свою гілку й відновлюєте середовище командою git stash pop.
Pitfalls — Imagine a real-world QA project scenario: you are carefully configuring a local Python/Selenium test automation environment and modifying test data. Suddenly, your tech lead asks you to urgently check a critical test run on another branch (main) where a build failure occurred. If you try to switch branches using git checkout main, Git will throw an error due to uncommitted changes. Instead of making a meaningless commit like "temp changes", you run git stash, smoothly switch to main, perform your checks, return to your branch, and restore your environment with git stash pop.
Уточнююче питання — Найпоширеніша помилка новачків — забувати про існування накопичених стешів і застосовувати git stash pop на зовсім іншій гілці, що часто призводить до складних конфліктів злиття. Інша небезпечна помилка — занадто довге використання схованки без прив'язки до змістовних повідомлень (невикористання описових назв), через що згодом доводиться розгортати кожен стеш наосліп. Також важливо пам'ятати, що нові (не відстежувані гітом) файли за замовчуванням не потрапляють у стеш, якщо не використати прапорець -u.
Follow-up question — The most common beginner mistake is forgetting about accumulated stashes and applying git stash pop on a completely different branch, which often leads to complex merge conflicts. Another dangerous mistake is using stashes for too long without descriptive messages, making it hard to identify what each stash contains later. It is also important to remember that new untracked files are not stashed by default unless you use the -u flag.
# Перевірити поточний статус робочої директорії
git status
# Зберегти незавершені зміни у стеш з описовим повідомленням
git stash save "WIP: fix login page locators for regression tests"
# Переглянути список усіх збережених стешів
git stash list
# Відновити зміни з останнього стешу та видалити його зі списку
git stash pop
# АБО відновити конкретний стеш без його видалення зі стеку
git stash apply stash@{1}
# Check the working directory status
git status
# Save unfinished changes to the stash with a descriptive message
git stash save "WIP: fix login page locators for regression tests"
# List all saved stashes
git stash list
# Restore the latest stash and drop it from the list
git stash pop
# OR apply a specific stash without removing it from the stack
git stash apply stash@{1}
git stash — це віртуальна кишеня у Git, куди можна тимчасово скласти свої незбережені напрацювання, щоб навести лад у репозиторії, переключитися на інше завдання, а потім дістати їх назад у тому ж стані, не засмічуючи історію зайвими комітами.git stash is like a virtual pocket in Git where you can temporarily put your uncommitted work to clean up your repository, switch to another task, and then pull it right back out in the exact same state without cluttering your history with garbage commits.Що це — Git cherry-pick — це команда, яка дозволяє застосувати конкретний коміт з однієї гілки до вашої поточної активної гілки, замість того щоб виконувати повне злиття (merge) або перебазування (rebase) всієї гілки. Це надзвичайно корисний інструмент у повсякденній роботі як розробників, так і QA-інженерів, коли потрібно точково перенести виправлення чи зміну.
What is it — Git cherry-pick is a command that allows you to apply a specific commit from one branch to your current active branch, instead of performing a full merge or rebase of the entire branch. This is an extremely useful tool in the daily workflow of both developers and QA engineers when a targeted fix or change needs to be ported over.
Навіщо/коли — Під капотом git cherry-pick бере зміни (diff), запроваджені вказаним комітом, і намагається накласти їх на поточну гілку як новий коміт. Оскільки створюється новий коміт, він матиме інший хеш (SHA-1), хоча міститиме ідентичні зміни в коді. Git використовує алгоритм тристороннього злиття; якщо змінені рядки не конфліктують із поточним станом гілки, новий коміт створюється автоматично.
Why/when — Under the hood, git cherry-pick takes the diff introduced by the specified commit and tries to apply it to the current branch as a brand-new commit. Because a new commit is created, it will have a different SHA-1 hash, even though it contains identical code changes. Git uses a three-way merge algorithm; if the changed lines do not conflict with the current state of the branch, the new commit is created automatically.
Приклад з проєкту — У реальних проєктах cherry-pick найчастіше використовується для термінових хотфіксів (hotfixes). Наприклад, розробник зафіксив критичний дефект у гілці develop або іншій фіч-гілці, а QA-інженеру чи тіді потрібно швидко перевірити це виправлення на середовищі релізу, де решта нових функцій ще не готова до викатки. Замість мерджити всю сиру гілку develop у release, ви просто «висмикуєте» один конкретний коміт із багфіксом.
Project example — In real projects, cherry-pick is most commonly used for urgent hotfixes. For example, a developer fixes a critical defect in the develop branch or a feature branch, and a QA engineer or lead needs to quickly test this fix on a release environment where the rest of the new features are not yet ready for deployment. Instead of merging the entire unstable develop branch into release, you simply "pluck" that single specific commit containing the bug fix.
Підводні камені — Головна поширена помилка при використанні cherry-pick — це зловживання ним замість повноцінного мерджу, що призводить до дублювання комітів та «засмічення» історії репозиторію. Також часто виникають складні конфлікти, якщо коміт, що копіюється, залежить від інших попередніх змін, яких немає на цільовій гілці. Якщо ви робите cherry-pick, важливо розуміти контекст коду і стежити за тим, щоб не переносити зайві залежності.
Pitfalls — A common mistake when using cherry-pick is abusing it instead of a proper merge, which leads to duplicate commits and clutters the repository history. Complex conflicts also frequently occur if the copied commit depends on prior changes that are missing on the target branch. If you perform a cherry-pick, it is crucial to understand the code context and ensure you aren't accidentally bringing over unintended dependencies.
Уточнення на співбесіді — Чим cherry-pick ризикований для релізних гілок? Коміт отримує новий хеш, і якщо він залежить від інших змін, у релізі може опинитися неповна правка.
Interview follow-up — Why is cherry-pick risky for release branches? The commit gets a new hash, and if it depends on other changes, the release may end up with an incomplete fix.
# 1. Переходимо на цільову гілку (наприклад, релізну), куди потрібно перенести виправлення
git checkout release
# 2. Виконуємо cherry-pick конкретного коміту за його хешем з іншої гілки
git cherry-pick a1b2c3d4
# 3. Якщо виникають конфлікти, редагуємо файл, додаємо його до індексу та продовжуємо:
git add src/components/PaymentModal.js
git cherry-pick --continue
# Або, якщо потрібно скасувати процес cherry-pick у разі тупика:
git cherry-pick --abort
# 1. Switch to the target branch (e.g. the release branch) that needs the fix
git checkout release
# 2. Cherry-pick a specific commit by its hash from another branch
git cherry-pick a1b2c3d4
# 3. On conflicts, edit the file, add it to the index and continue:
git add src/components/PaymentModal.js
git cherry-pick --continue
# Or, to abort the cherry-pick if you are stuck:
git cherry-pick --abort
git cherry-pick — це як висмикнути лише одну конкретну сторінку з чужого блокнота і вклеїти у свій, замість того щоб переписувати чи копіювати всю книжку. Це незамінно, коли потрібен терміновий багфікс з іншої робочої гілки прямо зараз, але зливати всю гілку ще зарано чи небезпечно.git cherry-pick is like plucking a single specific page out of someone else's notebook and pasting it into yours, instead of copying or merging the entire book. It's invaluable when you need an urgent bug fix from another working branch right now, but merging the whole branch is still too premature or risky.git cherry-pick, коли виникає потреба перенести точковий коміт із виправленням бага в іншу гілку без злиття всього репозиторію. Наприклад, у нас на проєкті розробник зафіксив критичний дефект у гілці розробки, а мені як тестувальнику потрібно терміново перевірити його на релізному середовищі, куди інші сирі фічі ще не повинні потрапити. Я копіюю хеш цього коміту, перемикаюся на гілку релізу і виконую команду git cherry-pick із цим хешем. Git створює новий коміт із тими ж самими змінами на моїй поточній гілці. Звісно, іноді трапляються конфлікти коду, якщо цільова гілка занадто відрізняється, але вони вирішуються стандартним шляхом. Головне — не перетворювати cherry-pick на постійну заміну звичайному мерджу, щоб не заплутати історію дублями.git cherry-pick when I need to apply a targeted bug-fix commit to another branch without merging the entire repository. For instance, on our project, a developer fixes a critical defect in a feature branch, and I as a QA engineer need to verify it immediately on the release environment where other raw features shouldn't be deployed yet. I copy the hash of that commit, switch to the release branch, and run git cherry-pick with that hash. Git then generates a brand-new commit containing those exact changes on my current branch. Of course, code conflicts can occasionally happen if the target branch has diverged significantly, but they are resolved just like regular merge conflicts. The main rule is to avoid making cherry-pick a permanent substitute for regular merging, so we don't clutter the git history with duplicates.Що це — .gitignore — це спеціальний текстовий файл, який розміщується в корені репозиторію та містить список папок і файлів, які Git повинен повністю ігнорувати і не відстежувати. Завдяки цьому файлу локальні артефакти, системні кеші та чутливі дані не потрапляють у віддалений репозиторій (наприклад, на GitHub чи GitLab), зберігаючи історію комітів чистою та безпечною для всієї команди.
What is it — .gitignore is a special text file located at the root of a repository that contains a list of directories and files that Git should completely ignore and not track. Thanks to this file, local artifacts, system caches, and sensitive data never reach the remote repository (such as GitHub or GitLab), keeping the commit history clean and safe for the entire team.
Навіщо/коли — Механізм роботи дуже простий: коли ви виконуєте команди на кшталт git status або git add ., Git спочатку перевіряє правила, прописані у .gitignore. Якщо назва чи шлях файлу збігаються з маскою у цьому файлі (наприклад, *.log або node_modules/), Git просто ігнорує їх під час сканування робочої директорії, і вони ніколи не стають кандидатами на створення коміту.
Why/when — The underlying mechanism is straightforward: when you run commands like git status or git add ., Git first checks the patterns defined in .gitignore. If a file name or path matches a rule inside this file (for example, *.log or node_modules/), Git simply ignores them while scanning the working directory, and they never become candidates for a commit.
Приклад з проєкту — У повсякденній роботі QA-інженера (як ручного, так і автоматизатора) до .gitignore обов'язково додають: середовища розробки (наприклад, папки .idea/ для IntelliJ IDEA чи .vscode/), файли конфігурації з паролями та токенами (.env), а також результати виконання тестів (папки зі звітами Allure, Playwright або Cypress, лог-файли та тимчасові дампи бази даних).
Project example — In the daily work of a QA engineer (both manual and automation), .gitignore typically includes: IDE settings (such as .idea/ for IntelliJ IDEA or .vscode/), configuration files containing passwords and tokens (.env), and test execution results (folders with Allure, Playwright, or Cypress reports, log files, and temporary database dumps).
Підводні камені — Типовий реальний сценарій: під час запуску локальних UI-тестів автоматизатор генерує тисячі скріншотів падінь та важких HTML-звітів загальним обємом у сотні мегабайт. Якщо не додати ці папки до .gitignore, вони випадково потраплять у Pull Request, роздують розмір репозиторію та створять величезну кількість зайвих змін, які нікому не потрібні на віддаленому сервері. З іншого боку, не варто додавати до .gitignore файли конфігурації-шаблони (на кшталт .env.example), які необхідні іншим членам команди для старту проєкту.
Pitfalls — A typical real-world scenario: while running local UI tests, an automation engineer generates thousands of failure screenshots and heavy HTML reports totaling hundreds of megabytes. If these folders are not added to .gitignore, they will accidentally end up in a Pull Request, bloat the repository size, and create massive unnecessary changes that nobody needs on the remote server. On the other hand, you should never ignore configuration templates (like .env.example) that other team members need to bootstrap the project.
Уточнююче питання — Найпоширеніша помилка новачків — спроба додати файл у .gitignore вже після того, як він був зафіксований (закомічений) у Git. Справа в тому, що .gitignore не діє на файли, які Git вже відстежує (tracked). У такому разі спочатку потрібно видалити файл з індексу за допомогою команди git rm --cached , а вже потім додати його маску до файлу ігнорування.
Follow-up question — The most common mistake beginners make is trying to add a file to .gitignore after it has already been tracked (committed) in Git. The thing is, .gitignore does not affect files that Git is already tracking. In such cases, you must first remove the file from the tracking index using the command git rm --cached and only then add its pattern to the ignore file.
# Dependencies
node_modules/
vendor/
# Test execution artifacts and reports
allure-results/
allure-report/
playwright-report/
test-results/
cypress/videos/
cypress/screenshots/
# Logs and databases
logs/
*.log
npm-debug.log*
*.db
# IDE and OS specific files
.idea/
.vscode/
*.iml
.DS_Store
Thumbs.db
# Environment variables and sensitive secrets
.env
secrets.json
credentials/
Що це — Безперервна інтеграція (Continuous Integration, CI) — це практика розробки програмного забезпечення, під час якої розробники регулярно (часто кілька разів на день) зливають свої зміни коду в єдиний центральний репозиторій. Кожне таке злиття автоматично запускає збірку проєкту та набір базових автоматизованих перевірок, таких як юніт-тести та лінтери статичного аналізу. Основна мета CI полягає в тому, щоб якомога раніше виявити та виправити конфлікти інтеграції ще до того, як вони перетворяться на масивну проблему.
What is it — Continuous Integration (CI) is a software development practice where developers regularly merge their code changes into a central repository, often multiple times a day. Each merge automatically triggers a build and a suite of basic automated checks, such as unit tests and static code linters. The primary goal of CI is to detect and fix integration conflicts as early as possible before they snowball into massive architectural issues.
Навіщо/коли — Механізм CI базується на принципі «маленьких і частих ітерацій» замість рідкісних і масивних злиттів коду. Коли розробник надсилає (push) код у гілку репозиторію, спеціальний CI-сервер (наприклад, Jenkins, GitHub Actions або GitLab CI) ізольовано створює середовище, завантажує залежності, виконує тести та повідомляє команду про результат. Якщо збірка «червона» (тести впали), команда одразу бачить, чий саме коміт спричинив проблему, що дозволяє миттєво локалізувати та виправити помилку.
Why/when — The CI mechanism relies on the principle of "small and frequent iterations" rather than rare, massive code merges. When a developer pushes code to a repository branch, a dedicated CI server (such as Jenkins, GitHub Actions, or GitLab CI) provisions an isolated environment, downloads dependencies, runs tests, and reports the outcome to the team. If the build turns "red" (tests fail), the team immediately sees whose commit caused the issue, allowing them to pinpoint and fix the bug instantly.
Приклад з проєкту — У реальному проєкті, наприклад, у розробці медичного або фінансового додатку, робочий процес виглядає так: розробник додає новий модуль валідації платежів, створює Pull Request, і CI-система автоматично проганяє весь набір юніт-тестів та перевірки безпеки. Якщо хоча б один тест падає через конфлікт типів, злиття коду в основну гілку блокується. Це гарантує, що QA-інженери завжди отримують стабільну та перевірену збірку для тестування, а не «сирий» продукт із базовими синтаксичними чи логічними помилками.
Project example — In a real project, such as building a healthcare or financial application, the workflow looks like this: a developer adds a new payment validation module, opens a Pull Request, and the CI system automatically executes the full suite of unit tests and security scans. If even a single test fails due to a type conflict, code merging into the main branch is blocked. This guarantees that QA engineers always receive a stable and pre-checked build for testing, rather than a raw product plagued by basic syntax or logic errors.
Підводні камені — Серед найпоширеніших помилок при впровадженні CI — ігнорування «червоних» білдів (коли команда продовжує комітити код попри падіння тестів), занадто повільний пайплайн, який змушує розробників годинами чекати на результати, а також відсутність локального запуску тестів перед відправкою в репозиторій. Важливо розуміти, що CI — це не просто набір налаштувань на сервері, а перш за все інженерна культура швидкого зворотного зв'язку.
Pitfalls — Among the most common mistakes when implementing CI are ignoring "red" builds (where the team keeps committing code despite failing tests), an overly slow pipeline that forces developers to wait hours for results, and failing to run tests locally before pushing to the repository. It is crucial to understand that CI is not just a set of server configurations, but primarily an engineering culture of fast feedback.
Уточнення на співбесіді — Скільки має тривати CI-прогін на pull request? Орієнтир — до 10–15 хвилин; довші набори переносять у нічний або post-merge запуск.
Interview follow-up — How long should a CI run on a pull request take? A rough target is 10–15 minutes; longer suites move to a nightly or post-merge run.
name: CI Pipeline
on:
pull_request:
branches: [ main ]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run Unit Tests
run: npm test
Що це — Безперервна доставка (Continuous Delivery, CD) — це практика розробки програмного забезпечення, яка гарантує, що ваш код завжди готовий до автоматичного або ручного розгортання в production. На відміну від Continuous Deployment, де кожна збірка йде на реальні серверні середовища напряму без участі людини, CD передбачає обов'язковий етап ручного підтвердження (approval gate) перед викатуванням змін для кінцевих користувачів.
What is it — Continuous Delivery (CD) is a software development practice that ensures your code is always in a deployable state to production. Unlike Continuous Deployment, where every passing build goes straight to production without human intervention, CD retains a mandatory manual approval gate before releasing changes to real users.
Навіщо/коли — Технічно механізм CD є продовженням CI-пайплайну: після успішного проходження юніт- та інтеграційних тестів, а також пакування коду в артефакт (наприклад, Docker-образ), система автоматично розгортає його на середовищі, ідентичному продукшену (Staging або UAT). QA-інженери, менеджери та стейкхолдери можуть проводити фінальне прийомне тестування в умовах, максимально наближених до реальних.
Why/when — Technically, the CD mechanism extends the CI pipeline: after successful unit and integration tests, along with packaging the code into an artifact (e.g., a Docker image), the system automatically deploys it to a production-like environment (Staging or UAT). QA engineers, managers, and stakeholders can perform final acceptance testing in conditions as close to reality as possible.
Приклад з проєкту — Цю практику варто використовувати в проєктах із короткими циклами випусків, SaaS-рішеннях та командах, які прагнуть уникнути стресових релізів наприкінці тижня. Проте CD не підходить для систем зі суворими регуляторними вимогами, де кожна зміна потребує тижневих аудитів до появи на пре-продакшені, або для проєктів із мінімальним і нестабільним автоматизованим тестовим покриттям.
Project example — This practice is ideal for projects with short release cycles, SaaS solutions, and teams aiming to eliminate stressful Friday releases. However, CD is not suitable for systems with strict regulatory compliance where every change requires weeks of audits before hitting pre-production, or for projects with minimal and unstable automated test coverage.
Підводні камені — У реальному проєкті електронної комерції CD дозволяє розробникам зливати фічі в main-гілку щодня. Пайплайн збирає мікросервіси, пушить образи в реєстр та оновлює QA-кластер Kubernetes. Після завершення автоматичних E2E-тестів QA-лід перевіряє результати й натискає кнопку «Approve» у Jenkins чи GitLab CI, після чого бізнес може одразу побачити нові фічі на стейджингу.
Pitfalls — In a real e-commerce project, CD allows developers to merge features into the main branch daily. The pipeline builds microservices, pushes images to a registry, and updates the QA Kubernetes cluster. Once automated E2E tests pass, the QA Lead reviews the results and clicks "Approve" in Jenkins or GitLab CI, allowing business stakeholders to immediately preview new features on staging.
Уточнююче питання — Типова помилка при впровадженні CD — фокус виключно на інструментах (ArgoCD, GitLab CI, Spinnaker) за відсутності належної культури якості. Якщо стейджинг-середовище постійно падає через зовнішні фактори або тести видають помилкові спрацьовування (flaky tests), команда швидко втрачає довіру до пайплайну і переходить до ручних збірок, що зводить нанівець саму ідею безперервної доставки.
Follow-up question — A common mistake when adopting CD is focusing purely on tools (ArgoCD, GitLab CI, Spinnaker) while neglecting quality culture. If the staging environment constantly fails due to external factors or tests produce false positives (flaky tests), the team quickly loses trust in the pipeline and reverts to manual builds, defeating the entire purpose of continuous delivery.
name: Production CD Pipeline
on:
push:
branches: [ main ]
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run Unit Tests
run: npm test
deploy-to-staging:
needs: test-and-build
runs-on: ubuntu-latest
environment: staging
steps:
- name: Deploy to Staging Cluster
run: |
echo "Deploying container to staging Kubernetes..."
kubectl apply -f k8s/staging/
manual-production-gate:
needs: deploy-to-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- name: Wait for manual approval
run: echo "Approved by QA/Release Manager. Proceeding to prod."
Що це — Continuous Deployment (CD) — це еволюція концепції Continuous Delivery, де кожна зміна коду, яка успішно проходить автоматизовані тести, миттєво та без жодного ручного втручання розгортається у виробничому середовищі (production). Якщо при Continuous Delivery рішення про реліз ухвалює людина (натисканням кнопки), то в Continuous Deployment весь шлях від коміту до продакшену повністю автоматизований.
What is it — Continuous Deployment (CD) is an evolution of Continuous Delivery where every code change that successfully passes automated tests is instantly and automatically deployed to the production environment without any manual intervention. While Continuous Delivery requires a human decision (a manual approval click) to release, Continuous Deployment fully automates the entire path from code commit to production.
Навіщо/коли — Механізм роботи CD базується на зв'язці систем контролю версій (Git) та CI/CD-пайплайнів (наприклад, GitHub Actions, GitLab CI/CD, Jenkins). Коли розробник робить merge у гілку main, пайплайн послідовно запускає юніт-тести, інтеграційні та контрактні тести, збирає Docker-образ, завантажує його в реєстр, виконує деплой на кластер (наприклад, Kubernetes або AWS ECS) та запускає автоматичні smoke-тести.
Why/when — The CD mechanism relies on version control systems (Git) and CI/CD pipelines (such as GitHub Actions, GitLab CI/CD, or Jenkins). When a developer merges code into the main branch, the pipeline sequentially executes unit, integration, and contract tests, builds a Docker image, pushes it to a registry, deploys it to a cluster (like Kubernetes or AWS ECS), and runs automated smoke tests.
Приклад з проєкту — Цей підхід ідеально підходить для SaaS-продуктів, мікросервісної архітектури з високим покриттям тестами та команд, які активно використовують feature flags для безпечного приховування неготових фіч. Проте CD категорично не рекомендується використовувати в критичних життєзабезпечуючих системах (медицина, авіація), на легасі-проєктах із нестабільними чи довгими тестами, або за відсутності якісного моніторингу й швидких інструментів відкату (rollback).
Project example — This approach is ideal for SaaS products, microservices architectures with high test coverage, and teams actively using feature flags to safely hide unfinished features. However, CD is strictly not recommended for safety-critical systems (medical, aerospace), legacy projects with brittle or slow tests, or environments lacking robust monitoring and fast rollback capabilities.
Підводні камені — Реальний проєктний сценарій: у fintech-додатку розробник виправляє баг валідації суми переказу. Після злиття коду пайплайн збирає новий Docker-контейнер та за допомогою стратегії canary deployment оновлює 10% серверів. Система автоматично відстежує метрики помилок (наприклад, через Prometheus/Datadog) протягом п'яти хвилин; якщо аномалій не виявлено, трафік плавно переводиться на нову версію на 100% без участі QA чи DevOps.
Pitfalls — A real project scenario: in a fintech app, a developer fixes a transfer amount validation bug. After merging, the pipeline builds a new Docker container and uses a canary deployment strategy to update 10% of the servers. The system automatically monitors error rates (e.g., via Prometheus/Datadog) for five minutes; if no anomalies are detected, traffic smoothly shifts 100% to the new version without any QA or DevOps manual involvement.
Уточнююче питання — Головна помилка QA при впровадженні CD — сприймати автоматизацію як заміну тестувальника, а не якісний бар'єр (quality gate). Інша поширена проблема — надмірна довіра до високого відсотка покриття коду замість фокусування на поведінкових, регресійних та контрактних тестах, що призводить до пропуску критичних бізнес-помилок прямо у прод.
Follow-up question — A common QA mistake when implementing CD is treating automation as a replacement for testers rather than a robust quality gate. Another widespread issue is over-relying on high code coverage percentages instead of focusing on behavioral, regression, and contract tests, which allows critical business-logic bugs to slip straight into production.
name: Production Continuous Deployment
on:
push:
branches: [ main ]
jobs:
deploy-to-prod:
runs-on: ubuntu-latest
steps:
- name: Checkout Source Code
uses: actions/checkout@v3
- name: Run Automated Test Suite
run: npm run test:ci
- name: Build Docker Container
run: docker build -t myapp:${{ github.sha }} .
- name: Push & Deploy via Kubernetes
run: |
docker push myapp:${{ github.sha }}
kubectl set image deployment/app app=myapp:${{ github.sha }}
- name: Run Post-Deployment Smoke Tests
run: npm run test:smoke --url=https://prod.company.com
Що це — У GitHub Actions процес неперервної інтеграції та доставки (CI/CD) описується за допомогою спеціальних конфігураційних файлів, які називаються workflows (робочі процеси). Вони обов'язково мають формат YAML (.yml або .yaml) і повинні знаходитися в репозиторії за чітко визначеним шляхом: .github/workflows/.
What is it — In GitHub Actions, the continuous integration and delivery (CI/CD) process is described using special configuration files called workflows. They must be in YAML format (.yml or .yaml) and located in the repository at a specific path: .github/workflows/.
Навіщо/коли — Механізм роботи цього файлу полягає в декларативному описі подій (on), які запускають pipeline, та завдань (jobs), що виконуються на віртуальних серверах (runners). Кожен job складається з послідовності кроків (steps), де можуть запускатися як готові сторонні дії з GitHub Marketplace (наприклад, встановлення Node.js), так і власні кастомні команди термінала для збирання проєкту та запуску тестів.
Why/when — The mechanism behind this file relies on a declarative description of events (on) that trigger the pipeline, and jobs (jobs) executed on virtual servers called runners. Each job consists of a sequence of steps (steps), which can run pre-made actions from the GitHub Marketplace (like setting up Node.js) or custom terminal commands to build the project and execute tests.
Приклад з проєкту — На реальному проєкті такий файл є критично важливим для QA-інженера, оскільки він автоматично активує перевірки якості коду та виконання автотестів при кожному створенні Pull Request або злитті гілок. Наприклад, файл ci.yml може налаштовувати середовище, інсталювати залежності та запускати регресійні сценарії на Playwright або Selenium, блокуючи злиття коду (merge), якщо хоча б один тест падає.
Project example — On a real project, such a file is critical for a QA engineer because it automatically triggers code quality checks and automated tests upon every Pull Request creation or branch merge. For instance, a ci.yml file can set up the environment, install dependencies, and run regression suites on Playwright or Selenium, blocking the code merge if even a single test fails.
Підводні камені — Найпоширенішими помилками при створенні цих файлів є порушення синтаксису YAML (наприклад, використання табуляції замість пробілів, що призводить до помилок валідації), неправильне налаштування тригерів, через що pipeline не реагує на події, або ж хардкодінг чутливих даних (паролів, токенів доступу) всередині файлу замість їх безпечного зберігання у GitHub Secrets.
Pitfalls — The most common mistakes when creating these files include YAML syntax errors (such as using tabs instead of spaces, leading to validation failures), misconfigured triggers that prevent the pipeline from responding to events, or hardcoding sensitive data (passwords, access tokens) inside the file instead of securely storing them in GitHub Secrets.
Уточнення на співбесіді — Як запустити workflow вручну з параметрами? Додати тригер workflow_dispatch з inputs — наприклад, вибір середовища або набору тестів.
Interview follow-up — How do you start a workflow manually with parameters? Add a workflow_dispatch trigger with inputs — for example, choosing the environment or the test suite.
name: QA Regression Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run Playwright tests
run: npx playwright test
Що це — Конфігураційний файл Jenkins Pipeline називається Jenkinsfile. Це текстовий файл, у якому описується весь життєвий цикл збірки проєкту, тестування та розгортання в рамках CI/CD-процесу. Замість налаштування задач через класичний веб-інтерфейс Jenkins (Classic UI), підхід "Pipeline as Code" дозволяє зберігати інструкції безпосередньо в репозиторії коду поруч із самим проєктом.
What is it — The Jenkins Pipeline configuration file is called a Jenkinsfile. It is a text file that defines the entire project build, test, and deployment lifecycle as part of the CI/CD process. Instead of configuring jobs through the classic Jenkins web UI, the "Pipeline as Code" approach allows storing instructions directly in the code repository alongside the project itself.
Навіщо/коли — Jenkinsfile зазвичай пишеться на основі мови Groovy та використовує один із двох синтаксисів: Declarative (більш структурований, читабельний і суворий до синтаксису) або Scripted (гнучкіший, але складніший у підтримці). Файл складається з логічних блоків — pipeline, stages, stage та steps, які послідовно виконуються агентами Jenkins. Завдяки цьому і QA-інженери, і розробники можуть бачити статус проходження збірок та тестів безпосередньо у гілках Git.
Why/when — A Jenkinsfile is typically written in Groovy and uses one of two syntaxes: Declarative (more structured, readable, and strict in syntax) or Scripted (more flexible, but harder to maintain). The file consists of logical blocks—pipeline, stages, stage, and steps—that are sequentially executed by Jenkins agents. This allows both QA engineers and developers to see the status of builds and tests directly within Git branches.
Приклад з проєкту — У реальних проєктах Jenkinsfile використовується щоразу, коли необхідно налаштувати автоматичний запуск регресійних тестів (наприклад, UI-тестів на Playwright або API-тестів на RestAssured) після кожного злиття коду в головну гілку або за розкладом (nightly builds). Наприклад, наш QA-відділ використовує Jenkinsfile для автоматичного тригера пакету регресійних тестів у Docker-контейнері одразу після того, як розробники викатують оновлення на тестове середовище (staging).
Project example — In real projects, a Jenkinsfile is used whenever it is necessary to configure automated execution of regression tests (e.g., UI tests with Playwright or API tests with RestAssured) after every code merge to the main branch or on a schedule (nightly builds). For instance, our QA team uses a Jenkinsfile to automatically trigger a regression test suite inside a Docker container right after developers roll out updates to the staging environment.
Підводні камені — Найпоширеніші помилки при роботі з Jenkinsfile — це жорстке прописування чутливих даних (паролів, токенів доступу, API-ключів) замість використання захищеного сховища Jenkins Credentials Store, а також створення занадто монолітних і довгих файлів замість винесення логіки у Shared Libraries. Початківці також часто припускаються синтаксичних помилок у Groovy або забувають налаштувати блоки post для автоматичного надсилання сповіщень у Slack чи генерації звітів на кшталт Allure.
Pitfalls — Common mistakes when working with a Jenkinsfile include hardcoding sensitive data (passwords, access tokens, API keys) instead of using the secure Jenkins Credentials Store, as well as creating overly monolithic and long files instead of extracting logic into Shared Libraries. Beginners also frequently make Groovy syntax errors or forget to configure post blocks for automatically sending notifications to Slack or generating reports like Allure.
Уточнення на співбесіді — Чим declarative pipeline відрізняється від scripted? Declarative має фіксовану структуру pipeline/stages/steps і простіший для читання, scripted — це довільний Groovy.
Interview follow-up — How does a declarative pipeline differ from a scripted one? Declarative has a fixed pipeline/stages/steps structure and is easier to read; scripted is free-form Groovy.
pipeline {
agent any
stages {
stage('Checkout Code') {
steps {
checkout scm
}
}
stage('Install Dependencies') {
steps {
sh 'npm install'
}
}
stage('Run Regression Tests') {
steps {
sh 'npm run test:regression'
}
}
}
post {
always {
allure includeProperties: false, results: [[path: 'allure-results']]
}
}
}
Що це — У контексті тестування Docker — це технологія віртуалізації на рівні операційної системи, яка дозволяє пакувати додаток та всі його залежності в ізольовані контейнери. Для QA-інженера це означає можливість запускати тести, тестові середовища та самі досліджувані програми у повністю контрольованих умовах, які є ідентичними на будь-якому комп'ютері чи сервері без потреби складного ручного налаштування.
What is it — In the context of testing, Docker is an operating system-level virtualization technology that packs an application and all its dependencies into isolated containers. For a QA engineer, this means the ability to run tests, test environments, and the applications under test in fully controlled conditions that remain identical across any computer or server without complex manual setup.
Навіщо/коли — Технічно Docker працює за рахунок спільного використання ядра хост-системи, що робить контейнери надзвичайно легкими та швидкими у запуску на відміну від класичних віртуальних машин. Кожен контейнер містить лише те, що потрібно для роботи конкретного сервісу — наприклад, певну версію Node.js або бази даних PostgreSQL. Це гарантує, що поведінка додатка під час тестування буде передбачуваною і повністю відповідатиме продакшену.
Why/when — Technically, Docker works by sharing the host system's kernel, making containers extremely lightweight and fast to start compared to traditional virtual machines. Each container contains only what is needed for a specific service to run—such as a specific version of Node.js or a PostgreSQL database. This ensures that the application's behavior during testing is predictable and matches production.
Приклад з проєкту — У реальних проєктах Docker використовується всюди: від локального запуску середовища для ручного тестування до автоматизованих пайплайнів у CI/CD. Наприклад, якщо розробники випустили мікросервісну архітектуру, QA може підняти весь стек із кількох сервісів однією командою docker-compose up, виконати дим-тести чи автоматизовані перевірки, а після завершення повністю очистити середовище, не залишаючи конфліктів у системі.
Project example — In real projects, Docker is used everywhere: from local environment setup for manual testing to automated pipelines in CI/CD. For example, if developers release a microservices architecture, a QA can spin up the entire stack of several services with a single docker-compose up command, run smoke tests or automated checks, and completely clean up the environment afterward without leaving conflicts on the system.
Підводні камені — Найпоширенішими помилками при використанні Docker у тестуванні є ігнорування концепції незмінності (намагання «лагодити» баги прямо всередині запущеного контейнера замість перезапуску зображення), відсутність очищення невикористаних томів та кешу, що швидко забиває дисковий простір, а також неправильне налаштування мережі між контейнерами тест-раннера та додатку.
Pitfalls — The most common mistakes when using Docker in testing include ignoring immutability (trying to "fix" bugs directly inside a running container instead of restarting the image), failing to clean up unused volumes and caches which quickly exhausts disk space, and improper network configuration between the test runner and application containers.
Уточнення на співбесіді — Чим контейнер відрізняється від віртуальної машини для тестів? Контейнер ділить ядро хоста і стартує за секунди, VM має власну ОС і важча.
Interview follow-up — How does a container differ from a virtual machine for tests? A container shares the host kernel and starts in seconds, a VM has its own OS and is heavier.
version: '3.8'
services:
app:
image: my-test-app:latest
ports:
- "8080:8080"
environment:
- DB_HOST=db
db:
image: postgres:14-alpine
environment:
- POSTGRES_DB=test_db
- POSTGRES_USER=qa_user
- POSTGRES_PASSWORD=secret
Що це — Dockerfile — це текстовий документ, який містить послідовність інструкцій (команд), необхідних для автоматичного створення (збірки) Docker-образу. Він виступає у ролі чіткого рецепта або креслення, у якому зазначено, яка саме базова операційна система, які системні бібліотеки, залежності та файли застосунку мають бути включені в ізольоване середовище виконання.
What is it — A Dockerfile is a text document containing a sequence of instructions (commands) required to automatically build a Docker image. It acts as a clear blueprint or recipe specifying which base operating system, system libraries, dependencies, and application files must be included in the isolated execution environment.
Навіщо/коли — Механізм роботи Dockerfile базується на шаруватій архітектурі (layers). Кожна інструкція у файлі (наприклад, FROM, RUN, COPY) створює новий незмінний шар поверх попереднього, що забезпечує високу ефективність кешування: якщо інструкції не змінювалися, Docker пропускає їх і використовує вже готові шари, суттєво прискорюючи повторну збірку.
Why/when — The mechanism behind a Dockerfile relies on a layered architecture. Each instruction in the file (such as FROM, RUN, COPY) creates a new immutable layer on top of the previous one, ensuring high caching efficiency: if instructions haven't changed, Docker skips them and uses pre-cached layers, significantly speeding up rebuild times.
Приклад з проєкту — У контексті тестування та QA Dockerfile використовується для створення однакових середовищ виконання автоматизованих тестів (наприклад, запуск Selenium Grid, Cypress або API-тестів усередині контейнерів). Це повністю вирішує проблему «у мене на машині все працює, а на тест-стенді падає», оскільки кожен інженер запускає ідентичний білд.
Project example — In the context of testing and QA, a Dockerfile is used to create consistent environments for running automated tests (e.g., executing Selenium Grid, Cypress, or API tests inside containers). This completely resolves the "it works on my machine but fails on the test server" issue, as every engineer runs an identical build.
Підводні камені — У реальному проєкті QA-інженер або DevOps створює Dockerfile для автоматизації: наприклад, визначає базовий образ Python, копіює файл залежностей requirements.txt, встановлює їх через pip, копіює решту тестового фреймворку та задає команду CMD для запуску тестів через pytest безпосередньо в конвеєрі CI/CD.
Pitfalls — In a real project, a QA engineer or DevOps specialist creates a Dockerfile for test automation: for instance, defining a base Python image, copying the requirements.txt dependency file, installing them via pip, copying the rest of the test framework, and setting a CMD instruction to run tests via pytest directly inside the CI/CD pipeline.
Уточнююче питання — Поширеною помилкою початківців є ігнорування файлу .dockerignore та копіювання зайвих важких файлів (наприклад, локальних логів або папок з артефактами збірки) за допомогою COPY . ., що призводить до роздування розміру фінального образу, сповільнення збірки та потенційних вразливостей безпеки.
Follow-up question — A common mistake among beginners is ignoring the .dockerignore file and copying unnecessary heavy files (such as local logs or build artifact folders) using COPY . ., which bloats the final image size, slows down the build process, and introduces potential security vulnerabilities.
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["pytest", "-v"]
Що це — docker-compose — це інструмент для визначення та запуску багатоконтейнерних Docker-додатків за допомогою єдиного файлу конфігурації у форматі YAML. У контексті тестування та забезпечення якості (QA), він дозволяє QA-інженерам однією командою підняти повне середовище додатка: веб-сервер, базу даних, кешування та сторонні сервіси-залежності.
What is it — docker-compose is a tool for defining and running multi-container Docker applications using a single YAML configuration file. In the context of testing and quality assurance (QA), it allows QA engineers to spin up a complete application environment with a single command: a web server, a database, caching layers, and external service dependencies.
Навіщо/коли — Технічно засіб зчитує файл docker-compose.yml, де описуються сервіси, мережі (networks) та volumes (сховища даних), а потім використовує Docker API для створення ізольованих інфраструктурних елементів. Механізм базується на створенні віртуальної мережі обміну даними між контейнерами, що дозволяє їм взаємодіяти за іменами сервісів замість складних IP-адрес, значно спрощуючи розгортання тестових стендів.
Why/when — Technically, the tool reads the docker-compose.yml file, which describes services, networks, and data volumes, and then uses the Docker API to create isolated infrastructure components. The mechanism relies on establishing a virtual internal network between containers, allowing them to communicate via service names instead of complex IP addresses, significantly simplifying the deployment of test environments.
Приклад з проєкту — docker-compose незамінний для локального тестування, регресійного тестування перед релізом, а також для автоматизації інтеграційних та E2E тестів у CI/CD пайплайнах. Наприклад, у проєкті електронної комерції QA-інженер може запустити мок-сервер оплати, PostgreSQL та сам сервіс одночасно для перевірки оформлення замовлення в ізольованому контурі, позбувшись проблем із різницею середовищ.
Project example — docker-compose is indispensable for local testing, pre-release regression testing, and automating integration and E2E tests within CI/CD pipelines. For example, in an e-commerce project, a QA engineer can spin up a payment mock server, PostgreSQL, and the core microservice simultaneously to verify checkout flows in an isolated environment, eliminating environment discrepancy issues.
Підводні камені — Проте його не варто використовувати для масштабного продакшн-розгортання, де потрібні складні оркестратори на кшталт Kubernetes з його автоскейлінгом, балансуванням навантаження та політиками самовідновлення. Поширеною помилкою серед тестувальників є збереження чутливих даних (паролів, API-ключів) безпосередньо у файлі конфігурації замість використання змінних середовища (.env), а також ігнорування механізмів очищення volumes, що призводить до захаращення дискового простору застарілими тестовими даними.
Pitfalls — However, it should not be used for large-scale production deployments that require complex orchestrators like Kubernetes with autoscaling, load balancing, and self-healing policies. A common mistake among testers is hardcoding sensitive data (passwords, API keys) directly into the configuration file instead of using environment variables (.env), as well as ignoring volume cleanup mechanisms, which leads to disk space clutter from outdated test data.
Уточнення на співбесіді — Чому depends_on не гарантує, що сервіс готовий? Він чекає лише старту контейнера; для готовності потрібні healthcheck і condition: service_healthy.
Interview follow-up — Why doesn't depends_on guarantee a service is ready? It only waits for the container to start; readiness needs a healthcheck and condition: service_healthy.
version: '3.8'
services:
web-app:
image: my-app:latest
ports:
- "8080:80"
environment:
- DB_HOST=database
depends_on:
- database
database:
image: postgres:14-alpine
environment:
- POSTGRES_DB=test_db
- POSTGRES_USER=qa_user
- POSTGRES_PASSWORD=secret
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
docker-compose — це як режисер театру для ваших контейнерів: ви даєте йому один сценарій (файл конфігурації), і він запускає всі декорації, акторів та сервіси (веб-додаток, базу даних, кеш) у правильному порядку однією командою.docker-compose is like a theater director for your containers: you give it a single script (configuration file), and it launches all the props, actors, and services (web app, database, cache) in the correct order with a single command.docker-compose у своїй QA-роботі, особливо коли потрібно швидко розгорнути повне середовище додатка для локального тестування або запуску автоматизованих E2E тестів. У мене є стандартний конфігураційний файл, який піднімає веб-сервіс разом із потрібною базою даних і тестовими моками однією командою docker-compose up. Це рятує від головного болю з налаштуванням залежностей вручну та дозволяє тестувати в умовах, максимально наближених до реального використання. Головне — не забувати керувати кешем сховищ даних і виносити секрети у змінні середовища.docker-compose in my QA work, especially when I need to quickly spin up a complete application environment for local testing or running automated E2E tests. I have a standard configuration file that starts the web service along with the required database and test mocks using a single docker-compose up command. This saves me from the headache of manual dependency setup and lets me test in conditions very close to real-world usage. The main thing is to remember to manage data volume caches and extract secrets into environment variables.Що це — Page Object Model (POM) — це популярний патерн проєктування в автоматизації тестування, який використовується для створення об'єктного сховища елементів вебсторінки. Основна ідея патерну полягає в тому, щоб повністю відокремити бізнес-логіку тестів від технічної структури сторінок додатку, перетворивши кожну вебсторінку або її логічний компонент на окремий клас.
What is it — Page Object Model (POM) is a popular design pattern in test automation used to create an object repository for web page elements. The main idea of the pattern is to completely separate test business logic from the technical structure of application pages by turning each web page or its logical component into a separate class.
Навіщо/коли — Цей підхід працює завдяки інкапсуляції селекторів та методів взаємодії з елементами. Коли вебдодаток змінює свій дизайн або ідентифікатори елементів, тестувальнику не потрібно масово переписувати десятки чи сотні тестів — достатньо оновити локатор лише в одному відповідному класі сторінки. Усі локатори (XPath, CSS) та дії (клік, введення тексту, вибір із випадаючого списку) концентруються в єдиному місці.
Why/when — This approach works through the encapsulation of selectors and interaction methods with elements. When a web application changes its design or element IDs, the tester doesn't need to rewrite dozens or hundreds of tests massively — it is enough to update the locator in just one corresponding page class. All locators (XPath, CSS) and actions (clicking, typing text, selecting from dropdowns) are concentrated in a single place.
Приклад з проєкту — Уявіть проєкт інтернет-магазину, де сценарії авторизації, оформлення замовлення та додавання товару в кошик використовують форму входу. Якщо поле введення email змінить свій ідентифікатор з input-email на user-email, без POM вам довелося б шукати цей селектор у тридцяти різних тестових файлах. Завдяки POM ви робите виправлення рівно один раз у файлі LoginPage.py, і всі залежні тести знову стають працездатними.
Project example — Imagine an e-commerce project where login, checkout, and add-to-cart scenarios all use the login form. If the email input field changes its ID from input-email to user-email, without POM you would have to search for this selector across thirty different test files. Thanks to POM, you make the fix exactly once in the LoginPage.py file, and all dependent tests become operational again.
Підводні камені — POM є обов'язковим стандартом для будь-яких серйозних проєктів з UI-автоматизації, хоча для одноразових мікроскриптів із трьох рядків він буде надлишковим. Поширеною помилкою початківців є спроба додавати перевірки (assertions) всередину класів сторінок. Класи сторінок мають лише описувати інтерфейс і виконувати дії, тоді як самі перевірки очікуваних результатів повинні залишатися виключно у тестових сценаріях.
Pitfalls — POM is an essential standard for any serious UI automation project, although it would be overkill for one-off micro-scripts of three lines. A common beginner mistake is trying to add assertions inside page classes. Page classes should only describe the interface and perform actions, while the checks of expected results must remain exclusively within the test scenarios.
Уточнення на співбесіді — Чи мають Page Object містити assert? Краще ні: сторінка повертає стан, а перевірку робить тест, інакше один об'єкт має дві причини змінюватися.
Interview follow-up — Should Page Objects contain asserts? Better not: the page returns state and the test does the check, otherwise one object has two reasons to change.
class LoginPage:
def __init__(self, driver):
self.driver = driver
self.email_input = (By.ID, "email")
self.login_button = (By.ID, "submit-btn")
def enter_email(self, email):
self.driver.find_element(*self.email_input).send_keys(email)
def click_login(self):
self.driver.find_element(*self.login_button).click()
# Використання у тестах / Usage in tests
def test_successful_login(driver):
page = LoginPage(driver)
page.enter_email("user@example.com")
page.click_login()
assert "dashboard" in driver.current_url
class LoginPage:
def __init__(self, driver):
self.driver = driver
self.email_input = (By.ID, "email")
self.login_button = (By.ID, "submit-btn")
def enter_email(self, email):
self.driver.find_element(*self.email_input).send_keys(email)
def click_login(self):
self.driver.find_element(*self.login_button).click()
# Usage in tests
def test_successful_login(driver):
page = LoginPage(driver)
page.enter_email("user@example.com")
page.click_login()
assert "dashboard" in driver.current_url
Що це — Головна перевага паттерну Page Object (POM) полягає в забезпеченні високого рівня підтримки (maintainability) та повторного використання коду через повну ізоляцію логіки інтерфейсу від тестової логіки. У цьому підході кожна вебсторінка або її логічний компонент представлені окремим класом, де інкапсульовані локатори елементів та методи взаємодії з ними.
What is it — The main advantage of the Page Object Model (POM) pattern is ensuring high maintainability and code reusability through the complete isolation of UI logic from test logic. In this approach, each web page or its logical component is represented by a separate class that encapsulates element locators and interaction methods.
Навіщо/коли — Це працює завдяки чіткому дотриманню принципу єдиної відповідальності (Single Responsibility Principle). Тестові сценарії містять лише бізнес-логіку перевірок (asserts) та кроки користувача високого рівня, тоді як деталі знаходження DOM-елементів сховані всередині класів сторінок. Коли фронтенд-розробники змінюють ідентифікатор кнопки чи структуру верстки, вам потрібно оновити локатор лише в одному місці — у відповідному класі Page Object, а не правити десятки тестів.
Why/when — This works due to strict adherence to the Single Responsibility Principle. Test scripts contain only validation business logic (asserts) and high-level user steps, while the details of finding DOM elements are hidden inside page classes. When frontend developers change a button's ID or layout structure, you need to update the locator in only one place — the corresponding Page Object class — rather than fixing dozens of tests.
Приклад з проєкту — Уявіть проєкт інтернет-магазину на сотню тестів, де елемент кошика використовується на кожній сторінці. Без Page Object зміна локатора кошика призведе до масового падіння тестів і необхідності редагувати кожен файл вручну, витрачаючи години часу. З використанням POM ви просто оновлюєте метод взаємодії в єдиному компоненті хедера, і всі тести знову зеленіють без додаткових виправлень.
Project example — Imagine an e-commerce project with a hundred tests where the cart element is used on every page. Without Page Object, changing the cart locator leads to massive test failures and the need to edit every file manually, wasting hours of time. With POM, you simply update the interaction method in a single header component, and all tests turn green again without additional fixes.
Підводні камені — Поширеною помилкою серед початківців є перенесення тверджень (assertions) всередину методів Page Object, що перетворює їх на гібриди і руйнує гнучкість тестів. Page Object має повертати стан сторінки, дані або інші об'єкти сторінок, але не перевіряти очікувані результати. Інша крайність — створення «класів-монстрів» (God Pages), що намагаються описувати весь додаток замість розбиття на дрібніші компоненти (модалки, хедера, футери).
Pitfalls — A common mistake among beginners is putting assertions inside Page Object methods, which turns them into hybrids and destroys test flexibility. A Page Object should return page state, data, or other page objects, but not verify expected results. Another extreme is creating "God Pages" that try to describe the entire application instead of breaking it down into smaller components (modals, headers, footers).
Уточнення на співбесіді — Що робити, коли Page Object розростається до сотень рядків? Виносити повторювані частини сторінки в компоненти, наприклад Header чи ProductCard.
Interview follow-up — What do you do when a Page Object grows to hundreds of lines? Extract repeated parts of the page into components, such as Header or ProductCard.
export class LoginPage {
readonly page: Page;
readonly usernameInput = '#username';
readonly submitButton = '#login-btn';
constructor(page: Page) {
this.page = page;
}
async login(user: string, pass: string) {
await this.page.fill(this.usernameInput, user);
await this.page.click(this.submitButton);
}
}
test('Successful login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('test_user', 'secret');
await expect(page).toHaveURL('/dashboard');
});
Що це — Page Factory — це вбудований у Selenium WebDriver патерн оптимізації для реалізації паттерну Page Object Model (POM), який дозволяє ініціалізувати веб-елементи за допомогою анотацій (наприклад, @FindBy). Він виступає як фабрика за замовчуванням, що зв'язує змінні класу з реальними DOM-елементами на сторінці через механізм рефлексії (Reflection).
What is it — Page Factory is a built-in optimization pattern in Selenium WebDriver used to implement the Page Object Model (POM) design pattern, allowing web elements to be initialized using annotations such as @FindBy. It acts as a default factory that links class variables to actual DOM elements on the page via Java reflection.
Навіщо/коли — Цей механізм працює через виклик методу PageFactory.initElements(), який сканує клас на наявність анотацій та створює динамічні проксі-об'єкти (dynamic proxies) для кожного елемента. Ключовою особливістю є «ліниве» (lazy) завантаження: пошук елемента в DOM відбувається не в момент ініціалізації сторінки, а безпосередньо перед кожним викликом дії над ним (наприклад, кліку чи введення тексту).
Why/when — This mechanism works by calling the PageFactory.initElements() method, which scans the class for annotations and creates dynamic proxy objects for each element. A key feature is lazy loading: the element is not searched in the DOM at the moment of page initialization, but right before each interaction (such as clicking or typing).
Приклад з проєкту — Page Factory доречно використовувати в класичних Java-проєктах на Selenium для зменшення кількості боєйлерплейт-коду (driver.findElement) та централізації локаторів. Проте у сучасній розробці його використання скорочується через певні недоліки: він схильний до частих винятків StaleElementReferenceException при зміні стану сторінки, погано масштабується в умовах надмірно динамічних SPA (Single Page Applications) і, починаючи з Selenium 4, офіційно визнаний застарілим (deprecated) на користь стандартного конструювання через By та Explicit Waits.
Project example — Page Factory is appropriate to use in classic Java Selenium projects to reduce boilerplate code (driver.findElement) and centralize locators. However, in modern development, its usage is declining due to specific drawbacks: it is prone to frequent StaleElementReferenceException errors during state changes, scales poorly in highly dynamic SPAs, and starting from Selenium 4, it has been officially deprecated in favor of standard construction using By locators and Explicit Waits.
Підводні камені — У реальному проєкті ми зіткнулися з цим під час рефакторингу legacy-тестів інтернет-магазину, де локатори дублювалися у тетах. Ми винесли всі елементи каталогу та кошика у відповідні класи Page Factory з анотаціями @FindBy(id = "product-add-to-cart"), що значно покращило читабельність коду та спростило підтримку при зміні ID на фронтенді.
Pitfalls — In a real project, we encountered this during the refactoring of legacy e-commerce tests where locators were duplicated across tests. We moved all catalog and cart elements into respective Page Factory classes annotated with @FindBy(id = "product-add-to-cart"), which significantly improved code readability and simplified maintenance when IDs changed on the frontend.
Уточнююче питання — Найпоширеніша помилка — ігнорування життєвого циклу елементів та відсутність очікувань (Explicit Waits) перед викликом дій, оскільки Page Factory не гарантує автоматичного очікування появи елемента в DOM, а лише його повторний пошук через проксі. Також помилкою є спроба застосувати Page Factory у не-Java мовних привязках (Python, C#), де його реалізація або обмежена, або повністю відсутня.
Follow-up question — The most common mistake is ignoring the element lifecycle and failing to use Explicit Waits before actions, as Page Factory does not guarantee automatic waiting for an element to appear in the DOM, but merely re-searches it via proxy. Another mistake is trying to apply Page Factory in non-Java bindings (Python, C#), where its implementation is either limited or completely absent.
public class LoginPage {
private WebDriver driver;
@FindBy(id = "username")
private WebElement usernameInput;
@FindBy(id = "password")
private WebElement passwordInput;
@FindBy(xpath = "//button[@type='submit']")
private WebElement loginButton;
public LoginPage(WebDriver driver) {
this.driver = driver;
PageFactory.initElements(driver, this);
}
public void login(String user, String pass) {
usernameInput.sendKeys(user);
passwordInput.sendKeys(pass);
loginButton.click();
}
}
Що це — Fluent Page Object — це розширення класичного патерну Page Object Model (POM), яке використовує концепцію ланцюжкових викликів (method chaining) для написання більш читабельних та компактних автотестів. Основна ідея полягає в тому, що кожен метод взаємодії з елементом сторінки повертає посилання на поточний об'єкт сторінки (this) або на новий об'єкт сторінки, якщо відбувається перехід (наприклад, return new DashboardPage();). Це перетворює набір розрізнених рядків коду на єдиний осмислений ланцюжок дій, схожий на природну мову.
What is it — A Fluent Page Object is an extension of the classic Page Object Model (POM) pattern that leverages method chaining to write more readable and concise automated tests. The core concept is that each interaction method returns a reference to the current page object (this) or a new page object instance if a navigation event occurs (e.g., return new DashboardPage();). This transforms a set of fragmented code lines into a single, cohesive, readable chain of actions resembling natural language.
Навіщо/коли — Механізм роботи базується на патерні проєктування Fluent Interface. У класичному POM після виконання дії метод зазвичай повертає void, через що в тестовому сценарії доводиться щоразу викликати екземпляр сторінки заново (page.typeEmail(); page.typePassword(); page.submit();). У Fluent-підході кожен метод завершується інструкцією повернення контексту. Це дозволяє об'єднувати виклики через крапку, знижуючи кількість дублювання імен змінних і роблячи сценарії схожими на бізнес-вимоги або Behavior-Driven Development (BDD) сценарії.
Why/when — The mechanism relies on the Fluent Interface design pattern. In a classic POM, action methods typically return void, forcing the test script to repeatedly reference the page instance for every single action (page.typeEmail(); page.typePassword(); page.submit();). With the fluent approach, each method ends by returning the current context, allowing actions to be chained together using dot notation. This drastically cuts down on variable name duplication and makes test scripts read like business requirements or Behavior-Driven Development (BDD) specs.
Приклад з проєкту — У реальному проєкті електронної комерції ми використовували Fluent Page Objects для тестування складного процесу оформлення замовлення (Checkout). Тестовий сценарій виглядав надзвичайно чисто: CatalogPage.open().searchItem("Laptop").addToCart().openCart().proceedToCheckout().fillShippingDetails(user).submitPayment().verifyConfirmation();. Такий підхід дозволив нашій QA-команді та Manual-інженерам, які долучалися до написання автоматизації, швидко розуміти логіку тестів без необхідності занурюватися в деталі реалізації локаторів.
Project example — In a real-world e-commerce project, we used Fluent Page Objects to test a complex multi-step checkout flow. The test scenario read exceptionally cleanly: CatalogPage.open().searchItem("Laptop").addToCart().openCart().proceedToCheckout().fillShippingDetails(user).submitPayment().verifyConfirmation();. This approach allowed both our QA team and manual engineers transitioning to automation to quickly grasp test logic without needing to dive into the low-level details of locator implementations.
Підводні камені — Цей патерн варто використовувати у проєктах зі стабільною архітектурою інтерфейсу, де UI-флоу є лінійними та передбачуваними, а читабельність тестів є пріоритетом. Проте його слід уникати або обмежувати на високодинамічних сторінках зі складними розгалуженнями станів, де ланцюжки стають занадто довгими. Занадто розгалужені або довгі ланцюжки ускладнюють відлагодження (debugging), оскільки стек викликів (stack trace) у разі падіння теста може вказувати лише на загальний рядок ланцюжка, ускладнюючи локалізацію конкретної проблемної дії.
Pitfalls — You should use this pattern in projects with stable UI architectures where user flows are linear and predictable, and test readability is a priority. However, it should be avoided or carefully bounded on highly dynamic pages with complex state branching where chains become excessively long. Overly long or branched chains complicate debugging because a test failure stack trace might point only to the entire chained line, making it harder to pinpoint the exact failing action.
Уточнююче питання — Найпоширеніша помилка — створення так званих "мега-ланцюжків" (God chains), що охоплюють усю систему від логіну до закриття сесії в одному нескінченному рядку. Це порушує принцип єдиного обов'язку та робить тест крихким. Також поширеною проблемою є неправильна обробка навігаційних переходів, коли метод повертає this замість нового екземпляра сторінки, що відкривається після кліку, призводячи до помилок часу виконання.
Follow-up question — The most common mistake is creating "God chains" that span the entire application flow from login to session closure in one endless line. This violates the Single Responsibility Principle and makes tests extremely brittle. Another frequent pitfall is mishandling navigation transitions—returning this instead of a new instance of the newly loaded page after a click, which leads to runtime errors.
class LoginPage {
constructor(private page: Page) {}
async enterUsername(username: string): Promise<this> {
await this.page.fill('#username', username);
return this;
}
async enterPassword(password: string): Promise<this> {
await this.page.fill('#password', password);
return this;
}
async clickLogin(): Promise<DashboardPage> {
await this.page.click('#login-btn');
return new DashboardPage(this.page);
}
}
Що це — Патерн Singleton (Одинак) у контексті Selenium WebDriver — це архітектурне рішення, яке гарантує, що під час виконання тестів існує лише один єдиний екземпляр об'єкта WebDriver на весь життєвий цикл потоку чи сесії. Це запобігає випадковому створенню кількох непотрібних екземплярів браузера, економить системні ресурси та централізує керування конфігурацією драйвера (наприклад, налаштуваннями вікна, таймаутами чи аргументами запуску).
What is it — The Singleton pattern in the context of Selenium WebDriver is an architectural design pattern that ensures only a single instance of the WebDriver object exists throughout the test execution lifecycle of a thread or session. This prevents the accidental creation of multiple redundant browser instances, saves system resources, and centralizes driver configuration management (such as window settings, timeouts, or launch arguments).
Навіщо/коли — Механізм реалізації зазвичай базується на приватному конструкторі (private constructor) та статичному методі доступу (наприклад, getInstance()), який перевіряє наявність активного драйвера перед його ініціалізацією. Однак для сучасних проєктів рівня Senior недостатньо класичного Single-thread Singleton, оскільки паралельний запуск тестів у CI/CD (наприклад, через Maven Surefire або PyTest-xdist) призведе до конфліктів і падіння тестів через спільний стан. Тому на практиці застосовують потокобезпечний підхід з використанням ThreadLocal, який ізолює екземпляр драйвера для кожного окремого потоку виконання.
Why/when — The implementation mechanism typically relies on a private constructor and a static access method (e.g., getInstance()) that checks for an existing active driver before initializing a new one. However, for modern Senior-level projects, a classic single-threaded Singleton is insufficient because parallel test execution in CI/CD (via Maven Surefire or PyTest-xdist) will cause race conditions and test failures due to shared state. Therefore, practice dictates using a thread-safe approach via ThreadLocal, which isolates the driver instance for each individual execution thread.
Приклад з проєкту — Уявіть проєкт із тисячами регресійних тестів, які запускаються в Docker-контейнерах на Jenkins-нодах. Без єдиної точки керування драйвером тестові фреймворки можуть хаотично відкривати та закривати браузери або залишати "зомбі-процеси" в пам'яті сервера. Використання Driver Singleton або фабрики драйверів дозволяє централізовано ініціалізувати браузер у базовому класі або хуках (наприклад, @BeforeMethod або setUp), передавати туди динамічні параметри на кшталт HEADLESS=true та гарантовано закривати його в методі tearDown після завершення тесту.
Project example — Imagine a project with thousands of regression tests running in Docker containers on Jenkins nodes. Without a single point of driver management, test frameworks might chaotically open and close browsers or leave "zombie processes" in server memory. Utilizing a Driver Singleton or a driver factory allows for centralized browser initialization in a base class or hooks (e.g., @BeforeMethod or setUp), passing dynamic parameters like HEADLESS=true, and ensuring proper cleanup via tearDown after test completion.
Підводні камені — Найпоширеніша помилка — використання глобальної змінної для драйвера без урахування багатопоточності, що робить паралельний запуск неможливим через перезапис сесій у різних потоках. Інша крайність — створення Singleton на рівні всього тестового процесу без очищення куків та кешу між тестами, що порушує ізоляцію тестів (test isolation) і призводить до ефекту доміно, коли падіння одного тесту ламає наступні. Також важливо пам'ятати про обов'язковий виклик driver.quit(), а не просто driver.close(), щоб уникнути витоків ресурсів операційної системи.
Pitfalls — The most common mistake is using a global variable for the driver without accounting for multithreading, making parallel execution impossible due to session overwriting across threads. Another extreme is creating a Singleton at the entire process level without clearing cookies and cache between tests, which violates test isolation and causes cascading failures where one failing test breaks subsequent ones. It is also critical to remember calling driver.quit() rather than just driver.close() to prevent operating system resource leaks.
Уточнення на співбесіді — Чому класичний Singleton ламає паралельний запуск? Один екземпляр драйвера ділять усі потоки, тому потрібен ThreadLocal або драйвер на кожен тест.
Interview follow-up — Why does a classic Singleton break parallel runs? All threads share one driver instance, so you need a ThreadLocal or a driver per test.
public class DriverManager {
private static ThreadLocal<WebDriver> driver = new ThreadLocal<>();
private DriverManager() {}
public static WebDriver getDriver() {
if (driver.get() == null) {
WebDriver localDriver = new ChromeDriver();
localDriver.manage().window().maximize();
driver.set(localDriver);
}
return driver.get();
}
public static void quitDriver() {
if (driver.get() != null) {
driver.get().quit();
driver.remove(); // Важливо очистити ThreadLocal
}
}
}
public class DriverManager {
private static ThreadLocal<WebDriver> driver = new ThreadLocal<>();
private DriverManager() {}
public static WebDriver getDriver() {
if (driver.get() == null) {
WebDriver localDriver = new ChromeDriver();
localDriver.manage().window().maximize();
driver.set(localDriver);
}
return driver.get();
}
public static void quitDriver() {
if (driver.get() != null) {
driver.get().quit();
driver.remove(); // Important: clear the ThreadLocal
}
}
}
ThreadLocal, щоб під час паралельного запуску кожен потік мав свій власний ізольований «одинокий» драйвер і тести не конфліктували між собою.ThreadLocal wrapper so that during parallel runs, each thread has its own isolated "singleton" driver without tests conflicting with one another.ThreadLocal для керування екземпляром WebDriver. Це критично важливо, оскільки дозволяє уникнути конфліктів під час паралельного запуску тестів у CI/CD, ізолюючи браузерну сесію для кожного потоку окремо. Ми реалізували клас-менеджер із приватним конструктором та методом getDriver(), який створює браузер лише за потреби. Крім того, на рівні фікстур ми гарантуємо закриття сесії через driver.quit() та очищення ThreadLocal, що запобігає витокам пам'яті та забезпечує повну ізоляцію тестів.ThreadLocal to manage the WebDriver instance. This is critical because it avoids conflicts during parallel test execution in CI/CD by isolating the browser session for each thread separately. We implemented a manager class with a private constructor and a getDriver() method that instantiates the browser only when needed. Furthermore, at the fixture level, we ensure session termination via driver.quit() and ThreadLocal cleanup, which prevents memory leaks and ensures complete test isolation.Що це — ThreadLocal<WebDriver> — це контейнер, у якому кожен потік бачить власний екземпляр драйвера. Виклик DRIVER.get() з потоку №3 повертає браузер потоку №3 і ніколи не повертає браузер сусіднього потоку.
What it is — ThreadLocal<WebDriver> is a holder in which every thread sees its own driver instance. Calling DRIVER.get() from thread #3 returns thread #3's browser and never the browser of a neighbouring thread.
Навіщо — класичний Singleton зі static WebDriver driver працює, поки тести йдуть послідовно. Щойно вмикаєш паралелізм у JUnit5 чи TestNG, два тести пишуть в одне поле: перший ще клікає, а другий уже перезаписав посилання своїм браузером або викликав quit(). Звідси «рандомні» NoSuchSessionException і кліки не в тому вікні. ThreadLocal дає ізоляцію без передачі драйвера параметром у кожен метод Page Object.
Why — a classic singleton with a static WebDriver driver works while tests run one after another. The moment you enable parallelism in JUnit5 or TestNG, two tests write to the same field: the first is still clicking while the second has already overwritten the reference with its own browser or called quit(). That is where the "random" NoSuchSessionException and clicks in the wrong window come from. ThreadLocal gives isolation without passing the driver as a parameter into every Page Object method.
Приклад — DriverManager створює драйвер ліниво в getDriver(), а в @AfterEach викликає quitDriver(), який робить і quit(), і remove().
Example — a DriverManager creates the driver lazily in getDriver(), and @AfterEach calls quitDriver(), which does both quit() and remove().
Підводні камені — пули потоків перевикористовують потоки. Якщо забути remove(), наступний тест на тому ж потоці отримає мертву сесію після quit(), а класлоадер тримає посилання — це вже витік пам'яті. Друга пастка — асинхронний код: у CompletableFuture інший потік, і там get() поверне null.
Pitfalls — thread pools reuse threads. Forget remove() and the next test on the same thread gets a dead session after quit(), while the value keeps a reference alive — a real memory leak. The second trap is async code: a CompletableFuture runs on another thread, where get() returns null.
Уточнення на співбесіді — «А в Selenide чи Playwright це теж треба?» Ні: Selenide сам тримає драйвер на потік, а Playwright Test дає новий контекст кожному тесту через фікстури.
Interview follow-up — "Do you need this in Selenide or Playwright?" No: Selenide already keeps one driver per thread, and Playwright Test hands each test a fresh context through fixtures.
public final class DriverManager {
// кожен потік JUnit має власний слот
private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();
public static WebDriver getDriver() {
if (DRIVER.get() == null) {
DRIVER.set(DriverFactory.create()); // лінива ініціалізація
}
return DRIVER.get();
}
public static void quitDriver() {
WebDriver d = DRIVER.get();
if (d != null) {
try { d.quit(); }
finally { DRIVER.remove(); } // без цього потік з пулу отримає мертву сесію
}
}
}
class BaseUiTest {
@AfterEach
void tearDown() { DriverManager.quitDriver(); }
}
public final class DriverManager {
// every JUnit thread gets its own slot
private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();
public static WebDriver getDriver() {
if (DRIVER.get() == null) {
DRIVER.set(DriverFactory.create()); // lazy initialisation
}
return DRIVER.get();
}
public static void quitDriver() {
WebDriver d = DRIVER.get();
if (d != null) {
try { d.quit(); }
finally { DRIVER.remove(); } // otherwise a pooled thread inherits a dead session
}
}
}
class BaseUiTest {
@AfterEach
void tearDown() { DriverManager.quitDriver(); }
}
set/get працюють лише з поточним потоком, а remove() у блоці finally гарантує очищення слота навіть тоді, коли quit() кинув виняток через уже закритий браузер.set/get only touch the current thread, and remove() inside finally guarantees the slot is cleared even when quit() throws because the browser is already gone.Що це — Builder збирає об'єкт покроково: aUser().withRole(ADMIN).build(). Для тестових даних він має валідні значення за замовчуванням, тож тест перевизначає лише те, що впливає на перевірку.
What it is — a Builder assembles an object step by step: aUser().withRole(ADMIN).build(). For test data it carries valid defaults, so a test overrides only the fields that affect the check.
Навіщо — конструктор new User("Ann", "a@x.io", 30, "UA", true, null, ADMIN) не читається: неясно, яке поле важливе. З білдером тест про заблокованого користувача містить лише .blocked(), а решта полів сховані й валідні. Коли в моделі з'являється нове обов'язкове поле, ти правиш один дефолт у білдері, а не сотню тестів.
Why — a constructor like new User("Ann", "a@x.io", 30, "UA", true, null, ADMIN) is unreadable: you cannot tell which field matters. With a builder, a test about a blocked user contains only .blocked(), and every other field is hidden and valid. When the model gains a new mandatory field, you fix one default in the builder instead of a hundred tests.
Приклад — у Java зручно взяти Lombok @Builder(toBuilder = true) або написати Test Data Builder вручну; у TypeScript те саме робить функція buildUser(overrides) зі spread-оператором.
Example — in Java you can use Lombok @Builder(toBuilder = true) or hand-write a Test Data Builder; in TypeScript a buildUser(overrides) function with the spread operator does the same job.
Підводні камені — випадкові дефолти (Faker) без логування seed роблять падіння невідтворюваним. Небезпечний і один статичний білдер, спільний для тестів: хтось викликав withEmail, і наступний тест отримав чужий email. Третя проблема — білдер, що мовчки підставляє критичне поле, від якого залежить assert: тоді читач тесту не бачить причини результату.
Pitfalls — random defaults (Faker) without a logged seed make failures irreproducible. A single static builder shared by tests is dangerous too: someone called withEmail, and the next test got a stranger's email. The third problem is a builder that silently fills a field the assert depends on, so the reader cannot see why the result happened.
Уточнення на співбесіді — «Чим Builder відрізняється від Object Mother?» Mother дає готові персонажі (Users.admin()), Builder дозволяє їх тонко налаштувати; часто Mother повертає вже заповнений білдер.
Interview follow-up — "How does a Builder differ from an Object Mother?" A Mother hands out ready personas (Users.admin()), a Builder lets you fine-tune them; often the Mother returns a pre-filled builder.
public class UserBuilder {
private String email = "user-" + UUID.randomUUID() + "@test.io";
private Role role = Role.CUSTOMER; // валідні дефолти
private boolean blocked = false;
public static UserBuilder aUser() { return new UserBuilder(); }
public UserBuilder withRole(Role r) { role = r; return this; }
public UserBuilder blocked() { blocked = true; return this; }
public User build() { return new User(email, role, blocked); }
}
@Test
void blockedUserCannotLogIn() {
User user = api.create(aUser().blocked().build()); // видно лише суть тесту
assertThat(auth.login(user).statusCode()).isEqualTo(403);
}
public class UserBuilder {
private String email = "user-" + UUID.randomUUID() + "@test.io";
private Role role = Role.CUSTOMER; // valid defaults
private boolean blocked = false;
public static UserBuilder aUser() { return new UserBuilder(); }
public UserBuilder withRole(Role r) { role = r; return this; }
public UserBuilder blocked() { blocked = true; return this; }
public User build() { return new User(email, role, blocked); }
}
@Test
void blockedUserCannotLogIn() {
User user = api.create(aUser().blocked().build()); // only the point of the test is visible
assertThat(auth.login(user).statusCode()).isEqualTo(403);
}
with… повертає this, тому виклики ланцюжаться; aUser() — статична фабрика, що створює новий білдер на кожен тест, а унікальний email одразу знімає конфлікти даних.with… returns this, so calls chain; aUser() is a static factory that creates a fresh builder per test, and the unique email removes data clashes up front.Що це — Factory — це одне місце, яке знає, як створити об'єкт потрібного типу. Тест просить «дай драйвер», а фабрика читає browser=firefox або remote=true і повертає FirefoxDriver чи RemoteWebDriver на Grid.
What it is — a Factory is the one place that knows how to create an object of the right type. The test asks "give me a driver", and the factory reads browser=firefox or remote=true and returns a FirefoxDriver or a RemoteWebDriver pointing at a Grid.
Навіщо — без фабрики new ChromeDriver() розкиданий по базових класах, і щоб прогнати регресію у Firefox у CI, треба правити код. З фабрикою досить параметра -Dbrowser=firefox. Те саме працює для API: ApiClientFactory повертає клієнт з потрібним baseURI й автентифікацією для dev чи stage.
Why — without a factory, new ChromeDriver() is scattered across base classes, and running regression in Firefox on CI means editing code. With a factory a -Dbrowser=firefox parameter is enough. The same works for APIs: an ApiClientFactory returns a client with the right baseURI and authentication for dev or stage.
Приклад — switch-вираз Java 17 перетворює рядок конфігу на enum і створює драйвер з потрібними options; невідоме значення одразу кидає виняток із зрозумілим повідомленням, а не падає десь у середині тесту.
Example — a Java 17 switch expression turns the config string into an enum and creates the driver with the right options; an unknown value fails immediately with a clear message instead of crashing somewhere in the middle of a test.
Підводні камені — фабрика обростає прапорцями (мобільна емуляція, проксі, розширення, розмір вікна) і перетворюється на god object на 400 рядків. Краще винести збирання options в окремі методи або класи на кожен браузер. Друга помилка — читати System.getProperty у п'яти місцях замість одного конфіг-класу: тоді значення по замовчуванню розходяться.
Pitfalls — the factory accretes flags (mobile emulation, proxy, extensions, window size) and grows into a 400-line god object. It is better to move options assembly into separate methods or per-browser classes. The second mistake is reading System.getProperty in five places instead of one config class, so the defaults drift apart.
Уточнення на співбесіді — «Factory чи Strategy?» Фабрика відповідає на питання «який об'єкт створити», стратегія — «яку поведінку виконати»; фабрика часто саме й повертає обрану стратегію.
Interview follow-up — "Factory or Strategy?" A factory answers "which object do I create", a strategy answers "which behaviour do I run"; quite often the factory is exactly what returns the chosen strategy.
public final class DriverFactory {
public static WebDriver create() {
Browser browser = Browser.valueOf(Config.browser().toUpperCase());
if (Config.remoteUrl() != null) { // Grid або Selenoid у CI
return new RemoteWebDriver(Config.remoteUrl(), options(browser));
}
return switch (browser) {
case CHROME -> new ChromeDriver((ChromeOptions) options(browser));
case FIREFOX -> new FirefoxDriver((FirefoxOptions) options(browser));
};
}
// збирання options винесене окремо, щоб фабрика не розрослася
private static Capabilities options(Browser b) {
return switch (b) {
case CHROME -> new ChromeOptions().addArguments("--headless=new");
case FIREFOX -> new FirefoxOptions().addArguments("-headless");
};
}
}
public final class DriverFactory {
public static WebDriver create() {
Browser browser = Browser.valueOf(Config.browser().toUpperCase());
if (Config.remoteUrl() != null) { // Grid or Selenoid on CI
return new RemoteWebDriver(Config.remoteUrl(), options(browser));
}
return switch (browser) {
case CHROME -> new ChromeDriver((ChromeOptions) options(browser));
case FIREFOX -> new FirefoxDriver((FirefoxOptions) options(browser));
};
}
// options assembly lives apart so the factory does not bloat
private static Capabilities options(Browser b) {
return switch (b) {
case CHROME -> new ChromeOptions().addArguments("--headless=new");
case FIREFOX -> new FirefoxOptions().addArguments("-headless");
};
}
}
default компілятор перевіряє на повноту — додаси EDGE в enum, і код не збереться, доки не опишеш новий випадок. Browser.valueOf кине IllegalArgumentException на опечатку в конфігу ще до старту браузера.default is checked for exhaustiveness — add EDGE to the enum and the code will not compile until you handle it. Browser.valueOf throws IllegalArgumentException on a config typo before any browser starts.new для драйвера чи HTTP-клієнта має зустрічатися у фреймворку рівно в одному класі. Якщо знаходиш його в тестах — фабрики фактично немає.new for a driver or HTTP client should appear in exactly one class of the framework. If you find it in tests, you effectively have no factory.Що це — Strategy виносить змінну поведінку за інтерфейс і дає підставити потрібну реалізацію під час виконання. Класичний тестовий приклад — LoginStrategy з трьома реалізаціями: логін через форму, через API-токен і через підкладання готової cookie.
What it is — Strategy moves varying behaviour behind an interface and lets you plug in the right implementation at runtime. The classic test example is a LoginStrategy with three implementations: log in through the form, via an API token, or by injecting a ready cookie.
Коли окупається — логін через UI потрібен у двох-трьох тестах, які перевіряють саму форму. Решта 300 тестів мають лише опинитися «залогіненими», і UI-логін додає їм 5–8 секунд і ще одну точку падіння. Зі стратегією тест кошика викликає loginAs(user), а фреймворк за конфігом чи анотацією обирає API-варіант; на середовищі без API для токенів той самий тест працює через форму без змін.
When it pays off — UI login is needed in two or three tests that check the form itself. The other 300 tests just need to end up "logged in", and a UI login costs them 5–8 seconds and one more point of failure. With a strategy, the cart test calls loginAs(user), and the framework picks the API variant from config or an annotation; on an environment without a token API the same test goes through the form unchanged.
Приклад — ще доречні місця: різні способи генерації даних (API чи SQL), платіжні провайдери в тестовому режимі, валідація відповіді для v1 і v2 API.
Example — other good spots: different ways to seed data (API vs SQL), payment providers in sandbox mode, response validation for v1 and v2 of an API.
Підводні камені — інтерфейс з однією реалізацією «на майбутнє» — це лише зайвий рівень абстракції; я вводжу Strategy, коли є мінімум два реальні варіанти. Інший трейдоф: API-логін не перевіряє фронтову частину автентифікації, тож потрібен хоча б один тест, що проходить повний UI-шлях.
Pitfalls — an interface with a single implementation "for the future" is just an extra layer of indirection; I introduce Strategy when there are at least two real variants. The other trade-off: API login skips the frontend part of authentication, so at least one test must still walk the full UI path.
Уточнення на співбесіді — «Чим це краще за if/else?» Новий спосіб логіну — це новий клас, а не ще одна гілка в методі, яку зачіпають усі; кожну реалізацію можна перевірити окремо.
Interview follow-up — "Why is it better than if/else?" A new login method is a new class, not another branch in a method everyone touches, and each implementation can be verified on its own.
public interface LoginStrategy { void login(User user); }
class UiLogin implements LoginStrategy {
public void login(User u) { new LoginPage().open().signIn(u.email(), u.password()); }
}
class ApiLogin implements LoginStrategy {
public void login(User u) {
String token = authClient.token(u); // ~200 мс замість ~6 с UI
driver().get(Config.baseUrl()); // cookie ставиться лише на відкритий домен
driver().manage().addCookie(new Cookie("session", token));
}
}
// тест не знає, як саме відбувся логін
@Test
void addsItemToCart() {
Session.loginStrategy().login(aUser().build());
cart.add("SKU-42");
assertThat(cart.count()).isEqualTo(1);
}
public interface LoginStrategy { void login(User user); }
class UiLogin implements LoginStrategy {
public void login(User u) { new LoginPage().open().signIn(u.email(), u.password()); }
}
class ApiLogin implements LoginStrategy {
public void login(User u) {
String token = authClient.token(u); // ~200 ms instead of ~6 s of UI
driver().get(Config.baseUrl()); // a cookie can only be set on an open domain
driver().manage().addCookie(new Cookie("session", token));
}
}
// the test does not know how the login happened
@Test
void addsItemToCart() {
Session.loginStrategy().login(aUser().build());
cart.add("SKU-42");
assertThat(cart.count()).isEqualTo(1);
}
Session.loginStrategy() повертає реалізацію за конфігом; ApiLogin спершу відкриває домен, бо Selenium не дозволяє ставити cookie на сторінці about:blank.Session.loginStrategy() returns the implementation chosen by config; ApiLogin opens the domain first because Selenium will not set a cookie on about:blank.Що це — типовий розподіл: tests (сценарії й асерти) → steps або бізнес-дії (checkout.payByCard()) → pages/components і API clients (як взаємодіяти з системою) → core (драйвер, конфіг, HTTP, логування, звіти). Залежності йдуть лише згори донизу.
What it is — a typical split: tests (scenarios and asserts) → steps or business actions (checkout.payByCard()) → pages/components and API clients (how to interact with the system) → core (driver, config, HTTP, logging, reporting). Dependencies point only downwards.
Навіщо — кожен шар має одну причину змінюватися. Змінився локатор — правка в page. Змінився ендпоінт — у клієнті. Змінився бізнес-флоу — у steps. Перейшли з Selenium на Playwright — переписується core і pages, а тести лишаються. Це і є критерій вдалої архітектури: вартість типової зміни локальна.
Why — each layer has one reason to change. A locator changes — edit the page. An endpoint changes — edit the client. A business flow changes — edit the steps. Moving from Selenium to Playwright rewrites core and pages while tests stay. That is the test of a good architecture: the cost of a typical change stays local.
Приклад — правила, які я закріплюю в рев'ю або ArchUnit: тести не імпортують org.openqa.selenium; page objects не містять assertThat, а повертають стан; API-клієнти не знають про UI; core не залежить ні від чого вище.
Example — rules I enforce in review or with ArchUnit: tests do not import org.openqa.selenium; page objects contain no assertThat and return state instead; API clients know nothing about the UI; core depends on nothing above it.
Підводні камені — асерти в page object роблять його непридатним для негативних сценаріїв. Прямі виклики driver.findElement у тестах обходять усі очікування фреймворку. І протилежна крайність — п'ять шарів для двадцяти тестів: steps, що лише проксюють page, — це церемонія без користі.
Pitfalls — asserts inside a page object make it useless for negative scenarios. Direct driver.findElement calls in tests bypass every wait the framework has. And the opposite extreme is five layers for twenty tests: steps that just proxy a page are ceremony without value.
Уточнення на співбесіді — «Де живуть тестові дані?» Окремий модуль data: білдери та фікстури, які використовують і API-клієнти для підготовки стану, і тести.
Interview follow-up — "Where does test data live?" In a separate data module: builders and fixtures used both by API clients to set up state and by tests.
// src/test/java/com/shop/
// tests/ CheckoutTest — сценарії та асерти
// steps/ CheckoutSteps — бізнес-дії з кількох сторінок
// pages/ CartPage, Header — локатори й дії на UI
// api/ OrdersClient — HTTP-виклики
// data/ UserBuilder — тестові дані
// core/ DriverManager, Config, AllureListener
@AnalyzeClasses(packages = "com.shop")
class LayerRulesTest {
@ArchTest
static final ArchRule testsDoNotTouchSelenium =
noClasses().that().resideInAPackage("..tests..")
.should().dependOnClassesThat().resideInAPackage("org.openqa.selenium..");
@ArchTest
static final ArchRule pagesHaveNoAsserts =
noClasses().that().resideInAPackage("..pages..")
.should().dependOnClassesThat().resideInAPackage("org.assertj..");
}
// src/test/java/com/shop/
// tests/ CheckoutTest — scenarios and asserts
// steps/ CheckoutSteps — business actions spanning pages
// pages/ CartPage, Header — UI locators and actions
// api/ OrdersClient — HTTP calls
// data/ UserBuilder — test data
// core/ DriverManager, Config, AllureListener
@AnalyzeClasses(packages = "com.shop")
class LayerRulesTest {
@ArchTest
static final ArchRule testsDoNotTouchSelenium =
noClasses().that().resideInAPackage("..tests..")
.should().dependOnClassesThat().resideInAPackage("org.openqa.selenium..");
@ArchTest
static final ArchRule pagesHaveNoAsserts =
noClasses().that().resideInAPackage("..pages..")
.should().dependOnClassesThat().resideInAPackage("org.assertj..");
}
Що це — один типізований конфіг-клас, що збирає значення за пріоритетом: системна властивість -Dkey → змінна оточення → файл середовища (dev.properties, stage.properties) → дефолт. Середовище обирається одним параметром, наприклад -Denv=stage.
What it is — one typed config class that resolves values by priority: system property -Dkey → environment variable → environment file (dev.properties, stage.properties) → default. The environment is chosen by a single parameter, for example -Denv=stage.
Навіщо — той самий набір тестів має бігати локально, у PR-пайплайні на dev і вночі на stage без правок коду. Бібліотека Owner для Java дає інтерфейс з анотаціями @Key і @DefaultValue та підставляє змінні; у TypeScript те саме роблять dotenv і функція, що читає process.env та перевіряє обов'язкові ключі.
Why — the same suite has to run locally, in the PR pipeline against dev and nightly against stage without code edits. The Owner library for Java gives an interface with @Key and @DefaultValue annotations and resolves variables; in TypeScript dotenv plus a function that reads process.env and checks required keys does the same.
Приклад — URL і таймаути лежать у репозиторії, а паролі й API-ключі — лише в сховищі секретів CI (GitHub Actions secrets, Vault) і потрапляють у тести як змінні оточення; локально — з файлу .env, який внесено в .gitignore.
Example — URLs and timeouts live in the repository, while passwords and API keys live only in the CI secret store (GitHub Actions secrets, Vault) and reach the tests as environment variables; locally they come from a .env file listed in .gitignore.
Підводні камені — тихий дефолт для URL: забули змінну в CI, і нічний прогін пішов у production. Тому для критичних ключів дефолту немає — фреймворк падає на старті з назвою відсутньої змінної. Ще одна пастка — секрет у логах: не друкуй увесь конфіг і не додавай токени в Allure-вкладення.
Pitfalls — a silent default for the URL: someone forgot the variable in CI, and the nightly run went to production. So critical keys have no default — the framework fails at startup naming the missing variable. Another trap is a secret in the logs: never print the whole config and never put tokens into Allure attachments.
Уточнення на співбесіді — «Як перевірити, що секрет витік?» GitHub маскує зареєстровані секрети в логах, але не в артефактах; у репозиторії допомагає gitleaks у pre-commit.
Interview follow-up — "How do you catch a leaked secret?" GitHub masks registered secrets in logs but not in artifacts; in the repository, gitleaks in pre-commit helps.
@Config.LoadPolicy(Config.LoadType.MERGE)
@Config.Sources({
"system:properties", // -DbaseUrl=... має найвищий пріоритет
"system:env", // змінні оточення з CI
"classpath:env/${env}.properties" // dev / stage файл
})
public interface TestConfig extends Config {
@Key("baseUrl") // без дефолту — впаде, якщо не задано
String baseUrl();
@Key("timeout.seconds") @DefaultValue("10")
int timeoutSeconds();
@Key("ADMIN_PASSWORD") // лише з секретів CI або .env
String adminPassword();
}
TestConfig cfg = ConfigFactory.create(TestConfig.class);
Objects.requireNonNull(cfg.baseUrl(), "baseUrl не задано: передайте -Denv або BASE_URL");
@Config.LoadPolicy(Config.LoadType.MERGE)
@Config.Sources({
"system:properties", // -DbaseUrl=... wins
"system:env", // environment variables from CI
"classpath:env/${env}.properties" // dev / stage file
})
public interface TestConfig extends Config {
@Key("baseUrl") // no default — fails when missing
String baseUrl();
@Key("timeout.seconds") @DefaultValue("10")
int timeoutSeconds();
@Key("ADMIN_PASSWORD") // only from CI secrets or .env
String adminPassword();
}
TestConfig cfg = ConfigFactory.create(TestConfig.class);
Objects.requireNonNull(cfg.baseUrl(), "baseUrl is not set: pass -Denv or BASE_URL");
LoadType.MERGE проходить усі джерела по черзі й бере перше знайдене значення; ${env} у шляху Owner підставляє із системної властивості, тому -Denv=stage перемикає файл.LoadType.MERGE walks the sources in order and takes the first value found; Owner expands ${env} in the path from a system property, so -Denv=stage switches the file.Що це — розширення JUnit5 — це клас, що реалізує інтерфейси точок розширення: BeforeEachCallback, AfterEachCallback, ParameterResolver, TestWatcher тощо. Його підключають через @ExtendWith або власну анотацію, яка вже містить @ExtendWith.
What it is — a JUnit5 extension is a class that implements extension-point interfaces: BeforeEachCallback, AfterEachCallback, ParameterResolver, TestWatcher and so on. You attach it with @ExtendWith or with your own annotation that already carries @ExtendWith.
Навіщо — базовий клас із @BeforeEach змушує всі тести успадковувати все підряд, а розширення компонуються: тест просить параметр User user, і ParameterResolver створює його через API, а після тесту видаляє. TestWatcher.testFailed викликається лише при падінні, тож скріншот і логи збираються не для кожного зеленого тесту.
Why — a base class with @BeforeEach forces every test to inherit everything, whereas extensions compose: a test asks for a User user parameter, and a ParameterResolver creates it via the API and deletes it after the test. TestWatcher.testFailed fires only on failure, so screenshots and logs are not collected for every green test.
Приклад — стан між колбеками зберігається в ExtensionContext.Store з власним Namespace, а не в полях розширення: один екземпляр розширення може обслуговувати паралельні тести.
Example — state between callbacks is kept in ExtensionContext.Store under your own Namespace, not in extension fields: one extension instance may serve parallel tests.
Підводні камені — порядок: testFailed викликається вже після @AfterEach, тож якщо драйвер закрито в @AfterEach, скріншот робити пізно; тоді знімай його в AfterEachCallback через context.getExecutionException() або використовуй TestExecutionExceptionHandler. Поля в розширенні замість Store дають гонки при паралельному запуску.
Pitfalls — ordering: testFailed runs after @AfterEach, so if the driver is closed there, it is too late for a screenshot; take it in an AfterEachCallback via context.getExecutionException() or use a TestExecutionExceptionHandler. Fields in the extension instead of the Store cause races in parallel runs.
Уточнення на співбесіді — «А як у Playwright?» test.extend оголошує фікстуру з кодом до і після use(); це той самий принцип, лише декларативніше.
Interview follow-up — "And in Playwright?" test.extend declares a fixture with code before and after use(); same principle, just more declarative.
public class UserExtension implements ParameterResolver, AfterEachCallback {
private static final Namespace NS = Namespace.create(UserExtension.class);
public boolean supportsParameter(ParameterContext p, ExtensionContext c) {
return p.getParameter().getType() == User.class;
}
public Object resolveParameter(ParameterContext p, ExtensionContext c) {
User user = UsersApi.create(aUser().build());
c.getStore(NS).put("user", user); // стан у Store, а не в полі
return user;
}
public void afterEach(ExtensionContext c) {
User user = c.getStore(NS).remove("user", User.class);
if (user != null) UsersApi.delete(user.id()); // прибирання навіть після падіння
}
}
@Test
@ExtendWith(UserExtension.class)
void userSeesEmptyOrderHistory(User user) { /* користувач уже існує */ }
public class UserExtension implements ParameterResolver, AfterEachCallback {
private static final Namespace NS = Namespace.create(UserExtension.class);
public boolean supportsParameter(ParameterContext p, ExtensionContext c) {
return p.getParameter().getType() == User.class;
}
public Object resolveParameter(ParameterContext p, ExtensionContext c) {
User user = UsersApi.create(aUser().build());
c.getStore(NS).put("user", user); // state in the Store, not in a field
return user;
}
public void afterEach(ExtensionContext c) {
User user = c.getStore(NS).remove("user", User.class);
if (user != null) UsersApi.delete(user.id()); // cleanup even after a failure
}
}
@Test
@ExtendWith(UserExtension.class)
void userSeesEmptyOrderHistory(User user) { /* the user already exists */ }
ParameterResolver, чи вміє він надати параметр типу User; afterEach розширення виконується незалежно від результату тесту, тому видалення даних гарантоване.ParameterResolver whether it can supply a parameter of type User; the extension's afterEach runs regardless of the test result, so data deletion is guaranteed.@BeforeEach «на всякий випадок» — розбий його на розширення, які тест підключає лише тоді, коли вони йому потрібні.@BeforeEach methods, split it into extensions that a test pulls in only when it needs them.Що це — Allure працює у два етапи. Під час прогону адаптер allure-junit5 слухає події JUnit і пише JSON-файли результатів і вкладення в target/allure-results. Потім Allure CLI або allure-maven генерує з них HTML-звіт.
What it is — Allure works in two stages. During the run the allure-junit5 adapter listens to JUnit events and writes JSON result files and attachments into target/allure-results. Then the Allure CLI or allure-maven generates an HTML report from them.
Навіщо — розділення етапів дозволяє збирати результати з кількох джоб CI в одну папку і згенерувати єдиний звіт. Анотації @Step і @Attachment працюють через AspectJ, тому в argLine плагіна Surefire потрібно додати -javaagent з aspectjweaver. Шлях до результатів задається властивістю allure.results.directory.
Why — separating the stages lets you collect results from several CI jobs into one folder and generate a single report. The @Step and @Attachment annotations work through AspectJ, so the Surefire plugin's argLine needs a -javaagent with aspectjweaver. The results path is set by the allure.results.directory property.
Приклад — локально mvn clean test, потім allure serve target/allure-results піднімає тимчасовий сервер зі звітом; у CI — allure generate --clean -o allure-report і публікація папки як артефакту або на Pages.
Example — locally, mvn clean test followed by allure serve target/allure-results starts a temporary server with the report; on CI, allure generate --clean -o allure-report and publish the folder as an artifact or to Pages.
Підводні камені — найчастіша скарга «у звіті немає кроків» означає, що агент AspectJ не підключено: тест проходить, а @Step мовчки ігнорується. Друга — забутий clean: старі JSON лишаються, і звіт показує тести, яких уже немає. Третя — відкрити index.html подвійним кліком: браузер блокує завантаження даних з файлової системи, тому потрібен сервер.
Pitfalls — the most common complaint, "there are no steps in the report", means the AspectJ agent is not attached: the test passes and @Step is silently ignored. The second is a forgotten clean: old JSON files remain and the report shows tests that no longer exist. The third is opening index.html by double-click: the browser blocks loading data from the file system, so you need a server.
Уточнення на співбесіді — «Навіщо allure-bom?» BOM вирівнює версії всіх модулів Allure (junit5, rest-assured, selenide), щоб не ловити несумісність між ними.
Interview follow-up — "Why allure-bom?" The BOM aligns versions of all Allure modules (junit5, rest-assured, selenide) so you don't hit incompatibilities between them.
<!-- версії Allure з BOM -->
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-junit5</artifactId>
<scope>test</scope>
</dependency>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- без агента @Step і @Attachment не спрацюють -->
<argLine>
-javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar"
</argLine>
<systemPropertyVariables>
<allure.results.directory>${project.build.directory}/allure-results</allure.results.directory>
</systemPropertyVariables>
</configuration>
</plugin>
<!-- далі: mvn clean test, потім allure serve target/allure-results -->
<!-- Allure versions come from the BOM -->
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-junit5</artifactId>
<scope>test</scope>
</dependency>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- without the agent @Step and @Attachment do nothing -->
<argLine>
-javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar"
</argLine>
<systemPropertyVariables>
<allure.results.directory>${project.build.directory}/allure-results</allure.results.directory>
</systemPropertyVariables>
</configuration>
</plugin>
<!-- then: mvn clean test, followed by allure serve target/allure-results -->
-javaagent вплітає аспекти Allure в байткод під час завантаження класів, а systemPropertyVariables передає тестовому JVM шлях, куди адаптер пише JSON.-javaagent weaves Allure aspects into bytecode at class-load time, and systemPropertyVariables passes the test JVM the path where the adapter writes its JSON.Що це — Крок в Allure — це вкладений вузол у звіті тесту зі своєю назвою, статусом, тривалістю і власними вкладеннями. Створити його можна двома способами. Анотація @Step("Додати в кошик {sku}") над методом робить кожен виклик цього методу кроком, а імена параметрів у фігурних дужках підставляються реальними значеннями (підтримується й доступ до полів, наприклад {user.email}). Другий спосіб — Allure.step("назва", () -> { ... }): крок оголошується прямо в тілі тесту без окремого методу.
What it is — A step in Allure is a nested node in the test report with its own name, status, duration and attachments. There are two ways to create one. The @Step("Add {sku} to cart") annotation on a method turns every call of that method into a step, and parameter names in curly braces are replaced with the real values (field access such as {user.email} works too). The second way is Allure.step("name", () -> { ... }): the step is declared inline in the test body without a separate method.
Навіщо — Звіт із кроками читає не тільки автор тесту: розробник чи менеджер бачить, на якій бізнес-дії впав сценарій, не відкриваючи код. Анотацію зручно ставити на методи step-класів, page objects і API-клієнтів: позначив один раз — і всі тести отримали кроки. Лямбда підходить для короткого одноразового блоку, наприклад щоб загорнути групу перевірок.
Why — A report with steps is read by more people than the author: a developer or a manager sees which business action the scenario died on without opening the code. The annotation fits methods in step classes, page objects and API clients: mark it once and every test gets the steps. The lambda suits a short one-off block, for example wrapping a group of checks.
Приклад — метод addToCart("SKU-42", 2) з анотацією дає у звіті рядок «Додати в кошик товар SKU-42 у кількості 2», а якщо всередині він викликає інший анотований метод, у звіті з'являється дерево вкладених кроків.
Example — calling addToCart("SKU-42", 2) on an annotated method shows the line "Add SKU-42 to the cart, quantity 2" in the report, and if it calls another annotated method inside, the report shows a tree of nested steps.
Підводні камені — @Step працює через AspectJ: якщо javaagent не підключений у surefire, кроки мовчки зникають, а тест при цьому зелений. Крок на кожен клік робить звіт нечитабельним — кроки мають відповідати діям користувача, а не викликам WebDriver.
Pitfalls — @Step relies on AspectJ: without the javaagent in surefire the steps silently vanish while the test stays green. A step per click makes the report unreadable — steps should match user actions, not WebDriver calls.
Уточнення на співбесіді — «Чи класти assert у крок?» Так, перевірку варто загорнути в крок на кшталт «Перевірити суму замовлення»: тоді падіння підсвічується на конкретному кроці, а не десь у кінці тесту.
Interview follow-up — "Should an assert live inside a step?" Yes, wrapping it in a step such as "Verify order total" makes the failure highlight that exact step instead of somewhere at the end of the test.
public class CartSteps {
@Step("Додати в кошик товар {sku} у кількості {qty}")
public void addToCart(String sku, int qty) {
api.addItem(sku, qty);
}
}
@Test
void totalIsRecalculated() {
steps.addToCart("SKU-42", 2);
Allure.step("Перевірити суму в кошику", () -> {
assertThat(cart.total()).isEqualByComparingTo("59.98");
});
}
public class CartSteps {
@Step("Add {sku} to the cart, quantity {qty}")
public void addToCart(String sku, int qty) {
api.addItem(sku, qty);
}
}
@Test
void totalIsRecalculated() {
steps.addToCart("SKU-42", 2);
Allure.step("Verify the cart total", () -> {
assertThat(cart.total()).isEqualByComparingTo("59.98");
});
}
{sku} і {qty} беруться з параметрів методу, тож назва кроку в звіті містить реальні дані. Лямбда в Allure.step робить групу перевірок окремим вузлом без створення нового методу.{sku} and {qty} come from the method parameters, so the step name in the report carries real data. The lambda in Allure.step turns a group of checks into a separate node without creating a new method.@Step на методи в step-класах і API-клієнтах, бо тоді кроки з'являються у всіх тестах автоматично. Всередині самого тесту інколи беру Allure.step з лямбдою, коли хочу загорнути кілька перевірок в один читабельний блок. Одного разу ми тиждень дивувалися, чому звіт без кроків, а причина була в тому, що хтось прибрав aspectj-агента з конфігурації surefire. Тепер я завжди перевіряю це першим.@Step on methods in step classes and API clients, because then every test gets the steps for free. Inside a test I sometimes use Allure.step with a lambda when I want several checks to show up as one readable block. Once we spent a week wondering why the report had no steps at all, and it turned out someone had removed the aspectj agent from the surefire config. Now that is the first thing I check.Що це — Анотації @Epic, @Feature і @Story додають до результату тесту мітки, з яких Allure будує вкладку Behaviors: епік містить фічі, фіча містить історії, а в історіях лежать тести. Їх можна ставити на клас (успадковують усі методи) і на окремий метод. @Severity задає важливість: BLOCKER, CRITICAL, NORMAL (за замовчуванням), MINOR, TRIVIAL. @Owner показує, до кого йти з питаннями, а @Description додає розгорнутий опис. Назву тесту у звіті Allure бере з JUnit-анотації @DisplayName.
What it is — The @Epic, @Feature and @Story annotations add labels to the test result, and Allure builds the Behaviors tab from them: an epic holds features, a feature holds stories, and stories hold tests. You can put them on the class (every method inherits them) or on a single method. @Severity sets importance: BLOCKER, CRITICAL, NORMAL (the default), MINOR, TRIVIAL. @Owner tells people whom to ask, and @Description adds a longer explanation. Allure takes the test name from the JUnit @DisplayName annotation.
Навіщо — Вкладка Suites групує тести за пакетами й класами, тобто так, як зручно автоматизатору. Behaviors групує їх за продуктом: продакт-оунер відкриває «Оформлення замовлення → Кошик» і бачить, що саме там червоне. Severity дає змогу відфільтрувати звіт і першими розбирати критичні падіння.
Why — The Suites tab groups tests by package and class, which is how an automation engineer thinks. Behaviors groups them by product: a product owner opens "Checkout → Cart" and sees what exactly is red there. Severity lets you filter the report and triage the critical failures first.
Приклад — клас CartTest позначений епіком і фічею, а кожен метод має свою історію, наприклад «Промокод», і severity відповідно до ризику.
Example — the CartTest class carries the epic and the feature, while each method has its own story, such as "Promo code", and a severity that reflects the risk.
Підводні камені — Якщо один автор пише «Checkout», а інший «Оформлення», дерево розпадається на дві гілки. Назви варто тримати в класі констант і використовувати з нього. Ще одна помилка — ставити CRITICAL на все підряд, після чого фільтр втрачає сенс.
Pitfalls — If one author writes "Checkout" and another writes "Order checkout", the tree splits into two branches. Keep the names in a constants class and reference them from there. Another mistake is marking everything CRITICAL, after which the filter is useless.
Уточнення на співбесіді — «Чи можна запустити тільки критичні тести за severity?» Ні, це лише метадані для звіту. Для вибіркового запуску потрібен JUnit @Tag("critical") і фільтр groups у surefire.
Interview follow-up — "Can I run only the critical tests by severity?" No, it is report metadata only. Selective runs need a JUnit @Tag("critical") and a groups filter in surefire.
@Epic("Оформлення замовлення")
@Feature("Кошик")
@Owner("o.koval")
class CartTest {
@Test
@Story("Промокод")
@Severity(SeverityLevel.CRITICAL)
@DisplayName("Промокод SALE10 зменшує суму на 10%")
@Description("Сума перераховується одразу після застосування коду")
void promoCodeReducesTotal() {
cart.applyPromo("SALE10");
assertThat(cart.total()).isEqualByComparingTo("90.00");
}
}
@Epic("Checkout")
@Feature("Cart")
@Owner("o.koval")
class CartTest {
@Test
@Story("Promo code")
@Severity(SeverityLevel.CRITICAL)
@DisplayName("Promo code SALE10 takes 10% off the total")
@Description("The total is recalculated right after the code is applied")
void promoCodeReducesTotal() {
cart.applyPromo("SALE10");
assertThat(cart.total()).isEqualByComparingTo("90.00");
}
}
Що це — @Issue("SHOP-3141") і @TmsLink("QA-1287") зберігають у результаті тесту лише ідентифікатор. Повну адресу Allure будує з шаблону у файлі allure.properties в src/test/resources: allure.link.issue.pattern і allure.link.tms.pattern, де {} замінюється на ID. @Link(name = "...", url = "...") потрібен для довільних посилань, наприклад на специфікацію API чи сторінку в Confluence.
What it is — @Issue("SHOP-3141") and @TmsLink("QA-1287") store only an identifier in the test result. Allure builds the full URL from a pattern in allure.properties under src/test/resources: allure.link.issue.pattern and allure.link.tms.pattern, where {} is replaced by the ID. @Link(name = "...", url = "...") covers arbitrary links such as an API spec or a Confluence page.
Навіщо — Це відповідає на два часті запитання. Перше — який автотест покриває тест-кейс QA-1287, тобто базова трасованість. Друге — чому цей тест червоний: якщо над ним висить @Issue з відкритим багом, розбирати падіння вдруге не треба. Шаблон в одному файлі означає, що після переїзду Jira на новий домен міняється один рядок, а не тисяча анотацій.
Why — It answers two frequent questions. First, which automated test covers test case QA-1287, which is basic traceability. Second, why is this test red: if it carries an @Issue pointing to an open bug, nobody needs to triage it again. Keeping the pattern in one file means that when Jira moves to a new domain you change one line, not a thousand annotations.
Приклад — тест часткового повернення коштів падає через відомий баг SHOP-3141. Ми додаємо @Issue, і в Allure поруч із падінням з'являється клікабельне посилання на тікет, а в описі видно, що проблема вже відома.
Example — a partial refund test fails because of the known bug SHOP-3141. We add @Issue, and Allure shows a clickable ticket link next to the failure, so everyone can see the problem is already known.
Підводні камені — Посилання гниють: баг закрили, а анотація лишилася, і тест, який знову впаде, помилково вважають відомою проблемою. Повні URL у кожній анотації замість шаблону теж погана ідея. Ще одна пастка — ключ властивості має збігатися з типом посилання, інакше Allure покаже голий ID без адреси.
Pitfalls — Links rot: the bug gets closed, the annotation stays, and a test that breaks again is mistaken for a known issue. Hardcoding full URLs in every annotation instead of a pattern is also a bad idea. Another trap is that the property key must match the link type, or Allure shows a bare ID with no URL.
Уточнення на співбесіді — «Як тримати посилання актуальними?» Простий скрипт у нічному CI збирає всі @Issue з результатів, питає статуси в Jira API і повідомляє про тести, чиї баги вже закриті.
Interview follow-up — "How do you keep the links current?" A small script in the nightly CI collects every @Issue from the results, queries the Jira API for statuses and reports tests whose bugs are already closed.
# src/test/resources/allure.properties
allure.results.directory=target/allure-results
allure.link.issue.pattern=https://jira.example.com/browse/{}
allure.link.tms.pattern=https://tms.example.com/case/{}
// RefundTest.java
@Test
@TmsLink("QA-1287")
@Issue("SHOP-3141")
@Link(name = "Refund API spec", url = "https://docs.example.com/orders#refund")
void partialRefundKeepsOrderOpen() {
Refund refund = orders.refund(orderId, new BigDecimal("10.00"));
assertThat(orders.get(orderId).status()).isEqualTo(OrderStatus.OPEN);
}
@Link несе повну адресу, бо для нього немає шаблону.@Link carries a full URL because there is no pattern for it.@TmsLink на кейс у TestRail, і з результатів Allure ми будуємо звіт покриття для релізу. @Issue я ставлю лише тоді, коли тест падає через уже заведений баг, і обов'язково з коментарем. Одного разу закритий баг повернувся регресією, а тест через стару анотацію всі вважали відомою проблемою. Після цього ми додали нічну перевірку статусів тікетів.@TmsLink to its TestRail case, and we build the release coverage report from the Allure results. I add @Issue only when a test fails because of a bug that is already filed, and always with a comment. Once a closed bug came back as a regression, and everyone ignored the failing test because of a stale annotation. After that we added a nightly check of ticket statuses.Що це — Вкладення в Allure додаються через Allure.addAttachment(name, type, content, extension) або метод з анотацією @Attachment(value = "...", type = "image/png"), який повертає byte[]. Щоб це відбувалося без ручних викликів у кожному тесті, логіку кладуть у JUnit5-розширення, яке перевіряє, чи був виняток. Для API є готовий фільтр AllureRestAssured: він прикріплює кожен запит і відповідь.
What it is — Allure attachments are added with Allure.addAttachment(name, type, content, extension) or with a method annotated @Attachment(value = "...", type = "image/png") that returns byte[]. To make it happen without manual calls in every test, the logic goes into a JUnit5 extension that checks whether an exception occurred. For APIs there is a ready filter, AllureRestAssured, which attaches every request and response.
Навіщо — Падіння в нічному прогоні часто не відтворюється локально. Скріншот показує стан екрана, page source дає DOM у момент помилки, а HTTP-обмін пояснює, що бекенд реально повернув. З цими трьома речами більшість падінь можна розібрати без перезапуску.
Why — A failure in the nightly run often does not reproduce locally. The screenshot shows the screen state, the page source gives the DOM at the moment of the error, and the HTTP exchange explains what the backend really returned. With those three you can triage most failures without a rerun.
Приклад — розширення реалізує AfterTestExecutionCallback і дивиться на getExecutionException(). Цей колбек виконується до методів @AfterEach, тож браузер ще живий.
Example — the extension implements AfterTestExecutionCallback and inspects getExecutionException(). This callback runs before the @AfterEach methods, so the browser is still alive.
Підводні камені — Популярна помилка — робити скріншот у TestWatcher.testFailed: він викликається вже після @AfterEach, де драйвер закрито, і замість скріншота маємо ще один виняток. Друга — знімати скріншот після кожного кроку: allure-results розростаються до гігабайтів, а CI-артефакти впираються в ліміти. Логи HTTP з токенами варто маскувати.
Pitfalls — A common mistake is taking the screenshot in TestWatcher.testFailed: it runs after @AfterEach, where the driver is already quit, so instead of a screenshot you get another exception. The second is a screenshot after every step: allure-results grows to gigabytes and CI artifacts hit their limits. HTTP logs containing tokens should be masked.
Уточнення на співбесіді — «А відео?» Відео пишемо для всіх, але зберігаємо лише для впалих тестів: у Selenoid чи Playwright це налаштування, а не код.
Interview follow-up — "What about video?" We record it for everything but keep it only for failed tests: in Selenoid or Playwright that is a setting, not code.
public class EvidenceOnFailure implements AfterTestExecutionCallback {
@Override
public void afterTestExecution(ExtensionContext ctx) {
if (ctx.getExecutionException().isEmpty()) return; // тест зелений
WebDriver driver = DriverHolder.get(); // драйвер ще не закритий
byte[] png = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
Allure.addAttachment("Скріншот у момент падіння", "image/png",
new ByteArrayInputStream(png), "png");
Allure.addAttachment("Page source", "text/html",
driver.getPageSource(), "html");
}
}
// API-тести: кожен запит і відповідь стають вкладенням
given().filter(new AllureRestAssured())
.when().get("/orders/42")
.then().statusCode(200);
public class EvidenceOnFailure implements AfterTestExecutionCallback {
@Override
public void afterTestExecution(ExtensionContext ctx) {
if (ctx.getExecutionException().isEmpty()) return; // test passed
WebDriver driver = DriverHolder.get(); // driver not quit yet
byte[] png = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
Allure.addAttachment("Screenshot at failure", "image/png",
new ByteArrayInputStream(png), "png");
Allure.addAttachment("Page source", "text/html",
driver.getPageSource(), "html");
}
}
// API tests: every request and response becomes an attachment
given().filter(new AllureRestAssured())
.when().get("/orders/42")
.then().statusCode(200);
getExecutionException() повертає Optional з винятком тесту, тож вкладення створюються тільки для червоних тестів. Розширення підключається через @ExtendWith(EvidenceOnFailure.class) на базовому класі.getExecutionException() returns an Optional with the test's exception, so attachments are created only for red tests. The extension is registered with @ExtendWith(EvidenceOnFailure.class) on a base class.AllureRestAssured у базову специфікацію. Спочатку я робив це в TestWatcher і отримував порожні вкладення, бо драйвер на той момент уже був закритий. Перейшов на AfterTestExecutionCallback, і все запрацювало. Скріншоти після кожного кроку ми свідомо не робимо, щоб артефакти не роздувалися.AllureRestAssured filter to the base spec. At first I did it in a TestWatcher and got empty attachments, because by then the driver was already closed. I switched to AfterTestExecutionCallback and it worked. We deliberately skip screenshots after every step so the artifacts stay small.Що це — Пакет allure-playwright — це репортер Playwright Test: його додають у масив reporter у playwright.config.ts, і під час прогону він пише JSON-файли в allure-results. Кроки з test.step() автоматично стають кроками Allure, а вкладення з testInfo.attach(), скріншоти й trace, налаштовані в секції use, прикріплюються до тесту. Мітки на кшталт epic, feature і severity задаються функціями з пакета allure-js-commons.
What it is — The allure-playwright package is a Playwright Test reporter: you add it to the reporter array in playwright.config.ts, and during the run it writes JSON files to allure-results. Steps from test.step() automatically become Allure steps, and attachments from testInfo.attach() plus the screenshots and traces configured under use are attached to the test. Labels such as epic, feature and severity are set with functions from the allure-js-commons package.
Навіщо і коли — Вбудований HTML-звіт Playwright чудовий для одного прогону: він відкриває trace viewer, показує ретраї й діфи скріншотів. Allure потрібен, коли важливі історія і тренди між збірками, категорії падінь, групування за продуктом або єдиний звіт для кількох фреймворків, наприклад API-тестів на Java і UI-тестів на TypeScript.
Why and when — Playwright's built-in HTML report is great for a single run: it opens the trace viewer and shows retries and screenshot diffs. Allure is worth it when you need history and trends across builds, failure categories, grouping by product, or one report for several frameworks, for example Java API tests and TypeScript UI tests.
Приклад — у конфігурації два репортери: list для консолі CI і allure-playwright для звіту. Тест задає фічу й severity, загортає дії в test.step і прикріплює JSON кошика.
Example — the config has two reporters: list for the CI console and allure-playwright for the report. The test sets a feature and severity, wraps actions in test.step and attaches the cart JSON.
Підводні камені — Репортер лише пише результати, а сам звіт генерує окремий CLI Allure, який треба встановити в CI. Версії allure-playwright і allure-js-commons мають збігатися, інакше мітки мовчки губляться. Якщо лишити тільки Allure, команда втрачає зручний перегляд trace — тому зазвичай тримають обидва звіти.
Pitfalls — The reporter only writes results; the report itself is generated by a separate Allure CLI that has to be installed in CI. The versions of allure-playwright and allure-js-commons must match, or labels silently disappear. If you keep only Allure, the team loses the convenient trace viewing, so teams usually keep both reports.
Уточнення на співбесіді — «Чи потрапить trace в Allure?» Так, як вкладення-архів, але зручно його відкривати все одно через npx playwright show-trace або trace.playwright.dev.
Interview follow-up — "Does the trace end up in Allure?" Yes, as a zip attachment, but it is still easier to open via npx playwright show-trace or trace.playwright.dev.
// playwright.config.ts
export default defineConfig({
reporter: [['list'], ['allure-playwright', { resultsDir: 'allure-results' }]],
use: { screenshot: 'only-on-failure', trace: 'retain-on-failure' },
});
// checkout.spec.ts
import * as allure from 'allure-js-commons';
test('гість оформлює замовлення', async ({ page }, testInfo) => {
await allure.feature('Оформлення замовлення');
await allure.severity('critical');
const cart = { sku: 'SKU-42', qty: 1 };
await test.step('Додати товар у кошик', async () => {
await page.getByTestId('add-to-cart').click();
});
await testInfo.attach('кошик.json', {
body: JSON.stringify(cart), contentType: 'application/json',
});
});
// playwright.config.ts
export default defineConfig({
reporter: [['list'], ['allure-playwright', { resultsDir: 'allure-results' }]],
use: { screenshot: 'only-on-failure', trace: 'retain-on-failure' },
});
// checkout.spec.ts
import * as allure from 'allure-js-commons';
test('guest places an order', async ({ page }, testInfo) => {
await allure.feature('Checkout');
await allure.severity('critical');
const cart = { sku: 'SKU-42', qty: 1 };
await test.step('Add the item to the cart', async () => {
await page.getByTestId('add-to-cart').click();
});
await testInfo.attach('cart.json', {
body: JSON.stringify(cart), contentType: 'application/json',
});
});
test.step і testInfo.attach — стандартне API Playwright, яке Allure просто підхоплює.test.step and testInfo.attach are plain Playwright API that Allure simply picks up.allure-playwright, бо наші API-тести на Java теж пишуть в Allure і менеджмент дивиться один звіт. Кроки я пишу тільки через test.step, без Allure-специфічних обгорток, тож тести не залежать від репортера. Якщо Allure колись приберуть, змінимо один рядок конфігурації.allure-playwright, because our Java API tests also write to Allure and management looks at a single report. I write steps only with test.step, without Allure-specific wrappers, so tests do not depend on the reporter. If we ever drop Allure, we change one line of config.Що це — Звіт Allure 2 генерується з папки allure-results і сам по собі нічого не пам'ятає. Графіки трендів і позначка flaky з'являються, лише якщо перед генерацією скопіювати папку history з попереднього звіту в allure-results/history. У CI це означає: завантажити минулий звіт (з гілки gh-pages, S3 чи артефакту), перенести історію, згенерувати новий звіт і опублікувати його туди ж.
What it is — An Allure 2 report is generated from the allure-results folder and remembers nothing on its own. Trend charts and the flaky marker appear only if, before generating, you copy the history folder from the previous report into allure-results/history. In CI that means: fetch the last report (from a gh-pages branch, S3 or an artifact), carry the history over, generate the new report and publish it to the same place.
Класифікація — За замовчуванням Allure ділить падіння на Product defects (статус failed, тобто AssertionError) і Test defects (broken, будь-який інший виняток). Файл categories.json у allure-results додає власні категорії за messageRegex і traceRegex: «Інфраструктура» для Connection refused і 502, «Таймаути UI» для TimeoutException. Поряд кладуть environment.properties зі стендом і версією збірки та executor.json
Classification — By default Allure splits failures into Product defects (status failed, meaning AssertionError) and Test defects (broken, any other exception). A categories.json file in allure-results adds custom categories by messageRegex and traceRegex: "Infrastructure" for Connection refused and 502, "UI timeouts" for TimeoutException. Next to it go environment.properties with the stand and build version, and executor.json with a link to the CI build.
Компроміси — Історію варто вести лише для main або нічного прогону: якщо змішати фіча-гілки, тренд показує шум чужих незавершених змін. Звіти на Pages ростуть, тому потрібна ротація, наприклад останні 20 збірок. Категорії — це регулярні вирази, які треба переглядати, інакше нова інфраструктурна помилка тихо потрапляє в Product defects.
Trade-offs — Keep history only for main or the nightly run: if you mix in feature branches, the trend shows the noise of other people's unfinished work. Reports on Pages keep growing, so you need rotation, for example the last 20 builds. Categories are regular expressions that need reviewing, or a new kind of infrastructure error silently lands in Product defects.
Уточнення на співбесіді — «Навіщо категорії, якщо є статуси?» Вони дають метрику: якщо 60% падінь за тиждень — інфраструктура, інвестувати треба в стенд, а не в переписування тестів.
Interview follow-up — "Why categories if you have statuses?" They give you a metric: if 60% of the week's failures are infrastructure, you invest in the environment, not in rewriting tests.
- name: Отримати попередній звіт
uses: actions/checkout@v4
with: { ref: gh-pages, path: gh-pages }
continue-on-error: true # першого разу гілки ще немає
- name: Перенести історію і категорії
run: |
mkdir -p allure-results/history
cp -r gh-pages/history/. allure-results/history/ || true
cp ci/categories.json ci/environment.properties allure-results/
- name: Згенерувати звіт
run: allure generate allure-results -o allure-report --clean
// ci/categories.json
[
{ "name": "Інфраструктура", "matchedStatuses": ["broken"],
"messageRegex": ".*(Connection refused|502 Bad Gateway).*" },
{ "name": "Таймаути UI", "matchedStatuses": ["broken", "failed"],
"traceRegex": ".*TimeoutException.*" }
]
- name: Fetch the previous report
uses: actions/checkout@v4
with: { ref: gh-pages, path: gh-pages }
continue-on-error: true # the branch does not exist on the first run
- name: Carry over history and categories
run: |
mkdir -p allure-results/history
cp -r gh-pages/history/. allure-results/history/ || true
cp ci/categories.json ci/environment.properties allure-results/
- name: Generate the report
run: allure generate allure-results -o allure-report --clean
// ci/categories.json
[
{ "name": "Infrastructure", "matchedStatuses": ["broken"],
"messageRegex": ".*(Connection refused|502 Bad Gateway).*" },
{ "name": "UI timeouts", "matchedStatuses": ["broken", "failed"],
"traceRegex": ".*TimeoutException.*" }
]
Що це — JUnit XML — це фактичний стандарт машинозчитуваних результатів: елементи testsuite, testcase, failure, error, skipped. Його без додаткових налаштувань пишуть Maven Surefire (target/surefire-reports/TEST-*.xml), Failsafe (target/failsafe-reports), Gradle (build/test-results/test), а в Playwright є вбудований репортер junit.
What it is — JUnit XML is the de facto standard for machine-readable results: the testsuite, testcase, failure, error and skipped elements. Maven Surefire writes it out of the box (target/surefire-reports/TEST-*.xml), so do Failsafe (target/failsafe-reports) and Gradle (build/test-results/test), and Playwright has a built-in junit reporter.
Навіщо — CI-системи читають цей формат нативно. GitLab через artifacts:reports:junit показує впалі тести прямо у віджеті merge request. Jenkins-крок junit будує тренд і позначає збірку як UNSTABLE. У GitHub Actions екшени на кшталт mikepenz/action-junit-report перетворюють XML на анотації в PR і зведення в check. Розробник бачить, що зламалося, там, де він і так працює, не відкриваючи окремий звіт. На цих же файлах будується аналітика flaky-тестів у багатьох CI.
Why — CI systems read this format natively. GitLab uses artifacts:reports:junit to show failed tests right in the merge request widget. The Jenkins junit step builds a trend and marks the build UNSTABLE. In GitHub Actions, actions such as mikepenz/action-junit-report turn the XML into PR annotations and a check summary. A developer sees what broke where they already work, without opening a separate report. Many CI flaky-test analytics are built on the same files.
Приклад — Allure лишається для глибокого розбору з кроками й вкладеннями, а XML дає швидку відповідь у PR: «впав OrderApiTest.refund, ось повідомлення».
Example — Allure stays for deep analysis with steps and attachments, while the XML gives a quick answer in the PR: "OrderApiTest.refund failed, here is the message".
Підводні камені — Без if: always() крок публікації пропускається саме тоді, коли тести впали. При шардингу однакові імена файлів з різних джобів перезаписують один одного. Шлях outputFile для Playwright треба вказати явно, інакше звіт іде в stdout.
Pitfalls — Without if: always() the publish step is skipped exactly when the tests failed. With sharding, identical file names from different jobs overwrite each other. The Playwright outputFile path must be set explicitly, otherwise the report goes to stdout.
Уточнення на співбесіді — «Чим failure відрізняється від error у XML?» Failure — це невдала перевірка, error — несподіваний виняток; це та сама різниця, що між failed і broken в Allure.
Interview follow-up — "How does failure differ from error in the XML?" Failure is a failed assertion, error is an unexpected exception; it is the same split as failed versus broken in Allure.
# GitHub Actions, Java
- name: Тести
run: mvn -B verify
- name: Результати в PR
if: always() # публікувати і при червоних тестах
uses: mikepenz/action-junit-report@v4
with:
report_paths: '**/target/surefire-reports/TEST-*.xml'
# GitLab CI, Playwright (у конфігу: reporter junit з outputFile results/junit.xml)
ui-tests:
script: npx playwright test
artifacts:
when: always
reports:
junit: results/junit.xml
# GitHub Actions, Java
- name: Tests
run: mvn -B verify
- name: Results in the PR
if: always() # publish even when tests are red
uses: mikepenz/action-junit-report@v4
with:
report_paths: '**/target/surefire-reports/TEST-*.xml'
# GitLab CI, Playwright (config: junit reporter with outputFile results/junit.xml)
ui-tests:
script: npx playwright test
artifacts:
when: always
reports:
junit: results/junit.xml
if: always() і when: always роблять одне й те саме в різних CI — запускають публікацію незалежно від статусу попередніх кроків. Glob-шаблон збирає XML з усіх модулів Maven.if: always() and when: always do the same thing in different CIs — run the publishing regardless of the previous steps' status. The glob pattern collects XML from every Maven module.if: always(), і звіт з'являвся лише для зелених збірок, тобто тоді, коли він нікому не потрібен.if: always(), and the report only showed up for green builds, exactly when nobody needs it.Що це — Політика локаторів — це письмовий порядок пріоритетів, якого дотримуються всі автори тестів. Типовий варіант: спершу доступна роль і підпис (getByRole, getByLabel у Playwright) або окремий тестовий атрибут data-testid; далі стабільний id; потім короткий CSS за атрибутами; XPath — лише коли треба знайти елемент за текстом чи відносно сусіда і нічого іншого немає.
What it is — A locator policy is a written priority order every test author follows. A typical version: first the accessible role and label (getByRole, getByLabel in Playwright) or a dedicated test attribute data-testid; then a stable id; then short attribute-based CSS; XPath only when you must find an element by text or relative to a neighbour and nothing else works.
Навіщо — Більшість поломок UI-тестів після редизайну — це локатори. CSS-класи часто генеруються (CSS Modules чи styled-components дають щось на кшталт .btn_x7f2a), структура DOM змінюється від обгортки, а локатор за текстом ламається від перекладу. Тестовий атрибут — це контракт: розробник знає, що його не можна мовчки видалити.
Why — Most UI test breakages after a redesign are locators. CSS classes are often generated (CSS Modules or styled-components produce something like .btn_x7f2a), the DOM structure changes when someone adds a wrapper, and a text locator breaks on translation. A test attribute is a contract: the developer knows it cannot be silently removed.
Приклад — домовляємося з фронтендом про формат імен за доменом (checkout-submit, cart-row), додаємо атрибути в Definition of Done задачі, а для списків ставимо id на рядок і фільтруємо за текстом усередині. Крихкий //div[3]/div/button[2] замінюється на пошук за checkout-submit.
Example — we agree a domain-based naming format with the frontend (checkout-submit, cart-row), add the attributes to the task's Definition of Done, and for lists we put the id on the row and filter by the text inside. The fragile //div[3]/div/button[2] becomes a lookup by checkout-submit.
Підводні камені — Тестові атрибути на кожному div перетворюються на новий шум; вони потрібні на інтерактивних і ключових елементах. Якщо брати тільки test id, тести перестають помічати проблеми доступності, тому для кнопок і полів локатор за роллю часто кращий. Дублікати id на сторінці дають неоднозначні збіги.
Pitfalls — Test attributes on every div become new noise; they belong on interactive and key elements. Relying only on test ids makes tests blind to accessibility problems, so for buttons and inputs a role locator is often better. Duplicate ids on a page give ambiguous matches.
Уточнення на співбесіді — «У Selenium немає getByTestId, що робите?» Пишемо хелпер byTestId(), що будує CSS за атрибутом, і використовуємо тільки його.
Interview follow-up — "Selenium has no getByTestId, what do you do?" We write a byTestId() helper that builds attribute CSS and use only that.
// Погано: залежить від верстки і згенерованих класів
By.xpath("//div[3]/div/button[2]");
By.cssSelector(".css-1x2y9 > span");
// Добре: контракт із розробниками
public static By byTestId(String id) {
return By.cssSelector("[data-testid='" + id + "']");
}
driver.findElement(byTestId("checkout-submit")).click();
// Playwright: роль для кнопок, test id для рядків списку
await page.getByRole('button', { name: 'Оформити замовлення' }).click();
await page.getByTestId('cart-row').filter({ hasText: 'SKU-42' })
.getByTestId('remove').click();
// Bad: depends on layout and generated classes
By.xpath("//div[3]/div/button[2]");
By.cssSelector(".css-1x2y9 > span");
// Good: a contract with developers
public static By byTestId(String id) {
return By.cssSelector("[data-testid='" + id + "']");
}
driver.findElement(byTestId("checkout-submit")).click();
// Playwright: role for buttons, test id for list rows
await page.getByRole('button', { name: 'Place order' }).click();
await page.getByTestId('cart-row').filter({ hasText: 'SKU-42' })
.getByTestId('remove').click();
byTestId ховає формат селектора в одному місці, тож перейменування атрибута — це одна правка. У Playwright атрибут для getByTestId можна змінити через testIdAttribute у конфігурації.byTestId helper keeps the selector format in one place, so renaming the attribute is a single edit. In Playwright the attribute used by getByTestId can be changed via testIdAttribute in the config.data-testid — для списків і складних віджетів. З фронтендерами домовилися, що атрибути додаються разом із фічею, а не коли тест уже впав. Після цього кількість падінь через редизайн у нас зменшилася в рази, бо локатори просто перестали залежати від верстки.data-testid is for lists and complex widgets. We agreed with the frontend developers that attributes are added together with the feature, not after a test has already failed. After that, breakages caused by redesigns dropped several times over, because locators simply stopped depending on the layout.Що це — findElement(By) повертає перший знайдений WebElement або кидає NoSuchElementException, якщо нічого не знайшлося. findElements(By) повертає List<WebElement> і ніколи не кидає виняток через відсутність: якщо збігів немає, список просто порожній.
What it is — findElement(By) returns the first matching WebElement or throws NoSuchElementException when nothing matches. findElements(By) returns a List<WebElement> and never throws because of absence: with no matches the list is simply empty.
Навіщо — Різниця важлива для перевірок «елемента не повинно бути»: промо-банера для преміум-користувача, кнопки видалення для гостя. Ловити виняток у тесті — поганий стиль, а findElements(...).isEmpty() читається як звичайна умова.
Why — The difference matters for "this must not be here" checks: a promo banner for a premium user, a delete button for a guest. Catching an exception inside a test is poor style, while findElements(...).isEmpty() reads like an ordinary condition.
Приклад — Для перевірки прав гостя я роблю assertThat(driver.findElements(deleteBtn)).isEmpty(). Якщо ж елемент має зникнути після дії (спінер), краще явне очікування invisibilityOfElementLocated, бо одноразова перевірка може спрацювати до того, як спінер з'явиться.
Example — To verify guest permissions I write assertThat(driver.findElements(deleteBtn)).isEmpty(). If the element has to disappear after an action (a spinner), an explicit invisibilityOfElementLocated wait is better, because a one-off check may run before the spinner even shows up.
Підводні камені — Якщо ввімкнено implicit wait, findElements для відсутнього елемента чекає весь таймаут, перш ніж повернути порожній список. Десять таких перевірок з implicit wait 10 с — це зайві 100 секунд прогону.
Pitfalls — With an implicit wait enabled, findElements on an absent element waits for the full timeout before returning the empty list. Ten such checks with a 10 s implicit wait add 100 useless seconds to the run.
Уточнення на співбесіді — «Що поверне findElement, якщо збігів три?» Перший у порядку документа; тому неунікальний локатор — прихована помилка, а не дрібниця.
Interview follow-up — "What does findElement return when three elements match?" The first one in document order, which is why a non-unique locator is a hidden bug rather than a detail.
// Гість не повинен бачити кнопку видалення
List<WebElement> buttons = driver.findElements(By.cssSelector("[data-testid='delete-post']"));
assertThat(buttons).isEmpty();
// findElement кидає виняток, якщо елемента немає
assertThrows(NoSuchElementException.class,
() -> driver.findElement(By.id("delete-post")));
// Елемент має зникнути після дії — чекаємо явно
new WebDriverWait(driver, Duration.ofSeconds(5))
.until(ExpectedConditions.invisibilityOfElementLocated(By.id("spinner")));
// A guest must not see the delete button
List<WebElement> buttons = driver.findElements(By.cssSelector("[data-testid='delete-post']"));
assertThat(buttons).isEmpty();
// findElement throws when the element is missing
assertThrows(NoSuchElementException.class,
() -> driver.findElement(By.id("delete-post")));
// The element must disappear after an action — wait explicitly
new WebDriverWait(driver, Duration.ofSeconds(5))
.until(ExpectedConditions.invisibilityOfElementLocated(By.id("spinner")));
findElements — штатний результат, тому AssertJ перевіряє його без обробки винятків. assertThrows показує поведінку findElement, а WebDriverWait закриває сценарій «має зникнути».findElements is a normal result, so AssertJ checks it with no exception handling. assertThrows shows how findElement behaves, and WebDriverWait covers the "must disappear" case.findElements(...).isEmpty(), «має зникнути» — це явне очікування невидимості. І не забудь, що implicit wait робить кожну перевірку відсутності повільною.findElements(...).isEmpty(); "must go away" is an explicit invisibility wait. And remember that an implicit wait makes every absence check slow.Що це — Явне очікування (WebDriverWait, що наслідує FluentWait) опитує умову з певним інтервалом, доки вона не поверне істину або не мине таймаут. Хелпер на кшталт класу Waits збирає типові умови в одному місці: видимість, клікабельність, кількість рядків, текст.
What it is — An explicit wait (WebDriverWait, which extends FluentWait) polls a condition at an interval until it returns something truthy or the timeout runs out. A helper such as a Waits class keeps the common conditions in one place: visibility, clickability, row count, text.
Навіщо — Thread.sleep(3000) завжди програє: на швидкому стенді марнує час, на повільному все одно падає. Умовне очікування завершується тієї ж миті, коли стан настав, а таймаут береться з конфігу, тож для CI його можна підняти без правок коду.
Why — Thread.sleep(3000) always loses: on a fast stand it wastes time, on a slow one it still fails. A conditional wait finishes the moment the state is reached, and the timeout comes from config, so it can be raised for CI without touching code.
Приклад — Після фільтрації таблиця перемальовується асинхронно. Умова-лямбда d -> d.findElements(rows).size() == 3 чекає саме бізнес-стану, а не абстрактної видимості. Для елементів, що перебудовуються React-ом, FluentWait з ignoring(StaleElementReferenceException.class) пропускає проміжні збої.
Example — After filtering, a table re-renders asynchronously. The lambda condition d -> d.findElements(rows).size() == 3 waits for the actual business state rather than generic visibility. For elements React keeps rebuilding, a FluentWait with ignoring(StaleElementReferenceException.class) skips the transient failures.
Підводні камені — Не варто ігнорувати всі винятки підряд — так ховаються справжні помилки локаторів. Не треба й чекати «на всяк випадок» перед кожною дією: хелпер має очікувати конкретну умову, пов'язану з наступним кроком.
Pitfalls — Do not ignore every exception type — that hides genuine locator mistakes. Nor should you wait "just in case" before every action: the helper should wait for a specific condition tied to the next step.
Уточнення на співбесіді — «Який інтервал опитування за замовчуванням?» У WebDriverWait це 500 мс; я змінюю його через pollingEvery лише тоді, коли умова дорога або стан змінюється дуже швидко.
Interview follow-up — "What is the default polling interval?" For WebDriverWait it is 500 ms; I change it with pollingEvery only when the condition is expensive or the state flips very quickly.
public final class Waits {
private final WebDriverWait wait;
public Waits(WebDriver driver, Duration timeout) { // таймаут із конфігу
this.wait = new WebDriverWait(driver, timeout);
}
public WebElement visible(By locator) {
return wait.until(ExpectedConditions.visibilityOfElementLocated(locator));
}
// Власна умова: у таблиці рівно expected рядків
public void rowCount(By rows, int expected) {
wait.until(d -> d.findElements(rows).size() == expected);
}
}
// Для елементів, які часто перебудовуються
Wait<WebDriver> fluent = new FluentWait<>(driver)
.withTimeout(Duration.ofSeconds(20))
.pollingEvery(Duration.ofMillis(300))
.ignoring(StaleElementReferenceException.class);
public final class Waits {
private final WebDriverWait wait;
public Waits(WebDriver driver, Duration timeout) { // timeout from config
this.wait = new WebDriverWait(driver, timeout);
}
public WebElement visible(By locator) {
return wait.until(ExpectedConditions.visibilityOfElementLocated(locator));
}
// Custom condition: the table has exactly expected rows
public void rowCount(By rows, int expected) {
wait.until(d -> d.findElements(rows).size() == expected);
}
}
// For elements that get rebuilt often
Wait<WebDriver> fluent = new FluentWait<>(driver)
.withTimeout(Duration.ofSeconds(20))
.pollingEvery(Duration.ofMillis(300))
.ignoring(StaleElementReferenceException.class);
until приймає будь-яку функцію від драйвера, тому лямбда з підрахунком рядків — повноцінна умова. FluentWait дає явний контроль над таймаутом, частотою опитування та списком винятків, які можна пропустити.until accepts any function of the driver, so a row-counting lambda is a full condition. FluentWait gives explicit control over timeout, polling rate and the exceptions that may be skipped.Thread.sleep, це привід для коментаря на рев'ю.Thread.sleep showing up in a pull request is a reason for a review comment.Що це — Implicit wait — глобальне налаштування сесії: кожен findElement повторює пошук до заданого часу. Explicit wait опитує умову, і всередині цієї умови теж викликається findElement. Коли обидва активні, очікування вкладаються одне в одне.
What it is — An implicit wait is a session-wide setting: every findElement retries the lookup up to the given time. An explicit wait polls a condition, and that condition calls findElement too. With both active, the waits nest inside each other.
Навіщо це знати — Документація Selenium прямо попереджає: implicit 10 с і explicit 15 с можуть дати таймаут через 20 с. Реальна тривалість залежить від того, коли саме настав останній виклик пошуку, тому поведінка непередбачувана і відрізняється між браузерами та драйверами.
Why it matters — The Selenium documentation warns about exactly this: an implicit 10 s plus an explicit 15 s can time out after 20 s. The real duration depends on when the last lookup started, so the behaviour is unpredictable and varies between browsers and drivers.
Приклад — Очікування invisibilityOfElementLocated(spinner): спінер зник за пів секунди, але умова дізнається про це лише тоді, коли внутрішній findElement вичерпає 10 секунд implicit wait і кине NoSuchElementException. Тест проходить, але кожне таке очікування коштує 10 секунд.
Example — Waiting for invisibilityOfElementLocated(spinner): the spinner is gone after half a second, but the condition only learns that once the inner findElement exhausts the 10-second implicit wait and throws NoSuchElementException. The test passes, yet every such wait costs 10 seconds.
Підводні камені — Часто implicit wait виставляє хтось у базовому класі «для надійності», а інші автори про це не знають. Лікування: implicit дорівнює нулю в одному місці створення драйвера, усі очікування — явні через хелпер.
Pitfalls — Often someone sets the implicit wait in a base class "for reliability" and other authors never find out. The cure: implicit is zero in the single place where the driver is created, and every wait is explicit through a helper.
Уточнення на співбесіді — «А Selenide?» Він не використовує implicit wait: кожна перевірка на кшталт shouldBe(visible) — це власне явне очікування з Configuration.timeout.
Interview follow-up — "What about Selenide?" It does not rely on implicit waits: every check such as shouldBe(visible) is its own explicit wait governed by Configuration.timeout.
// Погано: обидва механізми активні
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
new WebDriverWait(driver, Duration.ofSeconds(15))
.until(ExpectedConditions.invisibilityOfElementLocated(By.id("spinner")));
// спінер зник за 0.5 с, а крок триває ~10 с
// Добре: implicit = 0 у фабриці драйвера, усе інше — явно
driver.manage().timeouts().implicitlyWait(Duration.ZERO);
waits.invisible(By.id("spinner"));
// Bad: both mechanisms are active
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
new WebDriverWait(driver, Duration.ofSeconds(15))
.until(ExpectedConditions.invisibilityOfElementLocated(By.id("spinner")));
// the spinner left after 0.5 s, yet the step takes ~10 s
// Good: implicit = 0 in the driver factory, everything else explicit
driver.manage().timeouts().implicitlyWait(Duration.ZERO);
waits.invisible(By.id("spinner"));
waits.waits helper.implicitlyWait у коді.implicitlyWait first.Що це — Actions будує низькорівневі жести через протокол WebDriver: наведення, подвійний клік, перетягування, натискання клавіш із модифікаторами. JavascriptExecutor.executeScript виконує довільний JS у сторінці й повертає результат у Java.
What it is — Actions builds low-level gestures through the WebDriver protocol: hover, double click, drag and drop, key presses with modifiers. JavascriptExecutor.executeScript runs arbitrary JS in the page and returns the result to Java.
Коли виправдано — Actions — це нормальний спосіб відтворити реальний жест: меню, що відкривається при наведенні, або drag-and-drop на канбан-дошці. JS доречний для службових речей: прокрутити елемент у центр, прочитати стан застосунку (кількість активних запитів), очистити localStorage перед тестом.
When it is justified — Actions is the proper way to reproduce a real gesture: a hover menu or drag and drop on a kanban board. JS fits utility jobs: scrolling an element to the centre, reading application state (the number of pending requests), clearing localStorage before a test.
Приклад запаху — Тест падав з ElementClickInterceptedException, і хтось замінив клік на arguments[0].click(). Тест позеленів, але в продакшені кнопку оплати перекривав банер cookies, і користувачі не могли на неї натиснути. JS-клік обійшов саме ту перевірку, заради якої тест існував.
Smell example — A test failed with ElementClickInterceptedException, and someone replaced the click with arguments[0].click(). The test went green, but in production the cookie banner covered the pay button and users could not press it. The JS click bypassed exactly the check the test existed for.
Підводні камені — JS не генерує справжні події миші й клавіатури, тому обробники на mousedown чи input можуть не спрацювати. Встановлення значення поля через JS залишає React-стан незмінним.
Pitfalls — JS does not generate real mouse and keyboard events, so handlers on mousedown or input may never fire. Setting a field value through JS leaves React state unchanged.
Уточнення на співбесіді — «Як ви обробляєте перехоплений клік?» Чекаю зникнення оверлею або закриваю банер кроком тесту, а JS-клік дозволяю лише з коментарем і посиланням на тікет.
Interview follow-up — "How do you handle an intercepted click?" I wait for the overlay to disappear or close the banner as a test step, and a JS click is allowed only with a comment and a ticket link.
Actions actions = new Actions(driver);
// Доречно: реальний жест користувача
actions.moveToElement(accountMenu).click(logoutItem).perform();
actions.dragAndDrop(taskCard, doneColumn).perform();
JavascriptExecutor js = (JavascriptExecutor) driver;
// Доречно: службова прокрутка і читання стану
js.executeScript("arguments[0].scrollIntoView({block: 'center'});", table);
Long pending = (Long) js.executeScript("return window.pendingRequests || 0;");
// Запах: клікне навіть кнопку під модальним вікном
js.executeScript("arguments[0].click();", payButton);
Actions actions = new Actions(driver);
// Fine: a real user gesture
actions.moveToElement(accountMenu).click(logoutItem).perform();
actions.dragAndDrop(taskCard, doneColumn).perform();
JavascriptExecutor js = (JavascriptExecutor) driver;
// Fine: utility scrolling and reading state
js.executeScript("arguments[0].scrollIntoView({block: 'center'});", table);
Long pending = (Long) js.executeScript("return window.pendingRequests || 0;");
// Smell: clicks the button even under a modal
js.executeScript("arguments[0].click();", payButton);
Actions виконується лише після perform(). Числа з executeScript приходять у Java як Long, а arguments[0] — це переданий WebElement. Змінна window.pendingRequests — лічильник, який застосунок виставляє сам.Actions chain runs only after perform(). Numbers from executeScript arrive in Java as Long, and arguments[0] is the passed WebElement. window.pendingRequests is a counter the application exposes itself.Actions.Actions.Що це — Selenium спілкується з браузером через окремий бінарник драйвера: chromedriver, geckodriver, msedgedriver. Його версія має відповідати версії браузера. Раніше шлях задавали через webdriver.chrome.driver, потім з'явилася бібліотека WebDriverManager (bonigarcia), а з Selenium 4.6 у сам Selenium вбудовано Selenium Manager.
What it is — Selenium talks to a browser through a separate driver binary: chromedriver, geckodriver, msedgedriver. Its version has to match the browser. The path used to be set via webdriver.chrome.driver, then the WebDriverManager library (bonigarcia) appeared, and since Selenium 4.6 Selenium Manager ships inside Selenium itself.
Навіщо — Selenium Manager спрацьовує автоматично, якщо драйвер не знайдено в PATH: визначає версію браузера, завантажує потрібний драйвер і кешує його в ~/.cache/selenium. Для нового проєкту окрема залежність зазвичай не потрібна; WebDriverManager лишається корисним у старих версіях Selenium або коли потрібні його додаткові можливості.
Why — Selenium Manager kicks in automatically when no driver is found on the PATH: it detects the browser version, downloads the matching driver and caches it in ~/.cache/selenium. A new project usually needs no extra dependency; WebDriverManager remains handy on older Selenium versions or when you want its extra features.
Приклад — Типова помилка — SessionNotCreatedException з повідомленням, що ця версія ChromeDriver підтримує лише певну версію Chrome. Це трапляється, коли в репозиторії закомічено старий драйвер або шлях жорстко прописано в конфігу.
Example — The classic error is SessionNotCreatedException saying this version of ChromeDriver only supports a specific Chrome version. It happens when an old driver is committed to the repo or its path is hard-coded in config.
Підводні камені — Автозавантаження в CI означає залежність від інтернету й неявні оновлення браузера. Стабільніше запускати тести в Docker-образі із зафіксованою версією браузера або на Selenium Grid, де драйвер уже в контейнері.
Pitfalls — Auto-download in CI means depending on the internet and on silent browser upgrades. It is more stable to run tests in a Docker image with a pinned browser version or on a Selenium Grid, where the driver already lives in the container.
Уточнення на співбесіді — «А з RemoteWebDriver?» Тоді локальний драйвер не потрібен зовсім: драйвер і браузер працюють у вузлі Grid, а тест лише надсилає команди на його URL.
Interview follow-up — "And with RemoteWebDriver?" Then no local driver is needed at all: the driver and browser run on the Grid node, and the test only sends commands to its URL.
// Selenium 4.6+: Selenium Manager знайде або завантажить chromedriver сам
WebDriver local = new ChromeDriver();
// Старіший підхід: бібліотека WebDriverManager
WebDriverManager.chromedriver().setup();
WebDriver legacy = new ChromeDriver();
// CI: драйвер і браузер уже в контейнері Grid
WebDriver remote = new RemoteWebDriver(
URI.create("http://selenium-hub:4444").toURL(), new ChromeOptions());
// Selenium 4.6+: Selenium Manager finds or downloads chromedriver itself
WebDriver local = new ChromeDriver();
// Older approach: the WebDriverManager library
WebDriverManager.chromedriver().setup();
WebDriver legacy = new ChromeDriver();
// CI: the driver and browser already live in the Grid container
WebDriver remote = new RemoteWebDriver(
URI.create("http://selenium-hub:4444").toURL(), new ChromeOptions());
setup() від WebDriverManager виставляє системну властивість зі шляхом, а RemoteWebDriver переносить усю відповідальність за бінарники на Grid.setup() sets the system property with the path, and RemoteWebDriver moves all responsibility for binaries to the Grid.Що це — Ізольований тест сам готує потрібний стан, не залежить від порядку запуску і не залишає слідів для інших. Його можна запустити окремо, у будь-якій послідовності чи паралельно — і результат той самий.
What it is — An isolated test prepares the state it needs, does not depend on run order and leaves no traces for others. You can run it alone, in any sequence or in parallel, and the result is the same.
Навіщо — Без ізоляції одне падіння тягне за собою каскад червоних тестів, і реальну причину шукати довго. Паралельний запуск неможливий, а повторний прогін одного тесту в IDE не відтворює проблему з CI.
Why — Without isolation one failure drags a cascade of red tests after it, and finding the real cause takes ages. Parallel runs become impossible, and re-running a single test in the IDE does not reproduce the CI problem.
Як ламають — Статичне поле, яке один тест заповнює, а інший читає. Спільний тестовий користувач, у якого хтось змінив пароль. Рядки в БД, що лишилися після попереднього прогону. @TestMethodOrder, за яким тест оформлення замовлення тихо спирається на тест додавання в кошик. Сесія браузера, перевикористана між тестами.
How it breaks — A static field one test fills and another reads. A shared test user whose password someone changed. Database rows left from the previous run. A @TestMethodOrder that lets a checkout test quietly rely on the add-to-cart test. A browser session reused across tests.
Як виявити — Увімкнути випадковий порядок методів через junit-platform.properties, запускати кожен тест поодинці й повторно, у Playwright — --repeat-each. Тест, що падає лише в певній компанії, майже завжди має спільний стан.
How to detect it — Turn on random method order in junit-platform.properties, run each test alone and repeatedly, and in Playwright use --repeat-each. A test that fails only in certain company almost always shares state.
Уточнення на співбесіді — «А якщо створення даних дороге?» Тоді спільні незмінні дані створюю один раз на клас, а все, що тест змінює, — окремо для кожного тесту.
Interview follow-up — "What if creating data is expensive?" Then shared read-only data is created once per class, and anything a test mutates is created per test.
// Погано: тести передають стан через static-поле
class CartOrderDependentTest {
private static String cartId;
@Test void addItem() { cartId = api.createCart(); api.add(cartId, "SKU-1"); }
@Test void checkout() { api.checkout(cartId); } // залежить від порядку
}
// Добре: кожен тест має власний кошик і прибирає за собою
class CartTest {
private String cartId;
@BeforeEach void newCart() { cartId = api.createCart(); }
@AfterEach void cleanup() { api.deleteCart(cartId); }
@Test void checkoutSucceedsWithOneItem() {
api.add(cartId, "SKU-1");
assertThat(api.checkout(cartId).status()).isEqualTo("PAID");
}
}
# junit-platform.properties — ловимо залежність від порядку
junit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random
// Bad: tests pass state through a static field
class CartOrderDependentTest {
private static String cartId;
@Test void addItem() { cartId = api.createCart(); api.add(cartId, "SKU-1"); }
@Test void checkout() { api.checkout(cartId); } // depends on order
}
// Good: every test has its own cart and cleans up after itself
class CartTest {
private String cartId;
@BeforeEach void newCart() { cartId = api.createCart(); }
@AfterEach void cleanup() { api.deleteCart(cartId); }
@Test void checkoutSucceedsWithOneItem() {
api.add(cartId, "SKU-1");
assertThat(api.checkout(cartId).status()).isEqualTo("PAID");
}
}
# junit-platform.properties — catch order dependence
junit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random
cartId вже ізольоване, а static ламає це. Властивість MethodOrderer$Random перемішує порядок методів і швидко показує приховані залежності.cartId field is already isolated, while static breaks that. The MethodOrderer$Random property shuffles method order and quickly exposes hidden dependencies.Ситуація — Вісім воркерів CI і ще два розробники ганяють тести на одному staging. Якщо всі логіняться як qa@example.com і купують той самий товар зі складу в 5 одиниць, падіння випадкові: хтось розлогінив сесію, хтось вичерпав залишок.
Situation — Eight CI workers plus two developers run tests against one staging. If everyone logs in as qa@example.com and buys the same product with 5 units in stock, failures are random: someone logged the session out, someone drained the stock.
Підходи — Унікальні дані на тест: суфікс з UUID чи часової мітки в email, назві, SKU. Акаунт на воркер: worker-фікстура в Playwright або пул облікових записів у Java, який видає користувача потоку. Створення через API в підготовці та видалення в прибиранні. Префікс простору імен (at-), щоб нічне завдання чистило «сирітські» записи.
Approaches — Unique data per test: a UUID or timestamp suffix in the email, name, SKU. An account per worker: a worker fixture in Playwright or an account pool in Java that hands a user to each thread. Creation through the API in setup and deletion in teardown. A namespace prefix (at-) so a nightly job can clean up orphaned records.
Компроміс — Заздалегідь засіяні дані швидкі, але крихкі й «старіють». Дані на льоту повільніші й вимагають API для створення, зате відтворювані. Я комбіную: довідники (країни, валюти) — засіяні й лише для читання, а все, що тест змінює, створюється на льоту.
Trade-off — Pre-seeded data is fast but brittle and goes stale. On-the-fly data is slower and needs creation APIs, yet it is reproducible. I combine them: reference data (countries, currencies) is seeded and read-only, while anything a test mutates is created on the fly.
Підводні камені — Прибирання в afterEach не спрацює, якщо процес убили, тому потрібне і фонове очищення. Унікальні імена не рятують від глобальних лімітів — квоти запитів чи спільного складу.
Pitfalls — Teardown in afterEach does not run if the process is killed, so background cleanup is needed too. Unique names do not save you from global limits such as rate quotas or shared stock.
Уточнення на співбесіді — «А якщо API для створення немає?» Прошу розробників тестовий ендпоінт чи сид-скрипт, а до того — окремий пул засіяних записів на кожного воркера.
Interview follow-up — "What if there is no creation API?" I ask developers for a test endpoint or a seed script, and until then use a separate pool of seeded records per worker.
import { test as base } from '@playwright/test';
type Account = { email: string; password: string };
export const test = base.extend<{}, { account: Account }>({
account: [async ({}, use, workerInfo) => {
// Свій користувач на кожен воркер — жодних гонок за спільний акаунт
const email = 'at-w' + workerInfo.workerIndex + '-' + Date.now() + '@example.com';
const account = await createUserViaApi(email);
await use(account);
await deleteUserViaApi(account.email); // прибирання після воркера
}, { scope: 'worker' }],
});
test('купівля товару з власним залишком', async ({ page, account }) => {
const sku = 'at-' + crypto.randomUUID().slice(0, 8);
await createProductViaApi({ sku, stock: 1 });
// ... логін як account і купівля sku
});
import { test as base } from '@playwright/test';
type Account = { email: string; password: string };
export const test = base.extend<{}, { account: Account }>({
account: [async ({}, use, workerInfo) => {
// One user per worker — no races over a shared account
const email = 'at-w' + workerInfo.workerIndex + '-' + Date.now() + '@example.com';
const account = await createUserViaApi(email);
await use(account);
await deleteUserViaApi(account.email); // cleanup after the worker
}, { scope: 'worker' }],
});
test('buys a product with its own stock', async ({ page, account }) => {
const sku = 'at-' + crypto.randomUUID().slice(0, 8);
await createProductViaApi({ sku, stock: 1 });
// ... log in as account and buy sku
});
extend описує worker-фікстури, а { scope: 'worker' } створює акаунт один раз на процес-воркер. Префікс at- в email і SKU дозволяє фоновому завданню знайти й видалити все, що лишилося після аварійних прогонів.extend describes worker fixtures, and { scope: 'worker' } creates the account once per worker process. The at- prefix in the email and SKU lets a background job find and delete whatever crashed runs left behind.Що це — DRY (Don't Repeat Yourself) вимагає не дублювати знання. DAMP (Descriptive And Meaningful Phrases) каже, що в тесті читабельність важливіша: тіло тесту має читатися як специфікація, навіть якщо кілька рядків повторюються в сусідніх тестах.
What it is — DRY (Don't Repeat Yourself) says do not duplicate knowledge. DAMP (Descriptive And Meaningful Phrases) says that in a test readability matters more: the body should read like a specification, even if a few lines repeat in neighbouring tests.
Де межа — DRY доречний в інфраструктурі: фабрики драйверів, API-клієнти, білдери даних, хелпери очікувань. У самому тесті мають бути видимі три речі: вхідні дані, які важливі для сценарію, дія та перевірка. Якщо, щоб зрозуміти падіння, треба відкрити три допоміжні методи, абстракція зайва.
Where the line is — DRY belongs in the infrastructure: driver factories, API clients, data builders, wait helpers. Inside the test three things must stay visible: the inputs that matter for the scenario, the action and the assertion. If understanding a failure means opening three helper methods, the abstraction has gone too far.
Приклад — Метод runScenario("gold", 3, true) прибрав дублювання з десяти тестів знижок, але тепер незрозуміло, що означає true і де перевірка. Після рефакторингу кожен тест будує клієнта й замовлення через білдер, викликає розрахунок і перевіряє суму прямо у своєму тілі.
Example — A runScenario("gold", 3, true) method removed duplication from ten discount tests, but now nobody knows what true means or where the assertion is. After the refactor each test builds the customer and order with a builder, calls the calculation and checks the total right in its own body.
Підводні камені — Перевірки, заховані в хелпері, дають незрозумілі повідомлення про падіння. Умовна логіка (if, цикли) у тесті — теж ознака того, що один метод намагається бути кількома тестами; тут краще параметризація.
Pitfalls — Assertions hidden in a helper produce confusing failure messages. Conditional logic (if, loops) inside a test is another sign that one method is trying to be several tests; parameterisation fits better.
Уточнення на співбесіді — «То копіпаст у тестах — це нормально?» Повтор кроків підготовки — так, якщо він говорить про сценарій; повтор технічних деталей, як-от побудови HTTP-запиту, — ні, його виношу в клієнт.
Interview follow-up — "So copy-paste in tests is fine?" Repeating setup steps is, when they describe the scenario; repeating technical details, such as building an HTTP request, is not, and I move that into a client.
// Надто DRY: що перевіряється і чому — не видно
@Test void discount3() { runScenario("gold", 3, true); }
// DAMP: тіло читається як специфікація
@Test
void goldCustomerGetsTenPercentOffForThreeItems() {
Customer gold = aCustomer().withTier(Tier.GOLD).build();
Order order = anOrder().forCustomer(gold).withItems(3).pricedAtCents(10_000).build();
Money total = checkout.total(order);
assertThat(total).isEqualTo(Money.ofCents(9_000)); // 10% знижки
}
// Too DRY: what is checked and why is invisible
@Test void discount3() { runScenario("gold", 3, true); }
// DAMP: the body reads like a specification
@Test
void goldCustomerGetsTenPercentOffForThreeItems() {
Customer gold = aCustomer().withTier(Tier.GOLD).build();
Order order = anOrder().forCustomer(gold).withItems(3).pricedAtCents(10_000).build();
Money total = checkout.total(order);
assertThat(total).isEqualTo(Money.ofCents(9_000)); // 10% off
}
aCustomer() і anOrder() — це DRY в інфраструктурі, але вони показують лише важливі для сценарію поля. Назва методу, дія і перевірка лишаються в тілі тесту, тому звіт про падіння зрозумілий без відкриття хелперів.aCustomer() and anOrder() builders are DRY in the infrastructure, yet they expose only the fields that matter to the scenario. The method name, the action and the assertion stay in the test body, so a failure report makes sense without opening helpers.Що це — FIRST — абревіатура з книжок про чистий код: Fast (тест виконується за мілісекунди, тому його запускають постійно), Independent (не залежить від інших тестів і порядку), Repeatable (дає той самий результат на будь-якій машині й у будь-який час), Self-validating (сам каже pass або fail через assert, без ручного перегляду логів) і Timely (пишеться разом із кодом, а не через місяць після релізу).
What it is — FIRST is an acronym from the clean-code literature: Fast (a test runs in milliseconds, so people run it all the time), Independent (it does not rely on other tests or their order), Repeatable (same result on any machine at any time), Self-validating (it reports pass or fail through an assert, with no manual log reading) and Timely (written together with the code, not a month after the release).
Навіщо — принципи сформульовані для unit-тестів, але їх зручно використовувати як чекліст для будь-якого автотесту. Порушення кожної літери має свою ціну: повільні тести перестають запускати локально, залежні тести падають каскадом, неповторювані стають flaky.
Why — the principles were written for unit tests, yet they make a handy checklist for any automated test. Every broken letter has a cost: slow tests stop being run locally, dependent tests fail in cascades, non-repeatable ones turn flaky.
Приклад — UI і E2E-тести майже завжди порушують F (секунди або хвилини замість мілісекунд) і часто R (залежать від мережі, часу, стану стенду). Компенсуємо так: піднімаємо стан через API замість кліків, фіксуємо годинник і дані, запускаємо паралельно.
Example — UI and E2E tests almost always violate F (seconds or minutes instead of milliseconds) and often R (they depend on the network, the clock and the state of the environment). We compensate by preparing state through the API instead of clicks, pinning the clock and the data, and running in parallel.
Підводні камені — «Self-validating» ламається, коли тест лише друкує значення в консоль. «Timely» — коли автоматизація відстає від розробки на спринт і вже не ловить регресію вчасно.
Pitfalls — Self-validating breaks when a test only prints values to the console. Timely breaks when automation lags a sprint behind development and no longer catches the regression in time.
Уточнення на співбесіді — «Як перевірити Independent?» Запустити набір у випадковому порядку (MethodOrderer.Random) і кожен тест окремо.
Interview follow-up — "How do you check Independent?" Run the suite in random order (MethodOrderer.Random) and each test on its own.
// Погано: залежить від поточної дати і нічого не перевіряє
@Test
void discountBad() {
var price = new PriceService().priceFor("SKU-1"); // усередині LocalDate.now()
System.out.println(price); // людина має подивитись у лог
}
// Добре: фіксований годинник (Repeatable) і assert (Self-validating)
@Test
void blackFridayGivesTwentyPercent() {
Clock clock = Clock.fixed(Instant.parse("2025-11-28T10:00:00Z"), ZoneOffset.UTC);
var service = new PriceService(clock);
assertThat(service.priceFor("SKU-1")).isEqualByComparingTo("80.00");
}
// Bad: depends on today's date and checks nothing
@Test
void discountBad() {
var price = new PriceService().priceFor("SKU-1"); // uses LocalDate.now() inside
System.out.println(price); // a human has to read the log
}
// Good: fixed clock (Repeatable) and an assert (Self-validating)
@Test
void blackFridayGivesTwentyPercent() {
Clock clock = Clock.fixed(Instant.parse("2025-11-28T10:00:00Z"), ZoneOffset.UTC);
var service = new PriceService(clock);
assertThat(service.priceFor("SKU-1")).isEqualByComparingTo("80.00");
}
Clock.fixed прибирає залежність від реального часу, тож тест дає однаковий результат і в понеділок, і в Чорну п'ятницю. assertThat замінює друк у консоль на автоматичний вердикт.Clock.fixed removes the dependency on real time, so the test gives the same result on a Monday and on Black Friday. assertThat replaces console printing with an automatic verdict.Що це — «Test double» (термін Джерарда Мессароша) — будь-який об'єкт, що підміняє реальну залежність у тесті. Dummy лише заповнює параметр і ніколи не викликається. Stub повертає заздалегідь задані відповіді. Spy — stub, який ще й запам'ятовує, як його викликали. Mock наперед знає очікувані виклики і сам провалює тест, якщо їх не було. Fake — робоча, але спрощена реалізація: in-memory репозиторій, локальний SMTP.
What it is — A test double (Gerard Meszaros's term) is any object that replaces a real dependency in a test. A dummy only fills a parameter and is never called. A stub returns canned answers. A spy is a stub that also records how it was called. A mock knows the expected calls up front and fails the test itself if they did not happen. A fake is a working but simplified implementation: an in-memory repository, a local SMTP server.
Навіщо — дублери роблять тест швидким і детермінованим, дозволяють відтворити рідкісні відповіді (помилку платіжки) і перевірити побічні ефекти (лист надіслано).
Why — doubles make a test fast and deterministic, let you reproduce rare responses (a payment gateway error) and check side effects (an email was sent).
Приклад — сервіс реєстрації: логер у конструкторі — dummy; TaxClient з фіксованою ставкою — stub; поштовий сервіс, у якого перевіряємо один виклик send, — mock або spy; InMemoryUserRepository на HashMap — fake.
Example — a sign-up service: the logger in the constructor is a dummy; a TaxClient with a fixed rate is a stub; the mail service where we check one send call is a mock or a spy; an InMemoryUserRepository built on a HashMap is a fake.
Підводні камені — у Mockito усе називається mock(), тому люди плутають терміни; важлива не назва, а те, що саме перевіряє тест — стан чи взаємодію. Fake треба підтримувати, інакше він розходиться з реальною реалізацією.
Pitfalls — in Mockito everything is created with mock(), so people mix up the terms; what matters is not the name but what the test verifies, state or interaction. A fake needs maintenance, otherwise it drifts away from the real implementation.
Уточнення на співбесіді — «Що краще для репозиторію: mock чи fake?» Fake, якщо його використовують десятки тестів: він дає реальну поведінку збереження без налаштування кожного виклику.
Interview follow-up — "Which is better for a repository, a mock or a fake?" A fake if dozens of tests use it: it gives real save-and-load behaviour without stubbing every call.
@Test
void registersUserAndSendsWelcomeMail() {
AuditLogger dummy = mock(AuditLogger.class); // dummy: не використовується
TaxClient stub = country -> new BigDecimal("0.20"); // stub: фіксована відповідь
MailSender mail = mock(MailSender.class); // mock: перевіримо виклик
var repo = new InMemoryUserRepository(); // fake: HashMap усередині
var clock = spy(Clock.systemUTC()); // spy: реальний об'єкт + запис викликів
var service = new SignUpService(repo, stub, mail, dummy, clock);
service.register("ann@test.io", "UA");
assertThat(repo.findByEmail("ann@test.io")).isPresent(); // перевірка стану
verify(mail).send(eq("ann@test.io"), contains("Welcome")); // перевірка взаємодії
}
@Test
void registersUserAndSendsWelcomeMail() {
AuditLogger dummy = mock(AuditLogger.class); // dummy: never used
TaxClient stub = country -> new BigDecimal("0.20"); // stub: canned answer
MailSender mail = mock(MailSender.class); // mock: we verify the call
var repo = new InMemoryUserRepository(); // fake: a HashMap inside
var clock = spy(Clock.systemUTC()); // spy: real object + call recording
var service = new SignUpService(repo, stub, mail, dummy, clock);
service.register("ann@test.io", "UA");
assertThat(repo.findByEmail("ann@test.io")).isPresent(); // state check
verify(mail).send(eq("ann@test.io"), contains("Welcome")); // interaction check
}
spy() обгортає справжній об'єкт, а verify з матчерами eq і contains перевіряє аргументи виклику.spy() wraps a real object, and verify with the eq and contains matchers checks the call arguments.verify — у тебе stub, навіть коли він створений через mock().verify in the test, you have a stub, even when it was created with mock().Що це — Mockito — найпоширеніша Java-бібліотека для дублерів. when(mock.call(args)).thenReturn(value) задає відповідь (стабінг), thenThrow — виняток. verify(mock).call(args) перевіряє, що виклик відбувся, з модифікаторами times(2), never(). ArgumentCaptor ловить переданий об'єкт, щоб перевірити його поля через AssertJ.
What it is — Mockito is the most widely used Java library for test doubles. when(mock.call(args)).thenReturn(value) defines an answer (stubbing), thenThrow defines an exception. verify(mock).call(args) checks the call happened, with modifiers like times(2) and never(). ArgumentCaptor catches the passed object so you can check its fields with AssertJ.
Навіщо — перевірка стану (що повернув метод, що лежить у репозиторії) стійкіша до рефакторингу. Перевірка взаємодії потрібна там, де результат — побічний ефект: подія в Kafka, лист, запит у платіжний шлюз.
Why — state verification (what the method returned, what is in the repository) survives refactoring better. Interaction verification is needed where the outcome is a side effect: an event in Kafka, an email, a call to a payment gateway.
Приклад — сервіс замовлень публікує OrderCreated. Ми стабимо склад, щоб товар був у наявності, викликаємо place(), а потім через captor дістаємо подію і перевіряємо суму та статус.
Example — an order service publishes OrderCreated. We stub the warehouse so the item is in stock, call place(), then use a captor to pull out the event and check its amount and status.
Підводні камені — надмірний verify на кожен внутрішній виклик цементує реалізацію: тест падає після безпечного рефакторингу. Не мокай типи, якими не володієш (HTTP-клієнт, JDBC) — обгорни їх власним інтерфейсом. MockitoExtension у strict-режимі падає на зайвих стабах (UnnecessaryStubbingException) — це корисний сигнал, а не привід вимикати перевірку.
Pitfalls — excessive verify on every internal call cements the implementation: the test fails after a safe refactoring. Do not mock types you do not own (an HTTP client, JDBC) — wrap them in your own interface. MockitoExtension in strict mode fails on unused stubs (UnnecessaryStubbingException), which is a useful signal rather than a reason to switch the check off.
Уточнення на співбесіді — «Чим doReturn().when() відрізняється від when().thenReturn()?» Перший не викликає реальний метод, тож потрібен для spy і void-методів.
Interview follow-up — "How does doReturn().when() differ from when().thenReturn()?" The first does not call the real method, so it is needed for spies and void methods.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock Warehouse warehouse;
@Mock EventPublisher events;
@InjectMocks OrderService service;
@Captor ArgumentCaptor<OrderCreated> eventCaptor;
@Test
void publishesEventWithTotal() {
when(warehouse.inStock("SKU-7", 2)).thenReturn(true); // стабінг
service.place("SKU-7", 2);
verify(events, times(1)).publish(eventCaptor.capture()); // взаємодія
assertThat(eventCaptor.getValue())
.extracting(OrderCreated::total, OrderCreated::status)
.containsExactly(new BigDecimal("39.98"), "NEW"); // поля події
verifyNoMoreInteractions(events);
}
}
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock Warehouse warehouse;
@Mock EventPublisher events;
@InjectMocks OrderService service;
@Captor ArgumentCaptor<OrderCreated> eventCaptor;
@Test
void publishesEventWithTotal() {
when(warehouse.inStock("SKU-7", 2)).thenReturn(true); // stubbing
service.place("SKU-7", 2);
verify(events, times(1)).publish(eventCaptor.capture()); // interaction
assertThat(eventCaptor.getValue())
.extracting(OrderCreated::total, OrderCreated::status)
.containsExactly(new BigDecimal("39.98"), "NEW"); // event fields
verifyNoMoreInteractions(events);
}
}
@InjectMocks створює сервіс і підставляє в нього моки через конструктор. @Captor оголошує типізований captor без unchecked-попереджень, а verifyNoMoreInteractions гарантує, що зайвих подій не було.@InjectMocks builds the service and injects the mocks through the constructor. @Captor declares a typed captor without unchecked warnings, and verifyNoMoreInteractions makes sure no extra events were published.verify не потрібен.verify.Що це — WireMock — HTTP-сервер-дублер: ти описуєш правила «запит → відповідь», а застосунок ходить на нього замість справжнього сервісу. Працює як JUnit5-розширення (@WireMockTest), як бібліотека в коді або як standalone-контейнер wiremock/wiremock поруч із застосунком у docker compose.
What it is — WireMock is a stand-in HTTP server: you describe "request → response" rules and the application calls it instead of the real service. It runs as a JUnit5 extension (@WireMockTest), as a library in code, or as the standalone wiremock/wiremock container next to the application in docker compose.
Коли — коли сторонній сервіс платний, нестабільний, не має тестового стенду або коли треба відтворити збої, яких справжній сервіс на замовлення не видасть: withFixedDelay(5000) для таймауту, статус 503 для ретраїв, Fault.CONNECTION_RESET_BY_PEER для обриву. Сценарії (inScenario) дають «спершу 503, потім 200», щоб перевірити логіку повторів.
When — when the third-party service is paid, unstable or has no test environment, or when you need failures the real service will not produce on demand: withFixedDelay(5000) for a timeout, status 503 for retries, Fault.CONNECTION_RESET_BY_PEER for a dropped connection. Scenarios (inScenario) give "first 503, then 200" to test retry logic.
Приклад — сервіс доставки питає тарифи в перевізника. Ми перевіряємо, що при таймауті понад 2 секунди сервіс віддає кешований тариф, а при 503 робить рівно три спроби — це видно через verify(3, getRequestedFor(...)).
Example — a delivery service asks a carrier for rates. We check that on a timeout over 2 seconds the service returns a cached rate, and that on 503 it makes exactly three attempts, visible through verify(3, getRequestedFor(...)).
Підводні камені — стаби застарівають: провайдер змінив поле, а тести зелені. Лікується контрактними тестами або періодичною звіркою стабів із записом реального трафіку. Не стаб власні сервіси команди без потреби — там краще контракт.
Pitfalls — stubs go stale: the provider renamed a field and the tests stay green. The cure is contract tests or periodically re-checking stubs against recorded real traffic. Do not stub your own team's services without need; a contract fits better there.
Уточнення на співбесіді — «Як направити застосунок на WireMock?» Базовий URL клієнта береться з конфігу, і в тесті підставляємо wmInfo.getHttpBaseUrl().
Interview follow-up — "How do you point the application at WireMock?" The client base URL comes from config, and in the test we pass wmInfo.getHttpBaseUrl().
@WireMockTest
class CarrierClientTest {
@Test
void fallsBackToCacheOnTimeout(WireMockRuntimeInfo wm) {
stubFor(get(urlPathEqualTo("/rates"))
.willReturn(okJson("{\"price\": 120}").withFixedDelay(5000))); // довше за таймаут 2 с
var client = new CarrierClient(wm.getHttpBaseUrl(), Duration.ofSeconds(2));
assertThat(client.rate("KYIV").source()).isEqualTo("CACHE");
}
@Test
void retriesThreeTimesOnConnectionReset(WireMockRuntimeInfo wm) {
stubFor(get(urlPathEqualTo("/rates"))
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); // обрив
var client = new CarrierClient(wm.getHttpBaseUrl(), Duration.ofSeconds(2));
assertThatThrownBy(() -> client.rate("KYIV")).isInstanceOf(CarrierUnavailable.class);
verify(3, getRequestedFor(urlPathEqualTo("/rates"))); // рівно три спроби
}
}
@WireMockTest
class CarrierClientTest {
@Test
void fallsBackToCacheOnTimeout(WireMockRuntimeInfo wm) {
stubFor(get(urlPathEqualTo("/rates"))
.willReturn(okJson("{\"price\": 120}").withFixedDelay(5000))); // longer than the 2 s timeout
var client = new CarrierClient(wm.getHttpBaseUrl(), Duration.ofSeconds(2));
assertThat(client.rate("KYIV").source()).isEqualTo("CACHE");
}
@Test
void retriesThreeTimesOnConnectionReset(WireMockRuntimeInfo wm) {
stubFor(get(urlPathEqualTo("/rates"))
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); // dropped connection
var client = new CarrierClient(wm.getHttpBaseUrl(), Duration.ofSeconds(2));
assertThatThrownBy(() -> client.rate("KYIV")).isInstanceOf(CarrierUnavailable.class);
verify(3, getRequestedFor(urlPathEqualTo("/rates"))); // exactly three attempts
}
}
@WireMockTest піднімає сервер на випадковому порту і передає WireMockRuntimeInfo у тест. withFixedDelay і withFault моделюють збої, а verify(3, ...) рахує, скільки запитів реально дійшло до сервера.@WireMockTest starts the server on a random port and passes WireMockRuntimeInfo into the test. withFixedDelay and withFault model failures, and verify(3, ...) counts how many requests actually reached the server.Що це — Testcontainers — бібліотека (Java, а також Node, Go, .NET), яка з тесту запускає Docker-контейнери з реальними залежностями: PostgreSQL, Kafka, Redis, LocalStack, навіть браузер. Контейнер стартує перед тестами на випадковому порту і знищується після них.
What it is — Testcontainers is a library (Java, plus Node, Go, .NET) that starts Docker containers with real dependencies from inside a test: PostgreSQL, Kafka, Redis, LocalStack, even a browser. A container starts before the tests on a random port and is destroyed after them.
Коли — коли поведінка залежить від конкретного рушія: JSONB, специфічні індекси, міграції Flyway, транзакційна ізоляція, партиції Kafka. H2 «в режимі PostgreSQL» пропускає такі баги, а мок репозиторію взагалі не перевіряє SQL. Інтеграційні тести на Testcontainers ловлять помилки міграцій і запитів ще до стенду.
When — when behaviour depends on a specific engine: JSONB, specific indexes, Flyway migrations, transaction isolation, Kafka partitions. H2 "in PostgreSQL mode" lets such bugs through, and a repository mock does not check SQL at all. Integration tests on Testcontainers catch migration and query errors before any shared environment.
Ціна — потрібен Docker на CI-агенті (або Testcontainers Cloud), старт PostgreSQL займає кілька секунд, Kafka — довше. Тому використовують патерн singleton-контейнера: один контейнер на весь JVM-запуск, стан чистять між тестами (truncate або транзакція з відкатом). Образи фіксують за тегом (postgres:16-alpine), а не latest.
Cost — the CI agent needs Docker (or Testcontainers Cloud), PostgreSQL takes a few seconds to start and Kafka longer. That is why teams use the singleton container pattern: one container per JVM run, with state cleaned between tests (truncate or a rolled-back transaction). Images are pinned by tag (postgres:16-alpine), not latest.
Підводні камені — @Container на нестатичному полі перезапускає контейнер для кожного тесту — дуже повільно. Без очищення даних тести стають залежними.
Pitfalls — @Container on a non-static field restarts the container for every test, which is very slow. Without data cleanup the tests become dependent on each other.
Уточнення на співбесіді — «А unit-тести тоді не потрібні?» Потрібні: бізнес-логіку дешевше перевіряти без бази, контейнер — для шару доступу до даних.
Interview follow-up — "So unit tests are no longer needed?" They are: business logic is cheaper to test without a database; the container is for the data access layer.
@SpringBootTest
@Testcontainers
class OrderRepositoryIT {
// static: один контейнер на клас, а не на кожен тест
@Container
static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", db::getJdbcUrl); // випадковий порт
r.add("spring.datasource.username", db::getUsername);
r.add("spring.datasource.password", db::getPassword);
}
@Autowired OrderRepository repo;
@Test
void findsOrdersByJsonbTag() {
repo.save(Order.withTags("{\"vip\": true}")); // JSONB, якого немає в H2
assertThat(repo.findVip()).hasSize(1);
}
}
@SpringBootTest
@Testcontainers
class OrderRepositoryIT {
// static: one container per class, not per test
@Container
static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", db::getJdbcUrl); // random port
r.add("spring.datasource.username", db::getUsername);
r.add("spring.datasource.password", db::getPassword);
}
@Autowired OrderRepository repo;
@Test
void findsOrdersByJsonbTag() {
repo.save(Order.withTags("{\"vip\": true}")); // JSONB, which H2 lacks
assertThat(repo.findVip()).hasSize(1);
}
}
@Testcontainers керує життєвим циклом полів з @Container. @DynamicPropertySource підставляє JDBC URL контейнера в Spring вже після старту, тому порт може бути будь-яким.@Testcontainers manages the lifecycle of @Container fields. @DynamicPropertySource feeds the container's JDBC URL into Spring after startup, so the port can be anything.Що це — GET читає ресурс, POST створює новий, PUT повністю замінює, PATCH змінює частину полів, DELETE видаляє. Для кожного методу є очікуваний «щасливий» статус: 200 для читання, 201 Created із заголовком Location для створення, 204 No Content для видалення без тіла.
What it is — GET reads a resource, POST creates a new one, PUT replaces it entirely, PATCH changes some fields, DELETE removes it. Each method has an expected happy status: 200 for reads, 201 Created with a Location header for creation, 204 No Content for a delete with no body.
Навіщо — негативні коди — окрема група перевірок: 400 — синтаксично поганий запит, 422 — валідний JSON, але порушено бізнес-правило (так роблять не всі API, тому дивимось контракт), 401 — немає або прострочений токен, 403 — токен є, але прав бракує, 404 — ресурсу немає, 409 — конфлікт (дублікат email), 5xx — помилка сервера, яка в тесті майже завжди баг.
Why — negative codes are a separate group of checks: 400 is a malformed request, 422 is valid JSON that breaks a business rule (not every API does this, so check the contract), 401 means a missing or expired token, 403 means a token without enough rights, 404 means no such resource, 409 is a conflict (a duplicate email), and 5xx is a server error that is almost always a bug in a test.
Приклад — після POST /users перевіряємо 201, Location, тіло (id, email), Content-Type, а потім GET за цим id, щоб переконатися, що ресурс справді збережено.
Example — after POST /users we check 201, Location, the body (id, email), Content-Type, and then a GET by that id to be sure the resource was really saved.
Підводні камені — перевірка лише статусу: API може повернути 200 з {"error": "..."} у тілі. Також плутають 401 і 403 — це різні баги безпеки.
Pitfalls — checking the status alone: an API can return 200 with {"error": "..."} in the body. People also confuse 401 and 403, which are different security bugs.
Уточнення на співбесіді — «Що перевірити у відповіді з помилкою?» Структуру помилки (код, повідомлення, поле) і те, що в ній немає stack trace.
Interview follow-up — "What do you check in an error response?" The error structure (code, message, field) and that it contains no stack trace.
@Test
void createdUserCanBeReadBack() {
String location = given().spec(adminSpec)
.body(Map.of("email", "kate@test.io"))
.when().post("/users")
.then()
.statusCode(201) // створено
.header("Location", matchesPattern(".*/users/\\d+"))
.body("email", equalTo("kate@test.io"))
.extract().header("Location");
given().spec(adminSpec).get(location).then().statusCode(200); // справді збережено
}
@Test
void deleteNeedsRights() {
given().delete("/users/1").then().statusCode(401); // без токена
given().spec(viewerSpec).delete("/users/1").then().statusCode(403); // токен без прав
}
@Test
void createdUserCanBeReadBack() {
String location = given().spec(adminSpec)
.body(Map.of("email", "kate@test.io"))
.when().post("/users")
.then()
.statusCode(201) // created
.header("Location", matchesPattern(".*/users/\\d+"))
.body("email", equalTo("kate@test.io"))
.extract().header("Location");
given().spec(adminSpec).get(location).then().statusCode(200); // really saved
}
@Test
void deleteNeedsRights() {
given().delete("/users/1").then().statusCode(401); // no token
given().spec(viewerSpec).delete("/users/1").then().statusCode(403); // token without rights
}
extract().header() витягає Location, щоб наступним запитом перевірити створений ресурс. adminSpec і viewerSpec — заздалегідь налаштовані специфікації з різними токенами.extract().header() pulls out Location so the next request can check the created resource. adminSpec and viewerSpec are pre-built specifications carrying different tokens.Що це — метод ідемпотентний, якщо повторний однаковий запит не змінює стан сервера понад те, що зробив перший. За RFC 9110 ідемпотентні GET, HEAD, PUT, DELETE, OPTIONS; POST і PATCH — ні. Важливо: ідемпотентність стосується стану, а не відповіді — другий DELETE може повернути 404, і це нормально.
What it is — a method is idempotent if repeating the same request does not change server state beyond what the first one did. Per RFC 9110, GET, HEAD, PUT, DELETE and OPTIONS are idempotent; POST and PATCH are not. Crucially, idempotency is about state, not the response: a second DELETE may return 404, and that is fine.
Навіщо — клієнти, балансувальники і мобільні застосунки повторюють запити після таймауту. Якщо платіжний POST повториться, людина заплатить двічі. Тому для критичних операцій API приймає заголовок Idempotency-Key: сервер запам'ятовує ключ і на повтор повертає той самий результат без нового списання.
Why — clients, load balancers and mobile apps retry requests after a timeout. If a payment POST is repeated, the customer pays twice. That is why APIs accept an Idempotency-Key header for critical operations: the server remembers the key and returns the same result for a repeat without charging again.
Приклад — тест 1: двічі шлемо однаковий PUT /profile і перевіряємо, що стан однаковий і версія не зросла двічі. Тест 2: два POST /payments з тим самим ключем повертають однаковий id, а в списку платежів лише один запис. Тест 3: той самий ключ з іншим тілом має дати помилку (часто 409 або 422).
Example — test 1: send the same PUT /profile twice and check the state is identical and the version did not grow twice. Test 2: two POST /payments with the same key return the same id, and the payment list holds one record. Test 3: the same key with a different body should give an error (often 409 or 422).
Підводні камені — паралельні повтори: послідовний тест проходить, а два одночасні запити створюють дубль. Перевір і конкурентний варіант.
Pitfalls — parallel repeats: the sequential test passes while two simultaneous requests create a duplicate. Check the concurrent variant too.
Уточнення на співбесіді — «Ідемпотентний і безпечний — це одне?» Ні: безпечний метод (GET) взагалі не змінює стан, а DELETE змінює, але повтор нічого не додає.
Interview follow-up — "Are idempotent and safe the same?" No: a safe method (GET) changes no state at all, while DELETE does change it, but a repeat adds nothing.
@Test
void samePaymentKeyChargesOnce() {
String key = UUID.randomUUID().toString();
var body = Map.of("orderId", orderId, "amount", 250);
String first = pay(key, body).statusCode(201).extract().path("id");
String second = pay(key, body).statusCode(anyOf(is(200), is(201))).extract().path("id");
assertThat(second).isEqualTo(first); // той самий платіж
assertThat(payments.listFor(orderId)).hasSize(1); // без дубля в базі
}
private ValidatableResponse pay(String key, Object body) {
return given().spec(api).header("Idempotency-Key", key).body(body)
.post("/payments").then();
}
@Test
void samePaymentKeyChargesOnce() {
String key = UUID.randomUUID().toString();
var body = Map.of("orderId", orderId, "amount", 250);
String first = pay(key, body).statusCode(201).extract().path("id");
String second = pay(key, body).statusCode(anyOf(is(200), is(201))).extract().path("id");
assertThat(second).isEqualTo(first); // the same payment
assertThat(payments.listFor(orderId)).hasSize(1); // no duplicate stored
}
private ValidatableResponse pay(String key, Object body) {
return given().spec(api).header("Idempotency-Key", key).body(body)
.post("/payments").then();
}
UUID гарантує, що ключ не перетнеться з іншими тестами. Другий виклик дозволяє 200 або 201, бо API по-різному сигналізують повтор, але id і кількість записів мають збігтися.UUID ensures the key never collides with other tests. The second call allows 200 or 201, since APIs signal a replay differently, but the id and the record count must match.Що це — шар клієнтів — це набір класів на кшталт UsersClient, OrdersClient, де кожен метод відповідає одному ендпоінту: create(user), get(id). Базові налаштування (URL, токен, content type, логування, Allure-фільтр) живуть в одній RequestSpecification, а типові перевірки — у ResponseSpecification. У Playwright аналог — APIRequestContext з baseURL і extraHTTPHeaders.
What it is — a client layer is a set of classes like UsersClient and OrdersClient, where each method maps to one endpoint: create(user), get(id). Base settings (URL, token, content type, logging, the Allure filter) live in one RequestSpecification, and common checks in a ResponseSpecification. In Playwright the equivalent is an APIRequestContext with baseURL and extraHTTPHeaders.
Навіщо — коли ендпоінт змінюється з /users на /v2/users, правка одна, а не в сотні тестів. Тести читаються як бізнес-сценарій, а ті самі клієнти використовуються для підготовки даних в UI-тестах.
Why — when an endpoint moves from /users to /v2/users, you make one edit instead of touching a hundred tests. Tests read as a business scenario, and the same clients prepare data for UI tests.
Приклад — клієнт має два рівні методів: «сирий» повертає Response для негативних тестів, а «зручний» одразу перевіряє 201 і десеріалізує тіло в record — для підготовки даних.
Example — the client has two kinds of methods: a raw one returns a Response for negative tests, and a convenient one checks 201 and deserializes the body into a record straight away, for data setup.
Підводні камені — асерти бізнес-логіки всередині клієнта: тоді негативний тест неможливо написати. Статичний спільний стан у клієнті ламає паралельний запуск.
Pitfalls — business assertions inside the client, which make negative tests impossible to write. Static shared state inside a client breaks parallel runs.
Уточнення на співбесіді — «Чи генерувати клієнти з OpenAPI?» Можна, це прибирає ручну роботу, але згенерований код часто обгортають тонким шаром для читабельності тестів.
Interview follow-up — "Should clients be generated from OpenAPI?" They can be, it removes manual work, but generated code is often wrapped in a thin layer to keep tests readable.
public class UsersClient {
private final RequestSpecification spec;
public UsersClient(Config cfg, String token) {
this.spec = new RequestSpecBuilder()
.setBaseUri(cfg.apiUrl()) // URL лише з конфігу
.setContentType(ContentType.JSON)
.addHeader("Authorization", "Bearer " + token)
.addFilter(new AllureRestAssured()) // запит/відповідь у звіт
.build();
}
// «сирий» метод: для негативних тестів
public Response createRaw(NewUser user) {
return given().spec(spec).body(user).post("/users");
}
// зручний метод: для підготовки даних
public User create(NewUser user) {
return createRaw(user).then().statusCode(201).extract().as(User.class);
}
}
public class UsersClient {
private final RequestSpecification spec;
public UsersClient(Config cfg, String token) {
this.spec = new RequestSpecBuilder()
.setBaseUri(cfg.apiUrl()) // URL only from config
.setContentType(ContentType.JSON)
.addHeader("Authorization", "Bearer " + token)
.addFilter(new AllureRestAssured()) // request/response in the report
.build();
}
// raw method: for negative tests
public Response createRaw(NewUser user) {
return given().spec(spec).body(user).post("/users");
}
// convenient method: for data setup
public User create(NewUser user) {
return createRaw(user).then().statusCode(201).extract().as(User.class);
}
}
RequestSpecBuilder збирає всі спільні налаштування один раз у конструкторі. Поле spec незмінне і належить екземпляру, тому кожен потік може мати власного клієнта з власним токеном.RequestSpecBuilder gathers all shared settings once in the constructor. The spec field is final and per instance, so each thread can have its own client with its own token./api/v1/... — шар клієнтів протікає. Тест має говорити «створи користувача», а не «зроби POST на такий URL»./api/v1/..., the client layer is leaking. A test should say "create a user", not "send a POST to this URL".Що це — Десеріалізація перетворює JSON-тіло відповіді на типізований об'єкт. У RestAssured це .extract().as(UserResponse.class), а під капотом працює Jackson (або Gson, якщо в classpath лише він). У Java 17 для моделей відповіді зручно брати record: він незмінний, має equals і читабельний toString, а Jackson з версії 2.12 створює record через канонічний конструктор без сеттерів.
What it is — Deserialization turns the JSON response body into a typed object. In RestAssured that is .extract().as(UserResponse.class), with Jackson doing the work underneath (or Gson if it is the only mapper on the classpath). On Java 17 a record is a handy response model: it is immutable, has equals and a readable toString, and since 2.12 Jackson builds records through the canonical constructor without setters.
Навіщо — Замість десятка body("address.city", equalTo(...)) з рядковими шляхами тест працює з полями, які перевіряє компілятор. Перейменували поле в моделі — проєкт не збереться, і ви дізнаєтесь про це до запуску, а не з падіння з незрозумілим JSON-шляхом.
Why — Instead of a dozen body("address.city", equalTo(...)) calls with string paths, the test works with fields the compiler checks. Rename a field in the model and the project stops compiling, so you learn about it before the run rather than from a failure pointing at an obscure JSON path.
Приклад — Створюємо користувача, забираємо відповідь як UserResponse і порівнюємо з очікуваним об'єктом через AssertJ usingRecursiveComparison().ignoringFields("id", "createdAt"). Поля, які генерує сервер, ігноруємо у загальному порівнянні й перевіряємо окремо, а все інше звіряємо одним виразом. У TypeScript as User нічого не перевіряє в рантаймі, тому тип доповнюють валідацією схемою.
Example — We create a user, extract the response as UserResponse and compare it with an expected object using AssertJ usingRecursiveComparison().ignoringFields("id", "createdAt"). Server-generated fields are left out of the bulk comparison and checked separately, and everything else is matched in one expression. In TypeScript as User checks nothing at runtime, so the type is paired with schema validation.
Підводні камені — Рішення щодо FAIL_ON_UNKNOWN_PROPERTIES: якщо увімкнено, кожне нове поле в API валить усі тести; якщо вимкнено, перейменоване поле тихо стане null. Робочий компроміс — вимкнути в моделях, а контроль структури віддати JSON Schema. Друга помилка — одна модель і для запиту, і для відповіді, хоча в запиті немає id і дат.
Pitfalls — The FAIL_ON_UNKNOWN_PROPERTIES decision: turned on, every new API field breaks all tests; turned off, a renamed field silently becomes null. A workable compromise is to switch it off in models and leave structure control to JSON Schema. The second mistake is sharing one model for request and response, even though the request has no id or timestamps.
Уточнення на співбесіді — «Чому не Map<String, Object>?» Map дає ті самі рядкові ключі без типів; для разової перевірки одного поля підходить, для моделі домену — ні.
Interview follow-up — "Why not a Map<String, Object>?" A map gives you the same string keys with no types; fine for a one-off field check, wrong for a domain model.
public record UserResponse(Long id, String email, String role, String createdAt) {}
@Test
void createdUserMatchesRequest() {
// модель запиту — без серверних полів
var request = new CreateUserRequest("qa@example.com", "EDITOR");
UserResponse actual = given().spec(api)
.body(request)
.when().post("/users")
.then().statusCode(201)
.extract().as(UserResponse.class);
var expected = new UserResponse(null, "qa@example.com", "EDITOR", null);
// id і createdAt генерує сервер — перевіряємо окремо
assertThat(actual)
.usingRecursiveComparison()
.ignoringFields("id", "createdAt")
.isEqualTo(expected);
assertThat(actual.id()).isPositive();
}
public record UserResponse(Long id, String email, String role, String createdAt) {}
@Test
void createdUserMatchesRequest() {
// request model has no server-side fields
var request = new CreateUserRequest("qa@example.com", "EDITOR");
UserResponse actual = given().spec(api)
.body(request)
.when().post("/users")
.then().statusCode(201)
.extract().as(UserResponse.class);
var expected = new UserResponse(null, "qa@example.com", "EDITOR", null);
// id and createdAt come from the server, checked separately
assertThat(actual)
.usingRecursiveComparison()
.ignoringFields("id", "createdAt")
.isEqualTo(expected);
assertThat(actual.id()).isPositive();
}
extract().as(...) віддає тіло мапперу Jackson; usingRecursiveComparison порівнює поля вглиб, а ignoringFields прибирає з порівняння значення, яких тест не може знати наперед.extract().as(...) hands the body to the Jackson mapper; usingRecursiveComparison compares fields deeply, and ignoringFields drops values the test cannot know in advance.as і порівнюю з очікуваним об'єктом рекурсивно, виключаючи id і дати. Коли бекенд перейменовує поле, я бачу це ще на компіляції. А щоб не пропустити зміну структури, поруч тримаю перевірку схемою — модель і схема в мене відповідають за різне.as and compare it recursively against an expected object, excluding ids and dates. When the backend renames a field I find out at compile time. And so I don't miss a structural change, I keep a schema check next to it — for me the model and the schema have different jobs.Що це — JSON Schema — це JSON-документ, що описує форму відповіді: типи полів, обов'язкові поля (required), формати (email, date-time), переліки (enum), межі чисел і чи дозволені зайві поля (additionalProperties). У RestAssured перевірка підключається модулем json-schema-validator і матчером matchesJsonSchemaInClasspath, у TypeScript найчастіше беруть Ajv.
What it is — JSON Schema is a JSON document describing the shape of a response: field types, mandatory fields (required), formats (email, date-time), enumerations (enum), numeric limits and whether extra fields are allowed (additionalProperties). In RestAssured you add the json-schema-validator module and the matchesJsonSchemaInClasspath matcher; in TypeScript Ajv is the usual choice.
Навіщо — Схема ловить поломки контракту, які точкові перевірки пропускають: поле стало рядком замість числа, зник обов'язковий status, прийшов null, з'явилося значення поза enum. Одна схема на ендпоінт перевіряє всю структуру за один рядок і працює як дешевий захист від дрейфу API між релізами.
Why — A schema catches contract breakages that spot checks miss: a field turned from number into string, the mandatory status disappeared, a null arrived, a value outside the enum showed up. One schema per endpoint verifies the whole structure in one line and acts as a cheap guard against API drift between releases.
Приклад — Для GET /orders/{id} схема вимагає id як integer, status з enum NEW, PAID, SHIPPED і масив items мінімум з одним елементом. Файл лежить у src/test/resources/schemas, тест додає один матчер після statusCode(200).
Example — For GET /orders/{id} the schema requires id as an integer, status from the enum NEW, PAID, SHIPPED and an items array with at least one element. The file lives in src/test/resources/schemas, and the test adds one matcher after statusCode(200).
Підводні камені — Схема не знає бізнес-логіки: сума замовлення 0 замість 150, чужий userId чи неправильний статус після оплати пройдуть валідацію. Друга пастка — надто м'яка схема без required і з дозволеними зайвими полями, яка пропускає майже будь-що. Третя — валідатор підтримує конкретні драфти стандарту, тож ключове слово з новішого драфту може тихо ігноруватися.
Pitfalls — A schema knows no business logic: an order total of 0 instead of 150, someone else's userId or a wrong status after payment all pass validation. The second trap is an over-permissive schema with no required and extra fields allowed, which accepts almost anything. The third is that a validator supports specific drafts of the standard, so a keyword from a newer draft may be silently ignored.
Уточнення на співбесіді — «Звідки брати схему?» Найкраще генерувати з OpenAPI-специфікації сервісу, а не писати руками з однієї відповіді, інакше схема повторює випадкові дані.
Interview follow-up — "Where does the schema come from?" Ideally generated from the service's OpenAPI spec rather than hand-written from one response, otherwise it just mirrors accidental data.
import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath;
@Test
void orderResponseMatchesSchema() {
given().spec(api)
.when().get("/orders/{id}", orderId)
.then()
.statusCode(200)
// структура: типи, required, enum
.body(matchesJsonSchemaInClasspath("schemas/order.json"))
// бізнес-значення схема не перевірить — окремий assert
.body("total", equalTo(150.0f));
}
import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath;
@Test
void orderResponseMatchesSchema() {
given().spec(api)
.when().get("/orders/{id}", orderId)
.then()
.statusCode(200)
// structure: types, required, enum
.body(matchesJsonSchemaInClasspath("schemas/order.json"))
// the schema cannot check business values, so a separate assert
.body("total", equalTo(150.0f));
}
{
"type": "object",
"required": ["id", "status", "items"],
"additionalProperties": false,
"properties": {
"id": { "type": "integer" },
"status": { "enum": ["NEW", "PAID", "SHIPPED"] },
"total": { "type": "number", "minimum": 0 },
"items": { "type": "array", "minItems": 1 }
}
}
src/test/resources; additionalProperties: false робить її суворою, а перевірка total показує, що значення все одно треба звіряти окремо.src/test/resources; additionalProperties: false makes it strict, and the total check shows values still need their own assertion.Що це — Більшість API захищені токеном: клієнт отримує access token (наприклад, OAuth2 client credentials або логін технічного користувача) і передає його в заголовку Authorization: Bearer .... У фреймворку за це відповідає окремий компонент — провайдер токена, який видає актуальний токен будь-якому клієнту.
What it is — Most APIs are protected by a token: the client obtains an access token (for example via OAuth2 client credentials or a technical user login) and sends it in the Authorization: Bearer ... header. In a framework this is owned by a separate component, a token provider that hands a valid token to any client.
Навіщо — Логін у кожному тесті множить час прогону і навантажує сервіс авторизації: 500 тестів — це 500 зайвих запитів, а деякі identity-провайдери ще й ставлять rate limit. Тому токен беруть один раз на прогін чи на воркер, кешують і оновлюють лише перед завершенням строку дії.
Why — Logging in inside every test multiplies run time and loads the auth service: 500 tests means 500 extra requests, and some identity providers also enforce rate limits. So the token is fetched once per run or per worker, cached, and refreshed only shortly before it expires.
Приклад — TokenProvider тримає токен і час завершення з поля expires_in, оновлюючи його за хвилину до кінця. Базова специфікація RestAssured додає заголовок через фільтр, тож кожен запит бере свіжий токен у момент відправлення. Для різних ролей (admin, viewer) — окремий токен на роль. У Playwright для UI аналогічну роль грає storageState, збережений у setup-проєкті.
Example — TokenProvider holds the token and its expiry from the expires_in field, refreshing a minute before it runs out. The base RestAssured specification adds the header through a filter, so every request picks up a fresh token at send time. Different roles (admin, viewer) get a token each. In Playwright UI tests the same job is done by a storageState saved in a setup project.
Підводні камені — Токен, захардкоджений у properties, протухає і валить нічний прогін; секрети клієнта мають іти з CI secrets, а не з репозиторію. Заголовок, записаний у специфікацію під час її створення, не оновиться після refresh. І найважливіше: сам логін, протермінований токен, чужа роль (403) перевіряються окремими тестами без кешованого токена.
Pitfalls — A token hardcoded in properties expires and breaks the nightly run; client secrets must come from CI secrets, not the repository. A header baked into the specification when it is built will not change after a refresh. And most importantly, the login itself, an expired token and a wrong role (403) are covered by dedicated tests that do not use the cached token.
Уточнення на співбесіді — «А при паралельному запуску?» Метод отримання токена синхронізований, тож потоки не штурмують auth-сервіс одночасно.
Interview follow-up — "What about parallel runs?" The fetch method is synchronized, so threads do not storm the auth service at the same moment.
public final class TokenProvider {
private static String token;
private static Instant expiresAt = Instant.EPOCH;
// один токен на прогін, оновлення за 60 с до кінця строку
public static synchronized String get() {
if (Instant.now().isAfter(expiresAt.minusSeconds(60))) {
JsonPath json = given()
.formParam("grant_type", "client_credentials")
.formParam("client_id", Config.get("auth.clientId"))
.formParam("client_secret", System.getenv("AUTH_SECRET"))
.post(Config.get("auth.tokenUrl"))
.then().statusCode(200)
.extract().jsonPath();
token = json.getString("access_token");
expiresAt = Instant.now().plusSeconds(json.getLong("expires_in"));
}
return token;
}
}
// фільтр підставляє актуальний токен у кожен запит
RequestSpecification api = new RequestSpecBuilder()
.setBaseUri(Config.get("api.url"))
.addFilter((req, res, ctx) -> {
req.header("Authorization", "Bearer " + TokenProvider.get());
return ctx.next(req, res);
})
.build();
public final class TokenProvider {
private static String token;
private static Instant expiresAt = Instant.EPOCH;
// one token per run, refreshed 60 s before it expires
public static synchronized String get() {
if (Instant.now().isAfter(expiresAt.minusSeconds(60))) {
JsonPath json = given()
.formParam("grant_type", "client_credentials")
.formParam("client_id", Config.get("auth.clientId"))
.formParam("client_secret", System.getenv("AUTH_SECRET"))
.post(Config.get("auth.tokenUrl"))
.then().statusCode(200)
.extract().jsonPath();
token = json.getString("access_token");
expiresAt = Instant.now().plusSeconds(json.getLong("expires_in"));
}
return token;
}
}
// the filter puts the current token into every request
RequestSpecification api = new RequestSpecBuilder()
.setBaseUri(Config.get("api.url"))
.addFilter((req, res, ctx) -> {
req.header("Authorization", "Bearer " + TokenProvider.get());
return ctx.next(req, res);
})
.build();
synchronized не дає двом потокам одночасно просити новий токен; Filter у RestAssured — функціональний інтерфейс, тому лямбда додає заголовок у момент відправлення, а не при збиранні специфікації.synchronized stops two threads from requesting a new token at once; RestAssured's Filter is a functional interface, so the lambda adds the header at send time rather than when the specification is built.Що це — У consumer-driven підході контракт формує споживач. Тест споживача (фронтенд чи інший сервіс) ходить не в реальний провайдер, а в mock-сервер Pact і описує взаємодії: «на GET /users/42 чекаю 200 і тіло з полями id та email». Після тесту Pact записує ці очікування у pact-файл (JSON), який публікують у Pact Broker. Провайдер у своєму CI проганяє verification: Pact відтворює кожен запит проти реального сервісу й звіряє відповідь.
What it is — In the consumer-driven approach the consumer shapes the contract. The consumer test (a frontend or another service) calls a Pact mock server instead of the real provider and describes interactions: "for GET /users/42 I expect 200 and a body with id and email". Afterwards Pact writes these expectations to a pact file (JSON) that is published to a Pact Broker. The provider runs verification in its own CI: Pact replays every request against the real service and compares the response.
Коли це окупається — Коли сервісів багато і повний E2E-стенд повільний та нестабільний. Контракти дають швидку відповідь на питання «чи зламає мій реліз когось із клієнтів» без підняття всієї системи. Ворота — команда can-i-deploy: брокер перевіряє, чи ця версія сумісна з версіями, які вже стоять у цільовому середовищі.
When it pays off — When there are many services and a full E2E environment is slow and unstable. Contracts quickly answer "will my release break any of my clients" without bringing up the whole system. The gate is the can-i-deploy command: the broker checks whether this version is compatible with the versions already deployed to the target environment.
Приклад — Бекенд перейменував email на mail. Його unit і API-тести зелені, але verification пакту мобільного клієнта падає ще в PR, і can-i-deploy блокує деплой. Без контракту це знайшли б після релізу.
Example — The backend renamed email to mail. Its unit and API tests are green, but verification of the mobile client's pact fails right in the PR, and can-i-deploy blocks the deployment. Without the contract this would surface after the release.
Компроміси — Контракт перевіряє формат, а не бізнес-логіку провайдера. Потрібні provider states (підготовка даних «користувач 42 існує») і згода обох команд підтримувати процес. Для публічного API з невідомими клієнтами краще підходить provider-driven перевірка за OpenAPI. Типова помилка — пхати в пакт точні значення замість матчерів за типом, і тоді він ламається від будь-яких даних.
Trade-offs — A contract checks format, not the provider's business logic. You need provider states (data setup such as "user 42 exists") and both teams agreeing to maintain the process. For a public API with unknown clients, provider-driven checks against OpenAPI fit better. A classic mistake is putting exact values into the pact instead of type matchers, so it breaks on any data change.
Уточнення на співбесіді — «Чим це відрізняється від JSON Schema?» Схема описує все, що провайдер може віддати, а пакт — лише те, чим реально користується конкретний споживач.
Interview follow-up — "How is this different from JSON Schema?" A schema describes everything the provider may return; a pact covers only what a particular consumer actually uses.
# репозиторій споживача (web-app)
env:
PACT_BROKER_BASE_URL: https://pact.example.com
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
jobs:
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# тести споживача проти mock-сервера Pact пишуть ./pacts/*.json
- run: npm ci && npm run test:pact
- name: Publish pacts
run: >
pact-broker publish ./pacts
--consumer-app-version ${{ github.sha }}
--branch ${{ github.ref_name }}
# ворота перед деплоєм: чи сумісні ми з тим, що вже в prod
- name: Can I deploy
run: >
pact-broker can-i-deploy
--pacticipant web-app
--version ${{ github.sha }}
--to-environment production
# consumer repository (web-app)
env:
PACT_BROKER_BASE_URL: https://pact.example.com
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
jobs:
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# consumer tests against the Pact mock server write ./pacts/*.json
- run: npm ci && npm run test:pact
- name: Publish pacts
run: >
pact-broker publish ./pacts
--consumer-app-version ${{ github.sha }}
--branch ${{ github.ref_name }}
# gate before deploying: are we compatible with what is in prod
- name: Can I deploy
run: >
pact-broker can-i-deploy
--pacticipant web-app
--version ${{ github.sha }}
--to-environment production
PACT_BROKER_BASE_URL і PACT_BROKER_TOKEN. Провайдер має свій job із verification і після деплою викликає record-deployment.PACT_BROKER_BASE_URL and PACT_BROKER_TOKEN. The provider has its own verification job and calls record-deployment after deploying.Що це — Newman — консольний раннер Postman-колекцій. Колекцію експортують у JSON (або беруть через Postman API), оточення — окремим файлом зі змінними на кшталт baseUrl. Перевірки, написані у вкладці Tests через pm.test і pm.expect, Newman виконує так само, як Postman, і повертає ненульовий код виходу, якщо хоча б одна впала.
What it is — Newman is the command-line runner for Postman collections. The collection is exported to JSON (or fetched via the Postman API), and the environment is a separate file with variables such as baseUrl. Checks written in the Tests tab with pm.test and pm.expect run in Newman just as in Postman, and a non-zero exit code is returned if any of them fails.
Навіщо — Це найшвидший шлях зробити з ручних запитів регресійну перевірку в пайплайні: без нового фреймворку, силами тих, хто вже веде колекцію. Репортер junit дає XML, який CI показує як звичайні тести.
Why — It is the fastest way to turn manual requests into a regression check in the pipeline: no new framework, done by the people already maintaining the collection. The junit reporter produces XML that CI displays like ordinary tests.
Приклад — У job встановлюємо Newman, запускаємо колекцію з оточенням staging, секрет передаємо через --env-var із CI secrets, а звіт вивантажуємо як артефакт навіть при падінні.
Example — The job installs Newman, runs the collection with the staging environment, passes the secret with --env-var from CI secrets, and uploads the report as an artifact even when the run fails.
Підводні камені — Колекція в JSON погано рев'юється: diff на сотні рядків після зміни одного запиту. Логіка розмазана по pre-request скриптах, перевикористання обмежене, запити залежать від порядку через змінні колекції. Сигнали, що пора переходити в код: понад кілька десятків запитів, потрібні генерація даних, паралельний запуск, спільні клієнти з UI-тестами чи нормальне рев'ю в PR.
Pitfalls — A JSON collection is hard to review: changing one request yields a diff hundreds of lines long. Logic is scattered across pre-request scripts, reuse is limited, and requests depend on order through collection variables. Signals that it is time to move into code: more than a few dozen requests, a need for data generation, parallel runs, clients shared with UI tests, or proper PR review.
Уточнення на співбесіді — «Викинути колекцію після міграції?» Ні, її зручно лишити як живу документацію і пісочницю для ручного дослідження API.
Interview follow-up — "Throw the collection away after migrating?" No, it is still handy as living documentation and a sandbox for manual API exploration.
steps:
- uses: actions/checkout@v4
- run: npm install -g newman
# колекція і оточення лежать у репозиторії поруч із тестами
- name: API smoke
run: >
newman run postman/shop.postman_collection.json
-e postman/staging.postman_environment.json
--env-var apiKey=${{ secrets.API_KEY }}
--reporters cli,junit
--reporter-junit-export results/newman.xml
# звіт потрібен саме тоді, коли прогін упав
- if: always()
uses: actions/upload-artifact@v4
with:
name: newman-report
path: results/
steps:
- uses: actions/checkout@v4
- run: npm install -g newman
# collection and environment live in the repo next to the tests
- name: API smoke
run: >
newman run postman/shop.postman_collection.json
-e postman/staging.postman_environment.json
--env-var apiKey=${{ secrets.API_KEY }}
--reporters cli,junit
--reporter-junit-export results/newman.xml
# the report matters most when the run failed
- if: always()
uses: actions/upload-artifact@v4
with:
name: newman-report
path: results/
-e підключає файл оточення, --env-var перекриває одну змінну без комітування секрету, а if: always() вивантажує XML-звіт незалежно від результату попереднього кроку.-e loads the environment file, --env-var overrides one variable without committing the secret, and if: always() uploads the XML report whatever the previous step's result.Що це — Аналіз граничних значень (BVA) каже: помилки живуть на краях діапазонів, бо розробник пише > замість >=. Для діапазону 18–65 беремо значення на межі і поруч із нею: 17, 18, 19 та 64, 65, 66 (трьохточковий варіант) або лише 17, 18, 65, 66 (двоточковий). В автоматизації це ідеально лягає на @ParameterizedTest: одна логіка перевірки, багато рядків даних.
What it is — Boundary value analysis (BVA) says bugs live at the edges of ranges, because a developer writes > instead of >=. For a range of 18–65 we take values on and next to each boundary: 17, 18, 19 and 64, 65, 66 (three-point variant) or just 17, 18, 65, 66 (two-point). In automation this maps perfectly to @ParameterizedTest: one piece of check logic, many rows of data.
Навіщо — Шість окремих методів із копіпастою важко підтримувати, а таблиця в @CsvSource читається як тест-дизайн: вхід і очікуваний результат в одному рядку. Кожен рядок у звіті — окремий тест, тож видно, яка саме межа зламалась.
Why — Six copy-pasted methods are hard to maintain, while a table in @CsvSource reads like the test design itself: input and expected result on one line. Each row is a separate test in the report, so you see exactly which boundary broke.
Приклад — Правило «страховку можна оформити з 18 до 65 років включно». Атрибут name робить назви читабельними: «вік 17 → false». Якщо межа лежить у валідації API, той самий набір даних шлемо в запит і чекаємо 201 або 400.
Example — The rule is "insurance can be issued from age 18 to 65 inclusive". The name attribute makes names readable: "age 17 → false". If the boundary sits in API validation, the same data set goes into the request and we expect 201 or 400.
Підводні камені — Беруть лише «гарні» значення всередині діапазону й пропускають самі межі. Забувають про технічні межі типу: 0, від'ємне число, Integer.MAX_VALUE, порожній рядок, null (для рядків є @NullAndEmptySource). Ще одна помилка — очікуване значення обчислюють у тесті тією ж формулою, що й продакшн-код, і тест перевіряє сам себе.
Pitfalls — People pick only "nice" values inside the range and skip the boundaries themselves. They forget technical limits of the type: 0, a negative number, Integer.MAX_VALUE, an empty string, null (for strings there is @NullAndEmptySource). Another mistake is computing the expected value in the test with the same formula as the production code, so the test checks itself.
Уточнення на співбесіді — «А для дат?» Ті самі межі: останній день дії, перший прострочений, 29 лютого, перехід через північ у потрібній таймзоні.
Interview follow-up — "What about dates?" Same boundaries: the last valid day, the first expired one, 29 February, crossing midnight in the relevant time zone.
@ParameterizedTest(name = "вік {0} -> дозволено: {1}")
@CsvSource({
"17, false", // нижче нижньої межі
"18, true", // нижня межа
"19, true",
"64, true",
"65, true", // верхня межа включно
"66, false", // вище верхньої межі
"-1, false" // технічна межа: від'ємний вік
})
void insuranceAgeBoundaries(int age, boolean allowed) {
assertThat(policyService.canIssue(age)).isEqualTo(allowed);
}
@ParameterizedTest(name = "age {0} -> allowed: {1}")
@CsvSource({
"17, false", // below the lower boundary
"18, true", // lower boundary
"19, true",
"64, true",
"65, true", // upper boundary, inclusive
"66, false", // above the upper boundary
"-1, false" // technical limit: negative age
})
void insuranceAgeBoundaries(int age, boolean allowed) {
assertThat(policyService.canIssue(age)).isEqualTo(allowed);
}
int і boolean; {0} і {1} у name підставляють аргументи, тож у звіті видно конкретну межу, яка впала.int and boolean itself; {0} and {1} in name insert the arguments, so the report shows the exact boundary that failed.Що це — Еквівалентне розбиття ділить вхідні дані на класи, всередині яких система поводиться однаково. Якщо сервіс приймає оплату карткою VISA і Mastercard, а решту способів відхиляє, то класів два: «підтримувана картка» і «непідтримуваний спосіб». З кожного класу достатньо одного-двох представників — інші значення того ж класу не дадуть нової інформації.
What it is — Equivalence partitioning splits input data into classes within which the system behaves the same way. If a service accepts VISA and Mastercard payments and rejects every other method, there are two classes: "supported card" and "unsupported method". One or two representatives per class are enough; other values from the same class add no new information.
Навіщо — Техніка тримає набір тестів малим і осмисленим. У JUnit 5 класи природно лягають на джерела параметрів: @EnumSource з names для валідного класу і mode = EXCLUDE для решти, @ValueSource — для представників невалідних класів рядків.
Why — The technique keeps the test set small and meaningful. In JUnit 5 partitions map naturally onto argument sources: @EnumSource with names for the valid class and mode = EXCLUDE for the rest, @ValueSource for representatives of invalid string classes.
Приклад — Для email невалідні класи різні за природою: без «@», без домену, з пробілом, порожній. Кожен клас — один рядок, бо валідація відкидає їх з різних причин. Перелік EXCLUDE має приємний бонус: додали в enum новий спосіб оплати — він автоматично потрапив у тест «відхиляється», і якщо бізнес хотів його підтримати, тест про це нагадає.
Example — For email the invalid classes differ in nature: no "@", no domain, containing a space, empty. Each class gets one row because validation rejects them for different reasons. The EXCLUDE list has a nice bonus: add a new payment method to the enum and it automatically lands in the "rejected" test, so if the business meant to support it, the test will remind you.
Підводні камені — Двадцять значень з одного класу (a@b.com, c@d.com...) — це не покриття, а шум і довший прогін. Протилежна помилка — зливати різні невалідні класи в один тест, де перше ж невалідне поле маскує інші. Також класи визначає поведінка системи, а не зручність тестувальника: якщо для VIP-клієнтів інша логіка, це окремий клас.
Pitfalls — Twenty values from one class (a@b.com, c@d.com...) are noise and a longer run, not coverage. The opposite mistake is merging different invalid classes into one test, where the first invalid field masks the others. Also, classes are defined by system behaviour, not tester convenience: if VIP customers get different logic, that is a separate class.
Уточнення на співбесіді — «Як це поєднати з BVA?» Розбиття дає класи, а межі між числовими класами потім перевіряємо граничними значеннями.
Interview follow-up — "How does it combine with BVA?" Partitioning gives the classes, and the edges between numeric classes are then checked with boundary values.
// валідний клас: підтримувані картки
@ParameterizedTest
@EnumSource(value = PaymentMethod.class, names = {"VISA", "MASTERCARD"})
void supportedMethodIsAccepted(PaymentMethod method) {
assertThat(checkout.pay(order, method).status()).isEqualTo(Status.PAID);
}
// невалідний клас: усе інше, включно з новими значеннями enum
@ParameterizedTest
@EnumSource(value = PaymentMethod.class, mode = EnumSource.Mode.EXCLUDE,
names = {"VISA", "MASTERCARD"})
void otherMethodsAreRejected(PaymentMethod method) {
assertThatThrownBy(() -> checkout.pay(order, method))
.isInstanceOf(UnsupportedPaymentException.class);
}
// по одному представнику на кожен невалідний клас email
@ParameterizedTest
@ValueSource(strings = {"user.example.com", "user@", "us er@example.com", ""})
void invalidEmailIsRejected(String email) {
assertThat(validator.isValidEmail(email)).isFalse();
}
// valid class: supported cards
@ParameterizedTest
@EnumSource(value = PaymentMethod.class, names = {"VISA", "MASTERCARD"})
void supportedMethodIsAccepted(PaymentMethod method) {
assertThat(checkout.pay(order, method).status()).isEqualTo(Status.PAID);
}
// invalid class: everything else, including new enum values
@ParameterizedTest
@EnumSource(value = PaymentMethod.class, mode = EnumSource.Mode.EXCLUDE,
names = {"VISA", "MASTERCARD"})
void otherMethodsAreRejected(PaymentMethod method) {
assertThatThrownBy(() -> checkout.pay(order, method))
.isInstanceOf(UnsupportedPaymentException.class);
}
// one representative for each invalid email class
@ParameterizedTest
@ValueSource(strings = {"user.example.com", "user@", "us er@example.com", ""})
void invalidEmailIsRejected(String email) {
assertThat(validator.isValidEmail(email)).isFalse();
}
names обирає значення enum для валідного класу, а Mode.EXCLUDE генерує всі інші; у @ValueSource кожен рядок — представник окремої причини відмови.names selects the enum values for the valid class and Mode.EXCLUDE generates all the others; in @ValueSource each string represents a distinct reason for rejection.Що це — Таблиця рішень описує правило з кількома умовами: кожен стовпчик — комбінація умов (член клубу, сума від 1000, є купон), а рядок дій — результат (знижка 0, 5, 10 чи 15%). Для трьох бінарних умов повна таблиця має 8 правил, частину з них бізнес зазвичай схлопує. У коді кожне правило стає одним набором аргументів.
What it is — A decision table describes a rule with several conditions: each column is a combination of conditions (club member, total of 1000 or more, has a coupon) and the action row is the outcome (0, 5, 10 or 15% discount). With three binary conditions the full table has 8 rules, some of which the business usually collapses. In code each rule becomes one set of arguments.
Навіщо — Саме комбінації умов найчастіше зламані: кожна умова окремо працює, а «член клубу з купоном» отримує подвійну знижку. @MethodSource повертає Stream<Arguments>, тож таблиця лежить у коді як є і рев'юється разом із вимогою.
Why — Combinations of conditions are what breaks most often: each condition works alone, yet "member with a coupon" gets a double discount. @MethodSource returns a Stream<Arguments>, so the table sits in the code as is and is reviewed together with the requirement.
Приклад — Правило: член клубу — 5%, сума від 1000 — ще 5%, купон — 10%, але купон не сумується з клубною знижкою, а максимум 15%. Вісім рядків arguments(...) повністю покривають правило, а назва тесту показує комбінацію. Якщо таблицю веде продакт, її виносять у CSV і читають через @CsvFileSource(resources = "/discount-rules.csv", numLinesToSkip = 1).
Example — The rule: club members get 5%, a total of 1000 or more adds another 5%, a coupon gives 10%, but the coupon does not stack with the club discount and the cap is 15%. Eight arguments(...) rows fully cover the rule, and the test name shows the combination. If a product owner maintains the table, it moves to a CSV read with @CsvFileSource(resources = "/discount-rules.csv", numLinesToSkip = 1).
Підводні камені — Писати лише «щасливі» правила й пропустити комбінацію, де умови конфліктують. Змішувати в одному методі кілька незалежних таблиць. Для 5–6 умов повна таблиця розростається до 32–64 рядків — тоді застосовують pairwise або схлопують правила з однаковим результатом, свідомо документуючи, що пропущено.
Pitfalls — Writing only the "happy" rules and skipping the combination where conditions conflict. Mixing several independent tables in one method. With 5–6 conditions a full table grows to 32–64 rows; then you use pairwise or collapse rules with the same result, deliberately documenting what was left out.
Уточнення на співбесіді — «Чому не @CsvSource?» Можна, але Arguments дає типізовані значення, enum і об'єкти, а не лише рядки.
Interview follow-up — "Why not @CsvSource?" You could, but Arguments gives typed values, enums and objects rather than just strings.
// член сума≥1000 купон -> знижка %
static Stream<Arguments> discountRules() {
return Stream.of(
arguments(false, false, false, 0),
arguments(true, false, false, 5),
arguments(false, true, false, 5),
arguments(true, true, false, 10),
arguments(false, false, true, 10),
arguments(true, false, true, 10), // купон не сумується з клубною
arguments(false, true, true, 15),
arguments(true, true, true, 15) // стеля 15%
);
}
@ParameterizedTest(name = "член={0}, велика сума={1}, купон={2} -> {3}%")
@MethodSource("discountRules")
void discountFollowsDecisionTable(boolean member, boolean bigOrder,
boolean coupon, int expectedPercent) {
var order = OrderBuilder.anOrder()
.member(member).total(bigOrder ? 1200 : 300).coupon(coupon).build();
assertThat(pricing.discountPercent(order)).isEqualTo(expectedPercent);
}
// member total≥1000 coupon -> discount %
static Stream<Arguments> discountRules() {
return Stream.of(
arguments(false, false, false, 0),
arguments(true, false, false, 5),
arguments(false, true, false, 5),
arguments(true, true, false, 10),
arguments(false, false, true, 10),
arguments(true, false, true, 10), // coupon does not stack with club discount
arguments(false, true, true, 15),
arguments(true, true, true, 15) // 15% cap
);
}
@ParameterizedTest(name = "member={0}, big order={1}, coupon={2} -> {3}%")
@MethodSource("discountRules")
void discountFollowsDecisionTable(boolean member, boolean bigOrder,
boolean coupon, int expectedPercent) {
var order = OrderBuilder.anOrder()
.member(member).total(bigOrder ? 1200 : 300).coupon(coupon).build();
assertThat(pricing.discountPercent(order)).isEqualTo(expectedPercent);
}
Arguments на правило таблиці, @MethodSource посилається на нього за іменем, а коментар-заголовок над рядками робить код схожим на саму таблицю з вимоги.Arguments per table rule, @MethodSource refers to it by name, and the header comment above the rows makes the code look like the table from the requirement.Що це — техніка, де об'єкт (замовлення, заявка, підписка) описують як скінченний автомат: стани, події та дозволені переходи між ними. Для замовлення це, наприклад, NEW → PAID → SHIPPED і гілка NEW/PAID → CANCELLED. Тести перевіряють кожен дозволений перехід і, що важливіше, кожну заборонену пару «стан + подія».
What it is — a technique where an entity (an order, an application, a subscription) is modelled as a finite state machine: states, events and the allowed transitions between them. For an order that is, say, NEW → PAID → SHIPPED plus a branch NEW/PAID → CANCELLED. Tests check every allowed transition and, more importantly, every forbidden "state + event" pair.
Навіщо — найдорожчі баги в таких доменах живуть саме в заборонених переходах: скасування вже відправленого замовлення, повторна оплата, доставка неоплаченого. Ручний тестувальник зазвичай проходить щасливий шлях, а таблиця переходів змушує подумати про всі комбінації.
Why — the most expensive bugs in such domains live in forbidden transitions: cancelling an order that has already shipped, paying twice, shipping something unpaid. A manual tester usually walks the happy path, while a transition table forces you to consider every combination.
Приклад — я виписую таблицю «стан × подія» і перетворюю її на два параметризовані тести: валідні переходи очікують 200 і новий стан, невалідні — 409 Conflict і незмінний стан. Ключовий прийом — фабричний метод createInState(), який готує замовлення в потрібному стані через API або БД, а не проганяє весь ланцюжок подій перед кожною перевіркою.
Example — I write out a "state × event" table and turn it into two parameterized tests: valid transitions expect 200 and the new state, invalid ones expect 409 Conflict and an unchanged state. The key trick is a factory method createInState() that prepares the order in the required state via the API or the database instead of replaying the whole chain of events before each check.
Підводні камені — перевіряти лише код відповіді: сервер може повернути 409, але все одно змінити статус у базі. Друга помилка — покривати тільки 0-switch (окремі переходи) там, де баги виникають у послідовностях, наприклад «оплатив, скасував, оплатив знову».
Pitfalls — checking only the response code: the server can return 409 and still change the status in the database. The second mistake is covering only 0-switch (single transitions) where the bugs appear in sequences, such as "paid, cancelled, paid again".
Уточнення на співбесіді — «Скільки тестів це дає?» Для 4 станів і 3 подій — 12 комбінацій; я автоматизую всі, бо кожна — це один рядок CSV, а не новий метод.
Interview follow-up — "How many tests does that give?" With 4 states and 3 events there are 12 combinations; I automate all of them, because each one is a single CSV row, not a new method.
@ParameterizedTest(name = "{0} + {1} -> {2}")
@CsvSource({
// стан, подія, очікуваний стан
"NEW, pay, PAID",
"PAID, ship, SHIPPED",
"NEW, cancel, CANCELLED",
"PAID, cancel, CANCELLED"
})
void validTransition(String from, String event, String expected) {
long id = orders.createInState(from); // готуємо стан напряму, без ланцюжка
orders.apply(id, event).then().statusCode(200);
assertThat(orders.get(id).state()).isEqualTo(expected);
}
@ParameterizedTest(name = "{0} + {1} is rejected")
@CsvSource({ "SHIPPED, cancel", "CANCELLED, pay", "NEW, ship" })
void forbiddenTransition(String from, String event) {
long id = orders.createInState(from);
orders.apply(id, event).then().statusCode(409);
// статус у базі не мав змінитися
assertThat(orders.get(id).state()).isEqualTo(from);
}
@ParameterizedTest(name = "{0} + {1} -> {2}")
@CsvSource({
// state, event, expected state
"NEW, pay, PAID",
"PAID, ship, SHIPPED",
"NEW, cancel, CANCELLED",
"PAID, cancel, CANCELLED"
})
void validTransition(String from, String event, String expected) {
long id = orders.createInState(from); // set the state directly, no event chain
orders.apply(id, event).then().statusCode(200);
assertThat(orders.get(id).state()).isEqualTo(expected);
}
@ParameterizedTest(name = "{0} + {1} is rejected")
@CsvSource({ "SHIPPED, cancel", "CANCELLED, pay", "NEW, ship" })
void forbiddenTransition(String from, String event) {
long id = orders.createInState(from);
orders.apply(id, event).then().statusCode(409);
// the stored status must not have changed
assertThat(orders.get(id).state()).isEqualTo(from);
}
@CsvSource — клітинка таблиці переходів, а name робить її читабельною у звіті. Другий тест перевіряє не лише 409, а й те, що стан лишився старим.@CsvSource row is one cell of the transition table, and name makes it readable in the report. The second test checks not only the 409 but also that the state stayed the same.Що це — покриття коду показує, яку частину продакшн-коду виконали тести. Покриття рядків (line) рахує виконані рядки, покриття гілок (branch) — чи пройдено обидва виходи кожного if, switch або тернарного оператора. Рядок if (total >= 1000 && vip) може бути «зеленим» по рядках і мати лише 50% по гілках.
What it is — code coverage shows what share of production code the tests executed. Line coverage counts executed lines; branch coverage checks whether both outcomes of every if, switch or ternary were taken. The line if (total >= 1000 && vip) can be "green" by lines and still sit at 50% by branches.
Навіщо — метрика знаходить непротестовані ділянки: гілки обробки помилок, рідкісні значення enum, мертвий код. Branch coverage корисніша, ніж line, бо саме в умовах ховаються граничні баги.
Why — the metric finds untested areas: error-handling branches, rare enum values, dead code. Branch coverage is more useful than line coverage, because boundary bugs hide precisely in conditions.
Приклад — у Maven підключаю jacoco-maven-plugin: ціль prepare-agent додає Java-агент у запуск Surefire, report генерує HTML і XML у target/site/jacoco, а check валить білд, якщо покриття гілок нижче порогу. Для API- чи E2E-тестів агент JaCoCo можна запустити прямо на тестовому сервері (-javaagent:jacocoagent.jar=output=tcpserver) і після прогону зібрати дамп через dump — так видно, що з бекенда реально зачепили наші автотести.
Example — in Maven I add jacoco-maven-plugin: the prepare-agent goal adds a Java agent to the Surefire run, report generates HTML and XML into target/site/jacoco, and check fails the build if branch coverage drops below a threshold. For API or E2E tests you can run the JaCoCo agent right on the test server (-javaagent:jacocoagent.jar=output=tcpserver) and collect a dump after the run with dump — that shows what our automation actually touched in the backend.
Підводні камені — покриття вимірює виконання, а не перевірку: тест без жодного assert дає ті самі відсотки. Також часто забувають виключити згенерований код (DTO, Lombok, мапери), і він роздуває цифру.
Pitfalls — coverage measures execution, not verification: a test with no assert yields the same percentage. People also forget to exclude generated code (DTOs, Lombok, mappers), which inflates the number.
Уточнення на співбесіді — «Який поріг ставити?» Я б не фіксував глобальну цифру, а вимагав покриття нового коду в PR, наприклад 80% гілок через SonarQube.
Interview follow-up — "What threshold would you set?" I wouldn't fix a global number; I'd require coverage on new code in the PR, for example 80% of branches via SonarQube.
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<!-- агент підключається до Surefire автоматично -->
<execution><id>prepare</id><goals><goal>prepare-agent</goal></goals></execution>
<execution><id>report</id><phase>verify</phase><goals><goal>report</goal></goals></execution>
<execution>
<id>check</id>
<goals><goal>check</goal></goals>
<configuration><rules><rule>
<element>BUNDLE</element>
<limits><limit>
<!-- білд падає, якщо гілок покрито менше 70% -->
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit></limits>
</rule></rules></configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<!-- the agent is wired into Surefire automatically -->
<execution><id>prepare</id><goals><goal>prepare-agent</goal></goals></execution>
<execution><id>report</id><phase>verify</phase><goals><goal>report</goal></goals></execution>
<execution>
<id>check</id>
<goals><goal>check</goal></goals>
<configuration><rules><rule>
<element>BUNDLE</element>
<limits><limit>
<!-- the build fails if fewer than 70% of branches are covered -->
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit></limits>
</rule></rules></configuration>
</execution>
</executions>
</plugin>
verify і перевірка порогу. counter BRANCH з COVEREDRATIO 0.70 означає «не менше 70% гілок по всьому модулю».verify phase, and a threshold check. counter BRANCH with COVEREDRATIO 0.70 means "at least 70% of branches across the whole module".Що це — 100% покриття лише означає, що кожен рядок хоч раз виконався. Воно не гарантує, що тести щось перевіряють, і не бачить відсутнього коду: якщо розробник забув обробити від'ємну суму, покривати нема чого. Мутаційне тестування відповідає на інше питання: чи впадуть тести, якщо код зламати.
What it is — 100% coverage only means every line ran at least once. It does not guarantee the tests verify anything, and it cannot see missing code: if a developer forgot to handle a negative amount, there is nothing to cover. Mutation testing answers a different question: will the tests fail if the code is broken?
Коли і трейдоф — останні відсотки покриття найдорожчі: геттери, логування, неможливі catch. Команда під тиском цифри пише тести без assert, щоб «пофарбувати» рядки, і метрика перестає щось означати (закон Гудгарта). PIT (pitest-maven з pitest-junit5-plugin) створює мутанти — міняє >= на >, повертає null, прибирає виклик методу — і проганяє тести. Вижилий мутант означає слабку перевірку. Показник — mutation score, частка вбитих мутантів.
When and trade-off — the last percent of coverage is the most expensive: getters, logging, impossible catch blocks. Under pressure for the number, a team writes tests without asserts just to "paint" lines, and the metric stops meaning anything (Goodhart's law). PIT (pitest-maven with pitest-junit5-plugin) creates mutants — flips >= to >, returns null, removes a method call — and runs the tests. A surviving mutant means a weak check. The metric is the mutation score, the share of killed mutants.
Приклад — сервіс знижок мав 100% покриття, але PIT показав, що мутант >= 1000 → > 1000 вижив: жоден тест не перевіряв рівно 1000. Один граничний assert його вбив.
Example — a discount service had 100% coverage, but PIT showed that the mutant >= 1000 → > 1000 survived: no test checked exactly 1000. A single boundary assert killed it.
Підводні камені — PIT повільний, бо ганяє тести на кожен мутант, тож запускають його на змінених класах (targetClasses, інкрементальний аналіз) або вночі. Є «еквівалентні» мутанти, які неможливо вбити, — 100% mutation score теж не ціль.
Pitfalls — PIT is slow because it runs tests for every mutant, so you run it on changed classes (targetClasses, incremental analysis) or nightly. There are "equivalent" mutants that cannot be killed — a 100% mutation score is not a goal either.
Уточнення на співбесіді — «Чим замінити цифру?» Порогом на новий код у PR плюс mutation score для критичних модулів: платежі, тарифи, права доступу.
Interview follow-up — "What replaces the number?" A threshold on new code in the PR plus a mutation score for critical modules: payments, pricing, permissions.
// дає 100% рядків, але нічого не перевіряє
@Test
void calculatesDiscount() {
service.discount(new Order(1000));
}
// PIT міняє ">= 1000" на "> 1000" — цей тест мутанта вбиває
@Test
void discountStartsExactlyAtThousand() {
assertThat(service.discount(new Order(1000))).isEqualTo(100);
assertThat(service.discount(new Order(999))).isZero();
}
// запуск лише для пакета знижок
// mvn org.pitest:pitest-maven:mutationCoverage -DtargetClasses=com.shop.discount.*
// gives 100% of lines but verifies nothing
@Test
void calculatesDiscount() {
service.discount(new Order(1000));
}
// PIT flips ">= 1000" to "> 1000" — this test kills the mutant
@Test
void discountStartsExactlyAtThousand() {
assertThat(service.discount(new Order(1000))).isEqualTo(100);
assertThat(service.discount(new Order(999))).isZero();
}
// run only for the discount package
// mvn org.pitest:pitest-maven:mutationCoverage -DtargetClasses=com.shop.discount.*
targetClasses звужує мутації до одного пакета, щоб прогін тривав хвилини, а не години.targetClasses parameter narrows mutation to one package so the run takes minutes rather than hours.Що це — трасованість — це зв'язок «вимога ↔ тест ↔ результат прогону». В автоматизації його тримають у самому коді: ідентифікатор історії або вимоги стоїть поруч із тестом як анотація чи тег, а звіт прогону збирає ці мітки. Тоді матрицю покриття не ведуть вручну в Excel, а генерують з результатів CI.
What it is — traceability is the link "requirement ↔ test ↔ run result". In automation you keep it in the code itself: the story or requirement ID sits next to the test as an annotation or a tag, and the run report collects those labels. The coverage matrix is then generated from CI results instead of being maintained by hand in Excel.
Коли і трейдоф — це потрібно в регульованих доменах (медицина, фінанси), де аудитор питає «чим перевірено вимогу 4.2», і перед релізом, коли PO хоче знати, чи всі історії спринту мають зелені тести. Ціна — дисципліна: кожен новий тест має отримати мітку, а мітки треба оновлювати, коли вимоги змінюються.
When and trade-off — you need it in regulated domains (healthcare, finance), where an auditor asks "what verifies requirement 4.2", and before a release, when the PO wants to know whether every sprint story has green tests. The cost is discipline: every new test must get a label, and labels must be updated when requirements change.
Приклад — у Java я ставлю @TmsLink і @Tag("REQ-142"), у Playwright — tag і annotation в опціях тесту. Невеликий скрипт у CI бере список вимог спринту з Jira API, парсить allure-results або JUnit XML і будує таблицю: вимога — тести — статус. Вимоги без жодного тесту йдуть окремим списком «прогалини».
Example — in Java I put @TmsLink and @Tag("REQ-142"), in Playwright — tag and annotation in the test options. A small CI script pulls the sprint's requirements from the Jira API, parses allure-results or JUnit XML and builds a table: requirement — tests — status. Requirements with no test at all go into a separate "gaps" list.
Підводні камені — мітки «гниють»: вимогу закрили чи розбили на дві, а тест досі посилається на стару. Трасованість має бути двосторонньою — тест без вимоги теж сигнал (або мертвий тест, або незадокументована поведінка).
Pitfalls — labels rot: a requirement is closed or split in two, and the test still points to the old one. Traceability must be two-way — a test without a requirement is a signal too (either a dead test or undocumented behaviour).
Уточнення на співбесіді — «А якщо на одну вимогу 30 тестів?» Це нормально; матриця показує статус вимоги як найгірший статус її тестів.
Interview follow-up — "What if one requirement has 30 tests?" That's fine; the matrix shows the requirement's status as the worst status among its tests.
// Java: вимога як тег JUnit5 і посилання на тест-кейс у TMS
@Test
@Tag("REQ-142")
@TmsLink("TC-311")
@Story("Refund within 14 days")
void refundAllowedWithin14Days() { /* ... */ }
// Playwright: тег і анотація в опціях тесту
test('refund within 14 days', {
tag: '@REQ-142',
annotation: { type: 'requirement', description: 'REQ-142' },
}, async ({ request }) => { /* ... */ });
// запуск тестів лише однієї вимоги:
// mvn test -Dgroups=REQ-142 | npx playwright test --grep @REQ-142
// Java: the requirement as a JUnit5 tag and a link to the TMS test case
@Test
@Tag("REQ-142")
@TmsLink("TC-311")
@Story("Refund within 14 days")
void refundAllowedWithin14Days() { /* ... */ }
// Playwright: a tag and an annotation in the test options
test('refund within 14 days', {
tag: '@REQ-142',
annotation: { type: 'requirement', description: 'REQ-142' },
}, async ({ request }) => { /* ... */ });
// run only the tests of one requirement:
// mvn test -Dgroups=REQ-142 | npx playwright test --grep @REQ-142
@TmsLink перетворюється на клікабельне посилання в Allure, якщо в allure.properties задано шаблон.@TmsLink becomes a clickable link in Allure once a pattern is set in allure.properties.Що це — стратегія складається з чотирьох кроків: виявити, ізолювати, виправити, виміряти. Мета — щоб PR-гейт лишався довіреним сигналом: коли він червоний, люди мають шукати баг, а не тиснути «rerun».
What it is — the strategy has four steps: detect, isolate, fix, measure. The goal is to keep the PR gate a trusted signal: when it is red, people should look for a bug rather than press "rerun".
Коли і трейдоф — виявлення: тест, що на тому самому коміті то падає, то проходить, позначається як flaky (дані з історії Allure, статус «flaky» у Playwright або нічний прогін кожного тесту 20 разів). Ізоляція: тег quarantine прибирає тест з обов'язкового гейту, але він далі йде в нічному прогоні. Кожен карантин має власника, тікет і дедлайн, інакше карантин стає кладовищем. Виправлення за категоріями причин: синхронізація (відсутні очікування), дані (спільний користувач), порядок (залежність від іншого тесту), середовище (повільний стенд, нестабільний сторонній сервіс). Retry — лише тимчасова пов'язка, а не рішення.
When and trade-off — detection: a test that fails and passes on the same commit is marked flaky (Allure history data, Playwright's "flaky" status, or a nightly run of each test 20 times). Isolation: a quarantine tag removes the test from the required gate, but it still runs nightly. Every quarantined test has an owner, a ticket and a deadline, otherwise quarantine becomes a graveyard. Fixing goes by root-cause category: synchronisation (missing waits), data (a shared user), order (depends on another test), environment (slow stand, unstable third-party service). Retry is only a temporary bandage, not a solution.
Приклад — метрики: flaky rate (частка прогонів з нестабільними падіннями, ціль менше 1–2%), кількість тестів у карантині, середній час до виправлення, частка «зелених після rerun» у PR.
Example — metrics: flaky rate (share of runs with unstable failures, target under 1–2%), number of tests in quarantine, mean time to fix, share of PRs that went "green after rerun".
Підводні камені — карантинити тест, який насправді ловить гонку в продукті, і так приховати реальний баг. Тому перед карантином хтось дивиться trace і логи.
Pitfalls — quarantining a test that actually catches a race condition in the product and thereby hiding a real bug. So before quarantine someone looks at the trace and logs.
Уточнення на співбесіді — «Коли тест видаляти?» Якщо він місяць у карантині, ніхто не взявся і сценарій покритий на нижчому рівні — видаляю свідомо, з записом у тікеті.
Interview follow-up — "When do you delete a test?" If it has sat in quarantine for a month, nobody picked it up and the scenario is covered at a lower level — I delete it deliberately, with a note in the ticket.
// QA-512, власник: @olena, дедлайн 2026-10-15
@Tag("quarantine")
@Test
void checkoutShowsDeliveryDate() { /* ... */ }
# .github/workflows/tests.yml
jobs:
pr-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# обов'язкова перевірка — без карантину
- run: mvn -B test -DexcludedGroups=quarantine
quarantine-nightly:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# карантин ганяємо вночі, збираємо історію
- run: mvn -B test -Dgroups=quarantine
// QA-512, owner: @olena, deadline 2026-10-15
@Tag("quarantine")
@Test
void checkoutShowsDeliveryDate() { /* ... */ }
# .github/workflows/tests.yml
jobs:
pr-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# required check — quarantine excluded
- run: mvn -B test -DexcludedGroups=quarantine
quarantine-nightly:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# quarantine runs nightly to build up history
- run: mvn -B test -Dgroups=quarantine
groups і excludedGroups. Коментар над тестом — мінімальний «паспорт» карантину: тікет, власник, дедлайн.groups and excludedGroups. The comment above the test is the minimal quarantine "passport": ticket, owner, deadline.Що це — retry автоматично перезапускає тест після падіння. У Maven це -Dsurefire.rerunFailingTestsCount=2 (працює і з JUnit5), у JUnit Pioneer — анотація @RetryingTest(3), у TestNG — IRetryAnalyzer, у Playwright — retries у конфігу.
What it is — a retry automatically reruns a test after it fails. In Maven that is -Dsurefire.rerunFailingTestsCount=2 (works with JUnit5 too), in JUnit Pioneer the @RetryingTest(3) annotation, in TestNG an IRetryAnalyzer, in Playwright retries in the config.
Навіщо — у CI бувають збої, не пов'язані з кодом: мережа, холодний старт контейнера, тимчасово недоступний стенд. Один повтор не дає такому шуму заблокувати злиття. Важливо, що нормальні інструменти не приховують повтор: Surefire пише тест як flaky у звіті, Playwright показує статус «flaky», а не «passed».
Why — CI has failures unrelated to code: network, a container cold start, a temporarily unavailable environment. One retry stops that noise from blocking a merge. Importantly, good tools do not hide the retry: Surefire records the test as flaky in its report, and Playwright shows the status "flaky", not "passed".
Приклад — мої правила: retries лише в CI (process.env.CI ? 2 : 0), локально — нуль, щоб розробник бачив проблему одразу; не більше двох повторів; кожен flaky-результат потрапляє у звіт і метрику. У Playwright я вмикаю trace: 'on-first-retry', тож перший повтор одразу дає trace для розбору.
Example — my rules: retries only in CI (process.env.CI ? 2 : 0), zero locally so a developer sees the problem immediately; no more than two retries; every flaky result goes into the report and the metric. In Playwright I enable trace: 'on-first-retry', so the first retry immediately produces a trace to investigate.
Підводні камені — retry маскує справжні гонки в продукті: якщо подвійний клік раз на десять створює два замовлення, тест із повторами буде зеленим, а користувачі — ні. Друга пастка — retry тестів, що мають побічні ефекти: другий запуск стартує з брудних даних першого.
Pitfalls — retries mask genuine races in the product: if a double click creates two orders one time in ten, a test with retries stays green while users do not. The second trap is retrying tests with side effects: the second attempt starts from the dirty data left by the first.
Уточнення на співбесіді — «Retry на весь набір чи на окремі тести?» Глобально в CI з лімітом 1–2, але з дашбордом flaky; анотацію на конкретний тест вважаю запахом.
Interview follow-up — "Retry the whole suite or individual tests?" Globally in CI with a limit of 1–2 but backed by a flaky dashboard; an annotation on a specific test I treat as a smell.
# Maven: до 2 повторів, у звіті тест позначиться як flaky
mvn -B test -Dsurefire.rerunFailingTestsCount=2
// JUnit Pioneer: точковий повтор (краще уникати)
@RetryingTest(maxAttempts = 3)
void uploadsLargeFile() { /* ... */ }
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0, // локально без повторів
use: { trace: 'on-first-retry' }, // trace з першого повтору
reporter: [['html'], ['junit', { outputFile: 'results.xml' }]],
});
# Maven: up to 2 reruns, the report marks the test as flaky
mvn -B test -Dsurefire.rerunFailingTestsCount=2
// JUnit Pioneer: a targeted retry (better avoided)
@RetryingTest(maxAttempts = 3)
void uploadsLargeFile() { /* ... */ }
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0, // no retries locally
use: { trace: 'on-first-retry' }, // trace from the first retry
reporter: [['html'], ['junit', { outputFile: 'results.xml' }]],
});
CI. on-first-retry записує trace тільки для тестів, що вже впали, — це дешево і дає матеріал для розбору.CI variable is set. on-first-retry records a trace only for tests that have already failed — cheap, and it gives you material to investigate.Що це — JUnit Jupiter вміє виконувати тести в пулі потоків в одному JVM. Вмикається це у файлі src/test/resources/junit-platform.properties: прапорець parallel.enabled, режим за замовчуванням для методів і класів (same_thread або concurrent) і стратегія розміру пулу (dynamic від кількості ядер або fixed).
What it is — JUnit Jupiter can run tests in a thread pool within one JVM. You enable it in src/test/resources/junit-platform.properties: the parallel.enabled flag, the default mode for methods and classes (same_thread or concurrent) and the pool sizing strategy (dynamic based on cores, or fixed).
Коли і трейдоф — альтернатива — паралелізм процесами через Surefire forkCount: кожен форк — окремий JVM, статичні поля не перетинаються, але старт дорожчий і пам'яті треба більше. Потоки дешевші, зате вимагають thread-safe фреймворку: драйвер у ThreadLocal, жодних статичних змінних стану, унікальні тестові дані. Для тестів, які не можна розпаралелити, є @Execution(SAME_THREAD), а для доступу до спільного ресурсу (feature-флаг, системна властивість, один тестовий акаунт) — @ResourceLock: JUnit не запустить одночасно два тести з READ_WRITE-замком на той самий ключ.
When and trade-off — the alternative is process parallelism via Surefire forkCount: each fork is a separate JVM, static fields don't collide, but startup costs more and you need more memory. Threads are cheaper but require a thread-safe framework: the driver in a ThreadLocal, no static state, unique test data. For tests that cannot run in parallel there is @Execution(SAME_THREAD), and for access to a shared resource (a feature flag, a system property, a single test account) there is @ResourceLock: JUnit will not run two tests holding a READ_WRITE lock on the same key at the same time.
Приклад — для UI-набору я ставлю fixed з паралелізмом 4, бо кожен потік тримає браузер, а на CI-агенті 8 ГБ пам'яті; для API-тестів — dynamic.
Example — for a UI suite I use fixed with parallelism 4, since each thread holds a browser and the CI agent has 8 GB of memory; for API tests — dynamic.
Підводні камені — увімкнути паралельність у фреймворку, де драйвер статичний, і отримати тести, що клікають у чужих браузерах. Ще одна пастка — @BeforeAll зі спільним станом при concurrent для методів одного класу.
Pitfalls — enabling parallelism in a framework with a static driver and getting tests that click in each other's browsers. Another trap is @BeforeAll with shared state when methods of one class run concurrent.
Уточнення на співбесіді — «Як мігрувати поступово?» Спершу mode.classes.default=concurrent при методах у same_thread: класи йдуть паралельно, а всередині класу — послідовно.
Interview follow-up — "How do you migrate gradually?" Start with mode.classes.default=concurrent while methods stay same_thread: classes run in parallel, methods within a class run sequentially.
# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled = true
# класи паралельно, методи всередині класу — послідовно
junit.jupiter.execution.parallel.mode.default = same_thread
junit.jupiter.execution.parallel.mode.classes.default = concurrent
junit.jupiter.execution.parallel.config.strategy = fixed
junit.jupiter.execution.parallel.config.fixed.parallelism = 4
// експорт пише в одну папку — лише послідовно
@Execution(ExecutionMode.SAME_THREAD)
class ExportTests { /* ... */ }
// тести, що перемикають спільний feature-флаг, не перетнуться
@Test
@ResourceLock(value = "feature-flags", mode = ResourceAccessMode.READ_WRITE)
void newCheckoutBehindFlag() { /* ... */ }
# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled = true
# classes in parallel, methods inside a class sequentially
junit.jupiter.execution.parallel.mode.default = same_thread
junit.jupiter.execution.parallel.mode.classes.default = concurrent
junit.jupiter.execution.parallel.config.strategy = fixed
junit.jupiter.execution.parallel.config.fixed.parallelism = 4
// export writes into one folder — sequential only
@Execution(ExecutionMode.SAME_THREAD)
class ExportTests { /* ... */ }
// tests toggling a shared feature flag will never overlap
@Test
@ResourceLock(value = "feature-flags", mode = ResourceAccessMode.READ_WRITE)
void newCheckoutBehindFlag() { /* ... */ }
mode.default = same_thread і mode.classes.default = concurrent — найбезпечніший старт. @ResourceLock з однаковим ключем серіалізує лише тести, що ділять ресурс, решта йде паралельно.mode.default = same_thread with mode.classes.default = concurrent is the safest start. @ResourceLock with the same key serialises only the tests sharing that resource; the rest still run in parallel.Що це — шардинг ділить набір на N частин, і кожна виконується на окремій машині (джобі) одночасно. Це наступний рівень після паралельних потоків: одна машина впирається в CPU і пам'ять, а шарди масштабуються горизонтально. Час прогону наближається до «загальний час / N плюс накладні витрати на старт».
What it is — sharding splits the suite into N parts, each running on a separate machine (job) at the same time. It's the next level after parallel threads: one machine hits its CPU and memory limits, while shards scale horizontally. Run time approaches "total time / N plus startup overhead".
Коли і трейдоф — має сенс, коли набір іде довше 15–20 хвилин і вже розпаралелений на одній машині. У Playwright вбудовано --shard=1/4 і blob-репортер: кожен шард вивантажує blob-report як артефакт, а окрема джоба запускає merge-reports. У Java немає стандартного прапорця, тому ділять за тегами, пакетами або за історією тривалості (щоб шарди були рівні за часом, а не за кількістю тестів); Allure-результати з усіх джоб просто зливають в одну папку перед allure generate. Трейдоф — більше хвилин CI і складніший конвеєр.
When and trade-off — it pays off when the suite takes over 15–20 minutes and is already parallelised on one machine. Playwright has built-in --shard=1/4 and a blob reporter: each shard uploads its blob-report as an artifact, and a separate job runs merge-reports. Java has no standard flag, so you split by tags, packages or by duration history (so shards are balanced by time, not test count); Allure results from all jobs are simply merged into one folder before allure generate. The trade-off is more CI minutes and a more complex pipeline.
Приклад — 40-хвилинний E2E-набір на чотирьох шардах пройшов за 12 хвилин разом зі встановленням браузерів.
Example — a 40-minute E2E suite on four shards finished in 12 minutes, browser installation included.
Підводні камені — fail-fast у матриці скасовує інші шарди після першого падіння, і звіт виходить неповним. Шарди також б'ють по одному стенду одночасно, тому тестові дані мають бути ізольовані.
Pitfalls — fail-fast in the matrix cancels the other shards after the first failure, leaving an incomplete report. Shards also hit the same environment simultaneously, so test data must be isolated.
Уточнення на співбесіді — «Скільки шардів?» Доти, доки накладні витрати на старт не з'їдають виграш; зазвичай 4–8.
Interview follow-up — "How many shards?" Until startup overhead eats the gain; usually 4–8.
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false # не зупиняти інші шарди
matrix: { shard: [1, 2, 3, 4] }
steps:
- uses: actions/checkout@v4
- run: npm ci && npx playwright install --with-deps
# у конфігу для CI: reporter 'blob'
- run: npx playwright test --shard=${{ matrix.shard }}/4
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with: { name: blob-${{ matrix.shard }}, path: blob-report }
merge:
needs: e2e
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- uses: actions/download-artifact@v4
with: { pattern: blob-*, path: all-blobs, merge-multiple: true }
# один HTML-звіт з усіх шардів
- run: npx playwright merge-reports --reporter html ./all-blobs
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false # don't stop the other shards
matrix: { shard: [1, 2, 3, 4] }
steps:
- uses: actions/checkout@v4
- run: npm ci && npx playwright install --with-deps
# the CI config uses reporter 'blob'
- run: npx playwright test --shard=${{ matrix.shard }}/4
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with: { name: blob-${{ matrix.shard }}, path: blob-report }
merge:
needs: e2e
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- uses: actions/download-artifact@v4
with: { pattern: blob-*, path: all-blobs, merge-multiple: true }
# one HTML report from all shards
- run: npx playwright merge-reports --reporter html ./all-blobs
if: !cancelled() гарантує, що артефакти й злиття виконаються навіть після падіння тестів.if: !cancelled() ensures artifacts and the merge still run after test failures.Що це — Тріаж падіння — це послідовне з'ясування, чому прогін став червоним і хто має це виправити. Кожне падіння я кладу в одну з чотирьох корзин: дефект продукту; дефект тесту (застарілий локатор, неправильне очікування, залежність від порядку); середовище чи інфраструктура (стенд лежить, закінчився диск на агенті, недоступний сторонній сервіс); тестові дані (хтось видалив акаунт, зіпсований seed).
What it is — Failure triage is the methodical work of finding out why a run went red and who should fix it. I put every failure into one of four buckets: a product defect; a test defect (stale locator, wrong wait, order dependency); environment or infrastructure (the stand is down, the agent ran out of disk, a third-party service is unreachable); test data (someone deleted the account, the seed is corrupted).
Навіщо — Без класифікації команда звикає до червоного кольору («та це знову стенд»), і справжній баг тихо йде в реліз. Корзини живлять метрики: якщо 60% падінь за місяць — інфраструктура, гроші треба вкладати в стенд, а не переписувати тести.
Why — Without classification the team gets used to red ("it's the stand again") and a real bug quietly ships. The buckets also feed metrics: if 60% of last month's failures were infrastructure, the investment belongs in the environment, not in rewriting tests.
Приклад — Нічна регресія: 14 червоних тестів. Спершу групую за повідомленням помилки: у 12 однаковий Connection refused до payment-service — це одна задача на середовище, а не 12 розслідувань. Два інші падають на сумі знижки. Беру останній зелений і перший червоний коміти, дивлюся git log між ними — змінили правило округлення. Запускаю тест локально на тому ж коміті, відтворюється стабільно — отже, дефект продукту з посиланням на прогін.
Example — Nightly regression: 14 red tests. First I group by error message: 12 share the same Connection refused to payment-service — one environment ticket, not 12 investigations. The other two fail on a discount amount. I take the last green and first red commits and read git log between them — the rounding rule changed. I run the test locally on that commit, it reproduces every time, so it is a product defect linked to the run.
Підводні камені — Перезапускати весь пайплайн, доки не позеленіє; розбирати тест за тестом замість групування; не мати артефактів (скріншот, trace, лог HTTP-запиту), через що тріаж перетворюється на здогадки.
Pitfalls — Re-running the whole pipeline until it turns green; going test by test instead of grouping; having no artefacts (screenshot, trace, HTTP log), which turns triage into guesswork.
Уточнення на співбесіді — «Хто відповідає за тріаж?» Я пропоную тижневу ротацію чергового і правило: нічний прогін розібрано до стендапу, кожне падіння має мітку корзини й власника.
Interview follow-up — "Who owns triage?" I suggest a weekly on-call rotation and a rule: the nightly run is triaged before stand-up, and every failure gets a bucket label and an owner.
# 1. Які тести впали і з якими помилками — групуємо однакові
gh run view 9123456 --log-failed | grep -E "FAILED|Exception" | sort | uniq -c
# 2. Що змінилось між останнім зеленим і першим червоним прогоном
git log --oneline a1b2c3d..e4f5a6b -- src/
# 3. Відтворюємо один тест локально на тому ж коміті
git checkout e4f5a6b
mvn -Dtest=CheckoutDiscountTest#appliesRounding test
# 4. Чи живий стенд узагалі
curl -s -o /dev/null -w "%{http_code}" https://staging.example.com/health
# 1. Which tests failed and with which errors — group identical ones
gh run view 9123456 --log-failed | grep -E "FAILED|Exception" | sort | uniq -c
# 2. What changed between the last green and the first red run
git log --oneline a1b2c3d..e4f5a6b -- src/
# 3. Reproduce one test locally on the same commit
git checkout e4f5a6b
mvn -Dtest=CheckoutDiscountTest#appliesRounding test
# 4. Is the stand alive at all
curl -s -o /dev/null -w "%{http_code}" https://staging.example.com/health
--log-failed показує лише логи впалих кроків, а uniq -c одразу видає, скільки тестів мають ту саму причину. Діапазон a1b2c3d..e4f5a6b звужує пошук до комітів між зеленим і червоним прогонами.--log-failed prints only the logs of failed steps, and uniq -c tells you at once how many tests share a cause. The range a1b2c3d..e4f5a6b narrows the search to commits between the green and the red run.Що це — Коли автотест знаходить дефект продукту, баг-репорт пишуть так, щоб розробник зміг відтворити проблему без запуску нашого фреймворку. Автотест — лише спосіб виявлення, а репорт описує поведінку системи: кроки користувача чи API-виклики, очікуваний і фактичний результат.
What it is — When an automated test finds a product defect, the bug report is written so a developer can reproduce it without running our framework. The test is only how we detected it; the report describes system behaviour: user steps or API calls, expected and actual result.
Навіщо — Репорт на кшталт «впав тест CheckoutTest, дивіться CI» повертають із питаннями. Порівняно з ручним багом тут є козир — готові докази: скріншот, Playwright trace, лог запиту й відповіді, точний коміт і збірка.
Why — A report like "CheckoutTest failed, see CI" bounces back with questions. Compared with a manual bug there is an advantage: evidence already exists — screenshot, Playwright trace, request and response log, the exact commit and build.
Приклад — Обов'язкові поля: заголовок про симптом («Знижка 10% округлюється вниз до цілої гривні»), середовище, збірка й коміт, кроки, переписані з кроків тесту людською мовою, очікуване/фактичне, вкладення, посилання на прогін і на сам тест, частота (стабільно чи 2 з 10 запусків). Якщо дефект знайдено на API, додаю готову curl-команду.
Example — Required fields: a symptom title ("10% discount is rounded down to a whole unit"), environment, build and commit, steps rewritten in plain language from the test steps, expected/actual, attachments, links to the run and to the test itself, frequency (always or 2 out of 10 runs). If the defect was found through the API, I add a ready curl command.
Підводні камені — Вставляти стектрейс замість опису поведінки; писати в заголовок назву тесту; заводити баг на падіння, яке насправді є проблемою тесту чи стенда; забути про частоту, через що розробник закриває баг як «не відтворюється».
Pitfalls — Pasting a stack trace instead of describing behaviour; putting the test name in the title; filing a bug for a failure that is really a test or stand problem; omitting frequency, so the developer closes it as "cannot reproduce".
Уточнення на співбесіді — «Що робите з тестом, поки баг відкритий?» Позначаю його посиланням на баг (@Issue в Allure) і, якщо баг не блокер, тимчасово вимикаю з посиланням на тікет, щоб він не маскував нові падіння.
Interview follow-up — "What do you do with the test while the bug is open?" I link it to the bug (@Issue in Allure) and, if it is not a blocker, disable it temporarily with the ticket reference so it doesn't hide new failures.
Заголовок: Знижка 10% округлюється вниз до цілої гривні в кошику
Середовище: staging, збірка 2.14.0-rc3, коміт e4f5a6b, Chrome 129
Частота: стабільно, 10 з 10 запусків
Кроки:
1. Додати в кошик товар за 199.90
2. Застосувати промокод SAVE10
Очікувано: сума 179.91
Фактично: сума 179.00
Докази: screenshot.png, trace.zip, лог POST /api/cart/promo
API-репро: curl -X POST https://staging.example.com/api/cart/promo -d '{"code":"SAVE10"}'
Прогін: https://ci.example.com/runs/9123456
Тест: CheckoutDiscountTest#appliesRounding
Title: 10% discount is rounded down to a whole unit in the cart
Environment: staging, build 2.14.0-rc3, commit e4f5a6b, Chrome 129
Frequency: always, 10 out of 10 runs
Steps:
1. Add an item priced 199.90 to the cart
2. Apply promo code SAVE10
Expected: total 179.91
Actual: total 179.00
Evidence: screenshot.png, trace.zip, log of POST /api/cart/promo
API repro: curl -X POST https://staging.example.com/api/cart/promo -d '{"code":"SAVE10"}'
Run: https://ci.example.com/runs/9123456
Test: CheckoutDiscountTest#appliesRounding
Що це — Стадійність означає, що різні набори тестів запускаються за різними подіями й з різним бюджетом часу. Типова схема: на кожен PR — лінтер, unit, API-тести і короткий UI smoke (разом до 10–15 хвилин); після злиття в main — ширший набір; уночі — повна регресія на всіх браузерах; після деплою — smoke на реальному середовищі.
What it is — Staging means different test sets run on different events with different time budgets. A typical layout: every PR gets lint, unit, API tests and a short UI smoke (10–15 minutes in total); after merge to main a wider set runs; at night the full regression across all browsers; after deploy a smoke against the real environment.
Навіщо — Розробник чекатиме на PR-гейт хвилин десять, але не годину: якщо гейт повільний, його почнуть обходити. Водночас повна регресія мусить десь жити, і ніч — дешеве вікно. Принцип fail fast: дешеві й швидкі перевірки йдуть першими, дорогі стартують лише тоді, коли дешеві зелені.
Why — A developer will wait ten minutes for a PR gate, not an hour; a slow gate gets bypassed. The full regression still needs a home, and the night is a cheap window. The fail-fast principle: cheap, quick checks go first, expensive ones start only when the cheap ones are green.
Приклад — У GitHub Actions я розділяю набори JUnit5-тегами (@Tag("smoke"), @Tag("regression")) і обираю їх через -Dgroups у Surefire. Залежність needs: зупиняє UI smoke, якщо впали API-тести, а умова if: з github.event_name відрізняє PR від нічного запуску за schedule.
Example — In GitHub Actions I split the sets with JUnit5 tags (@Tag("smoke"), @Tag("regression")) and select them with -Dgroups in Surefire. The needs: dependency stops the UI smoke when API tests fail, and an if: on github.event_name tells a PR apart from the nightly schedule run.
Підводні камені — Нічна регресія, яку ніхто не читає; smoke, що розрісся до 300 тестів; відсутність стадії після деплою, через що зламану конфігурацію продакшену ловлять користувачі.
Pitfalls — A nightly regression nobody reads; a smoke that has grown to 300 tests; no post-deploy stage, so broken production configuration is caught by users.
Уточнення на співбесіді — «Як вирішуєте, що потрапляє в smoke?» Критичні бізнес-потоки (логін, пошук, оплата) і тести, що найчастіше ловили реальні регресії, з жорстким лімітом часу.
Interview follow-up — "How do you decide what goes into smoke?" Critical business flows (login, search, payment) plus the tests that caught real regressions most often, under a hard time limit.
name: tests
on:
pull_request:
schedule:
- cron: '0 2 * * *' # повна регресія о 02:00 UTC
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21', cache: maven }
- run: mvn -B verify -Dgroups=unit # лінтер + unit, ~3 хв
api:
needs: unit # не стартує, якщо unit червоний
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21', cache: maven }
- run: mvn -B test -Dgroups=api
ui:
needs: api
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21', cache: maven }
- if: github.event_name == 'pull_request'
run: mvn -B test -Dgroups=smoke # короткий smoke для PR
- if: github.event_name == 'schedule'
run: mvn -B test -Dgroups=regression # уночі — усе
name: tests
on:
pull_request:
schedule:
- cron: '0 2 * * *' # full regression at 02:00 UTC
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21', cache: maven }
- run: mvn -B verify -Dgroups=unit # lint + unit, ~3 min
api:
needs: unit # does not start if unit is red
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21', cache: maven }
- run: mvn -B test -Dgroups=api
ui:
needs: api
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21', cache: maven }
- if: github.event_name == 'pull_request'
run: mvn -B test -Dgroups=smoke # short smoke for a PR
- if: github.event_name == 'schedule'
run: mvn -B test -Dgroups=regression # everything at night
needs будує ланцюжок unit → api → ui, тому дорогий UI-джоб не витрачає хвилини, коли вже впав дешевший. Surefire передає -Dgroups у JUnit Platform як фільтр тегів.needs chains unit → api → ui, so the expensive UI job doesn't burn minutes once a cheaper one has failed. Surefire passes -Dgroups to the JUnit Platform as a tag filter.Що це — UI-тести в CI запускають у контейнерах, щоб версії браузера, драйвера й системних бібліотек були однакові на ноутбуці й на агенті. Два поширені варіанти: для Selenium — контейнер selenium/standalone-chrome (або Grid) плюс контейнер з тестами, що підключається через RemoteWebDriver; для Playwright — офіційний образ mcr.microsoft.com/playwright, де вже є браузери потрібної версії.
What it is — UI tests on CI run in containers so the browser, driver and system library versions are identical on a laptop and on the agent. Two common setups: for Selenium, a selenium/standalone-chrome container (or a Grid) plus a test container that connects via RemoteWebDriver; for Playwright, the official mcr.microsoft.com/playwright image, which already ships browsers of the right version.
Навіщо — Chrome активно використовує спільну пам'ять /dev/shm, а Docker за замовчуванням дає лише 64 МБ. На важких сторінках вкладка падає з «session deleted because of page crash» — і це виглядає як flaky-тест. Тому Selenium-контейнеру дають shm_size: 2gb, а для Playwright документація радить --ipc=host.
Why — Chrome relies heavily on shared memory in /dev/shm, and Docker gives only 64 MB by default. On heavy pages the tab dies with "session deleted because of page crash", which looks exactly like a flaky test. That is why the Selenium container gets shm_size: 2gb, and the Playwright docs recommend --ipc=host.
Приклад — У docker compose піднімаю застосунок із healthcheck, Chrome-контейнер і контейнер з Maven. Тести стартують лише після того, як застосунок здоровий, отримують BASE_URL і адресу браузера через змінні середовища, а звіти потрапляють назовні через volume і потім вантажаться як артефакт CI.
Example — In docker compose I bring up the app with a healthcheck, a Chrome container and a Maven container. Tests start only after the app is healthy, get BASE_URL and the browser address from environment variables, and reports reach the host through a volume to be uploaded as a CI artefact.
Підводні камені — Тег latest замість зафіксованої версії; версія образу Playwright, що не збігається з версією @playwright/test у package.json; localhost у BASE_URL, хоча всередині мережі compose застосунок доступний за ім'ям сервісу.
Pitfalls — Using the latest tag instead of a pinned version; a Playwright image version that differs from @playwright/test in package.json; localhost in BASE_URL although inside the compose network the app is reachable by its service name.
Уточнення на співбесіді — «Headless чи headed у CI?» Headless: швидше й не потребує дисплея; для розбору падінь покладаюся на скріншоти, відео чи trace, а не на спостереження за вікном.
Interview follow-up — "Headless or headed on CI?" Headless: faster and needs no display; to debug failures I rely on screenshots, video or trace rather than watching a window.
services:
app:
image: registry.example.com/shop:2.14.0
healthcheck: # в образі має бути curl
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 5s
retries: 20
chrome:
image: selenium/standalone-chrome:4.25.0 # версію фіксуємо
shm_size: 2gb # інакше вкладка падає на важких сторінках
tests:
image: maven:3.9-eclipse-temurin-21
depends_on:
app: { condition: service_healthy }
chrome: { condition: service_started }
environment:
BASE_URL: http://app:8080 # ім'я сервісу, не localhost
REMOTE_URL: http://chrome:4444
working_dir: /work
volumes: [".:/work"] # target/ зі звітами лишається на хості
command: mvn -B test -Dgroups=ui
# Playwright: образ тієї ж версії, що й @playwright/test
# docker run --rm --ipc=host -v "$PWD":/work -w /work \
# mcr.microsoft.com/playwright:v1.48.0-noble npx playwright test
services:
app:
image: registry.example.com/shop:2.14.0
healthcheck: # the image must contain curl
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 5s
retries: 20
chrome:
image: selenium/standalone-chrome:4.25.0 # pin the version
shm_size: 2gb # otherwise the tab crashes on heavy pages
tests:
image: maven:3.9-eclipse-temurin-21
depends_on:
app: { condition: service_healthy }
chrome: { condition: service_started }
environment:
BASE_URL: http://app:8080 # service name, not localhost
REMOTE_URL: http://chrome:4444
working_dir: /work
volumes: [".:/work"] # target/ with reports stays on the host
command: mvn -B test -Dgroups=ui
# Playwright: an image of the same version as @playwright/test
# docker run --rm --ipc=host -v "$PWD":/work -w /work \
# mcr.microsoft.com/playwright:v1.48.0-noble npx playwright test
condition: service_healthy змушує тести чекати, поки healthcheck застосунку стане зеленим, а не просто поки контейнер запуститься. shm_size і --ipc=host вирішують одну й ту саму проблему нестачі спільної пам'яті.condition: service_healthy makes the tests wait until the app healthcheck passes, not merely until the container starts. shm_size and --ipc=host solve the same shared-memory shortage.shm_size падіння зникли. Відтоді я завжди фіксую версії образів, у compose чекаю на healthcheck застосунку, а не на старт контейнера, і стежу, щоб образ Playwright збігався з версією пакета.shm_size the failures disappeared. Since then I always pin image versions, make compose wait for the app healthcheck rather than just the container start, and keep the Playwright image in line with the package version.Що це — Shift-left для автоматизації — це перенесення перевірок якомога ближче до моменту написання коду: частина спрацьовує ще на комп'ютері автора (pre-commit хуки: форматування, лінтер, пошук секретів), частина — як обов'язкові перевірки PR, без яких GitHub чи GitLab не дасть злити гілку (збірка, тести, quality gate SonarQube на новому коді).
What it is — Shift-left for automation means moving checks as close as possible to the moment code is written: some run on the author's machine (pre-commit hooks: formatting, linter, secret scanning), others as required PR checks without which GitHub or GitLab won't allow a merge (build, tests, SonarQube quality gate on new code).
Навіщо — Чим раніше знайдено проблему, тим дешевше її виправити: зайвий пробіл, закомічений токен чи Thread.sleep краще зловити за дві секунди локально, ніж через двадцять хвилин у CI або на рев'ю. До того ж рев'юер перестає витрачати увагу на стиль і дивиться на логіку тесту.
Why — The earlier a problem is found, the cheaper the fix: a stray space, a committed token or a Thread.sleep is better caught in two seconds locally than twenty minutes later in CI or in review. Reviewers also stop spending attention on style and look at test logic instead.
Приклад — У Java-фреймворку тестів я ставлю фреймворк pre-commit з хуками detect-private-key, check-merge-conflict і локальним spotless:check. У TS-проєкті те саме роблять husky і lint-staged з ESLint, включно з правилом playwright/no-wait-for-timeout. У CI той самий набір повторюється (хуки можна пропустити через --no-verify), а branch protection робить ці джоби обов'язковими.
Example — In a Java test framework I install the pre-commit framework with the detect-private-key and check-merge-conflict hooks plus a local spotless:check. In a TS project husky and lint-staged with ESLint do the same, including the playwright/no-wait-for-timeout rule. CI repeats the same set (hooks can be skipped with --no-verify), and branch protection makes those jobs required.
Підводні камені — Важкі хуки, що ганяють увесь набір тестів на кожен коміт, — люди їх вимикають; quality gate на всьому коді замість нового, через що старий борг блокує кожен PR; хуки без дублювання в CI.
Pitfalls — Heavy hooks that run the whole suite on every commit, which people then disable; a quality gate on all code rather than new code, so old debt blocks every PR; hooks without the same checks in CI.
Уточнення на співбесіді — «А тести пишуться теж зліва?» Так: автотест на історію — частина Definition of Done, тож він приходить в тому ж PR, що й фіча, а не спринтом пізніше.
Interview follow-up — "Are tests shifted left too?" Yes: the automated test for a story is part of the Definition of Done, so it arrives in the same PR as the feature, not a sprint later.
# .pre-commit-config.yaml — запускається при кожному git commit
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: check-merge-conflict
- id: detect-private-key # ключ не потрапить у репозиторій
- repo: local
hooks:
- id: spotless
name: spotless check
entry: mvn -q spotless:check
language: system
pass_filenames: false
files: \.java$ # лише коли змінено Java-файли
# Крок у CI: PR червоний, якщо quality gate на новому коді не пройдено
- run: mvn -B verify sonar:sonar -Dsonar.qualitygate.wait=true
# .pre-commit-config.yaml — runs on every git commit
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: check-merge-conflict
- id: detect-private-key # a key never reaches the repo
- repo: local
hooks:
- id: spotless
name: spotless check
entry: mvn -q spotless:check
language: system
pass_filenames: false
files: \.java$ # only when Java files changed
# CI step: the PR is red if the quality gate on new code fails
- run: mvn -B verify sonar:sonar -Dsonar.qualitygate.wait=true
pass_filenames: false запускає Spotless один раз для проєкту, а не для кожного файлу окремо. sonar.qualitygate.wait=true змушує Maven дочекатися вердикту SonarQube і завершитися з помилкою, якщо гейт червоний.pass_filenames: false runs Spotless once for the project rather than per file. sonar.qualitygate.wait=true makes Maven wait for the SonarQube verdict and fail if the gate is red.Що це — Рев'ю тестового коду відрізняється від рев'ю продакшен-коду: головне питання не «чи працює», а «чи впаде цей тест тоді й лише тоді, коли зламається перевірювана поведінка». Мій чекліст: назва описує поведінку; одна причина падіння; немає Thread.sleep; немає зашитих URL, паролів і спільних акаунтів; локатори за домовленою політикою; змістовні асерти без if і try/catch, що ковтають помилки; тест прибирає за собою й може йти паралельно.
What it is — Reviewing test code differs from reviewing production code: the key question isn't "does it work" but "will this test fail when, and only when, the behaviour under test breaks". My checklist: the name describes behaviour; one reason to fail; no Thread.sleep; no hardcoded URLs, passwords or shared accounts; locators follow the agreed policy; meaningful assertions without an if or a try/catch that swallows errors; the test cleans up and can run in parallel.
Навіщо — Поганий тест гірший за відсутній: він або дає хибне відчуття безпеки (не може впасти), або стає flaky і привчає команду ігнорувати червоне. Рев'ю — найдешевше місце, щоб це зупинити.
Why — A bad test is worse than none: it either gives false confidence (it cannot fail) or turns flaky and teaches the team to ignore red. Review is the cheapest place to stop that.
Приклад — Найпідступніша знахідка, яку я бачу найчастіше, — тест без справжнього асерта: перевірка через if з System.out.println, що завжди зелена. Прошу автора свідомо зламати продукт (або підмінити очікуване значення) і показати, що тест червоніє. Другий частий пункт — логін під спільним admin, що ламає паралельний запуск.
Example — The sneakiest finding, and the one I see most, is a test with no real assertion: a check via if with System.out.println that is always green. I ask the author to break the product deliberately (or change the expected value) and show the test goes red. The second frequent item is logging in as a shared admin, which breaks parallel runs.
Підводні камені — Рев'ювити лише стиль, бо «це ж тести»; приймати PR, де тест жодного разу не запускали в CI; не дивитися, як зміна впливає на час прогону.
Pitfalls — Reviewing only style because "they're just tests"; approving a PR whose test has never run in CI; ignoring how the change affects suite duration.
Уточнення на співбесіді — «Хто рев'ює тести?» Ідеально — і QA, і розробник фічі: розробник бачить, чи тест перевіряє правильне правило, а QA — чи тест стабільний і вписується у фреймворк.
Interview follow-up — "Who reviews tests?" Ideally both a QA and the feature developer: the developer sees whether the right rule is checked, the QA whether the test is stable and fits the framework.
// До рев'ю
@Test
void test1() throws Exception { // назва нічого не каже
driver.get("https://staging.shop.com/login"); // зашитий стенд
driver.findElement(By.xpath("//div[3]/form/input[1]"))
.sendKeys("admin"); // крихкий XPath, спільний акаунт
Thread.sleep(5000); // сліпе очікування
if (driver.getPageSource().contains("Welcome")) {
System.out.println("ok"); // тест не може впасти
}
}
// Після рев'ю
@Test
void activeUserSeesPersonalGreetingAfterLogin() {
User user = users.createActive(); // власні дані, безпечно паралельно
loginPage.open().loginAs(user); // BASE_URL із конфігу
assertThat(dashboard.greeting()) // явне очікування всередині методу
.isEqualTo("Welcome, " + user.firstName());
}
// Before review
@Test
void test1() throws Exception { // the name says nothing
driver.get("https://staging.shop.com/login"); // hardcoded stand
driver.findElement(By.xpath("//div[3]/form/input[1]"))
.sendKeys("admin"); // brittle XPath, shared account
Thread.sleep(5000); // blind wait
if (driver.getPageSource().contains("Welcome")) {
System.out.println("ok"); // the test can never fail
}
}
// After review
@Test
void activeUserSeesPersonalGreetingAfterLogin() {
User user = users.createActive(); // own data, safe in parallel
loginPage.open().loginAs(user); // BASE_URL comes from config
assertThat(dashboard.greeting()) // explicit wait inside the method
.isEqualTo("Welcome, " + user.firstName());
}
if без else ніколи не кидає помилку. Друга має один AssertJ-асерт, власного користувача і жодної інфраструктурної деталі в тілі тесту.if without an else never throws. The second has one AssertJ assertion, its own user and no infrastructure details in the test body.if і println були зеленими за будь-якого стану продукту. Далі дивлюся на стабільність: сліпі паузи, спільний акаунт, крихкі XPath, зашиті адреси. І прошу автора показати червоний прогін — це займає хвилину, але відсіює більшість порожніх тестів.if with a println. Next I look at stability: blind pauses, a shared account, brittle XPaths, hardcoded addresses. And I ask the author to show me a red run — it takes a minute and filters out most hollow tests.Що це — Перевірка продуктивності в CI — це невеликий навантажувальний сценарій із порогами, які перетворюють метрики на «так/ні». У k6 це thresholds: якщо p95 часу відповіді більший за 500 мс або частка помилок вища за 1%, k6 run завершується з ненульовим кодом і джоб червоніє. У Gatling ту саму роль виконують assertions, у JMeter — запуск у non-GUI режимі з аналізом результатів окремим кроком.
What it is — A performance check in CI is a small load scenario with thresholds that turn metrics into a yes/no. In k6 these are thresholds: if p95 response time exceeds 500 ms or the error rate goes above 1%, k6 run exits with a non-zero code and the job turns red. In Gatling assertions play the same role; in JMeter you run in non-GUI mode and evaluate results in a separate step.
Навіщо — Графік у Grafana, на який ніхто не дивиться, не зупинить реліз, а червоний джоб — зупинить. Регресії продуктивності (N+1 запит, зникнений індекс) найдешевше ловити в тому ж PR чи нічному прогоні, що їх спричинив.
Why — A Grafana chart nobody looks at won't stop a release, a red job will. Performance regressions (an N+1 query, a dropped index) are cheapest to catch in the same PR or nightly run that introduced them.
Приклад — Я розділяю два рівні. Smoke-load на 20 віртуальних користувачів протягом двох хвилин проти виділеного perf-стенда — у нічному пайплайні або перед релізом, пороги базуються на виміряному baseline плюс запас. Повноцінні stress- і soak-тести йдуть окремо, за розкладом, на середовищі, наближеному до продакшену, і їх результат аналізує людина.
Example — I split two levels. A smoke load of 20 virtual users for two minutes against a dedicated perf stand runs nightly or before a release, with thresholds based on a measured baseline plus a margin. Full stress and soak tests run separately, on a schedule, in a production-like environment, and a person analyses the results.
Підводні камені — Ганяти навантаження на спільному staging, де паралельно працюють UI-тести й інші команди, — цифри шумні, а колеги отримують flaky; пороги «зі стелі» без baseline; тестувати лише один ендпоінт без think time.
Pitfalls — Running load on a shared staging where UI tests and other teams work at the same time — numbers are noisy and colleagues get flaky runs; thresholds pulled from thin air without a baseline; hammering a single endpoint with no think time.
Уточнення на співбесіді — «Чому p95, а не середнє?» Середнє ховає хвіст: 95% запитів по 100 мс і 5% по 5 секунд дають пристойне середнє, але кожен двадцятий користувач страждає.
Interview follow-up — "Why p95 and not the average?" The average hides the tail: 95% of requests at 100 ms and 5% at 5 seconds give a decent mean, yet one user in twenty suffers.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 20,
duration: '2m',
thresholds: {
http_req_duration: ['p(95)<500'], // 95% запитів швидші за 500 мс
http_req_failed: ['rate<0.01'], // менше 1% помилок
checks: ['rate>0.99'],
},
};
export default function () {
const res = http.get(__ENV.BASE_URL + '/api/products?page=1');
check(res, { 'статус 200': (r) => r.status === 200 });
sleep(1); // think time між діями
}
// CI: k6 run -e BASE_URL=https://perf.example.com smoke-load.js
// порушений поріг → ненульовий код виходу → джоб червоний
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 20,
duration: '2m',
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests faster than 500 ms
http_req_failed: ['rate<0.01'], // under 1% errors
checks: ['rate>0.99'],
},
};
export default function () {
const res = http.get(__ENV.BASE_URL + '/api/products?page=1');
check(res, { 'status 200': (r) => r.status === 200 });
sleep(1); // think time between actions
}
// CI: k6 run -e BASE_URL=https://perf.example.com smoke-load.js
// a breached threshold → non-zero exit code → red job
check лише рахує успішні перевірки й не зупиняє тест; вирішальними є thresholds, зокрема checks: rate>0.99, що перетворює перевірки на умову провалу. __ENV читає змінні, передані через -e.check only counts passed checks and doesn't stop the test; thresholds decide, including checks: rate>0.99, which turns checks into a failure condition. __ENV reads variables passed with -e.Що це — Стратегія автоматизації — коротка жива сторінка, а не 40-сторінковий документ. У ній: цілі (що хочемо отримати: швидший реліз, менше ручної регресії); обсяг по шарах (що покриваємо unit, API, UI, а що свідомо лишаємо ручним); інструменти й мова; середовища й тестові дані; інтеграція в CI (які набори на яких подіях); звітність; власники; метрики. Критерії виходу — перевірювані умови, за яких збірка може йти далі.
What it is — An automation strategy is a short living page, not a 40-page document. It covers goals (what we want: faster releases, less manual regression); scope per layer (what unit, API and UI cover, and what we deliberately leave manual); tools and language; environments and test data; CI integration (which sets run on which events); reporting; owners; metrics. Exit criteria are verifiable conditions under which a build may move on.
Навіщо — Без стратегії автоматизація росте хаотично: UI-тести дублюють API, ніхто не знає, хто лагодить падіння. Без вимірюваних критеріїв виходу реліз вирішують відчуттями: «ну, наче все зелене».
Why — Without a strategy automation grows chaotically: UI tests duplicate API ones and nobody knows who fixes failures. Without measurable exit criteria a release is decided by gut feeling: "well, it looks green".
Приклад — Робочі критерії звучать так: 100% smoke пройдено на release candidate; 0 відкритих blocker/critical; усі тести критичних потоків (оплата, реєстрація) зелені без ретраїв; частка flaky за 7 днів нижче 2%; кожен новий story має автотест або погоджений виняток. Кожен пункт автоматично перевіряється або з CI, або з Jira-фільтра, і гейт стоїть перед деплоєм на продакшен.
Example — Workable criteria read like this: 100% of smoke passed on the release candidate; 0 open blocker/critical bugs; all critical-flow tests (payment, sign-up) green without retries; 7-day flaky rate below 2%; every new story has an automated test or an agreed exception. Each item is checked automatically from CI or a Jira filter, and the gate sits before the production deploy.
Підводні камені — «95% тестів пройдено» без розрізнення, які саме впали (5% можуть бути оплатою); критерії, яких ніхто не дотримується, бо дедлайн важливіший; стратегія, написана один раз і забута.
Pitfalls — "95% of tests passed" without distinguishing which failed (the 5% may be payment); criteria nobody honours because the deadline wins; a strategy written once and forgotten.
Уточнення на співбесіді — «А якщо бізнес хоче релізити попри невиконаний критерій?» Це легітимне рішення, але його фіксують явно: хто прийняв ризик, які обхідні заходи, до якої дати виправлення.
Interview follow-up — "What if the business wants to release despite an unmet criterion?" That's a legitimate decision, but it is recorded explicitly: who accepted the risk, what mitigations exist, and the fix deadline.
goal: "ручна регресія з 3 днів до 4 годин за 2 квартали"
layers:
api: бізнес-правила, валідація, права доступу # основна маса перевірок
ui: 8 критичних потоків end-to-end
manual: вичитка текстів, UX нових екранів # свідомо не автоматизуємо
stack: Java 21, JUnit5, RestAssured, Playwright for Java, Allure
ownership: тест пише автор фічі, падіння розбирає черговий QA
exit_criteria: # гейт перед продакшеном
- smoke на RC: 100% passed
- відкриті blocker/critical: 0
- критичні потоки зелені без ретраїв
- flaky rate за 7 днів: < 2%
metrics: [тривалість PR-гейту, flaky rate, баги з продакшену за реліз]
goal: "manual regression from 3 days to 4 hours within 2 quarters"
layers:
api: business rules, validation, permissions # the bulk of checks
ui: 8 critical end-to-end flows
manual: copy proofreading, UX of new screens # deliberately not automated
stack: Java 21, JUnit5, RestAssured, Playwright for Java, Allure
ownership: the feature author writes the test, the on-call QA triages failures
exit_criteria: # gate before production
- smoke on RC: 100% passed
- open blocker/critical: 0
- critical flows green without retries
- 7-day flaky rate: < 2%
metrics: [PR gate duration, flaky rate, production bugs per release]
exit_criteria — число чи булева умова, яку можна зчитати з CI або баг-трекера. Розділ manual так само важливий, як і автоматизований обсяг: він фіксує свідомі рішення.exit_criteria is a number or a boolean condition that can be read from CI or the bug tracker. The manual section matters as much as the automated scope: it records deliberate decisions.Що це — Вибір інструментів — інженерне рішення з наслідками на роки, тому я роблю його за критеріями, а не за модою. Основні: мова й стек команди (хто писатиме й підтримуватиме тести); тип застосунку (web, mobile, API, desktop); потрібні браузери й платформи; інтеграція з CI і звітністю; стабільність і швидкість; спільнота й документація; ліцензія та вартість; ризики вендор-локу.
What it is — Choosing tools is an engineering decision with consequences for years, so I make it by criteria, not by fashion. The main ones: the team's language and stack (who will write and maintain the tests); application type (web, mobile, API, desktop); required browsers and platforms; CI and reporting integration; stability and speed; community and documentation; licence and cost; vendor lock-in risk.
Навіщо — Мігрувати тисячу тестів з одного фреймворку на інший дуже дорого. Інструмент, яким не володіють розробники, прирікає автоматизацію на життя в ізоляції від них: ніхто, крім QA, не полагодить тест і не додасть новий.
Why — Migrating a thousand tests between frameworks is very expensive. A tool the developers don't know condemns automation to living in isolation: nobody but QA will fix a test or add one.
Приклад — Команда фронтенду пише на TypeScript, бекенд на Kotlin, потрібні Chrome і Safari. Кандидати: Playwright TS і Selenium Java. Я роблю PoC тиждень: 3–5 реальних сценаріїв (логін, оплата з iframe, завантаження файлу), запуск у CI, звіт. Зважена матриця показала перевагу Playwright TS: та сама мова, що й у фронтенду, WebKit із коробки, trace для розбору падінь. API-тести лишили на JVM-стеку поруч із бекендом.
Example — The frontend team writes TypeScript, the backend Kotlin, and we need Chrome and Safari. Candidates: Playwright TS and Selenium Java. I run a one-week PoC: 3–5 real scenarios (login, payment inside an iframe, file upload), a CI run, a report. A weighted matrix favoured Playwright TS: the same language as the frontend, WebKit out of the box, trace for debugging failures. API tests stayed on the JVM stack next to the backend.
Підводні камені — Обирати за «hello world» на демо-сайті замість складних місць свого застосунку; вирішувати наодинці без розробників; недооцінювати вартість навчання команди й міграції наявних тестів.
Pitfalls — Choosing based on a "hello world" on a demo site instead of the hard parts of your own app; deciding alone without developers; underestimating the cost of training the team and migrating existing tests.
Уточнення на співбесіді — «А якщо вже є 800 тестів на Selenium?» Тоді переписування має окупитися; часто розумніше писати нові тести на новому інструменті, а старі переносити лише тоді, коли їх все одно треба чіпати.
Interview follow-up — "What if there are already 800 Selenium tests?" Then a rewrite must pay for itself; it is often wiser to write new tests with the new tool and migrate old ones only when they have to be touched anyway.
# оцінка 1–5, вага — наскільки критерій важливий для цього проєкту
критерій вага Playwright TS Selenium Java
мова команди (фронт на TS) 3 5 3
Safari / WebKit 2 4 3
стабільність PoC у CI (10 прог.) 3 5 4
розбір падінь (trace, відео) 2 5 3
наявні напрацювання 1 2 4
зважений підсумок 50 37
# рішення: UI — Playwright TS; API — лишаємо на JVM поруч із бекендом
# score 1–5, weight — how much the criterion matters for this project
criterion weight Playwright TS Selenium Java
team language (frontend in TS) 3 5 3
Safari / WebKit 2 4 3
PoC stability in CI (10 runs) 3 5 4
failure debugging (trace, video) 2 5 3
existing assets 1 2 4
weighted total 50 37
# decision: UI — Playwright TS; API stays on the JVM next to the backend