← Valtive QA Quiz quiz.valtive.io/notes/javacore valtive.io
📖 Про цей гайд📖 About this guide
Для когоWho it's for
Для QA-автоматизаторів і SDET, яких на співбесіді питають «чисту» Java: не Selenium чи REST Assured, а мову, на якій написаний їхній фреймворк. Тут JVM і збірка тестів, рядки й пастка кешу Integer, ООП на прикладі Page Object, контракт equals/hashCode для DTO, колекції й generics, винятки, Stream API і Optional, потоки для паралельного запуску, пам’ять, рефлексія й анотації, патерни та типові задачі з live-coding. Усі 92 питання квізу Java Core покриті картками, а дрібні питання про сусідні методи зібрані разом.
For QA automation engineers and SDETs who get asked about plain Java in interviews: not Selenium or REST Assured, but the language their framework is written in. It covers the JVM and building tests, strings and the Integer cache trap, OOP through Page Objects, the equals/hashCode contract for DTOs, collections and generics, exceptions, the Stream API and Optional, threads for parallel runs, memory, reflection and annotations, patterns and typical live-coding tasks. All 92 questions of the Java Core quiz are covered by cards, and small questions about neighbouring methods are merged.
Як користуватисьHow to use it
Фільтруй картки за рівнем або розділом. Відповідь іде за схемою «Що це → Навіщо → Приклад → Підводні камені», а код написаний для Java 17/21 і JUnit 5 — його можна скопіювати в IDE і запустити. Senior-картки — це сценарії й трейдофи з реальних фреймворків. Наостанок закрий текст і перекажи відповідь своїми словами за блоком 🗣 Як читати вголос: так ти тренуєш живу відповідь інтерв'юеру, а не впізнавання правильного варіанта в тесті.
Filter cards by level or by section. Each answer follows "What it is → Why → Example → Pitfalls", and the code targets Java 17/21 and JUnit 5, so you can paste it into an IDE and run it. Senior cards are scenarios and trade-offs taken from real frameworks. Finally, hide the text and retell the answer in your own words using the 🗣 How to say it out loud block: that trains a live answer to an interviewer rather than recognising the right option in a quiz.
Три якорі — з них випливає більшість відповідейThree anchors — most answers follow from them
ЯКІР 1 · ООП і контракти equals/hashCodeANCHOR 1 · OOP and the equals/hashCode contract
Page Object, DTO відповіді API і тестові дані — це звичайні Java-класи. Коли порівнюєш об’єкти в assertEquals або кладеш їх у HashSet, працює саме equals/hashCode, а не «магія фреймворку». Record дає цей контракт безкоштовно.
Page Objects, API response DTOs and test data are plain Java classes. When you compare objects in assertEquals or put them into a HashSet, it is equals/hashCode doing the work, not "framework magic". A record gives you that contract for free.
ЯКІР 2 · Колекції і genericsANCHOR 2 · Collections and generics
Майже кожен тест фільтрує, групує чи порівнює набори даних. Вибір між List, Set і Map, порядок ітерації та незмінні List.of визначають, чи буде перевірка стабільною, а Stream API робить її короткою.
Almost every test filters, groups or compares data sets. The choice between List, Set and Map, iteration order and immutable List.of decide whether a check is stable, and the Stream API keeps it short.
ЯКІР 3 · Винятки, потоки й пам’ятьANCHOR 3 · Exceptions, threads and memory
Паралельний прогін ламає фреймворки, які не думали про потоки: спільний WebDriver, статичні поля, проковтнуті винятки й незакриті ресурси. ThreadLocal, try-with-resources і чесні винятки — це те, що відрізняє стабільний набір тестів від flaky.
Parallel runs break frameworks that never thought about threads: a shared WebDriver, static fields, swallowed exceptions and unclosed resources. ThreadLocal, try-with-resources and honest exceptions are what separate a stable suite from a flaky one.
75
питаньquestions
3
рівніlevels
—
voice-блокиvoice blocks
92/92
питань квізу покритоquiz questions covered
📚 9 РОЗДІЛІВ📚 9 SECTIONS
—
Junior
—
Middle
—
Senior
▶ 1 6
1 Що таке JVM і чим вона відрізняється від JRE та JDK? What is the JVM, and how does it differ from the JRE and the JDK?
JVM і пам’ять JVM and memory Junior ▶

Що це. JVM (Java Virtual Machine) — програма, яка виконує байткод: файли .class, які створює компілятор javac. Саме JVM завантажує класи, перевіряє байткод, керує пам’яттю через garbage collector і за допомогою JIT-компілятора перетворює «гарячі» методи на машинний код під конкретний процесор. Завдяки цьому той самий .jar з тестами запускається на Windows у розробника і на Linux-агенті CI.

What it is. The JVM (Java Virtual Machine) is a program that executes bytecode: the .class files produced by the javac compiler. The JVM loads classes, verifies bytecode, manages memory through the garbage collector and uses the JIT compiler to turn hot methods into machine code for the actual CPU. That is why the same .jar with tests runs on a developer's Windows laptop and on a Linux CI agent.

JRE і JDK. JRE — це JVM плюс стандартна бібліотека класів, тобто все, що потрібно для запуску. JDK — це JRE плюс інструменти розробника: javac, jar, jshell, jcmd, jstack, jfr. Починаючи з Java 11 окремий JRE від Oracle не поставляється, тому на CI-агент ставлять повний JDK (Temurin, Corretto, Zulu) — тести ж треба ще й компілювати.

JRE and JDK. The JRE is the JVM plus the standard class library, i.e. everything needed to run code. The JDK is the JRE plus developer tools: javac, jar, jshell, jcmd, jstack, jfr. Since Java 11 Oracle no longer ships a separate JRE, so a CI agent gets a full JDK (Temurin, Corretto, Zulu) — the tests have to be compiled there anyway.

Приклад з проєкту. Maven-збірка падає з «invalid target release: 21», хоча на ноутбуці все працює. Причина — на агенті JDK 17, а в pom.xml стоїть maven.compiler.release 21. Перевіряю java -version і mvn -v: Maven показує, з яким JDK він реально запущений, і це не завжди той, що в PATH.

Project example. A Maven build fails with "invalid target release: 21" although it works on a laptop. The agent has JDK 17 while pom.xml sets maven.compiler.release to 21. I check java -version and mvn -v: Maven prints the JDK it is actually running on, which is not always the one on the PATH.

Підводні камені. Плутати версію мови та версію JVM: код, скомпільований під 21, не запуститься на JVM 17 (UnsupportedClassVersionError), а навпаки — запуститься.

Pitfalls. Mixing up the language level and the runtime: code compiled for 21 will not start on a 17 JVM (UnsupportedClassVersionError), while the opposite direction works.

Що саме бачить JVM, коли запускає тест
What the JVM actually sees when it runs a test
public class RuntimeInfo {
    public static void main(String[] args) {
        // Версія JVM, на якій реально йде прогін
        System.out.println(Runtime.version());
        System.out.println(System.getProperty("java.vendor"));
        // Скільки heap дозволено через -Xmx
        long maxMb = Runtime.getRuntime().maxMemory() / (1024 * 1024);
        System.out.println("Max heap, MB: " + maxMb);
        System.out.println("CPUs: " + Runtime.getRuntime().availableProcessors());
    }
}
// javac RuntimeInfo.java   -> RuntimeInfo.class (байткод)
// java RuntimeInfo         -> JVM завантажує клас і виконує main
public class RuntimeInfo {
    public static void main(String[] args) {
        // The JVM version the run actually uses
        System.out.println(Runtime.version());
        System.out.println(System.getProperty("java.vendor"));
        // How much heap -Xmx allows
        long maxMb = Runtime.getRuntime().maxMemory() / (1024 * 1024);
        System.out.println("Max heap, MB: " + maxMb);
        System.out.println("CPUs: " + Runtime.getRuntime().availableProcessors());
    }
}
// javac RuntimeInfo.java   -> RuntimeInfo.class (bytecode)
// java RuntimeInfo         -> the JVM loads the class and runs main
🔧 Розбір синтаксису: Runtime.version() повертає об’єкт Runtime.Version з номером JVM, під якою виконується код. System.getProperty читає системні властивості JVM. maxMemory() показує межу heap, задану -Xmx або обчислену JVM за замовчуванням (близько чверті RAM контейнера). Цей клас зручно запускати першим кроком CI, щоб у лозі було видно реальне середовище.
🔧 Syntax breakdown: Runtime.version() returns a Runtime.Version describing the JVM the code runs on. System.getProperty reads JVM system properties. maxMemory() shows the heap limit set by -Xmx or chosen by the JVM by default (about a quarter of the container RAM). Running this class as the first CI step puts the real environment into the log.
💡 Простими словами: JDK — майстерня з інструментами, JRE — готовий автомобіль, JVM — його двигун, який уміє їздити будь-якою дорогою.
💡 In plain words: The JDK is the workshop with tools, the JRE is the finished car, and the JVM is its engine that can drive on any road.
🗣 Як читати вголос Я пояснюю це як три вкладені коробки. JVM виконує байткод, керує пам’яттю і через JIT робить код швидким. JRE додає до неї стандартну бібліотеку, а JDK — ще й компілятор та утиліти на кшталт jstack чи jcmd. Для автоматизатора практичний висновок простий: на CI потрібен саме JDK потрібної версії, і коли збірка падає з дивною помилкою версії, я першим ділом дивлюся mvn -v, бо Maven може бігти не на тій Java, що в PATH.
🗣 How to say it out loud I explain it as three nested boxes. The JVM runs bytecode, manages memory and makes code fast with the JIT. The JRE adds the standard library on top, and the JDK adds the compiler and tools such as jstack or jcmd. For an automation engineer the practical point is simple: CI needs a JDK of the right version, and when a build fails with an odd version error, the first thing I check is mvn -v, because Maven may be running on a different Java than the one on the PATH.
2 Як тести компілюються і потрапляють на classpath у Maven і Gradle? How are tests compiled and put on the classpath in Maven and Gradle?
JVM і пам’ять JVM and memory Middle ▶

Що це. Classpath — список каталогів і jar-файлів, де JVM шукає класи. Коли тест викликає RestAssured.given(), JVM знаходить клас лише тому, що jar REST Assured лежить на classpath. Помилки ClassNotFoundException і NoClassDefFoundError майже завжди означають, що потрібного jar там немає або є дві несумісні версії.

What it is. The classpath is the list of directories and jar files where the JVM looks for classes. When a test calls RestAssured.given(), the JVM finds the class only because the REST Assured jar is on the classpath. ClassNotFoundException and NoClassDefFoundError almost always mean the jar is missing or two incompatible versions are present.

Як це робить Maven. Код з src/main/java і тести з src/test/java компілюються окремо, а залежності зі <scope>test</scope> (JUnit, AssertJ, Selenium) потрапляють лише на тестовий classpath. Фаза test запускає Surefire, який знаходить класи з іменами на кшталт *Test і *Tests; інтеграційні *IT запускає Failsafe на фазі verify. У Gradle аналог — конфігурації testImplementation і задача test з useJUnitPlatform().

How Maven does it. Code in src/main/java and tests in src/test/java are compiled separately, and dependencies with <scope>test</scope> (JUnit, AssertJ, Selenium) land only on the test classpath. The test phase runs Surefire, which picks up classes named like *Test or *Tests; integration *IT classes run under Failsafe in the verify phase. In Gradle the equivalents are the testImplementation configuration and the test task with useJUnitPlatform().

Приклад з проєкту. Щоб прогнати один клас на CI: mvn -Dtest=LoginTest test або gradle test --tests LoginTest. Щоб зрозуміти конфлікт версій Selenium: mvn dependency:tree -Dincludes=org.seleniumhq.selenium.

Project example. To run one class on CI: mvn -Dtest=LoginTest test or gradle test --tests LoginTest. To untangle a Selenium version clash: mvn dependency:tree -Dincludes=org.seleniumhq.selenium.

Підводні камені. Тест без суфікса Test Surefire просто не знайде, і збірка буде зеленою з нулем тестів. Ще одна класика — забутий useJUnitPlatform() у Gradle: JUnit 5-тести мовчки не запускаються.

Pitfalls. A test class without the Test suffix is simply not found by Surefire, and the build goes green with zero tests. Another classic is a missing useJUnitPlatform() in Gradle: JUnit 5 tests silently do not run.

Мінімальний pom.xml для тестового проєкту на JUnit 5
A minimal pom.xml for a JUnit 5 test project
<properties>
  <maven.compiler.release>21</maven.compiler.release>
</properties>
<dependencies>
  <!-- лише тестовий classpath -->
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.4</version>
    <scope>test</scope>
  </dependency>
</dependencies>
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.5.2</version>
    </plugin>
  </plugins>
</build>
<!-- mvn -Dtest=LoginTest test   запускає один клас -->
<properties>
  <maven.compiler.release>21</maven.compiler.release>
</properties>
<dependencies>
  <!-- test classpath only -->
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.4</version>
    <scope>test</scope>
  </dependency>
</dependencies>
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.5.2</version>
    </plugin>
  </plugins>
</build>
<!-- mvn -Dtest=LoginTest test   runs a single class -->
🔧 Розбір синтаксису: maven.compiler.release задає і рівень мови, і API стандартної бібліотеки, на відміну від пари source/target. scope test означає, що залежність не потрапить у production-артефакт. Surefire 3.x сам знаходить JUnit Platform за наявності junit-jupiter, окремий провайдер не потрібен. Прапорець -Dtest приймає ім’я класу, маску або Class#method.
🔧 Syntax breakdown: maven.compiler.release sets both the language level and the standard library API, unlike the source/target pair. scope test keeps the dependency out of the production artifact. Surefire 3.x detects the JUnit Platform on its own when junit-jupiter is present, no extra provider needed. The -Dtest flag accepts a class name, a pattern or Class#method.
💡 Простими словами: Classpath — це полиця, з якої JVM бере класи. Maven і Gradle лише розкладають на цю полицю правильні jar-и у правильний момент.
💡 In plain words: The classpath is the shelf the JVM takes classes from. Maven and Gradle simply put the right jars on that shelf at the right moment.
🗣 Як читати вголос Я розповідаю про classpath через помилки, які з ним пов’язані. Якщо бачу NoClassDefFoundError, то дивлюся дерево залежностей, бо найчастіше там дві версії однієї бібліотеки. У Maven тести живуть у src/test/java, тестові залежності мають scope test, а Surefire підхоплює класи з суфіксом Test. Для точкового запуску на CI використовую -Dtest з ім’ям класу чи методу. А в Gradle завжди перевіряю useJUnitPlatform, бо без нього JUnit 5 просто мовчить.
🗣 How to say it out loud I talk about the classpath through the errors it causes. When I see NoClassDefFoundError I look at the dependency tree, because most often there are two versions of the same library. In Maven tests live in src/test/java, test dependencies have scope test, and Surefire picks up classes ending in Test. For a targeted run on CI I use -Dtest with a class or method name. And in Gradle I always check useJUnitPlatform, because without it JUnit 5 just stays silent.
3 Яка різниця між stack і heap у Java? What is the difference between the stack and the heap in Java?
JVM і пам’ять JVM and memory Junior ▶

Stack. Кожен потік має власний стек. На ньому лежать кадри викликів методів: локальні змінні примітивних типів, параметри і посилання на об’єкти. Кадр з’являється під час виклику методу і зникає після return, тому стек не потребує garbage collector і доступ до нього дуже швидкий. Розмір стека обмежений (типово 512 КБ–1 МБ, налаштовується -Xss).

Stack. Every thread has its own stack. It holds method call frames: local variables of primitive types, parameters and references to objects. A frame appears when a method is called and disappears on return, so the stack needs no garbage collector and access is very fast. Stack size is limited (typically 512 KB to 1 MB, configured with -Xss).

Heap. Heap — спільна пам’ять для всіх потоків, де живуть самі об’єкти й масиви, створені через new: сторінки Page Object, DTO відповідей, рядки. Звільняє її GC, коли на об’єкт більше ніхто не посилається. Розмір задають -Xms і -Xmx.

Heap. The heap is memory shared by all threads where the objects themselves and arrays created with new live: Page Objects, response DTOs, strings. The GC frees it once nothing references an object any more. Its size is set with -Xms and -Xmx.

Чому це важливо для тестів. Локальна змінна з драйвером у методі тесту безпечна для паралельного запуску — вона на стеку свого потоку. Але об’єкт, на який вона вказує, лежить у спільному heap: якщо посилання на нього ще й записане в static поле, інші потоки теж його побачать. Звідси гонки в паралельних тестах.

Why it matters for tests. A local variable holding a driver inside a test method is safe for parallel runs because it lives on its own thread's stack. The object it points to, however, is in the shared heap: if a reference to it is also stored in a static field, other threads see it too. That is where races in parallel tests come from.

Підводні камені. Фраза «примітиви завжди на стеку» неточна: поле int всередині об’єкта лежить у heap разом з об’єктом. На стеку — лише локальні змінні й параметри.

Pitfalls. The phrase "primitives always live on the stack" is imprecise: an int field inside an object lives on the heap together with the object. Only local variables and parameters are on the stack.

Що лежить на стеку, а що в heap
What lives on the stack and what lives on the heap
public class Memory {
    // Статичне поле: одне на весь JVM, бачать усі потоки
    static List<String> sharedLog = new ArrayList<>();

    int attempts;            // поле об’єкта: живе в heap разом з об’єктом

    void runStep(String name) {        // name: посилання на стеку, рядок у heap
        int retries = 3;               // примітив на стеку потоку
        var step = new StringBuilder(name); // посилання на стеку, об’єкт у heap
        step.append(" x").append(retries);
        sharedLog.add(step.toString()); // гонка при паралельному запуску!
    }
}
public class Memory {
    // Static field: one per JVM, visible to all threads
    static List<String> sharedLog = new ArrayList<>();

    int attempts;            // instance field: lives on the heap with its object

    void runStep(String name) {        // name: reference on the stack, string on the heap
        int retries = 3;               // primitive on the thread stack
        var step = new StringBuilder(name); // reference on the stack, object on the heap
        step.append(" x").append(retries);
        sharedLog.add(step.toString()); // a race in parallel runs!
    }
}
🔧 Розбір синтаксису: static List створюється один раз при завантаженні класу і живе в heap, доки клас завантажений. int attempts — поле екземпляра, тому воно в heap. У методі retries і посилання step лежать у кадрі стека, а сам StringBuilder — у heap. Виклик ArrayList.add з кількох потоків без синхронізації може загубити елементи або кинути виняток.
🔧 Syntax breakdown: The static List is created once when the class is loaded and lives on the heap as long as the class does. int attempts is an instance field, so it is on the heap. Inside the method retries and the step reference sit in the stack frame, while the StringBuilder itself is on the heap. Calling ArrayList.add from several threads without synchronisation can lose elements or throw.
💡 Простими словами: Стек — особистий блокнот кожного потоку, heap — спільна дошка в офісі. Що на дошці, те може стерти будь-хто.
💡 In plain words: The stack is each thread's personal notepad, the heap is the shared whiteboard in the office. Anything on the whiteboard can be erased by anyone.
🗣 Як читати вголос Я відповідаю так: стек належить потоку і зберігає кадри методів, тобто локальні змінні й посилання, і очищається сам після return. Heap спільний для всіх потоків, там живуть об’єкти, і за нього відповідає garbage collector. Для автоматизації з цього випливає головне правило паралельного запуску: локальна змінна в тесті безпечна, а статичне поле, через яке об’єкт видно всім потокам, — це майже гарантована гонка.
🗣 How to say it out loud My answer is: the stack belongs to a thread and holds method frames, meaning local variables and references, and it cleans itself up on return. The heap is shared by all threads, objects live there and the garbage collector takes care of it. For automation this gives the main rule of parallel runs: a local variable in a test is safe, while a static field that makes an object visible to every thread is an almost guaranteed race.
4 Як працює garbage collector і коли він впливає на тести? How does the garbage collector work, and when does it affect tests?
JVM і пам’ять JVM and memory Middle ▶

Що це. Garbage collector автоматично звільняє пам’ять heap від об’єктів, до яких не можна дістатися від «коренів»: локальних змінних активних потоків, статичних полів, JNI-посилань. Лічильника посилань немає, тому два об’єкти, що посилаються один на одного, теж будуть зібрані, якщо до них немає шляху від коренів.

What it is. The garbage collector automatically frees heap memory from objects that cannot be reached from the roots: local variables of live threads, static fields, JNI references. There is no reference counting, so two objects that point at each other are still collected if no path leads to them from a root.

Покоління. Більшість об’єктів помирає молодими (тимчасові рядки, DTO одного запиту), тому heap поділено на young і old generation. Young збирається часто й швидко, а об’єкти, що вижили кілька збирань, переїжджають в old. Типовий збирач у Java 17/21 — G1; для великих heap з вимогою коротких пауз є ZGC (-XX:+UseZGC, у Java 21 ще й generational через -XX:+ZGenerational).

Generations. Most objects die young (temporary strings, the DTO of one request), so the heap is split into young and old generations. The young one is collected often and quickly, and objects surviving several collections move to old. The default collector in Java 17/21 is G1; for large heaps that need short pauses there is ZGC (-XX:+UseZGC, and in Java 21 a generational mode via -XX:+ZGenerational).

Коли це бачить автоматизатор. Stop-the-world паузи рідко важливі для UI-тестів, але помітні в навантажувальних клієнтах і в довгих прогонах з тисячами тестів: якщо heap майже повний, GC працює безперервно, тести сповільнюються, а врешті падає OutOfMemoryError. Діагностика: -Xlog:gc у Surefire argLine, jcmd <pid> GC.heap_info.

When an automation engineer sees it. Stop-the-world pauses rarely matter for UI tests, but they show up in load-testing clients and in long runs with thousands of tests: when the heap is nearly full, GC runs non-stop, tests slow down and eventually an OutOfMemoryError follows. Diagnostics: -Xlog:gc in the Surefire argLine, jcmd <pid> GC.heap_info.

Підводні камені. System.gc() — лише прохання, його не можна використовувати, щоб «почистити пам’ять між тестами». А finalize() застарілий і не гарантує виклику — ресурси закривають через try-with-resources.

Pitfalls. System.gc() is only a request and must not be used to "clean memory between tests". And finalize() is deprecated and not guaranteed to run — resources are closed with try-with-resources.

Коли об’єкт стає «сміттям»
When an object becomes garbage
public class GcDemo {
    static final List<byte[]> CACHE = new ArrayList<>(); // корінь: статичне поле

    public static void main(String[] args) {
        byte[] report = new byte[10 * 1024 * 1024]; // досяжний з локальної змінної
        report = null;                              // тепер кандидат на збирання

        CACHE.add(new byte[10 * 1024 * 1024]);      // не буде зібраний, доки лежить у CACHE

        Runtime rt = Runtime.getRuntime();
        long usedMb = (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024);
        System.out.println("Used heap, MB: " + usedMb);
    }
}
// mvn test -DargLine="-Xmx512m -Xlog:gc"   лог пауз GC під час тестів
public class GcDemo {
    static final List<byte[]> CACHE = new ArrayList<>(); // a root: a static field

    public static void main(String[] args) {
        byte[] report = new byte[10 * 1024 * 1024]; // reachable from a local variable
        report = null;                              // now eligible for collection

        CACHE.add(new byte[10 * 1024 * 1024]);      // never collected while it sits in CACHE

        Runtime rt = Runtime.getRuntime();
        long usedMb = (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024);
        System.out.println("Used heap, MB: " + usedMb);
    }
}
// mvn test -DargLine="-Xmx512m -Xlog:gc"   log GC pauses during tests
🔧 Розбір синтаксису: Після report = null на масив не лишається посилань, і GC може його звільнити, коли захоче. Масив у CACHE досяжний зі статичного поля, тобто з кореня, тож житиме до кінця роботи JVM. totalMemory() - freeMemory() дає приблизно зайнятий heap. argLine передає параметри JVM у процес, який Surefire форкає для тестів.
🔧 Syntax breakdown: After report = null nothing references the array, and GC may free it whenever it decides. The array in CACHE is reachable from a static field, i.e. a root, so it lives until the JVM exits. totalMemory() - freeMemory() gives the approximate used heap. argLine passes JVM options to the process Surefire forks for tests.
💡 Простими словами: GC — прибиральник, який викидає лише те, до чого ніхто не може дотягнутися. Усе, що ти поклав у статичну шухляду, він не чіпатиме ніколи.
💡 In plain words: The GC is a cleaner who throws away only what nobody can reach. Anything you put into a static drawer it will never touch.
🗣 Як читати вголос Я пояснюю GC через досяжність: об’єкт живе, поки до нього є шлях від коренів — стеків потоків і статичних полів. Heap поділений на покоління, бо більшість об’єктів помирає молодими, і за замовчуванням у Java 17 і 21 працює G1. У тестах я стикаюся з GC, коли довгий прогін раптом сповільнюється: тоді вмикаю лог GC через argLine і дивлюся, чи heap не забитий. І ніколи не покладаюся на System.gc, бо це лише побажання.
🗣 How to say it out loud I explain GC through reachability: an object lives while there is a path to it from the roots, meaning thread stacks and static fields. The heap is split into generations because most objects die young, and by default Java 17 and 21 use G1. In tests I run into GC when a long run suddenly slows down: then I turn on GC logging through argLine and check whether the heap is full. And I never rely on System.gc, because it is only a hint.
5 Чим відрізняються StackOverflowError і OutOfMemoryError і як їх розслідувати? How do StackOverflowError and OutOfMemoryError differ, and how do you investigate them?
JVM і пам’ять JVM and memory Middle ▶

StackOverflowError. Виникає, коли стек потоку переповнюється кадрами викликів. Майже завжди причина — нескінченна або надто глибока рекурсія: метод, що викликає сам себе без умови виходу, або два класи, що викликають один одного. Класика в тестовому коді — toString() чи equals() у двох DTO з двосторонніми посиланнями (замовлення знає клієнта, клієнт — свої замовлення), і Lombok @Data на обох.

StackOverflowError. It occurs when a thread's stack fills up with call frames. The cause is almost always infinite or overly deep recursion: a method calling itself without an exit condition, or two classes calling each other. The classic in test code is toString() or equals() in two DTOs with references in both directions (an order knows its customer, the customer knows its orders) and Lombok @Data on both.

OutOfMemoryError. Виникає, коли в heap (або в metaspace, або при створенні нових потоків) не вистачає місця навіть після збирання сміття. Повідомлення підказує, де саме: Java heap space, Metaspace, unable to create native thread, GC overhead limit exceeded.

OutOfMemoryError. It occurs when the heap (or metaspace, or native memory for new threads) runs out of space even after garbage collection. The message tells you where: Java heap space, Metaspace, unable to create native thread, GC overhead limit exceeded.

Як розслідувати. Для SOE досить подивитися стек-трейс: той самий метод повторюється сотні разів. Для OOM вмикаю -XX:+HeapDumpOnOutOfMemoryError і відкриваю дамп в Eclipse MAT — він показує, який об’єкт тримає найбільше пам’яті.

How to investigate. For a SOE the stack trace is enough: the same method repeats hundreds of times. For an OOM I enable -XX:+HeapDumpOnOutOfMemoryError and open the dump in Eclipse MAT, which shows the object retaining the most memory.

Підводні камені. Обидві помилки — Error, а не Exception; ловити їх, щоб «продовжити тест», не можна — стан JVM уже ненадійний. Збільшення -Xss чи -Xmx без аналізу лише відкладає падіння.

Pitfalls. Both are Error, not Exception; catching them to "carry on with the test" is wrong because the JVM state is no longer reliable. Raising -Xss or -Xmx without analysis only postpones the failure.

Двостороннє посилання, яке дає StackOverflowError
A two-way reference that causes StackOverflowError
class Customer {
    String name;
    List<Order> orders = new ArrayList<>();
    @Override public String toString() {
        return "Customer{" + name + ", orders=" + orders + "}"; // викликає Order.toString
    }
}

class Order {
    int id;
    Customer customer;
    @Override public String toString() {
        return "Order{" + id + ", customer=" + customer + "}"; // і знову Customer.toString
    }
}

// customer.orders.add(order); order.customer = customer;
// System.out.println(customer);  -> StackOverflowError
// Виправлення: в Order друкувати лише customer.name
class Customer {
    String name;
    List<Order> orders = new ArrayList<>();
    @Override public String toString() {
        return "Customer{" + name + ", orders=" + orders + "}"; // calls Order.toString
    }
}

class Order {
    int id;
    Customer customer;
    @Override public String toString() {
        return "Order{" + id + ", customer=" + customer + "}"; // and back to Customer.toString
    }
}

// customer.orders.add(order); order.customer = customer;
// System.out.println(customer);  -> StackOverflowError
// Fix: print only customer.name in Order
🔧 Розбір синтаксису: Конкатенація рядка з об’єктом неявно викликає його toString(). Customer.toString друкує список замовлень, кожне замовлення друкує клієнта, і цикл ніколи не закінчується, доки стек не переповниться. Розрив циклу — друкувати в одному з класів лише ідентифікатор або ім’я, а не весь зв’язаний об’єкт.
🔧 Syntax breakdown: Concatenating a string with an object implicitly calls its toString(). Customer.toString prints the list of orders, each order prints the customer, and the loop never ends until the stack overflows. The cycle is broken by printing only an id or a name in one of the classes instead of the whole linked object.
💡 Простими словами: StackOverflowError — ти заблукав у дзеркальному коридорі. OutOfMemoryError — у тебе закінчилося місце на складі.
💡 In plain words: StackOverflowError is getting lost in a hall of mirrors. OutOfMemoryError is running out of room in the warehouse.
🗣 Як читати вголос Я розводжу їх за місцем: StackOverflowError — це стек одного потоку, і майже завжди рекурсія без виходу. Мій улюблений приклад з тестів — двосторонні DTO з автогенерованим toString, які друкують один одного до переповнення. OutOfMemoryError — це heap або metaspace, і тут я не гадаю, а вмикаю heap dump при падінні й дивлюся в MAT, хто тримає пам’ять. Обидва — Error, тому я їх не ловлю і не лікую збільшенням лімітів навмання.
🗣 How to say it out loud I separate them by where they happen: StackOverflowError is one thread's stack, and almost always recursion without an exit. My favourite example from tests is two-way DTOs with a generated toString that print each other until the stack blows. OutOfMemoryError is the heap or metaspace, and there I do not guess: I enable a heap dump on failure and look in MAT at what retains the memory. Both are Errors, so I do not catch them and I do not cure them by raising limits at random.
6 Як тестовий фреймворк може «текти» пам’яттю і як знайти витік? How can a test framework leak memory, and how do you find the leak?
JVM і пам’ять JVM and memory Senior ▶

Що це. Витік пам’яті в Java — це не «забутий free», а об’єкти, які вже не потрібні, але досі досяжні з кореня, тому GC не може їх зібрати. Прогін на 3000 тестів поступово їсть heap, сповільнюється через постійний GC і наприкінці падає з OutOfMemoryError, хоча кожен тест окремо проходить.

What it is. A memory leak in Java is not a "forgotten free" but objects that are no longer needed yet still reachable from a root, so GC cannot collect them. A run of 3,000 tests gradually eats the heap, slows down because GC runs constantly and finally fails with OutOfMemoryError, although every test passes on its own.

Типові джерела у фреймворках. Статичний Map або List як «кеш» чи «звіт», куди кожен тест додає відповіді API, скріншоти в byte[] чи логи. ThreadLocal з драйвером без remove() у пулі потоків: потоки живуть увесь прогін, і кожен тримає старий WebDriver з усім його станом. Слухачі й колбеки, які реєструють, але не знімають. Незакриті InputStream і HTTP-відповіді.

Typical sources in frameworks. A static Map or List used as a "cache" or "report" where every test adds API responses, screenshots as byte[] or logs. A ThreadLocal holding a driver without remove() in a thread pool: the threads live for the whole run and each keeps an old WebDriver with all its state. Listeners and callbacks that are registered but never removed. Unclosed InputStreams and HTTP responses.

Як знайти. Порівнюю два heap dump (jcmd <pid> GC.heap_dump) на початку і в середині прогону, в Eclipse MAT дивлюся Dominator Tree і шлях до GC root для найбільших колекцій. Часто одразу видно статичне поле фреймворку.

How to find it. I compare two heap dumps (jcmd <pid> GC.heap_dump) taken at the start and in the middle of the run, and in Eclipse MAT I look at the Dominator Tree and the path to GC roots for the biggest collections. Often a static field of the framework shows up immediately.

Трейдоф. Збільшити -Xmx дешево і швидко, але це маскує проблему, яка повернеться при зростанні набору. Правильне рішення — обмежити життя даних тестом: очищати в @AfterEach, зберігати артефакти на диск, а не в пам’ять, і робити ThreadLocal.remove().

Trade-off. Raising -Xmx is cheap and quick but hides a problem that will return as the suite grows. The proper fix is to limit data lifetime to the test: clean up in @AfterEach, store artifacts on disk instead of in memory, and call ThreadLocal.remove().

Витік через статичний «звіт» і його виправлення
A leak through a static "report" and the fix
public final class ResponseStore {
    // Погано: росте весь прогін, GC ніколи не звільнить
    private static final List<String> ALL_BODIES = new ArrayList<>();

    public static void remember(String body) {
        ALL_BODIES.add(body);
    }
}

public final class ResponseArtifacts {
    // Краще: зберігаємо на диск лише для впалих тестів
    public static void saveOnFailure(String testName, String body) throws IOException {
        Path file = Path.of("target", "responses", testName + ".json");
        Files.createDirectories(file.getParent());
        Files.writeString(file, body);
    }
}
// Діагностика: jcmd <pid> GC.heap_dump /tmp/run.hprof  -> Eclipse MAT, Dominator Tree
public final class ResponseStore {
    // Bad: grows for the whole run, GC never frees it
    private static final List<String> ALL_BODIES = new ArrayList<>();

    public static void remember(String body) {
        ALL_BODIES.add(body);
    }
}

public final class ResponseArtifacts {
    // Better: write to disk only for failed tests
    public static void saveOnFailure(String testName, String body) throws IOException {
        Path file = Path.of("target", "responses", testName + ".json");
        Files.createDirectories(file.getParent());
        Files.writeString(file, body);
    }
}
// Diagnostics: jcmd <pid> GC.heap_dump /tmp/run.hprof  -> Eclipse MAT, Dominator Tree
🔧 Розбір синтаксису: static final List — корінь GC, тож усе додане в нього живе до кінця JVM. Path.of будує шлях без звернення до диска, Files.createDirectories створює каталоги й не падає, якщо вони вже є, Files.writeString пише рядок в UTF-8. Так пам’ять не росте, а артефакт доступний у CI для розбору.
🔧 Syntax breakdown: A static final List is a GC root, so everything added to it lives until the JVM exits. Path.of builds a path without touching the disk, Files.createDirectories creates the directories and does not fail if they exist, and Files.writeString writes the string as UTF-8. Memory no longer grows, and the artifact is still available in CI for analysis.
💡 Простими словами: Витік у Java — це шухляда, куди всі кладуть і ніхто не прибирає. GC не викидає те, що лежить у шухляді, навіть якщо його вже ніхто не відкриває.
💡 In plain words: A leak in Java is a drawer everyone puts things into and nobody empties. GC will not throw out what is in the drawer, even if nobody opens it any more.
🗣 Як читати вголос Коли прогін падає з OutOfMemoryError лише наприкінці, я одразу підозрюю витік, а не «замало пам’яті». У фреймворках це майже завжди статичні колекції з відповідями чи скріншотами або ThreadLocal з драйвером без remove у пулі потоків. Я знімаю два heap dump з інтервалом, порівнюю їх у MAT і йду шляхом до GC root. Збільшити Xmx можна як тимчасову латку, але справжнє рішення — щоб дані жили не довше за тест.
🗣 How to say it out loud When a run fails with OutOfMemoryError only near the end, I suspect a leak straight away rather than "too little memory". In frameworks it is almost always static collections of responses or screenshots, or a ThreadLocal with a driver and no remove in a thread pool. I take two heap dumps some time apart, compare them in MAT and follow the path to the GC root. Raising Xmx is fine as a temporary patch, but the real fix is making data live no longer than the test.
▶ 2 9
7 Які примітивні типи є в Java і чим int відрізняється від Integer? What primitive types does Java have, and how does int differ from Integer?
Типи й рядки Types and strings Junior ▶

Вісім примітивів. byte (8 біт), short (16), int (32), long (64), float (32), double (64), char (16 біт, одиниця UTF-16) і boolean. Примітив зберігає саме значення, а не посилання, тому не може бути null і не має методів. Поля класу отримують значення за замовчуванням: 0 для числових, false для boolean, \u0000 для char і null для будь-якого посилання, зокрема Integer. Локальна змінна значення за замовчуванням не має: компілятор не дасть її прочитати до присвоєння.

Eight primitives. byte (8 bits), short (16), int (32), long (64), float (32), double (64), char (16 bits, one UTF-16 unit) and boolean. A primitive holds the value itself rather than a reference, so it can never be null and has no methods. Class fields get default values: 0 for numeric types, false for boolean, \u0000 for char and null for any reference, including Integer. A local variable has no default at all: the compiler refuses to let you read it before assignment.

int проти Integer. Integer — клас-обгортка в heap. Він потрібен там, де Java вимагає об’єкт: у колекціях і generics (List<Integer>, а не List<int>), у полях DTO, де відсутність значення відрізняється від нуля. Для лічильників і циклів беру int: він швидший і не створює об’єктів.

int versus Integer. Integer is a wrapper class living on the heap. It is needed where Java demands an object: in collections and generics (List<Integer>, never List<int>) and in DTO fields where a missing value must differ from zero. For counters and loops I use int: it is faster and creates no objects.

Приклад з проєкту. У DTO відповіді API поле discount оголошене як int. Сервер не надсилає його взагалі, Jackson лишає 0, і тест «знижки немає» проходить, хоча бекенд зламав контракт. З Integer поле було б null, і перевірка побачила б різницю. Для id і часових міток у мілісекундах беру long: int переповнюється після 2 147 483 647.

Project example. An API response DTO declares discount as an int. The server stops sending the field, Jackson leaves 0, and the "no discount" test passes even though the backend broke the contract. With Integer the field would be null and the check would notice. For ids and millisecond timestamps I pick long, because int overflows after 2,147,483,647.

Підводні камені. Переповнення int не кидає винятку, а мовчки «загортається» в мінус. Для грошей не беруть double через похибку двійкових дробів, а використовують BigDecimal або цілі копійки в long.

Pitfalls. Overflowing an int throws nothing; it silently wraps into a negative number. Money is never kept in double because of binary fraction errors; use BigDecimal or whole cents in a long.

Значення за замовчуванням і різниця між int та Integer у DTO
Default values and int versus Integer in a DTO
public class OrderDto {
    int quantity;          // поле: 0 за замовчуванням
    Integer discount;      // поле-обгортка: null за замовчуванням
    long createdAtMillis;  // час у мс не влазить в int

    public static void main(String[] args) {
        OrderDto dto = new OrderDto();
        System.out.println(dto.quantity);   // 0
        System.out.println(dto.discount);   // null

        int local;
        // System.out.println(local);  помилка компіляції: змінна не ініціалізована

        int big = Integer.MAX_VALUE;
        System.out.println(big + 1);        // -2147483648, без винятку
        System.out.println(Math.addExact(5, 7)); // addExact кидає ArithmeticException при переповненні
    }
}
public class OrderDto {
    int quantity;          // field: defaults to 0
    Integer discount;      // wrapper field: defaults to null
    long createdAtMillis;  // millisecond time does not fit into int

    public static void main(String[] args) {
        OrderDto dto = new OrderDto();
        System.out.println(dto.quantity);   // 0
        System.out.println(dto.discount);   // null

        int local;
        // compile error: variable not initialized

        int big = Integer.MAX_VALUE;
        System.out.println(big + 1);        // -2147483648, no exception
        System.out.println(Math.addExact(5, 7)); // addExact throws ArithmeticException on overflow
    }
}
🔧 Розбір синтаксису: Поля quantity і discount ініціалізуються автоматично, бо живуть в об’єкті, а локальна local — ні, тому рядок з її читанням закоментований. Integer.MAX_VALUE + 1 дає мінімальне від’ємне значення. Math.addExact — спосіб явно зловити переповнення замість тихого загортання.
🔧 Syntax breakdown: The fields quantity and discount are initialised automatically because they live inside an object, while the local local is not, so the line reading it is commented out. Integer.MAX_VALUE + 1 gives the smallest negative value. Math.addExact is a way to catch overflow explicitly instead of wrapping silently.
💡 Простими словами: Примітив — це число в кишені, обгортка — коробка з числом. Коробка може бути порожньою, кишеня — ні.
💡 In plain words: A primitive is a number in your pocket, a wrapper is a box holding a number. A box can be empty, a pocket cannot.
🗣 Як читати вголос Я перелічую вісім примітивів і одразу кажу головне: примітив не буває null, а обгортка буває. Тому в DTO відповіді API я беру Integer там, де сервер може не надіслати поле, інакше нуль за замовчуванням сховає баг контракту. Ще пам’ятаю, що поля класу мають значення за замовчуванням, а локальні змінні ні, і що int тихо переповнюється, тому для id і часу в мілісекундах у мене long.
🗣 How to say it out loud I list the eight primitives and immediately make the key point: a primitive is never null, a wrapper can be. So in API response DTOs I use Integer wherever the server may omit a field, otherwise the default zero hides a contract bug. I also remember that class fields get defaults while locals do not, and that int overflows silently, which is why ids and millisecond times are long in my code.
8 Що таке autoboxing і чому Integer 127 == 127, а 128 != 128? What is autoboxing, and why is Integer 127 == 127 but 128 != 128?
Типи й рядки Types and strings Junior ▶

Що це. Autoboxing — автоматичне перетворення примітива на обгортку: Integer x = 5 компілюється у Integer.valueOf(5). Unboxing — зворотне: int y = x стає x.intValue(). Завдяки цьому можна писати list.add(5) у List<Integer> без ручних конвертацій.

What it is. Autoboxing is the automatic conversion of a primitive into its wrapper: Integer x = 5 compiles into Integer.valueOf(5). Unboxing goes the other way: int y = x becomes x.intValue(). That is why you can write list.add(5) into a List<Integer> with no manual conversion.

Пастка кешу. Integer.valueOf повертає готові об’єкти з кешу для значень від -128 до 127. Тому два Integer зі значенням 127 — це той самий об’єкт, і == дає true. Для 128 створюються два різні об’єкти, і ==, який порівнює посилання, дає false. Верхню межу кешу можна підняти прапорцем JVM, тож поведінка ще й залежить від середовища.

The cache trap. Integer.valueOf returns ready objects from a cache for values between -128 and 127. So two Integers holding 127 are the same object and == gives true. For 128 two different objects are created, and ==, which compares references, gives false. The upper bound of the cache can be raised with a JVM flag, so the behaviour even depends on the environment.

Приклад з проєкту. Тест порівнює кількість товарів з кошика і з API: if (cartCount == apiCount), обидва Integer. Локально з 3 товарами все зелене, на стейджингу з 500 товарами — падіння без видимої причини. Виправлення: assertEquals(cartCount, apiCount) або Objects.equals, які викликають equals.

Project example. A test compares the item count from the cart with the one from the API: if (cartCount == apiCount), both Integer. Locally with 3 items it is green; on staging with 500 items it fails for no visible reason. The fix is assertEquals(cartCount, apiCount) or Objects.equals, which call equals.

Підводні камені. Unboxing null кидає NullPointerException у рядку, де не видно жодного виклику методу: int total = map.get("missing"). Те саме з JSON-полем, яке прийшло як null. А змішування Integer і int в одному виразі з == працює правильно, бо обгортка розпаковується, — саме це й збиває з пантелику.

Pitfalls. Unboxing null throws a NullPointerException on a line with no visible method call: int total = map.get("missing"). The same happens with a JSON field that arrived as null. And mixing Integer and int in one == expression works correctly because the wrapper is unboxed — which is exactly what makes the bug confusing.

Кеш Integer і прихований NullPointerException
The Integer cache and a hidden NullPointerException
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b);        // true: один об’єкт з кешу
System.out.println(c == d);        // false: два різні об’єкти
System.out.println(c.equals(d));   // true: порівняння значень
System.out.println(c == 128);      // true: c розпаковується в int

Map<String, Integer> stock = Map.of("apple", 10);
Integer pears = stock.get("pear");           // null, поки що без помилки
int total = stock.get("apple") + pears;      // NPE: unboxing null
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b);        // true: one cached object
System.out.println(c == d);        // false: two different objects
System.out.println(c.equals(d));   // true: compares values
System.out.println(c == 128);      // true: c is unboxed to int

Map<String, Integer> stock = Map.of("apple", 10);
Integer pears = stock.get("pear");           // null, no error yet
int total = stock.get("apple") + pears;      // NPE: unboxing null
🔧 Розбір синтаксису: Присвоєння літерала змінній типу Integer — це виклик Integer.valueOf, який бере об’єкт з кешу для -128..127. == між двома обгортками порівнює посилання, equals — значення. Коли з одного боку int, компілятор розпаковує обгортку. У виразі з + обидва операнди розпаковуються, і null дає NPE.
🔧 Syntax breakdown: Assigning a literal to an Integer variable calls Integer.valueOf, which takes the object from the cache for -128..127. == between two wrappers compares references, equals compares values. When one side is an int, the compiler unboxes the wrapper. In an expression with + both operands are unboxed, and a null gives an NPE.
💡 Простими словами: До 127 Java видає номерки з однієї коробки, тому вони «однакові». Після 128 кожен отримує новий номерок, і порівнювати треба цифри, а не папірці.
💡 In plain words: Up to 127 Java hands out tickets from one box, so they look "identical". From 128 everyone gets a new ticket, so compare the numbers printed on them, not the paper.
🗣 Як читати вголос Autoboxing — це коли компілятор сам загортає int в Integer через valueOf і розгортає назад. Підступ у тому, що valueOf кешує значення від мінус 128 до 127, тож порівняння через два знаки рівності для малих чисел випадково працює, а для 128 уже ні. У тестах я завжди порівнюю обгортки через equals або assertEquals. І пам’ятаю про другий бік: розпаковування null дає NullPointerException там, де його найменше чекаєш, наприклад при читанні з Map.
🗣 How to say it out loud Autoboxing is the compiler wrapping an int into an Integer through valueOf and unwrapping it back. The catch is that valueOf caches values from minus 128 to 127, so comparing with double equals happens to work for small numbers and breaks at 128. In tests I always compare wrappers with equals or assertEquals. And I keep the other side in mind: unboxing a null throws a NullPointerException where you least expect it, for example when reading from a Map.
9 Що таке String pool і чому String незмінний? What is the String pool, and why is String immutable?
Типи й рядки Types and strings Junior ▶

String pool. Це спеціальна область у heap, де JVM зберігає по одному екземпляру кожного рядкового літерала. Коли в коді двічі написано "admin", обидві змінні отримують посилання на той самий об’єкт. new String("admin") завжди створює новий об’єкт поза пулом, а метод intern() повертає «канонічну» копію з пулу. Рядки, зібрані під час виконання (конкатенація змінних, читання з файлу чи відповіді API), у пул автоматично не потрапляють.

String pool. It is a dedicated area of the heap where the JVM keeps one instance of every string literal. When "admin" appears twice in the code, both variables receive a reference to the same object. new String("admin") always creates a fresh object outside the pool, and intern() returns the canonical copy from the pool. Strings built at run time (concatenating variables, reading a file or an API response) do not enter the pool automatically.

Чому незмінний. Будь-який «змінювальний» метод (toUpperCase, replace, trim) повертає новий рядок, а старий лишається як був. Це дає кілька речей одразу: пул безпечний, бо ніхто не зіпсує спільний літерал; рядок можна передавати між потоками без синхронізації; hashCode обчислюється один раз і кешується, тому рядки — ідеальні ключі HashMap; URL, шлях до файлу чи ім’я класу не можна підмінити після перевірки.

Why immutable. Every "modifying" method (toUpperCase, replace, trim) returns a new string and leaves the old one untouched. That buys several things at once: the pool is safe because nobody can spoil a shared literal; a string can be passed between threads without synchronisation; hashCode is computed once and cached, which makes strings ideal HashMap keys; and a URL, file path or class name cannot be swapped after it was validated.

Приклад з проєкту. Початківець пише token.trim(); і дивується, що в заголовок Authorization пішов рядок з пробілом. Результат треба присвоїти: token = token.trim();.

Project example. A beginner writes token.trim(); and wonders why the Authorization header still carries a trailing space. The result has to be assigned: token = token.trim();.

Підводні камені. Паролі в тестових утилітах краще тримати в char[], який можна затерти, бо незмінний рядок висить у пам’яті до збирання сміття і потрапляє в heap dump. А intern() вручну для «економії пам’яті» майже ніколи не потрібен.

Pitfalls. Passwords in test utilities are better kept in a char[] that can be wiped, because an immutable string stays in memory until collected and ends up in heap dumps. Calling intern() by hand to "save memory" is almost never needed.

Літерали з пулу, new String і результат методів
Pooled literals, new String and method results
String a = "admin";
String b = "admin";
String c = new String("admin");
System.out.println(a == b);            // true: один об’єкт з пулу
System.out.println(a == c);            // false: c створено поза пулом
System.out.println(a == c.intern());   // true: intern повертає копію з пулу

String token = "  abc123  ";
token.trim();                          // результат загублено, token не змінився
System.out.println("[" + token + "]"); // [  abc123  ]
token = token.trim();                  // правильно: присвоюємо новий рядок
System.out.println("[" + token + "]"); // [abc123]
String a = "admin";
String b = "admin";
String c = new String("admin");
System.out.println(a == b);            // true: one pooled object
System.out.println(a == c);            // false: c lives outside the pool
System.out.println(a == c.intern());   // true: intern returns the pooled copy

String token = "  abc123  ";
token.trim();                          // result lost, token is unchanged
System.out.println("[" + token + "]"); // [  abc123  ]
token = token.trim();                  // correct: assign the new string
System.out.println("[" + token + "]"); // [abc123]
🔧 Розбір синтаксису: Два однакові літерали компілюються в посилання на один об’єкт пулу, тому a == b. new String явно просить новий об’єкт. intern() шукає рядок з таким самим вмістом у пулі і повертає його. trim() не змінює token, а повертає новий рядок — без присвоєння результат зникає.
🔧 Syntax breakdown: Two identical literals compile into references to one pooled object, so a == b. new String explicitly asks for a new object. intern() looks up a string with the same content in the pool and returns it. trim() does not change token; it returns a new string, and without assignment the result is lost.
💡 Простими словами: Рядок у Java — надрукований аркуш. Щоб виправити слово, друкують новий аркуш, а старий лишається таким, як був.
💡 In plain words: A Java string is a printed page. To fix a word you print a new page, and the old one stays exactly as it was.
🗣 Як читати вголос Я пояснюю, що JVM тримає літерали в пулі, тому однакові літерали — це один об’єкт, а new String завжди створює новий. Пул можливий саме тому, що рядок незмінний: жоден метод не змінює його, а повертає новий. З цього випливають безпечна передача між потоками і кешований hashCode, завдяки якому рядки — найкращі ключі мап. У тестах найчастіша помилка тут — викликати trim чи replace і забути присвоїти результат.
🗣 How to say it out loud I explain that the JVM keeps literals in a pool, so identical literals are one object, while new String always creates another. The pool is only possible because strings are immutable: no method changes a string, each returns a new one. That also gives safe sharing between threads and a cached hashCode, which makes strings the best map keys. In tests the most common mistake here is calling trim or replace and forgetting to assign the result.
10 Чим відрізняється == від equals() для рядків і як це ламає тести? How does == differ from equals() for strings, and how does it break tests?
Типи й рядки Types and strings Junior ▶

Що це. == для об’єктів порівнює посилання: чи це той самий об’єкт у пам’яті. equals() у класі String порівнює вміст посимвольно. Для рядків, що прийшли з відповіді API, з файлу, з getText() у Selenium чи зібрані конкатенацією, == майже завжди дає false, навіть якщо текст ідентичний.

What it is. For objects == compares references: whether both sides are the same object in memory. equals() in String compares the content character by character. For strings coming from an API response, a file, Selenium's getText() or built by concatenation, == almost always yields false even when the text is identical.

Корисні варіанти. equalsIgnoreCase — порівняння без урахування регістру, зручно для статусів на кшталт «Active» і «ACTIVE». Objects.equals(a, b) безпечний для null: повертає true, якщо обидва null, і не кидає NPE. Прийом «літерал ліворуч» — "ACTIVE".equals(status) — теж захищає від NPE, бо літерал ніколи не null.

Useful variants. equalsIgnoreCase ignores case, handy for statuses like "Active" versus "ACTIVE". Objects.equals(a, b) is null-safe: it returns true when both are null and never throws an NPE. The "literal on the left" trick, "ACTIVE".equals(status), also guards against NPE because a literal is never null.

Приклад з проєкту. Хелпер очікування крутиться, доки status != "DONE". У юніт-тесті з літералом цикл виходить одразу, а з реальним API — чекає до таймауту, бо рядок з JSON — інший об’єкт. Такий код ще й проходить code review, бо виглядає природно.

Project example. A wait helper loops while status != "DONE". In a unit test with a literal the loop exits at once; against the real API it waits until the timeout, because the string parsed from JSON is a different object. Such code even passes code review because it looks natural.

Підводні камені. В асертах assertEquals(expected, actual) і assertThat(actual).isEqualTo(expected) уже використовують equals, тому проблема виникає саме в умовах if, while і в фільтрах stream().filter. Ще одна пастка — невидимі символи: equals чесно поверне false для рядка з нерозривним пробілом, тому при дивних падіннях друкую довжину і коди символів.

Pitfalls. Assertions like assertEquals(expected, actual) and assertThat(actual).isEqualTo(expected) already use equals, so the trouble shows up in if and while conditions and in stream().filter predicates. Another trap is invisible characters: equals honestly returns false for a string with a non-breaking space, so on weird failures I print the length and the character codes.

Умова очікування, яка працює лише з літералами
A wait condition that only works with literals
String fromApi = new StringBuilder("DO").append("NE").toString(); // як рядок з JSON

System.out.println(fromApi == "DONE");               // false: різні об’єкти
System.out.println(fromApi.equals("DONE"));          // true
System.out.println("done".equalsIgnoreCase(fromApi)); // true

String status = null;
// status.equals("ACTIVE");                          // NPE
System.out.println("ACTIVE".equals(status));          // false, без NPE
System.out.println(Objects.equals(status, null));     // true

// Погано: while (status != "DONE") { ... }
// Добре:  while (!"DONE".equals(status)) { ... }
String fromApi = new StringBuilder("DO").append("NE").toString(); // like a string parsed from JSON

System.out.println(fromApi == "DONE");               // false: different objects
System.out.println(fromApi.equals("DONE"));          // true
System.out.println("done".equalsIgnoreCase(fromApi)); // true

String status = null;
// status.equals("ACTIVE");                          // NPE
System.out.println("ACTIVE".equals(status));          // false, no NPE
System.out.println(Objects.equals(status, null));     // true

// Bad: while (status != "DONE") { ... }
// Good: while (!"DONE".equals(status)) { ... }
🔧 Розбір синтаксису: StringBuilder.toString() створює новий рядок під час виконання, як це робить парсер JSON, тому == з літералом дає false. equalsIgnoreCase порівнює без урахування регістру. Виклик методу на літералі "ACTIVE".equals(status) безпечний для null. Objects.equals з пакета java.util обробляє null з обох боків.
🔧 Syntax breakdown: StringBuilder.toString() creates a new string at run time, just like a JSON parser, so == with a literal gives false. equalsIgnoreCase compares ignoring case. Calling the method on the literal, "ACTIVE".equals(status), is null-safe. Objects.equals from java.util handles null on either side.
💡 Простими словами: == питає «це та сама чашка?», а equals — «у чашках той самий чай?». У тестах нас майже завжди цікавить чай.
💡 In plain words: == asks "is it the same cup?", while equals asks "is it the same tea?". In tests we nearly always care about the tea.
🗣 Як читати вголос Для рядків два знаки рівності порівнюють посилання, а equals — вміст, тому текст з API чи з getText через подвійне рівно майже ніколи не збігається з літералом. В асертах це сховано, бо assertEquals викликає equals, а от в умовах циклів очікування і фільтрах я бачив такі баги наживо. Мої звички прості: літерал ліворуч або Objects.equals, щоб не ловити NPE, і equalsIgnoreCase для статусів, де регістр не має значення.
🗣 How to say it out loud For strings double equals compares references and equals compares content, so text from an API or from getText almost never matches a literal with double equals. Assertions hide this because assertEquals calls equals, but I have seen these bugs live in wait loops and filters. My habits are simple: the literal on the left or Objects.equals to avoid an NPE, and equalsIgnoreCase for statuses where case does not matter.
11 Яка різниця між String, StringBuilder і StringBuffer? What is the difference between String, StringBuilder and StringBuffer?
Типи й рядки Types and strings Junior ▶

Що це. String незмінний: кожна конкатенація створює новий об’єкт. StringBuilder — змінний буфер символів: append, insert, reverse, setLength змінюють той самий об’єкт, а toString() віддає готовий рядок. StringBuffer має той самий API, але всі методи synchronized; це спадщина Java 1.0, і в новому коді його майже не використовують.

What it is. String is immutable: every concatenation creates a new object. StringBuilder is a mutable character buffer: append, insert, reverse and setLength change the same object, and toString() returns the finished string. StringBuffer has the same API, but every method is synchronized; it is a Java 1.0 legacy and new code hardly ever uses it.

Коли що брати. Для одного виразу на кшталт "Hello, " + name достатньо +: компілятор сам оптимізує таку конкатенацію. У циклі result += line на кожній ітерації копіює весь накопичений текст, і на тисячах рядків це вже помітна квадратична робота — тут потрібен StringBuilder. Для списку зі стандартним роздільником ще зручніше String.join або Collectors.joining.

What to pick when. For a single expression such as "Hello, " + name plain + is fine: the compiler optimises that concatenation itself. In a loop, result += line copies all the accumulated text on every iteration, and over thousands of lines that becomes noticeable quadratic work — this is where StringBuilder belongs. For a list with a standard separator String.join or Collectors.joining are even handier.

Приклад з проєкту. Тест формує звіт про розбіжності між двома списками користувачів або збирає query-рядок з десятків фільтрів. З StringBuilder код читається як послідовність кроків і не створює сотні проміжних рядків.

Project example. A test builds a report of differences between two user lists, or assembles a query string from dozens of filters. With StringBuilder the code reads as a sequence of steps and does not create hundreds of intermediate strings.

Підводні камені. Один StringBuilder, який пишуть кілька потоків, небезпечний, але StringBuffer — не рішення: у паралельних тестах кожен потік має збирати свій рядок локально. Також StringBuilder не перевизначає equals, тож два білдери з однаковим текстом не рівні — порівнюй їхні toString().

Pitfalls. One StringBuilder written by several threads is unsafe, yet StringBuffer is not the answer: in parallel tests each thread should build its own string locally. Also, StringBuilder does not override equals, so two builders with the same text are not equal — compare their toString() results.

Звіт про розбіжності через StringBuilder
A difference report built with StringBuilder
static String diffReport(List<String> expected, List<String> actual) {
    StringBuilder sb = new StringBuilder("Missing users:\n");
    for (String user : expected) {
        if (!actual.contains(user)) {
            sb.append(" - ").append(user).append('\n'); // ланцюжок змінює той самий буфер
        }
    }
    return sb.toString();
}

// Для простого переліку достатньо join
String csv = String.join(",", List.of("id", "email", "role"));

// Порівнюємо вміст, а не білдери
boolean same = new StringBuilder("a").toString()
        .equals(new StringBuilder("a").toString()); // true
static String diffReport(List<String> expected, List<String> actual) {
    StringBuilder sb = new StringBuilder("Missing users:\n");
    for (String user : expected) {
        if (!actual.contains(user)) {
            sb.append(" - ").append(user).append('\n'); // the chain mutates the same buffer
        }
    }
    return sb.toString();
}

// For a plain list join is enough
String csv = String.join(",", List.of("id", "email", "role"));

// Compare content, not builders
boolean same = new StringBuilder("a").toString()
        .equals(new StringBuilder("a").toString()); // true
🔧 Розбір синтаксису: append повертає той самий StringBuilder, тому виклики можна ланцюжити. append('\n') додає символ, а не рядок. String.join приймає роздільник і колекцію або varargs. Останній приклад показує, що порівнювати треба результати toString(), бо StringBuilder успадковує equals від Object.
🔧 Syntax breakdown: append returns the same StringBuilder, so calls can be chained. append('\n') adds a character, not a string. String.join takes a separator and a collection or varargs. The last example shows that you compare toString() results, because StringBuilder inherits equals from Object.
💡 Простими словами: String — це кожного разу новий аркуш, StringBuilder — чернетка, в яку дописуєш, доки не переписав начисто.
💡 In plain words: String is a new sheet every time; StringBuilder is a draft you keep adding to until you copy it out neatly.
🗣 Як читати вголос Я кажу так: String незмінний, тому конкатенація в циклі щоразу копіює весь текст, а StringBuilder дописує в той самий буфер. Для одного виразу з плюсом компілятор усе зробить сам, а от звіт у циклі чи довгий query я збираю білдером. StringBuffer — синхронізована стара версія, і в тестах вона не рятує від гонок: кожен потік має свій локальний білдер. І ще нюанс для асертів — білдери порівнюю через toString.
🗣 How to say it out loud I put it this way: String is immutable, so concatenation in a loop copies the whole text each time, while StringBuilder appends to one buffer. For a single expression with plus the compiler handles it, but a report in a loop or a long query I build with a builder. StringBuffer is the old synchronised version and it does not save tests from races: each thread gets its own local builder. One more detail for assertions: I compare builders through toString.
12 Що повертає charAt() і які пастки має split() при розборі тестових даних? What does charAt() return, and what traps does split() have when parsing test data?
Типи й рядки Types and strings Junior ▶

charAt. s.charAt(i) повертає примітив char — один символ UTF-16 за індексом від 0 до length() - 1. Інший індекс дає StringIndexOutOfBoundsException. Оскільки це char, а не String, порівнюють його через == з одинарними лапками: s.charAt(0) == 'A'. Арифметика теж працює як з числом: '7' - '0' дає 7. Емодзі та деякі ієрогліфи займають два char, тому для них потрібні codePoints().

charAt. s.charAt(i) returns a primitive char, one UTF-16 unit at an index from 0 to length() - 1. Any other index throws StringIndexOutOfBoundsException. Because it is a char rather than a String, you compare it with == and single quotes: s.charAt(0) == 'A'. Arithmetic works as with a number too: '7' - '0' yields 7. Emoji and some CJK characters occupy two chars, so they need codePoints().

split і регулярні вирази. split приймає регулярний вираз, а не просто роздільник. Тому "1.2.3".split(".") повертає порожній масив: крапка означає «будь-який символ». Те саме з |, +, *, $. Треба екранувати ("\\.") або використати Pattern.quote.

split and regular expressions. split takes a regular expression, not a plain separator. So "1.2.3".split(".") returns an empty array: a dot means "any character". The same goes for |, +, * and $. You have to escape them ("\\.") or use Pattern.quote.

Порожні хвости. За замовчуванням split відкидає порожні рядки в кінці: "a,b,,".split(",") дає два елементи, а не чотири. Для CSV з порожніми останніми колонками це зсуває дані. Рішення — split(",", -1), який зберігає всі поля.

Empty tails. By default split drops trailing empty strings: "a,b,,".split(",") yields two elements, not four. For CSV with empty last columns this shifts the data. The fix is split(",", -1), which keeps every field.

Приклад з проєкту. Тест читає рядок "anna@example.com;Admin;;" з файлу даних і бере parts[3] — отримує виняток, бо хвіст обрізано. З лімітом -1 усі чотири поля на місці. Для справжнього CSV з лапками й комами всередині краще бібліотека на кшталт OpenCSV чи Jackson CSV.

Project example. A test reads the line "anna@example.com;Admin;;" from a data file and takes parts[3] — and gets an exception because the tail was trimmed. With limit -1 all four fields are there. For real CSV with quotes and commas inside values a library such as OpenCSV or Jackson CSV is the better choice.

Розбір рядка тестових даних без сюрпризів
Parsing a test data line without surprises
String line = "anna@example.com;Admin;;";
String[] lost = line.split(";");        // [anna@example.com, Admin] - хвіст обрізано
String[] all = line.split(";", -1);     // 4 поля, включно з порожніми
System.out.println(lost.length + " vs " + all.length); // 2 vs 4

String version = "1.2.3";
System.out.println(version.split(".").length);               // 0: крапка - будь-який символ
System.out.println(version.split("\\.").length);             // 3
System.out.println(version.split(Pattern.quote(".")).length); // 3

char first = line.charAt(0);            // 'a', це char, не String
boolean isLetter = Character.isLetter(first);
int digit = "7".charAt(0) - '0';        // 7
String line = "anna@example.com;Admin;;";
String[] lost = line.split(";");        // [anna@example.com, Admin] - tail dropped
String[] all = line.split(";", -1);     // 4 fields, empty ones included
System.out.println(lost.length + " vs " + all.length); // 2 vs 4

String version = "1.2.3";
System.out.println(version.split(".").length);               // 0: dot means any character
System.out.println(version.split("\\.").length);             // 3
System.out.println(version.split(Pattern.quote(".")).length); // 3

char first = line.charAt(0);            // 'a', a char, not a String
boolean isLetter = Character.isLetter(first);
int digit = "7".charAt(0) - '0';        // 7
🔧 Розбір синтаксису: Другий аргумент split — ліміт: від’ємне значення означає «не обмежувати і не відкидати порожні хвости». "\\." у Java-рядку — це регулярний вираз \., тобто буквальна крапка. Pattern.quote екранує будь-який рядок цілком. Character.isLetter працює з char, а віднімання '0' перетворює символ цифри на число.
🔧 Syntax breakdown: The second split argument is a limit: a negative value means "no limit and keep empty trailing strings". "\\." in a Java string is the regex \., a literal dot. Pattern.quote escapes any string as a whole. Character.isLetter works on a char, and subtracting '0' turns a digit character into a number.
💡 Простими словами: split слухає мову регулярних виразів, а не людську: для нього крапка — «що завгодно». А порожні колонки в кінці він тихо викидає, якщо не попросити інакше.
💡 In plain words: split speaks regex, not human: to it a dot means "anything". And it quietly throws away empty trailing columns unless you ask otherwise.
🗣 Як читати вголос Про charAt кажу коротко: повертає примітив char, індекс з нуля, вихід за межі — виняток, порівнюю з одинарними лапками. Про split розповідаю дві пастки, на яких сам колись спіткнувся. Перша — аргумент це регулярний вираз, тож крапку чи вертикальну риску треба екранувати або обгорнути в Pattern.quote. Друга — порожні поля в кінці рядка зникають, тому для файлів тестових даних я завжди передаю ліміт мінус один.
🗣 How to say it out loud On charAt I keep it short: it returns a primitive char, indexes start at zero, going out of range throws, and I compare with single quotes. On split I describe two traps I once fell into myself. First, the argument is a regular expression, so a dot or a pipe must be escaped or wrapped in Pattern.quote. Second, empty fields at the end of the line disappear, so for test data files I always pass a limit of minus one.
13 Що дають var і switch expression і де вони доречні в тестовому коді? What do var and switch expressions give you, and where do they fit in test code?
Типи й рядки Types and strings Middle ▶

var (Java 10+). Ключове слово для локальних змінних: тип виводиться компілятором з правої частини і далі фіксований, тобто Java лишається статично типізованою. var не можна використати для полів, параметрів методів і типу повернення, і без ініціалізатора або з null воно не компілюється. Воно доречне, коли тип і так видно: var users = new ArrayList<UserDto>() чи var response = given().get("/users").

var (Java 10+). A keyword for local variables: the compiler infers the type from the right-hand side and then fixes it, so Java stays statically typed. var cannot be used for fields, method parameters or return types, and it does not compile without an initialiser or with null. It fits where the type is obvious anyway: var users = new ArrayList<UserDto>() or var response = given().get("/users").

switch expression (Java 14+). switch став виразом, який повертає значення. Стрілка case X -> не має провалювання між гілками, кілька міток пишуться через кому, а для блоку з кількох рядків значення повертає yield. Якщо перемикаєшся по enum і покрив усі константи, default не потрібен: коли додадуть нове оточення, компілятор змусить його обробити.

switch expression (Java 14+). switch became an expression that yields a value. The arrow form case X -> has no fall-through between branches, several labels are separated by commas, and a multi-line block returns its value with yield. When switching over an enum and covering every constant, default is unnecessary: when someone adds a new environment, the compiler forces it to be handled.

Приклад з проєкту. Базовий URL за оточенням з System.getProperty("env"): Env перетворюється на адресу одним виразом без змінної, яку треба «не забути» присвоїти в кожній гілці.

Project example. A base URL chosen by environment from System.getProperty("env"): an Env becomes an address in one expression, without a variable you must "remember" to assign in every branch.

Підводні камені. var з new ArrayList<>() без типу в дужках дає ArrayList<Object>. var з літералом var id = 10 дає int, а не long. Надмірний var там, де праворуч виклик невідомого методу, погіршує читабельність у code review без IDE.

Pitfalls. var with new ArrayList<>() and nothing in the diamond gives ArrayList<Object>. var with a literal, var id = 10, gives int, not long. Overusing var where the right side is a call to an unfamiliar method hurts readability in a code review without an IDE.

Базовий URL за оточенням через enum і switch expression
A base URL per environment with an enum and a switch expression
enum Env { LOCAL, STAGE, PROD }

static String baseUrl(Env env) {
    return switch (env) {                       // немає default: компілятор перевіряє всі константи
        case LOCAL -> "http://localhost:8080";
        case STAGE -> "https://stage.shop.example.com";
        case PROD -> {
            if (!Boolean.getBoolean("allowProd")) {
                throw new IllegalStateException("Tests against PROD are disabled");
            }
            yield "https://shop.example.com";   // yield повертає значення з блоку
        }
    };
}

public static void main(String[] args) {
    var env = Env.valueOf(System.getProperty("env", "LOCAL").toUpperCase()); // var: тип Env виведено
    var url = baseUrl(env);
    System.out.println(url);
}
enum Env { LOCAL, STAGE, PROD }

static String baseUrl(Env env) {
    return switch (env) {                       // no default: the compiler checks every constant
        case LOCAL -> "http://localhost:8080";
        case STAGE -> "https://stage.shop.example.com";
        case PROD -> {
            if (!Boolean.getBoolean("allowProd")) {
                throw new IllegalStateException("Tests against PROD are disabled");
            }
            yield "https://shop.example.com";   // yield returns the value of a block
        }
    };
}

public static void main(String[] args) {
    var env = Env.valueOf(System.getProperty("env", "LOCAL").toUpperCase()); // var: type Env is inferred
    var url = baseUrl(env);
    System.out.println(url);
}
🔧 Розбір синтаксису: return switch (env) { ... }; — вираз, тому після фігурної дужки стоїть крапка з комою. Гілка зі стрілкою може бути виразом, throw або блоком з yield. Boolean.getBoolean читає системну властивість JVM, а не рядок. Env.valueOf кидає IllegalArgumentException для невідомого імені, тож помилка в -Denv видна одразу.
🔧 Syntax breakdown: return switch (env) { ... }; is an expression, so a semicolon follows the closing brace. An arrow branch can be an expression, a throw or a block with yield. Boolean.getBoolean reads a JVM system property, not a string. Env.valueOf throws IllegalArgumentException for an unknown name, so a typo in -Denv shows up immediately.
💡 Простими словами: var — це «компілятор, запиши тип сам», а не динамічна типізація. Switch expression — таблиця відповідностей, у якій компілятор не дасть пропустити рядок.
💡 In plain words: var means "compiler, write the type down for me", not dynamic typing. A switch expression is a lookup table in which the compiler will not let you skip a row.
🗣 Як читати вголос Про var я одразу кажу, що це не динамічна типізація: тип виводиться один раз і далі фіксований, і працює це лише для локальних змінних. Я використовую його, коли тип очевидний з правої частини. Switch expression люблю за три речі: він повертає значення, стрілки не провалюються між гілками, а для enum компілятор перевіряє, що я покрив усі варіанти. Типовий приклад у фреймворку — вибір базового URL за оточенням.
🗣 How to say it out loud About var I say straight away that it is not dynamic typing: the type is inferred once and then fixed, and it only works for local variables. I use it when the type is obvious from the right-hand side. I like switch expressions for three things: they return a value, arrows do not fall through, and with an enum the compiler checks that every case is covered. The typical framework example is choosing a base URL per environment.
14 Що таке text blocks і як вони спрощують JSON у тестах? What are text blocks, and how do they simplify JSON in tests?
Типи й рядки Types and strings Junior ▶

Що це. Text block (Java 15+) — багаторядковий рядковий літерал, що починається з трьох подвійних лапок і переходу на новий рядок і закінчується трьома лапками. Усередині не треба екранувати звичайні лапки й писати \n: JSON, SQL чи XML виглядають у коді так само, як у Postman чи в документації. Результат — звичайний String з пулу.

What it is. A text block (Java 15+) is a multi-line string literal that opens with three double quotes and a line break and closes with three quotes. Inside it you do not escape ordinary quotes or write \n: JSON, SQL or XML look in code exactly as they do in Postman or in the docs. The result is a plain pooled String.

Відступи. Компілятор прибирає спільний відступ усіх рядків, орієнтуючись на найлівіший рядок або на позицію закривальних лапок. Тому блок можна гарно вирівняти з кодом методу, а в рядку не буде зайвих пробілів. Пробіли в кінці рядків видаляються; якщо вони потрібні, є \s. Символ \ наприкінці рядка склеює його з наступним без переносу.

Indentation. The compiler strips the indentation common to all lines, using the leftmost line or the position of the closing quotes as the margin. So the block can be aligned nicely with the method code and the string gets no extra spaces. Trailing spaces on lines are removed; if you need them there is \s. A \ at the end of a line joins it with the next one without a line break.

Приклад з проєкту. Тіло POST-запиту для створення користувача з параметрами: шаблон у text block плюс formatted(name, role). Порівняно зі склеюванням через + і \" такий JSON легко читати і копіювати з логів назад у тест.

Project example. The body of a POST request that creates a user with parameters: a template in a text block plus formatted(name, role). Compared to gluing with + and \", such JSON is easy to read and to copy from logs back into the test.

Підводні камені. formatted не екранує значення: ім’я з лапками зламає JSON, тому для складних або ворожих даних краще серіалізувати DTO через Jackson. Знак % у шаблоні треба писати як %%. І перенос рядка в кінці блоку з’являється, якщо закривальні лапки стоять на окремому рядку.

Pitfalls. formatted does not escape values: a name containing quotes breaks the JSON, so for complex or hostile data serialising a DTO with Jackson is better. A % sign in the template must be written as %%. And a trailing newline appears at the end of the block when the closing quotes sit on their own line.

Тіло запиту з text block і formatted
A request body with a text block and formatted
String body = """
        {
          "name": "%s",
          "role": "%s",
          "active": true
        }
        """.formatted("Anna", "ADMIN");

String sql = """
        SELECT id, email \
        FROM users WHERE role = 'ADMIN'""";   // \ склеює рядки, лапки в кінці - без \n

// given().contentType("application/json").body(body).post("/users");
System.out.println(body);
System.out.println(sql);   // SELECT id, email FROM users WHERE role = 'ADMIN'
String body = """
        {
          "name": "%s",
          "role": "%s",
          "active": true
        }
        """.formatted("Anna", "ADMIN");

String sql = """
        SELECT id, email \
        FROM users WHERE role = 'ADMIN'""";   // \ joins lines, closing quotes on the line - no \n

// given().contentType("application/json").body(body).post("/users");
System.out.println(body);
System.out.println(sql);   // SELECT id, email FROM users WHERE role = 'ADMIN'
🔧 Розбір синтаксису: Відкривальні три лапки обов’язково стоять у кінці рядка, вміст починається з наступного. Спільний відступ у 8 пробілів компілятор прибирає, бо закривальні лапки першого блоку вирівняні так само. %s підставляє аргументи formatted, що працює як String.format. Зворотна скісна риска в кінці рядка SQL прибирає перенос, а закривальні лапки на тому ж рядку не додають фінального \n.
🔧 Syntax breakdown: The opening three quotes must end their line; the content starts on the next one. The compiler strips the common 8-space indentation because the closing quotes of the first block are aligned the same way. %s takes the arguments of formatted, which works like String.format. The backslash at the end of the SQL line removes the line break, and closing quotes on the same line add no final \n.
💡 Простими словами: Text block — це «вставити як є»: JSON у тесті виглядає так само, як у документації до API, без частоколу з лапок і плюсів.
💡 In plain words: A text block is "paste as is": JSON in a test looks just like it does in the API docs, without a fence of quotes and plus signs.
🗣 Як читати вголос Text blocks я використовую для тіл запитів і очікуваних відповідей: JSON у тесті виглядає як у документації, і його легко скопіювати з логу. Компілятор сам прибирає спільний відступ, а параметри я підставляю через formatted. Але знаю межі: formatted нічого не екранує, тож для даних з лапками чи довільним текстом я серіалізую DTO через Jackson, а text block лишаю для статичних і простих шаблонів.
🗣 How to say it out loud I use text blocks for request bodies and expected responses: the JSON in the test looks like the documentation and is easy to copy from a log. The compiler strips the common indentation itself, and I plug parameters in with formatted. But I know the limits: formatted escapes nothing, so for data with quotes or free text I serialise a DTO with Jackson and keep text blocks for static and simple templates.
15 Як працюють instanceof і pattern matching у Java 16–21? How do instanceof and pattern matching work in Java 16–21?
Типи й рядки Types and strings Middle ▶

instanceof. Оператор перевіряє, чи об’єкт є екземпляром класу або інтерфейсу, враховуючи спадкування. Для null він завжди повертає false, тому окрема перевірка на null перед ним не потрібна. Класичний код після перевірки робив явне приведення типу, яке дублювало інформацію.

instanceof. The operator checks whether an object is an instance of a class or interface, inheritance included. For null it always returns false, so no separate null check is needed before it. Classic code followed the check with an explicit cast that repeated the same information.

Pattern matching для instanceof (Java 16). Запис if (e instanceof HttpException http) одночасно перевіряє тип і оголошує змінну http потрібного типу. Змінна доступна лише там, де перевірка точно пройшла, зокрема в правій частині &&: obj instanceof String s && !s.isBlank().

Pattern matching for instanceof (Java 16). Writing if (e instanceof HttpException http) checks the type and declares a variable http of that type in one go. The variable is in scope only where the check definitely passed, including the right side of &&: obj instanceof String s && !s.isBlank().

Pattern matching у switch (Java 21). switch може розгалужуватися за типом: case TimeoutException t ->, з охоронною умовою when і з record patterns, які одразу розбирають запис на компоненти: case ApiError(var code, var msg) ->. Для sealed-ієрархій компілятор перевіряє, що покриті всі підтипи.

Pattern matching in switch (Java 21). switch can branch on type: case TimeoutException t ->, with a when guard, and with record patterns that take a record apart into components right away: case ApiError(var code, var msg) ->. For sealed hierarchies the compiler checks that every subtype is covered.

Приклад з проєкту. Хелпер ретраїв вирішує, чи повторювати дію: таймаут і 503 — так, 4xx і помилка асерту — ні. З pattern matching це одна читабельна конструкція замість ланцюжка if з приведеннями.

Project example. A retry helper decides whether to repeat an action: a timeout and a 503 mean yes, a 4xx and an assertion failure mean no. With pattern matching that is one readable construct instead of a chain of ifs with casts.

Підводні камені. Довгі ланцюжки instanceof по власних класах часто означають, що поведінку варто перенести в поліморфний метод. А в switch порядок важливий: загальніший тип вище за конкретний не скомпілюється.

Pitfalls. Long instanceof chains over your own classes often mean the behaviour belongs in a polymorphic method. And in a switch order matters: a more general type placed above a specific one does not compile.

Рішення про повтор дії через pattern matching
Deciding on a retry with pattern matching
record ApiError(int status, String message) {}

static boolean shouldRetry(Object failure) {
    return switch (failure) {
        case java.util.concurrent.TimeoutException t -> true;
        case ApiError(var status, var msg) when status == 503 -> true; // record pattern + when
        case ApiError e -> false;                  // решта HTTP-помилок
        case AssertionError e -> false;            // баг продукту не ретраїмо
        case null, default -> false;
    };
}

static void log(Object value) {
    // Java 16: перевірка типу і змінна одним записом
    if (value instanceof String s && !s.isBlank()) {
        System.out.println("text: " + s.strip());
    }
}
record ApiError(int status, String message) {}

static boolean shouldRetry(Object failure) {
    return switch (failure) {
        case java.util.concurrent.TimeoutException t -> true;
        case ApiError(var status, var msg) when status == 503 -> true; // record pattern + when
        case ApiError e -> false;                  // other HTTP errors
        case AssertionError e -> false;            // never retry a product bug
        case null, default -> false;
    };
}

static void log(Object value) {
    // Java 16: type check and variable in one go
    if (value instanceof String s && !s.isBlank()) {
        System.out.println("text: " + s.strip());
    }
}
🔧 Розбір синтаксису: case ApiError(var status, var msg) — record pattern: він перевіряє тип і витягає компоненти через аксесори. when додає умову до гілки. Гілка case ApiError e стоїть нижче за вужчу з when, інакше вужча була б недосяжною. case null, default обробляє і null, і все інше; без явного case null switch по null кинув би NPE.
🔧 Syntax breakdown: case ApiError(var status, var msg) is a record pattern: it checks the type and extracts the components through accessors. when adds a condition to the branch. case ApiError e sits below the narrower guarded branch, otherwise the narrower one would be unreachable. case null, default handles both null and everything else; without an explicit case null a switch on null would throw an NPE.
💡 Простими словами: Pattern matching — це «перевір і одразу назви»: питаєш «це помилка API?» і отримуєш її код, не перепитуючи.
💡 In plain words: Pattern matching is "check and name it at once": you ask "is this an API error?" and get its status code without asking twice.
🗣 Як читати вголос Я пояснюю еволюцію: спочатку instanceof і ручне приведення, з Java 16 — перевірка і змінна одним записом, а з Java 21 — switch за типами з when і record patterns. У фреймворку я застосовую це, наприклад, у логіці ретраїв: таймаут і 503 повторюю, а помилку асерту — ніколи. Водночас якщо бачу довгий ланцюжок instanceof по власних класах, думаю, чи не краще перенести поведінку в поліморфний метод.
🗣 How to say it out loud I explain the evolution: first instanceof with a manual cast, from Java 16 the check and the variable in one step, and from Java 21 a switch over types with when guards and record patterns. In a framework I use it, for instance, in retry logic: a timeout and a 503 get retried, an assertion failure never does. At the same time, when I see a long instanceof chain over our own classes, I ask whether the behaviour should move into a polymorphic method.
▶ 3 9
16 Які чотири принципи ООП і де кожен з них видно в тестовому фреймворку? What are the four OOP principles, and where does each show up in a test framework?
ООП OOP Junior ▶

Що це. Чотири класичні принципи — інкапсуляція, успадкування, поліморфізм і абстракція. На співбесіді автоматизатора мало їх перелічити: інтерв’юер чекає, що ти покажеш кожен на власному фреймворку, бо саме там видно, чи розумієш ти їх, чи вивчив визначення.

What it is. The four classic principles are encapsulation, inheritance, polymorphism and abstraction. In an automation interview listing them is not enough: the interviewer expects you to show each one in your own framework, because that is where it becomes clear whether you understand them or just memorised the definitions.

Як це виглядає в коді тестів. Інкапсуляція — Page Object ховає локатори й драйвер у приватних полях і віддає назовні лише дії на кшталт login(user). Успадкування — BaseTest з підготовкою драйвера і BasePage зі спільними очікуваннями, від яких наслідуються конкретні класи. Поліморфізм — змінна типу WebDriver може вказувати на ChromeDriver, FirefoxDriver чи RemoteWebDriver, а тест про це не знає. Абстракція — UserApiClient з методом createUser(user), за яким сховані HTTP-метод, заголовки, серіалізація і шлях ендпойнта.

What it looks like in test code. Encapsulation: a Page Object keeps locators and the driver in private fields and exposes only actions such as login(user). Inheritance: a BaseTest that prepares the driver and a BasePage with shared waits that concrete classes extend. Polymorphism: a variable of type WebDriver can point to ChromeDriver, FirefoxDriver or RemoteWebDriver, and the test does not know which. Abstraction: a UserApiClient with a createUser(user) method that hides the HTTP verb, headers, serialisation and endpoint path.

Приклад з проєкту. Коли команда перейшла з локального Chrome на Selenoid, змінилася лише фабрика драйверів: тести працювали з інтерфейсом WebDriver і не помітили заміни. Це поліморфізм і абстракція, які окупилися за один день.

Project example. When the team moved from a local Chrome to Selenoid, only the driver factory changed: the tests talked to the WebDriver interface and never noticed the swap. That is polymorphism and abstraction paying off within a day.

Підводні камені. Найчастіше надуживають успадкуванням: BasePage на 800 рядків, де зібрано все підряд. Успадкування — найсильніший зв’язок між класами, тому спершу варто спитати себе, чи не краще тут композиція.

Pitfalls. The most abused principle is inheritance: an 800-line BasePage that collects everything. Inheritance is the tightest coupling between classes, so first ask whether composition would fit better.

Чотири принципи в одному маленькому фреймворку
The four principles in one small framework
public abstract class BasePage {                 // успадкування: спільна основа сторінок
    protected final WebDriver driver;            // поліморфізм: будь-яка реалізація WebDriver
    protected BasePage(WebDriver driver) { this.driver = driver; }
    public abstract boolean isOpened();          // абстракція: кожна сторінка вирішує сама
}

public class LoginPage extends BasePage {
    private final By email = By.id("email");     // інкапсуляція: локатори приховані
    private final By submit = By.cssSelector("button[type=submit]");

    public LoginPage(WebDriver driver) { super(driver); }

    @Override
    public boolean isOpened() { return driver.findElement(email).isDisplayed(); }

    public void loginAs(String user) {
        driver.findElement(email).sendKeys(user);
        driver.findElement(submit).click();
    }
}
// WebDriver driver = new ChromeDriver();  або new FirefoxDriver() — тест не змінюється
public abstract class BasePage {                 // inheritance: common base for pages
    protected final WebDriver driver;            // polymorphism: any WebDriver implementation
    protected BasePage(WebDriver driver) { this.driver = driver; }
    public abstract boolean isOpened();          // abstraction: each page decides itself
}

public class LoginPage extends BasePage {
    private final By email = By.id("email");     // encapsulation: locators are hidden
    private final By submit = By.cssSelector("button[type=submit]");

    public LoginPage(WebDriver driver) { super(driver); }

    @Override
    public boolean isOpened() { return driver.findElement(email).isDisplayed(); }

    public void loginAs(String user) {
        driver.findElement(email).sendKeys(user);
        driver.findElement(submit).click();
    }
}
// WebDriver driver = new ChromeDriver();  or new FirefoxDriver() — the test stays the same
🔧 Розбір синтаксису: abstract class BasePage не можна створити напряму, лише успадкувати. Поле protected final WebDriver бачать підкласи, але не тести з іншого пакета. abstract boolean isOpened() змушує кожну сторінку дати власну реалізацію, а @Override просить компілятор перевірити, що сигнатура справді збігається. Приватні поля By недоступні ззовні, тож тест бачить лише loginAs.
🔧 Syntax breakdown: abstract class BasePage cannot be instantiated directly, only extended. The protected final WebDriver field is visible to subclasses but not to tests in another package. abstract boolean isOpened() forces every page to provide its own implementation, and @Override asks the compiler to verify that the signature really matches. The private By fields are invisible from outside, so a test only sees loginAs.
💡 Простими словами: Інкапсуляція ховає, успадкування ділиться, поліморфізм підміняє, абстракція спрощує. Чотири дієслова, які легко згадати під стресом.
💡 In plain words: Encapsulation hides, inheritance shares, polymorphism substitutes, abstraction simplifies. Four verbs that are easy to recall under stress.
🗣 Як читати вголос Я не зачитую визначення, а одразу показую принципи на своєму фреймворку. Page Object у мене інкапсулює локатори, BaseTest і BasePage дають успадкування спільного налаштування, поле типу WebDriver — це поліморфізм, бо за ним може стояти Chrome чи Selenoid, а API-клієнт — абстракція над HTTP. І обов’язково додаю, що успадкуванням легко перестаратися, тому глибокі ієрархії я замінюю компонентами.
🗣 How to say it out loud I do not recite definitions, I show the principles on my own framework straight away. My Page Objects encapsulate locators, BaseTest and BasePage give inheritance of shared setup, a field typed as WebDriver is polymorphism because Chrome or Selenoid can sit behind it, and the API client is an abstraction over HTTP. And I always add that inheritance is easy to overdo, so I replace deep hierarchies with components.
17 Що таке інкапсуляція і як вона працює в Page Object? What is encapsulation, and how does it work in a Page Object?
ООП OOP Junior ▶

Що це. Інкапсуляція — це приховування внутрішнього стану об’єкта і доступ до нього лише через методи, які гарантують коректність. Поля роблять private, а назовні відкривають поведінку. Мета не в «секретності», а в тому, щоб зміни всередині класу не ламали код, який ним користується.

What it is. Encapsulation means hiding an object's internal state and allowing access only through methods that keep it valid. Fields become private, and behaviour is what gets exposed. The point is not secrecy but making sure changes inside the class do not break the code that uses it.

Навіщо в Page Object. Тест має говорити мовою бізнесу: «увійти як адміністратор», «додати товар у кошик». Локатори, очікування і послідовність кліків — деталі сторінки. Коли фронтенд перейменує id="email" на data-test="login-email", виправлення буде в одному приватному полі, а не в сорока тестах. Методи повертають наступну сторінку або дані (String, int, record), але не WebElement.

Why in a Page Object. A test should speak the business language: "log in as admin", "add an item to the cart". Locators, waits and click sequences are details of the page. When the frontend renames id="email" to data-test="login-email", the fix lives in one private field instead of forty tests. Methods return the next page or data (String, int, a record), never a WebElement.

Приклад з проєкту. У наборі було 120 тестів, які викликали page.getEmailField().sendKeys(...). Після редизайну форми впали всі. Після рефакторингу на loginAs(user) наступний редизайн зачепив лише один клас.

Project example. A suite had 120 tests calling page.getEmailField().sendKeys(...). After a form redesign every one of them failed. After refactoring to loginAs(user), the next redesign touched a single class.

Підводні камені. Гетер на кожне поле — це інкапсуляція лише на папері: getSubmitButton() так само протікає, як публічне поле. Друга крайність — асерти всередині Page Object: сторінка має повідомляти стан, а рішення «пройшов чи ні» лишається за тестом.

Pitfalls. A getter for every field is encapsulation on paper only: getSubmitButton() leaks just as much as a public field. The opposite extreme is assertions inside the Page Object: the page should report its state, while the pass-or-fail decision stays in the test.

Протікаючий Page Object і інкапсульований
A leaky Page Object versus an encapsulated one
// Погано: тест керує елементами напряму
public class LeakyCartPage {
    public WebElement quantityInput;
    public WebElement getCheckoutButton() { return quantityInput.findElement(By.xpath("//button")); }
}

// Добре: назовні лише намір і дані
public class CartPage {
    private final WebDriver driver;
    private final By quantity = By.cssSelector("[data-test=qty]");
    private final By total = By.cssSelector("[data-test=total]");
    private final By checkout = By.cssSelector("[data-test=checkout]");

    public CartPage(WebDriver driver) { this.driver = driver; }

    public CartPage setQuantity(int value) {
        WebElement input = driver.findElement(quantity);
        input.clear();
        input.sendKeys(String.valueOf(value));
        return this;
    }

    public String totalText() { return driver.findElement(total).getText(); }

    public CheckoutPage proceedToCheckout() {
        driver.findElement(checkout).click();
        return new CheckoutPage(driver);
    }
}
// Тест: assertEquals("$40.00", cart.setQuantity(2).totalText());
// Bad: the test drives elements directly
public class LeakyCartPage {
    public WebElement quantityInput;
    public WebElement getCheckoutButton() { return quantityInput.findElement(By.xpath("//button")); }
}

// Good: only intent and data are exposed
public class CartPage {
    private final WebDriver driver;
    private final By quantity = By.cssSelector("[data-test=qty]");
    private final By total = By.cssSelector("[data-test=total]");
    private final By checkout = By.cssSelector("[data-test=checkout]");

    public CartPage(WebDriver driver) { this.driver = driver; }

    public CartPage setQuantity(int value) {
        WebElement input = driver.findElement(quantity);
        input.clear();
        input.sendKeys(String.valueOf(value));
        return this;
    }

    public String totalText() { return driver.findElement(total).getText(); }

    public CheckoutPage proceedToCheckout() {
        driver.findElement(checkout).click();
        return new CheckoutPage(driver);
    }
}
// Test: assertEquals("$40.00", cart.setQuantity(2).totalText());
🔧 Розбір синтаксису: Поля private final By задаються один раз і недоступні тестам. setQuantity повертає this, тож виклики можна ланцюжити. totalText() віддає рядок, а не елемент, і асерт лишається в тесті. proceedToCheckout() повертає об’єкт наступної сторінки — так навігація теж інкапсульована і читається як сценарій.
🔧 Syntax breakdown: The private final By fields are set once and are unreachable from tests. setQuantity returns this, so calls can be chained. totalText() returns a string rather than an element, and the assertion stays in the test. proceedToCheckout() returns the next page object, so navigation is encapsulated too and reads like a scenario.
💡 Простими словами: Page Object — це пульт від телевізора: кнопки «гучніше» і «наступний канал», а не доступ до плати всередині.
💡 In plain words: A Page Object is a TV remote: buttons for "louder" and "next channel", not access to the circuit board inside.
🗣 Як читати вголос Для мене інкапсуляція в Page Object означає просте правило: тест не знає жодного локатора. Усі By і сам драйвер приватні, а публічні методи описують дії користувача і повертають або наступну сторінку, або звичайні дані. Я не віддаю WebElement назовні і не пишу гетер на кожне поле, бо тоді редизайн знову розповзеться по тестах. А перевірки тримаю в тестах, щоб сторінка лише повідомляла стан.
🗣 How to say it out loud For me encapsulation in a Page Object comes down to one rule: the test knows no locators at all. Every By and the driver itself are private, and public methods describe user actions and return either the next page or plain data. I never hand out a WebElement and I do not write a getter for every field, because then a redesign spreads across the tests again. Assertions stay in tests, so the page only reports its state.
18 Що таке поліморфізм і чим overriding відрізняється від overloading? What is polymorphism, and how does overriding differ from overloading?
ООП OOP Junior ▶

Поліморфізм. Один інтерфейс — багато реалізацій. Змінна оголошена типом батька чи інтерфейсу, а метод, який реально виконається, JVM обирає під час виконання за фактичним класом об’єкта. WebDriver driver = new ChromeDriver() — хрестоматійний приклад: driver.get(url) виконає код Chrome, хоча тип змінної — інтерфейс.

Polymorphism. One interface, many implementations. A variable is declared with a parent or interface type, and the method that actually runs is chosen by the JVM at runtime from the object's real class. WebDriver driver = new ChromeDriver() is the textbook case: driver.get(url) runs Chrome's code even though the variable type is an interface.

Overriding. Перевизначення — підклас дає власну реалізацію методу з тією самою сигнатурою. Це динамічний, runtime-поліморфізм. Правила: той самий список параметрів, тип результату такий самий або вужчий (коваріантний), доступ не можна звузити, нових checked-винятків додавати не можна. Анотація @Override змушує компілятор перевірити, що ти справді перевизначаєш, а не створюєш новий метод через одруківку. Статичні й private методи не перевизначаються.

Overriding. A subclass provides its own implementation of a method with the same signature. This is dynamic, runtime polymorphism. Rules: the same parameter list, the same or a narrower (covariant) return type, no narrower access, and no new checked exceptions. The @Override annotation makes the compiler confirm that you really override rather than create a new method through a typo. Static and private methods are not overridden.

Overloading. Перевантаження — кілька методів з одним ім’ям, але різними параметрами в одному класі. Вибір робить компілятор за типами аргументів, тобто це compile-time. Приклад з фреймворку: login() з типовим користувачем, login(User user) і login(String email, String password).

Overloading. Several methods with the same name but different parameters in one class. The compiler picks one based on argument types, so this is compile time. A framework example: login() with a default user, login(User user) and login(String email, String password).

Підводні камені. Лише інший тип результату — це не перевантаження, код не скомпілюється. І перевантаження з null чи з автобоксингом (int проти Integer, long) може обрати не той метод, якого ти чекав.

Pitfalls. A different return type alone is not overloading, the code will not compile. And overloads combined with null or autoboxing (int versus Integer or long) can select a method you did not expect.

Перевизначення і перевантаження поруч
Overriding and overloading side by side
abstract class BasePage {
    abstract String title();
    BasePage open() { return this; }
}

class ProfilePage extends BasePage {
    @Override
    String title() { return "Profile"; }          // перевизначення: вибір у runtime

    @Override
    ProfilePage open() { return this; }           // коваріантний тип результату
}

class AuthSteps {
    void login() { login("qa@example.com", "secret"); }       // перевантаження
    void login(User user) { login(user.email(), user.password()); }
    void login(String email, String password) {
        // заповнити форму і натиснути Submit
    }
}

record User(String email, String password) {}

// BasePage page = new ProfilePage();
// page.title()  -> "Profile": рішення приймає JVM за фактичним класом
abstract class BasePage {
    abstract String title();
    BasePage open() { return this; }
}

class ProfilePage extends BasePage {
    @Override
    String title() { return "Profile"; }          // overriding: chosen at runtime

    @Override
    ProfilePage open() { return this; }           // covariant return type
}

class AuthSteps {
    void login() { login("qa@example.com", "secret"); }       // overloading
    void login(User user) { login(user.email(), user.password()); }
    void login(String email, String password) {
        // fill the form and press Submit
    }
}

record User(String email, String password) {}

// BasePage page = new ProfilePage();
// page.title()  -> "Profile": the JVM decides by the real class
🔧 Розбір синтаксису: ProfilePage.open() повертає ProfilePage, хоча в батька тип BasePage: це дозволена коваріантність. Три методи login мають різні списки параметрів, і компілятор обирає потрібний за аргументами. record User автоматично дає методи доступу email() і password(). Тіло третього login скорочене до коментаря, щоб приклад лишався компактним.
🔧 Syntax breakdown: ProfilePage.open() returns ProfilePage although the parent declares BasePage: that is allowed covariance. The three login methods have different parameter lists, and the compiler picks one based on the arguments. record User generates the email() and password() accessors. The body of the third login is cut down to a comment to keep the example compact.
💡 Простими словами: Overriding — той самий наказ, але кожен солдат виконує його по-своєму. Overloading — кілька наказів з однаковою назвою для різного спорядження.
💡 In plain words: Overriding is the same order carried out differently by each soldier. Overloading is several orders with the same name for different kit.
🗣 Як читати вголос Я пояснюю поліморфізм через WebDriver: змінна типу інтерфейсу, а працює Chrome чи Firefox, і вибір робить JVM під час виконання. Це і є overriding — підклас дає свою реалізацію з тією самою сигнатурою, а я завжди ставлю @Override, щоб компілятор зловив одруківку. Overloading — зовсім інша річ: однакове ім’я, різні параметри, і вирішує компілятор. У фреймворку це, наприклад, кілька варіантів login для різних сценаріїв.
🗣 How to say it out loud I explain polymorphism through WebDriver: the variable has an interface type, Chrome or Firefox does the work, and the JVM makes the choice at runtime. That is overriding: a subclass gives its own implementation with the same signature, and I always put @Override so the compiler catches a typo. Overloading is a different thing entirely: same name, different parameters, and the compiler decides. In a framework that is, say, several login variants for different scenarios.
19 Як працюють конструктори, this(...) і super(...) в ієрархії сторінок? How do constructors, this(...) and super(...) work in a page hierarchy?
ООП OOP Junior ▶

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

What it is. A constructor is a special block that runs during new and brings the object into a valid state. It carries the class name, has no return type and is not inherited. If you write no constructor, the compiler adds an empty no-arg one. As soon as you declare any constructor with parameters, that default disappears.

this(...) і super(...). this(...) викликає інший конструктор того самого класу — так роблять ланцюжок, щоб не дублювати ініціалізацію. super(...) викликає конструктор батька, і в Java 17/21 він має бути першим рядком. Якщо ти його не написав, компілятор вставить super() без аргументів — і якщо в батька такого немає, буде помилка компіляції. Окремо super.method() викликає батьківську версію перевизначеного методу.

this(...) and super(...). this(...) calls another constructor of the same class, which is how you chain them without duplicating initialisation. super(...) calls the parent's constructor, and in Java 17/21 it must be the first statement. If you omit it, the compiler inserts a no-arg super(), and if the parent has no such constructor, compilation fails. Separately, super.method() calls the parent's version of an overridden method.

Приклад з проєкту. BasePage(WebDriver driver) зберігає драйвер і створює WebDriverWait. Кожна сторінка оголошує LoginPage(WebDriver driver) { super(driver); }. Для сторінки з необов’язковим таймаутом робимо два конструктори, і коротший викликає довший через this(driver, Duration.ofSeconds(10)).

Project example. BasePage(WebDriver driver) stores the driver and creates a WebDriverWait. Every page declares LoginPage(WebDriver driver) { super(driver); }. For a page with an optional timeout we write two constructors, and the shorter one delegates through this(driver, Duration.ofSeconds(10)).

Підводні камені. Не варто виконувати в конструкторі навігацію чи довгі очікування: об’єкт ще не готовий, а падіння в new дає незрозумілий стек. І не викликай у конструкторі батька перевизначені методи — підклас ще не проініціалізував свої поля.

Pitfalls. Do not navigate or wait for long inside a constructor: the object is not ready yet, and a failure inside new produces a confusing stack trace. And never call overridden methods from the parent constructor, because the subclass has not initialised its fields yet.

Ланцюжок конструкторів у BasePage і підкласі
Constructor chaining in BasePage and a subclass
public abstract class BasePage {
    protected final WebDriver driver;
    protected final WebDriverWait wait;

    protected BasePage(WebDriver driver) {
        this(driver, Duration.ofSeconds(10));          // делегуємо довшому конструктору
    }

    protected BasePage(WebDriver driver, Duration timeout) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, timeout);
    }

    protected void waitForLoad() {
        wait.until(d -> ((JavascriptExecutor) d).executeScript("return document.readyState").equals("complete"));
    }
}

public class ReportsPage extends BasePage {
    public ReportsPage(WebDriver driver) {
        super(driver, Duration.ofSeconds(30));         // повільна сторінка — довший таймаут
    }

    @Override
    protected void waitForLoad() {
        super.waitForLoad();                           // спершу батьківська логіка
        wait.until(ExpectedConditions.invisibilityOfElementLocated(By.cssSelector(".spinner")));
    }
}
public abstract class BasePage {
    protected final WebDriver driver;
    protected final WebDriverWait wait;

    protected BasePage(WebDriver driver) {
        this(driver, Duration.ofSeconds(10));          // delegate to the longer constructor
    }

    protected BasePage(WebDriver driver, Duration timeout) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, timeout);
    }

    protected void waitForLoad() {
        wait.until(d -> ((JavascriptExecutor) d).executeScript("return document.readyState").equals("complete"));
    }
}

public class ReportsPage extends BasePage {
    public ReportsPage(WebDriver driver) {
        super(driver, Duration.ofSeconds(30));         // slow page — longer timeout
    }

    @Override
    protected void waitForLoad() {
        super.waitForLoad();                           // parent logic first
        wait.until(ExpectedConditions.invisibilityOfElementLocated(By.cssSelector(".spinner")));
    }
}
🔧 Розбір синтаксису: this(driver, Duration.ofSeconds(10)) передає керування іншому конструктору, тож поля заповнюються в одному місці. super(driver, Duration.ofSeconds(30)) обирає батьківський конструктор з таймаутом. Поля final мусять бути присвоєні рівно один раз у кожному шляху конструктора. super.waitForLoad() виконує версію з BasePage, а потім підклас додає очікування спінера.
🔧 Syntax breakdown: this(driver, Duration.ofSeconds(10)) hands control to another constructor, so fields are assigned in one place. super(driver, Duration.ofSeconds(30)) picks the parent constructor that takes a timeout. The final fields must be assigned exactly once along every constructor path. super.waitForLoad() runs the BasePage version, after which the subclass adds a wait for the spinner.
💡 Простими словами: Конструктор — це збирання меблів за інструкцією: спочатку каркас від батька через super, потім власні полиці. Ніхто не ставить полиці раніше за каркас.
💡 In plain words: A constructor is assembling furniture from instructions: first the frame from the parent via super, then your own shelves. Nobody fits shelves before the frame.
🗣 Як читати вголос Я розповідаю, що конструктор готує об’єкт до роботи, і нагадую пастку: як тільки оголосив конструктор з параметрами, типового без параметрів більше немає. У моїх сторінках BasePage приймає драйвер, а підкласи викликають super першим рядком. Якщо потрібні варіанти, коротший конструктор делегує довшому через this. Ще кажу, що в конструкторі я не відкриваю URL і не чекаю елементів, бо падіння там дуже погано читається.
🗣 How to say it out loud I say a constructor prepares an object for work, and I mention the trap: once you declare a constructor with parameters, the no-arg default is gone. In my pages BasePage takes the driver, and subclasses call super on the first line. When I need variants, the shorter constructor delegates to the longer one through this. I also say I never open a URL or wait for elements in a constructor, because a failure there is very hard to read.
20 Що означають static і final і чому static WebDriver ламає паралельні тести? What do static and final mean, and why does a static WebDriver break parallel tests?
ООП OOP Junior ▶

static. Статичний член належить класу, а не об’єкту: одна копія на весь клас у межах завантажувача класів, спільна для всіх екземплярів і всіх потоків. Статичні методи не мають this і не бачать полів екземпляра. Підходять для констант і чистих утиліт без стану, наприклад RandomData.email().

static. A static member belongs to the class, not to an object: one copy per class within a class loader, shared by all instances and all threads. Static methods have no this and cannot see instance fields. They suit constants and stateless pure utilities such as RandomData.email().

final. Для змінної — присвоїти можна лише раз. Для поля-посилання це не робить об’єкт незмінним: final List<String> names не можна перепризначити, але можна викликати add. Для методу — заборона перевизначення. Для класу — заборона успадкування (String, Integer). Окремо є «effectively final»: локальна змінна, яку не змінюють після присвоєння, і лише такі змінні можна використовувати в лямбдах.

final. On a variable it means assign once. On a reference field it does not make the object immutable: final List<String> names cannot be reassigned but can still be add-ed to. On a method it forbids overriding. On a class it forbids subclassing (String, Integer). There is also "effectively final": a local variable never changed after assignment, and only such variables can be used inside lambdas.

Приклад з проєкту. Константи таймаутів — public static final Duration DEFAULT_TIMEOUT. А ось public static WebDriver driver у BaseTest — класична причина flaky-тестів при parallel: другий потік перезаписує поле, і перший тест клікає в чужому браузері або отримує NoSuchSessionException, бо сусід уже викликав quit().

Project example. Timeout constants are public static final Duration DEFAULT_TIMEOUT. But public static WebDriver driver in a BaseTest is a classic source of flaky tests under parallel: a second thread overwrites the field, and the first test clicks in someone else's browser or gets NoSuchSessionException because its neighbour already called quit().

Підводні камені. Статичний змінний стан також «перетікає» між тестами в одному потоці, ламаючи їхню незалежність. Рішення для драйвера — ThreadLocal або ін’єкція через конструктор чи розширення JUnit.

Pitfalls. Mutable static state also leaks between tests running on one thread, breaking their independence. The fix for the driver is a ThreadLocal or injection through a constructor or a JUnit extension.

Константи — так, статичний драйвер — ні
Constants yes, a static driver no
public final class Timeouts {                               // final: не успадкувати
    public static final Duration DEFAULT = Duration.ofSeconds(10);
    public static final Duration SLOW_PAGE = Duration.ofSeconds(30);
    private Timeouts() {}                                   // утилітний клас без екземплярів
}

public class BrokenBaseTest {
    protected static WebDriver driver;                      // одне поле на всі потоки — гонка

    @BeforeEach
    void start() { driver = new ChromeDriver(); }           // потік B перезаписує драйвер потоку A

    @AfterEach
    void stop() { driver.quit(); }                          // може закрити чужий браузер
}

class FinalDemo {
    void demo() {
        final List<String> names = new ArrayList<>();
        names.add("anna");                                  // дозволено: змінюємо вміст
        // names = new ArrayList<>();                        // помилка компіляції
        String prefix = "user-";                            // effectively final
        names.forEach(n -> System.out.println(prefix + n));
    }
}
public final class Timeouts {                               // final: cannot be subclassed
    public static final Duration DEFAULT = Duration.ofSeconds(10);
    public static final Duration SLOW_PAGE = Duration.ofSeconds(30);
    private Timeouts() {}                                   // utility class, no instances
}

public class BrokenBaseTest {
    protected static WebDriver driver;                      // one field for all threads — a race

    @BeforeEach
    void start() { driver = new ChromeDriver(); }           // thread B overwrites thread A's driver

    @AfterEach
    void stop() { driver.quit(); }                          // may close someone else's browser
}

class FinalDemo {
    void demo() {
        final List<String> names = new ArrayList<>();
        names.add("anna");                                  // allowed: we change the contents
        // names = new ArrayList<>();                        // compilation error
        String prefix = "user-";                            // effectively final
        names.forEach(n -> System.out.println(prefix + n));
    }
}
🔧 Розбір синтаксису: public static final разом із незмінним типом Duration дає справжню константу. Приватний конструктор у Timeouts забороняє new Timeouts(). У BrokenBaseTest поле static одне на клас, тому @BeforeEach з різних потоків пише в ту саму комірку. Змінна prefix не змінюється після присвоєння, тож лямбда в forEach може її захопити.
🔧 Syntax breakdown: public static final combined with the immutable Duration type gives a real constant. The private constructor in Timeouts forbids new Timeouts(). In BrokenBaseTest the static field exists once per class, so @BeforeEach on different threads writes into the same slot. The prefix variable is never reassigned, so the lambda in forEach may capture it.
💡 Простими словами: static — це дошка оголошень у коридорі, яку бачать усі. Вішай там розклад (константи), але не свої ключі від машини (драйвер).
💡 In plain words: static is the notice board in the hallway that everybody sees. Pin the timetable there (constants), not your car keys (the driver).
🗣 Як читати вголос Я кажу, що static прив’язує член до класу, тобто одна копія на всіх, а final забороняє повторне присвоєння, перевизначення чи успадкування — залежно від того, де він стоїть. Для констант таймаутів це ідеальна пара. Але статичний WebDriver — найчастіша причина хаосу в паралельному запуску: потоки перезаписують одне поле і закривають чужі браузери. Тому драйвер у мене або в ThreadLocal, або передається в конструктор.
🗣 How to say it out loud I say static ties a member to the class, so there is one copy for everyone, and final forbids reassignment, overriding or subclassing depending on where it appears. For timeout constants that pair is ideal. But a static WebDriver is the most common cause of chaos in parallel runs: threads overwrite one field and close each other's browsers. So my driver lives either in a ThreadLocal or is passed into the constructor.
21 Які є модифікатори доступу і що з фреймворку варто робити публічним? What access modifiers exist, and what in a framework should be public?
ООП OOP Junior ▶

Що це. У Java чотири рівні доступу. private — лише всередині класу. Без модифікатора (package-private) — клас і всі класи того самого пакета. protected — те саме плюс підкласи в інших пакетах. public — будь-хто. Для класів верхнього рівня доступні лише public і package-private.

What it is. Java has four access levels. private: only inside the class. No modifier (package-private): the class and every class in the same package. protected: the same plus subclasses in other packages. public: anyone. Top-level classes can only be public or package-private.

Навіщо у фреймворку. Публічний API — це контракт з авторами тестів: усе, що public, хтось почне використовувати, і змінити це без поломок уже важко. Тому публічними роблять Page Object-и, кроки, API-клієнти і фабрики тестових даних. Внутрішні деталі — HTTP-обгортку, парсери конфігу, реєстр драйверів — ховають у package-private класи пакета internal або core.

Why in a framework. The public API is a contract with test authors: anything public will be used by someone, and changing it without breakage becomes hard. So Page Objects, steps, API clients and test-data factories are public. Internal details such as the HTTP wrapper, config parsers and the driver registry are hidden in package-private classes of an internal or core package.

Приклад з проєкту. BasePage має protected поля driver і wait та protected метод click(By locator), бо вони потрібні сторінкам-нащадкам, але не тестам. Тест у пакеті tests не бачить їх і змушений користуватися методами сторінок, тож архітектура тримається сама, без код-рев’ю на кожен рядок.

Project example. BasePage has protected fields driver and wait and a protected method click(By locator), because descendant pages need them and tests do not. A test in the tests package cannot see them and has to use page methods, so the architecture holds by itself without reviewing every line.

Підводні камені. protected у Java ширший, ніж здається: його бачить увесь пакет, а не лише підкласи. А рефлексія (та й деякі бібліотеки) може обходити private, тож модифікатор — це дизайнерський намір, а не захист секретів.

Pitfalls. protected in Java is wider than it seems: the whole package sees it, not only subclasses. And reflection (as well as some libraries) can bypass private, so a modifier expresses design intent, not protection of secrets.

Чотири рівні доступу в одному пакеті фреймворку
Four access levels in one framework package
package com.acme.qa.pages;

public abstract class BasePage {                     // public: частина API фреймворку
    protected final WebDriver driver;                // protected: для сторінок-нащадків
    private final Highlighter highlighter;           // private: деталь реалізації

    protected BasePage(WebDriver driver) {
        this.driver = driver;
        this.highlighter = new Highlighter(driver);
    }

    protected void click(By locator) {
        highlighter.flash(locator);
        driver.findElement(locator).click();
    }
}

class Highlighter {                                   // package-private: не видно поза пакетом
    private final WebDriver driver;
    Highlighter(WebDriver driver) { this.driver = driver; }
    void flash(By locator) {
        WebElement el = driver.findElement(locator);
        ((JavascriptExecutor) driver).executeScript("arguments[0].style.outline='2px solid red'", el);
    }
}
package com.acme.qa.pages;

public abstract class BasePage {                     // public: part of the framework API
    protected final WebDriver driver;                // protected: for descendant pages
    private final Highlighter highlighter;           // private: an implementation detail

    protected BasePage(WebDriver driver) {
        this.driver = driver;
        this.highlighter = new Highlighter(driver);
    }

    protected void click(By locator) {
        highlighter.flash(locator);
        driver.findElement(locator).click();
    }
}

class Highlighter {                                   // package-private: invisible outside the package
    private final WebDriver driver;
    Highlighter(WebDriver driver) { this.driver = driver; }
    void flash(By locator) {
        WebElement el = driver.findElement(locator);
        ((JavascriptExecutor) driver).executeScript("arguments[0].style.outline='2px solid red'", el);
    }
}
🔧 Розбір синтаксису: Клас Highlighter без модифікатора видно лише в пакеті com.acme.qa.pages, тож тести з інших пакетів не можуть на нього покластися. protected void click доступний підкласам BasePage у будь-якому пакеті. private final Highlighter ніхто ззовні не підмінить. executeScript з arguments[0] отримує елемент першим аргументом скрипту.
🔧 Syntax breakdown: The Highlighter class has no modifier, so it is visible only inside com.acme.qa.pages, and tests in other packages cannot depend on it. protected void click is available to BasePage subclasses in any package. Nobody outside can replace the private final Highlighter. executeScript with arguments[0] receives the element as the first script argument.
💡 Простими словами: Модифікатори — це двері в офісі: private — твій кабінет, package-private — відділ, protected — відділ і стажери з інших поверхів, public — рецепція.
💡 In plain words: Modifiers are office doors: private is your own room, package-private is the department, protected is the department plus trainees from other floors, public is reception.
🗣 Як читати вголос Я перелічую чотири рівні і одразу пояснюю, як вони формують архітектуру фреймворку. Публічним роблю лише те, чим мають користуватися тести: сторінки, кроки, клієнти. Спільні інструменти для сторінок — protected у BasePage, а службові класи — package-private. Так тест фізично не може звернутися до драйвера напряму. І уточнюю, що protected у Java видно ще й усьому пакету, а не лише нащадкам.
🗣 How to say it out loud I list the four levels and immediately explain how they shape a framework's architecture. I make public only what tests are supposed to use: pages, steps, clients. Shared tools for pages are protected in BasePage, and helper classes are package-private. That way a test physically cannot reach for the driver directly. And I point out that protected in Java is also visible to the whole package, not only to subclasses.
22 Коли у фреймворку брати abstract class, а коли interface з default-методами? When should a framework use an abstract class, and when an interface with default methods?
ООП OOP Middle ▶

Різниця. Абстрактний клас може мати стан (поля екземпляра), конструктори і будь-які модифікатори доступу, але клас успадковує лише одного батька. Інтерфейс не має стану екземпляра (лише public static final константи), зате клас може реалізувати їх скільки завгодно. З Java 8 інтерфейс має default-методи з тілом і static-методи, з Java 9 — ще й private-методи для спільного коду всередині інтерфейсу.

The difference. An abstract class can hold state (instance fields), constructors and any access modifiers, but a class extends only one parent. An interface has no instance state (only public static final constants), yet a class can implement as many as it needs. Since Java 8 interfaces have default methods with bodies and static methods, and since Java 9 also private methods for shared code inside the interface.

Навіщо default-методи. Вони з’явилися, щоб розширювати інтерфейси без поломки наявних реалізацій: так у Collection додали stream() і removeIf(). У фреймворку це зручно для «домішок» поведінки: HasHeader з методом default HeaderComponent header() можна додати будь-якій сторінці, незалежно від її батька.

Why default methods exist. They were introduced to extend interfaces without breaking existing implementations: that is how stream() and removeIf() were added to Collection. In a framework they work well for behaviour mix-ins: HasHeader with a default HeaderComponent header() method can be added to any page regardless of its parent.

Приклад з проєкту. BaseTest — абстрактний клас: у нього є стан (драйвер, конфіг) і життєвий цикл з @BeforeEach/@AfterEach. А можливості сторінок — інтерфейси: HasHeader, HasPagination, Loggable. Сторінка каталогу extends BasePage implements HasHeader, HasPagination.

Project example. BaseTest is an abstract class: it has state (driver, config) and a lifecycle with @BeforeEach/@AfterEach. Page capabilities are interfaces: HasHeader, HasPagination, Loggable. A catalogue page is declared as extends BasePage implements HasHeader, HasPagination.

Підводні камені. Якщо два інтерфейси мають однаковий default-метод, клас мусить перевизначити його явно і може обрати версію через HasHeader.super.method(). І default-методи не мають стану, тож не варто імітувати поля через статичні мапи в інтерфейсі.

Pitfalls. If two interfaces provide the same default method, the class has to override it explicitly and can pick a version with HasHeader.super.method(). And default methods have no state, so do not fake fields through static maps inside an interface.

Абстрактний BaseTest і інтерфейси-можливості
An abstract BaseTest and capability interfaces
public abstract class BaseTest {
    protected WebDriver driver;                              // стан — тому клас

    @BeforeEach
    void setUp() { driver = DriverFactory.create(); }

    @AfterEach
    void tearDown() { driver.quit(); }
}

public interface HasDriver {
    WebDriver driver();
}

public interface HasHeader extends HasDriver {
    default HeaderComponent header() {                       // default: готова поведінка
        return new HeaderComponent(driver(), headerRoot());
    }

    private By headerRoot() { return By.cssSelector("header"); } // Java 9+: private у інтерфейсі

    static boolean isVisible(WebDriver d) {                   // static: утиліта інтерфейсу
        return !d.findElements(By.cssSelector("header")).isEmpty();
    }
}

public class CatalogPage extends BasePage implements HasHeader {
    public CatalogPage(WebDriver driver) { super(driver); }
    @Override public WebDriver driver() { return driver; }
}
public abstract class BaseTest {
    protected WebDriver driver;                              // state — hence a class

    @BeforeEach
    void setUp() { driver = DriverFactory.create(); }

    @AfterEach
    void tearDown() { driver.quit(); }
}

public interface HasDriver {
    WebDriver driver();
}

public interface HasHeader extends HasDriver {
    default HeaderComponent header() {                       // default: ready-made behaviour
        return new HeaderComponent(driver(), headerRoot());
    }

    private By headerRoot() { return By.cssSelector("header"); } // Java 9+: private in an interface

    static boolean isVisible(WebDriver d) {                   // static: an interface utility
        return !d.findElements(By.cssSelector("header")).isEmpty();
    }
}

public class CatalogPage extends BasePage implements HasHeader {
    public CatalogPage(WebDriver driver) { super(driver); }
    @Override public WebDriver driver() { return driver; }
}
🔧 Розбір синтаксису: HasHeader extends HasDriver — інтерфейс може розширювати інший і використовувати його абстрактний метод driver() у своєму default-методі. private By headerRoot() видно лише всередині інтерфейсу. static boolean isVisible викликається як HasHeader.isVisible(driver) і не успадковується реалізаціями. CatalogPage бере стан від BasePage, а поведінку шапки — від інтерфейсу.
🔧 Syntax breakdown: HasHeader extends HasDriver: an interface can extend another and use its abstract driver() method inside its default method. private By headerRoot() is visible only inside the interface. static boolean isVisible is called as HasHeader.isVisible(driver) and is not inherited by implementations. CatalogPage takes state from BasePage and header behaviour from the interface.
💡 Простими словами: Абстрактний клас — це родовід: один батько, спільне майно. Інтерфейс — це посвідчення: водійське, дайверське, їх можна мати кілька.
💡 In plain words: An abstract class is a family tree: one parent and shared property. An interface is a licence: driving, diving, and you can hold several.
🗣 Як читати вголос Моє правило просте: якщо потрібен стан і спільний життєвий цикл, беру абстрактний клас, як BaseTest з драйвером і хуками. Якщо потрібна можливість, яку можна додати будь-якому класу, — інтерфейс, і з Java 8 він може мати default-методи з реалізацією. Так у мене сторінки успадковують BasePage, а шапку чи пагінацію отримують через інтерфейси. Якщо два default-методи конфліктують, я перевизначаю метод і явно обираю версію через super.
🗣 How to say it out loud My rule is simple: if I need state and a shared lifecycle, I take an abstract class, like BaseTest with the driver and hooks. If I need a capability that can be added to any class, it is an interface, and since Java 8 it can carry default methods with an implementation. So my pages extend BasePage and get the header or pagination through interfaces. If two default methods clash, I override the method and pick a version explicitly through super.
23 Успадкування чи композиція в Page Object: як не отримати BasePage на тисячу рядків? Inheritance or composition in Page Objects: how do you avoid a thousand-line BasePage?
ООП OOP Senior ▶

Проблема. Типова еволюція фреймворку: у BasePage додають шапку, потім меню, модалки, таблиці, тости, завантаження файлів. Потім з’являється BaseAdminPage extends BasePage і BaseReportPage extends BaseAdminPage. Через рік будь-яка зміна в базі ламає десятки сторінок, а нова людина не може зрозуміти, звідки в сторінки логіну метод sortTableBy. Це «крихкий базовий клас»: нащадки залежать від деталей реалізації батька.

The problem. A typical framework evolution: the header goes into BasePage, then the menu, modals, tables, toasts and file uploads. Then BaseAdminPage extends BasePage appears, followed by BaseReportPage extends BaseAdminPage. A year later any change to the base breaks dozens of pages, and a newcomer cannot tell why the login page has a sortTableBy method. This is the fragile base class: descendants depend on implementation details of the parent.

Композиція. Замість «сторінка є шапкою» — «сторінка має шапку». Повторювані частини UI стають компонентами з власним кореневим локатором: HeaderComponent, TableComponent, ModalComponent. Сторінка збирає їх у полях і делегує. Компонент таблиці пишеться один раз і використовується на п’яти сторінках з різними кореневими селекторами.

Composition. Instead of "a page is a header", say "a page has a header". Repeated UI parts become components with their own root locator: HeaderComponent, TableComponent, ModalComponent. A page holds them in fields and delegates. A table component is written once and reused on five pages with different root selectors.

Трейдоф. Неглибоке успадкування (один рівень BasePage з драйвером і очікуванням) лишається корисним — це справжня спільна основа. Композиція додає трохи коду делегування і вимагає домовленостей про те, як компоненти знаходять свій корінь. Натомість зміни локалізовані, компоненти тестуються окремо, а сторінка описує лише те, що на ній справді є.

Trade-off. Shallow inheritance (one level of BasePage with the driver and waits) stays useful, since it is a genuine shared foundation. Composition adds a little delegation code and needs conventions for how components locate their root. In exchange, changes stay local, components can be tested on their own, and a page describes only what it really contains.

Як мігрувати. Не переписувати все: виносити компонент при першій зміні відповідної частини UI, лишати в BasePage методи-делегати з @Deprecated і прибирати їх, коли зникнуть виклики.

How to migrate. Do not rewrite everything: extract a component the first time that part of the UI changes, keep delegating methods in BasePage marked @Deprecated, and delete them once the calls are gone.

Компоненти замість глибокої ієрархії
Components instead of a deep hierarchy
public class TableComponent {
    private final WebDriver driver;
    private final By root;

    public TableComponent(WebDriver driver, By root) {
        this.driver = driver;
        this.root = root;
    }

    public List<String> column(int index) {
        return driver.findElement(root)
                .findElements(By.cssSelector("tbody tr td:nth-child(" + index + ")"))
                .stream().map(WebElement::getText).toList();
    }

    public void sortBy(String header) {
        driver.findElement(root).findElement(By.xpath(".//th[normalize-space()='" + header + "']")).click();
    }
}

public class UsersPage extends BasePage {                    // один рівень успадкування
    private final HeaderComponent header;
    private final TableComponent users;                      // композиція: сторінка МАЄ таблицю

    public UsersPage(WebDriver driver) {
        super(driver);
        this.header = new HeaderComponent(driver, By.cssSelector("header"));
        this.users = new TableComponent(driver, By.id("users-table"));
    }

    public HeaderComponent header() { return header; }
    public List<String> emails() { return users.column(2); }
    public UsersPage sortByEmail() { users.sortBy("Email"); return this; }
}
public class TableComponent {
    private final WebDriver driver;
    private final By root;

    public TableComponent(WebDriver driver, By root) {
        this.driver = driver;
        this.root = root;
    }

    public List<String> column(int index) {
        return driver.findElement(root)
                .findElements(By.cssSelector("tbody tr td:nth-child(" + index + ")"))
                .stream().map(WebElement::getText).toList();
    }

    public void sortBy(String header) {
        driver.findElement(root).findElement(By.xpath(".//th[normalize-space()='" + header + "']")).click();
    }
}

public class UsersPage extends BasePage {                    // one level of inheritance
    private final HeaderComponent header;
    private final TableComponent users;                      // composition: the page HAS a table

    public UsersPage(WebDriver driver) {
        super(driver);
        this.header = new HeaderComponent(driver, By.cssSelector("header"));
        this.users = new TableComponent(driver, By.id("users-table"));
    }

    public HeaderComponent header() { return header; }
    public List<String> emails() { return users.column(2); }
    public UsersPage sortByEmail() { users.sortBy("Email"); return this; }
}
🔧 Розбір синтаксису: TableComponent отримує кореневий локатор у конструкторі, тож той самий клас працює з будь-якою таблицею. findElement(root).findElements(...) шукає лише всередині кореня. .stream().map(WebElement::getText).toList() перетворює комірки на незмінний список рядків (Java 16+). UsersPage успадковує лише базову сторінку, а таблицю й шапку тримає як поля і делегує їм роботу.
🔧 Syntax breakdown: TableComponent receives its root locator in the constructor, so the same class works with any table. findElement(root).findElements(...) searches only inside the root. .stream().map(WebElement::getText).toList() turns the cells into an unmodifiable list of strings (Java 16+). UsersPage extends only the base page and keeps the table and header as fields, delegating work to them.
💡 Простими словами: Успадкування — це купити будинок разом з усіма меблями попереднього власника. Композиція — обставити його самому з готових модулів.
💡 In plain words: Inheritance is buying a house with all the previous owner's furniture. Composition is furnishing it yourself from ready-made modules.
🗣 Як читати вголос Я бачив фреймворки, де BasePage розростався до тисячі рядків, а ієрархія сторінок мала чотири рівні, і будь-яка правка ламала пів набору. Тому я тримаю один рівень успадкування для драйвера й очікувань, а повторювані шматки UI роблю компонентами з власним кореневим локатором. Сторінка має шапку і таблицю, а не є ними. Мігрую поступово: виношу компонент тоді, коли ця частина UI однаково змінюється, і лишаю тимчасові делегати.
🗣 How to say it out loud I have seen frameworks where BasePage grew to a thousand lines and the page hierarchy had four levels, so any edit broke half the suite. That is why I keep a single level of inheritance for the driver and waits, and turn repeated UI pieces into components with their own root locator. A page has a header and a table rather than being them. I migrate gradually: I extract a component when that part of the UI is changing anyway, and leave temporary delegates in place.
24 Що таке маркерні інтерфейси і Serializable і чи потрібні вони в тестах? What are marker interfaces and Serializable, and do tests need them?
ООП OOP Middle ▶

Маркерний інтерфейс. Це інтерфейс без жодного методу, який лише «позначає» клас для JVM чи бібліотеки. Приклади з JDK: Serializable (об’єкт можна серіалізувати стандартним механізмом), Cloneable (дозволяє Object.clone(), інакше буде CloneNotSupportedException), RandomAccess (список має швидкий доступ за індексом, як ArrayList, і алгоритми можуть це враховувати).

Marker interface. An interface with no methods at all that merely tags a class for the JVM or a library. JDK examples: Serializable (the object may be serialised by the standard mechanism), Cloneable (permits Object.clone(), otherwise you get CloneNotSupportedException), RandomAccess (the list has fast indexed access like ArrayList, and algorithms may take that into account).

Serializable. Стандартна серіалізація перетворює граф об’єктів на байти через ObjectOutputStream. Поле transient пропускається, static теж. serialVersionUID — версія класу; якщо не оголосити її явно, JVM обчислить її з структури класу, і будь-яка зміна полів зробить старі дані нечитабельними (InvalidClassException).

Serializable. Standard serialisation turns an object graph into bytes via ObjectOutputStream. A transient field is skipped, and so is a static one. serialVersionUID is the class version; if you do not declare it, the JVM computes it from the class structure, and any change to the fields makes old data unreadable (InvalidClassException).

Анотації замість маркерів. З Java 5 метадані частіше виражають анотаціями: вони можуть мати параметри і ставитися на методи чи поля, а не лише на класи. JUnit 5 не вимагає інтерфейсу — достатньо @Test. Проте маркер має перевагу: його перевіряє компілятор через тип.

Annotations instead of markers. Since Java 5 metadata is more often expressed with annotations: they can carry parameters and sit on methods or fields, not only classes. JUnit 5 requires no interface, @Test is enough. A marker still has one advantage: the compiler checks it through the type.

У тестах. Серіалізація трапляється рідко: TestNG або Gradle можуть передавати дані між процесами, хтось кешує об’єкти на диск чи робить глибоку копію. Для тестових даних кращий JSON через Jackson: він читабельний, стабільний між версіями класу і не має відомих ризиків десеріалізації.

In tests. Serialisation shows up rarely: TestNG or Gradle may pass data between processes, someone caches objects on disk or makes a deep copy. For test data JSON via Jackson is better: it is readable, stable across class versions and free of the well-known deserialisation risks.

Serializable з transient і перевірка RandomAccess
Serializable with transient, and a RandomAccess check
public class TestSession implements Serializable {       // маркер: методів немає
    @Serial
    private static final long serialVersionUID = 1L;       // фіксуємо версію явно

    private final String userId;
    private final String token;
    private transient WebDriver driver;                    // не серіалізується

    public TestSession(String userId, String token) {
        this.userId = userId;
        this.token = token;
    }

    public String userId() { return userId; }
}

class MarkerDemo {
    static String firstAndLast(List<String> items) {
        if (items instanceof RandomAccess) {               // ArrayList: get(i) за O(1)
            return items.get(0) + ".." + items.get(items.size() - 1);
        }
        return items.getFirst() + ".." + items.getLast();  // Java 21: SequencedCollection
    }
}
public class TestSession implements Serializable {       // a marker: no methods
    @Serial
    private static final long serialVersionUID = 1L;       // pin the version explicitly

    private final String userId;
    private final String token;
    private transient WebDriver driver;                    // not serialised

    public TestSession(String userId, String token) {
        this.userId = userId;
        this.token = token;
    }

    public String userId() { return userId; }
}

class MarkerDemo {
    static String firstAndLast(List<String> items) {
        if (items instanceof RandomAccess) {               // ArrayList: get(i) in O(1)
            return items.get(0) + ".." + items.get(items.size() - 1);
        }
        return items.getFirst() + ".." + items.getLast();  // Java 21: SequencedCollection
    }
}
🔧 Розбір синтаксису: @Serial (Java 14+) просить компілятор перевірити, що serialVersionUID оголошено правильно. transient WebDriver після десеріалізації буде null — живий браузер серіалізувати неможливо. instanceof RandomAccess перевіряє маркер під час виконання. getFirst() і getLast() з’явилися в List у Java 21 через інтерфейс SequencedCollection.
🔧 Syntax breakdown: @Serial (Java 14+) asks the compiler to verify that serialVersionUID is declared correctly. The transient WebDriver will be null after deserialisation, since a live browser cannot be serialised. instanceof RandomAccess checks the marker at runtime. getFirst() and getLast() arrived in List with Java 21 through the SequencedCollection interface.
💡 Простими словами: Маркерний інтерфейс — це наліпка «крихке» на коробці: всередині нічого не додає, але пошта знає, як з нею поводитися.
💡 In plain words: A marker interface is a "fragile" sticker on a box: it adds nothing inside, but the post office knows how to handle it.
🗣 Як читати вголос Я пояснюю маркер як інтерфейс без методів, що лише позначає клас: Serializable, Cloneable, RandomAccess. Про Serializable додаю три деталі — transient пропускає поле, serialVersionUID краще задавати явно, а статичні поля не серіалізуються взагалі. Сьогодні більшість таких позначок роблять анотаціями, бо вони гнучкіші. У тестах стандартною серіалізацією я майже не користуюся: тестові дані зберігаю в JSON, бо його видно очима і він переживає зміни класів.
🗣 How to say it out loud I describe a marker as an interface with no methods that only tags a class: Serializable, Cloneable, RandomAccess. About Serializable I add three details: transient skips a field, serialVersionUID is best set explicitly, and static fields are never serialised. Today most such tags are annotations because they are more flexible. In tests I hardly use standard serialisation: I keep test data in JSON because you can read it and it survives class changes.
▶ 4 6
25 Що таке контракт equals() і hashCode() і чому він важливий для перевірок у тестах? What is the equals() and hashCode() contract, and why does it matter for test assertions?
Контракти й record Contracts and records Middle ▶

Що це. Метод equals() має бути рефлексивним (a.equals(a)), симетричним, транзитивним, узгодженим (той самий результат, поки поля не змінились) і повертати false для null. Контракт hashCode() доповнює його: якщо два об’єкти рівні за equals, їхні хеш-коди зобов’язані збігатися. Зворотне не обов’язкове: колізії хешів дозволені.

What it is. The equals() method must be reflexive (a.equals(a)), symmetric, transitive, consistent (the same result while fields do not change) and return false for null. The hashCode() contract complements it: if two objects are equal by equals, their hash codes must match. The reverse is not required: hash collisions are allowed.

Навіщо в автотестах. assertEquals(expected, actual) усередині викликає саме equals. Без перевизначення порівнюються посилання, тож два DTO з однаковими полями, один зібраний у тесті, а другий десеріалізований з відповіді API, будуть «різними», і тест впаде з незрозумілим повідомленням на кшталт UserDto@1b6d3586.

Why it matters in automation. assertEquals(expected, actual) calls equals under the hood. Without an override references are compared, so two DTOs with identical fields, one built in the test and one deserialised from an API response, count as "different", and the test fails with an unhelpful message such as UserDto@1b6d3586.

Приклад з проєкту. Команда перевизначила лише equals у UserDto і порівнювала множини користувачів з двох ендпоінтів через HashSet. Тест падав випадково: HashSet спершу шукає кошик за hashCode, а стандартний хеш від адреси об’єкта для однакових DTO різний, тож contains повертав false. Після додавання hashCode через Objects.hash перевірка стала стабільною.

Project example. A team overrode only equals in UserDto and compared sets of users from two endpoints through a HashSet. The test failed at random: HashSet first looks for a bucket by hashCode, and the default identity-based hash differs for equal DTOs, so contains returned false. Adding hashCode via Objects.hash made the check stable.

Підводні камені. Не включай у рівність змінні поля, які змінюються після того, як об’єкт поклали в HashMap як ключ: ключ «загубиться». Для полів з null використовуй Objects.equals, а не a.equals(b), щоб не отримати NPE.

Pitfalls. Do not include mutable fields in equality if they change after the object was put into a HashMap as a key: the key gets "lost". For nullable fields use Objects.equals rather than a.equals(b) to avoid an NPE.

DTO відповіді API з коректним equals і hashCode
An API response DTO with correct equals and hashCode
public final class UserDto {
    private final long id;
    private final String email;
    private final String role;   // може бути null

    public UserDto(long id, String email, String role) {
        this.id = id;
        this.email = email;
        this.role = role;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;                       // рефлексивність
        if (!(o instanceof UserDto other)) return false;  // також false для null
        return id == other.id
                && Objects.equals(email, other.email)
                && Objects.equals(role, other.role);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, email, role);             // ті самі поля, що в equals
    }
}

// assertEquals(new UserDto(7, "anna@example.com", "admin"), api.getUser(7));
// assertTrue(new HashSet<>(listA).containsAll(listB));
public final class UserDto {
    private final long id;
    private final String email;
    private final String role;   // may be null

    public UserDto(long id, String email, String role) {
        this.id = id;
        this.email = email;
        this.role = role;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;                       // reflexivity
        if (!(o instanceof UserDto other)) return false;  // also false for null
        return id == other.id
                && Objects.equals(email, other.email)
                && Objects.equals(role, other.role);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, email, role);             // the same fields as in equals
    }
}

// assertEquals(new UserDto(7, "anna@example.com", "admin"), api.getUser(7));
// assertTrue(new HashSet<>(listA).containsAll(listB));
🔧 Розбір синтаксису: o instanceof UserDto other — pattern matching з Java 16: перевірка типу й приведення в одному виразі, а для null результат завжди false. Клас final, тому симетрію не зламає підклас з додатковими полями. Objects.equals безпечно порівнює null-значення, а Objects.hash рахує хеш із тих самих полів, що беруть участь у equals.
🔧 Syntax breakdown: o instanceof UserDto other is Java 16 pattern matching: a type check and a cast in one expression, and it is always false for null. The class is final, so a subclass with extra fields cannot break symmetry. Objects.equals compares null values safely, and Objects.hash computes the hash from the same fields used in equals.
💡 Простими словами: hashCode — номер полиці, equals — порівняння книжок на полиці. Якщо однакові книжки стоять на різних полицях, бібліотекар їх ніколи не знайде.
💡 In plain words: hashCode is the shelf number and equals compares the books on that shelf. If identical books sit on different shelves, the librarian will never find them.
🗣 Як читати вголос Я завжди кажу, що equals і hashCode перевизначаються парою і з одних і тих самих полів. Рівні об’єкти мусять мати однаковий хеш, інакше HashSet і HashMap просто не знайдуть їх. У тестах це виринає постійно: assertEquals на DTO без equals порівнює посилання і падає, хоча дані однакові. Тому для відповідей API я або пишу equals з Objects.equals, або беру record, де контракт уже згенерований.
🗣 How to say it out loud I always say that equals and hashCode are overridden as a pair and from the same fields. Equal objects must share a hash, otherwise HashSet and HashMap simply cannot find them. In tests this comes up constantly: assertEquals on a DTO without equals compares references and fails even though the data matches. So for API responses I either write equals with Objects.equals or use a record, where the contract is already generated.
26 Що таке record і як використовувати його для DTO та тестових даних? What is a record, and how do you use it for DTOs and test data?
Контракти й record Contracts and records Middle ▶

Що це. Record (фінальна фіча з Java 16) — це клас-носій даних, у якого компілятор сам генерує приватні final поля, канонічний конструктор, методи доступу без префікса get (user.email()), а також equals, hashCode і toString за всіма компонентами. Record неявно final, не може наслідувати інший клас, але може реалізовувати інтерфейси й мати статичні фабрики.

What it is. A record (final since Java 16) is a data-carrier class for which the compiler generates private final fields, a canonical constructor, accessors without the get prefix (user.email()), plus equals, hashCode and toString over all components. A record is implicitly final, cannot extend another class, but can implement interfaces and have static factories.

Навіщо в тестах. Це ідеальний тип для тіла запиту, відповіді API чи рядка тестових даних: одна строчка замість п’ятдесяти, правильна рівність для assertEquals і читабельний toString у звіті про падіння. Jackson уміє десеріалізувати record, починаючи з версії 2.12, тож response.as(UserDto.class) у REST Assured працює без сетерів.

Why it helps in tests. It is the ideal type for a request body, an API response or a row of test data: one line instead of fifty, correct equality for assertEquals and a readable toString in the failure report. Jackson can deserialise records since version 2.12, so response.as(UserDto.class) in REST Assured works without setters.

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

Project example. A compact constructor checks invariants at creation time: if the test data factory passes an empty email, the test fails immediately with a clear message instead of three steps later on the server.

Підводні камені. Незмінність record поверхнева: поле типу List захищене від перепризначення, але сам список можна змінити ззовні. Тому в компактному конструкторі роблять List.copyOf. І record не підходить для Page Object чи сутностей JPA, де потрібен змінний стан або проксі.

Pitfalls. A record is only shallowly immutable: a List field cannot be reassigned, but the list itself can still be changed from outside. That is why the compact constructor does List.copyOf. And records do not fit Page Objects or JPA entities, which need mutable state or proxies.

Record з перевіркою і захисною копією
A record with validation and a defensive copy
public record OrderRequest(String customerEmail, List<String> skus, int quantity) {

    // Компактний конструктор: без списку параметрів
    public OrderRequest {
        if (customerEmail == null || customerEmail.isBlank()) {
            throw new IllegalArgumentException("customerEmail is required");
        }
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity must be positive: " + quantity);
        }
        skus = List.copyOf(skus);   // незмінна копія, null-елементи заборонені
    }

    public static OrderRequest single(String email, String sku) {
        return new OrderRequest(email, List.of(sku), 1);
    }
}

// var expected = OrderRequest.single("anna@example.com", "SKU-1");
// assertEquals(expected, response.as(OrderRequest.class)); // equals згенерований
// expected.skus().add("X");  -> UnsupportedOperationException
public record OrderRequest(String customerEmail, List<String> skus, int quantity) {

    // Compact constructor: no parameter list
    public OrderRequest {
        if (customerEmail == null || customerEmail.isBlank()) {
            throw new IllegalArgumentException("customerEmail is required");
        }
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity must be positive: " + quantity);
        }
        skus = List.copyOf(skus);   // immutable copy, null elements rejected
    }

    public static OrderRequest single(String email, String sku) {
        return new OrderRequest(email, List.of(sku), 1);
    }
}

// var expected = OrderRequest.single("anna@example.com", "SKU-1");
// assertEquals(expected, response.as(OrderRequest.class)); // equals is generated
// expected.skus().add("X");  -> UnsupportedOperationException
🔧 Розбір синтаксису: Компактний конструктор пишеться без дужок з параметрами: він виконується перед присвоєнням полів, а параметри в ньому можна перепризначити, як skus = List.copyOf(skus). Метод доступу skus() згенерований автоматично. List.copyOf повертає незмінний список і кидає NPE на null-елементах. Статичний метод single — звичайна фабрика всередині record.
🔧 Syntax breakdown: The compact constructor is written without the parameter list: it runs before the fields are assigned, and its parameters can be reassigned, as in skus = List.copyOf(skus). The skus() accessor is generated automatically. List.copyOf returns an unmodifiable list and throws an NPE on null elements. The static single method is an ordinary factory inside the record.
💡 Простими словами: Record — це запечатаний конверт з даними: вміст видно, порівнювати можна, але переклеїти марку після відправки не вийде.
💡 In plain words: A record is a sealed envelope of data: you can see and compare what is inside, but you cannot swap the stamp after it has been posted.
🗣 Як читати вголос Для мене record — це стандартний вибір для DTO в API-тестах. Я отримую equals, hashCode і toString без Lombok, і assertEquals на відповіді просто працює. У компактному конструкторі я перевіряю обов’язкові поля і роблю List.copyOf, бо незмінність record лише поверхнева. Jackson з версії 2.12 читає record напряму. Для Page Object чи чогось зі змінним станом я record не беру.
🗣 How to say it out loud For me a record is the default choice for DTOs in API tests. I get equals, hashCode and toString without Lombok, and assertEquals on a response just works. In the compact constructor I validate required fields and call List.copyOf, because a record is only shallowly immutable. Jackson reads records directly since 2.12. For Page Objects or anything with mutable state I do not use a record.
27 Що таке sealed-класи і як змоделювати ними результат виклику API? What are sealed classes, and how do you model an API call result with them?
Контракти й record Contracts and records Senior ▶

Що це. Sealed-клас або інтерфейс (фінальна фіча з Java 17) явно перелічує дозволених нащадків через permits. Кожен нащадок мусить бути final, sealed або non-sealed, а record неявно final. Компілятор знає повний список підтипів, тому switch з pattern matching (фінальний у Java 21) може бути вичерпним без гілки default.

What it is. A sealed class or interface (final since Java 17) explicitly lists its permitted subtypes with permits. Each subtype must be final, sealed or non-sealed, and a record is implicitly final. The compiler knows the full list of subtypes, so a pattern-matching switch (final in Java 21) can be exhaustive without a default branch.

Навіщо у фреймворку. Клієнт API у тестах часто повертає або тіло, або помилку. Замість Object чи пари нуллабельних полів можна описати ApiResult з трьома варіантами: Success, ClientError, ServerError. Тест, який розбирає результат через switch, компілятор змусить обробити всі випадки.

Why it helps in a framework. An API client in tests often returns either a body or an error. Instead of Object or a pair of nullable fields you can describe an ApiResult with three variants: Success, ClientError, ServerError. A test that inspects the result through a switch is forced by the compiler to handle every case.

Приклад з проєкту. Коли додали варіант RateLimited для відповіді 429, збірка впала у всіх місцях, де результат розбирався через switch. Це та поведінка, якої хочеться: нова гілка не може тихо провалитися в default і перетворити 429 на «успіх».

Project example. When a RateLimited variant was added for 429 responses, the build broke in every place that switched over the result. That is exactly the desired behaviour: a new case cannot quietly fall into default and turn a 429 into a "success".

Трейдофи. Enum простіший, коли варіанти не несуть різних даних. Відкрита ієрархія краща, коли нащадків додають сторонні модулі, наприклад плагіни фреймворку. Sealed-ієрархія вимагає, щоб усі нащадки жили в тому самому модулі (або пакеті, якщо модулів немає), і кожне розширення — це зміна API, яку відчують усі споживачі.

Trade-offs. An enum is simpler when the variants carry no distinct data. An open hierarchy is better when third-party modules add subtypes, such as framework plugins. A sealed hierarchy needs all subtypes in the same module (or package, without modules), and every extension is an API change felt by all consumers.

Вичерпний switch по результату виклику API
An exhaustive switch over an API call result
public sealed interface ApiResult<T> permits ApiResult.Success, ApiResult.ClientError, ApiResult.ServerError {
    record Success<T>(int status, T body) implements ApiResult<T> {}
    record ClientError<T>(int status, String message) implements ApiResult<T> {}
    record ServerError<T>(int status, String traceId) implements ApiResult<T> {}
}

static String describe(ApiResult<UserDto> result) {
    return switch (result) {                               // default не потрібен
        case ApiResult.Success<UserDto> s -> "OK " + s.body();
        case ApiResult.ClientError<UserDto> c when c.status() == 404 -> "not found";
        case ApiResult.ClientError<UserDto> c -> "client error: " + c.message();
        case ApiResult.ServerError<UserDto> e -> "server error, trace " + e.traceId();
    };
}
// Новий варіант у permits -> помилка компіляції тут
public sealed interface ApiResult<T> permits ApiResult.Success, ApiResult.ClientError, ApiResult.ServerError {
    record Success<T>(int status, T body) implements ApiResult<T> {}
    record ClientError<T>(int status, String message) implements ApiResult<T> {}
    record ServerError<T>(int status, String traceId) implements ApiResult<T> {}
}

static String describe(ApiResult<UserDto> result) {
    return switch (result) {                               // no default needed
        case ApiResult.Success<UserDto> s -> "OK " + s.body();
        case ApiResult.ClientError<UserDto> c when c.status() == 404 -> "not found";
        case ApiResult.ClientError<UserDto> c -> "client error: " + c.message();
        case ApiResult.ServerError<UserDto> e -> "server error, trace " + e.traceId();
    };
}
// A new variant in permits -> compile error here
🔧 Розбір синтаксису: sealed interface ... permits обмежує реалізації трьома вкладеними record, які неявно final. У Java 21 switch приймає шаблони типів, а when додає охоронну умову до гілки. Порядок важливий: гілка з when для 404 стоїть перед загальною ClientError, інакше компілятор повідомить про недосяжну гілку. Вичерпність перевіряється під час компіляції.
🔧 Syntax breakdown: sealed interface ... permits restricts implementations to three nested records that are implicitly final. In Java 21 switch accepts type patterns, and when adds a guard to a case. Order matters: the 404 case with when comes before the general ClientError one, otherwise the compiler reports a dominated case. Exhaustiveness is checked at compile time.
💡 Простими словами: Sealed-ієрархія — це закрите меню ресторану: кухар знає всі страви, тож офіціант не може принести щось, чого немає в списку.
💡 In plain words: A sealed hierarchy is a fixed restaurant menu: the chef knows every dish, so the waiter cannot bring something that is not on the list.
🗣 Як читати вголос Sealed-типи я використовую, коли варіантів небагато, вони відомі заздалегідь і кожен несе свої дані. Класичний приклад у тестовому клієнті — результат виклику: успіх, помилка клієнта або сервера. Разом зі switch з Java 21 компілятор гарантує, що я обробив усі випадки, і новий варіант ламає збірку, а не тихо проходить. Якщо дані однакові, мені вистачить enum, а якщо нащадків мають додавати інші модулі, я залишаю ієрархію відкритою.
🗣 How to say it out loud I use sealed types when there are few variants, they are known upfront and each carries its own data. The classic case in a test client is a call result: success, client error or server error. Together with the Java 21 switch, the compiler guarantees I handled every case, and a new variant breaks the build instead of slipping through. If the data is the same, an enum is enough, and if other modules must add subtypes, I keep the hierarchy open.
28 Яка різниця між Comparable і Comparator і як перевірити, що список відсортований? What is the difference between Comparable and Comparator, and how do you check that a list is sorted?
Контракти й record Contracts and records Middle ▶

Що це. Comparable<T> задає «природний» порядок самого класу через метод compareTo: так упорядковані String, Integer, LocalDate. Такий порядок у класу лише один. Comparator<T> — окремий об’єкт з методом compare, і їх можна мати скільки завгодно: за ціною, за назвою, за датою у зворотному порядку.

What it is. Comparable<T> defines the class's own "natural" order through compareTo: that is how String, Integer and LocalDate are ordered. A class has only one such order. Comparator<T> is a separate object with a compare method, and you can have as many as you like: by price, by name, by date in reverse.

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

Why it matters in tests. A typical check is "the product table is sorted by price descending, and by name when prices are equal". The most reliable approach: collect the actual list from the UI or API, make a sorted copy with the same comparator and compare the two lists. Never sort the actual list itself, or the test will always be green.

Приклад з проєкту. Comparator.comparing(Product::price).reversed().thenComparing(Product::name) точно описує вимогу. Для полів, які можуть бути null (товар без знижки), обгортаємо ключ у Comparator.nullsLast(...), щоб не отримати NPE і щоб зафіксувати, де саме мають бути порожні значення.

Project example. Comparator.comparing(Product::price).reversed().thenComparing(Product::name) expresses the requirement precisely. For fields that may be null (a product without a discount), wrap the key in Comparator.nullsLast(...) to avoid an NPE and to state where empty values belong.

Підводні камені. Бажано, щоб compareTo був узгоджений з equals: TreeSet вважає дублікатами елементи з compareTo == 0 і мовчки викидає їх. Ще одна пастка — reversed() в кінці ланцюжка розвертає весь компаратор, а не лише останній ключ.

Pitfalls. It is best when compareTo is consistent with equals: a TreeSet treats elements with compareTo == 0 as duplicates and silently drops them. Another trap: reversed() at the end of a chain reverses the whole comparator, not just the last key.

Перевірка сортування таблиці товарів
Checking how a product table is sorted
record Product(String name, BigDecimal price, BigDecimal discount) {}

@Test
void productsAreSortedByPriceDescThenByName() {
    List<Product> actual = catalogApi.products();   // порядок як прийшов з API

    Comparator<Product> expectedOrder = Comparator
            .comparing(Product::price).reversed()   // ціна за спаданням
            .thenComparing(Product::name);          // потім назва за зростанням

    List<Product> sortedCopy = actual.stream().sorted(expectedOrder).toList();
    assertEquals(sortedCopy, actual);               // не сортуємо сам actual!
}

// Порожні знижки в кінці:
Comparator<Product> byDiscount =
        Comparator.comparing(Product::discount, Comparator.nullsLast(Comparator.naturalOrder()));
record Product(String name, BigDecimal price, BigDecimal discount) {}

@Test
void productsAreSortedByPriceDescThenByName() {
    List<Product> actual = catalogApi.products();   // order as returned by the API

    Comparator<Product> expectedOrder = Comparator
            .comparing(Product::price).reversed()   // price descending
            .thenComparing(Product::name);          // then name ascending

    List<Product> sortedCopy = actual.stream().sorted(expectedOrder).toList();
    assertEquals(sortedCopy, actual);               // never sort actual itself!
}

// Empty discounts last:
Comparator<Product> byDiscount =
        Comparator.comparing(Product::discount, Comparator.nullsLast(Comparator.naturalOrder()));
🔧 Розбір синтаксису: Comparator.comparing приймає функцію-ключ, тут посилання на метод доступу record. reversed() стоїть одразу після першого ключа, тому розвертає лише ціну, а thenComparing додає другий ключ за зростанням. stream().sorted(...).toList() створює нову відсортовану копію, не змінюючи actual. nullsLast(naturalOrder()) ставить null у кінець, а решту порівнює природно.
🔧 Syntax breakdown: Comparator.comparing takes a key extractor, here a record accessor reference. reversed() is placed right after the first key, so it reverses only the price, and thenComparing adds a second ascending key. stream().sorted(...).toList() creates a new sorted copy without touching actual. nullsLast(naturalOrder()) puts null at the end and compares the rest naturally.
💡 Простими словами: Comparable — це вбудована в річ лінійка, вона одна. Comparator — набір лінійок на столі, і ти береш ту, яка потрібна саме зараз.
💡 In plain words: Comparable is a ruler built into the object, and there is only one. Comparator is a set of rulers on the desk, and you pick whichever one you need right now.
🗣 Як читати вголос Comparable — це природний порядок, який клас задає сам собі, і він лише один. Comparator — зовнішнє правило, їх може бути багато, і в Java 8 плюс я збираю їх ланцюжком comparing, thenComparing, reversed. Коли перевіряю сортування на сторінці, я ніколи не сортую фактичний список: роблю відсортовану копію тим самим компаратором і порівнюю. А для полів з null одразу додаю nullsLast, щоб вимога була явною.
🗣 How to say it out loud Comparable is the natural order a class defines for itself, and there is only one. Comparator is an external rule, you can have many, and since Java 8 I chain them with comparing, thenComparing and reversed. When I verify sorting on a page I never sort the actual list: I make a sorted copy with the same comparator and compare. And for nullable fields I add nullsLast straight away so the requirement is explicit.
29 Чому незмінні об’єкти важливі для паралельних тестів і як робити захисні копії? Why do immutable objects matter for parallel tests, and how do you make defensive copies?
Контракти й record Contracts and records Senior ▶

Що це. Незмінний об’єкт після створення не змінює свого стану: усі поля private final, немає сетерів, клас final або має приватний конструктор, а змінні складові (списки, мапи, масиви, Date) копіюються на вході й не віддаються назовні як є. Такий об’єкт можна вільно ділити між потоками без синхронізації.

What it is. An immutable object never changes state after construction: all fields are private final, there are no setters, the class is final or has a private constructor, and mutable parts (lists, maps, arrays, Date) are copied on the way in and never handed out as they are. Such an object can be shared freely between threads without synchronisation.

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

Why it matters in a framework. A parallel run is dozens of threads reading shared configuration and test data. As long as those objects are immutable, races are impossible by definition. The moment someone adds a setter "just for now", a class of bugs appears that reproduces once in twenty runs.

Приклад з проєкту. Був статичний TestUsers.ADMIN зі списком ролей. Один тест у своєму @BeforeEach робив ADMIN.getRoles().remove("billing"), щоб перевірити відмову в доступі. Послідовно все проходило, бо тест йшов останнім. У паралельному режимі сусідні тести, яким потрібна роль billing, почали падати з 403 у випадкових місцях. Виправлення: незмінний TestUser з List.copyOf і метод withoutRole, що повертає нову копію.

Project example. There was a static TestUsers.ADMIN with a list of roles. One test did ADMIN.getRoles().remove("billing") in its @BeforeEach to check an access denial. Run sequentially it passed because that test ran last. In parallel mode neighbouring tests that needed the billing role started failing with 403 at random. The fix: an immutable TestUser with List.copyOf and a withoutRole method returning a new copy.

Трейдофи. Копіювання коштує пам’яті й часу, але для тестових даних це копійки порівняно з годинами пошуку flaky-тесту. Collections.unmodifiableList — лише обгортка: зміни в оригінальному списку видно крізь неї, тож для захисної копії потрібен саме List.copyOf.

Trade-offs. Copying costs memory and time, but for test data that is pennies compared with hours spent hunting a flaky test. Collections.unmodifiableList is only a wrapper: changes to the original list show through it, so a defensive copy needs List.copyOf.

Незмінний тестовий користувач замість спільного змінного
An immutable test user instead of a shared mutable one
public final class TestUser {
    private final String login;
    private final List<String> roles;

    public TestUser(String login, List<String> roles) {
        this.login = login;
        this.roles = List.copyOf(roles);      // копія на вході
    }

    public String login() { return login; }
    public List<String> roles() { return roles; }  // вже незмінний, віддаємо як є

    public TestUser withoutRole(String role) {     // «зміна» = новий об’єкт
        List<String> left = new ArrayList<>(roles);
        left.remove(role);
        return new TestUser(login, left);
    }
}

// static final TestUser ADMIN = new TestUser("admin", List.of("admin", "billing"));
// TestUser limited = ADMIN.withoutRole("billing");   // ADMIN не змінився
public final class TestUser {
    private final String login;
    private final List<String> roles;

    public TestUser(String login, List<String> roles) {
        this.login = login;
        this.roles = List.copyOf(roles);      // copy on the way in
    }

    public String login() { return login; }
    public List<String> roles() { return roles; }  // already unmodifiable, safe to return

    public TestUser withoutRole(String role) {     // a "change" = a new object
        List<String> left = new ArrayList<>(roles);
        left.remove(role);
        return new TestUser(login, left);
    }
}

// static final TestUser ADMIN = new TestUser("admin", List.of("admin", "billing"));
// TestUser limited = ADMIN.withoutRole("billing");   // ADMIN is unchanged
🔧 Розбір синтаксису: Поля private final присвоюються один раз у конструкторі. List.copyOf(roles) відриває об’єкт від списку, який передав викликач: подальші зміни того списку нас не зачеплять. Метод доступу може віддати roles напряму, бо копія незмінна. withoutRole працює як «wither»: бере змінну робочу копію, міняє її і повертає новий TestUser.
🔧 Syntax breakdown: The private final fields are assigned once in the constructor. List.copyOf(roles) detaches the object from the caller's list: later changes to that list will not affect us. The accessor can return roles directly because the copy is unmodifiable. withoutRole works as a "wither": it takes a mutable working copy, changes it and returns a new TestUser.
💡 Простими словами: Незмінний об’єкт — як надрукована фотографія: хочеш іншу — друкуй нову, а не малюй маркером на тій, що висить у спільному коридорі.
💡 In plain words: An immutable object is like a printed photo: if you want a different one, print a new one instead of drawing with a marker on the one hanging in the shared hallway.
🗣 Як читати вголос Я пояснюю незмінність через паралельний запуск. Якщо конфіг і тестові користувачі незмінні, потокам нічого ділити, і гонок просто не буває. У мене був реальний випадок, коли один тест видаляв роль у статичного адміна, і сусідні тести падали з 403 лише в паралельному режимі. Тому я роблю поля final, копіюю колекції через List.copyOf і замість сетерів повертаю нову копію. Unmodifiable-обгортку я не вважаю захистом, бо оригінал усе ще можна змінити.
🗣 How to say it out loud I explain immutability through parallel runs. If the config and test users are immutable, threads have nothing to fight over and races simply do not happen. I had a real case where one test removed a role from a static admin, and neighbouring tests failed with 403 only in parallel mode. So I make fields final, copy collections with List.copyOf and return a new copy instead of using setters. I do not count an unmodifiable wrapper as protection, because the original can still change.
30 Як використовувати enum для тестових даних і конфігурації середовищ? How do you use enums for test data and environment configuration?
Контракти й record Contracts and records Junior ▶

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

What it is. An enum is a class with a fixed set of instances created once when the class is loaded. Unlike a bunch of string constants, an enum can have fields, a constructor and methods, and the compiler will not let you pass a value that is not in the list.

Навіщо в автотестах. Середовища (DEV, STAGE, PROD), браузери, ролі користувачів, статуси замовлення — усе це скінченні множини. Коли середовище описане як Env.STAGE з полями baseUrl і apiUrl, опечатка на кшталт «stagee» ловиться одразу, а не перетворюється на запит до неіснуючого хоста.

Why it helps in automation. Environments (DEV, STAGE, PROD), browsers, user roles, order statuses are all finite sets. When an environment is described as Env.STAGE with baseUrl and apiUrl fields, a typo such as "stagee" is caught at once instead of turning into a request to a host that does not exist.

Приклад з проєкту. Середовище читаємо з системної властивості: mvn test -Denv=stage. Env.valueOf чутливий до регістру і кидає IllegalArgumentException з незрозумілим для новачка текстом, тому обгортаємо його у власний метод з переліком допустимих значень через values(). Для ролей зручно EnumMap — компактна й швидка мапа з ключами-enum. У JUnit 5 @EnumSource(Role.class) запускає параметризований тест для кожного значення.

Project example. The environment comes from a system property: mvn test -Denv=stage. Env.valueOf is case-sensitive and throws IllegalArgumentException with a message that confuses newcomers, so we wrap it in our own method that lists the allowed values via values(). For roles an EnumMap is handy, a compact and fast map keyed by an enum. In JUnit 5 @EnumSource(Role.class) runs a parameterised test once per value.

Підводні камені. Не варто зберігати в enum змінний стан: екземпляр один на всю JVM, і паралельні тести його поділять. І не використовуй ordinal() для логіки — порядок зміниться, щойно хтось додасть нову константу посередині.

Pitfalls. Do not keep mutable state in an enum: there is one instance per JVM, and parallel tests will share it. And do not base logic on ordinal() — the order changes as soon as someone adds a constant in the middle.

Enum середовищ з полями і зрозумілою помилкою
An environment enum with fields and a clear error
public enum Env {
    DEV("https://dev.shop.example.com"),
    STAGE("https://stage.shop.example.com"),
    PROD("https://shop.example.com");

    private final String baseUrl;

    Env(String baseUrl) { this.baseUrl = baseUrl; }

    public String baseUrl() { return baseUrl; }

    public static Env current() {
        String raw = System.getProperty("env", "dev");
        try {
            return Env.valueOf(raw.trim().toUpperCase(Locale.ROOT));
        } catch (IllegalArgumentException e) {
            throw new IllegalStateException(
                    "Unknown env '" + raw + "', allowed: " + Arrays.toString(values()), e);
        }
    }
}

// switch (Env.current()) { case PROD -> skipDestructiveTests(); default -> {} } // switch по enum
// @ParameterizedTest @EnumSource(Role.class) void canOpenDashboard(Role role) { ... }
public enum Env {
    DEV("https://dev.shop.example.com"),
    STAGE("https://stage.shop.example.com"),
    PROD("https://shop.example.com");

    private final String baseUrl;

    Env(String baseUrl) { this.baseUrl = baseUrl; }

    public String baseUrl() { return baseUrl; }

    public static Env current() {
        String raw = System.getProperty("env", "dev");
        try {
            return Env.valueOf(raw.trim().toUpperCase(Locale.ROOT));
        } catch (IllegalArgumentException e) {
            throw new IllegalStateException(
                    "Unknown env '" + raw + "', allowed: " + Arrays.toString(values()), e);
        }
    }
}

// switch (Env.current()) { case PROD -> skipDestructiveTests(); default -> {} } // switch over an enum
// @ParameterizedTest @EnumSource(Role.class) void canOpenDashboard(Role role) { ... }
🔧 Розбір синтаксису: Константи оголошуються першими й викликають приватний конструктор з URL. System.getProperty("env", "dev") повертає значення за замовчуванням, якщо властивість не задана. toUpperCase(Locale.ROOT) захищає від сюрпризів локалі (наприклад, турецької «i»). Помилка перекидається з причиною і списком з values(). У switch по enum константи пишуться без імені класу.
🔧 Syntax breakdown: The constants are declared first and call the private constructor with a URL. System.getProperty("env", "dev") returns a default when the property is not set. toUpperCase(Locale.ROOT) guards against locale surprises (such as the Turkish "i"). The error is rethrown with its cause and the list from values(). In a switch over an enum the constants are written without the class name.
💡 Простими словами: Enum — це список гостей на вході: хто не в списку, того просто не пустять, скільки б він не наполягав.
💡 In plain words: An enum is the guest list at the door: whoever is not on the list does not get in, however hard they insist.
🗣 Як читати вголос Усе, що має скінченний набір значень, я описую як enum: середовища, браузери, ролі. Enum може мати поля, тому Env.STAGE сам знає свій URL, а опечатку ловить компілятор. Середовище я читаю з системної властивості, але обгортаю valueOf, щоб при помилці людина побачила список допустимих значень. У JUnit 5 зручно ганяти тест по всіх ролях через EnumSource. І я слідкую, щоб у enum не було змінного стану.
🗣 How to say it out loud Anything with a finite set of values I model as an enum: environments, browsers, roles. An enum can have fields, so Env.STAGE knows its own URL and the compiler catches typos. I read the environment from a system property, but I wrap valueOf so that on a mistake people see the list of allowed values. In JUnit 5 it is handy to run a test over every role with EnumSource. And I make sure an enum holds no mutable state.
▶ 5 11
31 Як влаштована ієрархія колекцій і чим ArrayList відрізняється від LinkedList? How is the collections hierarchy organised, and how does ArrayList differ from LinkedList?
Колекції Collections Junior ▶

Ієрархія. Кореневий інтерфейс — Collection, від нього походять три гілки: List (впорядкована послідовність з дублікатами й доступом за індексом), Set (без дублікатів) і Queue/Deque (черги). Map стоїть окремо: він не є Collection, бо зберігає пари ключ–значення. Реалізації обирають під задачу, а змінні й параметри оголошують через інтерфейс: List<User> users = new ArrayList<>().

Hierarchy. The root interface is Collection, and three branches come from it: List (an ordered sequence with duplicates and index access), Set (no duplicates) and Queue/Deque (queues). Map stands apart: it is not a Collection because it stores key–value pairs. You pick the implementation for the job and declare variables and parameters through the interface: List<User> users = new ArrayList<>().

ArrayList проти LinkedList. ArrayList тримає елементи у внутрішньому масиві: get(i) працює за O(1), додавання в кінець — амортизовано O(1), а вставка на початок зсуває весь масив. LinkedList — двозв’язний список вузлів: доступ за індексом O(n), бо треба йти від краю, зате вставка й видалення біля вже знайденого вузла дешеві. На практиці ArrayList майже завжди швидший навіть на вставках, бо масив лежить у пам’яті суцільно і дружить з кешем процесора, а кожен вузол LinkedList — окремий об’єкт з двома посиланнями.

ArrayList versus LinkedList. ArrayList keeps elements in an internal array: get(i) is O(1), appending is amortised O(1), and inserting at the front shifts the whole array. LinkedList is a doubly linked list of nodes: index access is O(n) because it walks from one end, while inserting or removing next to a node you already hold is cheap. In practice ArrayList is almost always faster, even for inserts, because the array sits contiguously in memory and plays well with the CPU cache, whereas every LinkedList node is a separate object with two references.

Приклад з проєкту. Список рядків таблиці, отриманий з UI чи з API, — це ArrayList: ми читаємо його за індексом і перебираємо. Якщо треба черга повторних спроб, беру ArrayDeque, а не LinkedList.

Project example. The rows of a table read from the UI or an API go into an ArrayList: we read them by index and iterate. When I need a retry queue I take ArrayDeque, not LinkedList.

Підводні камені. Цикл for (int i = 0; i < list.size(); i++) list.get(i) по LinkedList перетворюється на O(n²). А змінна типу ArrayList замість List прив’язує метод до реалізації без жодної користі.

Pitfalls. A loop like for (int i = 0; i < list.size(); i++) list.get(i) over a LinkedList silently becomes O(n²). And a variable typed as ArrayList instead of List ties a method to one implementation for no benefit.

Оголошення через інтерфейс і різна ціна доступу
Declaring through the interface and the different cost of access
import java.util.*;

public class ListChoice {
    // Параметр типу List: підійде будь-яка реалізація
    static String firstEmail(List<String> emails) {
        return emails.isEmpty() ? "" : emails.get(0);
    }

    public static void main(String[] args) {
        List<String> emails = new ArrayList<>(List.of("anna@example.com", "bob@example.com"));
        emails.add("carl@example.com");       // додавання в кінець масиву
        System.out.println(firstEmail(emails)); // get(0) за O(1)

        List<String> linked = new LinkedList<>(emails);
        String middle = linked.get(1);        // прохід від краю списку, O(n)
        System.out.println(middle);
    }
}
import java.util.*;

public class ListChoice {
    // A List parameter: any implementation fits
    static String firstEmail(List<String> emails) {
        return emails.isEmpty() ? "" : emails.get(0);
    }

    public static void main(String[] args) {
        List<String> emails = new ArrayList<>(List.of("anna@example.com", "bob@example.com"));
        emails.add("carl@example.com");       // append to the array
        System.out.println(firstEmail(emails)); // get(0) in O(1)

        List<String> linked = new LinkedList<>(emails);
        String middle = linked.get(1);        // walks from the end of the list, O(n)
        System.out.println(middle);
    }
}
🔧 Розбір синтаксису: new ArrayList<>(List.of(...)) робить змінну копію незмінного List.of, тому add працює. Метод firstEmail приймає List<String>, тож йому однаково, передали ArrayList чи LinkedList. Оператор <> (diamond) виводить параметр типу з лівої частини.
🔧 Syntax breakdown: new ArrayList<>(List.of(...)) makes a mutable copy of the immutable List.of, which is why add works. The firstEmail method takes a List<String>, so it does not care whether it gets an ArrayList or a LinkedList. The <> (diamond) operator infers the type argument from the left-hand side.
💡 Простими словами: ArrayList — це книжка з номерами сторінок, LinkedList — ланцюжок записок, де кожна каже, де лежить наступна.
💡 In plain words: ArrayList is a book with page numbers, LinkedList is a chain of notes where each one says where the next note is.
🗣 Як читати вголос Я починаю з того, що Collection має три гілки — List, Set і Queue, а Map живе окремо. Далі кажу, що ArrayList тримає масив і дає доступ за індексом миттєво, а LinkedList — ланцюжок вузлів, де для get треба пройти половину списку. Чесно додаю, що в реальних фреймворках я майже завжди беру ArrayList, а для черг ArrayDeque. І змінні оголошую через інтерфейс List, щоб код не залежав від конкретної реалізації.
🗣 How to say it out loud I start by saying that Collection has three branches, List, Set and Queue, and Map lives on its own. Then I explain that ArrayList holds an array and gives instant index access, while LinkedList is a chain of nodes where get has to walk half the list. I honestly add that in real frameworks I almost always pick ArrayList, and ArrayDeque for queues. And I declare variables as the List interface so the code does not depend on a concrete implementation.
32 Чим відрізняються HashMap, LinkedHashMap і TreeMap? How do HashMap, LinkedHashMap and TreeMap differ?
Колекції Collections Junior ▶

HashMap. Хеш-таблиця: get і put у середньому за O(1), порядок ітерації не гарантований і може змінитися після розширення таблиці. Дозволяє один null-ключ і будь-яку кількість null-значень. Це вибір за замовчуванням, коли порядок не важливий.

HashMap. A hash table: get and put are O(1) on average, the iteration order is not guaranteed and may change after the table resizes. It allows one null key and any number of null values. It is the default choice when order does not matter.

LinkedHashMap. Та сама хеш-таблиця плюс двозв’язний список записів, тому ітерація йде в порядку вставки (або доступу, якщо створити її з accessOrder = true — так будують простий LRU-кеш). Коштує трохи більше пам’яті, швидкість майже та сама.

LinkedHashMap. The same hash table plus a doubly linked list of entries, so iteration follows insertion order (or access order if created with accessOrder = true, which is how a simple LRU cache is built). It costs a bit more memory at almost the same speed.

TreeMap. Червоно-чорне дерево: ключі завжди відсортовані за Comparable або переданим Comparator, операції за O(log n), є навігаційні методи firstKey, headMap, ceilingKey. null-ключ за природного порядку кидає NullPointerException.

TreeMap. A red-black tree: keys are always sorted by Comparable or a supplied Comparator, operations are O(log n), and there are navigation methods such as firstKey, headMap, ceilingKey. A null key under natural ordering throws NullPointerException.

Приклад з проєкту. Коли тест збирає заголовки відповіді й друкує їх у звіт Allure, TreeMap з String.CASE_INSENSITIVE_ORDER дає стабільний алфавітний порядок і нечутливість до регістру, як і належить HTTP-заголовкам. Для параметрів запиту, де важливий порядок у рядку, беру LinkedHashMap.

Project example. When a test collects response headers and prints them into an Allure report, a TreeMap with String.CASE_INSENSITIVE_ORDER gives a stable alphabetical order and case-insensitive lookup, which is exactly how HTTP headers behave. For query parameters, where order in the string matters, I use LinkedHashMap.

Підводні камені. Перевірка на кшталт «перший ключ мапи — id» з HashMap працює на ноутбуці й падає на CI після зміни кількості елементів: порядок HashMap — деталь реалізації. А порівнювати мапи треба через equals, який не залежить від порядку.

Pitfalls. A check like "the first key of the map is id" on a HashMap passes on a laptop and fails on CI once the element count changes: HashMap order is an implementation detail. Maps should be compared with equals, which ignores order.

Три мапи з однаковими даними — три різні порядки
Three maps with the same data give three different orders
import java.util.*;

public class MapOrder {
    public static void main(String[] args) {
        Map<String, String> headers = new LinkedHashMap<>();
        headers.put("X-Request-Id", "42");
        headers.put("Content-Type", "application/json");
        headers.put("cache-control", "no-cache");

        // Порядок вставки
        System.out.println(headers.keySet());

        // Порядок не гарантований
        System.out.println(new HashMap<>(headers).keySet());

        // Алфавіт без урахування регістру
        Map<String, String> sorted = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
        sorted.putAll(headers);
        System.out.println(sorted.keySet());
        System.out.println(sorted.get("CONTENT-TYPE")); // application/json

        // equals не залежить від порядку
        System.out.println(headers.equals(new HashMap<>(headers))); // true
    }
}
import java.util.*;

public class MapOrder {
    public static void main(String[] args) {
        Map<String, String> headers = new LinkedHashMap<>();
        headers.put("X-Request-Id", "42");
        headers.put("Content-Type", "application/json");
        headers.put("cache-control", "no-cache");

        // Insertion order
        System.out.println(headers.keySet());

        // Order is not guaranteed
        System.out.println(new HashMap<>(headers).keySet());

        // Alphabetical, case-insensitive
        Map<String, String> sorted = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
        sorted.putAll(headers);
        System.out.println(sorted.keySet());
        System.out.println(sorted.get("CONTENT-TYPE")); // application/json

        // equals ignores order
        System.out.println(headers.equals(new HashMap<>(headers))); // true
    }
}
🔧 Розбір синтаксису: new TreeMap<>(String.CASE_INSENSITIVE_ORDER) передає компаратор, який визначає і сортування, і рівність ключів у цій мапі, тому get("CONTENT-TYPE") знаходить значення. Конструктор new HashMap<>(map) копіює записи в нову мапу. Map.equals порівнює набори пар, тож мапи різних типів з однаковими даними рівні.
🔧 Syntax breakdown: new TreeMap<>(String.CASE_INSENSITIVE_ORDER) passes a comparator that defines both the sorting and the key equality in this map, so get("CONTENT-TYPE") finds the value. The new HashMap<>(map) constructor copies entries into a new map. Map.equals compares sets of pairs, so maps of different types with the same data are equal.
💡 Простими словами: HashMap — шухляда, куди речі кидають як попало, LinkedHashMap — черга в касу, TreeMap — алфавітний каталог у бібліотеці.
💡 In plain words: HashMap is a drawer where things are thrown in any old way, LinkedHashMap is a queue at the till, and TreeMap is the alphabetical catalogue in a library.
🗣 Як читати вголос Я розрізняю їх за порядком ітерації. HashMap найшвидша, але порядку не обіцяє взагалі, LinkedHashMap пам’ятає порядок вставки, а TreeMap тримає ключі відсортованими ціною O(log n) і не любить null-ключі. У тестах це важливо для звітів і логів: якщо я друкую заголовки чи параметри, беру TreeMap або LinkedHashMap, щоб вивід був стабільним. А в асертах ніколи не покладаюся на порядок HashMap і порівнюю мапи через equals.
🗣 How to say it out loud I tell them apart by iteration order. HashMap is the fastest but promises no order at all, LinkedHashMap remembers insertion order, and TreeMap keeps keys sorted at O(log n) and does not like null keys. In tests this matters for reports and logs: when I print headers or parameters I use TreeMap or LinkedHashMap so the output is stable. And in assertions I never rely on HashMap order and compare maps with equals.
33 Як усередині працюють HashMap і HashSet і що ламає їхню швидкість? How do HashMap and HashSet work internally, and what breaks their performance?
Колекції Collections Senior ▶

HashSet — це HashMap. HashSet всередині тримає HashMap, де елементи множини — ключі, а значення — один спільний об’єкт-заглушка. Тому все, що стосується ключів HashMap, стосується й елементів HashSet.

HashSet is a HashMap. A HashSet internally holds a HashMap where the set elements are keys and the value is one shared dummy object. So everything that applies to HashMap keys applies to HashSet elements.

Як шукається ключ. HashMap бере hashCode() ключа, перемішує старші біти з молодшими і за індексом у масиві «кошиків» знаходить потрібний кошик. Усередині кошика записи порівнюються спочатку за хешем, потім через equals. Коли кількість записів перевищує місткість × load factor (0.75 за замовчуванням), масив подвоюється і записи перерозподіляються. З Java 8 кошик, у якому більше 8 записів (за місткості таблиці від 64), перетворюється на збалансоване дерево, тож найгірший випадок — O(log n), а не O(n).

How a key is found. HashMap takes the key's hashCode(), mixes the high bits into the low ones and uses the result as an index into the array of buckets. Inside a bucket, entries are compared first by hash, then with equals. When the number of entries exceeds capacity × load factor (0.75 by default), the array doubles and entries are redistributed. Since Java 8 a bucket holding more than 8 entries (with a table capacity of at least 64) is turned into a balanced tree, so the worst case is O(log n) rather than O(n).

Що ламає. Поганий hashCode, що повертає константу або залежить від одного поля з трьома значеннями, звалює все в кілька кошиків — пошук деградує. Змінний ключ ще гірший: якщо після put змінити поле, яке входить у hashCode, об’єкт лежить у «старому» кошику, і contains чи remove його вже не знайдуть, хоча ітерація показує його в множині.

What breaks it. A poor hashCode that returns a constant or depends on one field with three possible values dumps everything into a few buckets and lookup degrades. A mutable key is even worse: if a field used in hashCode changes after put, the object sits in the "old" bucket, and contains or remove no longer find it, although iteration still shows it in the set.

Трейдоф у фреймворку. Ключами мап і елементами множин роблю незмінні типи: String, enum, record. Якщо кількість елементів відома наперед (50 тисяч id з API), задаю початкову місткість, щоб уникнути серії перерозподілів.

Framework trade-off. I make map keys and set elements immutable types: String, enums, records. When the element count is known upfront (50 thousand ids from an API), I set the initial capacity to avoid a series of rehashes.

Змінний ключ «губиться» в HashSet
A mutable key gets lost in a HashSet
import java.util.*;

public class MutableKeyBug {
    static class User {
        String email;
        User(String email) { this.email = email; }
        @Override public boolean equals(Object o) {
            return o instanceof User u && email.equals(u.email);
        }
        @Override public int hashCode() { return email.hashCode(); }
    }

    public static void main(String[] args) {
        Set<User> checked = new HashSet<>();
        User anna = new User("anna@example.com");
        checked.add(anna);

        anna.email = "anna.new@example.com"; // змінили поле з hashCode
        System.out.println(checked.contains(anna)); // false: шукає не в тому кошику
        System.out.println(checked.size());         // 1: об’єкт досі всередині

        // Попередньо задана місткість для 50 000 id
        Set<Long> ids = HashSet.newHashSet(50_000); // Java 19+
        ids.add(42L);
    }
}
import java.util.*;

public class MutableKeyBug {
    static class User {
        String email;
        User(String email) { this.email = email; }
        @Override public boolean equals(Object o) {
            return o instanceof User u && email.equals(u.email);
        }
        @Override public int hashCode() { return email.hashCode(); }
    }

    public static void main(String[] args) {
        Set<User> checked = new HashSet<>();
        User anna = new User("anna@example.com");
        checked.add(anna);

        anna.email = "anna.new@example.com"; // changed a field used in hashCode
        System.out.println(checked.contains(anna)); // false: looks in the wrong bucket
        System.out.println(checked.size());         // 1: the object is still inside

        // Pre-sized for 50,000 ids
        Set<Long> ids = HashSet.newHashSet(50_000); // Java 19+
        ids.add(42L);
    }
}
🔧 Розбір синтаксису: o instanceof User u — pattern matching, що одразу дає змінну потрібного типу. hashCode залежить від email, тому після зміни поля об’єкт має новий хеш, а лежить у кошику за старим. HashSet.newHashSet(n) (з Java 19) рахує місткість з урахуванням load factor, на відміну від new HashSet<>(n), який трактує n як сиру місткість масиву.
🔧 Syntax breakdown: o instanceof User u is pattern matching that gives a variable of the right type straight away. hashCode depends on email, so after the field changes the object has a new hash while it sits in the bucket for the old one. HashSet.newHashSet(n) (since Java 19) computes the capacity with the load factor in mind, unlike new HashSet<>(n), which treats n as the raw array capacity.
💡 Простими словами: HashMap — гардероб з номерками. Якщо після здачі куртки перефарбувати номерок, гардеробник шукатиме її не на тому гачку.
💡 In plain words: A HashMap is a cloakroom with numbered tags. Repaint your tag after handing in the coat, and the attendant will look for it on the wrong hook.
🗣 Як читати вголос Я пояснюю, що HashSet — це просто обгортка над HashMap, де елементи є ключами. Пошук іде так: hashCode визначає кошик, а всередині кошика вирішує equals. Коли таблиця заповнена на три чверті, вона подвоюється, а з Java 8 довгі кошики стають деревами. На практиці продуктивність ламають дві речі: поганий hashCode і змінні ключі. Тому в тестових фреймворках я кладу в множини та мапи лише незмінні об’єкти — рядки, enum чи record.
🗣 How to say it out loud I explain that HashSet is just a wrapper around HashMap where the elements are keys. A lookup goes like this: hashCode picks the bucket, and inside the bucket equals decides. When the table is three quarters full it doubles, and since Java 8 long buckets become trees. In practice two things ruin performance: a bad hashCode and mutable keys. That is why in test frameworks I only put immutable objects into sets and maps: strings, enums or records.
34 Що таке Iterator та Iterable і як працює цикл for-each? What are Iterator and Iterable, and how does the for-each loop work?
Колекції Collections Junior ▶

Iterator. Об’єкт-курсор для послідовного проходу колекції з трьома методами: hasNext() перевіряє, чи є ще елементи, next() повертає наступний, а необов’язковий remove() видаляє щойно повернений елемент з колекції, що лежить під ним. Ітератор не знає індексів, тому однаково працює для List, Set і Queue.

Iterator. A cursor object for walking a collection sequentially, with three methods: hasNext() checks whether elements remain, next() returns the next one, and the optional remove() deletes the element just returned from the underlying collection. An iterator knows nothing about indexes, so it works the same for a List, a Set or a Queue.

Iterable. Інтерфейс з одним обов’язковим методом iterator(). Усе, що реалізує Iterable, можна перебрати циклом for-each: компілятор перетворює for (User u : users) на отримання ітератора і цикл з hasNext/next. Масиви теж працюють у for-each, але для них компілятор генерує звичайний цикл за індексом. Крім того, Iterable має default-метод forEach(Consumer).

Iterable. An interface with one required method, iterator(). Anything implementing Iterable can be walked with a for-each loop: the compiler turns for (User u : users) into obtaining an iterator and looping with hasNext/next. Arrays also work in for-each, but for them the compiler generates a plain index loop. On top of that, Iterable has a default method forEach(Consumer).

Приклад з проєкту. Набір тестових даних з CSV-файлу можна оформити як клас, що реалізує Iterable<TestUser>. Тоді тест пише for (TestUser u : dataset), а те, як дані читаються й фільтруються, сховано всередині класу.

Project example. A set of test data from a CSV file can be written as a class implementing Iterable<TestUser>. Then the test writes for (TestUser u : dataset), and how the data is read and filtered stays hidden inside the class.

Підводні камені. Виклик next() без перевірки hasNext() наприкінці кидає NoSuchElementException. remove() двічі поспіль або до першого next() — IllegalStateException. А ітератор List.of і інших незмінних колекцій на remove() кидає UnsupportedOperationException.

Pitfalls. Calling next() without checking hasNext() at the end throws NoSuchElementException. Calling remove() twice in a row or before the first next() throws IllegalStateException. And the iterator of List.of and other immutable collections throws UnsupportedOperationException on remove().

Власний набір тестових даних, який працює у for-each
A custom test data set that works in for-each
import java.util.*;

record TestUser(String email, String role) {}

class UserDataset implements Iterable<TestUser> {
    private final List<TestUser> users;

    UserDataset(List<TestUser> users) { this.users = List.copyOf(users); }

    @Override
    public Iterator<TestUser> iterator() {
        return users.iterator(); // делегуємо ітератору списку
    }
}

public class IterDemo {
    public static void main(String[] args) {
        var dataset = new UserDataset(List.of(
            new TestUser("admin@example.com", "ADMIN"),
            new TestUser("guest@example.com", "GUEST")));

        for (TestUser u : dataset) {           // for-each через iterator()
            System.out.println(u.email());
        }

        List<String> roles = new ArrayList<>(List.of("ADMIN", "GUEST", "BANNED"));
        Iterator<String> it = roles.iterator();
        while (it.hasNext()) {
            if (it.next().equals("BANNED")) it.remove(); // безпечне видалення
        }
        System.out.println(roles); // [ADMIN, GUEST]
    }
}
import java.util.*;

record TestUser(String email, String role) {}

class UserDataset implements Iterable<TestUser> {
    private final List<TestUser> users;

    UserDataset(List<TestUser> users) { this.users = List.copyOf(users); }

    @Override
    public Iterator<TestUser> iterator() {
        return users.iterator(); // delegate to the list iterator
    }
}

public class IterDemo {
    public static void main(String[] args) {
        var dataset = new UserDataset(List.of(
            new TestUser("admin@example.com", "ADMIN"),
            new TestUser("guest@example.com", "GUEST")));

        for (TestUser u : dataset) {           // for-each via iterator()
            System.out.println(u.email());
        }

        List<String> roles = new ArrayList<>(List.of("ADMIN", "GUEST", "BANNED"));
        Iterator<String> it = roles.iterator();
        while (it.hasNext()) {
            if (it.next().equals("BANNED")) it.remove(); // safe removal
        }
        System.out.println(roles); // [ADMIN, GUEST]
    }
}
🔧 Розбір синтаксису: implements Iterable<TestUser> зобов’язує написати iterator(), і після цього клас можна ставити праворуч від двокрапки в for-each. List.copyOf робить незмінну копію, тому набір даних не зміниться ззовні. У циклі while (it.hasNext()) видалення йде через it.remove(), а не через roles.remove, інакше буде ConcurrentModificationException.
🔧 Syntax breakdown: implements Iterable<TestUser> requires an iterator() method, after which the class can stand to the right of the colon in a for-each. List.copyOf makes an immutable copy, so the data set cannot change from outside. Inside the while (it.hasNext()) loop removal goes through it.remove() rather than roles.remove, otherwise a ConcurrentModificationException follows.
💡 Простими словами: Iterable — це книжка, Iterator — закладка в ній. Книжок одна, а закладок можна зробити скільки завгодно.
💡 In plain words: Iterable is the book, Iterator is a bookmark in it. There is one book, but you can have as many bookmarks as you like.
🗣 Як читати вголос Я кажу, що Iterable — це «те, що можна перебрати», а Iterator — курсор, який іде по елементах через hasNext і next і вміє безпечно видалити поточний. For-each — лише синтаксичний цукор: компілятор сам бере iterator і крутить цикл. У фреймворку я інколи реалізую Iterable для набору тестових даних, щоб тест просто писав for-each, не знаючи, звідки дані. І пам’ятаю, що видаляти під час проходу можна лише через remove самого ітератора.
🗣 How to say it out loud I say that Iterable is "something you can walk through", and Iterator is a cursor that moves over the elements with hasNext and next and can safely remove the current one. For-each is just syntactic sugar: the compiler grabs the iterator and runs the loop itself. In a framework I sometimes implement Iterable for a test data set so the test simply writes a for-each without knowing where the data comes from. And I keep in mind that removing during a pass is only safe through the iterator's own remove.
35 Коли виникає ConcurrentModificationException і як її уникнути? When does ConcurrentModificationException occur, and how do you avoid it?
Колекції Collections Middle ▶

Що це. ConcurrentModificationException кидає fail-fast ітератор, коли колекцію змінили в обхід нього під час проходу. ArrayList, HashMap та інші звичайні колекції рахують структурні зміни в полі modCount; ітератор запам’ятовує значення на старті й на кожному next() звіряє його. Не збіглося — виняток.

What it is. A fail-fast iterator throws ConcurrentModificationException when the collection is changed behind its back during iteration. ArrayList, HashMap and other ordinary collections count structural changes in a modCount field; the iterator records the value at the start and compares it on every next(). If they differ, it throws.

Не лише потоки. Попри назву, найчастіше це однопотокова помилка: видалення елемента з List прямо в тілі for-each. Типовий тестовий сценарій — прибрати з колекції «очікуваних» уже знайдені елементи або вичистити з результатів API записи зі статусом DELETED.

Not only threads. Despite the name, it is most often a single-threaded bug: removing an element from a List right inside a for-each body. A typical test scenario is dropping already-found items from an "expected" collection, or cleaning out API results with the status DELETED.

Як виправити. Iterator.remove() у явному циклі; list.removeIf(predicate) — найкоротший і найзрозуміліший варіант; побудова нового списку через stream().filter(...).toList(). Для мап — map.entrySet().removeIf(...) або map.values().removeIf(...). Якщо колекцію справді змінюють інші потоки, потрібні конкурентні структури: ConcurrentHashMap або CopyOnWriteArrayList — остання добра для списку слухачів тестового звіту, який рідко змінюється і часто читається.

How to fix it. Iterator.remove() in an explicit loop; list.removeIf(predicate), the shortest and clearest option; building a new list with stream().filter(...).toList(). For maps, map.entrySet().removeIf(...) or map.values().removeIf(...). If other threads really do modify the collection, you need concurrent structures: ConcurrentHashMap or CopyOnWriteArrayList, the latter being a good fit for a list of test report listeners that rarely changes and is read often.

Підводні камені. Fail-fast — це «best effort», а не гарантія: виняток може й не виникнути, особливо при видаленні передостаннього елемента, коли цикл просто завершується. Тому відсутність винятку на одному наборі даних не означає, що код правильний. А Collections.synchronizedList від CME під час перебору не рятує — ітерацію треба синхронізувати вручну.

Pitfalls. Fail-fast is best effort, not a guarantee: the exception may not happen at all, notably when you remove the second-to-last element and the loop simply ends. So the absence of the exception on one data set does not prove the code right. And Collections.synchronizedList does not prevent CME during iteration: you have to synchronise the loop yourself.

Помилка з for-each і три правильні варіанти
The for-each bug and three correct alternatives
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;

public class CmeDemo {
    record Item(int id, String status) {}

    public static void main(String[] args) {
        List<Item> items = new ArrayList<>(List.of(
            new Item(1, "ACTIVE"), new Item(2, "DELETED"), new Item(3, "ACTIVE")));

        // Погано: ConcurrentModificationException
        // for (Item i : items) if (i.status().equals("DELETED")) items.remove(i);

        // 1. removeIf
        items.removeIf(i -> i.status().equals("DELETED"));

        // 2. Новий список замість зміни старого
        List<Item> active = items.stream().filter(i -> i.status().equals("ACTIVE")).toList();

        // 3. Слухачі звіту, які читають з кількох потоків
        List<Runnable> listeners = new CopyOnWriteArrayList<>();
        listeners.add(() -> System.out.println("test finished"));
        for (Runnable l : listeners) listeners.add(() -> {}); // без CME: ітерація по знімку

        System.out.println(active.size() + " " + listeners.size()); // 2 2
    }
}
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;

public class CmeDemo {
    record Item(int id, String status) {}

    public static void main(String[] args) {
        List<Item> items = new ArrayList<>(List.of(
            new Item(1, "ACTIVE"), new Item(2, "DELETED"), new Item(3, "ACTIVE")));

        // Bad: ConcurrentModificationException
        // for (Item i : items) if (i.status().equals("DELETED")) items.remove(i);

        // 1. removeIf
        items.removeIf(i -> i.status().equals("DELETED"));

        // 2. A new list instead of changing the old one
        List<Item> active = items.stream().filter(i -> i.status().equals("ACTIVE")).toList();

        // 3. Report listeners read from several threads
        List<Runnable> listeners = new CopyOnWriteArrayList<>();
        listeners.add(() -> System.out.println("test finished"));
        for (Runnable l : listeners) listeners.add(() -> {}); // no CME: iterates a snapshot

        System.out.println(active.size() + " " + listeners.size()); // 2 2
    }
}
🔧 Розбір синтаксису: removeIf приймає Predicate і видаляє всі відповідні елементи за один прохід без ризику CME. stream().filter(...).toList() повертає новий незмінний список. Ітератор CopyOnWriteArrayList працює зі знімком масиву на момент старту, тому додавання під час циклу не зачіпає поточний прохід.
🔧 Syntax breakdown: removeIf takes a Predicate and removes all matching elements in one pass with no risk of CME. stream().filter(...).toList() returns a new immutable list. A CopyOnWriteArrayList iterator works on a snapshot of the array taken at the start, so additions during the loop do not affect the current pass.
💡 Простими словами: Не можна пиляти гілку, по якій ідеш: або йди з пилкою-ітератором, або спершу вирішуй, що відрізати, а ріж потім.
💡 In plain words: You cannot saw off the branch you are walking along: either carry the iterator's own saw, or decide what to cut first and cut afterwards.
🗣 Як читати вголос Я одразу кажу, що ця помилка зазвичай однопотокова, попри назву. Звичайні колекції рахують зміни, і якщо під час for-each я видалю елемент напряму, ітератор помітить розбіжність і кине виняток. Виправлення в мене стандартне: removeIf або новий список через stream з filter, а в старому коді Iterator.remove. Якщо ж колекцію справді ділять потоки, беру ConcurrentHashMap чи CopyOnWriteArrayList, а не надіюся на synchronizedList.
🗣 How to say it out loud I say straight away that despite its name this error is usually single-threaded. Ordinary collections count modifications, and if I remove an element directly during a for-each, the iterator notices the mismatch and throws. My fix is standard: removeIf, or a new list via stream and filter, and Iterator.remove in older code. If threads really do share the collection, I take ConcurrentHashMap or CopyOnWriteArrayList rather than hoping synchronizedList will do.
36 Чим Collections.unmodifiableList() відрізняється від List.of() і List.copyOf()? How does Collections.unmodifiableList() differ from List.of() and List.copyOf()?
Колекції Collections Middle ▶

unmodifiableList — це вид. Collections.unmodifiableList(list) повертає обгортку, яка кидає UnsupportedOperationException на add, remove, set. Але вона дивиться на оригінальний список: якщо хтось має посилання на оригінал і змінить його, зміна одразу видна через обгортку. Це захист від запису «через цей вхід», а не незмінність.

unmodifiableList is a view. Collections.unmodifiableList(list) returns a wrapper that throws UnsupportedOperationException on add, remove and set. But it looks at the original list: if someone holds a reference to the original and changes it, the change is instantly visible through the wrapper. It protects against writing "through this door", it is not immutability.

List.of і List.copyOf — справді незмінні. List.of(...) (Java 9+) створює новий незмінний список, не приймає null ні як елемент, ні в contains-перевірці (NullPointerException). List.copyOf(collection) (Java 10+) робить незмінну копію; якщо передати йому вже незмінний список, копії може й не бути. Stream.toList() (Java 16+) теж повертає незмінний список, але, на відміну від List.of, допускає null-елементи.

List.of and List.copyOf are truly immutable. List.of(...) (Java 9+) creates a new immutable list and rejects null, both as an element and in a contains check (NullPointerException). List.copyOf(collection) (Java 10+) makes an immutable copy; if you pass it a list that is already immutable, it may skip copying. Stream.toList() (Java 16+) also returns an immutable list, but unlike List.of it allows null elements.

Приклад з проєкту. Page Object повертає список назв товарів з кошика: return List.copyOf(names) гарантує, що тест не зможе випадково змінити внутрішній стан сторінки. Тестові константи на кшталт допустимих ролей оголошую як static final List<String> ROLES = List.of(...).

Project example. A Page Object returns the product names from the basket: return List.copyOf(names) guarantees the test cannot accidentally change the page's internal state. Test constants such as the allowed roles are declared as static final List<String> ROLES = List.of(...).

Підводні камені. final у полі не робить список незмінним — лише посилання. Arrays.asList дозволяє set, але не add/remove, і пише наскрізь у масив. А тест, який передає List.of у метод, що всередині робить sort чи add, падає з UnsupportedOperationException — у такому місці потрібна копія new ArrayList<>(...).

Pitfalls. final on a field does not make the list immutable, only the reference. Arrays.asList allows set but not add/remove, and writes through to the array. And a test that passes List.of into a method which calls sort or add inside fails with UnsupportedOperationException; that spot needs a copy via new ArrayList<>(...).

Вид проти копії: хто бачить зміни
View versus copy: who sees the changes
import java.util.*;

public class ImmutableLists {
    public static void main(String[] args) {
        List<String> source = new ArrayList<>(List.of("admin", "editor"));

        List<String> view = Collections.unmodifiableList(source);
        List<String> copy = List.copyOf(source);

        source.add("guest");
        System.out.println(view); // [admin, editor, guest] — вид бачить зміну
        System.out.println(copy); // [admin, editor] — копія ні

        try {
            view.add("root");
        } catch (UnsupportedOperationException e) {
            System.out.println("read-only view");
        }

        List<String> fromStream = source.stream().map(String::toUpperCase).toList();
        // fromStream.add("X");  -> UnsupportedOperationException
        // List.of("a", null)    -> NullPointerException
        System.out.println(fromStream);
    }
}
import java.util.*;

public class ImmutableLists {
    public static void main(String[] args) {
        List<String> source = new ArrayList<>(List.of("admin", "editor"));

        List<String> view = Collections.unmodifiableList(source);
        List<String> copy = List.copyOf(source);

        source.add("guest");
        System.out.println(view); // the view sees the change
        System.out.println(copy); // the copy does not

        try {
            view.add("root");
        } catch (UnsupportedOperationException e) {
            System.out.println("read-only view");
        }

        List<String> fromStream = source.stream().map(String::toUpperCase).toList();
        // fromStream.add("X");  -> UnsupportedOperationException
        // List.of("a", null)    -> NullPointerException
        System.out.println(fromStream);
    }
}
🔧 Розбір синтаксису: Collections.unmodifiableList огортає той самий об’єкт source, тому view показує доданий guest. List.copyOf копіює елементи в момент виклику. String::toUpperCase — посилання на метод, еквівалент s -> s.toUpperCase(). toList() на стрімі дає незмінний результат, тож для подальших змін потрібен new ArrayList<>(...).
🔧 Syntax breakdown: Collections.unmodifiableList wraps the same source object, which is why view shows the added guest. List.copyOf copies the elements at call time. String::toUpperCase is a method reference equivalent to s -> s.toUpperCase(). toList() on a stream gives an immutable result, so further changes need new ArrayList<>(...).
💡 Простими словами: unmodifiableList — вітрина з товаром: руками не чіпай, але продавець ззаду може все переставити. List.of — фотографія вітрини.
💡 In plain words: unmodifiableList is a shop window: you cannot touch anything, but the shop assistant behind it can rearrange it all. List.of is a photo of the window.
🗣 Як читати вголос Я пояснюю різницю одним словом: вид проти копії. unmodifiableList забороняє зміни лише через себе, а оригінальний список під ним живе далі, і всі його зміни видно. List.of і List.copyOf створюють справді незмінні списки, до того ж без null. У фреймворку я повертаю з Page Object і утиліт List.copyOf, щоб ніхто не зіпсував стан, а тестові константи роблю через List.of. І пам’ятаю, що final у полі захищає лише посилання.
🗣 How to say it out loud I explain the difference in two words: view versus copy. unmodifiableList forbids changes only through itself, while the original list underneath lives on and all its changes are visible. List.of and List.copyOf create truly immutable lists, and they do not accept null either. In a framework I return List.copyOf from Page Objects and utilities so nobody corrupts the state, and I declare test constants with List.of. And I remember that final on a field only protects the reference.
37 Чим Queue відрізняється від Deque і яку реалізацію обрати? How does Queue differ from Deque, and which implementation should you choose?
Колекції Collections Middle ▶

Queue. Черга FIFO: елементи додаються в хвіст і забираються з голови. Кожна операція є у двох варіантах: add/remove/element кидають виняток, якщо черга повна чи порожня, а offer/poll/peek повертають false або null. У тестовому коді зручніші другі — без try/catch. PriorityQueue теж є Queue, але віддає елементи не за порядком додавання, а за пріоритетом (Comparator).

Queue. A FIFO queue: elements are added at the tail and taken from the head. Each operation comes in two flavours: add/remove/element throw an exception if the queue is full or empty, while offer/poll/peek return false or null. In test code the latter are handier, with no try/catch. PriorityQueue is a Queue too, but it hands elements out by priority (Comparator), not in insertion order.

Deque. Двостороння черга: вставка й видалення з обох кінців (offerFirst, pollLast, push, pop). Тому Deque працює і як черга, і як стек. Реалізація за замовчуванням — ArrayDeque: кільцевий масив, швидший за LinkedList і за застарілий синхронізований Stack. Javadoc Stack прямо радить використовувати Deque замість нього.

Deque. A double-ended queue: insertion and removal at both ends (offerFirst, pollLast, push, pop). So a Deque works both as a queue and as a stack. The default implementation is ArrayDeque: a circular array, faster than LinkedList and than the legacy synchronised Stack. The Stack Javadoc itself recommends a Deque instead.

Приклад з проєкту. Краулер для перевірки битих посилань на сайті: ArrayDeque<String> як черга сторінок для обходу в ширину плюс HashSet відвіданих URL. Або черга повторних спроб для API-запитів, які отримали 503.

Project example. A crawler that checks a site for broken links: an ArrayDeque<String> as the queue of pages for a breadth-first walk plus a HashSet of visited URLs. Or a retry queue for API requests that got a 503.

Підводні камені. ArrayDeque не приймає null — offer(null) кидає NullPointerException, бо null зарезервований як сигнал «порожньо» для poll. PriorityQueue при ітерації чи в toString не показує відсортований порядок — відсортовано лише те, що повертає poll. Для черг між потоками потрібні BlockingQueue, а не ArrayDeque.

Pitfalls. ArrayDeque rejects null: offer(null) throws NullPointerException, because null is reserved as the "empty" signal for poll. A PriorityQueue does not show sorted order when iterated or in toString; only what poll returns comes out sorted. Queues shared between threads need a BlockingQueue, not an ArrayDeque.

Обхід сайту в ширину через ArrayDeque
A breadth-first site walk with ArrayDeque
import java.util.*;

public class LinkCrawler {
    static Map<String, List<String>> links = Map.of(
        "/", List.of("/catalog", "/about"),
        "/catalog", List.of("/catalog/1", "/"),
        "/about", List.of(),
        "/catalog/1", List.of());

    public static void main(String[] args) {
        Deque<String> queue = new ArrayDeque<>();
        Set<String> visited = new HashSet<>();
        queue.offer("/");

        String page;
        while ((page = queue.poll()) != null) {       // null означає «черга порожня»
            if (!visited.add(page)) continue;           // add повертає false для дубля
            System.out.println("check " + page);
            for (String next : links.getOrDefault(page, List.of())) queue.offer(next);
        }

        Deque<String> steps = new ArrayDeque<>();      // той самий клас як стек
        steps.push("open");
        steps.push("login");
        System.out.println(steps.pop());               // login
    }
}
import java.util.*;

public class LinkCrawler {
    static Map<String, List<String>> links = Map.of(
        "/", List.of("/catalog", "/about"),
        "/catalog", List.of("/catalog/1", "/"),
        "/about", List.of(),
        "/catalog/1", List.of());

    public static void main(String[] args) {
        Deque<String> queue = new ArrayDeque<>();
        Set<String> visited = new HashSet<>();
        queue.offer("/");

        String page;
        while ((page = queue.poll()) != null) {       // null means "queue is empty"
            if (!visited.add(page)) continue;           // add returns false for a duplicate
            System.out.println("check " + page);
            for (String next : links.getOrDefault(page, List.of())) queue.offer(next);
        }

        Deque<String> steps = new ArrayDeque<>();      // the same class as a stack
        steps.push("open");
        steps.push("login");
        System.out.println(steps.pop());               // login
    }
}
🔧 Розбір синтаксису: queue.poll() забирає голову черги або повертає null, тому цикл while ((page = queue.poll()) != null) працює без окремої перевірки розміру. Set.add повертає false, якщо елемент уже був, — це одночасно перевірка й позначка «відвідано». push і pop працюють з головою Deque, тобто за правилом LIFO.
🔧 Syntax breakdown: queue.poll() takes the head of the queue or returns null, so the while ((page = queue.poll()) != null) loop needs no separate size check. Set.add returns false if the element was already there, which is both the check and the "visited" mark. push and pop work on the head of the Deque, i.e. LIFO.
💡 Простими словами: Queue — черга в поліклініці, Deque — колода карт, яку можна брати зверху й знизу.
💡 In plain words: A Queue is the line at the clinic, a Deque is a deck of cards you can take from the top or the bottom.
🗣 Як читати вголос Я кажу, що Queue — це FIFO з двома наборами методів: ті, що кидають виняток, і м’які offer, poll та peek, які я в тестах і використовую. Deque дозволяє працювати з обох кінців, тож одна ArrayDeque служить і чергою, і стеком, і я не беру ні LinkedList, ні старий Stack. Із життя: я писав краулер битих посилань як обхід у ширину на ArrayDeque з множиною відвіданих сторінок. А для обміну між потоками обрав би BlockingQueue.
🗣 How to say it out loud I say that Queue is FIFO with two sets of methods: the ones that throw and the gentle offer, poll and peek, which are the ones I use in tests. Deque lets you work at both ends, so a single ArrayDeque serves as a queue and a stack, and I use neither LinkedList nor the old Stack. From real work: I wrote a broken-link crawler as a breadth-first walk on an ArrayDeque with a set of visited pages. For handing work between threads I would choose a BlockingQueue.
38 Як getOrDefault, computeIfAbsent і merge спрощують роботу з Map? How do getOrDefault, computeIfAbsent and merge simplify working with a Map?
Колекції Collections Middle ▶

getOrDefault. map.getOrDefault(key, fallback) повертає значення або запасне, якщо ключа немає. Замінює трирядкову перевірку containsKey. Важливо: у мапу нічого не записується, запасне значення лише повертається.

getOrDefault. map.getOrDefault(key, fallback) returns the value, or the fallback if the key is missing. It replaces a three-line containsKey check. Note that nothing is written into the map; the fallback is only returned.

computeIfAbsent. map.computeIfAbsent(key, k -> new ArrayList<>()) повертає наявне значення, а якщо ключа немає — обчислює його функцією, кладе в мапу і повертає. Це стандартний спосіб побудувати «мультимапу»: Map<String, List<String>>, де до списку одразу можна зробити add. Його ж використовують як лінивий кеш: драйвер чи токен створюється при першому зверненні.

computeIfAbsent. map.computeIfAbsent(key, k -> new ArrayList<>()) returns the existing value, and if the key is missing it computes one with the function, stores it and returns it. It is the standard way to build a "multimap": a Map<String, List<String>> where you can call add on the list straight away. It also works as a lazy cache: a driver or a token is created on first access.

merge і putIfAbsent. map.merge(key, 1, Integer::sum) кладе 1 для нового ключа або додає до наявного — ідеально для підрахунку. putIfAbsent записує значення, лише якщо ключа ще немає, і повертає попереднє.

merge and putIfAbsent. map.merge(key, 1, Integer::sum) stores 1 for a new key or adds to the existing value, which is ideal for counting. putIfAbsent writes a value only if the key is not there yet and returns the previous one.

Приклад з проєкту. Після прогону групуємо впалі тести за класом винятку, щоб у звіті було видно «12 падінь з TimeoutException, 3 з AssertionError», а merge рахує кількість.

Project example. After a run we group failed tests by exception class, so the report shows "12 failures with TimeoutException, 3 with AssertionError", while merge counts them.

Підводні камені. Функція в computeIfAbsent, що повертає null, нічого не записує — наступного разу вона викличеться знову. Функція не повинна змінювати ту саму мапу: для HashMap це дає ConcurrentModificationException. А якщо ключ є, але зіставлений з null, getOrDefault поверне саме цей null, а не запасне значення, і це часто дивує.

Pitfalls. A computeIfAbsent function that returns null stores nothing, so it will be called again next time. The function must not modify the same map: for HashMap that leads to ConcurrentModificationException. And if a key is present but mapped to null, getOrDefault returns that null rather than the fallback, which often surprises people.

Групування впалих тестів за типом винятку
Grouping failed tests by exception type
import java.util.*;

public class FailureReport {
    record Failure(String test, String exception) {}

    public static void main(String[] args) {
        List<Failure> failures = List.of(
            new Failure("loginTest", "TimeoutException"),
            new Failure("cartTest", "AssertionError"),
            new Failure("searchTest", "TimeoutException"));

        Map<String, List<String>> byType = new TreeMap<>();
        Map<String, Integer> counts = new HashMap<>();
        for (Failure f : failures) {
            // створює список при першій появі типу
            byType.computeIfAbsent(f.exception(), k -> new ArrayList<>()).add(f.test());
            counts.merge(f.exception(), 1, Integer::sum); // 1 або +1
        }

        System.out.println(byType);  // {AssertionError=[cartTest], TimeoutException=[loginTest, searchTest]}
        System.out.println(counts.getOrDefault("NoSuchElementException", 0)); // 0, у мапу не записано
    }
}
import java.util.*;

public class FailureReport {
    record Failure(String test, String exception) {}

    public static void main(String[] args) {
        List<Failure> failures = List.of(
            new Failure("loginTest", "TimeoutException"),
            new Failure("cartTest", "AssertionError"),
            new Failure("searchTest", "TimeoutException"));

        Map<String, List<String>> byType = new TreeMap<>();
        Map<String, Integer> counts = new HashMap<>();
        for (Failure f : failures) {
            // creates the list the first time a type appears
            byType.computeIfAbsent(f.exception(), k -> new ArrayList<>()).add(f.test());
            counts.merge(f.exception(), 1, Integer::sum); // 1 or +1
        }

        System.out.println(byType);  // {AssertionError=[cartTest], TimeoutException=[loginTest, searchTest]}
        System.out.println(counts.getOrDefault("NoSuchElementException", 0)); // 0, nothing stored
    }
}
🔧 Розбір синтаксису: computeIfAbsent(key, k -> new ArrayList<>()) повертає список, тож .add(f.test()) ланцюжком додає тест одразу. merge(key, 1, Integer::sum) викликає Integer.sum(old, 1) для наявного ключа. TreeMap обрано, щоб звіт виводився в алфавітному порядку. getOrDefault лише читає, не змінюючи counts.
🔧 Syntax breakdown: computeIfAbsent(key, k -> new ArrayList<>()) returns the list, so the chained .add(f.test()) adds the test right away. merge(key, 1, Integer::sum) calls Integer.sum(old, 1) for an existing key. TreeMap is chosen so the report prints in alphabetical order. getOrDefault only reads and leaves counts unchanged.
💡 Простими словами: computeIfAbsent — «якщо коробки з таким підписом ще немає, постав нову і поклади туди»; getOrDefault — «якщо коробки немає, просто скажи “нуль”».
💡 In plain words: computeIfAbsent means "if there is no box with this label, set up a new one and put the item in"; getOrDefault means "if there is no box, just say zero".
🗣 Як читати вголос Ці три методи прибирають з коду купу if з containsKey. getOrDefault я беру, коли треба лише прочитати значення із запасним варіантом, і пам’ятаю, що він нічого не записує. computeIfAbsent — мій стандарт для групування: ключ — тип помилки, значення — список тестів, і список створюється сам. merge з Integer::sum — найкоротший лічильник. Єдине, за чим стежу, — щоб функція в computeIfAbsent не повертала null і не чіпала ту саму мапу.
🗣 How to say it out loud These three methods remove a pile of containsKey ifs from the code. I use getOrDefault when I only need to read a value with a fallback, and I remember that it stores nothing. computeIfAbsent is my standard for grouping: the key is the error type, the value is a list of tests, and the list is created automatically. merge with Integer::sum is the shortest counter there is. The one thing I watch is that the computeIfAbsent function never returns null and never touches the same map.
39 Навіщо потрібні generics і що таке type erasure? Why do we need generics, and what is type erasure?
Колекції Collections Middle ▶

Навіщо. Generics переносять перевірку типів з рантайму на компіляцію. List<String> не дозволить покласти Integer, і не треба писати приведення (String) list.get(0), яке могло б упасти з ClassCastException. У фреймворку generics дають узагальнені класи на кшталт ApiResponse<T> з полями status і T body, а також узагальнені методи: <T> T readJson(String json, Class<T> type) повертає вже потрібний тип.

Why. Generics move type checks from runtime to compile time. A List<String> will not accept an Integer, and you do not need a cast like (String) list.get(0) that could fail with ClassCastException. In a framework generics give you generic classes such as ApiResponse<T> with a status field and a T body, plus generic methods: <T> T readJson(String json, Class<T> type) returns the right type directly.

Type erasure. Параметри типу існують лише для компілятора. У байткоді List<String> і List<User> — той самий клас List, а T замінюється на Object або на верхню межу (T extends Number → Number). Так зроблено заради сумісності з кодом до Java 5. Для перевизначення методів з різними параметрами компілятор генерує bridge-методи.

Type erasure. Type parameters exist only for the compiler. In bytecode List<String> and List<User> are the same List class, and T is replaced by Object or by its upper bound (T extends Number becomes Number). This was done for compatibility with pre-Java 5 code. To keep overriding working across different parameters, the compiler generates bridge methods.

Що з цього випливає. Не можна написати new T(), T.class, List<String>.class чи instanceof List<String>; не можна перевантажити метод, що відрізняється лише параметром типу. Тому бібліотеки просять передати Class<T>, а для вкладених типів — «токен типу»: Jackson new TypeReference<List<User>>() {}, REST Assured new TypeRef<List<User>>() {}. Анонімний підклас зберігає повний тип у метаданих класу, і його можна прочитати рефлексією.

What follows from it. You cannot write new T(), T.class, List<String>.class or instanceof List<String>, and you cannot overload a method that differs only in its type argument. That is why libraries ask for a Class<T>, and for nested types a "type token": Jackson's new TypeReference<List<User>>() {}, REST Assured's new TypeRef<List<User>>() {}. The anonymous subclass keeps the full type in its class metadata, where reflection can read it.

Підводні камені. Raw type List list = new ArrayList() вимикає перевірки і дає лише попередження. Змішування raw- і параметризованих типів веде до «heap pollution»: ClassCastException вилітає далеко від місця помилки.

Pitfalls. A raw type such as List list = new ArrayList() switches the checks off and only produces a warning. Mixing raw and parameterised types leads to heap pollution: the ClassCastException then fires far away from the actual mistake.

Узагальнена відповідь API і токен типу для списку
A generic API response and a type token for a list
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;

public class Generics {
    record User(int id, String email) {}
    record ApiResponse<T>(int status, T body) {}

    static final ObjectMapper MAPPER = new ObjectMapper();

    // Узагальнений метод: тип результату задає аргумент
    static <T> T readJson(String json, Class<T> type) throws Exception {
        return MAPPER.readValue(json, type);
    }

    public static void main(String[] args) throws Exception {
        User one = readJson("{\"id\":1,\"email\":\"anna@example.com\"}", User.class);
        var response = new ApiResponse<>(200, one);
        System.out.println(response.body().email());

        // List<User>.class не існує — потрібен TypeReference
        List<User> users = MAPPER.readValue("[{\"id\":2,\"email\":\"bob@example.com\"}]",
                new TypeReference<List<User>>() {});
        System.out.println(users.get(0).id());
    }
}
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;

public class Generics {
    record User(int id, String email) {}
    record ApiResponse<T>(int status, T body) {}

    static final ObjectMapper MAPPER = new ObjectMapper();

    // Generic method: the argument sets the result type
    static <T> T readJson(String json, Class<T> type) throws Exception {
        return MAPPER.readValue(json, type);
    }

    public static void main(String[] args) throws Exception {
        User one = readJson("{\"id\":1,\"email\":\"anna@example.com\"}", User.class);
        var response = new ApiResponse<>(200, one);
        System.out.println(response.body().email());

        // List<User>.class does not exist, so TypeReference is needed
        List<User> users = MAPPER.readValue("[{\"id\":2,\"email\":\"bob@example.com\"}]",
                new TypeReference<List<User>>() {});
        System.out.println(users.get(0).id());
    }
}
🔧 Розбір синтаксису: <T> T readJson(..., Class<T> type) оголошує параметр типу методу перед типом результату; User.class фіксує T = User. record ApiResponse<T> — узагальнений record, а new ApiResponse<>(200, one) виводить T з аргументу. Порожні фігурні дужки в new TypeReference<List<User>>() {} створюють анонімний підклас, який і зберігає List<User> для Jackson. Jackson 2.12+ уміє десеріалізувати record-и.
🔧 Syntax breakdown: <T> T readJson(..., Class<T> type) declares the method's type parameter before the return type; User.class fixes T = User. record ApiResponse<T> is a generic record, and new ApiResponse<>(200, one) infers T from the argument. The empty braces in new TypeReference<List<User>>() {} create an anonymous subclass, which is what preserves List<User> for Jackson. Jackson 2.12+ can deserialise records.
💡 Простими словами: Generics — це підпис на коробці «тут лише книжки». Компілятор перевіряє підпис на вході, а на складі (у рантаймі) підписи вже зірвані.
💡 In plain words: Generics are a label on a box saying "books only". The compiler checks the label at the door, but in the warehouse, at runtime, the labels have already been torn off.
🗣 Як читати вголос Я кажу, що generics — це про безпеку типів на етапі компіляції: без приведень і без ClassCastException у рантаймі. У фреймворку це ApiResponse з параметром T або метод readJson, який одразу повертає потрібний тип. Потім пояснюю type erasure: у байткоді параметрів типу немає, тому не можна зробити new T чи взяти клас від List of User. Звідси й TypeReference у Jackson та TypeRef у REST Assured — анонімний підклас зберігає повний тип.
🗣 How to say it out loud I say that generics are about type safety at compile time: no casts and no ClassCastException at runtime. In a framework that means an ApiResponse with a type parameter T, or a readJson method that returns the right type straight away. Then I explain type erasure: bytecode has no type parameters, so you cannot do new T or get the class of a list of users. Hence TypeReference in Jackson and TypeRef in REST Assured, where an anonymous subclass keeps the full type.
40 Що означають wildcard ? extends T і ? super T і як застосувати правило PECS? What do the wildcards ? extends T and ? super T mean, and how do you apply the PECS rule?
Колекції Collections Senior ▶

Проблема. Generics інваріантні: List<Integer> не є підтипом List<Number>, хоча Integer — підтип Number. Інакше в список цілих можна було б через посилання List<Number> покласти Double. Wildcard-и дозволяють послабити це обмеження там, де це безпечно.

The problem. Generics are invariant: List<Integer> is not a subtype of List<Number>, although Integer is a subtype of Number. Otherwise you could put a Double into a list of integers through a List<Number> reference. Wildcards relax this restriction where it is safe.

? extends T — виробник. List<? extends Number> приймає List<Integer>, List<Double> тощо. З нього можна читати як Number, але записати не можна нічого, крім null, — компілятор не знає, який саме підтип усередині. Приклад: double sum(List<? extends Number> values).

? extends T is a producer. List<? extends Number> accepts List<Integer>, List<Double> and so on. You can read from it as Number, but you cannot write anything except null, because the compiler does not know which subtype is inside. Example: double sum(List<? extends Number> values).

? super T — споживач. List<? super Integer> приймає List<Integer>, List<Number>, List<Object>. У нього безпечно записувати Integer, а читати — лише як Object. Приклад зі стандартної бібліотеки: Collections.addAll(Collection<? super T> c, T... elements).

? super T is a consumer. List<? super Integer> accepts List<Integer>, List<Number>, List<Object>. Writing an Integer into it is safe, and reading only gives you Object. An example from the standard library: Collections.addAll(Collection<? super T> c, T... elements).

PECS. Producer Extends, Consumer Super (Джошуа Блох, Effective Java): якщо параметр віддає дані — extends, якщо приймає — super, якщо і те й інше — точний тип. Collections.copy(List<? super T> dest, List<? extends T> src) ілюструє обидва.

PECS. Producer Extends, Consumer Super (Joshua Bloch, Effective Java): if a parameter hands data out, use extends; if it takes data in, use super; if both, use the exact type. Collections.copy(List<? super T> dest, List<? extends T> src) shows both at once.

Трейдоф для API фреймворку. Wildcard-и роблять утиліти гнучкішими: метод збору результатів з Consumer<? super TestResult> прийме і Consumer<Object> для логування. Але сигнатури з трьома рівнями wildcard-ів читати важко, і в повідомленнях компілятора з’являються capture#1-of ?. Тому я ставлю їх лише в параметри публічних методів, ніколи — у типи результату, і не ускладнюю внутрішній код.

Trade-off for a framework API. Wildcards make utilities more flexible: a result collector taking Consumer<? super TestResult> also accepts a Consumer<Object> for logging. But signatures with three levels of wildcards are hard to read, and compiler messages fill up with capture#1-of ?. So I put them only in parameters of public methods, never in return types, and keep internal code simple.

PECS у тестовій утиліті
PECS in a test utility
import java.util.*;
import java.util.function.Consumer;

public class Wildcards {
    // Виробник: лише читаємо як Number
    static double sum(List<? extends Number> values) {
        double total = 0;
        for (Number n : values) total += n.doubleValue();
        return total;
    }

    // Споживач: лише записуємо Integer
    static void addStatusCodes(List<? super Integer> target) {
        target.add(200);
        target.add(404);
    }

    // Обидва правила в одній сигнатурі
    static <T> void publish(List<? extends T> results, Consumer<? super T> sink) {
        results.forEach(sink);
    }

    public static void main(String[] args) {
        System.out.println(sum(List.of(1, 2, 3)));       // List<Integer>
        System.out.println(sum(List.of(0.5, 1.5)));      // List<Double>

        List<Number> codes = new ArrayList<>();
        addStatusCodes(codes);                            // List<Number> підходить

        Consumer<Object> log = o -> System.out.println("result: " + o);
        publish(List.of("loginTest PASSED"), log);        // T = String, споживач Object
    }
}
import java.util.*;
import java.util.function.Consumer;

public class Wildcards {
    // Producer: we only read as Number
    static double sum(List<? extends Number> values) {
        double total = 0;
        for (Number n : values) total += n.doubleValue();
        return total;
    }

    // Consumer: we only write Integer
    static void addStatusCodes(List<? super Integer> target) {
        target.add(200);
        target.add(404);
    }

    // Both rules in one signature
    static <T> void publish(List<? extends T> results, Consumer<? super T> sink) {
        results.forEach(sink);
    }

    public static void main(String[] args) {
        System.out.println(sum(List.of(1, 2, 3)));       // List<Integer>
        System.out.println(sum(List.of(0.5, 1.5)));      // List<Double>

        List<Number> codes = new ArrayList<>();
        addStatusCodes(codes);                            // List<Number> fits

        Consumer<Object> log = o -> System.out.println("result: " + o);
        publish(List.of("loginTest PASSED"), log);        // T = String, consumer of Object
    }
}
🔧 Розбір синтаксису: У sum змінна циклу має тип Number — це верхня межа wildcard-а, тому читати безпечно, а values.add(1) не скомпілюється. У addStatusCodes компілятор знає, що список приймає принаймні Integer, тож add(200) дозволено. У publish T виводиться як String, а Consumer<? super T> приймає Consumer<Object>.
🔧 Syntax breakdown: In sum the loop variable is a Number, the wildcard's upper bound, so reading is safe while values.add(1) would not compile. In addStatusCodes the compiler knows the list accepts at least Integer, so add(200) is allowed. In publish T is inferred as String, and Consumer<? super T> accepts a Consumer<Object>.
💡 Простими словами: extends — кошик, з якого тільки беруть фрукти; super — кошик, у який тільки кладуть яблука, і байдуже, що він підписаний «фрукти» чи «будь-що».
💡 In plain words: extends is a basket you only take fruit from; super is a basket you only drop apples into, whether it is labelled "fruit" or "anything".
🗣 Як читати вголос Спершу пояснюю, що generics інваріантні: список Integer не є списком Number, і це захищає від запису чужого типу. Wildcard extends робить параметр виробником — з нього можна читати, але не писати. Wildcard super робить його споживачем — у нього можна писати, а читаєш лише Object. Правило PECS я запам’ятовую як «що віддає — extends, що приймає — super». У фреймворку ставлю wildcard-и в параметри публічних утиліт, щоб вони були гнучкими, але не в типи результату, бо це вбиває читабельність.
🗣 How to say it out loud First I explain that generics are invariant: a list of Integer is not a list of Number, and that protects you from writing a foreign type. An extends wildcard makes a parameter a producer: you can read from it but not write. A super wildcard makes it a consumer: you can write into it, and reading only gives Object. I remember PECS as "what gives out is extends, what takes in is super". In a framework I put wildcards on parameters of public utilities so they stay flexible, but not on return types, because that kills readability.
41 Як обрати колекцію, коли тест обробляє десятки тисяч записів? How do you choose a collection when a test processes tens of thousands of records?
Колекції Collections Senior ▶

Сценарій. Тест вивантажує з API 50 тисяч id замовлень і перевіряє, що кожен з очікуваних 50 тисяч id з бази присутній. Перша версія робить expected.forEach(id -> assertTrue(actual.contains(id))) на двох ArrayList. Це 50 000 × 50 000 порівнянь — 2,5 мільярда операцій, тест іде хвилини. Заміна actual на HashSet дає O(1) на пошук, і перевірка займає мілісекунди.

The scenario. A test downloads 50 thousand order ids from an API and checks that each of the 50 thousand expected ids from the database is present. The first version does expected.forEach(id -> assertTrue(actual.contains(id))) on two ArrayLists. That is 50,000 × 50,000 comparisons, 2.5 billion operations, and the test takes minutes. Replacing actual with a HashSet makes each lookup O(1), and the check takes milliseconds.

Як я обираю. Спочатку питання про операцію, а не про клас: пошук за значенням — HashSet/HashMap; потрібен ще й порядок, у якому прийшли дані, — LinkedHashSet; потрібен діапазон чи сортування — TreeMap; ключі — enum (браузери, середовища) — EnumMap/EnumSet, що працюють на масиві чи бітовій масці. Для різниці множин: missing = new HashSet<>(expected); missing.removeAll(actual) — і в повідомленні асерту видно саме відсутні id, а не «false».

How I choose. I start from the operation, not the class: lookup by value means HashSet/HashMap; if I also need the order the data arrived in, LinkedHashSet; a range or sorting means TreeMap; enum keys (browsers, environments) mean EnumMap/EnumSet, backed by an array or a bit mask. For a set difference: missing = new HashSet<>(expected); missing.removeAll(actual), and the assertion message then shows exactly the missing ids rather than "false".

Дрібниці, що мають значення. Якщо розмір відомий, задаю місткість (new ArrayList<>(n), HashMap.newHashMap(n) з Java 19), щоб уникнути серії копіювань. Для мільйонів long List<Long> тримає окремий об’єкт на кожне число — масив long[] чи LongStream у кілька разів компактніший.

Small things that matter. When the size is known I set the capacity (new ArrayList<>(n), HashMap.newHashMap(n) since Java 19) to avoid a series of copies. For millions of long values a List<Long> holds a separate object per number, while a long[] array or a LongStream is several times more compact.

Трейдоф. Не оптимізую навмання: якщо колекція на 20 елементів, читабельність важливіша. Коли ж продуктивність справді має значення (утиліта, яку викликають тисячі тестів), міряю JMH, а не циклом з System.currentTimeMillis(), бо JIT і прогрів спотворюють наївні заміри.

Trade-off. I do not optimise blindly: for a collection of 20 elements readability matters more. When performance really counts (a utility called by thousands of tests), I measure with JMH rather than a loop around System.currentTimeMillis(), because the JIT and warm-up distort naive measurements.

Від O(n²) до O(n) з читабельним повідомленням
From O(n²) to O(n) with a readable message
import java.util.*;
import java.util.stream.*;

public class BulkIdCheck {
    enum Browser { CHROME, FIREFOX, WEBKIT }

    public static void main(String[] args) {
        List<Long> expected = LongStream.rangeClosed(1, 50_000).boxed().toList();
        List<Long> fromApi = LongStream.rangeClosed(3, 50_000).boxed().toList();

        // Погано: contains на List — O(n) на кожен id
        // expected.forEach(id -> assertTrue(fromApi.contains(id)));

        Set<Long> actual = new HashSet<>(fromApi);      // один прохід, далі O(1)
        Set<Long> missing = new LinkedHashSet<>(expected); // зберігає порядок для звіту
        missing.removeAll(actual);
        System.out.println("missing ids: " + missing); // [1, 2]

        // Ключі-enum: компактна мапа на масиві
        Map<Browser, Integer> failures = new EnumMap<>(Browser.class);
        failures.merge(Browser.WEBKIT, 1, Integer::sum);
        System.out.println(failures);
    }
}
import java.util.*;
import java.util.stream.*;

public class BulkIdCheck {
    enum Browser { CHROME, FIREFOX, WEBKIT }

    public static void main(String[] args) {
        List<Long> expected = LongStream.rangeClosed(1, 50_000).boxed().toList();
        List<Long> fromApi = LongStream.rangeClosed(3, 50_000).boxed().toList();

        // Bad: contains on a List is O(n) per id
        // expected.forEach(id -> assertTrue(fromApi.contains(id)));

        Set<Long> actual = new HashSet<>(fromApi);      // one pass, then O(1)
        Set<Long> missing = new LinkedHashSet<>(expected); // keeps order for the report
        missing.removeAll(actual);
        System.out.println("missing ids: " + missing); // [1, 2]

        // Enum keys: a compact array-based map
        Map<Browser, Integer> failures = new EnumMap<>(Browser.class);
        failures.merge(Browser.WEBKIT, 1, Integer::sum);
        System.out.println(failures);
    }
}
🔧 Розбір синтаксису: LongStream.rangeClosed(...).boxed().toList() генерує тестові id як List<Long>. new HashSet<>(fromApi) будує множину за один прохід. removeAll на LinkedHashSet викликає actual.contains для кожного елемента, тож складність лінійна, а порядок відсутніх id такий самий, як в очікуваних. new EnumMap<>(Browser.class) вимагає клас enum, бо зберігає значення в масиві за ordinal().
🔧 Syntax breakdown: LongStream.rangeClosed(...).boxed().toList() generates test ids as a List<Long>. new HashSet<>(fromApi) builds the set in one pass. removeAll on the LinkedHashSet calls actual.contains for each element, so the complexity is linear and the missing ids keep the same order as the expected ones. new EnumMap<>(Browser.class) needs the enum class because it stores values in an array indexed by ordinal().
💡 Простими словами: Шукати ім’я в неалфавітному списку гостей на 50 тисяч рядків — це List.contains. Мати фейс-контроль, який знає кожного в обличчя, — це HashSet.
💡 In plain words: Looking for a name in an unsorted guest list of 50 thousand lines is List.contains. Having a doorman who knows every face is a HashSet.
🗣 Як читати вголос Я розповідаю реальний випадок: перевірка 50 тисяч id через contains на ArrayList займала хвилини, бо це квадратична складність, а заміна на HashSet дала мілісекунди. Відтоді обираю колекцію від операції: пошук — хеш, порядок — LinkedHashSet, діапазон — TreeMap, enum-ключі — EnumMap. Різницю множин рахую через removeAll, щоб асерт показував саме відсутні записи. Але оптимізую лише там, де це справді гаряче місце, і міряю JMH, а не на око.
🗣 How to say it out loud I tell a real story: checking 50 thousand ids with contains on an ArrayList took minutes because it is quadratic, and switching to a HashSet brought it down to milliseconds. Since then I choose a collection by the operation: lookup means hashing, order means LinkedHashSet, a range means TreeMap, enum keys mean EnumMap. I compute set differences with removeAll so the assertion shows exactly the missing records. But I only optimise genuine hot spots, and I measure with JMH rather than by eye.
▶ 6 7
42 Яка ієрархія винятків у Java і де в ній AssertionError? What is the exception hierarchy in Java, and where does AssertionError sit in it?
Винятки Exceptions Junior ▶

Що це. Корінь ієрархії — Throwable. Від нього йдуть дві гілки. Error описує проблеми самої JVM або середовища: OutOfMemoryError, StackOverflowError, NoClassDefFoundError; їх зазвичай не обробляють. Exception описує ситуації, на які програма може зреагувати. Усередині Exception є підгілка RuntimeException — це unchecked-винятки: NullPointerException, IllegalArgumentException, IllegalStateException. Решта нащадків Exception, як IOException чи InterruptedException, — checked.

What it is. The root of the hierarchy is Throwable. Two branches grow from it. Error describes problems of the JVM or the environment itself: OutOfMemoryError, StackOverflowError, NoClassDefFoundError; these are normally not handled. Exception describes situations the program can react to. Inside Exception there is the RuntimeException sub-branch — the unchecked exceptions: NullPointerException, IllegalArgumentException, IllegalStateException. All other subclasses of Exception, such as IOException or InterruptedException, are checked.

Чому це важливо в тестах. Упала перевірка JUnit, AssertJ чи TestNG — це AssertionError (або його нащадок, як AssertionFailedError у JUnit 5), тобто Error, а не Exception. Блок catch (Exception e) його не зловить, і це добре: провалена перевірка має дійти до раннера.

Why it matters in tests. A failed JUnit, AssertJ or TestNG check is an AssertionError (or a subclass like AssertionFailedError in JUnit 5), which is an Error, not an Exception. A catch (Exception e) block does not catch it, and that is good: a failed assertion must reach the runner.

Приклад з проєкту. Винятки Selenium (NoSuchElementException, TimeoutException, StaleElementReferenceException) наслідують WebDriverException, а той — RuntimeException, тому компілятор не змушує їх ловити. Робота з файлами тестових даних кидає checked IOException.

Project example. Selenium exceptions (NoSuchElementException, TimeoutException, StaleElementReferenceException) extend WebDriverException, which extends RuntimeException, so the compiler does not force you to catch them. Reading test data files throws the checked IOException.

Підводні камені. catch (Throwable t) ловить і провалені перевірки, і OutOfMemoryError; такий код у тестовому хелпері здатний перетворити червоний тест на зелений. Також існує два класи з назвою NoSuchElementException: у java.util і в Selenium — варто дивитися на імпорт.

Pitfalls. catch (Throwable t) catches failed assertions and OutOfMemoryError alike; such code in a test helper can turn a red test green. There are also two classes called NoSuchElementException: one in java.util and one in Selenium — check the import.

Що ловить catch (Exception e), а що ні
What catch (Exception e) catches and what it does not
@Test
void assertionIsNotAnException() {
    try {
        assertEquals(200, 500);                  // кидає AssertionFailedError
    } catch (Exception e) {
        System.out.println("never printed");     // сюди не потрапимо
    }
}

// Throwable
// ├── Error                 -> AssertionError, OutOfMemoryError, StackOverflowError
// └── Exception             -> IOException, InterruptedException  (checked)
//     └── RuntimeException  -> NullPointerException, IllegalStateException,
//                              org.openqa.selenium.WebDriverException  (unchecked)
@Test
void assertionIsNotAnException() {
    try {
        assertEquals(200, 500);                  // throws AssertionFailedError
    } catch (Exception e) {
        System.out.println("never printed");     // we never get here
    }
}

// Throwable
// ├── Error                 -> AssertionError, OutOfMemoryError, StackOverflowError
// └── Exception             -> IOException, InterruptedException  (checked)
//     └── RuntimeException  -> NullPointerException, IllegalStateException,
//                              org.openqa.selenium.WebDriverException  (unchecked)
🔧 Розбір синтаксису: assertEquals з JUnit 5 при розбіжності кидає org.opentest4j.AssertionFailedError, нащадка AssertionError. Оскільки це Error, блок catch (Exception e) його пропускає, і тест падає, як і має. Дерево в коментарі показує, які гілки компілятор перевіряє (checked), а які ні.
🔧 Syntax breakdown: JUnit 5 assertEquals throws org.opentest4j.AssertionFailedError, a subclass of AssertionError, on a mismatch. Because it is an Error, the catch (Exception e) block lets it through, and the test fails as it should. The tree in the comment shows which branches the compiler checks (checked) and which it does not.
💡 Простими словами: Error — це пожежна тривога в будівлі, Exception — поламаний принтер в офісі. Принтер можна полагодити на місці, а від тривоги не ховаються під столом.
💡 In plain words: An Error is the fire alarm in the building, an Exception is a broken printer in the office. You can fix the printer on the spot, but you do not hide under the desk from the alarm.
🗣 Як читати вголос Я малюю ієрархію словами: Throwable, під ним Error і Exception, а під Exception окремо RuntimeException. Error — це проблеми JVM, Exception — те, на що код може реагувати, а RuntimeException — unchecked-гілка. Для автоматизатора важлива деталь: провалена перевірка — це AssertionError, тобто Error, тому catch Exception її не ковтає. Винятки Selenium — unchecked, а файловий IOException — checked, і компілятор змусить його обробити.
🗣 How to say it out loud I describe the hierarchy out loud: Throwable, under it Error and Exception, and under Exception the separate RuntimeException branch. Error means JVM trouble, Exception is what code can react to, and RuntimeException is the unchecked branch. The detail that matters for automation: a failed assertion is an AssertionError, i.e. an Error, so catch Exception does not swallow it. Selenium exceptions are unchecked, while a file IOException is checked and the compiler makes me handle it.
43 Яка різниця між checked і unchecked винятками і коли який обирати? What is the difference between checked and unchecked exceptions, and when do you choose each?
Винятки Exceptions Junior ▶

Що це. Checked-винятки (нащадки Exception, крім RuntimeException) компілятор перевіряє: метод має або обробити їх у try/catch, або оголосити в throws. Unchecked-винятки (RuntimeException і його нащадки) можна не оголошувати й не ловити. Різниця існує лише на етапі компіляції: для JVM усі вони однакові.

What it is. Checked exceptions (subclasses of Exception other than RuntimeException) are verified by the compiler: a method must either handle them in try/catch or declare them in throws. Unchecked exceptions (RuntimeException and its subclasses) need neither declaration nor catching. The difference exists only at compile time: to the JVM they are all the same.

Коли який. Checked-виняток доречний, коли викликач реально може відновитися: файл конфігурації відсутній — взяти значення за замовчуванням. Unchecked — для помилок програміста й порушених передумов: null там, де його не має бути, невідомий статус, некоректний аргумент. У тестовому коді більшість помилок невідновлювані, тому фреймворки здебільшого загортають checked-винятки в unchecked.

When to use which. A checked exception fits when the caller can genuinely recover: a config file is missing, so fall back to a default. Unchecked is for programmer errors and broken preconditions: null where it must not be, an unknown status, an invalid argument. In test code most failures are unrecoverable, so frameworks mostly wrap checked exceptions in unchecked ones.

Приклад з проєкту. Лямбди в Stream API реалізують Function, Consumer тощо, а їхні методи не оголошують throws. Тому paths.stream().map(Files::readString) не скомпілюється: readString кидає checked IOException. Рішення — хелпер, який перекидає UncheckedIOException зі збереженою причиною.

Project example. Stream API lambdas implement Function, Consumer and so on, whose methods declare no throws. That is why paths.stream().map(Files::readString) does not compile: readString throws the checked IOException. The fix is a helper that rethrows an UncheckedIOException with the cause preserved.

Підводні камені. throws Exception у сигнатурі кожного методу фреймворку вбиває сенс checked-винятків. Протилежна крайність — ловити IOException і повертати null: проблема зникне з логу і вистрілить NPE значно далі від причини.

Pitfalls. throws Exception on every framework method defeats the point of checked exceptions. The opposite extreme is catching IOException and returning null: the problem vanishes from the log and fires as an NPE far away from its cause.

Checked IOException усередині Stream
A checked IOException inside a stream
// Не компілюється: Function.apply не оголошує IOException
// List<String> bodies = files.stream().map(Files::readString).toList();

static String read(Path file) {
    try {
        return Files.readString(file);
    } catch (IOException e) {
        // unchecked-обгортка зі збереженою причиною
        throw new UncheckedIOException("Cannot read test data " + file, e);
    }
}

@Test
void allFixturesAreValidJson() throws IOException {   // checked оголошено в throws
    try (Stream<Path> files = Files.list(Path.of("src/test/resources/fixtures"))) {
        List<String> bodies = files.map(FixtureTest::read).toList();
        assertFalse(bodies.isEmpty());
    }
}
// Does not compile: Function.apply declares no IOException
// List<String> bodies = files.stream().map(Files::readString).toList();

static String read(Path file) {
    try {
        return Files.readString(file);
    } catch (IOException e) {
        // an unchecked wrapper that keeps the cause
        throw new UncheckedIOException("Cannot read test data " + file, e);
    }
}

@Test
void allFixturesAreValidJson() throws IOException {   // checked declared in throws
    try (Stream<Path> files = Files.list(Path.of("src/test/resources/fixtures"))) {
        List<String> bodies = files.map(FixtureTest::read).toList();
        assertFalse(bodies.isEmpty());
    }
}
🔧 Розбір синтаксису: UncheckedIOException зі стандартної бібліотеки створений саме для загортання IOException; другий аргумент конструктора — причина, яку буде видно в стек-трейсі. Тестовий метод може просто оголосити throws IOException: JUnit покаже виняток як падіння тесту. Files.list повертає потік, який тримає відкритий каталог, тому він у try-with-resources.
🔧 Syntax breakdown: UncheckedIOException from the standard library exists precisely to wrap an IOException; the constructor's second argument is the cause that shows up in the stack trace. A test method can simply declare throws IOException: JUnit reports the exception as a test failure. Files.list returns a stream holding an open directory, so it goes into try-with-resources.
💡 Простими словами: Checked-виняток — рекомендований лист: отримувач мусить розписатися. Unchecked — звичайний лист у скриньці, його можна й не відкрити.
💡 In plain words: A checked exception is registered mail: the recipient has to sign for it. An unchecked one is ordinary mail in the box, which you may never open.
🗣 Як читати вголос Різниця в тому, хто перевіряє: checked-винятки компілятор змушує обробити або оголосити в throws, unchecked — ні, а для JVM вони однакові. Checked я обираю, коли викликач справді може відновитися, а unchecked — для порушених передумов. У тестовому коді відновитися майже ніколи не можна, тому я загортаю IOException в UncheckedIOException з причиною, особливо всередині стрімів, де лямбда не пропускає checked-винятки.
🗣 How to say it out loud The difference is who checks: the compiler forces checked exceptions to be handled or declared in throws, but not unchecked ones, and for the JVM they are identical. I choose checked when the caller can really recover, and unchecked for broken preconditions. In test code recovery is almost never possible, so I wrap IOException in UncheckedIOException with the cause, especially inside streams, where a lambda does not let checked exceptions through.
44 Що робить блок finally і коли він не виконується? What does the finally block do, and when does it not run?
Винятки Exceptions Junior ▶

Що це. finally виконується після try (і catch, якщо він спрацював) незалежно від того, як блок завершився: нормально, через return, break чи через виняток. Його призначення — прибирання: закрити драйвер, видалити тимчасовий файл, повернути змінену налаштування.

What it is. finally runs after try (and catch, if one fired) regardless of how the block ended: normally, via return, break or with an exception. Its purpose is cleanup: close a driver, delete a temporary file, restore a changed setting.

Коли не виконується. Якщо викликано System.exit(), JVM аварійно завершилась або процес убили (kill -9, таймаут CI-агента). Також якщо потік назавжди завис усередині try, до finally черга не дійде.

When it does not run. When System.exit() is called, the JVM crashes or the process is killed (kill -9, a CI agent timeout). Also, if the thread hangs forever inside try, finally never gets its turn.

Приклад з проєкту. Хелпер тимчасово вмикає feature flag через API, виконує сценарій і у finally вимикає прапорець назад. Без finally перший же провалений крок залишив би флаг увімкненим, і наступні тести отримали б інше середовище. На рівні тесту ту саму роль грають @AfterEach і @AfterAll у JUnit 5: вони викликаються навіть після падіння тесту, тож driver.quit() логічно тримати там, а не в finally кожного тестового методу.

Project example. A helper temporarily turns on a feature flag through the API, runs the scenario and turns the flag back off in finally. Without finally the first failed step would leave the flag on, and the following tests would get a different environment. At the test level the same role is played by @AfterEach and @AfterAll in JUnit 5: they run even after a test failure, so driver.quit() belongs there rather than in a finally inside every test method.

Підводні камені. return або throw всередині finally перекриває результат try: виняток з try, включно з проваленою перевіркою, просто зникне. Також виняток, кинутий самим кодом прибирання, заміщує початковий — тоді справжня причина падіння губиться, чого try-with-resources уникає через suppressed-винятки.

Pitfalls. A return or throw inside finally overrides the outcome of try: an exception from try, a failed assertion included, simply disappears. An exception thrown by the cleanup code itself also replaces the original one, and the real cause is lost — try-with-resources avoids that through suppressed exceptions.

Повернення feature flag у finally і пастка з return
Restoring a feature flag in finally, and the return trap
void withFlag(String flag, Runnable scenario) {
    flags.enable(flag);
    try {
        scenario.run();
    } finally {
        flags.disable(flag);          // виконається навіть після падіння перевірки
    }
}

@SuppressWarnings("finally")
static int broken() {
    try {
        throw new IllegalStateException("real problem");
    } finally {
        return -1;                    // ковтає виняток: метод «успішно» поверне -1
    }
}
void withFlag(String flag, Runnable scenario) {
    flags.enable(flag);
    try {
        scenario.run();
    } finally {
        flags.disable(flag);          // runs even after a failed assertion
    }
}

@SuppressWarnings("finally")
static int broken() {
    try {
        throw new IllegalStateException("real problem");
    } finally {
        return -1;                    // swallows the exception: returns -1 "successfully"
    }
}
🔧 Розбір синтаксису: У withFlag немає catch: виняток зі сценарію летить далі до тесту, але спершу виконується finally з вимкненням прапорця. У broken return у finally скасовує кинутий виняток; компілятор попереджає про це, тому довелося додати @SuppressWarnings("finally") — вже сам цей рядок має насторожувати на рев’ю.
🔧 Syntax breakdown: withFlag has no catch: the scenario's exception propagates to the test, but finally runs first and turns the flag off. In broken the return in finally cancels the thrown exception; the compiler warns about it, which is why @SuppressWarnings("finally") had to be added — that line alone should raise a flag in review.
💡 Простими словами: finally — це правило «вимкни світло, коли виходиш», байдуже, чи ти вийшов спокійно, чи вибіг через пожежу.
💡 In plain words: finally is the rule "switch off the light when you leave", whether you walked out calmly or ran out because of a fire.
🗣 Як читати вголос Finally виконується завжди, коли try завершився, хоч нормально, хоч винятком, і служить для прибирання. Не виконається він лише при System.exit, падінні JVM або вбитому процесі. У тестах я використовую його в хелперах, які тимчасово змінюють стан, наприклад вмикають feature flag. А закриття драйвера тримаю в AfterEach. І я ніколи не пишу return у finally, бо він мовчки ковтає виняток, навіть провалену перевірку.
🗣 How to say it out loud Finally runs whenever try completes, normally or with an exception, and it is meant for cleanup. It only fails to run on System.exit, a JVM crash or a killed process. In tests I use it in helpers that change state temporarily, such as turning on a feature flag. Closing the driver I keep in AfterEach. And I never write return in finally, because it silently swallows the exception, even a failed assertion.
45 Як працює try-with-resources і як з його допомогою гарантовано закрити драйвер або файл? How does try-with-resources work, and how does it guarantee that a driver or a file gets closed?
Винятки Exceptions Middle ▶

Що це. Try-with-resources (Java 7) — це try, у дужках якого оголошуються ресурси, що реалізують AutoCloseable. Після виходу з блоку компілятор сам викликає close() для кожного з них у зворотному порядку оголошення. З Java 9 у дужках можна вказати й наявну final або effectively final змінну.

What it is. Try-with-resources (Java 7) is a try whose parentheses declare resources implementing AutoCloseable. On leaving the block the compiler calls close() on each of them in reverse declaration order. Since Java 9 the parentheses can also name an existing final or effectively final variable.

Suppressed-винятки. Якщо тіло блоку кинуло виняток, а потім close() теж упав, початковий виняток не губиться: помилка закриття додається до нього як suppressed і доступна через getSuppressed(). Ручний finally так не вміє — там виняток з close затре справжню причину.

Suppressed exceptions. If the body throws and then close() fails too, the original exception is not lost: the close failure is attached to it as suppressed and is available through getSuppressed(). A hand-written finally cannot do that — there the exception from close wipes out the real cause.

Приклад з проєкту. Сам WebDriver не реалізує AutoCloseable, але його легко обгорнути в маленький клас-сесію, де close() викликає quit(). Тоді утиліта, що знімає скриншоти з десятка сторінок, не залишить висіти процес chromedriver навіть при помилці. Так само закривають BufferedReader для CSV з тестовими даними, Stream з Files.lines і HTTP-відповіді.

Project example. WebDriver itself does not implement AutoCloseable, but it is easy to wrap in a small session class whose close() calls quit(). Then a utility that screenshots a dozen pages will not leave a chromedriver process hanging even when something fails. The same way you close a BufferedReader for a CSV of test data, the Stream from Files.lines and HTTP responses.

Підводні камені. Ресурс, створений до try і не згаданий у дужках, не закриється. Якщо конструктор ресурсу впав, close() для нього не викликається — лише для вже створених попередніх ресурсів.

Pitfalls. A resource created before try and not named in the parentheses is not closed. If a resource's constructor fails, close() is not called for it — only for the resources already created before it.

AutoCloseable-сесія браузера і читання CSV
An AutoCloseable browser session and reading a CSV
public final class BrowserSession implements AutoCloseable {
    private final WebDriver driver = new ChromeDriver();

    public WebDriver driver() { return driver; }

    @Override
    public void close() {
        driver.quit();                     // викликається завжди
    }
}

void screenshotPages(List<String> urls) throws IOException {
    try (var session = new BrowserSession();                                   // закриється другим
         var reader = Files.newBufferedReader(Path.of("pages.csv"))) {         // закриється першим
        for (String url : urls) {
            session.driver().get(url);
            // ...
        }
    } catch (IOException e) {
        for (Throwable s : e.getSuppressed()) {
            System.err.println("also failed on close: " + s);
        }
        throw e;
    }
}
public final class BrowserSession implements AutoCloseable {
    private final WebDriver driver = new ChromeDriver();

    public WebDriver driver() { return driver; }

    @Override
    public void close() {
        driver.quit();                     // always called
    }
}

void screenshotPages(List<String> urls) throws IOException {
    try (var session = new BrowserSession();                                   // closed second
         var reader = Files.newBufferedReader(Path.of("pages.csv"))) {         // closed first
        for (String url : urls) {
            session.driver().get(url);
            // ...
        }
    } catch (IOException e) {
        for (Throwable s : e.getSuppressed()) {
            System.err.println("also failed on close: " + s);
        }
        throw e;
    }
}
🔧 Розбір синтаксису: Клас реалізує AutoCloseable з одним методом close(); оскільки він не кидає checked-винятків, throws в сигнатурі не потрібен. У дужках два ресурси через крапку з комою, і закриваються вони у зворотному порядку: спочатку reader, потім session. getSuppressed() повертає масив винятків, що виникли під час закриття.
🔧 Syntax breakdown: The class implements AutoCloseable with a single close() method; since it throws no checked exceptions, no throws clause is needed. The parentheses hold two resources separated by a semicolon, and they are closed in reverse order: reader first, then session. getSuppressed() returns an array of the exceptions raised while closing.
💡 Простими словами: Try-with-resources — готель, де прибиральниця сама заходить у номер після виїзду гостя. Ти можеш забути здати ключ, але номер однаково приберуть.
💡 In plain words: Try-with-resources is a hotel where the cleaner enters the room automatically after checkout. You may forget to hand in the key, but the room gets cleaned anyway.
🗣 Як читати вголос Try-with-resources я використовую для всього, що треба закрити: файли, стріми з Files.lines, HTTP-відповіді. Ресурс має реалізувати AutoCloseable, а close викликається автоматично у зворотному порядку. Головна перевага над finally — suppressed-винятки: якщо впало і тіло, і закриття, я бачу обидві причини. WebDriver сам не AutoCloseable, тому в утилітах я обгортаю його в невелику сесію, де close викликає quit, і процес драйвера не зависає.
🗣 How to say it out loud I use try-with-resources for anything that needs closing: files, streams from Files.lines, HTTP responses. The resource must implement AutoCloseable, and close is called automatically in reverse order. Its main advantage over finally is suppressed exceptions: if both the body and the close fail, I see both causes. WebDriver is not AutoCloseable itself, so in utilities I wrap it in a small session whose close calls quit, and the driver process does not hang.
46 Як створити власний виняток для тестового фреймворку і від чого його наслідувати — Exception чи RuntimeException? How do you create a custom exception for a test framework, and should it extend Exception or RuntimeException?
Винятки Exceptions Middle ▶

Що це. Власний виняток — це клас-нащадок Exception (checked) або RuntimeException (unchecked) з конструкторами, що приймають повідомлення і причину. Наслідування від Exception змусить кожен метод на шляху оголошувати throws або ловити його. Наслідування від RuntimeException дозволяє винятку пройти крізь шари фреймворку до раннера без церемоній.

What it is. A custom exception is a subclass of Exception (checked) or RuntimeException (unchecked) with constructors taking a message and a cause. Extending Exception forces every method along the way to declare throws or catch it. Extending RuntimeException lets the exception travel through the framework layers to the runner without ceremony.

Що обрати. У тестовому фреймворку майже завжди RuntimeException: тест не може «відновитися» від відсутнього тестового користувача чи недоступного середовища, його треба зупинити з ясною причиною. Checked має сенс лише в бібліотечному коді, де викликач справді обирає альтернативний шлях.

Which one to pick. In a test framework it is almost always RuntimeException: a test cannot "recover" from a missing test user or an unavailable environment, it has to stop with a clear reason. Checked makes sense only in library code where the caller really chooses an alternative path.

Приклад з проєкту. EnvironmentNotReadyException кидається з @BeforeAll, коли health-check сервісу повертає 503. У звіті видно не «NullPointerException у LoginPage», а «stage-сервіс orders не готовий: 503 після 60 с». TestDataNotFoundException пояснює, якого саме запису бракує в базі. Так розбір падіння перетворюється з розслідування на читання одного рядка, а в дашборді легко відділити проблеми середовища від справжніх дефектів.

Project example. EnvironmentNotReadyException is thrown from @BeforeAll when a service health check returns 503. The report no longer says "NullPointerException in LoginPage" but "stage service orders not ready: 503 after 60 s". TestDataNotFoundException explains exactly which record is missing from the database. Analysing a failure turns from an investigation into reading one line, and on a dashboard environment problems are easy to separate from real defects.

Підводні камені. Завжди передавай cause, інакше стек-трейс первинної помилки зникне. Не наслідуй AssertionError для технічних збоїв: інфраструктурна проблема не повинна виглядати як дефект продукту.

Pitfalls. Always pass the cause, otherwise the stack trace of the original error disappears. Do not extend AssertionError for technical failures: an infrastructure problem must not look like a product defect.

Unchecked-виняток із контекстом і причиною
An unchecked exception with context and a cause
public class EnvironmentNotReadyException extends RuntimeException {
    public EnvironmentNotReadyException(String service, int status, Duration waited) {
        super("Service '%s' is not ready: HTTP %d after %ds"
                .formatted(service, status, waited.toSeconds()));
    }

    public EnvironmentNotReadyException(String service, Throwable cause) {
        super("Service '%s' is unreachable".formatted(service), cause);  // зберігаємо причину
    }
}

@BeforeAll
static void waitForOrders() {
    try {
        int status = health.waitUntilUp("orders", Duration.ofSeconds(60));
        if (status != 200) {
            throw new EnvironmentNotReadyException("orders", status, Duration.ofSeconds(60));
        }
    } catch (ConnectException e) {                                         // checked з мережі
        throw new EnvironmentNotReadyException("orders", e);
    }
}
public class EnvironmentNotReadyException extends RuntimeException {
    public EnvironmentNotReadyException(String service, int status, Duration waited) {
        super("Service '%s' is not ready: HTTP %d after %ds"
                .formatted(service, status, waited.toSeconds()));
    }

    public EnvironmentNotReadyException(String service, Throwable cause) {
        super("Service '%s' is unreachable".formatted(service), cause);  // keep the cause
    }
}

@BeforeAll
static void waitForOrders() {
    try {
        int status = health.waitUntilUp("orders", Duration.ofSeconds(60));
        if (status != 200) {
            throw new EnvironmentNotReadyException("orders", status, Duration.ofSeconds(60));
        }
    } catch (ConnectException e) {                                         // checked from the network
        throw new EnvironmentNotReadyException("orders", e);
    }
}
🔧 Розбір синтаксису: Клас наслідує RuntimeException, тому @BeforeAll не мусить оголошувати throws. Конструктор super(message, cause) пов’язує новий виняток з первинним, і в стек-трейсі з’явиться рядок «Caused by». String.formatted (Java 15) — це String.format, викликаний на самому шаблоні. ConnectException — checked-нащадок IOException, тут він перетворюється на доменний unchecked-виняток.
🔧 Syntax breakdown: The class extends RuntimeException, so @BeforeAll need not declare throws. The super(message, cause) constructor links the new exception to the original one, and a "Caused by" line appears in the stack trace. String.formatted (Java 15) is String.format called on the template itself. ConnectException is a checked subclass of IOException, turned here into a domain-level unchecked exception.
💡 Простими словами: Власний виняток — це табличка «ремонт ліфта, користуйтеся сходами» замість мовчки зачинених дверей. Людина одразу розуміє, що сталося і хто винен.
💡 In plain words: A custom exception is the sign "lift under repair, please use the stairs" instead of a silently locked door. People instantly understand what happened and whose problem it is.
🗣 Як читати вголос Власні винятки у фреймворку я наслідую від RuntimeException, бо тест однаково не може відновитися від недоступного середовища чи відсутніх даних, а throws у кожному методі лише засмічує код. Важливіше за базовий клас — повідомлення з контекстом і збережена причина. Тоді в звіті видно не NPE десь у Page Object, а «сервіс orders не піднявся за 60 секунд». І я свідомо не роблю їх нащадками AssertionError, щоб падіння середовища не виглядало як баг продукту.
🗣 How to say it out loud I extend RuntimeException for custom exceptions in a framework, because a test cannot recover from an unavailable environment or missing data anyway, and throws on every method just clutters the code. More important than the base class is a message with context and a preserved cause. Then the report shows not an NPE somewhere in a Page Object but "orders service did not come up in 60 seconds". And I deliberately do not extend AssertionError, so an environment failure does not look like a product bug.
47 Що таке multi-catch і чому не можна «ковтати» винятки? What is multi-catch, and why must you never swallow exceptions?
Винятки Exceptions Middle ▶

Що це. Multi-catch (Java 7) дозволяє обробити кілька типів винятків одним блоком: catch (TimeoutException | StaleElementReferenceException e). Змінна e неявно final, а її статичний тип — найближчий спільний предок. Типи в переліку не можуть бути пов’язані наслідуванням: catch (IOException | FileNotFoundException e) не скомпілюється, бо другий тип уже охоплений першим.

What it is. Multi-catch (Java 7) handles several exception types in one block: catch (TimeoutException | StaleElementReferenceException e). The variable e is implicitly final, and its static type is the nearest common ancestor. The listed types cannot be related by inheritance: catch (IOException | FileNotFoundException e) does not compile, because the second type is already covered by the first.

Чому не ковтати. Порожній catch, e.printStackTrace() без подальшого кидання чи catch (Throwable t) з поверненням false ховають справжню проблему. Тест проходить зеленим, а баг або нестабільність середовища лишаються непоміченими. Гірше, наступний крок падає з невиразною помилкою далеко від причини.

Why not swallow. An empty catch, e.printStackTrace() with no rethrow or catch (Throwable t) returning false hide the real problem. The test goes green while the bug or the unstable environment goes unnoticed. Worse, the next step fails with a vague error far from the cause.

Приклад з проєкту. Хелпер isElementPresent ловив Exception і повертав false. Коли через зміну API сторінка почала кидати InvalidSelectorException через зламаний локатор, хелпер чесно відповідав «немає елемента», і перевірка «банер не показано» роками проходила. Після звуження catch до NoSuchElementException тест одразу впав з правильною причиною.

Project example. An isElementPresent helper caught Exception and returned false. When the page started throwing InvalidSelectorException because of a broken locator, the helper honestly answered "no element", and the check "the banner is not shown" passed for years. After narrowing the catch to NoSuchElementException the test failed at once with the right reason.

Підводні камені. Цикл повторів, що ловить будь-який виняток і пробує ще раз, — теж ковтання: справжні дефекти перетворюються на «flaky». Якщо ловиш, то або обробляй осмислено, або перекидай далі з причиною: throw new FrameworkException("...", e).

Pitfalls. A retry loop that catches any exception and tries again is swallowing too: real defects turn into "flakiness". If you catch, either handle it meaningfully or rethrow with the cause: throw new FrameworkException("...", e).

Вузький multi-catch проти ковтання
A narrow multi-catch versus swallowing
// Погано: будь-яка помилка стає «елемента немає»
boolean isPresentBad(By locator) {
    try {
        driver.findElement(locator);
        return true;
    } catch (Exception e) {
        return false;
    }
}

// Добре: лише очікувана ситуація
boolean isPresent(By locator) {
    try {
        driver.findElement(locator);
        return true;
    } catch (NoSuchElementException e) {
        return false;
    }
}

void clickWithOneRetry(By locator) {
    try {
        driver.findElement(locator).click();
    } catch (StaleElementReferenceException | ElementClickInterceptedException e) {
        // e тут final; відомі тимчасові стани -> одна повторна спроба
        driver.findElement(locator).click();
    }
}
// Bad: any failure becomes "no element"
boolean isPresentBad(By locator) {
    try {
        driver.findElement(locator);
        return true;
    } catch (Exception e) {
        return false;
    }
}

// Good: only the expected situation
boolean isPresent(By locator) {
    try {
        driver.findElement(locator);
        return true;
    } catch (NoSuchElementException e) {
        return false;
    }
}

void clickWithOneRetry(By locator) {
    try {
        driver.findElement(locator).click();
    } catch (StaleElementReferenceException | ElementClickInterceptedException e) {
        // e is final here; known transient states -> one retry
        driver.findElement(locator).click();
    }
}
🔧 Розбір синтаксису: У isPresent ловиться лише org.openqa.selenium.NoSuchElementException, тож зламаний селектор чи мертва сесія долетять до тесту. У clickWithOneRetry оператор | об’єднує два не пов’язані наслідуванням типи Selenium; змінну e всередині не можна перепризначити. Повтор лише один і лише для відомих тимчасових станів.
🔧 Syntax breakdown: isPresent catches only org.openqa.selenium.NoSuchElementException, so a broken selector or a dead session reaches the test. In clickWithOneRetry the | operator joins two Selenium types unrelated by inheritance; the variable e cannot be reassigned inside. There is exactly one retry, and only for known transient states.
💡 Простими словами: Проковтнутий виняток — це вимкнений димовий датчик, бо він «надто часто пищав». Тихо, доки не згорить кухня.
💡 In plain words: A swallowed exception is a smoke detector you switched off because it "beeped too often". Quiet, right up until the kitchen burns down.
🗣 Як читати вголос Multi-catch — це просто кілька типів через вертикальну риску в одному catch, змінна там final, а типи не можуть бути батьком і нащадком. Але на співбесіді я завжди додаю правило, яке важливіше за синтаксис: не ковтати винятки. Я бачив хелпер, що ловив Exception і повертав false, і через нього перевірка роками проходила на зламаному локаторі. Тому я ловлю лише ту помилку, яку очікую, а решту пускаю далі з причиною.
🗣 How to say it out loud Multi-catch is simply several types joined with a vertical bar in one catch; the variable is final there, and the types cannot be a parent and its child. But in an interview I always add the rule that matters more than syntax: never swallow exceptions. I once saw a helper that caught Exception and returned false, and because of it a check passed for years on a broken locator. So I catch only the error I expect and let everything else propagate with its cause.
48 Як побудувати стратегію обробки винятків у тестовому фреймворку? How do you design an exception-handling strategy for a test framework?
Винятки Exceptions Senior ▶

Принцип меж. Винятки ловлять не там, де вони виникли, а на межах, де є що з ними зробити: клієнт API перетворює мережеві збої на доменні винятки з контекстом запиту, шар очікувань перетворює таймаут на повідомлення «що чекали і що побачили», а раннер фіксує падіння. Усередині Page Object чи кроку тесту try/catch майже ніколи не потрібен.

The boundary principle. Exceptions are caught not where they arise but at the boundaries where something can be done with them: the API client turns network failures into domain exceptions with request context, the waiting layer turns a timeout into "what we waited for and what we saw", and the runner records the failure. Inside a Page Object or a test step a try/catch is almost never needed.

Загортати чи пропускати. Загортати варто, коли додаєш контекст: URL, тіло відповіді, ідентифікатор тестових даних. Загортати заради «уніфікації» без нової інформації — шум у стек-трейсі. Повтори дозволені лише для явно тимчасових помилок (503 від gateway, StaleElementReferenceException), з лімітом і логуванням кожної спроби. AssertionError фреймворк не ловить ніколи: повтор проваленої перевірки — це приховування дефекту.

Wrap or propagate. Wrap when you add context: the URL, the response body, the test data id. Wrapping for the sake of "uniformity" with no new information is noise in the stack trace. Retries are allowed only for explicitly transient errors (a 503 from the gateway, StaleElementReferenceException), with a limit and every attempt logged. The framework never catches AssertionError: retrying a failed check is hiding a defect.

Контекст падіння. Скриншоти, page source, логи браузера, останні HTTP-обміни краще додавати централізовано: JUnit 5 TestWatcher або TestExecutionExceptionHandler, TestNG ITestListener. Тоді тести залишаються чистими, а артефакти з’являються для кожного падіння однаково.

Failure context. Screenshots, page source, browser logs and the last HTTP exchanges are best attached centrally: JUnit 5 TestWatcher or TestExecutionExceptionHandler, TestNG ITestListener. Tests stay clean, and artifacts appear the same way for every failure.

Трейдоф soft assertions. assertAll у JUnit 5 чи SoftAssertions в AssertJ збирають усі розбіжності, що зручно для перевірки великої форми чи DTO. Але після першого технічного збою (сторінка не завантажилась) решта перевірок генерує шум, тому soft-перевірки застосовують лише до незалежних полів одного вже отриманого результату.

The soft assertion trade-off. assertAll in JUnit 5 or AssertJ SoftAssertions collect every mismatch, which is handy for checking a large form or DTO. But after the first technical failure (the page did not load) the remaining checks just produce noise, so soft checks are applied only to independent fields of a result you already have.

Контекст падіння через розширення JUnit 5
Failure context through a JUnit 5 extension
public class FailureArtifactsExtension implements TestWatcher {

    @Override
    public void testFailed(ExtensionContext context, Throwable cause) {
        String name = context.getRequiredTestMethod().getName();
        // один раз для всіх тестів, а не try/catch у кожному
        Artifacts.saveScreenshot(name);
        Artifacts.saveLastHttpExchanges(name);
    }
}

@ExtendWith(FailureArtifactsExtension.class)
class CheckoutTest {

    @Test
    void orderSummaryMatches() {
        OrderDto order = api.getOrder(orderId);   // технічний збій тут зупинить тест одразу
        assertAll("order " + orderId,             // незалежні поля — усі розбіжності разом
                () -> assertEquals("PAID", order.status()),
                () -> assertEquals(new BigDecimal("59.90"), order.total()),
                () -> assertEquals(2, order.items().size()));
    }
}
public class FailureArtifactsExtension implements TestWatcher {

    @Override
    public void testFailed(ExtensionContext context, Throwable cause) {
        String name = context.getRequiredTestMethod().getName();
        // once for all tests instead of try/catch in each
        Artifacts.saveScreenshot(name);
        Artifacts.saveLastHttpExchanges(name);
    }
}

@ExtendWith(FailureArtifactsExtension.class)
class CheckoutTest {

    @Test
    void orderSummaryMatches() {
        OrderDto order = api.getOrder(orderId);   // a technical failure here stops the test at once
        assertAll("order " + orderId,             // independent fields — all mismatches together
                () -> assertEquals("PAID", order.status()),
                () -> assertEquals(new BigDecimal("59.90"), order.total()),
                () -> assertEquals(2, order.items().size()));
    }
}
🔧 Розбір синтаксису: TestWatcher — інтерфейс розширення JUnit 5 з default-методами; testFailed викликається після падіння тесту з його причиною. getRequiredTestMethod() повертає Method поточного тесту. @ExtendWith підключає розширення до класу. assertAll виконує всі лямбди-перевірки й кидає MultipleFailuresError з усіма розбіжностями разом.
🔧 Syntax breakdown: TestWatcher is a JUnit 5 extension interface with default methods; testFailed is called after a test fails, together with its cause. getRequiredTestMethod() returns the Method of the current test. @ExtendWith attaches the extension to the class. assertAll runs every assertion lambda and throws a MultipleFailuresError listing all mismatches together.
💡 Простими словами: Хороша стратегія винятків — як лікарня: у кожній палаті не тримають хірурга, пацієнта везуть у відділення, де знають, що робити, і там же пишуть історію хвороби.
💡 In plain words: A good exception strategy is like a hospital: you do not keep a surgeon in every ward, you take the patient to the department that knows what to do, and the medical record is written there.
🗣 Як читати вголос Моя стратегія стоїть на трьох правилах. Перше: ловити на межах, де є що додати, — у клієнті API, в шарі очікувань, у раннері, а не в кожному кроці. Друге: загортати лише з новим контекстом, повторювати лише відомі тимчасові помилки з лімітом, а AssertionError не чіпати ніколи. Третє: артефакти падіння збирати централізовано через TestWatcher чи ITestListener. Soft assertions я вмикаю лише для незалежних полів уже отриманого результату.
🗣 How to say it out loud My strategy rests on three rules. First: catch at boundaries where there is something to add — in the API client, the waiting layer, the runner — not in every step. Second: wrap only with new context, retry only known transient errors with a limit, and never touch AssertionError. Third: collect failure artifacts centrally through TestWatcher or ITestListener. I switch on soft assertions only for independent fields of a result I already have.
▶ 7 10
49 Що таке лямбда-вирази в Java і де вони з’являються в автотестах? What are lambda expressions in Java, and where do they show up in automated tests?
Лямбди і Stream Lambdas and streams Junior ▶

Що це. Лямбда — це компактний запис реалізації функціонального інтерфейсу, тобто інтерфейсу з одним абстрактним методом. Форми запису: () -> 42 без параметрів, s -> s.trim() з одним параметром, (a, b) -> a + b з кількома, і блочна форма x -> { log(x); return x * 2; } з фігурними дужками та явним return. Тип параметрів компілятор виводить сам.

What it is. A lambda is a compact way to implement a functional interface, i.e. an interface with a single abstract method. The forms are () -> 42 with no parameters, s -> s.trim() with one parameter, (a, b) -> a + b with several, and the block form x -> { log(x); return x * 2; } with braces and an explicit return. The compiler infers the parameter types.

Цільовий тип. Лямбда сама по собі не має типу: той самий текст () -> button.isDisplayed() може стати Supplier<Boolean>, BooleanSupplier чи ExpectedCondition залежно від того, куди ти її передаєш. Компілятор дивиться на параметр методу і підставляє лямбду в його єдиний абстрактний метод.

Target typing. A lambda has no type of its own: the same text () -> button.isDisplayed() can become a Supplier<Boolean>, a BooleanSupplier or an ExpectedCondition depending on where you pass it. The compiler looks at the method parameter and plugs the lambda into its single abstract method.

Приклад з тестів. assertThrows(IllegalArgumentException.class, () -> new Money(-1)) у JUnit 5, умова очікування wait.until(d -> d.findElement(By.id("toast")).isDisplayed()) у Selenium, сортування відповідей API через Comparator — усе це лямбди.

Example from tests. assertThrows(IllegalArgumentException.class, () -> new Money(-1)) in JUnit 5, the wait condition wait.until(d -> d.findElement(By.id("toast")).isDisplayed()) in Selenium, sorting API responses with a Comparator — these are all lambdas.

Підводні камені. Лямбда може читати локальні змінні лише якщо вони final або effectively final. Спроба зробити counter++ всередині forEach не скомпілюється з помилкою «local variables referenced from a lambda expression must be final or effectively final». Для лічильника беруть AtomicInteger, а краще — count() у стрімі.

Pitfalls. A lambda can read local variables only if they are final or effectively final. Writing counter++ inside forEach fails to compile with "local variables referenced from a lambda expression must be final or effectively final". For a counter people use AtomicInteger, or better, count() on the stream.

Лямбди в типовому тесті на JUnit 5
Lambdas in a typical JUnit 5 test
class PriceTest {
    @Test
    void rejectsNegativePrice() {
        // Лямбда відкладає виклик: JUnit сам викличе її всередині assertThrows
        var error = assertThrows(IllegalArgumentException.class, () -> new Price(-5));
        assertEquals("price must be >= 0", error.getMessage());
    }

    @Test
    void countsExpensiveItems() {
        List<Integer> prices = List.of(120, 40, 300, 15);
        int limit = 100;                       // effectively final: можна читати в лямбді
        long expensive = prices.stream().filter(p -> p > limit).count();
        // int counter = 0; prices.forEach(p -> counter++);  не скомпілюється
        assertEquals(2, expensive);
    }
}

record Price(int value) {
    Price {
        if (value < 0) throw new IllegalArgumentException("price must be >= 0");
    }
}
class PriceTest {
    @Test
    void rejectsNegativePrice() {
        // The lambda defers the call: JUnit invokes it inside assertThrows
        var error = assertThrows(IllegalArgumentException.class, () -> new Price(-5));
        assertEquals("price must be >= 0", error.getMessage());
    }

    @Test
    void countsExpensiveItems() {
        List<Integer> prices = List.of(120, 40, 300, 15);
        int limit = 100;                       // effectively final: readable inside the lambda
        long expensive = prices.stream().filter(p -> p > limit).count();
        // int counter = 0; prices.forEach(p -> counter++);  does not compile
        assertEquals(2, expensive);
    }
}

record Price(int value) {
    Price {
        if (value < 0) throw new IllegalArgumentException("price must be >= 0");
    }
}
🔧 Розбір синтаксису: () -> new Price(-5) — лямбда без параметрів, яку JUnit приводить до свого інтерфейсу Executable. p -> p > limit стає Predicate<Integer> для filter. Змінна limit ніде не переприсвоюється, тому вона effectively final і доступна в лямбді. Компактний конструктор record перевіряє значення до присвоєння поля.
🔧 Syntax breakdown: () -> new Price(-5) is a no-argument lambda that JUnit converts to its Executable interface. p -> p > limit becomes a Predicate<Integer> for filter. The limit variable is never reassigned, so it is effectively final and usable inside the lambda. The record's compact constructor validates the value before the field is assigned.
💡 Простими словами: Лямбда — це записка «зроби ось це пізніше», яку ти передаєш іншому методу. Хто і коли її виконає, вирішує той, хто взяв записку.
💡 In plain words: A lambda is a note saying "do this later" that you hand to another method. Whoever takes the note decides when to act on it.
🗣 Як читати вголос Я пояснюю лямбду як короткий спосіб передати поведінку туди, де очікують функціональний інтерфейс з одним методом. Тип у неї з’являється лише з контексту, тому одна й та сама стрілочка може стати Supplier або умовою очікування Selenium. У тестах я користуюся ними щодня: assertThrows, until у WebDriverWait, компаратори. І одразу згадую обмеження: захоплені локальні змінні мають бути effectively final, тож лічильник через плюс-плюс у лямбді не працює.
🗣 How to say it out loud I describe a lambda as a short way to pass behaviour to a place that expects a functional interface with one method. It only gets a type from its context, so the same arrow can become a Supplier or a Selenium wait condition. I use them in tests every day: assertThrows, until on WebDriverWait, comparators. And I mention the restriction straight away: captured local variables must be effectively final, so a counter with plus-plus inside a lambda does not work.
50 Що таке функціональний інтерфейс і чим відрізняються Predicate, Function, Consumer та Supplier? What is a functional interface, and how do Predicate, Function, Consumer and Supplier differ?
Лямбди і Stream Lambdas and streams Middle ▶

Що це. Функціональний інтерфейс має рівно один абстрактний метод; default- і static-методи не рахуються. Анотація @FunctionalInterface необов’язкова, але змушує компілятор перевірити це правило, щоб колега випадково не додав другий абстрактний метод і не зламав усі лямбди.

What it is. A functional interface has exactly one abstract method; default and static methods do not count. The @FunctionalInterface annotation is optional, but it makes the compiler enforce the rule, so a colleague cannot accidentally add a second abstract method and break every lambda.

Четвірка з java.util.function. Predicate<T> — boolean test(T), перевірка умови. Function<T,R> — R apply(T), перетворення. Consumer<T> — void accept(T), дія без результату. Supplier<T> — T get(), видає значення без вхідних даних. Поруч живуть BiFunction<T,U,R> для двох аргументів, UnaryOperator<T> як Function з однаковим типом входу й виходу, та примітивні IntPredicate, ToIntFunction без автоупаковки.

The four from java.util.function. Predicate<T> is boolean test(T), a condition check. Function<T,R> is R apply(T), a transformation. Consumer<T> is void accept(T), an action with no result. Supplier<T> is T get(), producing a value from nothing. Around them live BiFunction<T,U,R> for two arguments, UnaryOperator<T> as a Function whose input and output types match, and primitive versions such as IntPredicate and ToIntFunction that avoid boxing.

Приклад з проєкту. Умови фільтрації користувачів збираю з маленьких предикатів через and, or, negate. Перетворення DTO склеюю через andThen. Supplier використовую для лінивих тестових даних: дорогий об’єкт створюється лише тоді, коли тест справді його просить.

Project example. I build user filters from small predicates combined with and, or and negate. I chain DTO transformations with andThen. I use a Supplier for lazy test data: an expensive object is created only when a test actually asks for it.

Підводні камені. Стандартні інтерфейси не дозволяють кидати checked-винятки, тому для дії з повтором, яка кидає IOException, варто оголосити власний інтерфейс з throws Exception, а не обгортати все в RuntimeException всередині лямбди.

Pitfalls. The standard interfaces cannot throw checked exceptions, so for a retry action that throws IOException it is better to declare your own interface with throws Exception than to wrap everything in RuntimeException inside the lambda.

Чотири базові інтерфейси і власний для повторів
The four core interfaces and a custom one for retries
Predicate<User> active = u -> u.active();
Predicate<User> admin = u -> u.role().equals("ADMIN");
Predicate<User> activeNonAdmin = active.and(admin.negate());   // композиція умов

Function<User, String> email = User::email;
Function<User, String> domain = email.andThen(e -> e.substring(e.indexOf('@') + 1));

Consumer<User> log = u -> System.out.println("created " + u.email());
Supplier<User> lazyAdmin = () -> new User("root@example.com", "ADMIN", true); // створюється лише при get()

UnaryOperator<String> upper = String::toUpperCase;
BiFunction<Integer, Integer, Integer> sum = Integer::sum;

@FunctionalInterface
interface ThrowingAction<T> {
    T run() throws Exception;          // власний інтерфейс дозволяє checked-винятки
}

static <T> T retry(int times, ThrowingAction<T> action) throws Exception {
    Exception last = null;
    for (int i = 0; i < times; i++) {
        try { return action.run(); } catch (Exception e) { last = e; }
    }
    throw last;
}

record User(String email, String role, boolean active) {}
Predicate<User> active = u -> u.active();
Predicate<User> admin = u -> u.role().equals("ADMIN");
Predicate<User> activeNonAdmin = active.and(admin.negate());   // composing conditions

Function<User, String> email = User::email;
Function<User, String> domain = email.andThen(e -> e.substring(e.indexOf('@') + 1));

Consumer<User> log = u -> System.out.println("created " + u.email());
Supplier<User> lazyAdmin = () -> new User("root@example.com", "ADMIN", true); // created only on get()

UnaryOperator<String> upper = String::toUpperCase;
BiFunction<Integer, Integer, Integer> sum = Integer::sum;

@FunctionalInterface
interface ThrowingAction<T> {
    T run() throws Exception;          // a custom interface allows checked exceptions
}

static <T> T retry(int times, ThrowingAction<T> action) throws Exception {
    Exception last = null;
    for (int i = 0; i < times; i++) {
        try { return action.run(); } catch (Exception e) { last = e; }
    }
    throw last;
}

record User(String email, String role, boolean active) {}
🔧 Розбір синтаксису: active.and(admin.negate()) повертає новий Predicate, нічого не обчислюючи одразу. email.andThen(...) виконує спершу першу функцію, потім другу. Supplier викликає конструктор лише в момент get(). ThrowingAction оголошує throws Exception, тому лямбда () -> Files.readString(path) підходить без обгортки, а retry повертає перший успішний результат або кидає останній виняток.
🔧 Syntax breakdown: active.and(admin.negate()) returns a new Predicate without evaluating anything yet. email.andThen(...) runs the first function and then the second. The Supplier calls the constructor only at the moment of get(). ThrowingAction declares throws Exception, so a lambda like () -> Files.readString(path) fits without wrapping, and retry returns the first successful result or rethrows the last exception.
💡 Простими словами: Predicate питає «так чи ні?», Function перетворює, Consumer робить і мовчить, Supplier дає щось із нічого.
💡 In plain words: Predicate asks "yes or no?", Function transforms, Consumer acts and stays quiet, Supplier gives something out of nothing.
🗣 Як читати вголос Функціональний інтерфейс для мене — це просто контракт з одним абстрактним методом, під який підходить лямбда. Анотацію FunctionalInterface я ставлю на власні інтерфейси, щоб компілятор стеріг це правило. Із чотирьох базових я пам’ятаю сигнатури: Predicate повертає boolean, Function перетворює, Consumer нічого не повертає, Supplier нічого не приймає. У фреймворку я комбіную предикати через and і negate, а для повторів з checked-винятками пишу свій маленький інтерфейс.
🗣 How to say it out loud To me a functional interface is just a contract with one abstract method that a lambda can fit into. I put the FunctionalInterface annotation on my own interfaces so the compiler guards that rule. Of the four core ones I remember the signatures: Predicate returns a boolean, Function transforms, Consumer returns nothing, Supplier takes nothing. In a framework I combine predicates with and and negate, and for retries with checked exceptions I write my own small interface.
51 Що таке посилання на метод (::) і які бувають його види? What is a method reference (::), and what kinds are there?
Лямбди і Stream Lambdas and streams Junior ▶

Що це. Посилання на метод — скорочений запис лямбди, яка лише викликає вже наявний метод. Замість s -> s.trim() пишуть String::trim, замість x -> System.out.println(x) — System.out::println. Компілятор так само підганяє його під функціональний інтерфейс за кількістю й типами параметрів.

What it is. A method reference is a shorthand for a lambda that only calls an existing method. Instead of s -> s.trim() you write String::trim, instead of x -> System.out.println(x) you write System.out::println. The compiler matches it to a functional interface by the number and types of parameters, just like a lambda.

Чотири види. Статичний метод: Integer::parseInt відповідає s -> Integer.parseInt(s). Метод конкретного об’єкта (bound): this::click чи loginPage::open — об’єкт зафіксовано заздалегідь. Метод довільного об’єкта свого типу (unbound): String::toLowerCase — перший параметр лямбди стає отримувачем виклику. Конструктор: User::new або ArrayList::new, для масиву — String[]::new.

Four kinds. A static method: Integer::parseInt stands for s -> Integer.parseInt(s). A method of a particular object (bound): this::click or loginPage::open, where the object is fixed in advance. A method of an arbitrary object of a type (unbound): String::toLowerCase, where the first lambda parameter becomes the receiver. A constructor: User::new or ArrayList::new, and for arrays String[]::new.

Приклад з проєкту. Коли парсимо CSV з тестовими даними, ланцюжок map(String::trim).filter(Predicate.not(String::isBlank)).map(Integer::parseInt) читається як речення. У Page Object список кнопок можна обійти через buttons.forEach(this::highlight).

Project example. When parsing CSV test data, the chain map(String::trim).filter(Predicate.not(String::isBlank)).map(Integer::parseInt) reads like a sentence. In a Page Object a list of buttons can be walked with buttons.forEach(this::highlight).

Підводні камені. Коли потрібно передати додатковий аргумент чи змінити порядок, посилання вже не підходить, і лямбда s -> s.split(",", -1) чесніша. Також перевантажені методи іноді дають помилку неоднозначності, і тоді явна лямбда з типом прибирає її.

Pitfalls. When you need an extra argument or a different order, a reference no longer fits, and the lambda s -> s.split(",", -1) is more honest. Overloaded methods sometimes cause an ambiguity error, and an explicit typed lambda removes it.

Усі чотири види посилань на метод
All four kinds of method references
List<String> raw = List.of(" 42 ", "", "7", "  ");

List<Integer> ids = raw.stream()
        .map(String::trim)                        // unbound: s -> s.trim()
        .filter(Predicate.not(String::isBlank))   // unbound у Predicate.not
        .map(Integer::parseInt)                   // статичний: s -> Integer.parseInt(s)
        .toList();

List<User> users = Stream.of("anna@example.com", "bob@example.com")
        .map(User::new)                           // конструктор: e -> new User(e)
        .toList();

users.forEach(System.out::println);               // bound: конкретний об’єкт System.out
String[] emails = users.stream().map(User::email).toArray(String[]::new);

record User(String email) {}
List<String> raw = List.of(" 42 ", "", "7", "  ");

List<Integer> ids = raw.stream()
        .map(String::trim)                        // unbound: s -> s.trim()
        .filter(Predicate.not(String::isBlank))   // unbound inside Predicate.not
        .map(Integer::parseInt)                   // static: s -> Integer.parseInt(s)
        .toList();

List<User> users = Stream.of("anna@example.com", "bob@example.com")
        .map(User::new)                           // constructor: e -> new User(e)
        .toList();

users.forEach(System.out::println);               // bound: the particular System.out object
String[] emails = users.stream().map(User::email).toArray(String[]::new);

record User(String email) {}
🔧 Розбір синтаксису: String::trim і String::isBlank — unbound-посилання: рядок з потоку стає об’єктом, на якому викликається метод. Predicate.not (з Java 11) інвертує посилання без лямбди. User::new викликає канонічний конструктор record з одним параметром. toArray(String[]::new) передає генератор масиву потрібного розміру.
🔧 Syntax breakdown: String::trim and String::isBlank are unbound references: the string from the stream becomes the object the method is called on. Predicate.not (since Java 11) negates a reference without a lambda. User::new calls the record's canonical one-argument constructor. toArray(String[]::new) passes a generator for an array of the right size.
💡 Простими словами: Посилання на метод — це лямбда, у якої відрізали все зайве і залишили тільки ім’я методу.
💡 In plain words: A method reference is a lambda with everything trimmed away except the method name.
🗣 Як читати вголос Я кажу, що посилання на метод — це синтаксичний цукор для лямбди, яка просто викликає наявний метод. Видів чотири: статичний на кшталт Integer::parseInt, прив’язаний до конкретного об’єкта як this::click, неприв’язаний, де першим параметром стає сам об’єкт, як String::trim, і конструктор User::new. Я беру посилання, коли воно читається як речення, а якщо треба передати ще аргумент чи щось переставити, пишу звичайну лямбду.
🗣 How to say it out loud I say a method reference is syntactic sugar for a lambda that just calls an existing method. There are four kinds: static like Integer::parseInt, bound to a specific object like this::click, unbound where the object itself becomes the first parameter like String::trim, and a constructor like User::new. I use a reference when it reads like a sentence, and when I need an extra argument or a different order I write a plain lambda.
52 Що таке Stream у Java і чим проміжні операції відрізняються від термінальних? What is a Stream in Java, and how do intermediate operations differ from terminal ones?
Лямбди і Stream Lambdas and streams Junior ▶

Що це. Stream — це конвеєр обробки елементів, а не колекція: він нічого не зберігає, а тягне дані з джерела (списку, масиву, рядків файлу) і пропускає їх через ланцюжок операцій. Стрім одноразовий: після термінальної операції повторний виклик кидає IllegalStateException: stream has already been operated upon or closed.

What it is. A Stream is a processing pipeline, not a collection: it stores nothing, pulls data from a source (a list, an array, lines of a file) and passes it through a chain of operations. A stream is single-use: after a terminal operation, calling it again throws IllegalStateException: stream has already been operated upon or closed.

Проміжні й термінальні. Проміжні операції (filter, map, sorted, distinct, limit, peek) повертають новий Stream і лише описують, що робити. Термінальні (collect, toList, count, forEach, findFirst, anyMatch, reduce) запускають обчислення і повертають результат або нічого. Без термінальної операції конвеєр не виконується зовсім — це і є лінивість.

Intermediate and terminal. Intermediate operations (filter, map, sorted, distinct, limit, peek) return a new Stream and only describe what to do. Terminal ones (collect, toList, count, forEach, findFirst, anyMatch, reduce) start the computation and return a result or nothing. Without a terminal operation the pipeline never runs — that is laziness.

Приклад з тестів. Коли перевіряю, що серед 500 рядків відповіді API є хоч один з помилкою, anyMatch зупиниться на першому збігу, а не пройде весь список. Елементи йдуть по конвеєру по одному: перший елемент проходить filter і map, потім другий, а не «спершу весь filter, потім весь map».

Example from tests. When I check that at least one of 500 API response rows has an error, anyMatch stops at the first match instead of walking the whole list. Elements travel through the pipeline one at a time: the first element goes through filter and map, then the second, rather than "all of filter first, then all of map".

Підводні камені. Зберігати Stream у полі чи повертати його для кількох споживачів — пастка одноразовості. peek годиться лише для налагодження: якщо термінальна операція, наприклад count(), може порахувати розмір без обходу, peek узагалі не виконається.

Pitfalls. Keeping a Stream in a field or returning it to several consumers runs into the single-use trap. peek is only for debugging: if the terminal operation, such as count(), can compute the size without traversal, peek does not run at all.

Лінивість і одноразовість стріму
Stream laziness and single use
List<String> statuses = List.of("PASSED", "FAILED", "PASSED", "SKIPPED");

Stream<String> pipeline = statuses.stream()
        .filter(s -> {
            System.out.println("filter " + s);   // нічого не друкується, доки немає термінальної
            return !s.equals("SKIPPED");
        })
        .map(String::toLowerCase);

boolean anyFailed = pipeline.anyMatch(s -> s.equals("failed")); // запуск; зупиниться на 2-му елементі
System.out.println(anyFailed);                                   // true

// pipeline.count();  IllegalStateException: stream has already been operated upon or closed
long passed = statuses.stream().filter("PASSED"::equals).count(); // новий стрім з того ж джерела
List<String> statuses = List.of("PASSED", "FAILED", "PASSED", "SKIPPED");

Stream<String> pipeline = statuses.stream()
        .filter(s -> {
            System.out.println("filter " + s);   // prints nothing until a terminal operation runs
            return !s.equals("SKIPPED");
        })
        .map(String::toLowerCase);

boolean anyFailed = pipeline.anyMatch(s -> s.equals("failed")); // runs now; stops at the 2nd element
System.out.println(anyFailed);                                   // true

// pipeline.count();  IllegalStateException: stream has already been operated upon or closed
long passed = statuses.stream().filter("PASSED"::equals).count(); // a new stream from the same source
🔧 Розбір синтаксису: Змінна pipeline містить лише опис конвеєра. anyMatch — термінальна операція з коротким замиканням: вона викликає filter для PASSED і FAILED, знаходить збіг і далі не йде, тож у консолі буде два рядки «filter». Повторне використання того самого об’єкта Stream кидає виняток, тому для другого підрахунку створюємо новий стрім із колекції.
🔧 Syntax breakdown: The pipeline variable holds only a description of the pipeline. anyMatch is a short-circuiting terminal operation: it calls filter for PASSED and FAILED, finds a match and goes no further, so the console shows two "filter" lines. Reusing the same Stream object throws, so the second count creates a fresh stream from the collection.
💡 Простими словами: Стрім — це рецепт, а не страва. Проміжні операції дописують кроки рецепта, термінальна — нарешті вмикає плиту, і приготувати за ним можна лише один раз.
💡 In plain words: A stream is a recipe, not a dish. Intermediate operations add steps to the recipe, the terminal one finally turns on the stove, and you can cook from it only once.
🗣 Як читати вголос Я пояснюю Stream як конвеєр над джерелом даних, який сам нічого не зберігає. Проміжні операції на кшталт filter і map лише будують цей конвеєр і повертають новий стрім, а щось реально відбувається тільки на термінальній: collect, count, anyMatch. Два наслідки, які я завжди згадую: стрім одноразовий, і елементи йдуть по одному, тому anyMatch чи findFirst можуть не дочитати список до кінця. Peek я залишаю тільки для налагодження.
🗣 How to say it out loud I explain a Stream as a pipeline over a data source that stores nothing itself. Intermediate operations such as filter and map only build that pipeline and return a new stream, and nothing really happens until a terminal one: collect, count, anyMatch. I always mention two consequences: a stream is single-use, and elements flow one by one, so anyMatch or findFirst may never read the rest of the list. I keep peek for debugging only.
53 Як filter, map і flatMap допомагають готувати тестові дані? How do filter, map and flatMap help prepare test data?
Лямбди і Stream Lambdas and streams Junior ▶

filter. Приймає Predicate і пропускає далі лише елементи, для яких він повертає true; кількість елементів може зменшитися, тип не змінюється. Типово: залишити активних користувачів, рядки таблиці зі статусом «Paid», відповіді з кодом 5xx.

filter. It takes a Predicate and passes on only the elements for which it returns true; the count may shrink but the type stays the same. Typical uses: keep active users, table rows with status "Paid", responses with a 5xx code.

map. Приймає Function і перетворює кожен елемент рівно на один інший, можливо іншого типу: User на email, JSON-вузол на DTO, рядок на Integer. Для чисел краще mapToInt і mapToLong — вони дають IntStream з готовими sum(), average(), max() і без автоупаковки.

map. It takes a Function and turns each element into exactly one other element, possibly of another type: a User into an email, a JSON node into a DTO, a string into an Integer. For numbers mapToInt and mapToLong are better: they give an IntStream with ready sum(), average() and max() and no boxing.

flatMap. Коли кожен елемент перетворюється на кілька (або на жоден), map дав би Stream<List<Item>> — «стрім списків». flatMap розплющує їх в один Stream<Item>. Приклад з проєкту: з відповіді /orders дістати всі позиції всіх замовлень і перевірити, що жодна не має ціни 0; або з таблиці UI у вигляді List<List<String>> отримати плаский список клітинок.

flatMap. When each element becomes several (or none), map would produce a Stream<List<Item>>, a stream of lists. flatMap flattens them into one Stream<Item>. A project example: take every line item of every order from the /orders response and check that none has a price of 0; or turn a UI table held as List<List<String>> into a flat list of cells.

Підводні камені. Плутанина map і flatMap дає тип на кшталт List<List<String>> там, де очікували плаский список, і дивні повідомлення асертів. Ще одна помилка — побічні ефекти в map (кліки, запити): операція лінива й може не виконатися або виконатися не для всіх елементів.

Pitfalls. Confusing map and flatMap yields a type like List<List<String>> where a flat list was expected, along with confusing assertion messages. Another mistake is side effects inside map (clicks, requests): the operation is lazy and may not run, or may not run for every element.

filter, map і flatMap на відповіді зі замовленнями
filter, map and flatMap on an orders response
record Item(String sku, int priceCents) {}
record Order(String id, String status, List<Item> items) {}

List<Order> orders = List.of(
        new Order("o1", "PAID", List.of(new Item("A", 1999), new Item("B", 0))),
        new Order("o2", "CANCELLED", List.of(new Item("C", 500))),
        new Order("o3", "PAID", List.of()));

List<String> paidIds = orders.stream()
        .filter(o -> o.status().equals("PAID"))      // лише оплачені
        .map(Order::id)                              // Order -> String
        .toList();                                   // [o1, o3]

List<Item> freeItems = orders.stream()
        .flatMap(o -> o.items().stream())            // Stream<Order> -> Stream<Item>
        .filter(i -> i.priceCents() == 0)
        .toList();                                   // [Item[sku=B, priceCents=0]]

int totalCents = orders.stream()
        .flatMap(o -> o.items().stream())
        .mapToInt(Item::priceCents)                  // IntStream без упаковки
        .sum();                                      // 2499

List<List<String>> table = List.of(List.of("Anna", "admin"), List.of("Bob", "user"));
List<String> cells = table.stream().flatMap(List::stream).toList(); // [Anna, admin, Bob, user]
record Item(String sku, int priceCents) {}
record Order(String id, String status, List<Item> items) {}

List<Order> orders = List.of(
        new Order("o1", "PAID", List.of(new Item("A", 1999), new Item("B", 0))),
        new Order("o2", "CANCELLED", List.of(new Item("C", 500))),
        new Order("o3", "PAID", List.of()));

List<String> paidIds = orders.stream()
        .filter(o -> o.status().equals("PAID"))      // paid only
        .map(Order::id)                              // Order -> String
        .toList();                                   // [o1, o3]

List<Item> freeItems = orders.stream()
        .flatMap(o -> o.items().stream())            // Stream<Order> -> Stream<Item>
        .filter(i -> i.priceCents() == 0)
        .toList();                                   // [Item[sku=B, priceCents=0]]

int totalCents = orders.stream()
        .flatMap(o -> o.items().stream())
        .mapToInt(Item::priceCents)                  // IntStream with no boxing
        .sum();                                      // 2499

List<List<String>> table = List.of(List.of("Anna", "admin"), List.of("Bob", "user"));
List<String> cells = table.stream().flatMap(List::stream).toList(); // [Anna, admin, Bob, user]
🔧 Розбір синтаксису: filter отримує лямбду-предикат, map(Order::id) — функцію, що витягує поле record. flatMap(o -> o.items().stream()) для кожного замовлення повертає стрім позицій і зливає їх в один; порожній список замовлення o3 просто нічого не додає. mapToInt переходить на IntStream, де є sum(). List::stream — посилання на метод, що робить з кожного рядка таблиці стрім клітинок.
🔧 Syntax breakdown: filter receives a predicate lambda, and map(Order::id) a function that reads a record field. flatMap(o -> o.items().stream()) returns a stream of items for each order and merges them into one; the empty list of order o3 simply contributes nothing. mapToInt switches to an IntStream that has sum(). List::stream is a method reference that turns each table row into a stream of cells.
💡 Простими словами: filter — сито, map — перекладач, flatMap — людина, яка розпаковує всі коробки й складає вміст на одну полицю.
💡 In plain words: filter is a sieve, map is a translator, and flatMap is the person who unpacks every box and puts the contents on one shelf.
🗣 Як читати вголос Я розводжу ці три операції за тим, що відбувається з кількістю елементів. Filter може лише відсіяти, тип лишається той самий. Map робить з одного елемента рівно один, наприклад з користувача email. FlatMap потрібен, коли з одного елемента виходить кілька: зі замовлення — його позиції, з рядка таблиці — клітинки, і тоді я отримую плаский стрім замість стріму списків. Для сум і середніх значень переходжу на mapToInt, щоб не упаковувати числа.
🗣 How to say it out loud I tell these three apart by what happens to the number of elements. Filter can only drop elements and the type stays the same. Map turns one element into exactly one, for example a user into an email. FlatMap is needed when one element yields several, such as an order into its line items or a table row into cells, and then I get a flat stream instead of a stream of lists. For sums and averages I switch to mapToInt so numbers are not boxed.
54 Чим відрізняються collect(Collectors.toList()), Stream.toList(), toMap, count і joining? How do collect(Collectors.toList()), Stream.toList(), toMap, count and joining differ?
Лямбди і Stream Lambdas and streams Middle ▶

Збирання в список. collect(Collectors.toList()) повертає список, про змінюваність якого специфікація нічого не обіцяє; на практиці це ArrayList, і в нього можна додавати. Stream.toList() з Java 16 повертає незмінний список: add кине UnsupportedOperationException, зате він коротший і безпечний для передачі. Collectors.toUnmodifiableList() теж незмінний, але, на відміну від toList(), падає на null-елементах.

Collecting into a list. collect(Collectors.toList()) returns a list whose mutability the specification does not promise; in practice it is an ArrayList and you can add to it. Stream.toList() from Java 16 returns an unmodifiable list: add throws UnsupportedOperationException, but it is shorter and safe to hand around. Collectors.toUnmodifiableList() is also unmodifiable but, unlike toList(), fails on null elements.

Set і Map. toSet() прибирає дублікати за equals/hashCode. toMap(keyFn, valueFn) кидає IllegalStateException: Duplicate key, якщо два елементи дають однаковий ключ, — третій аргумент-злиття (a, b) -> a вирішує конфлікт явно, а четвертий, наприклад LinkedHashMap::new, задає тип мапи й порядок.

Set and Map. toSet() removes duplicates by equals/hashCode. toMap(keyFn, valueFn) throws IllegalStateException: Duplicate key if two elements produce the same key; the third merge argument (a, b) -> a resolves the clash explicitly, and a fourth one such as LinkedHashMap::new sets the map type and ordering.

count і joining. count() повертає long — кількість елементів після фільтрів. Collectors.joining(", ", "[", "]") склеює рядки з роздільником, префіксом і суфіксом. Приклад з проєкту: коли асерт падає, у повідомленні видно перелік проблемних SKU одним рядком, а не hashCode об’єкта.

count and joining. count() returns a long, the number of elements after the filters. Collectors.joining(", ", "[", "]") glues strings with a delimiter, a prefix and a suffix. A project example: when an assertion fails, the message shows the offending SKUs on one line instead of an object hash code.

Підводні камені. Звичка змінювати результат toList() у тесті дає UnsupportedOperationException, яке помилково сприймають за баг застосунку. А toMap на даних з API, де ключ раптом не унікальний, падає лише на певних середовищах.

Pitfalls. Habitually modifying the result of toList() in a test yields UnsupportedOperationException, which people mistake for an application bug. And toMap over API data whose key suddenly is not unique fails only on certain environments.

Різні способи зібрати результат стріму
Different ways to collect a stream result
record Product(String sku, String category, int stock) {}

List<Product> products = List.of(
        new Product("A1", "books", 0),
        new Product("B2", "toys", 5),
        new Product("C3", "books", 0));

List<String> mutable = products.stream().map(Product::sku).collect(Collectors.toList());
mutable.add("extra");                                   // працює: фактично ArrayList

List<String> fixed = products.stream().map(Product::sku).toList();
// fixed.add("extra");  UnsupportedOperationException

Map<String, Integer> stockBySku = products.stream()
        .collect(Collectors.toMap(Product::sku, Product::stock));
Map<String, String> skuByCategory = products.stream()
        .collect(Collectors.toMap(Product::category, Product::sku,
                (first, second) -> first, LinkedHashMap::new)); // без злиття: Duplicate key books

long outOfStock = products.stream().filter(p -> p.stock() == 0).count();
String skus = products.stream().filter(p -> p.stock() == 0)
        .map(Product::sku).collect(Collectors.joining(", ", "[", "]"));

assertEquals(0, outOfStock, "Out of stock: " + skus); // Out of stock: [A1, C3]
record Product(String sku, String category, int stock) {}

List<Product> products = List.of(
        new Product("A1", "books", 0),
        new Product("B2", "toys", 5),
        new Product("C3", "books", 0));

List<String> mutable = products.stream().map(Product::sku).collect(Collectors.toList());
mutable.add("extra");                                   // works: in practice an ArrayList

List<String> fixed = products.stream().map(Product::sku).toList();
// fixed.add("extra");  UnsupportedOperationException

Map<String, Integer> stockBySku = products.stream()
        .collect(Collectors.toMap(Product::sku, Product::stock));
Map<String, String> skuByCategory = products.stream()
        .collect(Collectors.toMap(Product::category, Product::sku,
                (first, second) -> first, LinkedHashMap::new)); // without merge: Duplicate key books

long outOfStock = products.stream().filter(p -> p.stock() == 0).count();
String skus = products.stream().filter(p -> p.stock() == 0)
        .map(Product::sku).collect(Collectors.joining(", ", "[", "]"));

assertEquals(0, outOfStock, "Out of stock: " + skus); // Out of stock: [A1, C3]
🔧 Розбір синтаксису: Collectors.toList() проти Stream.toList() — різниця лише в змінюваності результату. toMap з трьома аргументами злиття залишає перший SKU для категорії books, а LinkedHashMap::new зберігає порядок вставки. count() повертає long, тому асерт порівнює з 0, яке автоматично розширюється до long. joining будує рядок «[A1, C3]» для повідомлення про падіння.
🔧 Syntax breakdown: Collectors.toList() versus Stream.toList() differ only in whether the result can be modified. The three-argument merge in toMap keeps the first SKU for the books category, and LinkedHashMap::new preserves insertion order. count() returns a long, so the assertion compares with 0, which is widened to long. joining builds the string "[A1, C3]" for the failure message.
💡 Простими словами: toList() видає тобі фотографію списку — дивитися можна, дописати не можна. Collectors.toList() видає чернетку, у яку можна писати далі.
💡 In plain words: toList() hands you a photo of the list: you can look but not write. Collectors.toList() hands you a draft you can keep writing in.
🗣 Як читати вголос Я одразу кажу про різницю в змінюваності: Stream.toList з Java 16 повертає незмінний список, а Collectors.toList на практиці дає ArrayList, хоча специфікація цього не гарантує. З toMap я завжди думаю про дублікати ключів, бо без функції злиття він падає з Duplicate key. Count повертає long, а joining з префіксом і суфіксом я люблю для повідомлень в асертах, щоб одразу бачити, які саме елементи не пройшли перевірку.
🗣 How to say it out loud I start with the difference in mutability: Stream.toList from Java 16 returns an unmodifiable list, while Collectors.toList gives an ArrayList in practice, although the specification does not guarantee it. With toMap I always think about duplicate keys, because without a merge function it fails with Duplicate key. Count returns a long, and I like joining with a prefix and suffix for assertion messages, so I see at once which elements failed the check.
55 Як працює Collectors.groupingBy і коли потрібен partitioningBy? How does Collectors.groupingBy work, and when do you need partitioningBy?
Лямбди і Stream Lambdas and streams Middle ▶

Що це. groupingBy(classifier) розкладає елементи стріму по кошиках: функція-класифікатор обчислює ключ, і результатом стає Map<K, List<T>>. Другий аргумент — downstream-колектор, який вирішує, що зробити з кожним кошиком: counting() дає кількість, mapping(fn, toList()) — список перетворених значень, summingInt, averagingDouble, maxBy — агрегати.

What it is. groupingBy(classifier) sorts stream elements into buckets: the classifier function computes a key, and the result is a Map<K, List<T>>. The second argument is a downstream collector that decides what to do with each bucket: counting() gives a count, mapping(fn, toList()) a list of transformed values, and summingInt, averagingDouble or maxBy give aggregates.

Порядок ключів. За замовчуванням мапа — HashMap, тож порядок ключів не гарантований, і друкований звіт «стрибає» між запусками. Варіант з трьома аргументами groupingBy(fn, TreeMap::new, counting()) дає відсортовані ключі, що зручно для стабільних повідомлень і snapshot-порівнянь.

Key order. By default the map is a HashMap, so the key order is not guaranteed and a printed report "jumps around" between runs. The three-argument form groupingBy(fn, TreeMap::new, counting()) gives sorted keys, which is handy for stable messages and snapshot comparisons.

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

partitioningBy. A special case for a predicate: exactly two buckets, true and false, and both keys are present even when one bucket is empty. A project example: split run results into passed and failed, or API responses into successes and errors.

Приклад і підводні камені. Звіт після прогону: згрупувати помилки API за статус-кодом і порахувати їх — одразу видно, що 40 падінь з 42 — це 503 одного сервісу. Помилка початківців — класифікатор, що може повернути null: groupingBy кидає NullPointerException, тому невідомі значення варто мапити на явний ключ на кшталт «UNKNOWN».

Example and pitfalls. A post-run report: group API errors by status code and count them, and you see immediately that 40 of 42 failures are 503s from one service. A beginner's mistake is a classifier that can return null: groupingBy throws NullPointerException, so unknown values should map to an explicit key such as "UNKNOWN".

Звіт про прогін через groupingBy і partitioningBy
A run report built with groupingBy and partitioningBy
record Result(String test, String status, int httpCode, long millis) {}

List<Result> results = List.of(
        new Result("login", "PASSED", 200, 310),
        new Result("cart", "FAILED", 503, 5000),
        new Result("search", "FAILED", 503, 5000),
        new Result("profile", "FAILED", 404, 120));

Map<String, List<Result>> byStatus = results.stream()
        .collect(Collectors.groupingBy(Result::status));          // HashMap, порядок не гарантований

Map<Integer, Long> failuresByCode = results.stream()
        .filter(r -> r.status().equals("FAILED"))
        .collect(Collectors.groupingBy(Result::httpCode, TreeMap::new, Collectors.counting()));
// {404=1, 503=2}

Map<Integer, List<String>> testsByCode = results.stream()
        .collect(Collectors.groupingBy(Result::httpCode,
                Collectors.mapping(Result::test, Collectors.toList())));

Map<Boolean, List<Result>> slow = results.stream()
        .collect(Collectors.partitioningBy(r -> r.millis() > 1000)); // завжди є true і false
System.out.println("Slow tests: " + slow.get(true).size());
record Result(String test, String status, int httpCode, long millis) {}

List<Result> results = List.of(
        new Result("login", "PASSED", 200, 310),
        new Result("cart", "FAILED", 503, 5000),
        new Result("search", "FAILED", 503, 5000),
        new Result("profile", "FAILED", 404, 120));

Map<String, List<Result>> byStatus = results.stream()
        .collect(Collectors.groupingBy(Result::status));          // HashMap, order not guaranteed

Map<Integer, Long> failuresByCode = results.stream()
        .filter(r -> r.status().equals("FAILED"))
        .collect(Collectors.groupingBy(Result::httpCode, TreeMap::new, Collectors.counting()));
// {404=1, 503=2}

Map<Integer, List<String>> testsByCode = results.stream()
        .collect(Collectors.groupingBy(Result::httpCode,
                Collectors.mapping(Result::test, Collectors.toList())));

Map<Boolean, List<Result>> slow = results.stream()
        .collect(Collectors.partitioningBy(r -> r.millis() > 1000)); // always has true and false
System.out.println("Slow tests: " + slow.get(true).size());
🔧 Розбір синтаксису: groupingBy(Result::status) повертає Map<String, List<Result>>. Форма groupingBy(key, TreeMap::new, counting()) задає фабрику мапи і downstream-колектор, тому ключі відсортовані, а значення мають тип Long. mapping перетворює елементи кошика перед збиранням у список. partitioningBy приймає Predicate і повертає Map<Boolean, List<Result>> з обома ключами.
🔧 Syntax breakdown: groupingBy(Result::status) returns a Map<String, List<Result>>. The form groupingBy(key, TreeMap::new, counting()) supplies a map factory and a downstream collector, so the keys are sorted and the values are Long. mapping transforms bucket elements before collecting them into a list. partitioningBy takes a Predicate and returns a Map<Boolean, List<Result>> with both keys present.
💡 Простими словами: groupingBy — це як розкласти пошту по скриньках за адресою, а partitioningBy — по двох коробках «так» і «ні».
💡 In plain words: groupingBy is like sorting mail into boxes by address, and partitioningBy is sorting it into two boxes marked "yes" and "no".
🗣 Як читати вголос GroupingBy для мене — це SQL-ний GROUP BY усередині Java: класифікатор дає ключ, а downstream-колектор каже, що робити з кожною групою, наприклад counting чи mapping. У тестовому фреймворку я так будую підсумок прогону: помилки за статус-кодом, тести за модулем. Якщо звіт має бути стабільним, передаю TreeMap::new, бо HashMap не тримає порядок. А коли умова бінарна, беру partitioningBy — він гарантує обидва ключі, навіть якщо одна група порожня.
🗣 How to say it out loud To me groupingBy is SQL GROUP BY inside Java: the classifier gives a key and the downstream collector says what to do with each group, such as counting or mapping. In a test framework that is how I build a run summary: failures by status code, tests by module. If the report must be stable I pass TreeMap::new, because a HashMap keeps no order. And when the condition is binary I use partitioningBy, which guarantees both keys even when one group is empty.
56 Що роблять reduce, distinct, sorted, limit і skip і де вони корисні в тестах? What do reduce, distinct, sorted, limit and skip do, and where are they useful in tests?
Лямбди і Stream Lambdas and streams Middle ▶

reduce. Згортає стрім в одне значення: reduce(0, Integer::sum) з початковим значенням (identity) і асоціативною функцією. Без identity повертає Optional, бо стрім може бути порожнім. Для сум і максимумів краще готові sum(), max(), Collectors.summingInt — вони читабельніші. Функція має бути асоціативною, інакше паралельний стрім дасть інший результат.

reduce. It folds a stream into one value: reduce(0, Integer::sum) with an initial value (identity) and an associative function. Without an identity it returns an Optional, because the stream may be empty. For sums and maximums the ready-made sum(), max() and Collectors.summingInt are more readable. The function has to be associative, otherwise a parallel stream gives a different result.

distinct і sorted. distinct() прибирає дублікати за equals/hashCode, тому для власних класів без цих методів він нічого не прибере. sorted() сортує за натуральним порядком (Comparable), sorted(Comparator) — за заданим. Обидві операції stateful: sorted мусить прочитати весь стрім, перш ніж віддати перший елемент.

distinct and sorted. distinct() removes duplicates using equals/hashCode, so for your own classes without those methods it removes nothing. sorted() sorts by natural order (Comparable), sorted(Comparator) by the given one. Both are stateful: sorted has to read the whole stream before it can emit the first element.

limit і skip. limit(n) бере перші n елементів і діє як коротке замикання — на нескінченному Stream.iterate без нього обчислення не закінчиться. skip(n) пропускає перші n. Разом вони моделюють сторінку: skip((page - 1) * size).limit(size).

limit and skip. limit(n) takes the first n elements and short-circuits; on an infinite Stream.iterate the computation never finishes without it. skip(n) drops the first n. Together they model a page: skip((page - 1) * size).limit(size).

Приклад з проєкту. Перевірка пагінації API: з повного списку, відсортованого так само, як сортує бекенд, вирізаю очікувану другу сторінку через skip і limit та порівнюю з відповіддю ?page=2&size=20. Підводний камінь: якщо сортування на бекенді має інший tie-breaker, сторінки не збігаються — сортую за тими самими двома полями.

Project example. Checking API pagination: from the full list, sorted exactly as the backend sorts it, I cut the expected second page with skip and limit and compare it with the ?page=2&size=20 response. The pitfall: if the backend uses a different tie-breaker, pages will not match, so I sort by the same two fields.

Очікувана сторінка API через sorted, skip і limit
The expected API page via sorted, skip and limit
record User(int id, String name, int age) {}

List<User> all = List.of(new User(3, "Cid", 30), new User(1, "Ann", 25),
        new User(2, "Bob", 30), new User(4, "Dan", 41), new User(1, "Ann", 25));

List<User> expectedPage2 = all.stream()
        .distinct()                                              // record має equals/hashCode
        .sorted(Comparator.comparingInt(User::age).thenComparingInt(User::id)) // як на бекенді
        .skip(2)                                                 // (page - 1) * size, size = 2
        .limit(2)
        .toList();                                               // [Cid(30), Dan(41)]

int totalAge = all.stream().distinct().map(User::age).reduce(0, Integer::sum); // 126
int sameTotal = all.stream().distinct().mapToInt(User::age).sum();             // читабельніше
Optional<Integer> oldest = all.stream().map(User::age).reduce(Integer::max);   // без identity -> Optional

List<Integer> firstFive = Stream.iterate(1, n -> n * 2).limit(5).toList();     // [1, 2, 4, 8, 16]
record User(int id, String name, int age) {}

List<User> all = List.of(new User(3, "Cid", 30), new User(1, "Ann", 25),
        new User(2, "Bob", 30), new User(4, "Dan", 41), new User(1, "Ann", 25));

List<User> expectedPage2 = all.stream()
        .distinct()                                              // a record has equals/hashCode
        .sorted(Comparator.comparingInt(User::age).thenComparingInt(User::id)) // same as the backend
        .skip(2)                                                 // (page - 1) * size, size = 2
        .limit(2)
        .toList();                                               // [Cid(30), Dan(41)]

int totalAge = all.stream().distinct().map(User::age).reduce(0, Integer::sum); // 126
int sameTotal = all.stream().distinct().mapToInt(User::age).sum();             // more readable
Optional<Integer> oldest = all.stream().map(User::age).reduce(Integer::max);   // no identity -> Optional

List<Integer> firstFive = Stream.iterate(1, n -> n * 2).limit(5).toList();     // [1, 2, 4, 8, 16]
🔧 Розбір синтаксису: distinct() прибирає другий User(1, "Ann", 25), бо record генерує equals за всіма полями. thenComparingInt(User::id) — tie-breaker для однакового віку. skip(2).limit(2) відрізає другу сторінку розміром 2. reduce(0, Integer::sum) повертає int, а reduce(Integer::max) без identity — Optional. Stream.iterate нескінченний, і лише limit(5) робить його скінченним.
🔧 Syntax breakdown: distinct() removes the second User(1, "Ann", 25) because a record generates equals over all fields. thenComparingInt(User::id) is the tie-breaker for equal ages. skip(2).limit(2) cuts the second page of size 2. reduce(0, Integer::sum) returns an int, while reduce(Integer::max) without an identity returns an Optional. Stream.iterate is infinite, and only limit(5) makes it finite.
💡 Простими словами: reduce — злити все в одну склянку, distinct — викинути копії, sorted — вишикувати в чергу, skip і limit — відрахувати, з якого місця і скільки взяти.
💡 In plain words: reduce pours everything into one glass, distinct throws out copies, sorted lines everyone up, and skip with limit count where to start and how many to take.
🗣 Як читати вголос Reduce я пояснюю як згортку з початковим значенням і асоціативною функцією, але в реальному коді частіше беру sum чи max, бо вони читаються краще. Distinct працює через equals і hashCode, тож на класі без них нічого не прибере. Sorted — stateful операція, їй треба весь стрім. А skip і limit я використовую для перевірки пагінації: будую очікувану сторінку з повного списку, відсортованого так само, як бекенд, включно з другим полем для однакових значень.
🗣 How to say it out loud I explain reduce as a fold with an initial value and an associative function, but in real code I usually reach for sum or max because they read better. Distinct relies on equals and hashCode, so on a class without them it removes nothing. Sorted is a stateful operation that needs the whole stream. And I use skip and limit to check pagination: I build the expected page from the full list sorted the same way as the backend, including the second field for ties.
57 Що таке Optional і як ним користуватися правильно? What is Optional, and how do you use it correctly?
Лямбди і Stream Lambdas and streams Middle ▶

Що це. Optional<T> — контейнер, у якому є значення або порожнеча. Його додали в Java 8 як тип повернення для методів, що можуть нічого не знайти: findFirst(), max(), findUserByEmail(). Сигнатура одразу каже викликачу «результату може не бути», замість того щоб повертати null і сподіватися на пильність.

What it is. Optional<T> is a container that holds either a value or nothing. It was added in Java 8 as a return type for methods that may find nothing: findFirst(), max(), findUserByEmail(). The signature tells the caller up front "there may be no result", instead of returning null and hoping they are careful.

Як правильно. Використовувати як тип повернення, а не для полів, параметрів методів чи елементів колекцій — там він лише додає шум, а порожній список і так означає «нічого». Діставати значення через orElseThrow(() -> new AssertionError("...")), map, filter, ifPresentOrElse, а не через get(), яке на порожньому Optional кидає NoSuchElementException без жодного контексту.

How to use it. Use it as a return type, not for fields, method parameters or collection elements, where it only adds noise and an empty list already means "nothing". Extract the value with orElseThrow(() -> new AssertionError("...")), map, filter or ifPresentOrElse, not with get(), which throws NoSuchElementException on an empty Optional with no context at all.

orElse проти orElseGet. orElse(value) обчислює аргумент завжди, навіть якщо значення є. orElseGet(supplier) викликає постачальника лише для порожнього Optional. Приклад з проєкту: findUser(email).orElse(createUserViaApi()) створює зайвого користувача в кожному тесті, а з orElseGet — лише коли його справді немає.

orElse versus orElseGet. orElse(value) always evaluates its argument, even when a value is present. orElseGet(supplier) calls the supplier only for an empty Optional. A project example: findUser(email).orElse(createUserViaApi()) creates a spare user in every test, while orElseGet does so only when the user is really missing.

Підводні камені. Повертати null з методу, що має тип Optional, — найгірший варіант. Ніколи не загортати колекцію в Optional: Optional<List<User>> замінюється порожнім списком. І Optional.of(null) кидає NullPointerException — для можливого null є ofNullable.

Pitfalls. Returning null from a method whose type is Optional is the worst option. Never wrap a collection in Optional: Optional<List<User>> is replaced by an empty list. And Optional.of(null) throws NullPointerException; for a possible null there is ofNullable.

Optional у тестовому репозиторії користувачів
Optional in a test user repository
record User(String email, String role) {}

class TestUsers {
    private final List<User> users = new ArrayList<>(List.of(new User("anna@example.com", "ADMIN")));

    Optional<User> findByEmail(String email) {                 // Optional лише як тип повернення
        return users.stream().filter(u -> u.email().equals(email)).findFirst();
    }

    User createViaApi(String email) {
        System.out.println("POST /users " + email);             // дорогий виклик
        User created = new User(email, "USER");
        users.add(created);
        return created;
    }

    User getOrCreate(String email) {
        // orElse(createViaApi(email)) викликав би API завжди
        return findByEmail(email).orElseGet(() -> createViaApi(email));
    }

    String roleOf(String email) {
        return findByEmail(email)
                .map(User::role)
                .orElseThrow(() -> new AssertionError("No test user " + email)); // зрозуміле падіння
    }
}
record User(String email, String role) {}

class TestUsers {
    private final List<User> users = new ArrayList<>(List.of(new User("anna@example.com", "ADMIN")));

    Optional<User> findByEmail(String email) {                 // Optional only as a return type
        return users.stream().filter(u -> u.email().equals(email)).findFirst();
    }

    User createViaApi(String email) {
        System.out.println("POST /users " + email);             // an expensive call
        User created = new User(email, "USER");
        users.add(created);
        return created;
    }

    User getOrCreate(String email) {
        // orElse(createViaApi(email)) would call the API every time
        return findByEmail(email).orElseGet(() -> createViaApi(email));
    }

    String roleOf(String email) {
        return findByEmail(email)
                .map(User::role)
                .orElseThrow(() -> new AssertionError("No test user " + email)); // a readable failure
    }
}
🔧 Розбір синтаксису: findFirst() уже повертає Optional<User>, тож метод просто передає його далі. orElseGet приймає Supplier і викликає createViaApi лише для відсутнього користувача. map(User::role) перетворює вміст, якщо він є, і лишає порожнечу порожньою. orElseThrow з повідомленням дає у звіті тесту зрозумілу причину замість голого NoSuchElementException.
🔧 Syntax breakdown: findFirst() already returns an Optional<User>, so the method simply passes it on. orElseGet takes a Supplier and calls createViaApi only for a missing user. map(User::role) transforms the content if present and leaves an empty Optional empty. orElseThrow with a message puts a readable reason into the test report instead of a bare NoSuchElementException.
💡 Простими словами: Optional — коробка з написом «тут може бути порожньо». Перш ніж дістати вміст, скажи, що робити, якщо коробка порожня.
💡 In plain words: Optional is a box labelled "this may be empty". Before you take out what is inside, say what to do if the box is empty.
🗣 Як читати вголос Я кажу, що Optional — це спосіб чесно сказати в сигнатурі, що результату може не бути, і використовую його тільки як тип повернення, не для полів чи параметрів. Get без перевірки я не пишу: для тестів беру orElseThrow з повідомленням, щоб у звіті була причина. І завжди згадую різницю orElse та orElseGet: перший обчислює запасне значення завжди, тому виклик API всередині orElse створюватиме зайві дані навіть тоді, коли користувач уже є.
🗣 How to say it out loud I say Optional is a way to state honestly in the signature that there may be no result, and I use it only as a return type, not for fields or parameters. I never call get without a check: in tests I use orElseThrow with a message so the report shows the reason. And I always mention the difference between orElse and orElseGet: the first one always evaluates the fallback, so an API call inside orElse creates spare data even when the user already exists.
58 Коли parallelStream справді пришвидшує код і чому він небезпечний у тестах? When does parallelStream actually speed code up, and why is it dangerous in tests?
Лямбди і Stream Lambdas and streams Senior ▶

Що це. parallelStream() або stream().parallel() розбиває джерело на частини й обробляє їх у спільному ForkJoinPool.commonPool(), де потоків зазвичай на один менше, ніж ядер. Той самий пул використовують інші паралельні стріми й CompletableFuture без власного executor, тож він спільний на всю JVM.

What it is. parallelStream() or stream().parallel() splits the source into chunks and processes them in the shared ForkJoinPool.commonPool(), which usually has one thread fewer than there are cores. Other parallel streams and CompletableFuture without its own executor use the same pool, so it is shared by the whole JVM.

Коли допомагає. Коли робота CPU-bound, даних багато (десятки тисяч елементів і більше), джерело добре ділиться (ArrayList, масив, IntStream.range), а операції незалежні й без стану. Наприклад, порахувати хеші для сотень тисяч записів тестового дампу. Для 50 елементів накладні витрати на розбиття зазвичай більші за виграш.

When it helps. When the work is CPU-bound, the data is large (tens of thousands of elements and more), the source splits well (ArrayList, an array, IntStream.range) and the operations are independent and stateless. For example, computing hashes for hundreds of thousands of records in a test data dump. For 50 elements the splitting overhead usually outweighs the gain.

Чому небезпечно в тестах. Блокуючі виклики — HTTP-запити, запити до БД, дії WebDriver — займають потоки спільного пулу, і все інше, що від нього залежить, чекає. WebDriver не потокобезпечний, тому паралельні кліки в одному браузері дають хаос. forEach не зберігає порядок, потрібен forEachOrdered, який з’їдає частину виграшу. А додавання в спільний ArrayList з лямбди — гонка, що губить елементи.

Why it is dangerous in tests. Blocking calls such as HTTP requests, database queries and WebDriver actions occupy threads of the shared pool, and everything else relying on it waits. WebDriver is not thread-safe, so parallel clicks in one browser produce chaos. forEach does not keep order; you need forEachOrdered, which eats part of the gain. And adding to a shared ArrayList from a lambda is a race that loses elements.

Трейдоф. Для паралельних I/O-задач у тестах краще явний ExecutorService з обмеженим пулом (або віртуальні потоки в Java 21): ти контролюєш кількість потоків, таймаути і закриття. parallelStream лишаю для чистих обчислень і лише після вимірювання.

Trade-off. For parallel I/O tasks in tests an explicit ExecutorService with a bounded pool (or virtual threads in Java 21) is better: you control the thread count, timeouts and shutdown. I keep parallelStream for pure computation, and only after measuring.

Помилки parallelStream і керована альтернатива
parallelStream mistakes and a controlled alternative
List<Integer> ids = IntStream.rangeClosed(1, 1_000).boxed().toList();

// Гонка: ArrayList не потокобезпечний, елементи губляться
List<Integer> broken = new ArrayList<>();
ids.parallelStream().forEach(broken::add);

// Правильно: колектор сам безпечно зливає частини
List<Integer> evens = ids.parallelStream().filter(i -> i % 2 == 0).toList(); // порядок збережено

// I/O-виклики: власний пул замість commonPool
try (ExecutorService pool = Executors.newFixedThreadPool(8)) {  // AutoCloseable з Java 19
    List<Future<Integer>> codes = ids.stream().limit(20)
            .map(id -> pool.submit(() -> fetchStatus(id)))
            .toList();
    for (Future<Integer> code : codes) {
        assertEquals(200, code.get(10, TimeUnit.SECONDS));
    }
}

static int fetchStatus(int id) {
    return 200; // тут був би HTTP-запит
}
List<Integer> ids = IntStream.rangeClosed(1, 1_000).boxed().toList();

// Race: ArrayList is not thread-safe, elements get lost
List<Integer> broken = new ArrayList<>();
ids.parallelStream().forEach(broken::add);

// Correct: the collector merges the chunks safely
List<Integer> evens = ids.parallelStream().filter(i -> i % 2 == 0).toList(); // order is preserved

// I/O calls: a dedicated pool instead of commonPool
try (ExecutorService pool = Executors.newFixedThreadPool(8)) {  // AutoCloseable since Java 19
    List<Future<Integer>> codes = ids.stream().limit(20)
            .map(id -> pool.submit(() -> fetchStatus(id)))
            .toList();
    for (Future<Integer> code : codes) {
        assertEquals(200, code.get(10, TimeUnit.SECONDS));
    }
}

static int fetchStatus(int id) {
    return 200; // an HTTP request would go here
}
🔧 Розбір синтаксису: forEach(broken::add) з кількох потоків пише в ArrayList без синхронізації, тож розмір буде меншим за 1000 або вилетить ArrayIndexOutOfBoundsException. toList() на паралельному стрімі зберігає порядок зустрічі. Executors.newFixedThreadPool(8) обмежує паралелізм, submit повертає Future, а get(10, TimeUnit.SECONDS) додає таймаут. try-with-resources для ExecutorService працює з Java 19, і close() чекає завершення задач.
🔧 Syntax breakdown: forEach(broken::add) writes into an ArrayList from several threads without synchronisation, so the size ends up below 1,000 or an ArrayIndexOutOfBoundsException is thrown. toList() on a parallel stream keeps encounter order. Executors.newFixedThreadPool(8) bounds the parallelism, submit returns a Future, and get(10, TimeUnit.SECONDS) adds a timeout. try-with-resources for an ExecutorService works since Java 19, and close() waits for the tasks to finish.
💡 Простими словами: parallelStream — це позичити всіх кухарів ресторану для нарізання салату. Для величезної миски вийде швидше, але поки вони ріжуть, гарячі страви ніхто не готує.
💡 In plain words: parallelStream is borrowing every cook in the restaurant to chop a salad. For a huge bowl it is faster, but while they chop, nobody cooks the hot dishes.
🗣 Як читати вголос Я пояснюю, що parallelStream працює на спільному ForkJoinPool, одному на всю JVM, і дає виграш лише на великих CPU-bound задачах з джерелом, яке легко ділиться. У тестах я його майже не використовую: HTTP-запити й дії WebDriver блокують спільний пул, драйвер не потокобезпечний, а запис у звичайний список з лямбди губить дані. Для паралельних викликів API я беру власний ExecutorService з обмеженою кількістю потоків і таймаутами на Future, бо так я контролюю навантаження і закриття.
🗣 How to say it out loud I explain that parallelStream runs on the shared ForkJoinPool, one for the whole JVM, and pays off only for large CPU-bound work over a source that splits easily. In tests I hardly ever use it: HTTP calls and WebDriver actions block the shared pool, the driver is not thread-safe, and writing into a plain list from a lambda loses data. For parallel API calls I take my own ExecutorService with a bounded number of threads and timeouts on the Futures, because that way I control the load and the shutdown.
▶ 8 8
59 Як створити потік у Java і чим Runnable відрізняється від Callable? How do you create a thread in Java, and how does Runnable differ from Callable?
Потоки Concurrency Junior ▶

Способи створення. Можна успадкувати Thread і перевизначити run(), але так клас «витрачає» єдине успадкування і змішує задачу з механізмом її запуску. Тому звичний шлях — описати задачу як Runnable (часто лямбдою) і передати її в new Thread(task) або, краще, в ExecutorService. Потік стартує лише викликом start(): JVM створює новий потік і вже в ньому викликає run(). Прямий виклик run() просто виконає код у поточному потоці — жодної паралельності.

Ways to create one. You can extend Thread and override run(), but then the class spends its only inheritance slot and mixes the task with the mechanism that runs it. So the usual approach is to describe the task as a Runnable (often a lambda) and pass it to new Thread(task) or, better, to an ExecutorService. A thread starts only when you call start(): the JVM creates a new thread and calls run() inside it. Calling run() directly just executes the code on the current thread — no parallelism at all.

Runnable проти Callable. Runnable.run() нічого не повертає і не може кинути checked-виняток. Callable<V>.call() повертає значення типу V і має throws Exception. Callable не передають у Thread напряму — його віддають пулу через submit() і отримують Future<V> з результатом.

Runnable versus Callable. Runnable.run() returns nothing and cannot throw a checked exception. Callable<V>.call() returns a value of type V and declares throws Exception. A Callable is not passed to Thread directly; it goes to a pool via submit(), which hands back a Future<V> holding the result.

Приклад з проєкту. Перевірка, що сервіс витримує 20 одночасних логінів: кожен логін — Callable<Integer>, який повертає HTTP-статус, а тест збирає статуси з Future і перевіряє, що всі 200.

Project example. Checking that a service handles 20 simultaneous logins: each login is a Callable<Integer> returning the HTTP status, and the test collects the statuses from the Future objects and asserts they are all 200.

Віртуальні потоки Java 21. Thread.ofVirtual().start(task) і Executors.newVirtualThreadPerTaskExecutor() створюють дешеві потоки, яких можна запускати тисячі для I/O-задач на кшталт HTTP-викликів. Підводний камінь — вони не прискорюють обчислення і не скасовують потребу в синхронізації спільного стану.

Java 21 virtual threads. Thread.ofVirtual().start(task) and Executors.newVirtualThreadPerTaskExecutor() create cheap threads you can start by the thousand for I/O work such as HTTP calls. The pitfall: they do not speed up computation and do not remove the need to synchronise shared state.

Thread, Runnable, Callable і віртуальний потік
Thread, Runnable, Callable and a virtual thread
public class ThreadBasics {
    public static void main(String[] args) throws Exception {
        Runnable ping = () -> System.out.println("ping from " + Thread.currentThread().getName());

        Thread worker = new Thread(ping, "worker-1");
        worker.start();   // новий потік
        worker.join();    // чекаємо завершення
        ping.run();       // виконується в main, не паралельно

        Callable<Integer> login = () -> 200; // повертає результат, може кинути Exception
        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            Future<Integer> status = pool.submit(login);
            System.out.println("status = " + status.get());
        }

        Thread vt = Thread.ofVirtual().start(ping); // Java 21
        vt.join();
    }
}
public class ThreadBasics {
    public static void main(String[] args) throws Exception {
        Runnable ping = () -> System.out.println("ping from " + Thread.currentThread().getName());

        Thread worker = new Thread(ping, "worker-1");
        worker.start();   // a new thread
        worker.join();    // wait for it to finish
        ping.run();       // runs on main, not in parallel

        Callable<Integer> login = () -> 200; // returns a result, may throw Exception
        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            Future<Integer> status = pool.submit(login);
            System.out.println("status = " + status.get());
        }

        Thread vt = Thread.ofVirtual().start(ping); // Java 21
        vt.join();
    }
}
🔧 Розбір синтаксису: new Thread(ping, "worker-1") задає задачу й ім’я потоку, яке потім видно в логах і thread dump. join() блокує поточний потік до завершення іншого. pool.submit(login) приймає Callable і повертає Future<Integer>; get() чекає на результат. ExecutorService з Java 19 реалізує AutoCloseable, тому try-with-resources сам дочекається задач і закриє пул.
🔧 Syntax breakdown: new Thread(ping, "worker-1") sets the task and the thread name, which then shows up in logs and thread dumps. join() blocks the current thread until the other one finishes. pool.submit(login) accepts a Callable and returns a Future<Integer>; get() waits for the result. Since Java 19 ExecutorService implements AutoCloseable, so try-with-resources waits for the tasks and closes the pool itself.
💡 Простими словами: Runnable — доручення «зроби», Callable — доручення «зроби й принеси відповідь». А start() — це реально відправити кур’єра, тоді як run() — піти самому.
💡 In plain words: Runnable is an errand saying "do it", Callable is one saying "do it and bring back the answer". And start() actually sends the courier out, while run() means going yourself.
🗣 Як читати вголос Я кажу, що успадковувати Thread майже ніколи не варто: задачу я описую як Runnable або Callable, а запуск віддаю пулу. Різницю між ними пам’ятаю так: Callable повертає значення і може кинути checked-виняток, тому його результат я забираю через Future. Окремо завжди наголошую на пастці start проти run — прямий run нічого не розпаралелює. А для I/O-навантаження в Java 21 я б узяв віртуальні потоки, пам’ятаючи, що гонки вони не прибирають.
🗣 How to say it out loud I say that extending Thread is almost never worth it: I describe the task as a Runnable or a Callable and leave the running to a pool. I remember the difference this way: a Callable returns a value and may throw a checked exception, so I pick up its result through a Future. I always point out the start versus run trap as well, since calling run directly parallelises nothing. And for I/O-heavy work on Java 21 I would use virtual threads, keeping in mind they do not remove races.
60 Що таке ExecutorService і Future і як ними користуватись у тестах? What are ExecutorService and Future, and how do you use them in tests?
Потоки Concurrency Middle ▶

Що це. ExecutorService — пул потоків, якому віддають задачі замість того, щоб створювати Thread вручну. Пул перевикористовує потоки, обмежує паралельність і дає керований життєвий цикл: shutdown() перестає приймати нові задачі, awaitTermination() чекає на поточні, shutdownNow() перериває їх. Future — квитанція на результат: get() блокує до готовності, get(timeout, unit) кидає TimeoutException, якщо не встигли, а виняток із задачі приходить загорнутим у ExecutionException.

What it is. An ExecutorService is a thread pool you hand tasks to instead of creating a Thread by hand. The pool reuses threads, caps parallelism and gives a managed lifecycle: shutdown() stops accepting new tasks, awaitTermination() waits for the running ones, shutdownNow() interrupts them. A Future is a receipt for a result: get() blocks until it is ready, get(timeout, unit) throws TimeoutException if it is late, and an exception thrown by the task arrives wrapped in an ExecutionException.

Навіщо в автоматизації. Підготовка даних: створити 100 тестових користувачів через API послідовно — хвилина, пулом на 10 потоків — кілька секунд. invokeAll() приймає колекцію Callable і повертає список Future у тому ж порядку.

Why automation needs it. Test data setup: creating 100 test users through the API one by one takes a minute, with a pool of 10 threads it takes a few seconds. invokeAll() takes a collection of Callable tasks and returns a list of Future objects in the same order.

Приклад з проєкту. У @BeforeAll сідимо користувачів пулом з 10 потоків, кожен Future читаємо з таймаутом 30 секунд, щоб зависла відповідь сервісу не повісила весь прогін.

Project example. In @BeforeAll we seed users with a 10-thread pool and read each Future with a 30-second timeout, so one hanging service response cannot freeze the whole run.

Підводні камені. Забутий shutdown() лишає живі потоки, і JVM з тестами не завершується. Розмір пулу треба узгоджувати з тестовим середовищем: 200 потоків проти стенду, що тримає 20 з’єднань, дадуть не швидкість, а 503. І не ігноруйте Future: якщо ніхто не викликав get(), виняток задачі просто загубиться.

Pitfalls. A forgotten shutdown() leaves live threads behind, and the test JVM never exits. Pool size must match the test environment: 200 threads against a stand that holds 20 connections give you 503s, not speed. And do not ignore a Future: if nobody calls get(), the task's exception is silently lost.

Паралельне створення тестових користувачів
Creating test users in parallel
List<Callable<String>> tasks = IntStream.rangeClosed(1, 100)
        .mapToObj(i -> (Callable<String>) () -> userApi.createUser("qa_user_" + i))
        .toList();

ExecutorService pool = Executors.newFixedThreadPool(10); // не більше 10 запитів одночасно
try {
    List<Future<String>> results = pool.invokeAll(tasks);
    for (Future<String> f : results) {
        String userId = f.get(30, TimeUnit.SECONDS); // таймаут на кожен результат
        createdIds.add(userId);
    }
} catch (ExecutionException e) {
    throw new IllegalStateException("User seeding failed", e.getCause()); // справжня причина
} finally {
    pool.shutdown();
    pool.awaitTermination(1, TimeUnit.MINUTES);
}
List<Callable<String>> tasks = IntStream.rangeClosed(1, 100)
        .mapToObj(i -> (Callable<String>) () -> userApi.createUser("qa_user_" + i))
        .toList();

ExecutorService pool = Executors.newFixedThreadPool(10); // at most 10 requests at once
try {
    List<Future<String>> results = pool.invokeAll(tasks);
    for (Future<String> f : results) {
        String userId = f.get(30, TimeUnit.SECONDS); // a timeout per result
        createdIds.add(userId);
    }
} catch (ExecutionException e) {
    throw new IllegalStateException("User seeding failed", e.getCause()); // the real cause
} finally {
    pool.shutdown();
    pool.awaitTermination(1, TimeUnit.MINUTES);
}
🔧 Розбір синтаксису: IntStream.rangeClosed(1, 100) дає числа від 1 до 100, а приведення (Callable<String>) підказує компілятору тип лямбди. invokeAll чекає, доки всі задачі завершаться, і повертає Future у порядку задач. get(30, TimeUnit.SECONDS) обмежує очікування. e.getCause() дістає справжній виняток задачі з ExecutionException. Блок finally гарантує закриття пулу навіть при падінні.
🔧 Syntax breakdown: IntStream.rangeClosed(1, 100) yields the numbers 1 to 100, and the (Callable<String>) cast tells the compiler the lambda type. invokeAll waits until every task finishes and returns the Future list in task order. get(30, TimeUnit.SECONDS) bounds the wait. e.getCause() extracts the task's real exception from the ExecutionException. The finally block guarantees the pool is closed even on failure.
💡 Простими словами: ExecutorService — це бригада з фіксованою кількістю робітників, а Future — номерок у гардеробі: за ним ти потім забираєш результат або дізнаєшся, що щось пішло не так.
💡 In plain words: An ExecutorService is a crew with a fixed number of workers, and a Future is a cloakroom ticket: you use it later to collect the result or to learn that something went wrong.
🗣 Як читати вголос У тестах я використовую ExecutorService переважно для підготовки даних: коли треба створити сотню користувачів чи замовлень через API, пул на десять потоків скорочує це з хвилини до секунд. Результати забираю через Future з таймаутом, щоб одна зависла відповідь не повісила прогін, і розгортаю ExecutionException до справжньої причини. Пул завжди закриваю у finally або через try-with-resources, а його розмір підбираю під можливості стенду, а не під кількість ядер.
🗣 How to say it out loud In tests I mostly use ExecutorService for data setup: when I need a hundred users or orders created through the API, a pool of ten threads cuts that from a minute to seconds. I collect results through Futures with a timeout so one hanging response cannot freeze the run, and I unwrap ExecutionException down to the real cause. I always close the pool in finally or with try-with-resources, and I size it to what the test stand can handle rather than to the number of cores.
61 Що таке race condition і як synchronized її прибирає? What is a race condition, and how does synchronized remove it?
Потоки Concurrency Middle ▶

Що це. Race condition — ситуація, коли результат залежить від того, в якому порядку потоки встигли виконати свої кроки. Класика — counter++: це три операції (прочитати, додати, записати), і два потоки можуть прочитати те саме значення, тож одне збільшення губиться. Помилка не відтворюється стабільно, і саме тому її так важко ловити.

What it is. A race condition is when the outcome depends on the order in which threads happened to run their steps. The classic case is counter++: it is three operations (read, add, write), and two threads can read the same value, so one increment is lost. The bug does not reproduce reliably, which is exactly why it is so hard to catch.

Як працює synchronized. Кожен об’єкт у Java має вбудований монітор (intrinsic lock). synchronized-метод захоплює монітор this (або класу, якщо метод статичний), а блок synchronized (lock) { ... } — монітор вказаного об’єкта. Лише один потік одночасно виконує код під тим самим монітором, а при виході з блоку зміни гарантовано видно наступному потоку, що захопить цей монітор.

How synchronized works. Every Java object has a built-in monitor (an intrinsic lock). A synchronized method takes the monitor of this (or of the class for a static method), and a synchronized (lock) { ... } block takes the monitor of the given object. Only one thread at a time runs code under the same monitor, and on leaving the block its changes are guaranteed to be visible to the next thread that acquires that monitor.

Приклад з проєкту. Після ввімкнення паралельного JUnit 5 власний звіт почав губити рядки: кілька потоків писали в спільний ArrayList і StringBuilder. Обгортання запису в synchronized блок на приватному lock-об’єкті зробило звіт повним.

Project example. After parallel JUnit 5 was switched on, a custom report started losing lines: several threads wrote into a shared ArrayList and StringBuilder. Wrapping the write in a synchronized block on a private lock object made the report complete again.

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

Pitfalls. Synchronising on different objects protects nothing: all threads must take the same monitor. Keep the critical section minimal — an HTTP call or a screenshot under the lock turns a parallel run into a sequential one. And do not synchronise on String literals or public objects that foreign code might also lock.

Спільний звіт, захищений приватним локом
A shared report guarded by a private lock
public class RunReport {
    private final Object lock = new Object();
    private final List<String> lines = new ArrayList<>();
    private int failed;

    public void record(String testName, boolean passed) {
        String line = testName + (passed ? " PASSED" : " FAILED"); // готуємо поза локом
        synchronized (lock) {          // один потік за раз
            lines.add(line);
            if (!passed) {
                failed++;              // тепер це безпечно
            }
        }
    }

    public int failedCount() {
        synchronized (lock) {          // читаємо під тим самим локом
            return failed;
        }
    }
}
public class RunReport {
    private final Object lock = new Object();
    private final List<String> lines = new ArrayList<>();
    private int failed;

    public void record(String testName, boolean passed) {
        String line = testName + (passed ? " PASSED" : " FAILED"); // prepared outside the lock
        synchronized (lock) {          // one thread at a time
            lines.add(line);
            if (!passed) {
                failed++;              // now this is safe
            }
        }
    }

    public int failedCount() {
        synchronized (lock) {          // read under the same lock
            return failed;
        }
    }
}
🔧 Розбір синтаксису: private final Object lock — окремий монітор, до якого не дістанеться зовнішній код. Рядок будується до входу в synchronized, тож під локом лише дві дешеві операції. Читання в failedCount() теж іде під lock: модифікатор synchronized на методі взяв би інший монітор, this, і не гарантував би видимість останнього значення.
🔧 Syntax breakdown: private final Object lock is a dedicated monitor outside code cannot reach. The line is built before entering synchronized, so only two cheap operations run under the lock. The read in failedCount() also happens under lock: a synchronized modifier on the method would take a different monitor, this, and would not guarantee visibility of the latest value.
💡 Простими словами: synchronized — це ключ від єдиної примірочної: заходить один, решта чекають. Але якщо в кожного свій ключ від різних кабінок, черги немає і захисту теж.
💡 In plain words: synchronized is the key to the only fitting room: one person goes in, the rest wait. But if everyone holds a key to a different booth, there is no queue and no protection either.
🗣 Як читати вголос Я пояснюю гонку на прикладі counter++, бо це не одна операція, а три, і між ними може вклинитися інший потік. synchronized дає взаємне виключення на моніторі об’єкта і водночас гарантує видимість змін. На практиці я стикався з цим, коли паралельні тести губили рядки у спільному звіті. Моє правило — синхронізуватися на приватному final-об’єкті, тримати під локом лише мінімум і нічого повільного на кшталт HTTP чи скріншотів.
🗣 How to say it out loud I explain a race with counter++, because it is not one operation but three, and another thread can slip in between them. synchronized gives mutual exclusion on an object's monitor and at the same time guarantees that changes become visible. In practice I hit this when parallel tests were losing lines in a shared report. My rule is to synchronise on a private final object, keep only the bare minimum under the lock and nothing slow such as HTTP calls or screenshots.
62 Чим volatile відрізняється від AtomicInteger? How does volatile differ from AtomicInteger?
Потоки Concurrency Middle ▶

volatile. Модифікатор поля, який гарантує видимість: запис одного потоку одразу стає видимим іншим, а JIT не може «закешувати» значення в регістрі. Атомарності він не дає: volatileCounter++ так само губить збільшення, як і звичайне поле. Типове правильне застосування — прапорець, який пише один потік, а читають інші, наприклад volatile boolean stopRequested для фонового потоку, що опитує стан сервісу.

volatile. A field modifier that guarantees visibility: a write by one thread becomes visible to others immediately, and the JIT cannot keep the value cached in a register. It gives no atomicity: volatileCounter++ loses increments just like a plain field. The typical correct use is a flag written by one thread and read by others, such as volatile boolean stopRequested for a background thread polling service health.

AtomicInteger. Клас з java.util.concurrent.atomic, що дає атомарні операції без блокувань: incrementAndGet(), addAndGet(), compareAndSet(). Під капотом — інструкція процесора CAS (compare-and-swap): «запиши нове значення, лише якщо поточне досі дорівнює очікуваному», з повтором при невдачі. Видимість він теж гарантує.

AtomicInteger. A class from java.util.concurrent.atomic offering lock-free atomic operations: incrementAndGet(), addAndGet(), compareAndSet(). Underneath is the CPU compare-and-swap (CAS) instruction: "write the new value only if the current one still equals the expected one", retried on failure. It guarantees visibility too.

Приклад з проєкту. Лічильник упалих тестів у слухачі при паралельному прогоні — AtomicInteger. Лічильники по класах — ConcurrentHashMap<String, AtomicInteger> з computeIfAbsent, або merge(key, 1, Integer::sum), що теж атомарний для одного ключа.

Project example. A counter of failed tests in a listener during a parallel run is an AtomicInteger. Per-class counters are a ConcurrentHashMap<String, AtomicInteger> with computeIfAbsent, or merge(key, 1, Integer::sum), which is also atomic for a single key.

Підводні камені. Дві атомарні операції поспіль не утворюють атомарну пару: «перевірити, потім збільшити» через get() і incrementAndGet() — знову гонка, для цього є compareAndSet або updateAndGet. А volatile на посиланні на ArrayList не робить сам список потокобезпечним.

Pitfalls. Two atomic operations in a row do not form an atomic pair: "check, then increment" through get() and incrementAndGet() is a race again, which is what compareAndSet or updateAndGet exist for. And volatile on a reference to an ArrayList does not make the list itself thread-safe.

Прапорець зупинки та лічильник падінь
A stop flag and a failure counter
public class RunStats {
    private volatile boolean stopRequested;                 // лише видимість
    private final AtomicInteger failed = new AtomicInteger();
    private final Map<String, Integer> failedByClass = new ConcurrentHashMap<>();

    public void onFailure(String testClass) {
        failed.incrementAndGet();                           // атомарно, без локів
        failedByClass.merge(testClass, 1, Integer::sum);    // атомарно для одного ключа
    }

    public void startHealthPoller(Runnable ping) {
        Thread poller = new Thread(() -> {
            while (!stopRequested) {                        // бачить запис з іншого потоку
                ping.run();
            }
        });
        poller.setDaemon(true);
        poller.start();
    }

    public void stop() { stopRequested = true; }
    public int failedCount() { return failed.get(); }
}
public class RunStats {
    private volatile boolean stopRequested;                 // visibility only
    private final AtomicInteger failed = new AtomicInteger();
    private final Map<String, Integer> failedByClass = new ConcurrentHashMap<>();

    public void onFailure(String testClass) {
        failed.incrementAndGet();                           // atomic, lock-free
        failedByClass.merge(testClass, 1, Integer::sum);    // atomic per key
    }

    public void startHealthPoller(Runnable ping) {
        Thread poller = new Thread(() -> {
            while (!stopRequested) {                        // sees the write from another thread
                ping.run();
            }
        });
        poller.setDaemon(true);
        poller.start();
    }

    public void stop() { stopRequested = true; }
    public int failedCount() { return failed.get(); }
}
🔧 Розбір синтаксису: Без volatile цикл while (!stopRequested) міг би ніколи не побачити true, бо JIT має право прочитати поле один раз. incrementAndGet() збільшує і повертає нове значення атомарно. merge(testClass, 1, Integer::sum) кладе 1, якщо ключа немає, інакше додає 1 до наявного. setDaemon(true) не дає фоновому потоку тримати JVM після завершення тестів.
🔧 Syntax breakdown: Without volatile the while (!stopRequested) loop might never see true, because the JIT may read the field only once. incrementAndGet() increments and returns the new value atomically. merge(testClass, 1, Integer::sum) puts 1 when the key is absent and otherwise adds 1 to the existing value. setDaemon(true) stops the background thread from keeping the JVM alive after the tests end.
💡 Простими словами: volatile — табло, яке всі бачать одразу після зміни. AtomicInteger — табло з турнікетом: воно ще й не дасть двом людям одночасно переписати ту саму цифру.
💡 In plain words: volatile is a scoreboard everyone sees as soon as it changes. AtomicInteger is a scoreboard with a turnstile: it also stops two people from rewriting the same digit at once.
🗣 Як читати вголос Я розводжу ці поняття так: volatile — це про видимість, а атомарні класи — про видимість плюс атомарність. volatile ідеально підходить для прапорця зупинки, який пише один потік, але для лічильника він не годиться, бо інкремент складається з кількох кроків. Для лічильника падінь у паралельному прогоні я беру AtomicInteger, а для підрахунку по класах — ConcurrentHashMap з merge. І пам’ятаю, що дві атомарні операції поспіль уже не атомарні разом.
🗣 How to say it out loud I separate them like this: volatile is about visibility, while the atomic classes give visibility plus atomicity. volatile is perfect for a stop flag written by one thread, but it is no good for a counter, because an increment is several steps. For a failure counter in a parallel run I use AtomicInteger, and for per-class counts a ConcurrentHashMap with merge. And I remember that two atomic operations in a row are no longer atomic together.
63 Що таке deadlock, як його знайти в паралельному прогоні і як запобігти? What is a deadlock, how do you find it in a parallel run, and how do you prevent it?
Потоки Concurrency Senior ▶

Що це. Deadlock — стан, коли два або більше потоків чекають один на одного вічно. Для нього мають виконатися чотири умови Коффмана: взаємне виключення (ресурс має одного власника), утримання з очікуванням (потік тримає один лок і чекає інший), відсутність примусового відбирання і циклічне очікування. Класика — потік A взяв lockAccounts і чекає lockOrders, а потік B взяв їх у зворотному порядку.

What it is. A deadlock is a state where two or more threads wait for each other forever. It needs all four Coffman conditions: mutual exclusion (a resource has one owner), hold and wait (a thread holds one lock and waits for another), no preemption, and circular wait. The classic: thread A has taken lockAccounts and waits for lockOrders, while thread B took them in the opposite order.

Як виглядає в автоматизації. CI-джоба з паралельними тестами не падає, а висить до глобального таймауту, процес споживає 0% CPU. У фреймворку це часто два синхронізовані менеджери (пул тестових акаунтів і пул браузерів), які беруть ресурси в різному порядку.

What it looks like in automation. A CI job with parallel tests does not fail but hangs until the global timeout, with the process at 0% CPU. In a framework it is often two synchronised managers (a pool of test accounts and a pool of browsers) that acquire resources in different orders.

Як знайти. Знімаю thread dump: jstack <pid> або jcmd <pid> Thread.print. JVM сама шукає цикли на моніторах і ReentrantLock і друкує блок «Found one Java-level deadlock» з потоками та локами, на яких вони застрягли. Корисно, щоб CI при таймауті автоматично знімав dump перед вбиванням процесу.

How to find it. I take a thread dump with jstack <pid> or jcmd <pid> Thread.print. The JVM itself searches for cycles on monitors and ReentrantLocks and prints a "Found one Java-level deadlock" block naming the threads and the locks they are stuck on. It pays to make CI take a dump automatically on timeout before killing the process.

Як запобігти і трейдофи. Найнадійніше — глобальний порядок захоплення локів: усі беруть спочатку акаунт, потім браузер. Друге — tryLock(timeout) з ReentrantLock: потік відпускає свій лок і повторює спробу, але це ускладнює код і може дати livelock. Найкраще — зменшити спільний стан, щоб локів було менше: окремі дані на потік, неблокуючі черги.

Prevention and trade-offs. The most reliable fix is a global lock order: everyone takes the account first, then the browser. The second is tryLock(timeout) on a ReentrantLock: the thread releases what it holds and retries, but the code gets more complex and can livelock. Best of all is shrinking shared state so there are fewer locks: per-thread data, non-blocking queues.

Зворотний порядок локів і виправлення через tryLock
Opposite lock order and a tryLock fix
private final ReentrantLock accounts = new ReentrantLock();
private final ReentrantLock browsers = new ReentrantLock();

// Потік 1: accounts -> browsers; потік 2: browsers -> accounts  => deadlock

public boolean reserveBoth(Duration wait) throws InterruptedException {
    // Правило: завжди спочатку accounts, потім browsers
    if (!accounts.tryLock(wait.toMillis(), TimeUnit.MILLISECONDS)) {
        return false;
    }
    try {
        if (!browsers.tryLock(wait.toMillis(), TimeUnit.MILLISECONDS)) {
            return false;                  // не чекаємо вічно
        }
        try {
            // бронюємо акаунт і браузер
            return true;
        } finally {
            browsers.unlock();
        }
    } finally {
        accounts.unlock();
    }
}
// jcmd <pid> Thread.print  ->  "Found one Java-level deadlock:"
private final ReentrantLock accounts = new ReentrantLock();
private final ReentrantLock browsers = new ReentrantLock();

// Thread 1: accounts -> browsers; thread 2: browsers -> accounts  => deadlock

public boolean reserveBoth(Duration wait) throws InterruptedException {
    // Rule: always accounts first, then browsers
    if (!accounts.tryLock(wait.toMillis(), TimeUnit.MILLISECONDS)) {
        return false;
    }
    try {
        if (!browsers.tryLock(wait.toMillis(), TimeUnit.MILLISECONDS)) {
            return false;                  // we never wait forever
        }
        try {
            // reserve an account and a browser
            return true;
        } finally {
            browsers.unlock();
        }
    } finally {
        accounts.unlock();
    }
}
// jcmd <pid> Thread.print  ->  "Found one Java-level deadlock:"
🔧 Розбір синтаксису: ReentrantLock.tryLock(time, unit) чекає лок обмежений час і повертає false замість вічного блокування. Кожен lock чи успішний tryLock має парний unlock() у finally, інакше лок витече після винятку. Фіксований порядок accounts → browsers прибирає циклічне очікування, а таймаут страхує від місць, де порядок хтось порушив.
🔧 Syntax breakdown: ReentrantLock.tryLock(time, unit) waits for the lock for a bounded time and returns false instead of blocking forever. Every lock or successful tryLock has a matching unlock() in finally, otherwise the lock leaks after an exception. The fixed accounts → browsers order removes circular wait, and the timeout guards against places where someone broke that order.
💡 Простими словами: Deadlock — двоє у вузьких дверях, кожен чемно чекає, поки пройде інший. Правило «спочатку заходить той, хто ліворуч» прибирає проблему назавжди.
💡 In plain words: A deadlock is two people in a narrow doorway, each politely waiting for the other to go first. A rule like "the one on the left goes first" removes the problem for good.
🗣 Як читати вголос Deadlock я впізнаю за симптомом: паралельний прогін не падає, а висить з нульовим завантаженням процесора. Першим ділом знімаю thread dump через jstack чи jcmd — JVM сама знаходить цикл і пише, які потоки на яких локах застрягли. Причина майже завжди в тому, що дві частини фреймворку беруть ті самі ресурси в різному порядку. Лікую фіксованим порядком захоплення, як страховку додаю tryLock з таймаутом, а в ідеалі прибираю зайвий спільний стан.
🗣 How to say it out loud I recognise a deadlock by its symptom: a parallel run does not fail, it hangs with zero CPU usage. The first thing I do is take a thread dump with jstack or jcmd, and the JVM itself finds the cycle and reports which threads are stuck on which locks. The cause is almost always two parts of the framework acquiring the same resources in different orders. I cure it with a fixed acquisition order, add tryLock with a timeout as insurance, and ideally remove the unnecessary shared state.
64 Що таке ThreadLocal і чому WebDriver у паралельних тестах тримають саме в ньому? What is ThreadLocal, and why is WebDriver kept in it for parallel tests?
Потоки Concurrency Middle ▶

Що це. ThreadLocal<T> — змінна, у якої кожен потік має власну копію значення. set() записує значення для поточного потоку, get() його читає, remove() видаляє, а ThreadLocal.withInitial(supplier) задає, як створити значення при першому get() у потоці.

What it is. A ThreadLocal<T> is a variable where every thread has its own copy of the value. set() stores a value for the current thread, get() reads it, remove() deletes it, and ThreadLocal.withInitial(supplier) defines how to create the value on the first get() in a thread.

Навіщо для WebDriver. Екземпляр WebDriver не потокобезпечний: два тести, що одночасно клікають одним драйвером, ламають один одному сесію. При паралельному запуску кожен потік раннера виконує свій тест, тож ThreadLocal<WebDriver> дає кожному тесту власний браузер, при цьому Page Object і кроки можуть звертатися до драйвера через статичний DriverManager.get() без передачі параметром.

Why for WebDriver. A WebDriver instance is not thread-safe: two tests clicking through one driver at the same time break each other's session. In a parallel run each runner thread executes its own test, so a ThreadLocal<WebDriver> gives each test its own browser, while Page Objects and steps can reach the driver through a static DriverManager.get() without passing it around.

Приклад з проєкту. @BeforeEach створює драйвер і кладе в ThreadLocal, @AfterEach викликає quit() і обов’язково remove(). Selenide робить те саме всередині: драйвер прив’язаний до потоку.

Project example. @BeforeEach creates the driver and puts it into the ThreadLocal, @AfterEach calls quit() and always remove(). Selenide does the same thing internally: the driver is bound to the thread.

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

Pitfalls. Runner threads live in a pool and are reused. Without remove() the next test on that thread receives the old, already closed driver, and the object itself is never collected, which is a memory leak. InheritableThreadLocal copies values to child threads, but that does not help with pools: a thread is created once and the value gets frozen. If a test launches an asynchronous task itself, the driver inside it will be a different one or missing.

Мінімальний DriverManager на ThreadLocal
A minimal ThreadLocal-based DriverManager
public final class DriverManager {
    private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();

    private DriverManager() { }

    public static void start() {
        DRIVER.set(new ChromeDriver());          // свій браузер для кожного потоку
    }

    public static WebDriver get() {
        WebDriver driver = DRIVER.get();
        if (driver == null) {
            throw new IllegalStateException("Driver not started in " + Thread.currentThread().getName());
        }
        return driver;
    }

    public static void stop() {
        WebDriver driver = DRIVER.get();
        try {
            if (driver != null) driver.quit();
        } finally {
            DRIVER.remove();                     // інакше потік пулу збереже мертвий драйвер
        }
    }
}
public final class DriverManager {
    private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();

    private DriverManager() { }

    public static void start() {
        DRIVER.set(new ChromeDriver());          // its own browser for every thread
    }

    public static WebDriver get() {
        WebDriver driver = DRIVER.get();
        if (driver == null) {
            throw new IllegalStateException("Driver not started in " + Thread.currentThread().getName());
        }
        return driver;
    }

    public static void stop() {
        WebDriver driver = DRIVER.get();
        try {
            if (driver != null) driver.quit();
        } finally {
            DRIVER.remove();                     // otherwise the pool thread keeps a dead driver
        }
    }
}
🔧 Розбір синтаксису: static final ThreadLocal — одне поле на клас, але значення в ньому окреме для кожного потоку. get() кидає зрозумілий виняток з іменем потоку замість NullPointerException десь у Page Object. У stop() remove() стоїть у finally, тож навіть якщо quit() впаде, посилання на драйвер буде прибране з потоку.
🔧 Syntax breakdown: A static final ThreadLocal is one field per class, yet the value inside it is separate for every thread. get() throws a clear exception naming the thread instead of a NullPointerException somewhere in a Page Object. In stop() the remove() call sits in finally, so even if quit() fails, the driver reference is cleared from the thread.
💡 Простими словами: ThreadLocal — це персональна шафка для кожного працівника з однаковим номером на дверцятах. Але якщо не спорожнити шафку, наступна зміна знайде в ній чужі речі.
💡 In plain words: A ThreadLocal is a personal locker for each worker with the same number on every door. But if you do not empty the locker, the next shift finds someone else's stuff in it.
🗣 Як читати вголос Я пояснюю ThreadLocal як змінну з окремим значенням для кожного потоку. Для паралельних UI-тестів це природне місце для WebDriver, бо сам драйвер не потокобезпечний, а так кожен тест має свій браузер і Page Object можуть брати його через статичний метод. Головне, на чому я наголошую, — remove в AfterEach. Раннер перевикористовує потоки, і без remove наступний тест отримає закритий драйвер, а пам’ять потече.
🗣 How to say it out loud I describe ThreadLocal as a variable with a separate value for each thread. For parallel UI tests it is the natural home for WebDriver, because the driver itself is not thread-safe, and this way every test has its own browser and Page Objects can fetch it through a static method. What I stress most is remove in AfterEach. The runner reuses threads, and without remove the next test gets a closed driver and memory leaks.
65 Коли в тестовому фреймворку варто брати CompletableFuture і які в нього пастки? When is CompletableFuture worth using in a test framework, and what are its traps?
Потоки Concurrency Senior ▶

Що це. CompletableFuture<T> — майбутній результат, до якого можна чіпляти подальші кроки без блокування: thenApply перетворює результат, thenCompose запускає наступний асинхронний виклик, thenCombine поєднує два незалежні результати, allOf чекає на групу. Помилки обробляють exceptionally і handle, а orTimeout (з Java 9) завершує future винятком, якщо відповіді немає вчасно.

What it is. A CompletableFuture<T> is a future result you can attach further steps to without blocking: thenApply transforms the result, thenCompose launches the next asynchronous call, thenCombine joins two independent results, allOf waits for a group. Errors are handled with exceptionally and handle, and orTimeout (since Java 9) completes the future exceptionally if no answer arrives in time.

Коли це виправдано. Підготовка тесту залежить від трьох незалежних сервісів: створити користувача, отримати токен каталогу, завантажити налаштування фіч. Послідовно — 3 × 800 мс, паралельно — близько 800 мс, а на тисячі тестів це хвилини прогону. Пул задають явно через supplyAsync(supplier, executor): без нього використовується ForkJoinPool.commonPool(), розрахований на обчислення, і блокуючі HTTP-виклики можуть його виснажити.

When it pays off. A test's setup depends on three independent services: create a user, get a catalogue token, load feature settings. Sequentially that is 3 × 800 ms, in parallel about 800 ms, and across thousands of tests it adds up to minutes of run time. Set the pool explicitly with supplyAsync(supplier, executor): without it ForkJoinPool.commonPool() is used, which is sized for computation, and blocking HTTP calls can starve it.

Трейдофи. Для «запусти N однакових задач і збери результати» простіше ExecutorService.invokeAll. CompletableFuture окупається, коли є граф залежностей між кроками. Але стек-трейси стають рваними, а налагодження складнішим, тож у тестовому коді ланцюжки краще тримати короткими.

Trade-offs. For "run N identical tasks and collect the results" ExecutorService.invokeAll is simpler. CompletableFuture earns its keep when there is a graph of dependencies between steps. But stack traces become fragmented and debugging gets harder, so in test code the chains should stay short.

Підводні камені. join() кидає неперевірений CompletionException, get() — checked ExecutionException; справжня причина в getCause(). Future, який ніхто не чекає, ковтає виняток мовчки. А exceptionally, що повертає значення за замовчуванням, легко ховає реальну поломку середовища за «зеленим» сетапом.

Pitfalls. join() throws an unchecked CompletionException, get() a checked ExecutionException; the real reason is in getCause(). A future nobody waits on swallows its exception silently. And an exceptionally that returns a default value easily hides a genuinely broken environment behind a "green" setup.

Паралельна підготовка даних з трьох API
Preparing data from three APIs in parallel
ExecutorService io = Executors.newFixedThreadPool(8); // свій пул для I/O

CompletableFuture<User> user = CompletableFuture
        .supplyAsync(() -> usersApi.create("qa_buyer"), io)
        .orTimeout(5, TimeUnit.SECONDS);
CompletableFuture<String> token = CompletableFuture
        .supplyAsync(() -> catalogApi.issueToken(), io)
        .orTimeout(5, TimeUnit.SECONDS);
CompletableFuture<Flags> flags = CompletableFuture
        .supplyAsync(() -> flagsApi.load("checkout-v2"), io)
        .orTimeout(5, TimeUnit.SECONDS);

TestContext ctx = user
        .thenCombine(token, (u, t) -> new TestContext(u, t, null))
        .thenCombine(flags, (c, f) -> new TestContext(c.user(), c.token(), f))
        .join();  // CompletionException, якщо хоч один крок упав

record TestContext(User user, String token, Flags flags) { }
ExecutorService io = Executors.newFixedThreadPool(8); // a dedicated I/O pool

CompletableFuture<User> user = CompletableFuture
        .supplyAsync(() -> usersApi.create("qa_buyer"), io)
        .orTimeout(5, TimeUnit.SECONDS);
CompletableFuture<String> token = CompletableFuture
        .supplyAsync(() -> catalogApi.issueToken(), io)
        .orTimeout(5, TimeUnit.SECONDS);
CompletableFuture<Flags> flags = CompletableFuture
        .supplyAsync(() -> flagsApi.load("checkout-v2"), io)
        .orTimeout(5, TimeUnit.SECONDS);

TestContext ctx = user
        .thenCombine(token, (u, t) -> new TestContext(u, t, null))
        .thenCombine(flags, (c, f) -> new TestContext(c.user(), c.token(), f))
        .join();  // CompletionException if any step failed

record TestContext(User user, String token, Flags flags) { }
🔧 Розбір синтаксису: supplyAsync(..., io) запускає виклик у нашому пулі, а не в commonPool. orTimeout обмежує кожен виклик окремо. thenCombine чекає обидва future і передає їхні результати в функцію; ланцюжок з двох thenCombine зводить три результати в один record. join() блокує лише в кінці, коли дані справді потрібні тесту.
🔧 Syntax breakdown: supplyAsync(..., io) runs the call on our pool rather than the commonPool. orTimeout bounds each call separately. thenCombine waits for both futures and passes their results to a function; a chain of two thenCombine calls folds three results into one record. join() blocks only at the end, when the test really needs the data.
💡 Простими словами: CompletableFuture — це замовити таксі, піцу і квитки одночасно, а не по черзі. Головне — щоб хтось перевірив, що все три замовлення справді приїхали.
💡 In plain words: CompletableFuture is ordering a taxi, a pizza and tickets at the same time instead of one after another. The key is that someone checks all three orders actually arrived.
🗣 Як читати вголос Я беру CompletableFuture тоді, коли в підготовці тесту є кілька незалежних повільних викликів, і їх можна пустити паралельно, а потім зібрати через thenCombine чи allOf. Завжди передаю власний пул, бо commonPool не розрахований на блокуючі HTTP-запити, і ставлю orTimeout. Якщо ж задачі однакові, мені простіше ExecutorService з invokeAll. Найбільше стежу за тим, щоб винятки не губилися і щоб exceptionally не маскував зламане середовище.
🗣 How to say it out loud I reach for CompletableFuture when a test's setup has several independent slow calls that can run in parallel and then be combined with thenCombine or allOf. I always pass my own pool, because the commonPool is not meant for blocking HTTP requests, and I add orTimeout. If the tasks are all alike, an ExecutorService with invokeAll is simpler for me. What I watch most is that exceptions do not get lost and that exceptionally does not mask a broken environment.
66 Як спроєктувати потокобезпечне керування драйвером для паралельного JUnit 5 або TestNG? How would you design thread-safe driver management for parallel JUnit 5 or TestNG?
Потоки Concurrency Senior ▶

Сценарій. Набір із 600 UI-тестів іде 50 хвилин, команда вмикає junit.jupiter.execution.parallel.enabled=true з режимом concurrent (або parallel="methods" у TestNG), і тести починають падати випадково: чужі сторінки, закриті сесії, змішані дані. Причина майже завжди — статичний WebDriver і інший спільний стан, який фреймворк накопичив за роки.

Scenario. A suite of 600 UI tests takes 50 minutes, the team enables junit.jupiter.execution.parallel.enabled=true with concurrent mode (or parallel="methods" in TestNG), and tests start failing at random: someone else's pages, closed sessions, mixed-up data. The cause is almost always a static WebDriver plus other shared state the framework has accumulated over the years.

Дизайн. Драйвер живе в ThreadLocal всередині DriverManager; створюється в @BeforeEach, закривається й видаляється в @AfterEach через finally. Page Object отримують драйвер у конструкторі або з менеджера, але ніколи не зберігають його в статичних полях. Тестові дані — унікальні на тест (email з UUID), а не спільний акаунт «qa_user», бо два потоки, що логіняться одним користувачем, розлогінюють один одного.

Design. The driver lives in a ThreadLocal inside a DriverManager; it is created in @BeforeEach, and quit and removed in @AfterEach through finally. Page Objects receive the driver in their constructor or from the manager but never keep it in static fields. Test data is unique per test (an email with a UUID) rather than a shared "qa_user" account, because two threads logging in as the same user log each other out.

Аудит. Шукаю всі static змінні поля: драйвери, «поточний користувач», SimpleDateFormat, кеші, лічильники. Кожне або стає final і незмінним, або переїжджає в ThreadLocal чи в контекст тесту, або отримує потокобезпечну структуру.

Audit. I search for every mutable static field: drivers, the "current user", SimpleDateFormat, caches, counters. Each one either becomes final and immutable, moves into a ThreadLocal or the test context, or gets a thread-safe structure.

Трейдофи. Паралельність обмежується не ядрами, а ємністю Grid чи Selenoid і стенду: при 10 слотах parallelism 10, інакше тести чекатимуть сесію і падатимуть з таймаутом. Тести, що змінюють глобальні налаштування, позначаю @Isolated або @ResourceLock, щоб не вимикати паралельність для всіх.

Trade-offs. Parallelism is limited not by cores but by the capacity of the Grid or Selenoid and of the test stand: with 10 slots use a parallelism of 10, otherwise tests wait for a session and fail on timeout. Tests that change global settings get @Isolated or @ResourceLock so parallelism does not have to be switched off for everyone.

Налаштування паралельності та життєвий цикл драйвера
Parallelism settings and the driver lifecycle
// src/test/resources/junit-platform.properties
// junit.jupiter.execution.parallel.enabled=true
// junit.jupiter.execution.parallel.mode.default=concurrent
// junit.jupiter.execution.parallel.config.strategy=fixed
// junit.jupiter.execution.parallel.config.fixed.parallelism=10   // = слотів у Grid

public abstract class UiTest {
    protected final String email = "qa+" + UUID.randomUUID() + "@example.com"; // свої дані на тест

    @BeforeEach
    void startBrowser() {
        DriverManager.start();
    }

    @AfterEach
    void stopBrowser() {
        DriverManager.stop();      // quit() + remove() у finally
    }

    protected WebDriver driver() {
        return DriverManager.get(); // ніколи не static WebDriver
    }
}

@Isolated // змінює глобальні налаштування стенду
class FeatureFlagTest extends UiTest { }
// src/test/resources/junit-platform.properties
// junit.jupiter.execution.parallel.enabled=true
// junit.jupiter.execution.parallel.mode.default=concurrent
// junit.jupiter.execution.parallel.config.strategy=fixed
// junit.jupiter.execution.parallel.config.fixed.parallelism=10   // = Grid slots

public abstract class UiTest {
    protected final String email = "qa+" + UUID.randomUUID() + "@example.com"; // per-test data

    @BeforeEach
    void startBrowser() {
        DriverManager.start();
    }

    @AfterEach
    void stopBrowser() {
        DriverManager.stop();      // quit() + remove() in finally
    }

    protected WebDriver driver() {
        return DriverManager.get(); // never a static WebDriver
    }
}

@Isolated // changes global stand settings
class FeatureFlagTest extends UiTest { }
🔧 Розбір синтаксису: Властивості в junit-platform.properties вмикають паралельне виконання, роблять режим concurrent типовим і фіксують кількість потоків. Поле email ініціалізується для кожного екземпляра тесту, а JUnit 5 за замовчуванням створює новий екземпляр на кожен метод. @Isolated з JUnit 5.7 запускає клас окремо від усіх інших тестів.
🔧 Syntax breakdown: The properties in junit-platform.properties enable parallel execution, make concurrent the default mode and fix the number of threads. The email field is initialised per test instance, and by default JUnit 5 creates a new instance for every method. @Isolated, available since JUnit 5.7, runs the class apart from all other tests.
💡 Простими словами: Паралельні тести — це кілька кухарів на одній кухні. Кожному потрібна своя дошка й ніж, а кількість кухарів визначає розмір плити, а не бажання шефа.
💡 In plain words: Parallel tests are several cooks in one kitchen. Each needs their own board and knife, and the number of cooks is set by the size of the stove, not by the chef's wishes.
🗣 Як читати вголос Коли мене просять розпаралелити старий набір, я не починаю з конфігу раннера. Спершу роблю аудит статичного стану: драйвер, поточний користувач, кеші, форматери дат. Драйвер переношу в ThreadLocal з чітким життєвим циклом: створення в BeforeEach, quit і remove в AfterEach. Дані роблю унікальними на тест, щоб потоки не ділили акаунти. Паралельність виставляю під кількість слотів у Grid, а тести, що чіпають глобальні налаштування, ізолюю анотаціями, а не вимикаю паралельність для всіх.
🗣 How to say it out loud When I am asked to parallelise an old suite, I do not start with the runner config. First I audit static state: the driver, the current user, caches, date formatters. I move the driver into a ThreadLocal with a clear lifecycle: created in BeforeEach, quit and removed in AfterEach. I make data unique per test so threads do not share accounts. I set parallelism to the number of Grid slots, and tests that touch global settings I isolate with annotations instead of switching parallelism off for everyone.
▶ 9 9
67 Як правильно читати тестові дані з файлів і ресурсів у Java? How do you correctly read test data from files and resources in Java?
Інструменти й задачі Toolbox and tasks Junior ▶

Що це. Для роботи з файлами в сучасній Java використовують NIO.2: клас Path описує шлях, а статичні методи Files читають і пишуть. Files.readString(path) повертає весь файл одним рядком (зручно для JSON-тіла запиту), Files.readAllLines — список рядків (CSV на кілька десятків записів), а Files.lines віддає лінивий Stream для великих файлів.

What it is. Modern Java works with files through NIO.2: the Path class describes a location and static methods on Files read and write. Files.readString(path) returns the whole file as one string (handy for a JSON request body), Files.readAllLines returns a list of lines (a CSV with a few dozen records), and Files.lines gives a lazy Stream for big files.

Файл чи ресурс. Дані, які лежать у src/test/resources, після збірки опиняються на classpath, а не за фіксованим шляхом. Їх читають через getClass().getResourceAsStream("/users.csv"): такий код працює і з IDE, і з Maven, і коли тести запаковані в jar. Шлях через Path.of годиться для файлів, які створює сам прогін, наприклад звітів у target.

File or resource. Data stored in src/test/resources ends up on the classpath after the build, not at a fixed path. It is read with getClass().getResourceAsStream("/users.csv"): that code works from the IDE, from Maven and when the tests are packaged into a jar. A Path.of path suits files the run creates itself, such as reports under target.

Приклад з проєкту. Тест логіну бере облікові записи з users.csv. Читаю ресурс як потік, явно вказую StandardCharsets.UTF_8 і пропускаю заголовок. Без явного кодування кирилиця в іменах на Windows-агенті ламалася, бо до Java 18 кодування за замовчуванням залежало від ОС.

Project example. A login test takes accounts from users.csv. I read the resource as a stream, specify StandardCharsets.UTF_8 explicitly and skip the header. Without the explicit charset, Cyrillic names broke on a Windows agent, because before Java 18 the default encoding depended on the OS.

Підводні камені. Відносний шлях Path.of("data/users.csv") рахується від робочого каталогу процесу, тому тест проходить з кореня проєкту й падає з IDE чи в багатомодульній збірці. А Files.lines тримає файл відкритим, доки потік не закрито, — тільки в try-with-resources.

Pitfalls. A relative path such as Path.of("data/users.csv") is resolved against the process working directory, so the test passes from the project root and fails from the IDE or in a multi-module build. And Files.lines keeps the file open until the stream is closed, so use it only inside try-with-resources.

Читання CSV з ресурсів і JSON з диска
Reading a CSV from resources and JSON from disk
public final class TestData {

    public static List<String[]> users() throws IOException {
        // Ресурс з classpath: працює і з IDE, і з jar
        try (InputStream in = TestData.class.getResourceAsStream("/users.csv")) {
            if (in == null) throw new IllegalStateException("users.csv not found on classpath");
            try (var reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
                return reader.lines()
                        .skip(1)                       // пропускаємо заголовок
                        .map(line -> line.split(","))
                        .toList();
            }
        }
    }

    public static String body(String name) throws IOException {
        Path file = Path.of("target", "payloads", name); // файл, згенерований прогоном
        return Files.readString(file, StandardCharsets.UTF_8);
    }

    public static long errorLines(Path log) throws IOException {
        try (Stream<String> lines = Files.lines(log)) {   // потік закриється разом з файлом
            return lines.filter(l -> l.contains("ERROR")).count();
        }
    }
}
public final class TestData {

    public static List<String[]> users() throws IOException {
        // Classpath resource: works from the IDE and from a jar
        try (InputStream in = TestData.class.getResourceAsStream("/users.csv")) {
            if (in == null) throw new IllegalStateException("users.csv not found on classpath");
            try (var reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
                return reader.lines()
                        .skip(1)                       // skip the header
                        .map(line -> line.split(","))
                        .toList();
            }
        }
    }

    public static String body(String name) throws IOException {
        Path file = Path.of("target", "payloads", name); // a file produced by the run
        return Files.readString(file, StandardCharsets.UTF_8);
    }

    public static long errorLines(Path log) throws IOException {
        try (Stream<String> lines = Files.lines(log)) {   // the stream closes the file
            return lines.filter(l -> l.contains("ERROR")).count();
        }
    }
}
🔧 Розбір синтаксису: getResourceAsStream з початковим слешем шукає від кореня classpath і повертає null, якщо ресурсу немає, тому перевірка на null дає зрозуміле повідомлення замість NullPointerException. InputStreamReader з явним UTF_8 знімає залежність від ОС. reader.lines() і Files.lines — ліниві потоки, тож обидва живуть у try-with-resources. toList() з Java 16 повертає незмінний список.
🔧 Syntax breakdown: getResourceAsStream with a leading slash searches from the classpath root and returns null when the resource is missing, so the null check gives a clear message instead of a NullPointerException. An InputStreamReader with explicit UTF_8 removes the OS dependency. reader.lines() and Files.lines are lazy streams, so both live inside try-with-resources. toList() from Java 16 returns an unmodifiable list.
💡 Простими словами: Ресурс — це річ у валізі з тестами, вона поїде з ними куди завгодно. Шлях до файлу — адреса квартири, яка правильна лише з того місця, звідки ти рахуєш.
💡 In plain words: A resource is something packed in the test suitcase, so it travels wherever the tests go. A file path is a flat address that is only right from the place you count from.
🗣 Як читати вголос Я розділяю два випадки. Вхідні тестові дані кладу в src/test/resources і читаю через getResourceAsStream, бо так вони знайдуться і з IDE, і з CI, і з jar. Файли, які створює сам прогін, читаю через Path і Files. Кодування завжди вказую явно, а Files.lines відкриваю тільки в try-with-resources, щоб не лишати відкриті дескриптори. І я уникаю відносних шляхів, бо вони тихо залежать від робочого каталогу.
🗣 How to say it out loud I separate two cases. Input test data goes into src/test/resources and I read it with getResourceAsStream, because then it is found from the IDE, from CI and from a jar. Files the run produces itself I read through Path and Files. I always pass the charset explicitly, and I only open Files.lines inside try-with-resources so no file handles are left open. And I avoid relative paths, because they quietly depend on the working directory.
68 Як працювати з датами через java.time і не зламати перевірки через часові пояси? How do you work with dates through java.time without breaking assertions on time zones?
Інструменти й задачі Toolbox and tasks Middle ▶

Що це. Пакет java.time з Java 8 дає незмінні й потокобезпечні типи. LocalDate — дата без часу й поясу (дата народження, дедлайн), LocalDateTime — дата з часом, але без поясу, ZonedDateTime — момент з конкретною зоною на кшталт Europe/Kyiv, Instant — точка на шкалі UTC, яку зручно порівнювати й зберігати. Будь-яка зміна, наприклад plusDays(1), повертає новий об’єкт.

What it is. The java.time package from Java 8 provides immutable, thread-safe types. LocalDate is a date without time or zone (a birthday, a deadline), LocalDateTime is a date and time with no zone, ZonedDateTime is a moment in a specific zone such as Europe/Kyiv, and Instant is a point on the UTC timeline that is easy to compare and store. Every change, for example plusDays(1), returns a new object.

Навіщо в тестах. API зазвичай віддає час у UTC у форматі ISO-8601, а UI показує його в поясі користувача. Коректна перевірка порівнює моменти (Instant або OffsetDateTime), а не рядки: «2024-03-31T01:30Z» і «2024-03-31T04:30+03:00» — той самий момент. Для відображення використовують DateTimeFormatter.ofPattern.

Why it matters in tests. An API usually returns time in UTC as ISO-8601, while the UI shows it in the user's zone. A correct check compares moments (Instant or OffsetDateTime) rather than strings: "2024-03-31T01:30Z" and "2024-03-31T04:30+03:00" are the same moment. For display you use DateTimeFormatter.ofPattern.

Приклад з проєкту. Тест «термін дії підписки — завтра» падав щоночі між 00:00 і 03:00 за Києвом, бо сервер рахував «завтра» в UTC, а тест — у поясі агента. Рішення: сервіс отримує Clock ззовні, а тест передає Clock.fixed з відомою датою й поясом. Так результат детермінований незалежно від того, коли й де запущено прогін.

Project example. A "subscription expires tomorrow" test failed every night between 00:00 and 03:00 Kyiv time, because the server computed "tomorrow" in UTC and the test did it in the agent's zone. The fix: the service receives a Clock from outside and the test passes Clock.fixed with a known date and zone. The result becomes deterministic no matter when or where the run happens.

Підводні камені. java.util.Date змінюваний, а SimpleDateFormat не потокобезпечний і дає дивні дати в паралельних тестах. LocalDateTime.now() бере пояс JVM, який на CI часто UTC. Перехід на літній час означає, що доба може мати 23 або 25 годин.

Pitfalls. java.util.Date is mutable and SimpleDateFormat is not thread-safe, producing odd dates in parallel tests. LocalDateTime.now() uses the JVM zone, which is often UTC on CI. A daylight saving switch means a day can have 23 or 25 hours.

Порівняння моментів і фіксований Clock у тесті
Comparing moments and a fixed Clock in a test
class SubscriptionService {
    private final Clock clock;
    SubscriptionService(Clock clock) { this.clock = clock; }

    LocalDate expiryDate(int days) {
        return LocalDate.now(clock).plusDays(days); // «зараз» береться з Clock
    }
}

class DatesTest {
    @Test
    void sameMomentInDifferentZones() {
        Instant fromApi = OffsetDateTime.parse("2024-03-31T01:30Z").toInstant();
        Instant fromUi = OffsetDateTime.parse("2024-03-31T04:30+03:00").toInstant();
        assertEquals(fromApi, fromUi);            // порівнюємо моменти, а не рядки
    }

    @Test
    void expiryIsDeterministic() {
        Clock fixed = Clock.fixed(Instant.parse("2024-12-31T22:30:00Z"), ZoneId.of("Europe/Kyiv"));
        var service = new SubscriptionService(fixed);
        // У Києві вже 1 січня, тому +1 день дає 2 січня
        assertEquals(LocalDate.of(2025, 1, 2), service.expiryDate(1));
        String shown = service.expiryDate(1).format(DateTimeFormatter.ofPattern("dd.MM.yyyy"));
        assertEquals("02.01.2025", shown);
    }
}
class SubscriptionService {
    private final Clock clock;
    SubscriptionService(Clock clock) { this.clock = clock; }

    LocalDate expiryDate(int days) {
        return LocalDate.now(clock).plusDays(days); // "now" comes from the Clock
    }
}

class DatesTest {
    @Test
    void sameMomentInDifferentZones() {
        Instant fromApi = OffsetDateTime.parse("2024-03-31T01:30Z").toInstant();
        Instant fromUi = OffsetDateTime.parse("2024-03-31T04:30+03:00").toInstant();
        assertEquals(fromApi, fromUi);            // compare moments, not strings
    }

    @Test
    void expiryIsDeterministic() {
        Clock fixed = Clock.fixed(Instant.parse("2024-12-31T22:30:00Z"), ZoneId.of("Europe/Kyiv"));
        var service = new SubscriptionService(fixed);
        // In Kyiv it is already 1 January, so +1 day gives 2 January
        assertEquals(LocalDate.of(2025, 1, 2), service.expiryDate(1));
        String shown = service.expiryDate(1).format(DateTimeFormatter.ofPattern("dd.MM.yyyy"));
        assertEquals("02.01.2025", shown);
    }
}
🔧 Розбір синтаксису: OffsetDateTime.parse розбирає ISO-рядок зі зсувом, toInstant() зводить його до UTC, і два Instant рівні, якщо це один момент. Clock.fixed приймає момент і зону: LocalDate.now(clock) обчислює дату саме в цій зоні, тож 22:30 UTC 31 грудня — це вже 1 січня в Києві (UTC+2 взимку). ofPattern("dd.MM.yyyy") форматує дату для порівняння з UI.
🔧 Syntax breakdown: OffsetDateTime.parse parses an ISO string with an offset, toInstant() converts it to UTC, and two Instants are equal when they are the same moment. Clock.fixed takes an instant and a zone: LocalDate.now(clock) computes the date in that zone, so 22:30 UTC on 31 December is already 1 January in Kyiv (UTC+2 in winter). ofPattern("dd.MM.yyyy") formats the date for comparison with the UI.
💡 Простими словами: Instant — це «коли саме це сталося», а пояс — лише те, на який годинник дивиться людина. Порівнюй події, а не циферблати.
💡 In plain words: An Instant is "when exactly it happened", and a zone is just the clock a person looks at. Compare events, not clock faces.
🗣 Як читати вголос Я користуюся лише java.time: типи там незмінні й безпечні для паралельних тестів, на відміну від Date і SimpleDateFormat. Для перевірок API я порівнюю моменти через Instant, а не рядки, бо той самий час може прийти з різним зсувом. А якщо логіка залежить від «сьогодні», прошу розробників прийняти Clock, і в тесті передаю Clock.fixed. Після цього зникають нічні падіння, які трапляються лише тоді, коли в UTC і в Києві різні дати.
🗣 How to say it out loud I use only java.time: its types are immutable and safe for parallel tests, unlike Date and SimpleDateFormat. For API checks I compare moments via Instant rather than strings, because the same time can arrive with a different offset. And when logic depends on "today", I ask developers to accept a Clock, and in the test I pass Clock.fixed. That removes the night-time failures that only happen when UTC and Kyiv are on different dates.
69 Як JUnit і TestNG використовують рефлексію та анотації і як написати власну анотацію? How do JUnit and TestNG use reflection and annotations, and how do you write your own annotation?
Інструменти й задачі Toolbox and tasks Middle ▶

Що це. Анотація — це метадані, прикріплені до класу, методу чи поля. Сама по собі вона нічого не робить: хтось має її прочитати. Рефлексія — API java.lang.reflect, що дозволяє під час виконання отримати методи класу, їхні анотації, викликати метод чи прочитати поле за іменем.

What it is. An annotation is metadata attached to a class, method or field. On its own it does nothing: someone has to read it. Reflection is the java.lang.reflect API that lets code, at runtime, list a class's methods and their annotations, invoke a method or read a field by name.

Як це працює в раннерах. JUnit 5 сканує тестові класи, через getDeclaredMethods() знаходить методи з @Test, створює екземпляр класу і викликає кожен метод через Method.invoke. TestNG робить те саме зі своєю @Test. Це можливо лише тому, що ці анотації мають @Retention(RetentionPolicy.RUNTIME): без цього анотація зникає після компіляції й рефлексія її не побачить.

How runners use it. JUnit 5 scans test classes, finds methods carrying @Test via getDeclaredMethods(), creates an instance of the class and calls each method through Method.invoke. TestNG does the same with its own @Test. This works only because those annotations have @Retention(RetentionPolicy.RUNTIME): without it the annotation is gone after compilation and reflection cannot see it.

Приклад з проєкту. Команда хоче пов’язати тести з кейсами в TMS. Пишемо @TestCaseId("QA-123") з @Target(ElementType.METHOD) і RUNTIME-збереженням, а JUnit-розширення зчитує значення через context.getRequiredTestMethod().getAnnotation(...) і додає його до звіту чи надсилає результат у TMS.

Project example. The team wants to link tests to cases in the TMS. We write @TestCaseId("QA-123") with @Target(ElementType.METHOD) and runtime retention, and a JUnit extension reads the value through context.getRequiredTestMethod().getAnnotation(...) and adds it to the report or sends the result to the TMS.

Підводні камені. Забутий @Retention(RUNTIME) — найчастіша причина, чому «анотація не працює»: за замовчуванням діє CLASS. setAccessible(true) на приватних полях чужих класів ламається на модулях Java 17 з InaccessibleObjectException. Рефлексія повільніша за прямий виклик, тому результати пошуку анотацій варто кешувати.

Pitfalls. A missing @Retention(RUNTIME) is the most common reason an "annotation does nothing": the default is CLASS. setAccessible(true) on private fields of foreign classes fails on Java 17 modules with InaccessibleObjectException. Reflection is slower than a direct call, so cache the results of annotation lookups.

Власна анотація і розширення JUnit 5, яке її читає
A custom annotation and a JUnit 5 extension that reads it
@Retention(RetentionPolicy.RUNTIME)   // без цього рефлексія анотацію не побачить
@Target(ElementType.METHOD)
public @interface TestCaseId {
    String value();
}

public class TestCaseIdExtension implements AfterTestExecutionCallback {
    @Override
    public void afterTestExecution(ExtensionContext context) {
        Method method = context.getRequiredTestMethod();
        TestCaseId id = method.getAnnotation(TestCaseId.class);
        if (id == null) return;                            // тест без прив’язки до TMS
        boolean failed = context.getExecutionException().isPresent();
        System.out.printf("%s -> %s%n", id.value(), failed ? "FAILED" : "PASSED");
    }
}

@ExtendWith(TestCaseIdExtension.class)
class CheckoutTest {
    @Test
    @TestCaseId("QA-123")
    void guestCanPayByCard() { /* ... */ }
}
@Retention(RetentionPolicy.RUNTIME)   // without it reflection cannot see the annotation
@Target(ElementType.METHOD)
public @interface TestCaseId {
    String value();
}

public class TestCaseIdExtension implements AfterTestExecutionCallback {
    @Override
    public void afterTestExecution(ExtensionContext context) {
        Method method = context.getRequiredTestMethod();
        TestCaseId id = method.getAnnotation(TestCaseId.class);
        if (id == null) return;                            // test not linked to the TMS
        boolean failed = context.getExecutionException().isPresent();
        System.out.printf("%s -> %s%n", id.value(), failed ? "FAILED" : "PASSED");
    }
}

@ExtendWith(TestCaseIdExtension.class)
class CheckoutTest {
    @Test
    @TestCaseId("QA-123")
    void guestCanPayByCard() { /* ... */ }
}
🔧 Розбір синтаксису: @interface оголошує анотацію, а метод value() дозволяє писати @TestCaseId("QA-123") без імені параметра. @Target обмежує, куди її можна поставити. AfterTestExecutionCallback викликається одразу після тіла тесту, а getExecutionException() повертає Optional з винятком, якщо тест упав. getAnnotation повертає null, коли анотації немає.
🔧 Syntax breakdown: @interface declares an annotation, and the value() element lets you write @TestCaseId("QA-123") without naming the parameter. @Target restricts where it can be placed. AfterTestExecutionCallback runs right after the test body, and getExecutionException() returns an Optional holding the exception if the test failed. getAnnotation returns null when the annotation is absent.
💡 Простими словами: Анотація — наліпка на коробці, рефлексія — працівник складу, який ці наліпки читає. Якщо наліпку зняли ще на заводі, читати нічого.
💡 In plain words: An annotation is a sticker on a box, and reflection is the warehouse worker who reads the stickers. If the sticker was peeled off at the factory, there is nothing to read.
🗣 Як читати вголос Я пояснюю, що анотація — лише позначка, а працює той, хто її читає через рефлексію. JUnit знаходить методи з @Test саме так і викликає їх через invoke. Коли мені треба прив’язати тести до TMS, я пишу власну анотацію з Retention RUNTIME і Target METHOD і читаю її в розширенні після виконання тесту. Найчастіша помилка, яку я бачив, — забутий Retention: код компілюється, а анотацію ніхто не бачить.
🗣 How to say it out loud I explain that an annotation is only a label, and the real work is done by whoever reads it through reflection. That is exactly how JUnit finds methods marked @Test and calls them with invoke. When I need to link tests to a TMS, I write my own annotation with RUNTIME retention and METHOD target and read it in an extension after the test runs. The most common mistake I have seen is a forgotten Retention: the code compiles, but nobody ever sees the annotation.
70 Які Java-механізми стоять за параметризованими тестами і розширеннями JUnit 5? Which Java mechanisms power parameterized tests and extensions in JUnit 5?
Інструменти й задачі Toolbox and tasks Senior ▶

Що це. Параметризований тест — один метод, який JUnit запускає для кожного набору аргументів. Джерело даних — це звичайна Java: @MethodSource посилається на статичний метод, що повертає Stream<Arguments>, List чи масив, а @CsvSource перетворює рядки CSV на параметри з автоматичним приведенням типів. Розширення — клас, що реалізує інтерфейси-колбеки (TestWatcher, BeforeEachCallback, ParameterResolver) і реєструється через @ExtendWith.

What it is. A parameterized test is one method that JUnit runs for each set of arguments. The data source is plain Java: @MethodSource points to a static method returning a Stream<Arguments>, a List or an array, and @CsvSource turns CSV lines into parameters with automatic type conversion. An extension is a class implementing callback interfaces (TestWatcher, BeforeEachCallback, ParameterResolver) and registered with @ExtendWith.

Навіщо розуміти механіку. Статичний фабричний метод Arguments.of(Object...) приймає varargs, тож типи перевіряються лише під час запуску: переплутаний порядок дає ParameterResolutionException або неправильне приведення. Лінивий Stream генерує дані тільки тоді, коли JUnit їх запитує, а метод-джерело має бути static (крім @TestInstance(PER_CLASS)).

Why the mechanics matter. The static factory Arguments.of(Object...) takes varargs, so types are checked only at run time: a swapped order gives a ParameterResolutionException or a wrong conversion. The lazy Stream produces data only when JUnit asks for it, and the source method must be static (unless you use @TestInstance(PER_CLASS)).

Приклад з проєкту. Перевірка валідації email: 15 наборів у @MethodSource замість 15 копій тесту. Розширення TestWatcher у методі testFailed зберігає тіло останньої відповіді API у target/artifacts, тож у CI видно, що саме повернув сервер.

Project example. Email validation checks: 15 cases in a @MethodSource instead of 15 copies of the test. A TestWatcher extension saves the body of the last API response to target/artifacts in its testFailed method, so CI shows exactly what the server returned.

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

Trade-off. Extensions remove duplication from base classes but hide behaviour: a newcomer cannot see where the screenshot comes from. So I document extensions, register them explicitly on the class rather than through global auto-detection, and keep test business logic out of them.

MethodSource, CsvSource і TestWatcher, що зберігає артефакт
MethodSource, CsvSource and a TestWatcher that saves an artifact
public class SaveResponseOnFailure implements TestWatcher {
    static final ThreadLocal<String> LAST_BODY = new ThreadLocal<>();

    @Override
    public void testFailed(ExtensionContext context, Throwable cause) {
        String body = LAST_BODY.get();
        if (body == null) return;
        try {
            Path file = Path.of("target", "artifacts", context.getDisplayName() + ".json");
            Files.createDirectories(file.getParent());
            Files.writeString(file, body);
        } catch (IOException e) {
            throw new UncheckedIOException(e);   // не ковтаємо помилку запису
        } finally {
            LAST_BODY.remove();
        }
    }
}

@ExtendWith(SaveResponseOnFailure.class)
class EmailValidationTest {

    static Stream<Arguments> emails() {              // статичний метод-джерело
        return Stream.of(
                Arguments.of("anna@example.com", true),
                Arguments.of("anna@", false),
                Arguments.of("", false));
    }

    @ParameterizedTest(name = "{0} valid={1}")
    @MethodSource("emails")
    void validatesEmail(String email, boolean expected) {
        assertEquals(expected, Validator.isEmail(email));
    }

    @ParameterizedTest
    @CsvSource({"1, 10.00", "3, 30.00"})              // рядки приводяться до int і BigDecimal
    void totalPrice(int qty, BigDecimal expected) {
        assertEquals(expected, Cart.total(qty, new BigDecimal("10.00")));
    }
}
public class SaveResponseOnFailure implements TestWatcher {
    static final ThreadLocal<String> LAST_BODY = new ThreadLocal<>();

    @Override
    public void testFailed(ExtensionContext context, Throwable cause) {
        String body = LAST_BODY.get();
        if (body == null) return;
        try {
            Path file = Path.of("target", "artifacts", context.getDisplayName() + ".json");
            Files.createDirectories(file.getParent());
            Files.writeString(file, body);
        } catch (IOException e) {
            throw new UncheckedIOException(e);   // do not swallow the write error
        } finally {
            LAST_BODY.remove();
        }
    }
}

@ExtendWith(SaveResponseOnFailure.class)
class EmailValidationTest {

    static Stream<Arguments> emails() {              // static source method
        return Stream.of(
                Arguments.of("anna@example.com", true),
                Arguments.of("anna@", false),
                Arguments.of("", false));
    }

    @ParameterizedTest(name = "{0} valid={1}")
    @MethodSource("emails")
    void validatesEmail(String email, boolean expected) {
        assertEquals(expected, Validator.isEmail(email));
    }

    @ParameterizedTest
    @CsvSource({"1, 10.00", "3, 30.00"})              // strings are converted to int and BigDecimal
    void totalPrice(int qty, BigDecimal expected) {
        assertEquals(expected, Cart.total(qty, new BigDecimal("10.00")));
    }
}
🔧 Розбір синтаксису: TestWatcher.testFailed отримує контекст і причину падіння. ThreadLocal дозволяє кожному потоку паралельного прогону мати власне «останнє тіло», а remove() у finally не дає даним протекти в наступний тест. @MethodSource("emails") знаходить статичний метод за іменем. name = "{0} valid={1}" робить звіт читабельним. @CsvSource приводить текст до типів параметрів методу.
🔧 Syntax breakdown: TestWatcher.testFailed receives the context and the cause of failure. The ThreadLocal gives each thread of a parallel run its own "last body", and remove() in finally stops data from leaking into the next test. @MethodSource("emails") finds the static method by name. name = "{0} valid={1}" keeps the report readable. @CsvSource converts text to the method parameter types.
💡 Простими словами: Параметризований тест — один рецепт і кошик різних продуктів. Розширення — кухар-помічник, який сам прибирає й фотографує невдалі страви.
💡 In plain words: A parameterized test is one recipe and a basket of different ingredients. An extension is an assistant cook who tidies up and photographs failed dishes on their own.
🗣 Як читати вголос Для мене параметризований тест — це просто статичний метод, який повертає потік аргументів, і JUnit викликає тест для кожного з них. Я пам’ятаю, що Arguments.of приймає varargs, тож помилки типів вилізуть лише під час запуску. Розширення я використовую для наскрізних речей на кшталт збереження відповіді API при падінні, але тримаю їх маленькими, явно реєструю на класі й не ховаю туди логіку самого тесту, інакше команда перестає розуміти, що відбувається.
🗣 How to say it out loud To me a parameterized test is just a static method returning a stream of arguments, and JUnit calls the test for each of them. I keep in mind that Arguments.of takes varargs, so type mistakes only show up at run time. I use extensions for cross-cutting things such as saving the API response on failure, but I keep them small, register them explicitly on the class and never hide the test's own logic in them, otherwise the team stops understanding what is going on.
71 Як Builder і Factory допомагають створювати тестові дані та драйвери? How do Builder and Factory help create test data and drivers?
Інструменти й задачі Toolbox and tasks Middle ▶

Що це. Builder — патерн для покрокового створення об’єкта з багатьма полями: замість конструктора з вісьмома параметрами пишемо ланцюжок withRole(...), withEmail(...) і завершуємо build(). Factory ховає вибір конкретного класу за одним методом: DriverFactory.create(Browser.CHROME) повертає готовий WebDriver, а тест не знає, як саме налаштовано Chrome.

What it is. Builder is a pattern for creating an object with many fields step by step: instead of a constructor with eight parameters you write a chain of withRole(...), withEmail(...) and finish with build(). Factory hides the choice of a concrete class behind one method: DriverFactory.create(Browser.CHROME) returns a ready WebDriver, and the test does not know how Chrome is configured.

Навіщо в тестах. Тестові дані мають бути короткими й показувати лише те, що важливо для перевірки. Builder з розумними значеннями за замовчуванням дає aUser().withRole(ADMIN).build(): одразу видно, що тест про роль, а решта полів валідна й не заважає. Статичні фабричні методи на кшталт Users.blockedCustomer() іменують типові сценарії.

Why in tests. Test data should be short and show only what matters for the check. A builder with sensible defaults gives aUser().withRole(ADMIN).build(): it is obvious the test is about the role, and the other fields are valid and stay out of the way. Static factory methods such as Users.blockedCustomer() give names to typical scenarios.

Приклад з проєкту. Після додавання обов’язкового поля phone у реєстрацію впало 40 тестів, які викликали конструктор напряму. З builder-ом достатньо було додати значення за замовчуванням в одному місці. DriverFactory з switch по enum зібрав у собі всі опції headless, розміру вікна і віддаленого Grid.

Project example. After a mandatory phone field was added to sign-up, 40 tests that called the constructor directly failed. With a builder it was enough to add a default value in one place. A DriverFactory with a switch over an enum collected all the headless, window size and remote Grid options.

Підводні камені. Випадкові дані за замовчуванням (унікальний email) — добре, а випадкові значення, від яких залежить перевірка, — ні. Lombok @Builder економить код, але не дає розумних дефолтів без @Builder.Default і ховає згенеровані методи від читача.

Pitfalls. Random defaults (a unique email) are fine, but random values that the assertion depends on are not. Lombok @Builder saves code but gives no sensible defaults without @Builder.Default and hides the generated methods from the reader.

UserBuilder з дефолтами і DriverFactory за enum
A UserBuilder with defaults and a DriverFactory by enum
public record User(String email, String password, Role role, boolean verified) {}

public final class UserBuilder {
    private String email = "user+" + UUID.randomUUID() + "@example.com"; // унікальний за замовчуванням
    private String password = "Secret123!";
    private Role role = Role.CUSTOMER;
    private boolean verified = true;

    private UserBuilder() {}
    public static UserBuilder aUser() { return new UserBuilder(); }

    public UserBuilder withRole(Role role) { this.role = role; return this; }
    public UserBuilder unverified() { this.verified = false; return this; }
    public User build() { return new User(email, password, role, verified); }
}

public enum Browser { CHROME, FIREFOX }

public final class DriverFactory {
    private DriverFactory() {}

    public static WebDriver create(Browser browser) {
        return switch (browser) {                    // усі опції драйверів в одному місці
            case CHROME -> new ChromeDriver(new ChromeOptions().addArguments("--headless=new"));
            case FIREFOX -> new FirefoxDriver(new FirefoxOptions().addArguments("-headless"));
        };
    }
}
// User admin = UserBuilder.aUser().withRole(Role.ADMIN).build();
public record User(String email, String password, Role role, boolean verified) {}

public final class UserBuilder {
    private String email = "user+" + UUID.randomUUID() + "@example.com"; // unique by default
    private String password = "Secret123!";
    private Role role = Role.CUSTOMER;
    private boolean verified = true;

    private UserBuilder() {}
    public static UserBuilder aUser() { return new UserBuilder(); }

    public UserBuilder withRole(Role role) { this.role = role; return this; }
    public UserBuilder unverified() { this.verified = false; return this; }
    public User build() { return new User(email, password, role, verified); }
}

public enum Browser { CHROME, FIREFOX }

public final class DriverFactory {
    private DriverFactory() {}

    public static WebDriver create(Browser browser) {
        return switch (browser) {                    // all driver options in one place
            case CHROME -> new ChromeDriver(new ChromeOptions().addArguments("--headless=new"));
            case FIREFOX -> new FirefoxDriver(new FirefoxOptions().addArguments("-headless"));
        };
    }
}
// User admin = UserBuilder.aUser().withRole(Role.ADMIN).build();
🔧 Розбір синтаксису: Приватний конструктор і статичний aUser() роблять точку входу читабельною. Кожен with... повертає this, що дає ланцюжок викликів. build() створює незмінний record. У фабриці switch-вираз по enum вичерпний: якщо додати EDGE в enum і забути гілку, код не скомпілюється. Приватний конструктор DriverFactory забороняє створювати екземпляри утилітного класу.
🔧 Syntax breakdown: The private constructor and the static aUser() make the entry point readable. Each with... returns this, which gives the call chain. build() creates an immutable record. In the factory, a switch expression over an enum is exhaustive: if you add EDGE to the enum and forget the branch, the code does not compile. The private constructor of DriverFactory prevents instantiating a utility class.
💡 Простими словами: Builder — це замовлення піци, де все вже є за замовчуванням, а ти кажеш лише «без цибулі». Factory — віконце, де видають готову піцу, не показуючи кухню.
💡 In plain words: A builder is ordering a pizza where everything is included by default and you only say "no onions". A factory is the counter that hands out a ready pizza without showing the kitchen.
🗣 Як читати вголос У тестових даних я завжди використовую builder з валідними значеннями за замовчуванням, щоб тест змінював лише одне поле, про яке він перевіряє. Коли в модель додають нове обов’язкове поле, я правлю один дефолт, а не сорок тестів. Для драйверів у мене фабрика зі switch по enum браузера, де зібрані всі опції. Lombok Builder я використовую обережно, бо без Builder.Default він не дає нормальних дефолтів.
🗣 How to say it out loud For test data I always use a builder with valid defaults so that a test changes only the one field it checks. When a new mandatory field appears in the model, I fix one default instead of forty tests. For drivers I have a factory with a switch over a browser enum where all the options live. I use the Lombok builder carefully, because without Builder.Default it gives no proper defaults.
72 Чому Singleton для драйвера шкодить паралельним тестам і де доречний Strategy? Why does a Singleton driver hurt parallel tests, and where does Strategy fit?
Інструменти й задачі Toolbox and tasks Senior ▶

Проблема Singleton. Класичний DriverManager.getInstance().getDriver() зі static полем означає один браузер на всю JVM. Щойно JUnit чи TestNG запускає тести в чотири потоки, вони починають клікати в одному вікні, і падіння стають випадковими. До того ж singleton — прихований глобальний стан: тест залежить від того, що зробив попередній, і його важко ізолювати чи підмінити в юніт-тесті фреймворку.

The Singleton problem. The classic DriverManager.getInstance().getDriver() with a static field means one browser per JVM. As soon as JUnit or TestNG runs tests in four threads, they start clicking in the same window and failures become random. A singleton is also hidden global state: a test depends on what the previous one did, and it is hard to isolate or replace in a unit test of the framework.

Ледача ініціалізація. Перевірка if (instance == null) instance = new ... без синхронізації створює два екземпляри при одночасному першому виклику. Якщо один екземпляр справді потрібен (наприклад, конфіг), безпечні варіанти — idiom holder-класу або enum-singleton: JVM гарантує потокобезпечну ініціалізацію класу. Для драйвера правильна відповідь інша — один екземпляр на потік через ThreadLocal або передача драйвера в Page Object.

Lazy initialization. The check if (instance == null) instance = new ... without synchronization creates two instances when two threads call it for the first time together. If you really need one instance (a config, say), safe options are the holder-class idiom or an enum singleton: the JVM guarantees thread-safe class initialization. For a driver the right answer is different: one instance per thread through ThreadLocal, or passing the driver into Page Objects.

Strategy. Strategy виносить змінну поведінку за інтерфейс. API-клієнт тестів приймає AuthStrategy: bearer-токен, basic або cookie сесії. Тест обирає стратегію, а клієнт не має розгалужень if (authType == ...). У Java 17 інтерфейс з одним методом — функціональний, тому стратегію можна передати лямбдою.

Strategy. Strategy moves varying behaviour behind an interface. The test API client accepts an AuthStrategy: a bearer token, basic auth or a session cookie. The test chooses the strategy, and the client has no if (authType == ...) branches. In Java 17 a single-method interface is functional, so a strategy can be passed as a lambda.

Трейдоф. Strategy додає інтерфейс і кілька класів; якщо варіант один і не зміниться, простий метод чесніший. Singleton виправданий лише для незмінного, потокобезпечного стану.

Trade-off. Strategy adds an interface and a few classes; if there is one variant that will not change, a plain method is more honest. A singleton is justified only for immutable, thread-safe state.

Безпечний singleton для конфігу і Strategy для авторизації
A safe singleton for config and Strategy for authentication
public final class Config {
    private final Properties props = new Properties();

    private Config() {
        try (InputStream in = Config.class.getResourceAsStream("/test.properties")) {
            props.load(in);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    private static final class Holder {             // ініціалізується при першому зверненні, потокобезпечно
        static final Config INSTANCE = new Config();
    }

    public static Config get() { return Holder.INSTANCE; }
    public String baseUrl() { return props.getProperty("base.url"); }
}

@FunctionalInterface
public interface AuthStrategy {
    HttpRequest.Builder apply(HttpRequest.Builder request);

    static AuthStrategy bearer(String token) {
        return r -> r.header("Authorization", "Bearer " + token);
    }

    static AuthStrategy cookie(String session) {
        return r -> r.header("Cookie", "SESSION=" + session);
    }
}

public final class ApiClient {
    private final AuthStrategy auth;
    public ApiClient(AuthStrategy auth) { this.auth = auth; }  // стратегія приходить ззовні

    public HttpRequest get(String path) {
        var builder = HttpRequest.newBuilder(URI.create(Config.get().baseUrl() + path));
        return auth.apply(builder).GET().build();
    }
}
// new ApiClient(AuthStrategy.bearer(token)).get("/orders");
public final class Config {
    private final Properties props = new Properties();

    private Config() {
        try (InputStream in = Config.class.getResourceAsStream("/test.properties")) {
            props.load(in);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    private static final class Holder {             // initialised on first access, thread-safe
        static final Config INSTANCE = new Config();
    }

    public static Config get() { return Holder.INSTANCE; }
    public String baseUrl() { return props.getProperty("base.url"); }
}

@FunctionalInterface
public interface AuthStrategy {
    HttpRequest.Builder apply(HttpRequest.Builder request);

    static AuthStrategy bearer(String token) {
        return r -> r.header("Authorization", "Bearer " + token);
    }

    static AuthStrategy cookie(String session) {
        return r -> r.header("Cookie", "SESSION=" + session);
    }
}

public final class ApiClient {
    private final AuthStrategy auth;
    public ApiClient(AuthStrategy auth) { this.auth = auth; }  // the strategy comes from outside

    public HttpRequest get(String path) {
        var builder = HttpRequest.newBuilder(URI.create(Config.get().baseUrl() + path));
        return auth.apply(builder).GET().build();
    }
}
// new ApiClient(AuthStrategy.bearer(token)).get("/orders");
🔧 Розбір синтаксису: Вкладений клас Holder завантажується лише при першому виклику get(), а JVM ініціалізує клас рівно один раз і потокобезпечно, тому synchronized не потрібен. AuthStrategy має один абстрактний метод, отже стратегії створюються лямбдами у статичних фабричних методах інтерфейсу. ApiClient отримує стратегію в конструкторі й нічого не знає про її тип.
🔧 Syntax breakdown: The nested Holder class is loaded only on the first call to get(), and the JVM initializes a class exactly once and thread-safely, so no synchronized is needed. AuthStrategy has one abstract method, so strategies are created as lambdas in static factory methods of the interface. ApiClient receives the strategy in its constructor and knows nothing about its type.
💡 Простими словами: Singleton-драйвер — одне кермо на чотирьох водіїв. Strategy — набір змінних насадок до одного шуруповерта.
💡 In plain words: A singleton driver is one steering wheel for four drivers. Strategy is a set of interchangeable bits for one screwdriver.
🗣 Як читати вголос Коли в співбесіді питають про singleton, я одразу кажу, що для WebDriver він шкідливий: при паралельному запуску всі потоки ділять один браузер. Замість нього драйвер живе в ThreadLocal або передається в Page Object. Singleton я лишаю для незмінних речей на кшталт конфігурації і роблю його через holder-клас, щоб не було гонки при ініціалізації. Strategy мені подобається для авторизації в API-клієнті: тест передає токен чи cookie лямбдою, а клієнт лишається без розгалужень.
🗣 How to say it out loud When an interviewer asks about singletons, I say straight away that for WebDriver it is harmful: in a parallel run every thread shares one browser. Instead the driver lives in a ThreadLocal or is passed into Page Objects. I keep singletons for immutable things such as configuration and build them with a holder class so initialization has no race. I like Strategy for authentication in an API client: the test passes a token or a cookie as a lambda, and the client stays free of branching.
73 Як на live-coding розвернути рядок, перевірити паліндром і знайти дублікати? How do you reverse a string, check a palindrome and find duplicates in a live-coding round?
Інструменти й задачі Toolbox and tasks Junior ▶

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

What is being checked. These tasks are not about the algorithm itself: the interviewer wants to see whether you know the standard library, think about edge cases and can explain complexity. Saying "first the simplest working solution, then improvements" nearly always beats silently writing clever code.

Розворот і паліндром. Найкоротше — new StringBuilder(s).reverse().toString(), це O(n). Якщо просять без бібліотеки, використовую масив char[] і два індекси, що рухаються назустріч. Для паліндрома не треба створювати розвернутий рядок: ті самі два вказівники порівнюють символи, пропускаючи все, що не є літерою чи цифрою, і порівнюють без урахування регістру через Character.toLowerCase.

Reverse and palindrome. The shortest version is new StringBuilder(s).reverse().toString(), which is O(n). If asked to avoid the library, I use a char[] array with two indices moving towards each other. A palindrome check does not need a reversed copy: the same two pointers compare characters, skipping anything that is not a letter or digit, and compare case-insensitively through Character.toLowerCase.

Дублікати. Set.add повертає false, якщо елемент уже є, тому один прохід із HashSet дає всі повтори за O(n) часу й O(n) пам’яті. Подвійний цикл працює за O(n²) і на великому списку помітно повільніший. Для збереження порядку першої появи беру LinkedHashSet.

Duplicates. Set.add returns false when the element is already present, so one pass with a HashSet finds all repeats in O(n) time and O(n) memory. A nested loop runs in O(n²) and is noticeably slower on a big list. To keep the order of first appearance I take a LinkedHashSet.

Підводні камені. Уточни, чи може вхід бути null чи порожнім, і чи важливий регістр. StringBuilder.reverse коректно обробляє сурогатні пари (емодзі), а ручний розворот масиву char — ні; про це варто згадати вголос.

Pitfalls. Ask whether the input can be null or empty and whether case matters. StringBuilder.reverse handles surrogate pairs (emoji) correctly, while a hand-written char array reversal does not; that is worth saying out loud.

Три класичні задачі з оцінкою складності
Three classic tasks with complexity notes
public final class Strings {

    static String reverse(String s) {
        return new StringBuilder(s).reverse().toString();     // O(n)
    }

    static String reverseManually(String s) {
        char[] chars = s.toCharArray();
        for (int i = 0, j = chars.length - 1; i < j; i++, j--) {
            char tmp = chars[i];
            chars[i] = chars[j];
            chars[j] = tmp;
        }
        return new String(chars);
    }

    static boolean isPalindrome(String s) {
        int i = 0, j = s.length() - 1;
        while (i < j) {
            if (!Character.isLetterOrDigit(s.charAt(i))) { i++; continue; } // пропускаємо пробіли й розділові знаки
            if (!Character.isLetterOrDigit(s.charAt(j))) { j--; continue; }
            if (Character.toLowerCase(s.charAt(i)) != Character.toLowerCase(s.charAt(j))) return false;
            i++;
            j--;
        }
        return true;
    }

    static <T> Set<T> duplicates(List<T> items) {
        Set<T> seen = new HashSet<>();
        Set<T> dups = new LinkedHashSet<>();
        for (T item : items) {
            if (!seen.add(item)) dups.add(item);              // add повертає false для повтору
        }
        return dups;                                           // O(n) час, O(n) пам’ять
    }
}
// isPalindrome("A man, a plan, a canal: Panama") == true
// duplicates(List.of("a", "b", "a", "c", "b")) == [a, b]
public final class Strings {

    static String reverse(String s) {
        return new StringBuilder(s).reverse().toString();     // O(n)
    }

    static String reverseManually(String s) {
        char[] chars = s.toCharArray();
        for (int i = 0, j = chars.length - 1; i < j; i++, j--) {
            char tmp = chars[i];
            chars[i] = chars[j];
            chars[j] = tmp;
        }
        return new String(chars);
    }

    static boolean isPalindrome(String s) {
        int i = 0, j = s.length() - 1;
        while (i < j) {
            if (!Character.isLetterOrDigit(s.charAt(i))) { i++; continue; } // skip spaces and punctuation
            if (!Character.isLetterOrDigit(s.charAt(j))) { j--; continue; }
            if (Character.toLowerCase(s.charAt(i)) != Character.toLowerCase(s.charAt(j))) return false;
            i++;
            j--;
        }
        return true;
    }

    static <T> Set<T> duplicates(List<T> items) {
        Set<T> seen = new HashSet<>();
        Set<T> dups = new LinkedHashSet<>();
        for (T item : items) {
            if (!seen.add(item)) dups.add(item);              // add returns false for a repeat
        }
        return dups;                                           // O(n) time, O(n) memory
    }
}
// isPalindrome("A man, a plan, a canal: Panama") == true
// duplicates(List.of("a", "b", "a", "c", "b")) == [a, b]
🔧 Розбір синтаксису: У reverseManually цикл for оголошує два лічильники через кому й міняє символи місцями, доки вони не зустрінуться. Character.isLetterOrDigit відсіює розділові знаки, Character.toLowerCase прибирає різницю регістру. Узагальнений метод <T> Set<T> duplicates працює для будь-яких елементів з коректними equals і hashCode.
🔧 Syntax breakdown: In reverseManually the for loop declares two counters separated by a comma and swaps characters until they meet. Character.isLetterOrDigit filters out punctuation and Character.toLowerCase removes the case difference. The generic method <T> Set<T> duplicates works for any elements with correct equals and hashCode.
💡 Простими словами: Два вказівники — це двоє людей, що йдуть з різних кінців коридору назустріч і порівнюють двері, повз які проходять.
💡 In plain words: Two pointers are two people walking towards each other from opposite ends of a corridor, comparing the doors they pass.
🗣 Як читати вголос На таких задачах я спершу проговорюю крайові випадки: null, порожній рядок, регістр і розділові знаки. Потім пишу найпростіше рішення, наприклад StringBuilder.reverse, і пропоную ручний варіант з двома вказівниками, якщо бібліотеку заборонили. Для дублікатів використовую те, що add у HashSet повертає false, і називаю складність: лінійний час і лінійна пам’ять замість квадратичного подвійного циклу. Інтерв’юеру важливо чути, як я міркую.
🗣 How to say it out loud With these tasks I first talk through the edge cases: null, an empty string, case and punctuation. Then I write the simplest solution, for example StringBuilder.reverse, and offer a manual two-pointer version if the library is off limits. For duplicates I rely on add in a HashSet returning false, and I name the complexity: linear time and linear memory instead of a quadratic nested loop. The interviewer wants to hear how I reason.
74 Як розв’язати підрахунок слів, перший неповторний символ і FizzBuzz через Stream API? How do you solve word counting, the first non-repeating character and FizzBuzz with the Stream API?
Інструменти й задачі Toolbox and tasks Middle ▶

Підрахунок слів. Класика: розбити текст на слова і порахувати кожне. Потоком це Arrays.stream(text.split("\\s+")), потім groupingBy(Function.identity(), counting()). Інтерв’юер чекає, що ти сам спитаєш: чи рахувати «Test» і «test» як одне слово, що робити з розділовими знаками, і що поверне split для порожнього рядка (масив з одним порожнім елементом, а не порожній масив).

Word counting. The classic: split text into words and count each one. As a stream that is Arrays.stream(text.split("\\s+")) followed by groupingBy(Function.identity(), counting()). The interviewer expects you to ask yourself whether "Test" and "test" are the same word, what to do with punctuation, and what split returns for an empty string (an array with one empty element, not an empty array).

Перший неповторний символ. Потрібна частота кожного символу і порядок появи, тому HashMap не годиться — він не зберігає порядок. groupingBy з фабрикою LinkedHashMap::new рахує символи в порядку першої появи, а далі перший запис зі значенням 1 і є відповіддю. Два проходи, O(n).

First non-repeating character. You need the frequency of each character and the order of appearance, so a HashMap does not work because it keeps no order. groupingBy with a LinkedHashMap::new factory counts characters in order of first appearance, and the first entry with value 1 is the answer. Two passes, O(n).

FizzBuzz. IntStream.rangeClosed(1, n) і mapToObj з switch-виразом по парі остач виглядають сучасно, але звичайний цикл тут так само прийнятний. Головне — перевіряти ділення на 15 першим, інакше «FizzBuzz» ніколи не з’явиться.

FizzBuzz. IntStream.rangeClosed(1, n) and mapToObj with a switch expression over a pair of remainders look modern, but a plain loop is equally acceptable here. The key is to check divisibility by 15 first, otherwise "FizzBuzz" never appears.

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

Pitfalls. Streams are not always more readable: a complex lambda nested three levels deep is worse than a loop. In an interview I show the stream solution and then say when I would pick a loop. An Optional for "no such character" is more honest than returning ' ' or null.

Три задачі на потоках з обробкою крайових випадків
Three stream tasks with edge cases handled
public final class StreamTasks {

    static Map<String, Long> countWords(String text) {
        if (text == null || text.isBlank()) return Map.of();         // порожній вхід
        return Arrays.stream(text.toLowerCase().split("[^\\p{L}\\p{N}]+"))
                .filter(w -> !w.isEmpty())                            // прибираємо порожній перший елемент
                .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
    }

    static Optional<Character> firstUnique(String s) {
        Map<Character, Long> freq = s.chars()
                .mapToObj(c -> (char) c)
                .collect(Collectors.groupingBy(Function.identity(),
                        LinkedHashMap::new, Collectors.counting()));   // порядок першої появи
        return freq.entrySet().stream()
                .filter(e -> e.getValue() == 1)
                .map(Map.Entry::getKey)
                .findFirst();
    }

    static List<String> fizzBuzz(int n) {
        return IntStream.rangeClosed(1, n)
                .mapToObj(i -> switch (i % 15) {
                    case 0 -> "FizzBuzz";
                    case 3, 6, 9, 12 -> "Fizz";
                    case 5, 10 -> "Buzz";
                    default -> String.valueOf(i);
                })
                .toList();
    }
}
// countWords("Test, test and TEST again") -> {test=3, and=1, again=1}
// firstUnique("swiss") -> Optional[w]
public final class StreamTasks {

    static Map<String, Long> countWords(String text) {
        if (text == null || text.isBlank()) return Map.of();         // empty input
        return Arrays.stream(text.toLowerCase().split("[^\\p{L}\\p{N}]+"))
                .filter(w -> !w.isEmpty())                            // drop the leading empty token
                .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
    }

    static Optional<Character> firstUnique(String s) {
        Map<Character, Long> freq = s.chars()
                .mapToObj(c -> (char) c)
                .collect(Collectors.groupingBy(Function.identity(),
                        LinkedHashMap::new, Collectors.counting()));   // order of first appearance
        return freq.entrySet().stream()
                .filter(e -> e.getValue() == 1)
                .map(Map.Entry::getKey)
                .findFirst();
    }

    static List<String> fizzBuzz(int n) {
        return IntStream.rangeClosed(1, n)
                .mapToObj(i -> switch (i % 15) {
                    case 0 -> "FizzBuzz";
                    case 3, 6, 9, 12 -> "Fizz";
                    case 5, 10 -> "Buzz";
                    default -> String.valueOf(i);
                })
                .toList();
    }
}
// countWords("Test, test and TEST again") -> {test=3, and=1, again=1}
// firstUnique("swiss") -> Optional[w]
🔧 Розбір синтаксису: Регулярний вираз [^\\p{L}\\p{N}]+ ділить текст за будь-якою послідовністю не-літер і не-цифр, тому розділові знаки й кирилиця обробляються коректно. s.chars() повертає IntStream, тож mapToObj(c -> (char) c) перетворює коди на Character. Трьохаргументний groupingBy приймає фабрику мапи. Остача від ділення на 15 одразу визначає, чи число кратне 3, 5 чи обом.
🔧 Syntax breakdown: The regex [^\\p{L}\\p{N}]+ splits text on any run of non-letters and non-digits, so punctuation and Cyrillic are handled correctly. s.chars() returns an IntStream, so mapToObj(c -> (char) c) turns codes into Character. The three-argument groupingBy accepts a map factory. The remainder modulo 15 tells at once whether a number is divisible by 3, 5 or both.
💡 Простими словами: groupingBy — це розкласти картки по купках за ознакою, а counting — порахувати, скільки карток у кожній купці.
💡 In plain words: groupingBy is sorting cards into piles by some feature, and counting is counting how many cards ended up in each pile.
🗣 Як читати вголос Перш ніж писати код, я уточнюю правила: регістр, розділові знаки, порожній вхід. Слова рахую через groupingBy з identity і counting, а для першого неповторного символу беру LinkedHashMap, бо мені потрібен порядок появи. Результат повертаю як Optional, щоб не вигадувати спеціальне значення. FizzBuzz можу написати потоком зі switch по остачі від п’ятнадцяти, але чесно кажу, що звичайний цикл тут не гірший. Мені важливо показати, що я обираю інструмент свідомо.
🗣 How to say it out loud Before writing code I clarify the rules: case, punctuation, empty input. I count words with groupingBy using identity and counting, and for the first non-repeating character I take a LinkedHashMap because I need the order of appearance. I return the result as an Optional instead of inventing a special value. I can write FizzBuzz as a stream with a switch on the remainder of fifteen, but I say honestly that a plain loop is no worse here. I want to show that I choose the tool deliberately.
75 Як би ти рефакторив величезний статичний TestUtils у фреймворку? How would you refactor a huge static TestUtils class in a framework?
Інструменти й задачі Toolbox and tasks Senior ▶

Ситуація. У проєкті є TestUtils на 1500 рядків: статичне поле driver, Thread.sleep(3000) у «клікових» методах, catch (Exception e) {}, генерація email, парсинг JSON, робота з БД і скріншоти впереміш. Паралельний запуск неможливий, падіння пояснити важко, а будь-яка зміна ламає десятки тестів.

The situation. The project has a 1,500-line TestUtils: a static driver field, Thread.sleep(3000) in "click" helpers, catch (Exception e) {}, email generation, JSON parsing, database access and screenshots all mixed together. Parallel runs are impossible, failures are hard to explain, and every change breaks dozens of tests.

Як я діяв би. Спершу — фіксую поведінку: пишу юніт-тести на чисті функції (генератори даних, форматери, парсери), бо їх перевіряти найлегше. Далі ділю клас за відповідальністю: TestDataGenerator, JsonHelper, DbClient, ScreenshotService. Чисті функції без стану лишаються статичними, а все, що має стан або залежність (драйвер, з’єднання), стає екземплярним класом, який отримує залежність у конструкторі. Thread.sleep замінюю явними очікуваннями, проковтнуті винятки — на прокидання з контекстом.

What I would do. First I pin down behaviour: I write unit tests for the pure functions (data generators, formatters, parsers), since they are the easiest to check. Then I split the class by responsibility: TestDataGenerator, JsonHelper, DbClient, ScreenshotService. Stateless pure functions stay static, while everything with state or a dependency (a driver, a connection) becomes an instance class that receives the dependency in its constructor. Thread.sleep is replaced with explicit waits, and swallowed exceptions are rethrown with context.

Інкрементальність. Великий переписаний PR ніхто не перевірить. Роблю кроками: новий клас, старий статичний метод делегує в нього й позначається @Deprecated, тести мігрують поступово. Правило в ревʼю — не додавати нового в TestUtils.

Incrementally. Nobody can review one huge rewrite PR. I go in steps: a new class appears, the old static method delegates to it and gets @Deprecated, and tests migrate gradually. The review rule is that nothing new goes into TestUtils.

Трейдоф. Інжекція залежностей робить код трохи довшим і змушує думати про життєвий цикл об’єктів, зате дає паралельність, ізоляцію і можливість підмінити залежність. Повністю переписати швидше на словах, але ризикованіше: стара поведінка, на яку покладаються тести, часто ніде не описана.

Trade-off. Dependency injection makes code a little longer and forces you to think about object lifecycles, but it brings parallelism, isolation and the ability to swap a dependency. A full rewrite sounds faster but is riskier: the old behaviour tests rely on is often documented nowhere.

Було і стало: статичний хаос проти класів з однією відповідальністю
Before and after: static chaos versus single-responsibility classes
// Було: глобальний драйвер, sleep і проковтнутий виняток
public class TestUtils {
    public static WebDriver driver;

    public static void click(String css) {
        try {
            Thread.sleep(3000);
            driver.findElement(By.cssSelector(css)).click();
        } catch (Exception e) { }
    }
}

// Стало: чиста функція лишається статичною
public final class TestDataGenerator {
    private TestDataGenerator() {}
    public static String uniqueEmail() {
        return "qa+" + UUID.randomUUID() + "@example.com";
    }
}

// Стало: стан і залежність передаються в конструктор
public final class UiActions {
    private final WebDriver driver;
    private final WebDriverWait wait;

    public UiActions(WebDriver driver, Duration timeout) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, timeout);
    }

    public void click(By locator) {
        wait.until(ExpectedConditions.elementToBeClickable(locator)).click(); // чекаємо стан, а не час
    }
}

// Міст на час міграції
// @Deprecated public static String email() { return TestDataGenerator.uniqueEmail(); }
// Before: a global driver, sleep and a swallowed exception
public class TestUtils {
    public static WebDriver driver;

    public static void click(String css) {
        try {
            Thread.sleep(3000);
            driver.findElement(By.cssSelector(css)).click();
        } catch (Exception e) { }
    }
}

// After: a pure function stays static
public final class TestDataGenerator {
    private TestDataGenerator() {}
    public static String uniqueEmail() {
        return "qa+" + UUID.randomUUID() + "@example.com";
    }
}

// After: state and dependency come through the constructor
public final class UiActions {
    private final WebDriver driver;
    private final WebDriverWait wait;

    public UiActions(WebDriver driver, Duration timeout) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, timeout);
    }

    public void click(By locator) {
        wait.until(ExpectedConditions.elementToBeClickable(locator)).click(); // wait for a state, not for time
    }
}

// A bridge during migration
// @Deprecated public static String email() { return TestDataGenerator.uniqueEmail(); }
🔧 Розбір синтаксису: У старому коді catch (Exception e) { } ховає і NoSuchElementException, і InterruptedException, тож тест іде далі в неправильному стані. TestDataGenerator не має полів, тому статичний метод безпечний у будь-якому потоці. UiActions отримує драйвер у конструкторі, отже кожен потік створює власний екземпляр. elementToBeClickable чекає реальний стан елемента, а виняток таймауту долітає до звіту.
🔧 Syntax breakdown: In the old code catch (Exception e) { } hides both NoSuchElementException and InterruptedException, so the test carries on in a wrong state. TestDataGenerator has no fields, so its static method is safe on any thread. UiActions receives the driver in its constructor, so each thread creates its own instance. elementToBeClickable waits for the element's real state, and a timeout exception reaches the report.
💡 Простими словами: Рефакторинг «звалища утиліт» — це переїзд з комори, де все в одній купі: спершу підписуєш коробки, потім переносиш по одній і не кладеш нічого нового в стару комору.
💡 In plain words: Refactoring a "utility dump" is moving out of a storeroom where everything is in one pile: first you label the boxes, then you carry them one at a time and put nothing new back into the old room.
🗣 Як читати вголос Я б не переписував такий клас одним махом. Спершу покрив би тестами чисті функції, потім розрізав клас за відповідальністю: дані, JSON, база, UI-дії. Усе без стану лишив би статичним, а все, що тримає драйвер чи з’єднання, зробив би звичайним класом із залежністю в конструкторі — це й відкриває паралельний запуск. Старі методи на час міграції делегують у нові й позначені Deprecated. А sleep і порожні catch прибираю першими, бо саме вони ховають справжні причини падінь.
🗣 How to say it out loud I would not rewrite such a class in one go. First I would cover the pure functions with tests, then cut the class by responsibility: data, JSON, database, UI actions. Everything stateless would stay static, and everything holding a driver or a connection would become a normal class with the dependency in its constructor, which is exactly what unlocks parallel runs. During migration the old methods delegate to the new ones and are marked Deprecated. And I remove the sleeps and empty catches first, because they are what hides the real causes of failures.