Що це. 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.
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, щоб у лозі було видно реальне середовище.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.Що це. 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.
<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.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.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.
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 з кількох потоків без синхронізації може загубити елементи або кинути виняток.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.Що це. 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.
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 форкає для тестів.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.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.
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 друкує список замовлень, кожне замовлення друкує клієнта, і цикл ніколи не закінчується, доки стек не переповниться. Розрив циклу — друкувати в одному з класів лише ідентифікатор або ім’я, а не весь зв’язаний об’єкт.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.Що це. Витік пам’яті в 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().
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 для розбору.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.Вісім примітивів. 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.
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 — спосіб явно зловити переповнення замість тихого загортання.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.Що це. 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 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.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.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.
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, а повертає новий рядок — без присвоєння результат зникає.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.Що це. == для об’єктів порівнює посилання: чи це той самий об’єкт у пам’яті. 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.
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 з обох боків.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 — «у чашках той самий чай?». У тестах нас майже завжди цікавить чай.== asks "is it the same cup?", while equals asks "is it the same tea?". In tests we nearly always care about the tea.Що це. 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.
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.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.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.
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' перетворює символ цифри на число.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.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.
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 видна одразу.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.Що це. 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.
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'
%s підставляє аргументи formatted, що працює як String.format. Зворотна скісна риска в кінці рядка SQL прибирає перенос, а закривальні лапки на тому ж рядку не додають фінального \n.%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.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.
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.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.Що це. Чотири класичні принципи — інкапсуляція, успадкування, поліморфізм і абстракція. На співбесіді автоматизатора мало їх перелічити: інтерв’юер чекає, що ти покажеш кожен на власному фреймворку, бо саме там видно, чи розумієш ти їх, чи вивчив визначення.
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.
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.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.Що це. Інкапсуляція — це приховування внутрішнього стану об’єкта і доступ до нього лише через методи, які гарантують коректність. Поля роблять 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.
// Погано: тест керує елементами напряму
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() повертає об’єкт наступної сторінки — так навігація теж інкапсульована і читається як сценарій.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.Поліморфізм. Один інтерфейс — багато реалізацій. Змінна оголошена типом батька чи інтерфейсу, а метод, який реально виконається, 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.
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 скорочене до коментаря, щоб приклад лишався компактним.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.Що це. Конструктор — спеціальний блок, який виконується під час 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.
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, а потім підклас додає очікування спінера.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.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.
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 може її захопити.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.Що це. У 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.
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] отримує елемент першим аргументом скрипту.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.Різниця. Абстрактний клас може мати стан (поля екземпляра), конструктори і будь-які модифікатори доступу, але клас успадковує лише одного батька. Інтерфейс не має стану екземпляра (лише 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.
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, а поведінку шапки — від інтерфейсу.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.Проблема. Типова еволюція фреймворку: у 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.
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 успадковує лише базову сторінку, а таблицю й шапку тримає як поля і делегує їм роботу.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.Маркерний інтерфейс. Це інтерфейс без жодного методу, який лише «позначає» клас для 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.
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.@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.Що це. Метод 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.
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.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.Що це. 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.
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.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.Що це. 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.
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, інакше компілятор повідомить про недосяжну гілку. Вичерпність перевіряється під час компіляції.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.Що це. 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.
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 у кінець, а решту порівнює природно.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.Що це. Незмінний об’єкт після створення не змінює свого стану: усі поля 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.
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.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.Що це. 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.
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) { ... }
System.getProperty("env", "dev") повертає значення за замовчуванням, якщо властивість не задана. toUpperCase(Locale.ROOT) захищає від сюрпризів локалі (наприклад, турецької «i»). Помилка перекидається з причиною і списком з values(). У switch по enum константи пишуться без імені класу.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.Ієрархія. Кореневий інтерфейс — 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.
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) виводить параметр типу з лівої частини.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.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.
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 порівнює набори пар, тож мапи різних типів з однаковими даними рівні.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.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.
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 як сиру місткість масиву.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.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().
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.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.Що це. 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.
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 працює зі знімком масиву на момент старту, тому додавання під час циклу не зачіпає поточний прохід.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.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<>(...).
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<>(...).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<>(...).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.
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.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.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.
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.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.Навіщо. 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.
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-и.<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 інваріантні: 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.
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>.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>.Сценарій. Тест вивантажує з 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.
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().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().Що це. Корінь ієрархії — 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.
@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), а які ні.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.Що це. 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.
// Не компілюється: 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.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.Що це. 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.
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") — вже сам цей рядок має насторожувати на рев’ю.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.Що це. 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.
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() повертає масив винятків, що виникли під час закриття.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.Що це. Власний виняток — це клас-нащадок 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.
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-виняток.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.Що це. 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).
// Погано: будь-яка помилка стає «елемента немає»
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 всередині не можна перепризначити. Повтор лише один і лише для відомих тимчасових станів.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.Принцип меж. Винятки ловлять не там, де вони виникли, а на межах, де є що з ними зробити: клієнт 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.
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 з усіма розбіжностями разом.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.Що це. Лямбда — це компактний запис реалізації функціонального інтерфейсу, тобто інтерфейсу з одним абстрактним методом. Форми запису: () -> 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.
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 перевіряє значення до присвоєння поля.() -> 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.Що це. Функціональний інтерфейс має рівно один абстрактний метод; 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.
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 повертає перший успішний результат або кидає останній виняток.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.Що це. Посилання на метод — скорочений запис лямбди, яка лише викликає вже наявний метод. Замість 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.
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) передає генератор масиву потрібного розміру.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.Що це. 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.
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 кидає виняток, тому для другого підрахунку створюємо новий стрім із колекції.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.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.
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 — посилання на метод, що робить з кожного рядка таблиці стрім клітинок.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.Збирання в список. 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.
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]» для повідомлення про падіння.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.Що це. 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".
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>> з обома ключами.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.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.
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) робить його скінченним.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.Що це. 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.
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.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.Що це. 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.
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() чекає завершення задач.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.Способи створення. Можна успадкувати 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.
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 сам дочекається задач і закриє пул.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.Що це. 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.
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 гарантує закриття пулу навіть при падінні.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.Що це. 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.
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, і не гарантував би видимість останнього значення.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.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.
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 після завершення тестів.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.Що це. 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.
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 прибирає циклічне очікування, а таймаут страхує від місць, де порядок хтось порушив.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.Що це. 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.
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() впаде, посилання на драйвер буде прибране з потоку.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.Що це. 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.
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() блокує лише в кінці, коли дані справді потрібні тесту.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.Сценарій. Набір із 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.
// 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 запускає клас окремо від усіх інших тестів.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.Що це. Для роботи з файлами в сучасній 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.
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 повертає незмінний список.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.Що це. Пакет 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.
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.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.Що це. Анотація — це метадані, прикріплені до класу, методу чи поля. Сама по собі вона нічого не робить: хтось має її прочитати. Рефлексія — 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.
@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, коли анотації немає.@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.Що це. Параметризований тест — один метод, який 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.
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 приводить текст до типів параметрів методу.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.Що це. 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.
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 забороняє створювати екземпляри утилітного класу.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.Проблема 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.
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 отримує стратегію в конструкторі й нічого не знає про її тип.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.Що перевіряють. Ці задачі дають не заради алгоритму, а щоб побачити, чи знаєш ти стандартну бібліотеку, чи думаєш про крайові випадки і чи можеш пояснити складність. Відповідь «спершу найпростіше робоче рішення, потім покращення» майже завжди краща за мовчазне написання хитрого коду.
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.
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.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.Підрахунок слів. Класика: розбити текст на слова і порахувати кожне. Потоком це 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.
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 чи обом.[^\\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.Ситуація. У проєкті є 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.
// Було: глобальний драйвер, 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 чекає реальний стан елемента, а виняток таймауту долітає до звіту.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.