← Valtive QA Quiz quiz.valtive.io/notes/pythonqa valtive.io
📖 Про цей гайд📖 About this guide
Для когоWho it's for
Для QA-інженерів, які йдуть на співбесіду на позицію автоматизатора з Python: від мануальника, що пише перші скрипти, до senior, якого попросять спроєктувати фреймворк для API й UI. Гайд покриває всі 100 питань квізу «Python Core» і додає те, що на співбесідах питають поза квізом: mock, схеми відповідей, часові зони, конкурентність, CI і лайв-кодинг.
For QA engineers heading into a Python automation interview: from a manual tester writing first scripts to a senior who will be asked to design a framework for API and UI. The guide covers all 100 questions of the «Python Core» quiz and adds what interviews ask beyond it: mocks, response schemas, time zones, concurrency, CI and live coding.
Як користуватисьHow to use it
Кожен приклад коду запускається як є: збережи його у файл і виконай командою з картки, а блок «Вивід» під ним показує, що було насправді. Поміняй одне значення й подивись, як зміниться результат, — пастки Python так запам'ятовуються швидше, ніж з тексту. Потім закрий картку й розкажи відповідь уголос за блоком 🗣 Як читати вголос: на співбесіді оцінюють, як ти пояснюєш і які приклади наводиш, а не чи пам'ятаєш назву параметра.
Every code example runs as is: save it to a file and run the command from the card, and the «Output» block under it shows what really happened. Change one value and watch the result move — Python traps stick faster that way than from reading. Then close the card and give the answer out loud using the 🗣 How to say it out loud block: an interview judges how you explain and which examples you pick, not whether you remember a parameter name.
Три якорі — з них випливає більшість відповідейThree anchors — most answers follow from them
ЯКІР 1 · Мова без пастокANCHOR 1 · The language without traps
Змінна в Python — це ярлик на об'єкт, а не коробка зі значенням. Звідси майже всі пастки: спільний список у значенні за замовчуванням, копія, що змінює оригінал, і різниця між is та ==.
A Python variable is a label on an object, not a box holding a value. Nearly every trap comes from that: a shared list in a default argument, a copy that changes the original, and the gap between is and ==.
ЯКІР 2 · pytest і фікстуриANCHOR 2 · pytest and fixtures
Тест у pytest — це функція з assert, а все, що їй потрібно, приходить через фікстури. Фікстура з yield готує дані й прибирає після себе, scope каже, як часто її створювати, а conftest.py робить її спільною.
A pytest test is a function with an assert, and everything it needs arrives through fixtures. A yield fixture prepares data and cleans up after itself, scope says how often it is created, and conftest.py makes it shared.
ЯКІР 3 · API й UI автотестиANCHOR 3 · API and UI automation
HTTP-запит без timeout може висіти вічно, а UI-тест зі sleep — або повільний, або нестабільний. Сесія, повтори на рівні транспорту, перевірка схеми й явні очікування роблять тести і швидкими, і надійними.
An HTTP call without a timeout can hang forever, and a UI test with sleep is either slow or flaky. A session, transport-level retries, schema checks and explicit waits make tests both fast and reliable.
74
питаньquestions
3
рівніlevels
—
voice-блокиvoice blocks
100/100
питань квізу покритоquiz questions covered
Як повторити прикладиHow to reproduce the examples
Приклади перевірені на Python 3.11 і 3.13, pytest 9, requests 2.34, pydantic 2 і Playwright 1.63; вивід у картках знято з реальних запусків, лише шляхи до тимчасових тек скорочено. Створи окреме віртуальне середовище й постав пакети з блоку нижче. Файли з назвою test_*.py запускай через pytest -q, решту — через python. Приклади з requests звертаються до публічного сервісу httpbin.org, тому для них потрібен інтернет; приклади з Playwright і Selenium відкривають локальну HTML-сторінку й мережі не потребують.
The examples were checked on Python 3.11 and 3.13, pytest 9, requests 2.34, pydantic 2 and Playwright 1.63; the output in the cards comes from real runs, with only temporary folder paths shortened. Create a separate virtual environment and install the packages from the block below. Run files named test_*.py with pytest -q and everything else with python. The requests examples call the public service httpbin.org, so they need internet access; the Playwright and Selenium examples open a local HTML page and need no network.
python3 -m venv .venv
source .venv/bin/activate
pip install pytest requests pydantic jsonschema pyyaml httpx \
    pytest-xdist pytest-rerunfailures pytest-html pytest-playwright selenium
playwright install chromium
📚 9 РОЗДІЛІВ📚 9 SECTIONS
—
Junior
—
Middle
—
Senior
▶ 1 8
1 Що означає mutable та immutable і чим list відрізняється від tuple? What do mutable and immutable mean, and how does a list differ from a tuple?
Основи мовиLanguage basics Junior ▶

Що це. Mutable-об'єкт можна змінити на місці, не створюючи новий: list, dict, set, bytearray. Immutable-об'єкт після створення не змінюється: int, float, str, tuple, frozenset, bytes. Коли ти пишеш s += "x" для рядка, Python створює новий рядок і перевішує на нього ім'я, а для списку += дописує елементи в той самий об'єкт. Перевірити це легко через id().

What it is. A mutable object can be changed in place without creating a new one: list, dict, set, bytearray. An immutable object never changes after creation: int, float, str, tuple, frozenset, bytes. When you write s += "x" on a string, Python builds a new string and moves the name to it, while += on a list appends into the same object. id() makes this easy to see.

list чи tuple. Список — змінна послідовність однотипних елементів, яка росте й скорочується: результати тестів, ID створених користувачів. Кортеж — незмінний запис фіксованої довжини, де позиція має зміст: ("admin", 200), координати, рядок таблиці параметрів. Кортеж можна зробити ключем словника чи елементом множини, бо він хешується (якщо всередині теж лише хешовані значення), а список — ні.

list or tuple. A list is a changeable sequence of similar items that grows and shrinks: test results, IDs of created users. A tuple is a fixed-length immutable record where position carries meaning: ("admin", 200), coordinates, a row of a parameter table. A tuple can be a dict key or a set member because it is hashable (as long as everything inside is hashable too); a list cannot.

Навіщо QA. Незмінні константи тестових даних захищають від тесту, який випадково змінив спільний набір і зламав сусіда. Набори для parametrize зручно писати кортежами.

Why QA cares. Immutable test-data constants protect you from a test that quietly changed a shared data set and broke its neighbour. Rows for parametrize read naturally as tuples.

Підводні камені. Незмінність кортежу поверхнева: t = ([1], 2) не дасть замінити t[0], але t[0].append(3) спрацює. І кортеж з одного елемента пишеться з комою: (5,), а (5) — це просто число в дужках.

Pitfalls. Tuple immutability is shallow: t = ([1], 2) will not let you replace t[0], but t[0].append(3) works. And a one-element tuple needs a comma: (5,), while (5) is just a number in parentheses.

Той самий += для рядка і для списку
The same += on a string and on a list
s = "abc"
before = id(s)
s += "d"
print("str same object:", id(s) == before)

items = [1, 2]
before = id(items)
items += [3]
print("list same object:", id(items) == before)

record = ("admin", 200)
print({record: "ok"})  # кортеж може бути ключем словника
try:
    {[1, 2]: "no"}
except TypeError as e:
    print("list as key:", e)

t = ([1], 2)
t[0].append(3)  # змінився не кортеж, а список усередині
print(t, type((5)), type((5,)))
s = "abc"
before = id(s)
s += "d"
print("str same object:", id(s) == before)

items = [1, 2]
before = id(items)
items += [3]
print("list same object:", id(items) == before)

record = ("admin", 200)
print({record: "ok"})  # a tuple can be a dict key
try:
    {[1, 2]: "no"}
except TypeError as e:
    print("list as key:", e)

t = ([1], 2)
t[0].append(3)  # the tuple did not change, the list inside did
print(t, type((5)), type((5,)))
Вивід
Output
str same object: False
list same object: True
{('admin', 200): 'ok'}
list as key: unhashable type: 'list'
([1, 3], 2) <class 'int'> <class 'tuple'>
🔧 Розбір коду: id() повертає ідентичність об'єкта: однаковий id до й після означає зміну на місці. try/except TypeError ловить спробу використати нехешований список як ключ. Останній рядок показує, що вміст кортежу може змінитися, якщо там лежить список.
🔧 Code breakdown: id() returns the object identity: the same id before and after means an in-place change. try/except TypeError catches the attempt to use an unhashable list as a key. The last line shows a tuple's contents can still change when it holds a list.
💡 Простими словами: Список — кошик, куди докладаєш речі; кортеж — надрукований чек, де кожен рядок на своєму місці.
💡 In plain words: A list is a basket you keep adding to; a tuple is a printed receipt where every line has its fixed place.
🗣 Як читати вголос Я розділяю типи за тим, чи можна змінити об'єкт на місці. Список, словник і множина змінюються, а число, рядок і кортеж — ні: будь-яка «зміна» рядка створює новий об'єкт. Список беру для колекцій, що ростуть, а кортеж — для записів фіксованої форми, наприклад рядків для parametrize, і тоді, коли потрібен ключ словника. Одразу згадую нюанс: кортеж захищає лише посилання, тож список усередині нього все одно можна змінити.
🗣 How to say it out loud I split types by whether the object can change in place. Lists, dicts and sets can; numbers, strings and tuples cannot, so any «change» to a string really creates a new object. I reach for a list when a collection grows and for a tuple when the record has a fixed shape, like rows for parametrize, or when I need a dict key. I always add the nuance that a tuple only freezes its references, so a list stored inside it can still be modified.
2 Чим is відрізняється від == і як правильно перевіряти змінну на None? How does is differ from ==, and how do you check a variable for None correctly?
Основи мовиLanguage basics Junior ▶

Що це. == порівнює значення: викликає __eq__ і питає «чи рівні ці об'єкти за змістом». is порівнює ідентичність: чи обидва імені вказують на той самий об'єкт у пам'яті. Два різні списки [1, 2] рівні за ==, але is для них дасть False.

What it is. == compares values: it calls __eq__ and asks «are these objects equal in content». is compares identity: do both names point to the very same object in memory. Two separate lists [1, 2] are equal by ==, yet is returns False for them.

None. None — єдиний об'єкт типу NoneType, який означає «значення відсутнє». Функція без return повертає саме його. Оскільки він у програмі один, перевірка пишеться if x is None або if x is not None, як радить PEP 8. x == None працює здебільшого, але клас може перевизначити __eq__ і сказати «так» будь-чому.

None. None is the single object of type NoneType and means «no value». A function without return returns exactly that. Because there is only one of it, the check is written if x is None or if x is not None, as PEP 8 recommends. x == None mostly works, but a class can override __eq__ and say «yes» to anything.

Де це важливо в тестах. Якщо поле відповіді може бути 0, "" чи [], умова if not value сплутає їх з відсутнім значенням. Перевірка is None відрізняє «поле прийшло порожнім» від «поля немає».

Where it matters in tests. If a response field can be 0, "" or [], a condition like if not value confuses them with a missing value. An is None check tells «the field came back empty» apart from «there is no field».

Підводні камені. Не порівнюй через is числа й рядки: a is b для 256 може бути True через кеш малих цілих, а для 1000, отриманого обчисленням, — False. Це деталь реалізації CPython, а не правило мови, і Python 3.8+ навіть попереджає про is з літералом.

Pitfalls. Do not compare numbers and strings with is: a is b may be True for 256 thanks to the small-int cache and False for a computed 1000. That is a CPython implementation detail, not a language rule, and Python 3.8+ even warns about is with a literal.

Рівні, але не ті самі
Equal but not the same
a = [1, 2]
b = [1, 2]
c = a
print(a == b, a is b, a is c)

class AlwaysEqual:
    def __eq__(self, other):
        return True

weird = AlwaysEqual()
print("== None:", weird == None)  # noqa: E711, __eq__ обманює
print("is None:", weird is None)

def find_discount(user):
    return user.get("discount")

for user in [{"discount": 0}, {}]:
    d = find_discount(user)
    print(repr(d), "missing" if d is None else "present")
a = [1, 2]
b = [1, 2]
c = a
print(a == b, a is b, a is c)

class AlwaysEqual:
    def __eq__(self, other):
        return True

weird = AlwaysEqual()
print("== None:", weird == None)  # noqa: E711, fooled by __eq__
print("is None:", weird is None)

def find_discount(user):
    return user.get("discount")

for user in [{"discount": 0}, {}]:
    d = find_discount(user)
    print(repr(d), "missing" if d is None else "present")
Вивід
Output
True False True
== None: True
is None: False
0 present
None missing
🔧 Розбір коду: c = a не копіює список, а дає йому друге ім'я, тому a is c дорівнює True. Клас AlwaysEqual показує, чому == None ненадійне. dict.get повертає None для відсутнього ключа, і is None не плутає його зі знижкою 0.
🔧 Code breakdown: c = a does not copy the list, it gives it a second name, so a is c is True. The AlwaysEqual class shows why == None is unreliable. dict.get returns None for a missing key, and is None never confuses it with a discount of 0.
💡 Простими словами: == питає «чи однакові вони», is питає «чи це той самий предмет».
💡 In plain words: == asks «are they alike», is asks «is it the very same thing».
🗣 Як читати вголос Для мене == — це рівність за значенням через метод __eq__, а is — перевірка, що обидва імені дивляться на один об'єкт. Тому is я використовую майже лише для None, бо None в програмі один, і так радить PEP 8. У тестах це важливо: якщо API повертає знижку нуль, умова «if not discount» вирішить, що знижки немає, а «is None» відрізнить нуль від відсутнього поля. Числа й рядки через is не порівнюю, бо там можна вловити кеш CPython.
🗣 How to say it out loud To me == is equality by value through the __eq__ method, and is checks that both names look at one object. So I use is almost only for None, since there is exactly one None and PEP 8 asks for it. It matters in tests: if an API returns a discount of zero, «if not discount» decides there is no discount, while «is None» separates zero from a missing field. I never compare numbers or strings with is, because the CPython cache can fool you there.
3 Чому змінюване значення за замовчуванням — пастка і чому acc = acc or [] теж може бути багом? Why is a mutable default argument a trap, and why can acc = acc or [] also be a bug?
Основи мовиLanguage basics Senior ▶

Що відбувається. Значення за замовчуванням обчислюється один раз — коли виконується рядок def, а не при кожному виклику. Якщо це список, усі виклики без аргументу працюють з тим самим списком, і дані «протікають» між викликами. У тестовому хелпері це виглядає як тест, що проходить окремо й падає в повному прогоні, бо попередній тест уже наповнив спільний список.

What happens. A default value is evaluated once — when the def line runs — not on every call. If it is a list, every call without that argument works with the same list and data leaks between calls. In a test helper this shows up as a test that passes alone and fails in the full run, because an earlier test already filled the shared list.

Чому acc or [] не рятує. Порожній список хибний, тому acc = acc or [] замінює і None, і явно переданий порожній список новим. Виклик, який передав свій [] і чекає, що функція заповнить саме його, отримає порожній результат, а дані підуть у внутрішній список, якого викликач не бачить. Та сама проблема з 0, "" і порожнім словником.

Why acc or [] does not save you. An empty list is falsy, so acc = acc or [] replaces both None and an explicitly passed empty list with a new one. A caller that passed its own [] and expects the function to fill it gets an empty result, while the data goes into an internal list the caller never sees. The same goes for 0, "" and an empty dict.

Правильна ідіома. Значення за замовчуванням None і перевірка if acc is None: acc = [] у тілі функції. Вона реагує лише на відсутність аргументу і не чіпає хибні, але валідні значення. Якщо None теж допустиме значення, використовують окремий маркер _MISSING = object().

The correct idiom. Default to None and check if acc is None: acc = [] in the body. It reacts only to a missing argument and leaves falsy but valid values alone. If None is itself a legal value, use a separate sentinel like _MISSING = object().

Як ловити. Лінтер ruff має правило B006 (з набору flake8-bugbear), що підсвічує змінювані значення за замовчуванням. На рев'ю я дивлюся ще й на dataclass: там списки задають через field(default_factory=list).

How to catch it. The ruff linter has rule B006 (from the flake8-bugbear set) that flags mutable defaults. In review I also look at dataclass: lists there go through field(default_factory=list).

Три версії одного хелпера
Three versions of one helper
def collect_bad(item, acc=[]):
    acc.append(item)
    return acc

print(collect_bad("a"), collect_bad("b"))  # обидва виклики ділять один список

def collect_or(item, acc=None):
    acc = acc or []  # переданий порожній список замінюється
    acc.append(item)
    return acc

mine = []
collect_or("x", mine)
print("or-version, caller list:", mine)

def collect(item, acc=None):
    if acc is None:
        acc = []
    acc.append(item)
    return acc

mine = []
collect("x", mine)
print("is None version, caller list:", mine)
print(collect("a"), collect("b"))
def collect_bad(item, acc=[]):
    acc.append(item)
    return acc

print(collect_bad("a"), collect_bad("b"))  # both calls share one list

def collect_or(item, acc=None):
    acc = acc or []  # an empty list passed in is replaced
    acc.append(item)
    return acc

mine = []
collect_or("x", mine)
print("or-version, caller list:", mine)

def collect(item, acc=None):
    if acc is None:
        acc = []
    acc.append(item)
    return acc

mine = []
collect("x", mine)
print("is None version, caller list:", mine)
print(collect("a"), collect("b"))
Вивід
Output
['a', 'b'] ['a', 'b']
or-version, caller list: []
is None version, caller list: ['x']
['a'] ['b']
🔧 Розбір коду: acc=[] у сигнатурі створюється один раз, тому collect_bad накопичує дані між викликами. У collect_or порожній mine хибний, і функція пише в новий список. if acc is None створює новий список лише тоді, коли аргументу не передали.
🔧 Code breakdown: acc=[] in the signature is created once, so collect_bad accumulates data across calls. In collect_or the empty mine is falsy and the function writes into a new list. if acc is None creates a fresh list only when no argument was passed.
💡 Простими словами: Значення за замовчуванням — це одна спільна тарілка на всіх гостей; None каже «принеси свою або візьми нову».
💡 In plain words: A default value is one shared plate for every guest; None says «bring your own or take a fresh one».
🗣 Як читати вголос Я пояснюю, що дефолт обчислюється один раз при визначенні функції, тож список у сигнатурі — спільний для всіх викликів. Популярний фікс «acc = acc or []» я вважаю неповним: порожній список хибний, і якщо викликач передав свій порожній список, функція мовчки створить новий і заповнить не той. Правильно ставити None і перевіряти «acc is None». У проєкті це ловить правило B006 у ruff, а в dataclass я використовую default_factory.
🗣 How to say it out loud I explain that a default is evaluated once when the function is defined, so a list in the signature is shared by every call. I call the popular «acc = acc or []» fix incomplete: an empty list is falsy, so if the caller passes its own empty list the function silently makes a new one and fills the wrong list. The right way is a None default and an «acc is None» check. In a project, ruff rule B006 catches it, and in a dataclass I use default_factory.
4 Які значення в Python хибні і як truthiness впливає на перевірки в тестах? Which values are falsy in Python, and how does truthiness affect checks in tests?
Основи мовиLanguage basics Junior ▶

Що це. У контексті if, while, and, or і not будь-який об'єкт перетворюється на True або False. Хибні: None, False, нулі всіх числових типів (0, 0.0, 0j, Decimal(0)), порожні колекції й рядки ("", [], (), {}, set(), range(0)). Усе інше за замовчуванням істинне, зокрема рядок "0" і "False".

What it is. In the context of if, while, and, or and not any object becomes True or False. Falsy values: None, False, zeros of every numeric type (0, 0.0, 0j, Decimal(0)), empty collections and strings ("", [], (), {}, set(), range(0)). Everything else is truthy by default, including the strings "0" and "False".

Власні класи. Об'єкт стає хибним, якщо його __bool__ повертає False або, коли __bool__ немає, __len__ повертає 0.

Your own classes. An object becomes falsy when its __bool__ returns False or, if there is no __bool__, when __len__ returns 0.

Де це допомагає. if not errors: читається природно: список помилок порожній. assert items перевіряє, що пошук щось знайшов, а while queue: крутить цикл, доки черга задач на обробку не спорожніє.

Where it helps. if not errors: reads naturally: the error list is empty. assert items checks that a search found something, and while queue: keeps looping until the work queue is empty.

Підводні камені. and і or повертають не bool, а один з операндів: "" or "default" дасть "default". assert response.json()["count"] впаде, коли count законно дорівнює нулю, тож коли важливе конкретне значення, порівнюй явно: == 0, is None, == []. І пам'ятай про bool("False"): змінна середовища DEBUG=False — це непорожній рядок, тобто істина.

Pitfalls. and and or return one of the operands, not a bool: "" or "default" gives "default". assert response.json()["count"] fails when count is legitimately zero, so when the exact value matters, compare explicitly: == 0, is None, == []. And remember bool("False"): an environment variable DEBUG=False is a non-empty string, which is truthy.

Що Python вважає хибним
What Python treats as false
from decimal import Decimal

values = [None, False, 0, 0.0, Decimal(0), "", [], {}, set(), range(0), "0", "False", [0]]
print([v for v in values if not v])

class Cart:
    def __init__(self, items):
        self.items = items
    def __len__(self):
        return len(self.items)

print(bool(Cart([])), bool(Cart(["book"])))
print(repr("" or "default"), repr(0 and 1/0))  # and зупиняється, ZeroDivisionError немає

response = {"count": 0}
print("naive check passes:", bool(response["count"]))
print("explicit check passes:", response["count"] == 0)
from decimal import Decimal

values = [None, False, 0, 0.0, Decimal(0), "", [], {}, set(), range(0), "0", "False", [0]]
print([v for v in values if not v])

class Cart:
    def __init__(self, items):
        self.items = items
    def __len__(self):
        return len(self.items)

print(bool(Cart([])), bool(Cart(["book"])))
print(repr("" or "default"), repr(0 and 1/0))  # and stops early, no ZeroDivisionError

response = {"count": 0}
print("naive check passes:", bool(response["count"]))
print("explicit check passes:", response["count"] == 0)
Вивід
Output
[None, False, 0, 0.0, Decimal('0'), '', [], {}, set(), range(0, 0)]
False True
'default' 0
naive check passes: False
explicit check passes: True
🔧 Розбір коду: Список з if not v залишає лише хибні значення: "0", "False" і [0] туди не потрапили. Cart без __bool__ бере істинність з __len__. 0 and 1/0 повертає 0 і не виконує ділення, бо and обчислює лише потрібне.
🔧 Code breakdown: The list with if not v keeps only falsy values: "0", "False" and [0] did not get in. Cart has no __bool__, so its truthiness comes from __len__. 0 and 1/0 returns 0 without dividing, because and evaluates only what it needs.
💡 Простими словами: Хибне — це «нічого»: нуль, порожнеча або None; рядок з текстом «False» — це вже «щось».
💡 In plain words: Falsy means «nothing»: zero, emptiness or None; a string that says «False» is already «something».
🗣 Як читати вголос Хибними я називаю None, False, нулі та порожні рядки й колекції, а все інше — істинне, навіть рядок «0». Власний клас стає хибним через __bool__ або нульову довжину. У тестах я використовую truthiness для читабельних перевірок на кшталт «список помилок порожній», але коли нуль — нормальне значення, як лічильник у відповіді, порівнюю явно, інакше тест впаде на валідних даних. Ще одна пастка — змінні середовища, бо рядок «False» істинний.
🗣 How to say it out loud I list None, False, zeros and empty strings and collections as falsy, and everything else as truthy, even the string «0». A custom class becomes falsy through __bool__ or a zero length. In tests I use truthiness for readable checks like «the error list is empty», but when zero is a normal value, like a counter in a response, I compare explicitly, otherwise the test fails on valid data. Environment variables are another trap, since the string «False» is truthy.
5 Чим shallow copy відрізняється від deep copy і що повертає [1, 2] * 2? How does a shallow copy differ from a deep copy, and what does [1, 2] * 2 return?
Основи мовиLanguage basics Middle ▶

Присвоєння — не копія. b = a дає тому самому об'єкту друге ім'я. Зміна через b видна через a.

Assignment is not a copy. b = a gives the same object a second name. A change through b is visible through a.

Shallow copy. copy.copy(x), list(x), x[:], dict(x) і x.copy() створюють новий зовнішній контейнер, але кладуть у нього ті самі внутрішні об'єкти. Додати елемент у копію безпечно, а змінити вкладений словник — ні: зміна з'явиться в оригіналі.

Shallow copy. copy.copy(x), list(x), x[:], dict(x) and x.copy() build a new outer container but put the same inner objects into it. Appending to the copy is safe; changing a nested dict is not: the change shows up in the original.

Deep copy. copy.deepcopy(x) рекурсивно копіює все дерево й правильно обробляє циклічні посилання. Це те, що потрібно, коли тест бере шаблон JSON-тіла запиту й змінює в ньому вкладені поля: без глибокої копії наступний тест отримає вже зіпсований шаблон.

Deep copy. copy.deepcopy(x) copies the whole tree recursively and handles circular references correctly. That is what you need when a test takes a JSON request body template and edits nested fields: without a deep copy the next test receives an already damaged template.

Множення списку. [1, 2] * 2 повертає новий список [1, 2, 1, 2] — повтор елементів, а не множення чисел. Для незмінних елементів це нешкідливо. Пастка — [[0] * 3] * 3: зовнішній список містить три посилання на один внутрішній, тож присвоєння в одну «клітинку» змінює цілий стовпчик. Сітку створюють через comprehension: [[0] * 3 for _ in range(3)].

List multiplication. [1, 2] * 2 returns a new list [1, 2, 1, 2] — repetition of items, not arithmetic. With immutable items that is harmless. The trap is [[0] * 3] * 3: the outer list holds three references to one inner list, so assigning to one «cell» changes a whole column. Build a grid with a comprehension: [[0] * 3 for _ in range(3)].

Підводні камені. deepcopy повільніший і не завжди можливий: об'єкти з відкритими з'єднаннями чи файлами копіювати не варто.

Pitfalls. deepcopy is slower and not always possible: objects with open connections or files should not be copied.

Шаблон тіла запиту і сітка
A request body template and a grid
import copy

TEMPLATE = {"user": {"name": "Ann", "roles": ["viewer"]}, "active": True}

shallow = copy.copy(TEMPLATE)
shallow["user"]["roles"].append("admin")  # вкладений список спільний
print("after shallow edit:", TEMPLATE["user"]["roles"])

TEMPLATE["user"]["roles"] = ["viewer"]
deep = copy.deepcopy(TEMPLATE)
deep["user"]["roles"].append("admin")
print("after deep edit:   ", TEMPLATE["user"]["roles"])

print([1, 2] * 2)
grid = [[0] * 3] * 3
grid[0][0] = 9
print("shared rows:", grid)
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 9
print("own rows:   ", grid)
import copy

TEMPLATE = {"user": {"name": "Ann", "roles": ["viewer"]}, "active": True}

shallow = copy.copy(TEMPLATE)
shallow["user"]["roles"].append("admin")  # nested list is shared
print("after shallow edit:", TEMPLATE["user"]["roles"])

TEMPLATE["user"]["roles"] = ["viewer"]
deep = copy.deepcopy(TEMPLATE)
deep["user"]["roles"].append("admin")
print("after deep edit:   ", TEMPLATE["user"]["roles"])

print([1, 2] * 2)
grid = [[0] * 3] * 3
grid[0][0] = 9
print("shared rows:", grid)
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 9
print("own rows:   ", grid)
Вивід
Output
after shallow edit: ['viewer', 'admin']
after deep edit:    ['viewer']
[1, 2, 1, 2]
shared rows: [[9, 0, 0], [9, 0, 0], [9, 0, 0]]
own rows:    [[9, 0, 0], [0, 0, 0], [0, 0, 0]]
🔧 Розбір коду: copy.copy скопіював лише верхній словник, тому append у вкладений список змінив TEMPLATE. copy.deepcopy створив нові вкладені об'єкти. [[0] * 3] * 3 повторює посилання на один рядок, а comprehension створює новий рядок на кожній ітерації.
🔧 Code breakdown: copy.copy copied only the top-level dict, so append into the nested list changed TEMPLATE. copy.deepcopy created new nested objects. [[0] * 3] * 3 repeats references to one row, while the comprehension builds a new row on every iteration.
💡 Простими словами: Поверхнева копія — нова папка з ярликами на ті самі файли; глибока — нова папка з копіями файлів.
💡 In plain words: A shallow copy is a new folder of shortcuts to the same files; a deep copy is a new folder with copies of the files.
🗣 Як читати вголос Спершу я кажу, що присвоєння взагалі не копіює, а лише додає ім'я. Поверхнева копія створює новий контейнер з тими самими вкладеними об'єктами, тому зміна вкладеного словника проб'ється в оригінал. Глибока копія рекурсивно копіює все, і саме її я беру, коли тест модифікує шаблон тіла запиту. Щодо [1, 2] помножити на 2 — це повтор, чотири елементи, а для вкладених списків множення дає спільні рядки, тож сітку будую через comprehension.
🗣 How to say it out loud First I say that assignment does not copy at all, it only adds a name. A shallow copy makes a new container holding the same nested objects, so changing a nested dict leaks into the original. A deep copy copies everything recursively, and that is what I use when a test modifies a request body template. As for [1, 2] times 2, that is repetition giving four items, and with nested lists multiplication produces shared rows, so I build grids with a comprehension.
6 Як працюють зрізи й розпакування: що повернуть "abc"[::-1] та items[1:4]? How do slicing and unpacking work: what do "abc"[::-1] and items[1:4] return?
Основи мовиLanguage basics Junior ▶

Зрізи. Синтаксис seq[start:stop:step] повертає нову послідовність того ж типу. start включно, stop не включно, тож items[1:4] — елементи з індексами 1, 2 і 3. Пропущений start означає початок, пропущений stop — кінець. Від'ємні індекси рахуються з кінця: items[-2:] — два останні. Від'ємний крок іде назад, тому "abc"[::-1] повертає "cba". Зріз за межами довжини не падає, а просто повертає менше, тоді як items[10] дає IndexError.

Slicing. The syntax seq[start:stop:step] returns a new sequence of the same type. start is inclusive and stop exclusive, so items[1:4] is the items at indexes 1, 2 and 3. An omitted start means the beginning, an omitted stop the end. Negative indexes count from the end: items[-2:] is the last two. A negative step walks backwards, which is why "abc"[::-1] returns "cba". A slice past the length does not fail, it just returns less, whereas items[10] raises IndexError.

Розпакування. name, code = ("admin", 200) розкладає кортеж по змінних; кількість має збігатися, інакше ValueError. Зірочка збирає залишок у список: first, *rest = items. Обмін значень без тимчасової змінної — a, b = b, a. Розпакування працює в for: for user, status in results.items().

Unpacking. name, code = ("admin", 200) spreads a tuple over variables; the counts must match or you get ValueError. A star collects the rest into a list: first, *rest = items. Swapping values without a temporary variable is a, b = b, a. Unpacking works in for: for user, status in results.items().

Навіщо QA. Зрізи зручні, щоб взяти перші N рядків логу чи перевірити сторінку пагінації, а розпакування робить тести з кортежами даних читабельними.

Why QA cares. Slices are handy for taking the first N log lines or checking one page of pagination, and unpacking keeps tests over data tuples readable.

Підводні камені. Зріз списку створює поверхневу копію, а зріз рядка — новий рядок; призначення в зріз items[1:3] = [...] змінює список на місці.

Pitfalls. Slicing a list makes a shallow copy and slicing a string makes a new string; assigning to a slice items[1:3] = [...] changes the list in place.

Зрізи та розпакування
Slices and unpacking
items = ["a", "b", "c", "d", "e"]
print("abc"[::-1], items[1:4], items[-2:], items[::2], items[10:])

first, *middle, last = items
print(first, middle, last)

a, b = 1, 2
a, b = b, a
print(a, b)

results = {"login": "passed", "checkout": "failed"}
for test, status in results.items():
    print(f"{test:<10}{status}")

try:
    name, code = ("admin", 200, "extra")
except ValueError as e:
    print("ValueError:", e)
Вивід
Output
cba ['b', 'c', 'd'] ['d', 'e'] ['a', 'c', 'e'] []
a ['b', 'c', 'd'] e
2 1
login     passed
checkout  failed
ValueError: too many values to unpack (expected 2)
🔧 Розбір коду: items[::2] бере кожен другий елемент, items[10:] повертає порожній список замість помилки. first, *middle, last — розширене розпакування: зірочка збирає все між першим і останнім. f"{test:<10}" вирівнює назву по лівому краю в полі з 10 символів.
🔧 Code breakdown: items[::2] takes every second item, items[10:] returns an empty list instead of an error. first, *middle, last is extended unpacking: the star collects everything between the first and the last. f"{test:<10}" left-aligns the name in a 10-character field.
💡 Простими словами: Зріз — це «з якого місця, до якого (не включно), з яким кроком»; розпакування — це роздати елементи по іменах.
💡 In plain words: A slice is «from where, up to where (exclusive), with what step»; unpacking hands the items out to names.
🗣 Як читати вголос Зріз я описую як старт включно, стоп не включно і крок, тож items від одного до чотирьох — це елементи з індексами один, два і три, а крок мінус один розвертає рядок, і «abc» стає «cba». Зріз за межами не падає, на відміну від звичайного індексу. Розпакування використовую постійно: обмін значень одним рядком, зірочка для «решти» і цикл по items словника. Якщо кількість змінних не збігається, буде ValueError.
🗣 How to say it out loud I describe a slice as start inclusive, stop exclusive and a step, so items one to four means indexes one, two and three, and a step of minus one reverses a string, turning «abc» into «cba». A slice out of range does not fail, unlike a plain index. I use unpacking all the time: swapping values in one line, a star for «the rest», and looping over a dict's items. If the number of names does not match, you get a ValueError.
7 Що таке f-рядок і які його можливості корисні в тестах і логах? What is an f-string, and which of its features help in tests and logs?
Основи мовиLanguage basics Junior ▶

Що це. f-рядок — рядковий літерал з префіксом f, у якому вирази в фігурних дужках обчислюються під час виконання: f"user {user_id} got {status}". З'явився в Python 3.6, читається легше за % і str.format і працює швидше.

What it is. An f-string is a string literal with an f prefix in which expressions inside braces are evaluated at run time: f"user {user_id} got {status}". It arrived in Python 3.6, reads more easily than % and str.format, and runs faster.

Корисні можливості. Після двокрапки йде специфікатор формату: {price:.2f} — два знаки після коми, {n:05d} — доповнення нулями, {name:<12} — вирівнювання, {ratio:.1%} — відсотки, {count:,} — роздільник тисяч. Суфікс !r показує repr, тобто рядок з лапками, і в повідомленні про помилку одразу видно зайвий пробіл. З Python 3.8 є {expr=}: він друкує і вираз, і значення, що зручно для швидкої діагностики.

Useful features. A format spec follows a colon: {price:.2f} gives two decimals, {n:05d} pads with zeros, {name:<12} aligns, {ratio:.1%} prints a percentage, {count:,} adds a thousands separator. The !r suffix shows the repr, i.e. a string in quotes, so a stray space jumps out in an error message. Since Python 3.8 there is {expr=}, which prints both the expression and its value — handy for quick debugging.

Де стане в пригоді. Повідомлення в assert, назви тестових даних, URL: f"{base_url}/users/{user_id}".

Where it helps. assert messages, names of test data, URLs: f"{base_url}/users/{user_id}".

Підводні камені. Для SQL і shell-команд f-рядок — шлях до ін'єкції: параметри передають окремо, через плейсхолдери драйвера. У logging краще log.info("user %s", user_id): рядок форматується лише тоді, коли повідомлення справді буде записане. І до Python 3.12 всередині f-рядка не можна використовувати ті самі лапки, що й зовні.

Pitfalls. For SQL and shell commands an f-string is a path to injection: pass parameters separately through the driver's own parameter markers. In logging prefer log.info("user %s", user_id): the string is formatted only if the message is actually written. And before Python 3.12 you cannot reuse the outer quote character inside an f-string.

Формати, які найчастіше потрібні
The formats you need most often
price, ratio, count = 7.5, 0.4567, 1234567
name, run_id = "checkout ", 42
print(f"{price:.2f} | {run_id:05d} | {ratio:.1%} | {count:,}")
print(f"[{name:<12}] vs {name!r}")
print(f"{price * 2 = }")

expected, actual = 200, 404
message = f"expected status {expected}, got {actual} for /users/{run_id}"
print(message)
Вивід
Output
7.50 | 00042 | 45.7% | 1,234,567
[checkout    ] vs 'checkout '
price * 2 = 15.0
expected status 200, got 404 for /users/42
🔧 Розбір коду: :.2f, :05d, :.1% і :, — міні-мова форматування після двокрапки. !r виводить рядок разом з лапками, тож видно пробіл у кінці "checkout ". {price * 2 = } друкує сам вираз, знак рівності та результат.
🔧 Code breakdown: :.2f, :05d, :.1% and :, are the format mini-language after the colon. !r prints the string with its quotes, so the trailing space in "checkout " is visible. {price * 2 = } prints the expression itself, an equals sign and the result.
💡 Простими словами: f-рядок — це шаблон, у який Python сам підставляє значення змінних у фігурних дужках.
💡 In plain words: An f-string is a template where Python drops in the values of the variables in braces for you.
🗣 Як читати вголос f-рядок — це рядок з префіксом f, де вирази у фігурних дужках підставляються під час виконання. У тестах я пишу з ним повідомлення для assert, будую URL і назви даних. Корисні дрібниці: після двокрапки формат, наприклад два знаки після коми чи відсотки, «!r» показує рядок у лапках, і зайвий пробіл видно одразу, а «вираз дорівнює» друкує вираз разом зі значенням. Для SQL і логування я f-рядки не використовую: там параметри передаються окремо.
🗣 How to say it out loud An f-string is a string with an f prefix where expressions in braces are filled in at run time. In tests I use it for assert messages, building URLs and naming data. Useful details: a format after the colon, like two decimals or a percentage, «!r» shows a string in quotes so a stray space is obvious, and «expression equals» prints the expression along with its value. For SQL and logging I avoid f-strings, because there the parameters are passed separately.
8 Що таке правило LEGB і навіщо ключові слова global та nonlocal? What is the LEGB rule, and what are the global and nonlocal keywords for?
Основи мовиLanguage basics Middle ▶

Що це. Коли Python зустрічає ім'я, він шукає його в чотирьох областях по черзі: Local — всередині поточної функції, Enclosing — у зовнішніх функціях, якщо функція вкладена, Global — на рівні модуля, Built-in — вбудовані імена на кшталт len чи print. Перше знайдене перемагає.

What it is. When Python meets a name it looks it up in four scopes in order: Local — inside the current function, Enclosing — in outer functions when functions are nested, Global — at module level, Built-in — built-in names like len or print. The first match wins.

Присвоєння змінює правила. Якщо у функції є присвоєння імені, Python вважає це ім'я локальним для всієї функції. Тому counter += 1 без оголошень дасть UnboundLocalError: читання відбувається до локального присвоєння. global counter каже «працюй зі змінною модуля», а nonlocal counter — «зі змінною найближчої зовнішньої функції».

Assignment changes the rules. If a function assigns to a name anywhere, Python treats that name as local for the whole function. That is why counter += 1 without declarations raises UnboundLocalError: the read happens before the local assignment. global counter says «use the module variable», and nonlocal counter says «use the variable of the nearest enclosing function».

Де це в QA. nonlocal трапляється в замиканнях і декораторах, наприклад лічильник спроб у retry-обгортці. global у тестовому коді — майже завжди запах: спільний стан між тестами робить їх залежними від порядку. Замість цього стан тримають у фікстурі чи об'єкті.

Where QA meets it. nonlocal shows up in closures and decorators, for example an attempt counter inside a retry wrapper. global in test code is almost always a smell: shared state between tests makes them depend on execution order. Keep that state in a fixture or an object instead.

Підводні камені. Назва змінної list, id чи type затіняє вбудовану функцію до кінця області видимості, і далі list(x) падає з дивною помилкою TypeError. Ruff ловить це правилами з префіксом A.

Pitfalls. Naming a variable list, id or type shadows the built-in for the rest of that scope, and later list(x) fails with a puzzling TypeError. Ruff catches this with the rules prefixed A.

Local, enclosing, global
Local, enclosing, global
calls = 0

def broken():
    calls += 1  # присвоєння робить calls локальною

try:
    broken()
except UnboundLocalError as e:
    print("UnboundLocalError:", e)

def track():
    global calls
    calls += 1

track(); track()
print("global calls:", calls)

def make_counter():
    count = 0
    def inc():
        nonlocal count
        count += 1
        return count
    return inc

inc = make_counter()
print("closure:", inc(), inc(), inc())
calls = 0

def broken():
    calls += 1  # assignment makes calls local

try:
    broken()
except UnboundLocalError as e:
    print("UnboundLocalError:", e)

def track():
    global calls
    calls += 1

track(); track()
print("global calls:", calls)

def make_counter():
    count = 0
    def inc():
        nonlocal count
        count += 1
        return count
    return inc

inc = make_counter()
print("closure:", inc(), inc(), inc())
Вивід
Output
UnboundLocalError: cannot access local variable 'calls' where it is not associated with a value
global calls: 2
closure: 1 2 3
🔧 Розбір коду: У broken присвоєння робить calls локальною, тож читання падає. global calls перемикає функцію на змінну модуля. nonlocal count дозволяє вкладеній inc змінювати змінну з make_counter, і значення живе між викликами.
🔧 Code breakdown: In broken the assignment makes calls local, so the read fails. global calls switches the function to the module variable. nonlocal count lets the nested inc change the variable from make_counter, and the value survives between calls.
💡 Простими словами: Python шукає ім'я від найближчої кімнати до найдальшої: своя функція, зовнішня, модуль, вбудовані.
💡 In plain words: Python searches for a name from the nearest room outwards: its own function, the enclosing one, the module, the built-ins.
🗣 Як читати вголос LEGB — це порядок пошуку імен: локальна область, зовнішня функція, модуль і вбудовані імена. Головна пастка — будь-яке присвоєння робить ім'я локальним для всієї функції, тому «плюс дорівнює» до глобальної змінної без оголошення дає UnboundLocalError. global перемикає на змінну модуля, nonlocal — на змінну зовнішньої функції, і саме nonlocal я використовую в декораторах. global у тестах уникаю, бо спільний стан робить тести залежними від порядку.
🗣 How to say it out loud LEGB is the order in which names are looked up: local scope, enclosing function, module and built-ins. The main trap is that any assignment makes the name local for the whole function, so «plus equals» on a global without a declaration raises UnboundLocalError. global switches to the module variable and nonlocal to the enclosing function's variable, and nonlocal is the one I use in decorators. I avoid global in tests, since shared state makes tests order-dependent.
▶ 2 10
9 Які вбудовані колекції є в Python і яка складність їхніх основних операцій? Which built-in collections does Python have, and what do their main operations cost?
КолекціїCollections Junior ▶

Чотири базові. list — впорядкована змінна послідовність з доступом за індексом. tuple — те саме, але незмінне. dict — відображення «ключ → значення» на хеш-таблиці; з Python 3.7 гарантовано зберігає порядок вставки. set — невпорядкований набір унікальних хешованих елементів, frozenset — його незмінна версія.

The four basics. list is an ordered mutable sequence with index access. tuple is the same but immutable. dict maps «key → value» on a hash table; since Python 3.7 it is guaranteed to keep insertion order. set is an unordered collection of unique hashable items, and frozenset is its immutable twin.

Складність. Для списку: доступ за індексом і append — O(1), x in list, insert(0, x), remove і pop(0) — O(n), бо треба пройти або зсунути елементи. Для dict і set пошук, додавання й видалення — в середньому O(1) завдяки хешуванню. Для черги з обох кінців є collections.deque з O(1) на appendleft і popleft.

Cost. For a list: index access and append are O(1); x in list, insert(0, x), remove and pop(0) are O(n) because items must be scanned or shifted. For dict and set lookup, insert and delete are O(1) on average thanks to hashing. For a queue with two ends there is collections.deque with O(1) appendleft and popleft.

Що це дає в тестах. Перевірка «чи є ID у списку з 100 тисяч» у циклі — це квадратична робота; та сама перевірка по множині — лінійна. Коли тест звіряє дві великі вибірки з API й бази, правильний тип колекції перетворює хвилини на мілісекунди.

What it means in tests. Checking «is this ID among 100 thousand» inside a loop over a list is quadratic work; the same check against a set is linear. When a test reconciles two large samples from an API and a database, the right collection type turns minutes into milliseconds.

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

Pitfalls. Dict keys and set items must be hashable: a list or a dict cannot go in. A set drops duplicates and does not keep order, so on its own it cannot check «the API returned records in the right order with no duplicates».

Пошук у списку й у множині
Lookup in a list and in a set
import timeit
from collections import deque

ids_list = list(range(100_000))
ids_set = set(ids_list)
needle = 99_999

t_list = timeit.timeit(lambda: needle in ids_list, number=200)
t_set = timeit.timeit(lambda: needle in ids_set, number=200)
print(f"list: {t_list:.4f}s  set: {t_set:.6f}s  set is ~{t_list / t_set:,.0f}x faster")

queue = deque(["login", "search"])
queue.appendleft("setup")
print(queue.popleft(), list(queue))

user = {"id": 7, "name": "Ann"}
print(list(user), {3, 1, 3, 2})
Вивід
Output
list: 0.1094s  set: 0.000010s  set is ~11,042x faster
setup ['login', 'search']
['id', 'name'] {1, 2, 3}
🔧 Розбір коду: timeit.timeit виконує лямбду 200 разів і повертає загальний час. Пошук останнього елемента в списку проходить усі 100 тисяч, а в множині — одна перевірка хешу. deque.popleft забирає елемент з початку за O(1), і список list(user) показує ключі словника в порядку вставки.
🔧 Code breakdown: timeit.timeit runs the lambda 200 times and returns the total time. Finding the last item in a list walks all 100 thousand, while a set needs one hash check. deque.popleft takes from the front in O(1), and list(user) shows the dict keys in insertion order.
💡 Простими словами: Список — це черга людей: щоб знайти когось, треба пройти всіх; множина — гардероб з номерками: знаєш номер — одразу береш.
💡 In plain words: A list is a line of people: to find someone you walk past everyone; a set is a cloakroom with tickets: know the number and you grab it at once.
🗣 Як читати вголос Я називаю чотири базові колекції: список, кортеж, словник і множину, плюс deque для черг. Далі кажу про складність: індекс і append у списку константні, а пошук через «in» і вставка на початок — лінійні. Словник і множина шукають за константний час у середньому, бо це хеш-таблиці. На практиці це відчутно, коли звіряєш тисячі ID з API й бази: заміна списку на множину перетворює квадратичну перевірку на лінійну. Ще згадую, що словник зберігає порядок вставки з версії 3.7.
🗣 How to say it out loud I name the four basic collections: list, tuple, dict and set, plus deque for queues. Then I talk about cost: index access and append on a list are constant, while «in» and inserting at the front are linear. Dicts and sets look things up in constant time on average because they are hash tables. You feel it when reconciling thousands of IDs between an API and a database: swapping a list for a set turns a quadratic check into a linear one. I also mention that dicts keep insertion order since 3.7.
10 Навіщо dict.get("key", default) і коли краще setdefault, defaultdict або Counter? Why use dict.get("key", default), and when are setdefault, defaultdict or Counter better?
КолекціїCollections Junior ▶

get. d["key"] кидає KeyError, якщо ключа немає. d.get("key") поверне None, а d.get("key", 0) — вказане значення за замовчуванням. Це зручно для необов'язкових полів у JSON-відповіді: body.get("middle_name", "").

get. d["key"] raises KeyError when the key is missing. d.get("key") returns None, and d.get("key", 0) returns the given default. That suits optional fields in a JSON response: body.get("middle_name", "").

Коли що брати. setdefault(key, []) повертає значення, а якщо ключа немає — спершу записує дефолт; так групують записи без if key not in d. collections.defaultdict(list) робить те саме автоматично для будь-якого нового ключа. collections.Counter рахує повторення: Counter(statuses) одразу дає, скільки тестів passed, а скільки failed, і має most_common().

What to use when. setdefault(key, []) returns the value and, if the key is missing, first stores the default; this groups records without if key not in d. collections.defaultdict(list) does the same automatically for any new key. collections.Counter counts occurrences: Counter(statuses) tells you at once how many tests passed and how many failed, and it has most_common().

Коли get шкодить. У тесті перевірка обов'язкового поля через get ховає баг: якщо API перестав повертати id, body.get("id") дасть None, і повідомлення про помилку буде невиразним. Для обов'язкових полів краще body["id"] — KeyError з назвою поля одразу пояснює, що зламалось. get — для полів, які справді можуть бути відсутні.

When get hurts. In a test, checking a required field through get hides the bug: if the API stopped returning id, body.get("id") gives None and the failure message is vague. For required fields prefer body["id"] — a KeyError naming the field explains immediately what broke. Keep get for fields that really may be absent.

Підводні камені. defaultdict створює ключ навіть при читанні d["missing"], тож словник росте від перевірок. І get не відрізнить «ключа немає» від «ключ є зі значенням None» — для цього є "key" in d.

Pitfalls. defaultdict creates a key even when you merely read d["missing"], so the dict grows from lookups. And get cannot tell «no key» from «key present with value None» — use "key" in d for that.

Групування й підрахунок результатів
Grouping and counting results
from collections import Counter, defaultdict

body = {"id": 5, "name": "Ann", "nickname": None}
print(repr(body.get("middle_name", "")), body.get("nickname", "n/a"), "nickname" in body)

results = [("login", "passed"), ("cart", "failed"), ("search", "passed"), ("pay", "failed")]

by_status = defaultdict(list)
for test, status in results:
    by_status[status].append(test)
print(dict(by_status))

counts = Counter(status for _, status in results)
print(counts, counts.most_common(1))

grouped = {}
for test, status in results:
    grouped.setdefault(status, []).append(test)
print(grouped == dict(by_status))

try:
    body["email"]
except KeyError as e:
    print("KeyError:", e)
Вивід
Output
'' None True
{'passed': ['login', 'search'], 'failed': ['cart', 'pay']}
Counter({'passed': 2, 'failed': 2}) [('passed', 2)]
True
KeyError: 'email'
🔧 Розбір коду: body.get("nickname", "n/a") повертає None, бо ключ є, а дефолт спрацьовує лише для відсутнього ключа. defaultdict(list) сам створює порожній список для нового статусу. Counter приймає генераторний вираз. setdefault дає той самий результат без імпорту.
🔧 Code breakdown: body.get("nickname", "n/a") returns None because the key exists; the default only applies to a missing key. defaultdict(list) creates an empty list for a new status on its own. Counter accepts a generator expression. setdefault gives the same result without an import.
💡 Простими словами: get — «дай, якщо є, інакше ось запасне»; квадратні дужки — «дай, а якщо немає, голосно скажи про це».
💡 In plain words: get means «give it if it is there, otherwise take this spare»; square brackets mean «give it, and shout if it is missing».
🗣 Як читати вголос dict.get з дефолтом я використовую для необов'язкових полів, щоб не ловити KeyError. Але в перевірках обов'язкових полів свідомо беру квадратні дужки: якщо API загубив id, KeyError з назвою поля скаже про це чесніше, ніж None десь нижче. Для групування беру defaultdict або setdefault, а для підрахунку статусів — Counter з most_common. І пам'ятаю, що get не відрізняє відсутній ключ від ключа зі значенням None.
🗣 How to say it out loud I use dict.get with a default for optional fields so I do not have to catch KeyError. But when checking required fields I deliberately use square brackets: if the API lost the id, a KeyError naming the field says so more honestly than a None somewhere further down. For grouping I use defaultdict or setdefault, and for counting statuses, Counter with most_common. And I keep in mind that get cannot tell a missing key from a key holding None.
11 Як множини допомагають порівнювати очікувані й фактичні дані в тестах? How do sets help compare expected and actual data in tests?
КолекціїCollections Middle ▶

Що це. set тримає унікальні хешовані значення і підтримує операції з теорії множин: a | b — об'єднання, a & b — перетин, a - b — різниця, a ^ b — симетрична різниця, a <= b — «a є підмножиною b». Усі вони працюють за лінійний час.

What it is. A set holds unique hashable values and supports set-theory operations: a | b is union, a & b intersection, a - b difference, a ^ b symmetric difference, a <= b «a is a subset of b». All of them run in linear time.

Як це виглядає в тесті. Типове завдання: після імпорту файла в системі мають з'явитися саме ці 500 користувачів. Порівнювати два списки через == незручно: різний порядок робить їх нерівними, а повідомлення показує лише першу розбіжність. Дві різниці множин дають повну картину: expected - actual — кого не вистачає, actual - expected — хто зайвий. pytest при падінні assert на множинах теж друкує, яких елементів бракує з кожного боку.

What it looks like in a test. A typical task: after a file import exactly these 500 users must exist in the system. Comparing two lists with == is awkward: a different order makes them unequal, and the message shows only the first mismatch. Two set differences give the full picture: expected - actual is who is missing, actual - expected is who is extra. When an assert on sets fails, pytest also prints which items are missing on each side.

Перевірка ролей і прав. required <= granted — «у користувача є принаймні ці права», без вимоги точного збігу.

Checking roles and permissions. required <= granted means «the user has at least these permissions», without demanding an exact match.

Підводні камені. Множина ховає дублікати. Якщо API повернув одного користувача двічі, set(ids) == expected пройде. Тому перевірку на дублі роблять окремо: len(ids) == len(set(ids)), або порівнюють Counter. Порядок множина теж не перевіряє. І словники чи списки в множину не покладеш: перетворюй записи на кортежі або бери лише ключові поля.

Pitfalls. A set hides duplicates. If the API returned one user twice, set(ids) == expected still passes. So check duplicates separately: len(ids) == len(set(ids)), or compare Counter objects. A set does not check order either. And dicts or lists cannot go into a set: turn records into tuples or keep only the key fields.

Звірка імпорту користувачів
Reconciling a user import
from collections import Counter

EXPECTED = {"ann", "bob", "eve", "max"}

def fetch_imported_logins():
    return ["bob", "ann", "zoe", "bob", "max"]  # замість виклику API

def test_import_matches_file():
    actual = fetch_imported_logins()
    dupes = [login for login, n in Counter(actual).items() if n > 1]
    print("missing:", EXPECTED - set(actual), "extra:", set(actual) - EXPECTED, "dupes:", dupes)
    assert set(actual) == EXPECTED

def test_admin_has_required_permissions():
    granted = {"read", "write", "delete", "export"}
    assert {"read", "write"} <= granted
from collections import Counter

EXPECTED = {"ann", "bob", "eve", "max"}

def fetch_imported_logins():
    return ["bob", "ann", "zoe", "bob", "max"]  # stand-in for an API call

def test_import_matches_file():
    actual = fetch_imported_logins()
    dupes = [login for login, n in Counter(actual).items() if n > 1]
    print("missing:", EXPECTED - set(actual), "extra:", set(actual) - EXPECTED, "dupes:", dupes)
    assert set(actual) == EXPECTED

def test_admin_has_required_permissions():
    granted = {"read", "write", "delete", "export"}
    assert {"read", "write"} <= granted
Вивід
Output
$ pytest -q test_sets.py
F.                                                                       [100%]
=================================== FAILURES ===================================
___________________________ test_import_matches_file ___________________________

    def test_import_matches_file():
        actual = fetch_imported_logins()
        dupes = [login for login, n in Counter(actual).items() if n > 1]
        print("missing:", EXPECTED - set(actual), "extra:", set(actual) - EXPECTED, "dupes:", dupes)
>       assert set(actual) == EXPECTED
E       AssertionError: assert {'ann', 'bob', 'max', 'zoe'} == {'ann', 'bob', 'eve', 'max'}
E         
E         Extra items in the left set:
E         'zoe'
E         Extra items in the right set:
E         'eve'
E         Use -v to get more diff

test_sets.py:12: AssertionError
----------------------------- Captured stdout call -----------------------------
missing: {'eve'} extra: {'zoe'} dupes: ['bob']
=========================== short test summary info ============================
FAILED test_sets.py::test_import_matches_file - AssertionError: assert {'ann'...
1 failed, 1 passed in 0.01s
🔧 Розбір коду: EXPECTED - set(actual) показує відсутніх, set(actual) - EXPECTED — зайвих, а Counter знаходить дубль bob, який множина сховала б. <= перевіряє підмножину. У виводі pytest сам розписує зайві елементи лівої й правої множини (Extra items in the left set і in the right set).
🔧 Code breakdown: EXPECTED - set(actual) shows who is missing, set(actual) - EXPECTED who is extra, and Counter finds the duplicate bob that a set would hide. <= checks for a subset. In the output pytest itself lists the extra items of the left and of the right set.
💡 Простими словами: Дві різниці множин відповідають на два питання звірки: «кого бракує?» і «хто зайвий?».
💡 In plain words: Two set differences answer the two questions of any reconciliation: «who is missing?» and «who is extra?».
🗣 Як читати вголос Коли треба звірити дві вибірки, я переходжу на множини. Різниця очікуваного й фактичного показує, чого бракує, зворотна різниця — що зайве, і порядок більше не заважає. Для прав доступу зручна перевірка підмножини. Але я завжди додаю окрему перевірку на дублікати, бо множина їх мовчки згортає: API може повернути користувача двічі, і тест на множинах цього не помітить. Для цього рахую Counter або порівнюю довжини.
🗣 How to say it out loud When I need to reconcile two samples I switch to sets. Expected minus actual shows what is missing, the reverse difference shows what is extra, and order no longer gets in the way. For permissions a subset check is handy. But I always add a separate duplicate check, because a set silently folds duplicates: the API may return a user twice and a set-based test will not notice. For that I use a Counter or compare lengths.
12 Що таке list comprehension і як писати dict, set та генераторні вирази? What is a list comprehension, and how do you write dict, set and generator expressions?
КолекціїCollections Junior ▶

Що це. List comprehension — компактний запис циклу, який будує новий список: [вираз for x in джерело if умова]. [u["id"] for u in users if u["active"]] замінює чотири рядки з append і читається як речення: «ID кожного активного користувача».

What it is. A list comprehension is a compact loop that builds a new list: [expression for x in source if condition]. [u["id"] for u in users if u["active"]] replaces four lines with append and reads like a sentence: «the ID of every active user».

Інші форми. Фігурні дужки з двокрапкою дають словник: {u["id"]: u for u in users} — індекс за ID для швидкого пошуку. Фігурні дужки без двокрапки — множину: {u["role"] for u in users}. Круглі дужки — генераторний вираз: він не будує колекцію, а видає значення по одному, тож sum(len(line) for line in f) не тримає файл у пам'яті. Умову-фільтр пишуть у кінці, а тернарний вираз для перетворення — на початку: ["ok" if s == 200 else "fail" for s in codes].

Other forms. Braces with a colon give a dict: {u["id"]: u for u in users} — an index by ID for quick lookup. Braces without a colon give a set: {u["role"] for u in users}. Parentheses give a generator expression: it builds no collection and yields values one by one, so sum(len(line) for line in f) does not hold the file in memory. A filter condition goes at the end, a conditional expression for transforming goes at the start: ["ok" if s == 200 else "fail" for s in codes].

У тестах. Comprehension зручно використовувати для підготовки очікуваних даних і для assert all(u["email"] for u in users), де all з генератором зупиняється на першому хибному значенні.

In tests. Comprehensions are handy for preparing expected data and for assert all(u["email"] for u in users), where all with a generator stops at the first falsy value.

Підводні камені. Два вкладені for з умовами в одному рядку вже важко читати — там звичайний цикл кращий. Comprehension заради побічних ефектів ([print(x) for x in items]) — антипатерн: він будує непотрібний список. Змінна циклу в comprehension не протікає назовні, на відміну від звичайного for.

Pitfalls. Two nested for clauses with conditions on one line are already hard to read — a plain loop is better there. A comprehension used for side effects ([print(x) for x in items]) is an anti-pattern: it builds a useless list. The loop variable of a comprehension does not leak outside, unlike a plain for.

Чотири форми на одних даних
Four forms on the same data
users = [
    {"id": 1, "role": "admin", "active": True, "email": "a@x.io"},
    {"id": 2, "role": "viewer", "active": False, "email": "b@x.io"},
    {"id": 3, "role": "viewer", "active": True, "email": ""},
]

active_ids = [u["id"] for u in users if u["active"]]
by_id = {u["id"]: u["role"] for u in users}
roles = {u["role"] for u in users}
labels = ["ok" if u["email"] else "no email" for u in users]
total = sum(u["id"] for u in users)

print(active_ids, by_id, sorted(roles), labels, total)
print("all have email:", all(u["email"] for u in users))
Вивід
Output
[1, 3] {1: 'admin', 2: 'viewer', 3: 'viewer'} ['admin', 'viewer'] ['ok', 'ok', 'no email'] 6
all have email: False
🔧 Розбір коду: if u["active"] у кінці фільтрує, а "ok" if ... else ... на початку перетворює кожен елемент. sum(u["id"] for u in users) приймає генераторний вираз без додаткових дужок. sorted(roles) потрібен, бо порядок множини не визначений.
🔧 Code breakdown: if u["active"] at the end filters, while "ok" if ... else ... at the start transforms each item. sum(u["id"] for u in users) takes a generator expression without extra parentheses. sorted(roles) is there because a set has no defined order.
💡 Простими словами: Comprehension — це «зроби з кожного елемента ось це, але лише для тих, що підходять», записане одним рядком.
💡 In plain words: A comprehension is «do this to every item, but only the ones that qualify», written on one line.
🗣 Як читати вголос List comprehension — це короткий запис циклу, що будує новий список, з необов'язковим фільтром у кінці. Так само пишуться словник і множина у фігурних дужках, а круглі дужки дають генераторний вираз, який не створює колекцію й економить пам'ять. У тестах я так готую очікувані дані, а в перевірках на кшталт «у всіх користувачів є email» використовую all з генератором. Якщо comprehension розростається до двох циклів з умовами, переписую на звичайний for.
🗣 How to say it out loud A list comprehension is a short loop that builds a new list, with an optional filter at the end. Dicts and sets are written the same way in braces, and parentheses give a generator expression that builds no collection and saves memory. In tests I prepare expected data like this, and for checks such as «every user has an email» I use all with a generator. When a comprehension grows into two loops with conditions, I rewrite it as a plain for.
13 Чим list.append() відрізняється від list.extend() і від оператора +? How does list.append() differ from list.extend() and from the + operator?
КолекціїCollections Junior ▶

Що це. append(x) додає в кінець списку рівно один елемент — яким би він не був. Якщо передати список, він ляже всередину як один вкладений елемент. extend(iterable) проходить по переданому об'єкту і додає кожен його елемент окремо. Обидва методи змінюють список на місці й повертають None.

What it is. append(x) adds exactly one item to the end of the list — whatever it is. Pass a list and it goes in as a single nested item. extend(iterable) walks the given object and adds each of its items separately. Both change the list in place and return None.

А плюс? a + b створює новий список і не чіпає ні a, ні b. a += b для списку поводиться як extend: змінює a на місці, і це видно всім, хто тримає посилання на той самий список. insert(i, x) вставляє в довільну позицію, але коштує O(n).

What about plus? a + b creates a new list and leaves both a and b untouched. a += b on a list behaves like extend: it changes a in place, and everyone holding a reference to that list sees it. insert(i, x) puts an item at any position but costs O(n).

Де плутають. Збираєш помилки валідації з кількох перевірок: errors.append(check_email()) покладе список у список, і len(errors) покаже кількість перевірок, а не помилок. Потрібен extend. Інша типова помилка — items = items.append(x): результат None, і список втрачено.

Where people mix them up. You gather validation errors from several checks: errors.append(check_email()) puts a list inside the list, and len(errors) counts checks, not errors. You need extend. Another classic is items = items.append(x): the result is None and the list is lost.

Підводні камені. extend("abc") додасть три символи, бо рядок — теж ітерований об'єкт. Для одного рядка потрібен append. А extend з генератором працює: він просто вичерпає генератор.

Pitfalls. extend("abc") adds three characters, because a string is iterable too. For one string use append. extend with a generator works fine: it simply exhausts the generator.

Збирання помилок валідації
Collecting validation errors
def check_email(user):
    return [] if "@" in user["email"] else ["email: missing @"]

def check_age(user):
    return [] if user["age"] >= 18 else ["age: under 18"]

user = {"email": "ann.example.com", "age": 16}

wrong = []
for check in (check_email, check_age):
    wrong.append(check(user))
print("append:", wrong, len(wrong))

errors = []
for check in (check_email, check_age):
    errors.extend(check(user))
print("extend:", errors, len(errors))

tags = ["smoke"]
tags.extend("api")  # рядок теж ітерований
print(tags, tags.append("x"))

a = [1]
alias = a
a = a + [2]
print("plus:", a, alias)
def check_email(user):
    return [] if "@" in user["email"] else ["email: missing @"]

def check_age(user):
    return [] if user["age"] >= 18 else ["age: under 18"]

user = {"email": "ann.example.com", "age": 16}

wrong = []
for check in (check_email, check_age):
    wrong.append(check(user))
print("append:", wrong, len(wrong))

errors = []
for check in (check_email, check_age):
    errors.extend(check(user))
print("extend:", errors, len(errors))

tags = ["smoke"]
tags.extend("api")  # a string is iterable too
print(tags, tags.append("x"))

a = [1]
alias = a
a = a + [2]
print("plus:", a, alias)
Вивід
Output
append: [['email: missing @'], ['age: under 18']] 2
extend: ['email: missing @', 'age: under 18'] 2
['smoke', 'a', 'p', 'i', 'x'] None
plus: [1, 2] [1]
🔧 Розбір коду: У першому циклі append кладе кожен повернутий список цілим, тому len дорівнює 2 навіть для порожнього результату. extend розкладає елементи. tags.append("x") усередині print повертає None. a = a + [2] створює новий список, і alias лишається старим.
🔧 Code breakdown: In the first loop append adds each returned list whole, so len is 2 even with an empty result. extend unpacks the items. tags.append("x") inside print returns None. a = a + [2] creates a new list, and alias keeps the old one.
💡 Простими словами: append кладе в кошик пакет цілим; extend висипає вміст пакета в кошик.
💡 In plain words: append puts the whole bag into the basket; extend tips the bag's contents into the basket.
🗣 Як читати вголос append додає рівно один елемент, навіть якщо це цілий список, а extend розпаковує будь-який ітерований об'єкт і додає елементи по одному. Обидва змінюють список на місці й повертають None, тому присвоювати їхній результат — помилка. Плюс створює новий список, а «плюс дорівнює» для списку працює як extend. Класична пастка з тестів — збирати помилки валідації через append і отримати список списків, де довжина рахує перевірки замість помилок.
🗣 How to say it out loud append adds exactly one item, even if that item is a whole list, while extend unpacks any iterable and adds its items one by one. Both change the list in place and return None, so assigning their result is a bug. Plus builds a new list, and «plus equals» on a list works like extend. The classic test trap is gathering validation errors with append and ending up with a list of lists, where the length counts checks instead of errors.
14 Чим sorted() відрізняється від list.sort(), що робить key=len і навіщо тут lambda? How does sorted() differ from list.sort(), what does key=len do, and why a lambda here?
КолекціїCollections Junior ▶

Різниця. sorted(iterable) приймає будь-який ітерований об'єкт — список, кортеж, словник, генератор — і повертає новий список, не чіпаючи вихідні дані. list.sort() є лише у списків, сортує на місці й повертає None. Звідси класичний баг result = items.sort(): у result опиниться None, бо функція без явного значення повертає саме його.

The difference. sorted(iterable) takes any iterable — list, tuple, dict, generator — and returns a new list, leaving the source alone. list.sort() exists only on lists, sorts in place and returns None. Hence the classic bug result = items.sort(): result ends up None, because a function with no explicit return value returns exactly that.

key. Параметр key приймає функцію, яку викликають для кожного елемента один раз; порівнюють уже її результати. sorted(names, key=len) впорядковує за довжиною, key=str.lower — без урахування регістру. Коли потрібне поле словника, пишуть key=lambda u: u["age"]. Lambda — це анонімна функція з одного виразу, зручна саме як короткий аргумент; для складнішої логіки краще звичайний def з іменем. Кортеж у ключі дає сортування за кількома полями: key=lambda u: (u["role"], -u["age"]).

key. The key parameter takes a function called once per item; the results are what get compared. sorted(names, key=len) orders by length, key=str.lower ignores case. When you need a dict field you write key=lambda u: u["age"]. A lambda is an anonymous single-expression function, handy precisely as a short argument; for more complex logic a named def is better. A tuple as the key sorts by several fields: key=lambda u: (u["role"], -u["age"]).

Стабільність. Сортування в Python стабільне: елементи з однаковим ключем зберігають відносний порядок. Це дозволяє сортувати в кілька проходів.

Stability. Python's sort is stable: items with equal keys keep their relative order. That lets you sort in several passes.

У тестах. Перевірка «API повернув список відсортованим за датою» — assert items == sorted(items, key=...). Порівнюй відповіді без урахування порядку через sorted з обох боків, якщо порядок не гарантований контрактом.

In tests. Checking «the API returned the list sorted by date» is assert items == sorted(items, key=...). Compare responses regardless of order by applying sorted to both sides when the contract does not promise an order.

Сортування і перевірка порядку
Sorting and checking order
users = [
    {"name": "bob", "role": "viewer", "age": 31},
    {"name": "Ann", "role": "admin", "age": 25},
    {"name": "eve", "role": "viewer", "age": 40},
]

def test_sort_helpers():
    names = [u["name"] for u in users]
    assert sorted(names, key=len) == ["bob", "Ann", "eve"]  # стабільно: рівні довжини зберігають порядок
    assert sorted(names, key=str.lower) == ["Ann", "bob", "eve"]
    assert names.sort() is None

def test_api_returns_users_sorted_by_role_then_oldest_first():
    response = users  # замість response.json()
    expected = sorted(response, key=lambda u: (u["role"], -u["age"]))
    assert response == expected
users = [
    {"name": "bob", "role": "viewer", "age": 31},
    {"name": "Ann", "role": "admin", "age": 25},
    {"name": "eve", "role": "viewer", "age": 40},
]

def test_sort_helpers():
    names = [u["name"] for u in users]
    assert sorted(names, key=len) == ["bob", "Ann", "eve"]  # stable: equal lengths keep order
    assert sorted(names, key=str.lower) == ["Ann", "bob", "eve"]
    assert names.sort() is None

def test_api_returns_users_sorted_by_role_then_oldest_first():
    response = users  # stand-in for response.json()
    expected = sorted(response, key=lambda u: (u["role"], -u["age"]))
    assert response == expected
Вивід
Output
$ pytest -q test_sorting.py
.F                                                                       [100%]
=================================== FAILURES ===================================
___________ test_api_returns_users_sorted_by_role_then_oldest_first ____________

    def test_api_returns_users_sorted_by_role_then_oldest_first():
        response = users  # stand-in for response.json()
        expected = sorted(response, key=lambda u: (u["role"], -u["age"]))
>       assert response == expected
E       AssertionError: assert [{'name': 'bo...', 'age': 40}] == [{'name': 'An...', 'age': 31}]
E         
E         At index 0 diff: {'name': 'bob', 'role': 'viewer', 'age': 31} != {'name': 'Ann', 'role': 'admin', 'age': 25}
E         Use -v to get more diff

test_sorting.py:16: AssertionError
=========================== short test summary info ============================
FAILED test_sorting.py::test_api_returns_users_sorted_by_role_then_oldest_first
1 failed, 1 passed in 0.01s
🔧 Розбір коду: key=len передає саму функцію без дужок. names.sort() повертає None, а список змінюється на місці. У ключі (u["role"], -u["age"]) мінус перед віком дає спадання всередині ролі. Другий тест падає навмисно: pytest показує, на якому індексі списки розійшлися.
🔧 Code breakdown: key=len passes the function itself, without parentheses. names.sort() returns None and changes the list in place. In the key (u["role"], -u["age"]) the minus before the age gives descending order within a role. The second test fails on purpose: pytest shows at which index the lists diverge.
💡 Простими словами: sorted робить відсортовану копію, sort переставляє речі в самій шухляді й нічого не повертає.
💡 In plain words: sorted hands you a sorted copy; sort rearranges the drawer itself and hands back nothing.
🗣 Як читати вголос sorted приймає будь-який ітерований об'єкт і повертає новий список, а sort є тільки у списку, сортує на місці й повертає None, тому присвоювати його результат не можна. key — це функція, яку викликають для кожного елемента: key дорівнює len сортує за довжиною. Для поля словника пишу коротку lambda, а для сортування за кількома полями повертаю кортеж. У тестах перевіряю порядок відповіді API так: список дорівнює його відсортованій копії.
🗣 How to say it out loud sorted takes any iterable and returns a new list, while sort exists only on lists, sorts in place and returns None, so you must not assign its result. key is a function called for each item: key equals len sorts by length. For a dict field I write a short lambda, and to sort by several fields I return a tuple. In tests I check an API response order like this: the list equals its own sorted copy.
15 Що роблять enumerate() і zip() і як вони допомагають у тестах? What do enumerate() and zip() do, and how do they help in tests?
КолекціїCollections Junior ▶

enumerate. enumerate(items, start=0) видає пари (індекс, елемент). Це замінює ручний лічильник і конструкцію for i in range(len(items)). Параметр start=1 зручний для людських номерів рядків: «рядок 3 CSV-файла має порожній email».

enumerate. enumerate(items, start=0) yields (index, item) pairs. It replaces a hand-made counter and the for i in range(len(items)) construct. start=1 suits human line numbers: «line 3 of the CSV file has an empty email».

zip. zip(a, b, ...) йде паралельно по кількох послідовностях і видає кортежі з елементів на однакових позиціях. За замовчуванням зупиняється на найкоротшій. З Python 3.10 є zip(a, b, strict=True): якщо довжини різні, він кидає ValueError. dict(zip(keys, values)) будує словник з двох списків, а zip(*rows) транспонує таблицю.

zip. zip(a, b, ...) walks several sequences in parallel and yields tuples of items at the same positions. By default it stops at the shortest one. Since Python 3.10 there is zip(a, b, strict=True): if the lengths differ it raises ValueError. dict(zip(keys, values)) builds a dict from two lists, and zip(*rows) transposes a table.

У тестах. Порівняння очікуваних і фактичних рядків таблиці в UI: for i, (exp, act) in enumerate(zip(expected, actual), start=1) дає і номер рядка для повідомлення, і обидва значення.

In tests. Comparing expected and actual table rows in a UI: for i, (exp, act) in enumerate(zip(expected, actual), start=1) gives both a row number for the message and both values.

Підводні камені. Звичайний zip мовчки обрізає довший список. Якщо UI показав 9 рядків замість 10, порівняння по zip пройде по дев'яти й скаже, що все гаразд. Тому або окремо перевіряй довжину, або використовуй strict=True. Обидві функції повертають лінивий ітератор: пройти його можна лише один раз.

Pitfalls. Plain zip silently truncates the longer list. If the UI showed 9 rows instead of 10, a zip-based comparison checks nine and says all is well. So either check the length separately or use strict=True. Both functions return a lazy iterator that can be walked only once.

Порівняння рядків таблиці
Comparing table rows
expected = ["Ann", "Bob", "Eve", "Max"]
actual = ["Ann", "Rob", "Eve"]  # UI загубив рядок

for row, (exp, act) in enumerate(zip(expected, actual), start=1):
    if exp != act:
        print(f"row {row}: expected {exp!r}, got {act!r}")

print("plain zip compared", len(list(zip(expected, actual))), "rows")
try:
    list(zip(expected, actual, strict=True))
except ValueError as e:
    print("strict:", e)

headers = ["id", "status"]
print(dict(zip(headers, [7, "paid"])))
print(list(zip(*[(1, "a"), (2, "b")])))
expected = ["Ann", "Bob", "Eve", "Max"]
actual = ["Ann", "Rob", "Eve"]  # the UI lost a row

for row, (exp, act) in enumerate(zip(expected, actual), start=1):
    if exp != act:
        print(f"row {row}: expected {exp!r}, got {act!r}")

print("plain zip compared", len(list(zip(expected, actual))), "rows")
try:
    list(zip(expected, actual, strict=True))
except ValueError as e:
    print("strict:", e)

headers = ["id", "status"]
print(dict(zip(headers, [7, "paid"])))
print(list(zip(*[(1, "a"), (2, "b")])))
Вивід
Output
row 2: expected 'Bob', got 'Rob'
plain zip compared 3 rows
strict: zip() argument 2 is shorter than argument 1
{'id': 7, 'status': 'paid'}
[(1, 2), ('a', 'b')]
🔧 Розбір коду: enumerate(..., start=1) нумерує з одиниці, а (exp, act) розпаковує кортеж від zip прямо в заголовку циклу. Без strict четвертий рядок Max ніхто не порівняв. zip(*rows) розпаковує рядки як окремі аргументи і перевертає таблицю.
🔧 Code breakdown: enumerate(..., start=1) numbers from one, and (exp, act) unpacks the tuple from zip right in the loop header. Without strict nobody compared the fourth row Max. zip(*rows) passes the rows as separate arguments and flips the table.
💡 Простими словами: enumerate додає номерок до кожного елемента; zip застібає два списки, як блискавку, зубчик до зубчика.
💡 In plain words: enumerate pins a number on each item; zip closes two lists like a zipper, tooth to tooth.
🗣 Як читати вголос enumerate дає пари «індекс і елемент», тож ручний лічильник не потрібен, а start дорівнює одиниці дає людські номери рядків для повідомлень. zip іде паралельно по кількох списках і видає кортежі. Я завжди пам'ятаю, що zip за замовчуванням обрізає довший список, тому в перевірках таблиць додаю strict дорівнює True з Python 3.10 або окремо порівнюю довжини — інакше загублений рядок у UI тест не помітить.
🗣 How to say it out loud enumerate gives «index and item» pairs, so no manual counter is needed, and start equals one gives human row numbers for messages. zip walks several lists in parallel and yields tuples. I always remember that zip truncates the longer list by default, so in table checks I add strict equals True from Python 3.10 or compare lengths separately — otherwise the test will not notice a row lost in the UI.
16 Що таке ітератор у Python і чим він відрізняється від ітерованого об'єкта? What is an iterator in Python, and how does it differ from an iterable?
КолекціїCollections Middle ▶

Два поняття. Ітерований об'єкт (iterable) — це те, по чому можна пройти циклом: список, рядок, словник, файл. У нього є метод __iter__, який повертає ітератор. Ітератор — об'єкт зі станом, який знає поточну позицію і має __next__: кожен виклик повертає наступний елемент, а коли елементи скінчилися, кидає StopIteration. Ітератор сам теж ітерований: його __iter__ повертає self.

Two concepts. An iterable is anything you can loop over: a list, a string, a dict, a file. It has an __iter__ method that returns an iterator. An iterator is a stateful object that knows its current position and has __next__: every call returns the next item, and once the items run out it raises StopIteration. An iterator is iterable too: its __iter__ returns self.

Що робить for. Цикл for x in items викликає iter(items), а потім next(), доки не прийде StopIteration. Список можна проходити скільки завгодно разів, бо кожен цикл отримує новий ітератор. Ітератор проходиться один раз.

What for does. The loop for x in items calls iter(items) and then next() until StopIteration arrives. A list can be looped over any number of times because each loop gets a fresh iterator. An iterator can be walked once.

Де це кусає в тестах. zip, map, filter, генератори й файлові об'єкти — ітератори. Якщо зберегти rows = map(parse, lines), порахувати len(list(rows)) у перевірці, а потім пройти rows ще раз, другий прохід буде порожнім, і тест «нічого не перевірить» без жодної помилки.

Where it bites in tests. zip, map, filter, generators and file objects are iterators. Save rows = map(parse, lines), count len(list(rows)) in one check, then loop over rows again — the second pass is empty and the test «checks nothing» without any error.

Свій ітератор. Клас з __iter__ і __next__ — це повний протокол, але на практиці частіше пишуть генераторну функцію з yield, яка створює ітератор автоматично. next(it, default) повертає дефолт замість StopIteration, що зручно для «перший елемент або None».

Your own iterator. A class with __iter__ and __next__ is the full protocol, but in practice people usually write a generator function with yield, which creates the iterator automatically. next(it, default) returns the default instead of raising StopIteration, which is handy for «first item or None».

Протокол ітератора і вичерпання
The iterator protocol and exhaustion
class Countdown:
    def __init__(self, start):
        self.current = start
    def __iter__(self):
        return self
    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        self.current -= 1
        return self.current + 1

print(list(Countdown(3)))

it = iter(["a", "b"])
print(next(it), next(it), next(it, "done"))

rows = map(str.upper, ["ok", "fail"])
print("first pass:", list(rows))
print("second pass:", list(rows))  # вичерпаний, мовчки порожній

statuses = [200, 200, 500]
first_error = next((s for s in statuses if s >= 500), None)
print("first error:", first_error)
class Countdown:
    def __init__(self, start):
        self.current = start
    def __iter__(self):
        return self
    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        self.current -= 1
        return self.current + 1

print(list(Countdown(3)))

it = iter(["a", "b"])
print(next(it), next(it), next(it, "done"))

rows = map(str.upper, ["ok", "fail"])
print("first pass:", list(rows))
print("second pass:", list(rows))  # exhausted, silently empty

statuses = [200, 200, 500]
first_error = next((s for s in statuses if s >= 500), None)
print("first error:", first_error)
Вивід
Output
[3, 2, 1]
a b done
first pass: ['OK', 'FAIL']
second pass: []
first error: 500
🔧 Розбір коду: Countdown реалізує протокол вручну: __iter__ повертає себе, __next__ кидає StopIteration у кінці. next(it, "done") не падає на вичерпаному ітераторі. Другий list(rows) порожній, бо map — одноразовий ітератор. next з генератором знаходить перший збіг і зупиняється.
🔧 Code breakdown: Countdown implements the protocol by hand: __iter__ returns itself, __next__ raises StopIteration at the end. next(it, "done") does not fail on an exhausted iterator. The second list(rows) is empty because map is a one-shot iterator. next over a generator finds the first match and stops.
💡 Простими словами: Ітерований об'єкт — це книжка, ітератор — закладка в ній: закладка рухається лише вперед і лише раз.
💡 In plain words: An iterable is a book, an iterator is the bookmark in it: the bookmark only moves forward, and only once.
🗣 Як читати вголос Я розрізняю ітерований об'єкт, у якого є __iter__, і ітератор, який ще має __next__ і пам'ятає позицію. Цикл for бере ітератор і викликає next, доки не прийде StopIteration. Практичний наслідок: список можна пройти багато разів, а ітератор — лише раз, і map, zip, генератори та файли саме такі. У тестах це дає тихі баги: другий прохід по вже використаному map порожній, і перевірка нічого не перевіряє. Щоб узяти перший елемент або None, використовую next з дефолтом.
🗣 How to say it out loud I separate an iterable, which has __iter__, from an iterator, which also has __next__ and remembers its position. A for loop gets an iterator and calls next until StopIteration comes. The practical consequence is that a list can be walked many times but an iterator only once, and map, zip, generators and files all behave that way. In tests that causes silent bugs: a second pass over a used map is empty, so the check verifies nothing. To take the first item or None, I use next with a default.
17 Що таке генератори та yield і чому генератор кращий за список для великих даних? What are generators and yield, and why is a generator better than a list for large data?
КолекціїCollections Middle ▶

Що це. Функція з yield — генераторна: її виклик не виконує тіло, а повертає об'єкт-генератор. Кожен next() запускає код до наступного yield, віддає значення і «заморожує» функцію разом з локальними змінними до наступного запиту. Коли функція доходить до кінця, генератор кидає StopIteration.

What it is. A function containing yield is a generator function: calling it does not run the body but returns a generator object. Each next() runs the code up to the next yield, hands out a value and «freezes» the function with its local variables until the next request. When the function reaches its end, the generator raises StopIteration.

Чому це економить пам'ять. Список з мільйона рядків логу існує в пам'яті цілком. Генератор тримає лише поточний рядок і стан. sys.getsizeof для списку з мільйона чисел покаже мегабайти, а для генератора — сотню-дві байт незалежно від кількості елементів. Друга перевага — ліниве обчислення: якщо шукаєш перший рядок з ERROR, генератор зупиниться на ньому й не читатиме решту файла.

Why it saves memory. A list of a million log lines lives in memory in full. A generator holds only the current line and its state. sys.getsizeof on a list of a million numbers shows megabytes, and on a generator a couple of hundred bytes regardless of the item count. The second benefit is lazy evaluation: if you are looking for the first line with ERROR, the generator stops there and never reads the rest of the file.

У QA. Читання величезних логів і CSV рядок за рядком, пагінація API (yield кожного запису, доки сервер повертає наступну сторінку), генерація тестових даних на льоту. Фікстури pytest з yield — теж генератори: код до yield — підготовка, після — прибирання.

In QA. Reading huge logs and CSV files line by line, API pagination (yield each record while the server keeps returning another page), generating test data on the fly. pytest fixtures with yield are generators too: code before yield is setup, code after it is teardown.

Підводні камені. Генератор одноразовий і не має len(). Помилка всередині генератора з'явиться не при виклику функції, а при першому next, тобто в іншому рядку тесту. І yield from other() делегує іншому генератору без ручного циклу.

Pitfalls. A generator is one-shot and has no len(). An error inside it surfaces not when the function is called but on the first next, i.e. on a different line of the test. And yield from other() delegates to another generator without a manual loop.

Пагінація API як генератор
API pagination as a generator
import sys

def fetch_page(page):
    data = {1: ["u1", "u2"], 2: ["u3", "u4"], 3: ["u5"]}
    print(f"  requesting page {page}")
    return data.get(page, [])

def all_users():
    page = 1
    while batch := fetch_page(page):
        yield from batch
        page += 1

gen = all_users()
print("created, nothing requested yet")
print("first:", next(gen))
print("found:", next(u for u in gen if u == "u3"))

numbers = list(range(1_000_000))
lazy = (n for n in range(1_000_000))
print(f"list: {sys.getsizeof(numbers):,} bytes, generator: {sys.getsizeof(lazy)} bytes")
Вивід
Output
created, nothing requested yet
  requesting page 1
first: u1
  requesting page 2
found: u3
list: 8,000,056 bytes, generator: 192 bytes
🔧 Розбір коду: Виклик all_users() нічого не запитує — перший запит іде лише з першим next. while batch := fetch_page(page) використовує оператор присвоєння у виразі (Python 3.8+) і зупиняється на порожній сторінці. Пошук u3 запросив тільки другу сторінку, до третьої справа не дійшла.
🔧 Code breakdown: Calling all_users() requests nothing — the first request goes out only with the first next. while batch := fetch_page(page) uses the assignment expression (Python 3.8+) and stops on an empty page. Looking for u3 requested only page two; page three was never needed.
💡 Простими словами: Список — це вся партія товару на складі; генератор — конвеєр, що видає по одній коробці, коли її попросять.
💡 In plain words: A list is the whole shipment sitting in a warehouse; a generator is a conveyor handing out one box whenever you ask.
🗣 Як читати вголос Генератор — це функція з yield, яка при виклику повертає об'єкт і виконується частинами: кожен next доганяє код до наступного yield і заморожує стан. Для великих даних це важливо двічі: у пам'яті лише поточний елемент, а не весь список, і обчислення ліниве, тож пошук першої помилки в логу не читає файл до кінця. У тестах я так обходжу пагінацію API й читаю великі CSV. Пам'ятаю, що генератор одноразовий і не має len.
🗣 How to say it out loud A generator is a function with yield that returns an object when called and runs in pieces: each next advances the code to the next yield and freezes the state. For large data that matters twice: only the current item is in memory, not the whole list, and evaluation is lazy, so looking for the first error in a log does not read the whole file. In tests I walk API pagination and read big CSV files this way. I keep in mind a generator is one-shot and has no len.
18 Які функції itertools найкорисніші для тестових даних і обробки результатів? Which itertools functions are most useful for test data and result processing?
КолекціїCollections Middle ▶

Що це. itertools — стандартний модуль з лінивими будівельними блоками для ітераторів. Вони не створюють проміжних списків, тому комбінуються як конвеєр.

What it is. itertools is a standard module of lazy building blocks for iterators. They create no intermediate lists, so they chain together like a pipeline.

Для тестових даних. product(browsers, locales, roles) дає всі комбінації — готову матрицю для parametrize, але її розмір росте як добуток, тому часто беруть лише попарні комбінації з combinations(items, 2). permutations — усі порядки, корисно для тестів, де порядок кроків не має впливати на результат. cycle з islice розкладає користувачів по воркерах по колу.

For test data. product(browsers, locales, roles) yields every combination — a ready matrix for parametrize, but its size grows as a product, so teams often keep only pairs from combinations(items, 2). permutations gives every ordering, useful for tests where the order of steps must not change the result. cycle with islice deals users out to workers round-robin.

Для результатів. chain.from_iterable(pages) склеює сторінки відповіді API в один потік. islice(gen, 10) бере перші 10 елементів з генератора без list(). groupby групує сусідні елементи з однаковим ключем — наприклад, рядки логу за рівнем. pairwise (Python 3.10+) дає сусідні пари: зручно перевіряти, що часові мітки подій ідуть за зростанням. accumulate рахує накопичувальні суми.

For results. chain.from_iterable(pages) joins pages of an API response into one stream. islice(gen, 10) takes the first 10 items of a generator without list(). groupby groups adjacent items with the same key — log lines by level, for example. pairwise (Python 3.10+) gives neighbouring pairs: handy to check that event timestamps increase. accumulate produces running totals.

Підводні камені. groupby об'єднує лише сусідні елементи, тому дані спершу сортують за тим самим ключем, інакше отримаєш кілька груп з одним ключем. Групи в groupby — теж ітератори, які псуються після переходу до наступної групи, тож їх одразу перетворюють на список. batched для порцій з'явився лише в Python 3.12.

Pitfalls. groupby merges only adjacent items, so sort the data by the same key first, or you get several groups with one key. Groups from groupby are iterators that break once you move to the next group, so turn them into lists right away. batched for chunking only arrived in Python 3.12.

Матриця, сторінки, групи й порядок подій
A matrix, pages, groups and event order
from itertools import chain, combinations, groupby, islice, pairwise, product

matrix = list(product(["chromium", "firefox"], ["en", "uk"]))
print(len(matrix), matrix[:2])
print(list(combinations(["cart", "promo", "gift"], 2)))

pages = [[1, 2], [3], [4, 5]]
print(list(islice(chain.from_iterable(pages), 4)))

log = [("ERROR", "db"), ("INFO", "start"), ("ERROR", "timeout"), ("INFO", "stop")]
log.sort(key=lambda r: r[0])  # groupby потребує відсортованих даних
print({level: [msg for _, msg in grp] for level, grp in groupby(log, key=lambda r: r[0])})

timestamps = [10, 12, 12, 11]
print("ordered:", all(a <= b for a, b in pairwise(timestamps)))
from itertools import chain, combinations, groupby, islice, pairwise, product

matrix = list(product(["chromium", "firefox"], ["en", "uk"]))
print(len(matrix), matrix[:2])
print(list(combinations(["cart", "promo", "gift"], 2)))

pages = [[1, 2], [3], [4, 5]]
print(list(islice(chain.from_iterable(pages), 4)))

log = [("ERROR", "db"), ("INFO", "start"), ("ERROR", "timeout"), ("INFO", "stop")]
log.sort(key=lambda r: r[0])  # groupby needs sorted input
print({level: [msg for _, msg in grp] for level, grp in groupby(log, key=lambda r: r[0])})

timestamps = [10, 12, 12, 11]
print("ordered:", all(a <= b for a, b in pairwise(timestamps)))
Вивід
Output
4 [('chromium', 'en'), ('chromium', 'uk')]
[('cart', 'promo'), ('cart', 'gift'), ('promo', 'gift')]
[1, 2, 3, 4]
{'ERROR': ['db', 'timeout'], 'INFO': ['start', 'stop']}
ordered: False
🔧 Розбір коду: product дає 4 комбінації браузер × мова. islice бере 4 елементи з об'єднаного потоку сторінок. Сортування перед groupby гарантує одну групу на рівень, а comprehension одразу матеріалізує кожну групу. pairwise знаходить пару (12, 11), де час пішов назад.
🔧 Code breakdown: product yields 4 browser × locale combinations. islice takes 4 items from the joined stream of pages. Sorting before groupby guarantees one group per level, and the comprehension materialises each group immediately. pairwise finds the pair (12, 11) where time went backwards.
💡 Простими словами: itertools — це набір насадок на конвеєр: розмножити, склеїти, відрізати перші N, згрупувати сусідів.
💡 In plain words: itertools is a set of attachments for a conveyor: multiply, join, cut off the first N, group neighbours.
🗣 Як читати вголос З itertools я найчастіше беру product для матриці браузерів і мов, хоча пам'ятаю, що вона росте як добуток, тому інколи обмежуюсь парами через combinations. Для відповідей API з пагінацією — chain.from_iterable, для перших N з генератора — islice, для перевірки, що події йдуть по порядку, — pairwise з версії 3.10. groupby використовую обережно: він групує лише сусідні елементи, тож дані спершу сортую тим самим ключем.
🗣 How to say it out loud From itertools I mostly reach for product to build a browser and locale matrix, keeping in mind it grows as a product, so sometimes I limit myself to pairs with combinations. For paginated API responses I use chain.from_iterable, for the first N from a generator islice, and to check events arrive in order, pairwise from 3.10. I use groupby carefully: it groups only adjacent items, so I sort the data by the same key first.
▶ 3 11
19 Що таке *args і **kwargs і як ними користуватися без хаосу в сигнатурах? What are *args and **kwargs, and how do you use them without messy signatures?
Функції й класиFunctions and classes Junior ▶

Що це. У визначенні функції *args збирає всі зайві позиційні аргументи в кортеж, а **kwargs — усі зайві іменовані аргументи в словник. Імена args і kwargs — лише домовленість, важливі зірочки. У виклику зірочки працюють навпаки: f(*items) розпаковує список у позиційні аргументи, f(**payload) — словник в іменовані.

What it is. In a function definition *args collects any extra positional arguments into a tuple, and **kwargs collects any extra keyword arguments into a dict. The names args and kwargs are only a convention; the stars are what matter. In a call the stars work the other way: f(*items) unpacks a list into positional arguments, f(**payload) a dict into keyword arguments.

Порядок параметрів. Звичайні, потім *args, потім іменовані лише за ключем, потім **kwargs. Параметр після * можна передати тільки на ім'я: def create_user(name, *, role="viewer") не дасть випадково написати create_user("ann", "admin"). Символ / (Python 3.8+) робить параметри перед ним лише позиційними.

Parameter order. Regular ones, then *args, then keyword-only ones, then **kwargs. A parameter after * can only be passed by name: def create_user(name, *, role="viewer") stops anyone from writing create_user("ann", "admin") by mistake. The / marker (Python 3.8+) makes the parameters before it positional-only.

Де це в тестах. Обгортки й декоратори, які мають прокинути будь-які аргументи далі: def wrapper(*args, **kwargs): return func(*args, **kwargs). Фабрики тестових даних: make_user(**overrides) бере типового користувача і перекриває лише потрібні поля. Тонкий клієнт API, що передає **kwargs прямо в requests, дозволяє тесту додати timeout чи headers без зміни клієнта.

Where tests use it. Wrappers and decorators that must pass any arguments through: def wrapper(*args, **kwargs): return func(*args, **kwargs). Test data factories: make_user(**overrides) starts from a typical user and overrides only the fields you need. A thin API client that forwards **kwargs straight into requests lets a test add timeout or headers without touching the client.

Підводні камені. **kwargs ковтає друкарські помилки: make_user(emial="x") не впаде, а тихо додасть непотрібне поле. У фабриках даних варто перевіряти ключі на відомі поля. І сигнатура з одними *args, **kwargs нічого не каже ні IDE, ні mypy — для публічних хелперів краще явні параметри.

Pitfalls. **kwargs swallows typos: make_user(emial="x") will not fail, it quietly adds a useless field. Data factories should check keys against known fields. And a signature made only of *args, **kwargs tells neither the IDE nor mypy anything — public helpers deserve explicit parameters.

Фабрика тестових користувачів
A test user factory
DEFAULT_USER = {"name": "Test User", "email": "user@example.com", "role": "viewer"}

def make_user(**overrides):
    unknown = overrides.keys() - DEFAULT_USER.keys()
    if unknown:
        raise TypeError(f"unknown fields: {sorted(unknown)}")
    return {**DEFAULT_USER, **overrides}

def log_call(*args, **kwargs):
    print("args:", args, "kwargs:", kwargs)

def create_user(name, *, role="viewer"):
    return f"{name}:{role}"

print(make_user(role="admin"))
log_call(1, "two", timeout=5, retries=2)
point = [3, 4]
log_call(*point, **{"unit": "px"})
print(create_user("ann", role="admin"))
for bad_call in (lambda: make_user(emial="x"), lambda: create_user("ann", "admin")):
    try:
        bad_call()
    except TypeError as e:
        print("TypeError:", e)
Вивід
Output
{'name': 'Test User', 'email': 'user@example.com', 'role': 'admin'}
args: (1, 'two') kwargs: {'timeout': 5, 'retries': 2}
args: (3, 4) kwargs: {'unit': 'px'}
ann:admin
TypeError: unknown fields: ['emial']
TypeError: create_user() takes 1 positional argument but 2 were given
🔧 Розбір коду: {**DEFAULT_USER, **overrides} зливає два словники, і праві ключі перемагають. overrides.keys() - DEFAULT_USER.keys() — різниця множин ключів, що ловить друкарську помилку emial. Зірочка в create_user(name, *, role) забороняє передати роль позиційно.
🔧 Code breakdown: {**DEFAULT_USER, **overrides} merges two dicts, with the right-hand keys winning. overrides.keys() - DEFAULT_USER.keys() is a set difference of keys that catches the emial typo. The bare star in create_user(name, *, role) forbids passing the role positionally.
💡 Простими словами: Одна зірочка — «решта позиційних у кортеж», дві — «решта іменованих у словник»; у виклику — навпаки, розпакувати.
💡 In plain words: One star means «the rest of the positional ones into a tuple», two mean «the rest of the named ones into a dict»; in a call they unpack instead.
🗣 Як читати вголос args збирає зайві позиційні аргументи в кортеж, kwargs — іменовані в словник, а у виклику зірочки розпаковують список чи словник назад в аргументи. Я використовую їх у декораторах, щоб прокинути будь-які аргументи, і у фабриках тестових даних, де kwargs перекривають типові поля. Але kwargs ковтають друкарські помилки, тому у фабриці я перевіряю ключі. А для публічних функцій роблю параметри явними, часто лише іменованими через зірочку.
🗣 How to say it out loud args collects extra positional arguments into a tuple, kwargs collects named ones into a dict, and in a call the stars unpack a list or dict back into arguments. I use them in decorators to pass any arguments through and in test data factories where kwargs override default fields. But kwargs swallow typos, so the factory checks the keys. For public functions I keep parameters explicit, often keyword-only with a bare star.
20 Що таке декоратор і як написати власний, не зламавши ім'я та docstring функції? What is a decorator, and how do you write your own without breaking the function's name and docstring?
Функції й класиFunctions and classes Middle ▶

Що це. Декоратор — функція, яка приймає функцію і повертає іншу функцію, зазвичай обгортку з додатковою поведінкою. Запис @timed над def — це лише синтаксичний цукор для func = timed(func). Працює це завдяки тому, що функції в Python — звичайні об'єкти, а вкладена функція пам'ятає змінні зовнішньої (замикання).

What it is. A decorator is a function that takes a function and returns another one, usually a wrapper with extra behaviour. @timed above a def is just syntactic sugar for func = timed(func). It works because functions in Python are ordinary objects and a nested function remembers the outer function's variables (a closure).

Як писати. Обгортка приймає *args, **kwargs, щоб підійти до будь-якої сигнатури, викликає оригінал і повертає його результат. Над обгорткою ставлять @functools.wraps(func): він копіює __name__, __doc__, __module__ і посилання __wrapped__. Без нього всі задекоровані функції в логах і звітах називаються wrapper, а pytest та інші інструменти, що читають сигнатуру, можуть поводитися дивно. Декоратор з параметрами — це ще один рівень: функція, що повертає декоратор.

How to write one. The wrapper takes *args, **kwargs to fit any signature, calls the original and returns its result. Put @functools.wraps(func) on the wrapper: it copies __name__, __doc__, __module__ and a __wrapped__ reference. Without it every decorated function appears as wrapper in logs and reports, and pytest and other tools that read signatures can misbehave. A decorator with parameters adds one more level: a function that returns a decorator.

Декоратори, які QA бачить щодня. @pytest.fixture, @pytest.mark.parametrize, @property, @dataclass, @functools.cache. Власні пишуть для вимірювання часу кроку, логування викликів API, повторних спроб і позначення кроків звіту в Allure.

Decorators QA sees every day. @pytest.fixture, @pytest.mark.parametrize, @property, @dataclass, @functools.cache. People write their own to time a step, log API calls, retry and mark report steps in Allure.

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

Pitfalls. Decorator order matters: they apply bottom-up. A wrapper that forgets return silently returns None instead of the result.

Декоратор вимірювання часу кроку
A step timing decorator
import functools
import time

def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            print(f"{func.__name__} took {time.perf_counter() - start:.2f}s")
    return wrapper

def no_wraps(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@timed
def open_cart(user):
    """Open the cart page for a user."""
    time.sleep(0.2)
    return f"cart of {user}"

@no_wraps
def checkout():
    """Pay for the cart."""

print(open_cart("ann"))
print(open_cart.__name__, "|", open_cart.__doc__)
print(checkout.__name__, "|", checkout.__doc__)
Вивід
Output
open_cart took 0.20s
cart of ann
open_cart | Open the cart page for a user.
wrapper | None
🔧 Розбір коду: try/finally в обгортці друкує час навіть тоді, коли функція впала. return func(*args, **kwargs) повертає результат оригіналу. functools.wraps зберіг ім'я та docstring open_cart, а checkout без нього став wrapper з порожньою документацією.
🔧 Code breakdown: try/finally in the wrapper prints the time even when the function failed. return func(*args, **kwargs) passes the original's result back. functools.wraps kept the name and docstring of open_cart, while checkout without it became wrapper with no documentation.
💡 Простими словами: Декоратор — це обгортка для подарунка: всередині та сама функція, але зверху додали щось своє.
💡 In plain words: A decorator is gift wrapping: the same function is inside, but something extra has been added on the outside.
🗣 Як читати вголос Декоратор — це функція, яка приймає функцію й повертає нову, зазвичай обгортку, а символ собачки — лише коротший запис присвоєння. Обгортку я пишу з args і kwargs, повертаю результат оригіналу і завжди ставлю functools.wraps, щоб у логах і звітах лишалися справжні імена функцій. У тестах бачу декоратори щодня: фікстури, parametrize, property. Сам писав декоратори для заміру часу кроків і для повторних спроб нестабільних викликів.
🗣 How to say it out loud A decorator is a function that takes a function and returns a new one, usually a wrapper, and the at sign is just shorter syntax for an assignment. I write the wrapper with args and kwargs, return the original's result and always add functools.wraps so logs and reports keep the real function names. In tests I see decorators daily: fixtures, parametrize, property. I have written my own for timing steps and for retrying unstable calls.
21 Як написати декоратор retry для нестабільних викликів і де його застосовувати не можна? How do you write a retry decorator for flaky calls, and where must it not be used?
Функції й класиFunctions and classes Senior ▶

Що він робить. Повторює виклик, якщо той кинув один із переданих типів винятків, з паузою, що зростає (експоненційний backoff), і здається після N спроб, прокидаючи останню помилку. Параметри — tries, delay, backoff і кортеж exceptions. Це декоратор з параметрами, тож рівнів три: фабрика, декоратор, обгортка з functools.wraps.

What it does. It repeats a call when the call raised one of the given exception types, with a growing pause (exponential backoff), and gives up after N attempts, re-raising the last error. The parameters are tries, delay, backoff and an exceptions tuple. It is a decorator with parameters, so there are three levels: factory, decorator, and a wrapper with functools.wraps.

Де доречний. Навколо інфраструктурних кроків, які падають з причин поза тестом: підключення до тестового стенду, що прокидається, завантаження токена, очікування появи запису в асинхронній системі. Тоді повтор ловить ConnectionError чи TimeoutError, а не будь-що.

Where it fits. Around infrastructure steps that fail for reasons outside the test: connecting to a test environment that is waking up, fetching a token, waiting for a record to appear in an asynchronous system. Then the retry catches ConnectionError or TimeoutError, not anything at all.

Де не можна. Ніколи не ловити AssertionError і не обгортати сам тест: так справжній баг, що проявляється раз на три запуски, стає зеленим. Не повторювати неідемпотентні запити — POST «створити замовлення» після таймауту міг уже виконатися на сервері, і повтор створить дубль. Для HTTP краще повтори на рівні транспорту (urllib3.Retry), які за замовчуванням не повторюють POST.

Where it must not go. Never catch AssertionError and never wrap the test itself: a real bug that shows up once in three runs turns green that way. Do not retry non-idempotent requests — a POST «create order» that timed out may already have run on the server, and a retry creates a duplicate. For HTTP prefer transport-level retries (urllib3.Retry), which by default do not retry POST.

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

What else matters. Every attempt must be visible in the logs: otherwise the test «passes» while the environment slowly degrades unnoticed. The total retry time has to fit inside the test timeout. The tenacity library covers complex cases, but writing retry yourself is a classic interview task.

retry з backoff і журналом спроб
retry with backoff and an attempt log
import functools
import time

def retry(tries=3, delay=0.1, backoff=2, exceptions=(ConnectionError, TimeoutError)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            wait = delay
            for attempt in range(1, tries + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    if attempt == tries:
                        raise
                    print(f"{func.__name__}: attempt {attempt} failed ({e!r}), retry in {wait:.1f}s")
                    time.sleep(wait)
                    wait *= backoff
        return wrapper
    return decorator

calls = {"n": 0}

@retry(tries=4)
def get_token():
    calls["n"] += 1
    if calls["n"] < 3:
        raise ConnectionError("stand is waking up")
    return "token-123"

@retry(tries=4)
def check_total():
    calls["total"] = calls.get("total", 0) + 1
    assert 2 + 2 == 5, "real bug"  # має впасти одразу, без повторів

print(get_token())
try:
    check_total()
except AssertionError as e:
    print("AssertionError after", calls["total"], "call:", e)
import functools
import time

def retry(tries=3, delay=0.1, backoff=2, exceptions=(ConnectionError, TimeoutError)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            wait = delay
            for attempt in range(1, tries + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    if attempt == tries:
                        raise
                    print(f"{func.__name__}: attempt {attempt} failed ({e!r}), retry in {wait:.1f}s")
                    time.sleep(wait)
                    wait *= backoff
        return wrapper
    return decorator

calls = {"n": 0}

@retry(tries=4)
def get_token():
    calls["n"] += 1
    if calls["n"] < 3:
        raise ConnectionError("stand is waking up")
    return "token-123"

@retry(tries=4)
def check_total():
    calls["total"] = calls.get("total", 0) + 1
    assert 2 + 2 == 5, "real bug"  # must fail at once, no retries

print(get_token())
try:
    check_total()
except AssertionError as e:
    print("AssertionError after", calls["total"], "call:", e)
Вивід
Output
$ python retry.py
get_token: attempt 1 failed (ConnectionError('stand is waking up')), retry in 0.1s
get_token: attempt 2 failed (ConnectionError('stand is waking up')), retry in 0.2s
token-123
AssertionError after 1 call: real bug
🔧 Розбір коду: Три рівні: retry(...) повертає decorator, той — wrapper. except exceptions ловить лише перелічені типи, тому AssertionError проходить наскрізь з першого разу. Голий raise на останній спробі прокидає оригінальну помилку зі своїм traceback. wait *= backoff подвоює паузу.
🔧 Code breakdown: Three levels: retry(...) returns decorator, which returns wrapper. except exceptions catches only the listed types, so AssertionError goes straight through on the first call. A bare raise on the last attempt re-raises the original error with its traceback. wait *= backoff doubles the pause.
💡 Простими словами: Retry — це «подзвони ще раз, якщо не взяли слухавку», а не «перепитай, доки не почуєш потрібну відповідь».
💡 In plain words: Retry means «call again if nobody picked up», not «keep asking until you hear the answer you want».
🗣 Як читати вголос Я пишу retry як декоратор з параметрами: кількість спроб, пауза, множник backoff і кортеж винятків, які варто повторювати. Всередині цикл, try, і на останній спробі голий raise, щоб зберегти оригінальну помилку. Головне для мене — межі: повторюю лише інфраструктурні збої на кшталт ConnectionError, ніколи не AssertionError і не цілий тест, бо так ховаються справжні баги. POST без ідемпотентності не повторюю, а кожну спробу логую, щоб деградація стенду була видна.
🗣 How to say it out loud I write retry as a decorator with parameters: number of tries, delay, a backoff multiplier and a tuple of exceptions worth retrying. Inside there is a loop, a try, and a bare raise on the last attempt so the original error survives. The key part for me is the limits: I retry only infrastructure failures like ConnectionError, never AssertionError and never a whole test, because that hides real bugs. I do not retry a non-idempotent POST, and I log every attempt so a degrading environment stays visible.
22 Що таке self, __init__ і як викликати метод батьківського класу через super()? What are self and __init__, and how do you call a parent class method with super()?
Функції й класиFunctions and classes Junior ▶

self. Перший параметр методу екземпляра — посилання на сам об'єкт, для якого метод викликали. page.open() — це фактично Page.open(page). Ім'я self — домовленість, але порушувати її не варто. Через self метод читає й змінює атрибути конкретного об'єкта.

self. The first parameter of an instance method is a reference to the very object the method was called on. page.open() is effectively Page.open(page). The name self is a convention, but do not break it. Through self a method reads and changes the attributes of that particular object.

__init__. Ініціалізатор: викликається автоматично після створення об'єкта і задає початковий стан — self.base_url = base_url. Це не конструктор у строгому сенсі: об'єкт створює __new__, а __init__ лише налаштовує вже готовий. Він має повертати None.

__init__. The initialiser: it runs automatically after the object is created and sets the initial state — self.base_url = base_url. Strictly it is not a constructor: __new__ creates the object, and __init__ only configures the ready one. It must return None.

super(). У класі-нащадку super().__init__(...) викликає ініціалізатор батька, щоб не дублювати його логіку. Типовий приклад з автоматизації: BasePage зберігає драйвер і базовий URL, а LoginPage(BasePage) додає свої локатори й спершу викликає super().__init__(driver). Так само можна розширити будь-який метод батька: викликати super().open() і додати очікування.

super(). In a subclass super().__init__(...) calls the parent's initialiser so its logic is not duplicated. A typical automation example: BasePage stores the driver and the base URL, and LoginPage(BasePage) adds its own locators and first calls super().__init__(driver). You can extend any parent method the same way: call super().open() and add a wait.

Підводні камені. Якщо нащадок визначив __init__ і забув super().__init__(), атрибути батька не з'являться, і падіння буде пізніше з AttributeError. Атрибут, оголошений на рівні класу як змінюваний список, спільний для всіх екземплярів — стан створюй у __init__.

Pitfalls. If a subclass defines __init__ and forgets super().__init__(), the parent's attributes never appear and the failure comes later as an AttributeError. An attribute declared at class level as a mutable list is shared by all instances — create state in __init__.

Базова сторінка і нащадок
A base page and a subclass
class BasePage:
    base_url = "https://shop.test"
    visited = []  # атрибут класу, спільний для всіх сторінок

    def __init__(self, driver, path="/"):
        self.driver = driver
        self.url = self.base_url + path

    def open(self):
        self.visited.append(self.url)
        return f"{self.driver} opens {self.url}"

class LoginPage(BasePage):
    def __init__(self, driver):
        super().__init__(driver, path="/login")
        self.username_field = "#username"

    def open(self):
        result = super().open()
        return result + " and waits for " + self.username_field

class BrokenPage(BasePage):
    def __init__(self, driver):
        self.title = "no super call"

print(LoginPage("chrome").open())
print(BasePage.open(BasePage("firefox")))
print(BasePage.visited)
try:
    BrokenPage("chrome").open()
except AttributeError as e:
    print("AttributeError:", e)
class BasePage:
    base_url = "https://shop.test"
    visited = []  # class attribute, shared by every page

    def __init__(self, driver, path="/"):
        self.driver = driver
        self.url = self.base_url + path

    def open(self):
        self.visited.append(self.url)
        return f"{self.driver} opens {self.url}"

class LoginPage(BasePage):
    def __init__(self, driver):
        super().__init__(driver, path="/login")
        self.username_field = "#username"

    def open(self):
        result = super().open()
        return result + " and waits for " + self.username_field

class BrokenPage(BasePage):
    def __init__(self, driver):
        self.title = "no super call"

print(LoginPage("chrome").open())
print(BasePage.open(BasePage("firefox")))
print(BasePage.visited)
try:
    BrokenPage("chrome").open()
except AttributeError as e:
    print("AttributeError:", e)
Вивід
Output
chrome opens https://shop.test/login and waits for #username
firefox opens https://shop.test/
['https://shop.test/login', 'https://shop.test/']
AttributeError: 'BrokenPage' object has no attribute 'url'
🔧 Розбір коду: super().__init__(driver, path="/login") виконує логіку батька, а далі нащадок додає своє поле. super().open() розширює метод батька. BasePage.open(BasePage("firefox")) показує, що self — звичайний перший аргумент. visited на рівні класу спільний для всіх сторінок, а BrokenPage без super() не має url.
🔧 Code breakdown: super().__init__(driver, path="/login") runs the parent's logic, then the subclass adds its own field. super().open() extends the parent method. BasePage.open(BasePage("firefox")) shows that self is just the first argument. visited at class level is shared by every page, and BrokenPage without super() has no url.
💡 Простими словами: self — це «я сам» об'єкта, __init__ — його перший день на роботі, а super() — «спершу зроби, як батько, а потім по-своєму».
💡 In plain words: self is the object's «me», __init__ is its first day at work, and super() means «first do what the parent does, then add your own».
🗣 Як читати вголос self — це посилання на конкретний об'єкт, для якого викликали метод, тому page крапка open — те саме, що Page крапка open від page. __init__ ініціалізує стан нового об'єкта, хоча створює його __new__. У нащадку я викликаю super крапка __init__, щоб батько налаштував свою частину, наприклад драйвер у BasePage, а потім додаю свої локатори. Знаю дві пастки: забутий super дає AttributeError пізніше, а змінюваний атрибут класу спільний для всіх екземплярів.
🗣 How to say it out loud self is a reference to the particular object the method was called on, so page dot open is the same as Page dot open of page. __init__ initialises the new object's state, although __new__ is what creates it. In a subclass I call super dot __init__ so the parent sets up its part, like the driver in BasePage, and then add my own locators. I know two traps: a forgotten super gives an AttributeError later, and a mutable class attribute is shared by all instances.
23 Що таке dunder-методи (__str__, __repr__, __eq__, __len__) і навіщо вони в тестовому коді? What are dunder methods (__str__, __repr__, __eq__, __len__), and why do they matter in test code?
Функції й класиFunctions and classes Middle ▶

Що це. Методи з подвійним підкресленням з обох боків — «магічні»: Python викликає їх сам, коли ти користуєшся вбудованим синтаксисом. len(x) викликає x.__len__(), a == b — a.__eq__(b), str(x) і print — __str__, а repr(x), налагоджувач і pytest у звіті про падіння — __repr__. Є також __contains__ для in, __iter__ для циклу, __getitem__ для квадратних дужок, __enter__ і __exit__ для with.

What it is. Methods with double underscores on both sides are «magic»: Python calls them itself when you use built-in syntax. len(x) calls x.__len__(), a == b calls a.__eq__(b), str(x) and print call __str__, while repr(x), the debugger and pytest's failure report call __repr__. There are also __contains__ for in, __iter__ for loops, __getitem__ for square brackets, and __enter__ and __exit__ for with.

Чому це важливо в тестах. Без __eq__ два об'єкти з однаковими даними не рівні: порівнюється ідентичність. Тоді assert order == expected_order падає, хоча всі поля збігаються. Без __repr__ повідомлення pytest виглядає як <Order object at 0x7f...> і нічого не пояснює. Хороший __repr__ робить звіт про падіння самодостатнім.

Why tests care. Without __eq__ two objects with the same data are not equal: identity is compared. Then assert order == expected_order fails even though every field matches. Without __repr__ a pytest message looks like <Order object at 0x7f...> and explains nothing. A good __repr__ makes a failure report self-explanatory.

Правила. __repr__ — для розробника, в ідеалі виглядає як код, що створює об'єкт; __str__ — для людини. Якщо __str__ немає, використовується __repr__. __eq__ повертає NotImplemented для чужих типів, щоб Python спробував інший бік порівняння.

Rules. __repr__ is for developers and ideally looks like code that recreates the object; __str__ is for humans. Without __str__, __repr__ is used. __eq__ returns NotImplemented for foreign types so Python can try the other side of the comparison.

Підводні камені. Визначивши __eq__, клас втрачає __hash__, і об'єкти вже не можна класти в множину — потрібен явний __hash__ для незмінних об'єктів. На практиці більшість цього дає @dataclass.

Pitfalls. Once you define __eq__, the class loses __hash__ and its objects no longer fit in a set — immutable objects need an explicit __hash__. In practice @dataclass gives you most of this.

Модель замовлення, яку зручно порівнювати
An order model that compares nicely
class Order:
    def __init__(self, order_id, items):
        self.order_id = order_id
        self.items = list(items)

    def __repr__(self):
        return f"Order({self.order_id!r}, {self.items!r})"

    def __str__(self):
        return f"order #{self.order_id} with {len(self)} items"

    def __eq__(self, other):
        if not isinstance(other, Order):
            return NotImplemented
        return (self.order_id, self.items) == (other.order_id, other.items)

    def __len__(self):
        return len(self.items)

    def __contains__(self, sku):
        return sku in self.items

def test_order_from_api_matches_expected():
    actual = Order(42, ["book", "pen"])
    print(str(actual), "|", "pen" in actual)
    assert actual == Order(42, ["book", "pen"])
    assert actual == Order(42, ["book"])
Вивід
Output
$ pytest -q test_dunder.py
F                                                                        [100%]
=================================== FAILURES ===================================
_____________________ test_order_from_api_matches_expected _____________________

    def test_order_from_api_matches_expected():
        actual = Order(42, ["book", "pen"])
        print(str(actual), "|", "pen" in actual)
        assert actual == Order(42, ["book", "pen"])
>       assert actual == Order(42, ["book"])
E       AssertionError: assert Order(42, ['book', 'pen']) == Order(42, ['book'])
E        +  where Order(42, ['book']) = Order(42, ['book'])

test_dunder.py:27: AssertionError
----------------------------- Captured stdout call -----------------------------
order #42 with 2 items | True
=========================== short test summary info ============================
FAILED test_dunder.py::test_order_from_api_matches_expected - AssertionError:...
1 failed in 0.01s
🔧 Розбір коду: __eq__ порівнює кортежі полів і повертає NotImplemented для чужих типів. __contains__ вмикає "pen" in actual, __len__ — len(self) у __str__. Друга перевірка падає навмисно: у звіті pytest видно обидва об'єкти завдяки __repr__, а не адреси в пам'яті.
🔧 Code breakdown: __eq__ compares tuples of fields and returns NotImplemented for foreign types. __contains__ enables "pen" in actual, __len__ powers len(self) inside __str__. The second check fails on purpose: thanks to __repr__ the pytest report shows both objects rather than memory addresses.
💡 Простими словами: Dunder-методи — це «гачки», за які Python чіпляє твій клас до len, ==, print і in.
💡 In plain words: Dunder methods are the hooks by which Python attaches your class to len, ==, print and in.
🗣 Як читати вголос Dunder-методи Python викликає сам, коли ми пишемо звичний синтаксис: len викликає __len__, дорівнює-дорівнює — __eq__, print — __str__, а pytest у звіті про падіння — __repr__. У тестовому коді два з них критичні: без __eq__ два однакові за даними об'єкти не рівні, і перевірка падає, а без __repr__ звіт показує адресу в пам'яті. Ще пам'ятаю, що __eq__ прибирає __hash__. Зазвичай я просто беру dataclass, який генерує це сам.
🗣 How to say it out loud Python calls dunder methods itself whenever we use ordinary syntax: len calls __len__, double equals calls __eq__, print calls __str__, and pytest calls __repr__ in a failure report. In test code two of them are critical: without __eq__ two objects with the same data are not equal and the check fails, and without __repr__ the report shows a memory address. I also remember that __eq__ removes __hash__. Usually I just use a dataclass, which generates all this.
24 Що роблять @property, @staticmethod і @classmethod і коли кожен доречний? What do @property, @staticmethod and @classmethod do, and when does each fit?
Функції й класиFunctions and classes Middle ▶

@property. Перетворює метод на атрибут для читання: page.title замість page.title(). Так обчислюване значення виглядає як поле, а клас може пізніше замінити просте поле на логіку, не ламаючи код, що його використовує. Сеттер @title.setter додає валідацію при записі. В Page Object property часто повертає елемент чи текст, щоб тест читався як assert cart.total == "10.00".

@property. Turns a method into a read attribute: page.title instead of page.title(). A computed value then looks like a field, and a class can later replace a plain field with logic without breaking code that uses it. A setter @title.setter adds validation on write. In a Page Object a property often returns an element or its text, so the test reads like assert cart.total == "10.00".

@staticmethod. Звичайна функція, покладена в клас заради організації коду: не отримує ні self, ні cls і не знає про стан. Наприклад, Price.parse("10,50 грн") як допоміжний парсер.

@staticmethod. A plain function placed in a class for organisation: it gets neither self nor cls and knows nothing about state. For example Price.parse("10,50 UAH") as a helper parser.

@classmethod. Отримує клас першим аргументом cls. Типове застосування — альтернативні конструктори: User.from_json(data), Config.from_env(). Оскільки створення йде через cls(...), нащадок отримує екземпляр свого класу, а не батьківського.

@classmethod. Receives the class as its first argument cls. The typical use is alternative constructors: User.from_json(data), Config.from_env(). Because creation goes through cls(...), a subclass gets an instance of itself rather than of the parent.

Підводні камені. Property, що робить мережевий запит чи довге очікування, вводить в оману: доступ до атрибута виглядає дешевим. Такі речі краще лишати методами з дієслівними назвами. Property без сеттера захищає від запису: присвоєння дасть AttributeError. І якщо staticmethod почав потребувати даних класу, його час переробити на classmethod.

Pitfalls. A property that makes a network call or waits for a long time is misleading: attribute access looks cheap. Such things are better left as methods with verb names. A property without a setter protects against writes: assigning raises AttributeError. And once a staticmethod starts needing class data, it is time to turn it into a classmethod.

Три декоратори в одному класі
Three decorators in one class
from decimal import Decimal

class Price:
    currency = "UAH"

    def __init__(self, amount):
        self._amount = Decimal(amount)

    @property
    def amount(self):
        return self._amount

    @amount.setter
    def amount(self, value):
        if Decimal(value) < 0:
            raise ValueError("price cannot be negative")
        self._amount = Decimal(value)

    @property
    def label(self):
        return f"{self._amount:.2f} {self.currency}"

    @staticmethod
    def parse(text):
        return text.split()[0].replace(",", ".")

    @classmethod
    def from_text(cls, text):
        return cls(cls.parse(text))

class UsdPrice(Price):
    currency = "USD"

p = Price.from_text("10,50 UAH")
print(p.label, type(UsdPrice.from_text("3 USD")).__name__, UsdPrice.from_text("3 USD").label)
for action in (lambda: setattr(p, "amount", -1), lambda: setattr(p, "label", "free")):
    try:
        action()
    except (ValueError, AttributeError) as e:
        print(type(e).__name__, "-", e)
Вивід
Output
10.50 UAH UsdPrice 3.00 USD
ValueError - price cannot be negative
AttributeError - property 'label' of 'Price' object has no setter
🔧 Розбір коду: @amount.setter перевіряє значення при присвоєнні. label без сеттера — лише для читання, тому запис дає AttributeError. parse не залежить від стану, а from_text створює об'єкт через cls, тож UsdPrice.from_text повертає UsdPrice з валютою USD.
🔧 Code breakdown: @amount.setter validates the value on assignment. label has no setter, so it is read-only and writing raises AttributeError. parse does not depend on state, while from_text creates the object through cls, so UsdPrice.from_text returns a UsdPrice in USD.
💡 Простими словами: property — «метод, що прикидається полем», staticmethod — «функція, що живе в класі», classmethod — «метод, який знає свій клас».
💡 In plain words: property is «a method posing as a field», staticmethod is «a function living in a class», classmethod is «a method that knows its class».
🗣 Як читати вголос property дозволяє звертатися до методу як до атрибута, і я використовую його в Page Object, щоб тест читався як «кошик крапка total». З сеттером можна додати валідацію, а без сеттера поле лише для читання. staticmethod не отримує ні self, ні cls — це просто допоміжна функція в просторі імен класу. classmethod отримує cls, і це мій вибір для альтернативних конструкторів на кшталт from_json, бо нащадок отримає екземпляр свого класу. Важких дій у property не роблю.
🗣 How to say it out loud property lets you access a method as an attribute, and I use it in a Page Object so the test reads like «cart dot total». With a setter you can add validation, and without one the field is read-only. staticmethod gets neither self nor cls — it is just a helper in the class namespace. classmethod receives cls, and that is my choice for alternative constructors like from_json, because a subclass gets an instance of its own type. I never put heavy work inside a property.
25 Що таке @dataclass і чому він зручний для моделей тестових даних? What is @dataclass, and why is it handy for test data models?
Функції й класиFunctions and classes Middle ▶

Що це. Декоратор з модуля dataclasses (Python 3.7+), який за анотаціями полів генерує __init__, __repr__ і __eq__. Замість двадцяти рядків шаблонного коду — оголошення полів з типами й значеннями за замовчуванням.

What it is. A decorator from the dataclasses module (Python 3.7+) that generates __init__, __repr__ and __eq__ from annotated fields. Instead of twenty lines of boilerplate you declare fields with types and defaults.

Корисні параметри. frozen=True забороняє змінювати поля після створення і робить об'єкт хешованим — ідеально для еталонних тестових даних, які ніхто не повинен випадково зіпсувати. field(default_factory=list) — правильний спосіб задати змінюване значення за замовчуванням: dataclass навіть не дасть написати tags: list = [] і кине ValueError. dataclasses.replace(obj, role="admin") створює копію зі зміненими полями — зручно для варіацій даних. asdict(obj) перетворює об'єкт на словник, готовий до json= у запиті. __post_init__ дозволяє додати перевірку.

Useful options. frozen=True forbids changing fields after creation and makes the object hashable — ideal for reference test data nobody should damage by accident. field(default_factory=list) is the proper way to give a mutable default: a dataclass will not even let you write tags: list = [] and raises ValueError. dataclasses.replace(obj, role="admin") makes a copy with changed fields — handy for data variations. asdict(obj) turns the object into a dict ready for json= in a request. __post_init__ lets you add validation.

У тестах. Dataclass як модель очікуваного результату: assert parse(response) == Order(id=1, total=Decimal("9.99")), і pytest покаже різницю по полях. Також як білдер запитів і конфігурація фреймворку.

In tests. A dataclass as the model of an expected result: assert parse(response) == Order(id=1, total=Decimal("9.99")), and pytest shows the difference field by field. Also as a request builder and as framework configuration.

Підводні камені. Анотації типів у dataclass не перевіряються під час виконання: User(age="old") створиться без помилки. Для валідації вхідних даних з API потрібен pydantic або власний __post_init__. frozen захищає поля, але не вміст списку всередині.

Pitfalls. Type annotations in a dataclass are not checked at run time: User(age="old") is created without complaint. Validating input from an API needs pydantic or your own __post_init__. frozen protects the fields but not the contents of a list inside.

Незмінний еталонний користувач
An immutable reference user
from dataclasses import FrozenInstanceError, asdict, dataclass, field, replace

@dataclass(frozen=True)
class User:
    name: str
    email: str
    role: str = "viewer"
    tags: tuple = ()

@dataclass
class Suite:
    name: str
    tests: list = field(default_factory=list)

base = User("Ann", "ann@example.com")
admin = replace(base, role="admin")
print(admin)
print(asdict(admin))
print(base == User("Ann", "ann@example.com"), {base, admin} == {admin, base})

try:
    base.role = "admin"
except FrozenInstanceError as e:
    print("frozen:", e)

print(Suite("smoke"), User("Bob", "b@x.io", role=42))  # типи не перевіряються
from dataclasses import FrozenInstanceError, asdict, dataclass, field, replace

@dataclass(frozen=True)
class User:
    name: str
    email: str
    role: str = "viewer"
    tags: tuple = ()

@dataclass
class Suite:
    name: str
    tests: list = field(default_factory=list)

base = User("Ann", "ann@example.com")
admin = replace(base, role="admin")
print(admin)
print(asdict(admin))
print(base == User("Ann", "ann@example.com"), {base, admin} == {admin, base})

try:
    base.role = "admin"
except FrozenInstanceError as e:
    print("frozen:", e)

print(Suite("smoke"), User("Bob", "b@x.io", role=42))  # types are not checked
Вивід
Output
User(name='Ann', email='ann@example.com', role='admin', tags=())
{'name': 'Ann', 'email': 'ann@example.com', 'role': 'admin', 'tags': ()}
True True
frozen: cannot assign to field 'role'
Suite(name='smoke', tests=[]) User(name='Bob', email='b@x.io', role=42, tags=())
🔧 Розбір коду: frozen=True робить об'єкт незмінним і хешованим, тому User можна класти в множину. replace повертає нову копію, asdict — словник. field(default_factory=list) дає кожному Suite власний список. Останній рядок показує, що role=42 пройшов: анотації — не валідація.
🔧 Code breakdown: frozen=True makes the object immutable and hashable, so a User fits in a set. replace returns a new copy, asdict a dict. field(default_factory=list) gives each Suite its own list. The last line shows role=42 went through: annotations are not validation.
💡 Простими словами: dataclass — це «клас-анкета»: перелічив поля, а конструктор, друк і порівняння Python допише сам.
💡 In plain words: A dataclass is a «form class»: list the fields and Python writes the constructor, printing and comparison for you.
🗣 Як читати вголос dataclass генерує __init__, __repr__ і __eq__ з оголошених полів, тож модель даних займає кілька рядків. Для тестових даних я ставлю frozen дорівнює True, щоб еталон ніхто не змінив, а варіації створюю через replace. Змінювані значення за замовчуванням задаю через default_factory. asdict зручно передавати в json запиту. Важливо, що dataclass не перевіряє типи: якщо треба валідувати відповідь API, я беру pydantic.
🗣 How to say it out loud A dataclass generates __init__, __repr__ and __eq__ from declared fields, so a data model takes a few lines. For test data I set frozen equals True so nobody changes the reference object, and I create variations with replace. I give mutable defaults through default_factory. asdict is handy to pass as the request json. Importantly, a dataclass does not check types: when an API response must be validated, I use pydantic.
26 Що таке duck typing і як typing.Protocol робить його перевірюваним? What is duck typing, and how does typing.Protocol make it checkable?
Функції й класиFunctions and classes Middle ▶

Що це. Duck typing — «якщо ходить як качка і крякає як качка, то це качка». Python не питає тип об'єкта, а просто викликає потрібний метод. Функції save_report(stream) байдуже, чи це файл, io.StringIO чи сокет — важливо, щоб був метод write. Саме тому len працює з будь-чим, що має __len__.

What it is. Duck typing means «if it walks like a duck and quacks like a duck, it is a duck». Python does not ask for an object's type; it simply calls the method it needs. A function save_report(stream) does not care whether it gets a file, an io.StringIO or a socket — what matters is a write method. That is why len works with anything that has __len__.

Навіщо це QA. Duck typing робить код легко тестованим: замість справжнього клієнта API в тест можна передати простий фейковий клас з тими самими методами, без наслідування і без бібліотек. Так само фікстура може віддати тесту StringIO замість файла.

Why QA cares. Duck typing makes code easy to test: instead of a real API client you can hand the test a plain fake class with the same methods, with no inheritance and no libraries. Likewise a fixture can give a test a StringIO instead of a file.

Protocol. typing.Protocol (Python 3.8+) описує «форму» об'єкта: які методи з якими сигнатурами він має. Класам не треба від нього наслідуватися — mypy перевіряє структурну відповідність. Це дає переваги обох світів: гнучкість duck typing і перевірку друкарських помилок до запуску тестів. @runtime_checkable дозволяє ще й isinstance, але перевіряє лише наявність методів, не сигнатури.

Protocol. typing.Protocol (Python 3.8+) describes an object's «shape»: which methods with which signatures it has. Classes do not inherit from it — mypy checks the structural match. You get the best of both worlds: duck typing's flexibility and typo checks before the tests run. @runtime_checkable also enables isinstance, but it checks only that the methods exist, not their signatures.

Підводні камені. Помилка duck typing проявляється пізно — AttributeError під час виконання, часто в глибині стека. Перевірка isinstance(x, list) там, де досить «будь-якої послідовності», ламає цю гнучкість. Альтернатива Protocol — абстрактні базові класи з abc, але вони вимагають явного наслідування.

Pitfalls. A duck typing error surfaces late — an AttributeError at run time, often deep in the stack. An isinstance(x, list) check where «any sequence» would do destroys that flexibility. The alternative to Protocol is an abstract base class from abc, but that requires explicit inheritance.

Фейковий клієнт замість справжнього
A fake client instead of a real one
import io
from typing import Protocol, runtime_checkable

@runtime_checkable
class UsersApi(Protocol):
    def get_user(self, user_id: int) -> dict: ...

class FakeUsersApi:  # без наслідування, та сама форма
    def __init__(self, users):
        self.users = users
    def get_user(self, user_id: int) -> dict:
        return self.users[user_id]

def greeting(api: UsersApi, user_id: int) -> str:
    return f"Hello, {api.get_user(user_id)['name']}!"

def save_report(stream, lines):
    for line in lines:
        stream.write(line + "\n")

def test_greeting_with_fake_client():
    api = FakeUsersApi({7: {"name": "Ann"}})
    assert isinstance(api, UsersApi)
    assert greeting(api, 7) == "Hello, Ann!"

def test_report_goes_to_any_writable_stream():
    buffer = io.StringIO()
    save_report(buffer, ["login: passed", "cart: failed"])
    assert buffer.getvalue().splitlines() == ["login: passed", "cart: failed"]
import io
from typing import Protocol, runtime_checkable

@runtime_checkable
class UsersApi(Protocol):
    def get_user(self, user_id: int) -> dict: ...

class FakeUsersApi:  # no inheritance, same shape
    def __init__(self, users):
        self.users = users
    def get_user(self, user_id: int) -> dict:
        return self.users[user_id]

def greeting(api: UsersApi, user_id: int) -> str:
    return f"Hello, {api.get_user(user_id)['name']}!"

def save_report(stream, lines):
    for line in lines:
        stream.write(line + "\n")

def test_greeting_with_fake_client():
    api = FakeUsersApi({7: {"name": "Ann"}})
    assert isinstance(api, UsersApi)
    assert greeting(api, 7) == "Hello, Ann!"

def test_report_goes_to_any_writable_stream():
    buffer = io.StringIO()
    save_report(buffer, ["login: passed", "cart: failed"])
    assert buffer.getvalue().splitlines() == ["login: passed", "cart: failed"]
Вивід
Output
$ pytest -q test_duck.py
..                                                                       [100%]
2 passed in 0.00s
🔧 Розбір коду: FakeUsersApi не наслідує UsersApi, але має метод з тією самою сигнатурою, тому mypy приймає його в greeting, а isinstance працює завдяки @runtime_checkable. save_report пише в StringIO так само, як у файл, бо потребує лише write.
🔧 Code breakdown: FakeUsersApi does not inherit from UsersApi, but it has a method with the same signature, so mypy accepts it in greeting, and isinstance works thanks to @runtime_checkable. save_report writes to a StringIO just as to a file, because all it needs is write.
💡 Простими словами: Python дивиться не на паспорт об'єкта, а на те, що той уміє робити.
💡 In plain words: Python looks not at an object's passport but at what it can do.
🗣 Як читати вголос Duck typing означає, що Python не перевіряє тип, а просто викликає потрібний метод: якщо об'єкт уміє write, у нього можна писати звіт. Для тестів це величезний плюс, бо замість справжнього клієнта API я передаю маленький фейковий клас з тими самими методами. Щоб при цьому не втрачати перевірки, описую інтерфейс через typing.Protocol: наслідування не потрібне, а mypy ловить невідповідність сигнатур ще до запуску тестів.
🗣 How to say it out loud Duck typing means Python does not check the type, it just calls the method it needs: if an object can write, you can write a report into it. For tests that is a huge plus, because instead of a real API client I pass a small fake class with the same methods. To keep checks anyway, I describe the interface with typing.Protocol: no inheritance needed, and mypy catches signature mismatches before the tests even run.
27 Яка повна структура try/except/else/finally, коли виконується finally і як кинути виняток вручну? What is the full try/except/else/finally structure, when does finally run, and how do you raise an exception yourself?
Функції й класиFunctions and classes Junior ▶

Структура. try — код, який може впасти. except SomeError as e — обробка конкретного типу; блоків може бути кілька, перевіряються зверху вниз, тому специфічніші типи пишуть першими. Кілька типів в одному блоці — кортежем: except (ConnectionError, TimeoutError):. else виконується, лише якщо в try винятку не було, — туди кладуть код, помилки якого не треба ловити цим except. finally виконується завжди.

The structure. try holds code that may fail. except SomeError as e handles a specific type; there can be several such blocks, checked top to bottom, so more specific types go first. Several types in one block go in a tuple: except (ConnectionError, TimeoutError):. else runs only if try raised nothing — put code there whose errors this except should not catch. finally always runs.

Коли finally. Після успішного try, після обробленого винятку, після необробленого (перед тим, як виняток полетить далі), і навіть коли в try спрацював return, break чи continue. Тому там закривають з'єднання, видаляють тимчасові дані, роблять скриншот і закривають браузер.

When finally runs. After a successful try, after a handled exception, after an unhandled one (before it propagates), and even when try hit return, break or continue. That is why connections are closed, temporary data deleted, screenshots taken and browsers closed there.

Кинути виняток. raise ValueError("amount must be positive"). Голий raise усередині except прокидає поточний виняток далі зі збереженим traceback — так логують і не ковтають помилку. raise NewError(...) from e зв'язує нову помилку з причиною.

Raising. raise ValueError("amount must be positive"). A bare raise inside except re-raises the current exception with its traceback intact — that is how you log without swallowing the error. raise NewError(...) from e links the new error to its cause.

Підводні камені. return у finally перекриває і результат, і виняток з try: помилка зникне безслідно. Python 3.14 за PEP 765 уже видає на це попередження. Не клади в try більше коду, ніж потрібно, інакше except KeyError зловить не ту помилку.

Pitfalls. A return in finally overrides both the result and any exception from try: the error vanishes without a trace. Python 3.14 already warns about it under PEP 765. Do not put more code inside try than necessary, or except KeyError will catch the wrong error.

Усі гілки і порядок виконання
Every branch and the order it runs in
def load_order(raw):
    try:
        amount = float(raw["amount"])
    except (KeyError, TypeError) as e:
        print("  bad payload:", type(e).__name__)
        raise
    except ValueError:
        print("  not a number")
        return None
    else:
        if amount <= 0:
            raise ValueError("amount must be positive")
        return amount
    finally:
        print("  finally for", raw)

for raw in ({"amount": "9.5"}, {"amount": "abc"}, {}, {"amount": "-1"}):
    try:
        print("result:", load_order(raw))
    except Exception as e:
        print("caller got:", type(e).__name__, e)
Вивід
Output
  finally for {'amount': '9.5'}
result: 9.5
  not a number
  finally for {'amount': 'abc'}
result: None
  bad payload: KeyError
  finally for {}
caller got: KeyError 'amount'
  finally for {'amount': '-1'}
caller got: ValueError amount must be positive
🔧 Розбір коду: except (KeyError, TypeError) ловить два типи і голим raise віддає помилку викликачу. ValueError від float("abc") обробляється окремо. else працює лише після успішного try, і ValueError звідти вже не ловиться цим же except ValueError. finally друкується в усіх чотирьох випадках, навіть перед return.
🔧 Code breakdown: except (KeyError, TypeError) catches two types and hands the error to the caller with a bare raise. The ValueError from float("abc") is handled separately. else runs only after a successful try, and a ValueError raised there is not caught by the same except ValueError. finally prints in all four cases, even before a return.
💡 Простими словами: try — спроба, except — план Б, else — «якщо все вийшло», finally — прибирання, яке буде за будь-якого розкладу.
💡 In plain words: try is the attempt, except is plan B, else is «if it worked», finally is the clean-up that happens no matter what.
🗣 Як читати вголос Повна структура — try, один або кілька except від конкретніших до загальніших, else для коду, що виконується лише без помилки, і finally, який виконується завжди: після успіху, після обробленої і необробленої помилки і навіть після return. Кілька типів ловлю кортежем. Власну помилку кидаю через raise з повідомленням, а голий raise в except використовую, щоб залогувати й не проковтнути виняток. В автотестах finally — це місце для закриття браузера і прибирання даних.
🗣 How to say it out loud The full structure is try, one or more except blocks from specific to general, else for code that runs only when nothing failed, and finally, which always runs: after success, after a handled or unhandled error, and even after return. I catch several types with a tuple. I raise my own error with raise and a message, and I use a bare raise inside except to log without swallowing the exception. In automated tests finally is where the browser gets closed and data cleaned up.
28 Чому голий except: шкідливий і як спроєктувати власні винятки для тестового фреймворку? Why is a bare except: harmful, and how do you design custom exceptions for a test framework?
Функції й класиFunctions and classes Senior ▶

Чому голий except шкідливий. except: ловить геть усе, включно з KeyboardInterrupt і SystemExit, тож Ctrl+C не зупинить зависле очікування. except Exception: пропускає їх, але однаково ловить AssertionError, NameError від друкарської помилки та AttributeError. У тестовому коді це найгірше: хелпер «на всяк випадок» ковтає падіння перевірки, і тест зелений. Правило — ловити лише те, що ти вмієш обробити, а решту пропускати. Широкий except Exception доречний лише на межі системи, де помилку логують і прокидають далі.

Why a bare except hurts. except: catches literally everything, including KeyboardInterrupt and SystemExit, so Ctrl+C will not stop a hung wait. except Exception: lets those through but still catches AssertionError, a NameError from a typo and AttributeError. In test code that is the worst case: a helper that swallows «just in case» eats a failing check and the test goes green. The rule is to catch only what you know how to handle and let the rest through. A broad except Exception belongs only at a system boundary, where the error is logged and re-raised.

Власні винятки. Клас наслідують від Exception (не від BaseException): class ApiError(Exception): .... Для фреймворку зручна ієрархія з одним коренем: FrameworkError → ApiError → UnexpectedStatus, SchemaMismatch; окремо EnvironmentNotReady. Корінь дозволяє зловити все «наше» одним блоком, а конкретні типи — точно розрізняти причину. Корисні дані (статус, URL, фрагмент тіла) кладуть в атрибути, а в __str__ — зрозуміле повідомлення.

Custom exceptions. Subclass Exception (not BaseException): class ApiError(Exception): .... A framework benefits from a hierarchy with one root: FrameworkError → ApiError → UnexpectedStatus, SchemaMismatch; and separately EnvironmentNotReady. The root lets you catch everything «ours» in one block, and the specific types tell the causes apart precisely. Useful data (status, URL, a slice of the body) goes into attributes, and a clear message into __str__.

Навіщо розрізняти. У звіті видно, що впало: продукт (AssertionError чи UnexpectedStatus) чи оточення (EnvironmentNotReady). На цьому будують правила перезапуску: повторювати можна лише інфраструктурні збої.

Why distinguish them. The report shows what failed: the product (AssertionError or UnexpectedStatus) or the environment (EnvironmentNotReady). Rerun rules build on that: only infrastructure failures may be retried.

Підводні камені. raise NewError(...) from e зберігає причину, а без from всередині except Python теж її покаже, але як «під час обробки сталася інша помилка», що збиває з пантелику. Не створюй клас на кожну дрібницю: тип потрібен тоді, коли хтось його ловитиме окремо.

Pitfalls. raise NewError(...) from e keeps the cause; without from inside an except Python still shows it, but as «during handling another exception occurred», which confuses readers. Do not create a class for every trifle: a type is worth having when someone will catch it separately.

Ієрархія винятків API-клієнта
An API client exception hierarchy
class FrameworkError(Exception):
    """Root of everything the framework raises."""

class EnvironmentNotReady(FrameworkError):
    pass

class ApiError(FrameworkError):
    pass

class UnexpectedStatus(ApiError):
    def __init__(self, method, url, expected, actual, body):
        self.expected, self.actual = expected, actual
        super().__init__(f"{method} {url}: expected {expected}, got {actual}, body={body[:40]!r}")

def check_status(method, url, expected, actual, body):
    if actual != expected:
        raise UnexpectedStatus(method, url, expected, actual, body)

def swallow_everything(check):
    try:
        check()
    except Exception:
        return "looks fine"

try:
    check_status("POST", "/orders", 201, 500, '{"error": "db connection pool exhausted"}')
except ApiError as e:
    print(type(e).__name__, "|", e, "|", isinstance(e, FrameworkError), e.actual)

try:
    {}["token"]
except KeyError as e:
    try:
        raise EnvironmentNotReady("auth service returned no token") from e
    except FrameworkError as err:
        print(repr(err), "caused by", repr(err.__cause__))

def failing_check():
    assert 1 == 2
print(swallow_everything(failing_check))  # перевірка зникла
class FrameworkError(Exception):
    """Root of everything the framework raises."""

class EnvironmentNotReady(FrameworkError):
    pass

class ApiError(FrameworkError):
    pass

class UnexpectedStatus(ApiError):
    def __init__(self, method, url, expected, actual, body):
        self.expected, self.actual = expected, actual
        super().__init__(f"{method} {url}: expected {expected}, got {actual}, body={body[:40]!r}")

def check_status(method, url, expected, actual, body):
    if actual != expected:
        raise UnexpectedStatus(method, url, expected, actual, body)

def swallow_everything(check):
    try:
        check()
    except Exception:
        return "looks fine"

try:
    check_status("POST", "/orders", 201, 500, '{"error": "db connection pool exhausted"}')
except ApiError as e:
    print(type(e).__name__, "|", e, "|", isinstance(e, FrameworkError), e.actual)

try:
    {}["token"]
except KeyError as e:
    try:
        raise EnvironmentNotReady("auth service returned no token") from e
    except FrameworkError as err:
        print(repr(err), "caused by", repr(err.__cause__))

def failing_check():
    assert 1 == 2
print(swallow_everything(failing_check))  # the assertion vanished
Вивід
Output
UnexpectedStatus | POST /orders: expected 201, got 500, body='{"error": "db connection pool exhausted"' | True 500
EnvironmentNotReady('auth service returned no token') caused by KeyError('token')
looks fine
🔧 Розбір коду: UnexpectedStatus зберігає статуси в атрибутах і формує повідомлення через super().__init__. except ApiError ловить нащадка, а isinstance(e, FrameworkError) підтверджує спільний корінь. from e кладе KeyError у __cause__. swallow_everything показує, як except Exception перетворює провалений assert на «все добре».
🔧 Code breakdown: UnexpectedStatus keeps the statuses in attributes and builds its message through super().__init__. except ApiError catches the subclass, and isinstance(e, FrameworkError) confirms the shared root. from e stores the KeyError in __cause__. swallow_everything shows how except Exception turns a failed assert into «all good».
💡 Простими словами: Лови лише ту рибу, яку вмієш приготувати; решту відпускай далі за течією.
💡 In plain words: Catch only the fish you know how to cook; let the rest swim on downstream.
🗣 Як читати вголос Голий except ловить навіть Ctrl+C і вихід з програми, а except Exception — ще й AssertionError та друкарські помилки. У тестах це означає, що хелпер «на всяк випадок» робить червоний тест зеленим, тому я ловлю лише конкретні типи, які вмію обробити. Для фреймворку будую ієрархію від Exception з одним коренем і гілками для помилок API й оточення, кладу в атрибути статус і URL і прокидаю причину через raise from. Це дозволяє в звіті відрізнити баг продукту від проблем стенду.
🗣 How to say it out loud A bare except catches even Ctrl+C and program exit, and except Exception also catches AssertionError and typos. In tests that means a «just in case» helper turns a red test green, so I catch only specific types I know how to handle. For a framework I build a hierarchy from Exception with one root and branches for API and environment errors, store the status and URL in attributes and chain the cause with raise from. That lets the report separate a product bug from environment trouble.
29 Що таке контекстний менеджер (with) і як написати свій через contextlib? What is a context manager (with), and how do you write your own with contextlib?
Функції й класиFunctions and classes Middle ▶

Що це. Об'єкт з методами __enter__ і __exit__. Блок with open(path) as f: викликає __enter__ на вході (результат потрапляє в f) і __exit__ на виході — завжди, навіть якщо всередині сталася помилка. По суті, це оформлений try/finally, який неможливо забути.

What it is. An object with __enter__ and __exit__ methods. A block with open(path) as f: calls __enter__ on the way in (its result lands in f) and __exit__ on the way out — always, even if an error happened inside. Essentially it is a packaged try/finally you cannot forget.

Де зустрічається. Файли, requests.Session(), з'єднання з базою, блокування, sync_playwright(), pytest.raises, unittest.mock.patch, тимчасова зміна змінних середовища. Кілька менеджерів можна відкрити в одному with через кому.

Where you meet it. Files, requests.Session(), database connections, locks, sync_playwright(), pytest.raises, unittest.mock.patch, temporarily changing environment variables. Several managers can open in one with, separated by commas.

Свій менеджер. Найпростіше — генераторна функція з @contextlib.contextmanager: код до yield — вхід, значення в yield — те, що отримає as, код після — вихід. Щоб прибирання виконалося й при помилці, yield загортають у try/finally. Приклад з QA — створити тестового користувача через API і гарантовано видалити після блоку. Ще корисні contextlib.suppress(FileNotFoundError) для очікуваних помилок і ExitStack для динамічної кількості ресурсів.

Your own manager. The easiest way is a generator function with @contextlib.contextmanager: code before yield is the entry, the yielded value is what as receives, and code after is the exit. So that clean-up runs on errors too, wrap the yield in try/finally. A QA example: create a test user through the API and guarantee its deletion after the block. Also useful are contextlib.suppress(FileNotFoundError) for expected errors and ExitStack for a dynamic number of resources.

Підводні камені. Без try/finally навколо yield виняток з блоку пропустить прибирання. __exit__, що повертає True, придушує виняток — робити це без потреби не варто. У pytest той самий патерн — фікстура з yield, і для тестів вона зазвичай зручніша.

Pitfalls. Without try/finally around yield an exception in the block skips the clean-up. An __exit__ that returns True suppresses the exception — do not do it without a reason. In pytest the same pattern is a fixture with yield, and for tests it is usually more convenient.

Тимчасовий користувач, який прибирається завжди
A temporary user that is always cleaned up
import contextlib
import os

@contextlib.contextmanager
def temp_user(api, name):
    user_id = api.create(name)
    print(f"created {name} -> {user_id}")
    try:
        yield user_id
    finally:
        api.delete(user_id)
        print(f"deleted {user_id}")

class FakeApi:
    def __init__(self):
        self.users, self.next_id = {}, 1
    def create(self, name):
        self.users[self.next_id] = name
        self.next_id += 1
        return self.next_id - 1
    def delete(self, user_id):
        del self.users[user_id]

api = FakeApi()
try:
    with temp_user(api, "ann") as uid:
        raise RuntimeError(f"test step failed for {uid}")
except RuntimeError as e:
    print("error:", e, "| users left:", api.users)

with contextlib.suppress(FileNotFoundError):
    os.remove("no-such-report.html")
print("suppress: missing file ignored")
Вивід
Output
created ann -> 1
deleted 1
error: test step failed for 1 | users left: {}
suppress: missing file ignored
🔧 Розбір коду: @contextlib.contextmanager перетворює генератор на менеджер: yield user_id віддає значення в as uid. try/finally гарантує видалення навіть після RuntimeError, тому api.users порожній. suppress ігнорує лише вказаний тип помилки.
🔧 Code breakdown: @contextlib.contextmanager turns a generator into a manager: yield user_id hands the value to as uid. try/finally guarantees deletion even after the RuntimeError, so api.users is empty. suppress ignores only the given error type.
💡 Простими словами: with — це «увійшов, попрацював, вийшов і обов'язково зачинив за собою двері».
💡 In plain words: with means «walk in, do the work, walk out and always close the door behind you».
🗣 Як читати вголос Контекстний менеджер — це об'єкт з __enter__ і __exit__, а with гарантує, що __exit__ виконається навіть при помилці, тобто це оформлений try-finally. Я бачу його у файлах, сесіях requests, sync_playwright і pytest.raises. Свій зазвичай пишу через contextlib.contextmanager: до yield — підготовка, після — прибирання, а сам yield обгортаю в try-finally, інакше при падінні прибирання не буде. Для тестів той самий патерн часто оформлюю як фікстуру з yield.
🗣 How to say it out loud A context manager is an object with __enter__ and __exit__, and with guarantees __exit__ runs even on an error, so it is a packaged try-finally. I see it in files, requests sessions, sync_playwright and pytest.raises. I usually write my own with contextlib.contextmanager: setup before yield, clean-up after, and I wrap the yield in try-finally, otherwise a failure skips the clean-up. In tests I often express the same pattern as a yield fixture.
▶ 4 14
30 Як pytest знаходить тести, як запустити один конкретний тест і що показує -v? How does pytest discover tests, how do you run one specific test, and what does -v show?
pytestpytest Junior ▶

Правила пошуку. Без аргументів pytest починає з поточної теки (або з testpaths у конфігурації) і рекурсивно шукає файли test_*.py або *_test.py. У них він збирає функції з префіксом test і методи з таким самим префіксом у класах, чия назва починається з Test і які не мають __init__. Решта функцій — хелпери, їх не запускають. Правила можна змінити параметрами python_files, python_classes, python_functions.

Discovery rules. With no arguments pytest starts from the current folder (or from testpaths in the config) and recursively looks for files named test_*.py or *_test.py. In them it collects functions prefixed test and methods with the same prefix inside classes whose names start with Test and that have no __init__. Other functions are helpers and do not run. The rules can be changed with python_files, python_classes and python_functions.

Запуск вибірково. Файл: pytest tests/test_cart.py. Один тест — через node id з подвійною двокрапкою: pytest tests/test_cart.py::TestCart::test_add_item. Кілька за частиною назви: -k "cart and not slow" — це вираз над іменами тестів, класів і файлів. --collect-only -q лише показує, що буде зібрано, нічого не запускаючи, — корисно перевірити, чи новий тест узагалі знаходиться.

Running a subset. A file: pytest tests/test_cart.py. One test by node id with double colons: pytest tests/test_cart.py::TestCart::test_add_item. Several by part of the name: -k "cart and not slow" — an expression over test, class and file names. --collect-only -q only lists what would be collected without running anything — handy to check a new test is found at all.

Що дає -v. Замість крапок — повний node id кожного тесту і його статус (PASSED, FAILED, SKIPPED, XFAIL), а для параметризованих — ще й id параметра в квадратних дужках. -vv додатково не обрізає довгі різниці в assert.

What -v gives you. Instead of dots, the full node id of every test with its status (PASSED, FAILED, SKIPPED, XFAIL), and for parametrised tests the parameter id in square brackets. -vv additionally stops truncating long assert diffs.

Підводні камені. Файл check_cart.py чи клас CartTests pytest мовчки пропустить. Клас TestCart з __init__ теж: буде лише попередження в журналі. І два файли test_utils.py у різних теках без __init__.py дадуть помилку імпорту через однакову назву модуля.

Pitfalls. pytest silently skips a file check_cart.py or a class CartTests. A class TestCart with __init__ too: you only get a warning in the log. And two test_utils.py files in different folders without __init__.py cause an import error because the module names clash.

tests/test_cart.py
tests/test_cart.py
def make_cart():
    return []

class TestCart:
    def test_add_item(self):
        cart = make_cart()
        cart.append("book")
        assert cart == ["book"]

    def test_empty_cart_total(self):
        assert sum(make_cart()) == 0

def test_cart_checkout_slow():
    assert True

class CartTests:  # не збирається: назва не починається з Test
    def test_hidden(self):
        assert False
def make_cart():
    return []

class TestCart:
    def test_add_item(self):
        cart = make_cart()
        cart.append("book")
        assert cart == ["book"]

    def test_empty_cart_total(self):
        assert sum(make_cart()) == 0

def test_cart_checkout_slow():
    assert True

class CartTests:  # not collected: the name does not start with Test
    def test_hidden(self):
        assert False
tests/check_login.py — pytest його не побачить
tests/check_login.py — pytest will not see it
def test_login():
    assert False
Вивід
Output
$ pytest --collect-only -q
tests/test_cart.py::TestCart::test_add_item
tests/test_cart.py::TestCart::test_empty_cart_total
tests/test_cart.py::test_cart_checkout_slow

3 tests collected in 0.00s

$ pytest -v tests/test_cart.py::TestCart::test_add_item
============================= test session starts ==============================
platform linux -- Python 3.13.15, pytest-9.1.1, pluggy-1.6.0 -- ~/pyenv/bin/python
rootdir: .
collecting ... collected 1 item

tests/test_cart.py::TestCart::test_add_item PASSED                       [100%]

============================== 1 passed in 0.00s ===============================

$ pytest -q -k "cart and not slow"
..                                                                       [100%]
2 passed, 1 deselected in 0.00s
🔧 Розбір коду: --collect-only -q показує три тести: CartTests і check_login.py у список не потрапили. Node id файл::Клас::метод запускає рівно один тест, а -v друкує його повну назву і статус. Вираз -k "cart and not slow" відкинув тест, у назві якого є slow.
🔧 Code breakdown: --collect-only -q lists three tests: CartTests and check_login.py did not make it. A node id file::Class::method runs exactly one test, and -v prints its full name and status. The expression -k "cart and not slow" dropped the test whose name contains slow.
💡 Простими словами: pytest шукає за іменами: файл test_, функція test, клас Test — і все, що не підходить під ці шаблони, для нього не існує.
💡 In plain words: pytest finds tests by name: a test_ file, a test function, a Test class — anything outside those patterns does not exist for it.
🗣 Як читати вголос pytest рекурсивно шукає файли test-підкреслення або з суфіксом _test, у них функції з префіксом test і методи класів Test без __init__. Один тест я запускаю через node id: шлях до файла, дві двокрапки, клас і метод. Групу — через -k з логічним виразом по назвах, а щоб переконатися, що новий тест знаходиться, роблю --collect-only. Прапорець -v показує повну назву й статус кожного тесту, разом з id параметрів.
🗣 How to say it out loud pytest recursively looks for files named test-underscore or ending in _test, and inside them for functions prefixed test and methods of Test classes without __init__. I run one test by its node id: the file path, a double colon, the class and the method. A group I select with -k and a boolean expression over names, and to make sure a new test is picked up I run --collect-only. The -v flag shows every test's full name and status, including parameter ids.
31 Чому в pytest достатньо звичайного assert без спеціальних матчерів? Why is a plain assert enough in pytest, without special matchers?
pytestpytest Junior ▶

Що відбувається. Під час імпорту тестових модулів pytest переписує їхні інструкції assert на рівні AST: кожен підвираз обчислюється окремо, і при падінні pytest показує проміжні значення. Для assert response["status"] == "paid" звіт покаже, що було ліворуч. Для рядків — посимвольну різницю, для списків — перший індекс, де вони розходяться, для словників — які ключі відрізняються, яких бракує і які зайві.

What happens. When importing test modules pytest rewrites their assert statements at the AST level: each sub-expression is evaluated separately and on failure pytest shows the intermediate values. For assert response["status"] == "paid" the report shows what was on the left. For strings you get a character diff, for lists the first index where they diverge, for dicts which keys differ, which are missing and which are extra.

Чому це зручно. Не треба вчити assertEqual, assertIn, assertDictContainsSubset з unittest чи окрему бібліотеку матчерів: будь-який Python-вираз стає перевіркою. assert "error" not in body, assert 200 <= code < 300, assert set(ids) >= expected — і звіт однаково інформативний. Для чисел з плаваючою крапкою є pytest.approx.

Why it is convenient. No need to learn unittest's assertEqual, assertIn, assertDictContainsSubset or a separate matcher library: any Python expression becomes a check. assert "error" not in body, assert 200 <= code < 300, assert set(ids) >= expected — and the report stays informative. For floating-point numbers there is pytest.approx.

Де переписування працює. У тестових модулях, conftest.py і плагінах. Якщо перевірки живуть у хелпер-модулі (helpers/asserts.py), їх треба зареєструвати через pytest.register_assert_rewrite("helpers.asserts") у кореневому conftest, інакше при падінні буде голий AssertionError без деталей.

Where rewriting applies. In test modules, conftest.py and plugins. If checks live in a helper module (helpers/asserts.py), register it with pytest.register_assert_rewrite("helpers.asserts") in the root conftest, or a failure there is a bare AssertionError with no details.

Підводні камені. assert (x == 1, "msg") з дужками — це кортеж, він завжди істинний, і тест ніколи не впаде; Python попереджає про це. Повідомлення пишуть через кому без дужок. І python -O вимикає всі assert — тести так не запускають.

Pitfalls. assert (x == 1, "msg") with parentheses is a tuple, which is always truthy, so the test never fails; Python warns about it. Write the message after a comma, without parentheses. And python -O strips all asserts — tests are never run that way.

Що pytest показує при падінні
What pytest shows on failure
import pytest

def get_order():
    return {"id": 7, "status": "pending", "total": 0.1 + 0.2, "items": ["book", "pen"]}

def test_order_fields():
    order = get_order()
    assert order == {"id": 7, "status": "paid", "total": 0.3, "items": ["book", "pen"]}

def test_total_with_approx():
    assert get_order()["total"] == pytest.approx(0.3)

def test_status_with_message():
    order = get_order()
    assert order["status"] in {"paid", "shipped"}, f"order {order['id']} is stuck"
Вивід
Output
$ pytest -q test_asserts.py
F.F                                                                      [100%]
=================================== FAILURES ===================================
______________________________ test_order_fields _______________________________

    def test_order_fields():
        order = get_order()
>       assert order == {"id": 7, "status": "paid", "total": 0.3, "items": ["book", "pen"]}
E       AssertionError: assert {'id': 7, 'st...book', 'pen']} == {'id': 7, 'st...book', 'pen']}
E         
E         Omitting 2 identical items, use -vv to show
E         Differing items:
E         {'status': 'pending'} != {'status': 'paid'}
E         {'total': 0.30000000000000004} != {'total': 0.3}
E         Use -v to get more diff

test_asserts.py:8: AssertionError
___________________________ test_status_with_message ___________________________

    def test_status_with_message():
        order = get_order()
>       assert order["status"] in {"paid", "shipped"}, f"order {order['id']} is stuck"
E       AssertionError: order 7 is stuck
E       assert 'pending' in {'paid', 'shipped'}

test_asserts.py:15: AssertionError
=========================== short test summary info ============================
FAILED test_asserts.py::test_order_fields - AssertionError: assert {'id': 7, ...
FAILED test_asserts.py::test_status_with_message - AssertionError: order 7 is...
2 failed, 1 passed in 0.01s
🔧 Розбір коду: У першому тесті pytest розкладає словник: однакові поля згорнуті, а різниця видна в status і в total, де 0.1 + 0.2 дало 0.30000000000000004. pytest.approx порівнює з допуском. Повідомлення після коми в третьому тесті з'являється першим рядком помилки.
🔧 Code breakdown: In the first test pytest breaks the dict down: matching fields are folded, and the difference shows in status and in total, where 0.1 + 0.2 gave 0.30000000000000004. pytest.approx compares with a tolerance. The message after the comma in the third test appears as the first line of the error.
💡 Простими словами: Пишеш звичайну умову, а pytest сам розповідає, що саме в ній не збіглося.
💡 In plain words: You write a plain condition and pytest itself tells you what in it did not match.
🗣 Як читати вголос pytest при імпорті переписує інструкції assert, тож при падінні бачить кожен підвираз і показує значення: різницю рядків, перший різний індекс у списку, відмінні ключі словника. Тому мені не потрібні assertEqual чи окремі матчери — будь-який вираз Python уже є перевіркою. Для дробових чисел беру pytest.approx. Пам'ятаю, що переписування працює в тестах і conftest, а для хелпер-модулів потрібен register_assert_rewrite, і що assert з кортежем у дужках ніколи не падає.
🗣 How to say it out loud At import time pytest rewrites assert statements, so on failure it sees every sub-expression and shows the values: a string diff, the first differing index in a list, the differing keys in a dict. So I do not need assertEqual or separate matchers — any Python expression is already a check. For floats I use pytest.approx. I remember that rewriting applies to tests and conftest, that helper modules need register_assert_rewrite, and that an assert on a tuple in parentheses never fails.
32 Що таке фікстура в pytest і чим вона краща за setUp з unittest? What is a pytest fixture, and why is it better than unittest's setUp?
pytestpytest Junior ▶

Що це. Фікстура — функція з декоратором @pytest.fixture, яка готує щось потрібне тесту: дані, клієнт API, відкритий браузер, запис у базі. Тест просить фікстуру, просто вказавши її ім'я як параметр: def test_login(api_client):. pytest бачить параметр, знаходить фікстуру з такою назвою, викликає її і передає результат. Це dependency injection без жодної конфігурації.

What it is. A fixture is a function decorated with @pytest.fixture that prepares something a test needs: data, an API client, an open browser, a database record. A test asks for a fixture simply by naming it as a parameter: def test_login(api_client):. pytest sees the parameter, finds the fixture with that name, calls it and passes the result in. It is dependency injection with no configuration at all.

Чим краща за setUp. У unittest setUp один на клас і виконується для кожного тесту, навіть якщо тесту потрібна лише частина підготовки. Фікстури модульні: кожен тест бере тільки те, що просить. Фікстури можуть залежати від інших фікстур (admin_user просить api_client), мати різний scope, бути спільними через conftest.py, параметризуватися і прибирати за собою через yield. У межах одного тесту фікстура створюється один раз, навіть якщо її просять кілька інших фікстур.

Why it beats setUp. In unittest there is one setUp per class, run for every test, even when a test needs only part of the preparation. Fixtures are modular: each test takes only what it asks for. Fixtures can depend on other fixtures (admin_user asks for api_client), have different scopes, be shared through conftest.py, be parametrised and clean up after themselves with yield. Within one test a fixture is created once, even if several other fixtures request it.

Як побачити, що відбувається. pytest --fixtures перелічує доступні фікстури з їхніми docstring, --setup-show показує, коли кожна з них створюється і знищується.

How to see what happens. pytest --fixtures lists the available fixtures with their docstrings, and --setup-show shows when each one is created and torn down.

Підводні камені. Не викликай фікстуру напряму як функцію — pytest кине помилку. Фікстура, що повертає змінюваний об'єкт зі scope ширшим за function, ділить його між тестами. І назва параметра з друкарською помилкою дасть fixture '...' not found зі списком доступних фікстур.

Pitfalls. Do not call a fixture directly as a function — pytest raises an error. A fixture that returns a mutable object with a scope wider than function shares it between tests. And a misspelt parameter name gives fixture '...' not found along with a list of available fixtures.

Фікстури, що залежать одна від одної
Fixtures that depend on each other
import pytest

class FakeApi:
    def __init__(self):
        self.users = {}
    def create_user(self, name, role):
        self.users[name] = role
        return {"name": name, "role": role}

@pytest.fixture
def api_client():
    """A fresh client for every test."""
    return FakeApi()

@pytest.fixture
def admin_user(api_client):
    return api_client.create_user("root", "admin")

def test_admin_is_created(admin_user, api_client):
    assert admin_user["role"] == "admin"
    assert api_client.users == {"root": "admin"}  # той самий екземпляр клієнта

def test_client_starts_empty(api_client):
    assert api_client.users == {}
import pytest

class FakeApi:
    def __init__(self):
        self.users = {}
    def create_user(self, name, role):
        self.users[name] = role
        return {"name": name, "role": role}

@pytest.fixture
def api_client():
    """A fresh client for every test."""
    return FakeApi()

@pytest.fixture
def admin_user(api_client):
    return api_client.create_user("root", "admin")

def test_admin_is_created(admin_user, api_client):
    assert admin_user["role"] == "admin"
    assert api_client.users == {"root": "admin"}  # the same client instance

def test_client_starts_empty(api_client):
    assert api_client.users == {}
Вивід
Output
$ pytest -q --setup-show test_fixtures.py

        SETUP    F api_client
        SETUP    F admin_user (fixtures used: api_client)
        test_fixtures.py::test_admin_is_created (fixtures used: admin_user, api_client) .
        TEARDOWN F admin_user
        TEARDOWN F api_client
        SETUP    F api_client
        test_fixtures.py::test_client_starts_empty (fixtures used: api_client) .
        TEARDOWN F api_client
2 passed in 0.00s
🔧 Розбір коду: admin_user просить api_client параметром, і тест отримує той самий екземпляр клієнта, тому в ньому вже є root. --setup-show показує порядок: SETUP api_client, потім admin_user, тест, і TEARDOWN у зворотному порядку. Другий тест отримує новий порожній клієнт.
🔧 Code breakdown: admin_user requests api_client as a parameter, and the test receives the same client instance, which is why root is already in it. --setup-show shows the order: SETUP api_client, then admin_user, the test, and TEARDOWN in reverse. The second test gets a new empty client.
💡 Простими словами: Фікстура — це «замовлення» на те, що тесту потрібно: назвав у параметрах — і pytest приніс готове.
💡 In plain words: A fixture is an «order» for what the test needs: name it in the parameters and pytest brings it ready.
🗣 Як читати вголос Фікстура — це функція з декоратором pytest.fixture, яка готує тесту залежність, а тест отримує її, просто вказавши ім'я параметром. На відміну від setUp, кожен тест бере тільки те, що йому потрібно, фікстури складаються одна з одної, мають scope і прибирають за собою через yield. У межах тесту фікстура створюється один раз, тож дві фікстури, що просять клієнт, отримають той самий. Для налагодження використовую --setup-show і --fixtures.
🗣 How to say it out loud A fixture is a function with the pytest.fixture decorator that prepares a dependency, and the test receives it simply by naming it as a parameter. Unlike setUp, each test takes only what it needs, fixtures compose from each other, have a scope and clean up with yield. Within one test a fixture is created once, so two fixtures asking for the client get the same one. For debugging I use --setup-show and --fixtures.
33 Як зробити teardown у фікстурі і що буде, якщо тест впаде? How do you do teardown in a fixture, and what happens if the test fails?
pytestpytest Junior ▶

yield-фікстура. Замість return фікстура робить yield значення. Код до yield — підготовка, значення йде в тест, код після yield — прибирання. pytest виконає прибирання після тесту незалежно від результату: пройшов, упав на assert чи кинув виняток. Для кількох фікстур teardown іде у зворотному порядку створення, тож браузер закривається після того, як сторінка вже прибрана.

A yield fixture. Instead of return the fixture does yield value. Code before yield is setup, the value goes to the test, code after yield is teardown. pytest runs the teardown after the test whatever the outcome: passed, failed an assert or raised an exception. With several fixtures teardown runs in reverse order of creation, so the browser closes after the page has been cleaned up.

Коли прибирання не буде. Якщо впала сама підготовка — код до yield, — pytest не виконуватиме рядки після нього: ресурс ще не створений. Тому фікстура, що створює кілька речей, або розбивається на кілька маленьких фікстур, або використовує request.addfinalizer після кожного кроку, або try/finally.

When teardown does not run. If setup itself failed — the code before yield — pytest will not run the lines after it: the resource was never created. So a fixture that creates several things is either split into several small fixtures, or uses request.addfinalizer after each step, or try/finally.

Приклад з QA. Фікстура створює користувача через API, віддає його тесту, а після тесту видаляє. Або відкриває браузер і закриває його. Помилка в teardown не робить тест червоним, а з'являється як окрема помилка ERROR at teardown — у звіті видно, що саме прибирання не вдалося.

A QA example. A fixture creates a user through the API, gives it to the test and deletes it afterwards. Or it opens a browser and closes it. An error in teardown does not turn the test red; it shows up as a separate ERROR at teardown, so the report shows exactly that clean-up failed.

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

Pitfalls. Do not swallow teardown errors silently: if user deletion keeps failing, the environment fills up and neighbouring tests start failing on email uniqueness. Teardown must be idempotent: delete only what the fixture created and not fail if the test already deleted the record itself.

Teardown виконується і після падіння
Teardown runs even after a failure
import pytest

STORE = {}

@pytest.fixture
def temp_user():
    user_id = len(STORE) + 1
    STORE[user_id] = "ann"
    print(f"\n  setup: created user {user_id}")
    yield user_id
    STORE.pop(user_id, None)  # ідемпотентно: не падає, якщо тест уже видалив
    print(f"  teardown: deleted user {user_id}, store={STORE}")

def test_user_exists(temp_user):
    assert STORE[temp_user] == "ann"

def test_rename_fails(temp_user):
    STORE[temp_user] = "bob"
    assert STORE[temp_user] == "ann"
import pytest

STORE = {}

@pytest.fixture
def temp_user():
    user_id = len(STORE) + 1
    STORE[user_id] = "ann"
    print(f"\n  setup: created user {user_id}")
    yield user_id
    STORE.pop(user_id, None)  # idempotent: fine if the test already deleted it
    print(f"  teardown: deleted user {user_id}, store={STORE}")

def test_user_exists(temp_user):
    assert STORE[temp_user] == "ann"

def test_rename_fails(temp_user):
    STORE[temp_user] = "bob"
    assert STORE[temp_user] == "ann"
Вивід
Output
$ pytest -q -s --tb=line test_teardown.py

  setup: created user 1
.  teardown: deleted user 1, store={}

  setup: created user 1
F  teardown: deleted user 1, store={}

=================================== FAILURES ===================================
E   AssertionError: assert 'bob' == 'ann'
      
      - ann
      + bob
./test_teardown.py:19: AssertionError: assert 'bob' == 'ann'
=========================== short test summary info ============================
FAILED test_teardown.py::test_rename_fails - AssertionError: assert 'bob' == ...
1 failed, 1 passed in 0.00s
🔧 Розбір коду: Рядки після yield виконалися для обох тестів, зокрема для того, що впав на assert: store={} після кожного. -s вимикає перехоплення виводу, щоб бачити print з фікстури, а --tb=line скорочує звіт про падіння до одного рядка.
🔧 Code breakdown: The lines after yield ran for both tests, including the one that failed its assert: store={} after each. -s turns off output capturing so the fixture's print is visible, and --tb=line shrinks the failure report to one line.
💡 Простими словами: yield ділить фікстуру навпіл: зверху «прийшов і підготував», знизу «пішов і прибрав» — навіть якщо тест усе зламав.
💡 In plain words: yield splits a fixture in two: above it «arrive and prepare», below it «leave and tidy up» — even if the test broke everything.
🗣 Як читати вголос Teardown я роблю через yield: до нього підготовка, значення йде в тест, після нього прибирання. pytest виконує прибирання завжди, навіть коли тест упав, а кілька фікстур прибираються у зворотному порядку. Прибирання не буде, лише якщо впала сама підготовка, тому складну підготовку розбиваю на маленькі фікстури або використовую addfinalizer. Прибирання пишу ідемпотентним, а його помилки pytest показує окремо як ERROR at teardown.
🗣 How to say it out loud I do teardown with yield: setup before it, the value goes to the test, clean-up after it. pytest always runs the clean-up, even when the test failed, and several fixtures are cleaned up in reverse order. The clean-up is skipped only if setup itself failed, so I split complex setup into small fixtures or use addfinalizer. I write teardown to be idempotent, and pytest reports its failures separately as ERROR at teardown.
34 Які є scope фікстур і як вибрати scope, щоб не отримати витік стану між тестами? Which fixture scopes exist, and how do you pick one without leaking state between tests?
pytestpytest Middle ▶

П'ять рівнів. function (за замовчуванням) — нова фікстура для кожного тесту. class — одна на клас тестів. module — одна на файл. package — одна на пакет тестів. session — одна на весь запуск. Задається так: @pytest.fixture(scope="session"). Фікстура може просити лише фікстури з таким самим або ширшим scope; інакше буде ScopeMismatch.

Five levels. function (the default) — a new fixture for each test. class — one per test class. module — one per file. package — one per test package. session — one for the whole run. Set it like this: @pytest.fixture(scope="session"). A fixture may request only fixtures of the same or a wider scope; otherwise you get ScopeMismatch.

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

How to choose. Expensive and unchanging resources go wide: configuration, an auth token, a launched browser (Playwright), a database connection pool — session. Anything a test modifies goes narrow: a browser page, a cart, a test user — function. The middle ground is an expensive resource per module, such as a data set tests only read.

Витік стану. Коли фікстура з scope="session" повертає змінюваний словник чи об'єкт, а один тест його змінює, наступний тест бачить уже змінену версію. Результат — тести, які проходять окремо й падають разом, або залежать від порядку і ламаються під pytest-xdist. Ліки: повертати незмінні дані (кортеж, frozen dataclass, MappingProxyType), копію для кожного тесту через вузьку фікстуру над широкою, або повертати фабрику замість об'єкта.

State leaks. When a scope="session" fixture returns a mutable dict or object and one test changes it, the next test sees the changed version. The result is tests that pass alone and fail together, or depend on order and break under pytest-xdist. The cures: return immutable data (a tuple, a frozen dataclass, MappingProxyType), give each test a copy through a narrow fixture over the wide one, or return a factory instead of an object.

Підводні камені. З pytest-xdist «session» — це сесія одного воркера: чотири воркери створять чотири «сесійні» фікстури. Для справді одноразової підготовки потрібне файлове блокування або підготовка до запуску тестів.

Pitfalls. Under pytest-xdist «session» means one worker's session: four workers create four «session» fixtures. For truly one-time setup you need a file lock or preparation before the test run.

Спільний словник, незмінний словник і копія
A shared dict, a frozen dict and a copy
from types import MappingProxyType
import pytest

@pytest.fixture(scope="session")
def settings():
    print("\n  building settings once")
    return {"headers": {"Accept": "application/json"}}

@pytest.fixture(scope="session")
def frozen_headers():
    return MappingProxyType({"Accept": "application/json"})

@pytest.fixture
def request_headers(frozen_headers):
    return dict(frozen_headers)  # нова змінювана копія для кожного тесту

def test_adds_auth_to_shared_settings(settings):
    settings["headers"]["Authorization"] = "Bearer admin"  # протікає в наступні тести

def test_anonymous_request(settings):
    assert "Authorization" not in settings["headers"]

def test_frozen_data_refuses_changes(frozen_headers):
    with pytest.raises(TypeError):
        frozen_headers["Authorization"] = "Bearer admin"

def test_copy_can_be_changed(request_headers):
    request_headers["Authorization"] = "Bearer admin"

def test_next_copy_is_clean(request_headers):
    assert "Authorization" not in request_headers
from types import MappingProxyType
import pytest

@pytest.fixture(scope="session")
def settings():
    print("\n  building settings once")
    return {"headers": {"Accept": "application/json"}}

@pytest.fixture(scope="session")
def frozen_headers():
    return MappingProxyType({"Accept": "application/json"})

@pytest.fixture
def request_headers(frozen_headers):
    return dict(frozen_headers)  # a fresh mutable copy for every test

def test_adds_auth_to_shared_settings(settings):
    settings["headers"]["Authorization"] = "Bearer admin"  # leaks into later tests

def test_anonymous_request(settings):
    assert "Authorization" not in settings["headers"]

def test_frozen_data_refuses_changes(frozen_headers):
    with pytest.raises(TypeError):
        frozen_headers["Authorization"] = "Bearer admin"

def test_copy_can_be_changed(request_headers):
    request_headers["Authorization"] = "Bearer admin"

def test_next_copy_is_clean(request_headers):
    assert "Authorization" not in request_headers
Вивід
Output
$ pytest -q -s --tb=line test_scopes.py

  building settings once
.F...
=================================== FAILURES ===================================
E   AssertionError: assert 'Authorization' not in {'Accept': 'application/json', 'Authorization': 'Bearer admin'}
./test_scopes.py:21: AssertionError: assert 'Authorization' not in {'Accept': 'application/json', 'Authorization': 'Bearer admin'}
=========================== short test summary info ============================
FAILED test_scopes.py::test_anonymous_request - AssertionError: assert 'Autho...
1 failed, 4 passed in 0.01s
🔧 Розбір коду: settings створюється один раз на сесію — рядок «building settings once» надруковано лише раз. Перший тест змінює спільний словник, і другий падає, хоча окремо пройшов би. MappingProxyType — незмінне подання словника: спроба запису дає TypeError прямо в тесті, що намагався змінити спільні дані. request_headers зі scope function дає кожному тесту власну копію, тож зміна в одному не видна в наступному.
🔧 Code breakdown: settings is built once per session — «building settings once» is printed only once. The first test changes the shared dict, and the second one fails although it would pass alone. MappingProxyType is a read-only view of a dict: a write raises TypeError right in the test that tried to change shared data. request_headers with function scope gives each test its own copy, so a change in one is invisible in the next.
💡 Простими словами: Широкий scope — для того, що тести лише читають; усе, що вони змінюють, має народжуватися заново для кожного тесту.
💡 In plain words: A wide scope is for what tests only read; everything they change must be born fresh for every test.
🗣 Як читати вголос Scope буває function, class, module, package і session, за замовчуванням function. Широкий scope я даю дорогим і незмінним ресурсам: конфігурації, токену, запущеному браузеру. Усе, що тест змінює, — сторінка, кошик, користувач — живе на рівні функції. Головна пастка — сесійна фікстура, що повертає змінюваний словник: один тест його змінив, і інший падає лише в загальному прогоні. Тому повертаю незмінні дані або роблю вузьку фікстуру-копію. І пам'ятаю, що в xdist кожен воркер має свою сесію.
🗣 How to say it out loud Scopes are function, class, module, package and session, with function as the default. I give a wide scope to expensive, unchanging resources: configuration, a token, a launched browser. Anything a test changes — a page, a cart, a user — lives at function level. The main trap is a session fixture returning a mutable dict: one test changes it and another fails only in the full run. So I return immutable data or add a narrow copying fixture. And I remember that under xdist each worker has its own session.
35 Що таке conftest.py, як працює його ієрархія і коли доречна autouse-фікстура? What is conftest.py, how does its hierarchy work, and when is an autouse fixture appropriate?
pytestpytest Middle ▶

conftest.py. Файл, який pytest завантажує автоматично. Фікстури й хуки з нього доступні всім тестам у тій самій теці та в підтеках без жодного імпорту. Кореневий conftest.py тримає спільне: конфігурацію, клієнтів, опції командного рядка через pytest_addoption. tests/api/conftest.py і tests/ui/conftest.py — те, що потрібне лише своєму шару. Фікстура з тим самим іменем у глибшому conftest перекриває зовнішню, і вона навіть може попросити батьківську версію під тим самим ім'ям та розширити її.

conftest.py. A file pytest loads automatically. Fixtures and hooks in it are available to every test in the same folder and its subfolders without any import. The root conftest.py holds shared things: configuration, clients, command-line options via pytest_addoption. tests/api/conftest.py and tests/ui/conftest.py hold what only their layer needs. A fixture with the same name in a deeper conftest overrides the outer one, and it can even request the parent version under the same name and extend it.

autouse. @pytest.fixture(autouse=True) застосовується до всіх тестів у своїй області видимості без явного запиту. Доречно для наскрізних речей, про які тест не повинен думати: скинути стан мок-сервера, почистити cookies, записати назву тесту в лог, зафіксувати часову зону чи seed генератора випадкових чисел.

autouse. @pytest.fixture(autouse=True) applies to every test in its scope without being requested. It fits cross-cutting concerns a test should not think about: resetting a mock server, clearing cookies, writing the test name to a log, pinning the time zone or the random seed.

Чому не зловживати. Autouse-фікстура прихована: читаючи тест, не видно, що перед ним щось відбувається. Якщо вона дорога (логін через UI), вона сповільнить кожен тест, навіть той, якому логін не потрібен. Правило: autouse — лише для дешевих і справді універсальних дій, решту тест просить явно.

Why not overuse it. An autouse fixture is hidden: reading the test you cannot see that something happens before it. If it is expensive (a UI login), it slows down every test, even those that need no login. The rule: autouse only for cheap, truly universal actions; everything else the test requests explicitly.

Підводні камені. conftest.py не імпортують вручну — from conftest import ... ламається залежно від того, звідки запущено pytest. Хелпери, які треба імпортувати, виносять в окремий модуль. І забагато логіки в одному conftest робить його смітником: краще кілька по шарах або плагін.

Pitfalls. conftest.py is not imported by hand — from conftest import ... breaks depending on where pytest was started. Helpers that must be imported go into a separate module. And too much logic in one conftest turns it into a junk drawer: prefer several per layer, or a plugin.

conftest.py (корінь)
conftest.py (root)
import pytest

@pytest.fixture
def base_url():
    return "https://shop.test"

@pytest.fixture(autouse=True)
def log_test_name(request):
    print(f"\n  >> {request.node.name}")
tests/api/conftest.py — перекриває base_url для API
tests/api/conftest.py — overrides base_url for the API
import pytest

@pytest.fixture
def base_url(base_url):  # розширює батьківську фікстуру
    return base_url + "/api/v2"
import pytest

@pytest.fixture
def base_url(base_url):  # extends the parent fixture
    return base_url + "/api/v2"
tests/api/test_users.py і tests/ui/test_home.py
tests/api/test_users.py and tests/ui/test_home.py
def test_users_endpoint(base_url):
    assert base_url == "https://shop.test/api/v2"
Вивід
Output
$ pytest -q -s

  >> test_users_endpoint
.
  >> test_home_page
.
2 passed in 0.01s
🔧 Розбір коду: Жоден тестовий файл нічого не імпортує: фікстури приходять з conftest. base_url у tests/api/conftest.py просить однойменну батьківську фікстуру й дописує шлях, тому API-тест бачить /api/v2, а UI-тест (tests/ui/test_home.py з тією ж перевіркою без суфікса) — корінь. Autouse-фікстура друкує назву кожного тесту без явного запиту.
🔧 Code breakdown: No test file imports anything: fixtures come from conftest. base_url in tests/api/conftest.py requests the parent fixture of the same name and appends a path, so the API test sees /api/v2 while the UI test (tests/ui/test_home.py with the same check minus the suffix) sees the root. The autouse fixture prints each test's name without being requested.
💡 Простими словами: conftest.py — це спільна кухня поверху: усі кімнати нижче користуються нею без запрошення.
💡 In plain words: conftest.py is a shared kitchen on a floor: every room below uses it without an invitation.
🗣 Як читати вголос conftest.py pytest підхоплює сам, і фікстури з нього доступні всім тестам у теці та підтеках без імпорту. Я роблю кореневий conftest для конфігурації й клієнтів і окремі в теках api та ui для специфічних фікстур, а глибша фікстура може перекрити або розширити зовнішню з тим самим ім'ям. autouse-фікстура працює для всіх тестів без запиту, і я використовую її лише для дешевих наскрізних речей на кшталт логування назви тесту чи скидання стану, бо вона прихована і може непомітно сповільнити весь набір.
🗣 How to say it out loud pytest picks up conftest.py by itself, and its fixtures are available to every test in the folder and below without imports. I keep a root conftest for configuration and clients and separate ones in the api and ui folders for layer-specific fixtures, and a deeper fixture can override or extend an outer one with the same name. An autouse fixture applies to all tests without a request, and I use it only for cheap cross-cutting things like logging the test name or resetting state, because it is hidden and can quietly slow down the whole suite.
36 Що робить @pytest.mark.parametrize і як дати параметрам зрозумілі ids? What does @pytest.mark.parametrize do, and how do you give parameters readable ids?
pytestpytest Middle ▶

Що це. Декоратор запускає ту саму тестову функцію кілька разів з різними даними: @pytest.mark.parametrize("email, valid", [("a@b.io", True), ("no-at", False)]). Перший аргумент — імена параметрів через кому, другий — список кортежів. Кожен набір стає окремим тестом зі своїм статусом у звіті, тож падіння одного випадку не ховає інші — на відміну від циклу всередині тесту, який зупиняється на першій помилці.

What it is. The decorator runs the same test function several times with different data: @pytest.mark.parametrize("email, valid", [("a@b.io", True), ("no-at", False)]). The first argument is the parameter names separated by commas, the second a list of tuples. Each set becomes a separate test with its own status in the report, so one failing case does not hide the others — unlike a loop inside a test, which stops at the first error.

ids. За замовчуванням pytest будує id з самих значень: test_email[a@b.io-True]. Для складних об'єктів це data0, data1, що нічого не каже. Параметр ids=[...] задає назви списком, ids=функція — обчислює їх, а pytest.param(..., id="empty email") — для конкретного випадку. Хороші id видно у звіті CI, і їх можна вибирати через -k.

ids. By default pytest builds an id from the values themselves: test_email[a@b.io-True]. For complex objects it becomes data0, data1, which says nothing. ids=[...] sets names as a list, ids=function computes them, and pytest.param(..., id="empty email") names a single case. Good ids show up in the CI report and can be selected with -k.

Додаткові можливості. pytest.param(..., marks=pytest.mark.xfail(reason="BUG-123")) позначає відомий баг лише для одного набору. Два декоратори parametrize один над одним дають декартів добуток. indirect=True передає значення не в тест, а у фікстуру з тим самим ім'ям через request.param — так параметризують, наприклад, роль користувача, під якою фікстура логіниться.

Extras. pytest.param(..., marks=pytest.mark.xfail(reason="BUG-123")) marks a known bug for just one set. Two parametrize decorators stacked give a cartesian product. indirect=True sends the value not to the test but to the fixture of the same name through request.param — that is how you parametrise, say, the user role a fixture logs in with.

Підводні камені. Декартів добуток росте швидко: 3 браузери × 4 мови × 5 ролей — це 60 тестів. Дані, що генеруються під час збирання (випадкові значення), роблять id нестабільними і ламають --lf та xdist.

Pitfalls. A cartesian product grows fast: 3 browsers × 4 locales × 5 roles is 60 tests. Data generated at collection time (random values) makes ids unstable and breaks --lf and xdist.

Валідація email з назвами випадків
Email validation with named cases
import pytest

def is_valid_email(value):
    return "@" in value and "." in value.split("@")[-1] and " " not in value

@pytest.mark.parametrize(
    "email, expected",
    [
        ("ann@example.com", True),
        ("ann.example.com", False),
        pytest.param("ann@example", False, id="no top-level domain"),
        pytest.param("ann @example.com", False, id="space inside"),
        pytest.param("ANN@EXAMPLE.COM", True, id="upper case",
                     marks=pytest.mark.xfail(reason="BUG-42: validator rejects upper case")),
    ],
)
def test_email_validation(email, expected):
    assert is_valid_email(email) is expected

@pytest.fixture
def user_role(request):
    return {"name": "test-" + request.param, "role": request.param}

@pytest.mark.parametrize("user_role", ["admin", "viewer"], indirect=True)
def test_role_fixture(user_role):
    assert user_role["name"].startswith("test-")
Вивід
Output
$ pytest -v test_parametrize.py
============================= test session starts ==============================
platform linux -- Python 3.13.15, pytest-9.1.1, pluggy-1.6.0 -- ~/pyenv/bin/python
rootdir: .
collecting ... collected 7 items

test_parametrize.py::test_email_validation[ann@example.com-True] PASSED  [ 14%]
test_parametrize.py::test_email_validation[ann.example.com-False] PASSED [ 28%]
test_parametrize.py::test_email_validation[no top-level domain] PASSED   [ 42%]
test_parametrize.py::test_email_validation[space inside] PASSED          [ 57%]
test_parametrize.py::test_email_validation[upper case] XPASS (BUG-42...) [ 71%]
test_parametrize.py::test_role_fixture[admin] PASSED                     [ 85%]
test_parametrize.py::test_role_fixture[viewer] PASSED                    [100%]

========================= 6 passed, 1 xpassed in 0.01s =========================
🔧 Розбір коду: Перші два набори отримали id із самих значень, решта — людські назви з pytest.param(..., id=...). Випадок з великими літерами позначено xfail, але валідатор його пропускає, тому статус XPASS — сигнал, що баг, можливо, вже виправлено. indirect=True передав admin і viewer у фікстуру через request.param.
🔧 Code breakdown: The first two sets got ids from their values, the rest got human names from pytest.param(..., id=...). The upper-case case is marked xfail, yet the validator accepts it, so its status is XPASS — a hint the bug may already be fixed. indirect=True sent admin and viewer into the fixture through request.param.
💡 Простими словами: parametrize — один рецепт тесту, який pytest готує окремо з кожним набором інгредієнтів, а ids — підписи на тарілках.
💡 In plain words: parametrize is one test recipe pytest cooks separately with each set of ingredients, and ids are the labels on the plates.
🗣 Як читати вголос parametrize запускає одну тестову функцію з різними наборами даних, і кожен набір — окремий тест зі своїм статусом, тож падіння одного випадку не ховає інші, як це було б з циклом. Я завжди даю зрозумілі ids через pytest.param або параметр ids, щоб у звіті CI було «space inside», а не data3. Відомий баг позначаю xfail лише для одного набору, а параметр для фікстури передаю через indirect. І стежу за розміром декартового добутку.
🗣 How to say it out loud parametrize runs one test function with different data sets, and each set is a separate test with its own status, so one failing case does not hide the others as it would inside a loop. I always give readable ids through pytest.param or the ids argument, so the CI report says «space inside» instead of data3. I mark a known bug as xfail for just one set, and pass a parameter to a fixture with indirect. And I watch the size of the cartesian product.
37 Як перевірити, що код кидає очікуваний виняток, за допомогою pytest.raises? How do you check that code raises the expected exception with pytest.raises?
pytestpytest Junior ▶

Що це. with pytest.raises(ValueError): — контекстний менеджер, який проходить, якщо код усередині кинув ValueError або його нащадка, і валить тест з повідомленням DID NOT RAISE, якщо винятку не було. Інший тип винятку пролетить наскрізь і теж зробить тест червоним.

What it is. with pytest.raises(ValueError): is a context manager that passes if the code inside raised ValueError or a subclass, and fails the test with DID NOT RAISE if nothing was raised. A different exception type flies straight through and also turns the test red.

Перевірити деталі. Параметр match="regex" перевіряє текст повідомлення через re.search, тож спецсимволи на кшталт дужок і крапок треба екранувати або обгорнути re.escape. as excinfo дає об'єкт, у якого excinfo.value — сам виняток: можна перевірити його атрибути, наприклад статус у власному ApiError. excinfo.type — фактичний клас.

Checking details. match="regex" checks the message text with re.search, so special characters like brackets and dots must be escaped or wrapped in re.escape. as excinfo gives an object whose excinfo.value is the exception itself: you can check its attributes, like the status in your own ApiError. excinfo.type is the actual class.

Кращі практики. У блоці with має бути лише один рядок, який повинен упасти. Якщо там три рядки, тест пройде, навіть коли впав зовсім не той, що мався на увазі. Рядки після того, що кинув виняток, не виконуються — перевірки пишуть після блоку with. Перевіряй найконкретніший тип, а не Exception, інакше тест пройде й на TypeError від друкарської помилки.

Good practice. The with block should contain only the one line that is supposed to fail. With three lines in there the test passes even when a different line failed than the one intended. Lines after the one that raised never run — put checks after the with block. Check the most specific type, not Exception, or the test passes on a TypeError from a typo.

Суміжні інструменти. pytest.warns(DeprecationWarning) — те саме для попереджень. Для групи винятків з Python 3.11 є pytest.RaisesGroup у свіжих версіях pytest.

Related tools. pytest.warns(DeprecationWarning) does the same for warnings. For exception groups from Python 3.11 recent pytest versions offer pytest.RaisesGroup.

Перевірка типу, тексту й атрибутів
Checking type, text and attributes
import re
import pytest

class ApiError(Exception):
    def __init__(self, status, message):
        self.status = status
        super().__init__(f"[{status}] {message}")

def withdraw(balance, amount):
    if amount > balance:
        raise ApiError(409, f"insufficient funds (balance {balance})")
    return balance - amount

def test_overdraft_is_rejected():
    with pytest.raises(ApiError, match=re.escape("[409] insufficient funds")) as excinfo:
        withdraw(100, 150)
    assert excinfo.value.status == 409

def test_forgot_that_code_does_not_raise():
    with pytest.raises(ApiError):
        withdraw(100, 50)
Вивід
Output
$ pytest -q --tb=short test_raises.py
.F                                                                       [100%]
=================================== FAILURES ===================================
_____________________ test_forgot_that_code_does_not_raise _____________________
test_raises.py:20: in test_forgot_that_code_does_not_raise
    with pytest.raises(ApiError):
         ^^^^^^^^^^^^^^^^^^^^^^^
E   Failed: DID NOT RAISE ApiError
=========================== short test summary info ============================
FAILED test_raises.py::test_forgot_that_code_does_not_raise - Failed: DID NOT...
1 failed, 1 passed in 0.01s
🔧 Розбір коду: re.escape екранує квадратні дужки, бо match — регулярний вираз. Перевірка excinfo.value.status стоїть після блоку with, де вона гарантовано виконається. Другий тест падає з DID NOT RAISE: зняття в межах балансу не кидає помилку.
🔧 Code breakdown: re.escape escapes the square brackets, because match is a regular expression. The excinfo.value.status check sits after the with block, where it is sure to run. The second test fails with DID NOT RAISE: a withdrawal within the balance raises nothing.
💡 Простими словами: pytest.raises — це «я чекаю, що тут бахне, і саме ось так»; якщо не бахнуло — тест червоний.
💡 In plain words: pytest.raises says «I expect this to blow up, and exactly like this»; if nothing blows up, the test is red.
🗣 Як читати вголос Очікуваний виняток перевіряю через контекстний менеджер pytest.raises з конкретним типом. Якщо винятку немає, тест падає з DID NOT RAISE, а інший тип пролітає наскрізь. Текст перевіряю параметром match — це регулярний вираз, тому дужки екраную через re.escape. Через excinfo дістаю сам виняток і перевіряю атрибути, наприклад статус. У блоці with тримаю лише один рядок, що має впасти, а решту перевірок пишу після нього.
🗣 How to say it out loud I check an expected exception with the pytest.raises context manager and a specific type. If nothing is raised the test fails with DID NOT RAISE, and a different type flies straight through. I check the text with match, which is a regular expression, so I escape brackets with re.escape. Through excinfo I get the exception object and check its attributes, like the status. I keep only the one line that should fail inside the with block and put other checks after it.
38 Як пропустити тест і що таке @pytest.mark.xfail, зокрема зі strict=True? How do you skip a test, and what is @pytest.mark.xfail, including with strict=True?
pytestpytest Junior ▶

skip. @pytest.mark.skip(reason="...") — безумовно пропустити. @pytest.mark.skipif(sys.platform == "win32", reason="...") — за умовою, що обчислюється під час збирання. pytest.skip("...") всередині тесту чи фікстури — коли умову можна перевірити лише під час виконання, наприклад стенд не має потрібної фічі. pytest.importorskip("playwright") пропускає, якщо пакет не встановлено. Пропуск означає «цей тест тут не має сенсу».

skip. @pytest.mark.skip(reason="...") skips unconditionally. @pytest.mark.skipif(sys.platform == "win32", reason="...") skips on a condition evaluated at collection time. pytest.skip("...") inside a test or fixture is for conditions you can only check at run time, like an environment lacking a feature. pytest.importorskip("playwright") skips if the package is not installed. A skip means «this test makes no sense here».

xfail. @pytest.mark.xfail(reason="BUG-123") каже «цей тест має сенс, але зараз очікувано падає через відомий баг». Тест виконується: якщо впав — статус XFAIL і запуск зелений; якщо раптом пройшов — XPASS. За замовчуванням XPASS не валить запуск, і про виправлений баг легко не дізнатися. strict=True перетворює XPASS на падіння: команда одразу бачить, що баг виправили, і прибирає позначку. Глобально це вмикає xfail_strict = true у конфігурації. raises=SomeError обмежує xfail конкретним типом помилки.

xfail. @pytest.mark.xfail(reason="BUG-123") says «this test makes sense, but right now it is expected to fail because of a known bug». The test runs: if it fails, the status is XFAIL and the run stays green; if it suddenly passes, the status is XPASS. By default XPASS does not fail the run, so a fixed bug can easily go unnoticed. strict=True turns XPASS into a failure: the team sees at once that the bug was fixed and removes the mark. xfail_strict = true in the config enables it globally. raises=SomeError limits the xfail to one error type.

Як бачити причини. -ra друкує в кінці короткий підсумок усіх пропусків, xfail і падінь з причинами — корисно в CI, щоб пропуски не накопичувались непоміченими.

Seeing the reasons. -ra prints a short summary of all skips, xfails and failures with their reasons at the end — useful in CI so skips do not pile up unnoticed.

Підводні камені. skip для нестабільного тесту — це приховування проблеми: тест мовчки перестає захищати продукт. Кожен skip і xfail має посилатися на задачу в трекері. І xfail без raises «прийме» будь-яке падіння, навіть NameError від зламаного тесту.

Pitfalls. Skipping a flaky test hides the problem: the test silently stops protecting the product. Every skip and xfail should reference a ticket. And an xfail without raises «accepts» any failure, even a NameError from a broken test.

Усі варіанти пропуску та очікуваного падіння
Every flavour of skip and expected failure
import sys
import pytest

@pytest.mark.skip(reason="payment sandbox is down, see OPS-7")
def test_refund():
    assert False

@pytest.mark.skipif(sys.version_info < (3, 12), reason="needs Python 3.12+")
def test_new_syntax():
    assert True

def test_feature_flag():
    enabled = False  # наприклад, прочитано з оточення
    if not enabled:
        pytest.skip("new checkout is off on this stand")

@pytest.mark.xfail(reason="BUG-123: rounding", raises=AssertionError)
def test_rounding():
    assert round(2.675, 2) == 2.68

@pytest.mark.xfail(reason="BUG-124: fixed already?", strict=True)
def test_fixed_bug():
    assert 1 + 1 == 2
import sys
import pytest

@pytest.mark.skip(reason="payment sandbox is down, see OPS-7")
def test_refund():
    assert False

@pytest.mark.skipif(sys.version_info < (3, 12), reason="needs Python 3.12+")
def test_new_syntax():
    assert True

def test_feature_flag():
    enabled = False  # e.g. read from the environment
    if not enabled:
        pytest.skip("new checkout is off on this stand")

@pytest.mark.xfail(reason="BUG-123: rounding", raises=AssertionError)
def test_rounding():
    assert round(2.675, 2) == 2.68

@pytest.mark.xfail(reason="BUG-124: fixed already?", strict=True)
def test_fixed_bug():
    assert 1 + 1 == 2
Вивід
Output
$ pytest -q -ra test_skip_xfail.py
s.sxF                                                                    [100%]
=================================== FAILURES ===================================
________________________________ test_fixed_bug ________________________________
[XPASS(strict)] BUG-124: fixed already?
=========================== short test summary info ============================
SKIPPED [1] test_skip_xfail.py:4: payment sandbox is down, see OPS-7
SKIPPED [1] test_skip_xfail.py:15: new checkout is off on this stand
XFAIL test_skip_xfail.py::test_rounding - BUG-123: rounding
FAILED test_skip_xfail.py::test_fixed_bug - [XPASS(strict)] BUG-124: fixed al...
1 failed, 1 passed, 2 skipped, 1 xfailed in 0.01s
🔧 Розбір коду: round(2.675, 2) дає 2.67 через двійкове представлення, тому тест очікувано падає (XFAIL) лише на AssertionError. strict=True перетворив XPASS на FAILED з поясненням [XPASS(strict)]. -ra вивів усі пропуски та xfail з причинами наприкінці.
🔧 Code breakdown: round(2.675, 2) gives 2.67 because of the binary representation, so the test fails as expected (XFAIL), but only on AssertionError. strict=True turned the XPASS into FAILED with the note [XPASS(strict)]. -ra printed all skips and xfails with their reasons at the end.
💡 Простими словами: skip — «тут цей тест не потрібен»; xfail — «тест правильний, але продукт поки зламаний».
💡 In plain words: skip means «this test does not belong here»; xfail means «the test is right, but the product is still broken».
🗣 Як читати вголос Пропустити тест можна маркером skip, умовно через skipif, або викликом pytest.skip усередині, коли умова відома лише під час виконання. xfail означає інше: тест правильний, але зараз падає через відомий баг, тому він виконується і дає статус XFAIL. Я вмикаю strict, щоб несподіваний прохід, тобто XPASS, валив запуск, і команда дізнавалася, що баг виправили. Ще додаю raises, щоб xfail не ковтав інші помилки, і запускаю з -ra, щоб бачити причини всіх пропусків.
🗣 How to say it out loud You can skip a test with the skip marker, conditionally with skipif, or by calling pytest.skip inside when the condition is only known at run time. xfail means something else: the test is right but currently fails because of a known bug, so it runs and reports XFAIL. I turn on strict so an unexpected pass, an XPASS, fails the run and the team learns the bug is fixed. I also add raises so xfail does not swallow other errors, and I run with -ra to see the reasons for every skip.
39 Як працювати з маркерами і запускати лише тести з певним маркером через -m? How do you work with markers and run only tests with a certain marker using -m?
pytestpytest Middle ▶

Що це. Маркер — мітка на тесті: @pytest.mark.smoke. Вбудовані маркери (skip, xfail, parametrize, usefixtures) змінюють поведінку, а власні лише класифікують тести: smoke, regression, api, slow. Маркер можна поставити на функцію, на клас (діє на всі методи) і на весь модуль через pytestmark = [pytest.mark.api].

What it is. A marker is a label on a test: @pytest.mark.smoke. Built-in markers (skip, xfail, parametrize, usefixtures) change behaviour, while your own only classify tests: smoke, regression, api, slow. A marker can go on a function, on a class (applies to all its methods) and on a whole module via pytestmark = [pytest.mark.api].

Запуск за маркером. pytest -m smoke запускає лише позначені тести. Вираз підтримує and, or, not і дужки: -m "api and not slow". Типове використання — окремі кроки CI: швидкий smoke на кожен pull request, повна регресія вночі.

Running by marker. pytest -m smoke runs only the marked tests. The expression supports and, or, not and parentheses: -m "api and not slow". The typical use is separate CI steps: a fast smoke run on every pull request, full regression at night.

Реєстрація. Власні маркери реєструють у конфігурації в секції markers з описом. Незареєстрований маркер дає попередження, а з --strict-markers (його варто додати в addopts) — помилку. Це захищає від друкарської помилки: тест з @pytest.mark.smoek інакше мовчки випаде з smoke-набору. pytest --markers показує список усіх маркерів з описами.

Registration. Custom markers are registered in the markers section of the config with a description. An unregistered marker gives a warning, and with --strict-markers (worth adding to addopts) an error. That protects against typos: a test with @pytest.mark.smoek would otherwise silently drop out of the smoke set. pytest --markers lists all markers with descriptions.

Підводні камені. -m працює з маркерами, а -k — з назвами; їх плутають. Маркери з параметрами (@pytest.mark.jira("QA-1")) можна читати у фікстурі через request.node.get_closest_marker("jira"), щоб, наприклад, додати посилання у звіт. І не роби з маркерів другу систему папок: якщо тести з маркером api все одно лежать у tests/api, достатньо шляху.

Pitfalls. -m works on markers and -k on names; people mix them up. Markers with arguments (@pytest.mark.jira("QA-1")) can be read in a fixture via request.node.get_closest_marker("jira"), for example to add a link to the report. And do not turn markers into a second folder system: if tests marked api already live in tests/api, the path is enough.

pytest.ini
pytest.ini
[pytest]
addopts = --strict-markers
markers =
    smoke: critical path, runs on every pull request
    slow: takes more than a few seconds
    api: talks to the HTTP API
test_markers.py
test_markers.py
import pytest

pytestmark = pytest.mark.api

@pytest.mark.smoke
def test_login():
    assert True

@pytest.mark.smoke
@pytest.mark.slow
def test_full_checkout():
    assert True

def test_profile_update():
    assert True
Вивід
Output
$ pytest -v -m "smoke and not slow"

test_markers.py::test_login PASSED                                       [100%]

======================= 1 passed, 2 deselected in 0.00s ========================

$ pytest -q -m api
...                                                                      [100%]
3 passed in 0.00s
🔧 Розбір коду: pytestmark на рівні модуля ставить api на всі три тести. Вираз smoke and not slow відібрав лише test_login, а решта пішла в deselected. Якщо в якомусь тесті написати @pytest.mark.smoek, --strict-markers зупинить збирання з помилкою «'smoek' not found in markers configuration option», і тест не випаде з набору непомітно.
🔧 Code breakdown: pytestmark at module level puts api on all three tests. The expression smoke and not slow selected only test_login, and the rest went to deselected. If some test says @pytest.mark.smoek, --strict-markers stops collection with «'smoek' not found in markers configuration option», so the test cannot drop out of the set unnoticed.
💡 Простими словами: Маркер — це кольоровий стікер на тесті; -m каже «запусти лише тести з жовтим стікером, але без червоного».
💡 In plain words: A marker is a coloured sticker on a test; -m says «run only the tests with a yellow sticker and no red one».
🗣 Як читати вголос Маркери — це мітки на тестах. Вбудовані на кшталт skip чи xfail змінюють поведінку, а власні, як smoke чи slow, лише класифікують. Запускаю вибірку через -m з логічним виразом, наприклад «smoke and not slow», і так розділяю швидкий прогін на pull request і нічну регресію. Власні маркери обов'язково реєструю в конфігурації й вмикаю --strict-markers, бо інакше тест з друкарською помилкою в маркері мовчки випаде з набору. І не плутаю -m для маркерів з -k для назв.
🗣 How to say it out loud Markers are labels on tests. Built-in ones like skip or xfail change behaviour, while custom ones like smoke or slow only classify. I run a subset with -m and a boolean expression such as «smoke and not slow», which is how I split a fast pull request run from nightly regression. I always register custom markers in the config and enable --strict-markers, because otherwise a test with a typo in its marker silently drops out. And I do not confuse -m for markers with -k for names.
40 Що дають вбудовані фікстури tmp_path, monkeypatch і capsys? What do the built-in fixtures tmp_path, monkeypatch and capsys give you?
pytestpytest Middle ▶

tmp_path. Унікальна тимчасова тека (pathlib.Path) для кожного тесту. Тест може писати туди звіти, завантажені файли, згенеровані CSV, не турбуючись про конфлікти з паралельними тестами і про прибирання: pytest зберігає лише кілька останніх запусків. Для спільної теки на сесію є tmp_path_factory.

tmp_path. A unique temporary folder (pathlib.Path) for each test. A test can write reports, downloaded files and generated CSVs there without worrying about clashes with parallel tests or about cleanup: pytest keeps only the last few runs. For a folder shared across the session there is tmp_path_factory.

monkeypatch. Тимчасово змінює світ для одного тесту й автоматично повертає все назад після нього: setenv і delenv — змінні середовища, setattr — атрибут модуля чи об'єкта (наприклад, підмінити функцію, що ходить у мережу), setitem — ключ словника, chdir — поточну теку, syspath_prepend — шлях імпорту. Головна перевага над ручною заміною — гарантоване відновлення, навіть якщо тест упав.

monkeypatch. Temporarily changes the world for one test and automatically restores everything afterwards: setenv and delenv for environment variables, setattr for a module or object attribute (for instance, replacing a function that hits the network), setitem for a dict key, chdir for the current folder, syspath_prepend for the import path. Its main advantage over manual swapping is guaranteed restoration, even when the test fails.

capsys. Перехоплює stdout і stderr: out, err = capsys.readouterr(). Так тестують CLI-утиліти й скрипти, що друкують результат. Для логів через модуль logging є окрема фікстура caplog з caplog.records і caplog.text.

capsys. Captures stdout and stderr: out, err = capsys.readouterr(). That is how you test CLI tools and scripts that print their result. For logs written through the logging module there is a separate caplog fixture with caplog.records and caplog.text.

Підводні камені. monkeypatch.setattr треба застосовувати там, де ім'я шукають: якщо модуль зробив from config import BASE_URL, заміна config.BASE_URL його не зачепить. monkeypatch має scope function, тож у сесійній фікстурі його не використати — там потрібен pytest.MonkeyPatch.context().

Pitfalls. Apply monkeypatch.setattr where the name is looked up: if a module did from config import BASE_URL, patching config.BASE_URL will not reach it. monkeypatch has function scope, so it cannot be used in a session fixture — there you need pytest.MonkeyPatch.context().

Три фікстури в одному файлі
Three fixtures in one file
import os
import pytest

def export_report(results, folder):
    path = folder / "report.csv"
    path.write_text("\n".join(f"{name},{status}" for name, status in results))
    return path

def stand_url():
    return os.environ.get("STAND_URL", "https://prod.shop")

def print_summary(results):
    failed = [n for n, s in results if s == "failed"]
    print(f"{len(results)} tests, {len(failed)} failed")
    if failed:
        print("failed: " + ", ".join(failed), file=__import__("sys").stderr)

def test_report_file(tmp_path):
    path = export_report([("login", "passed")], tmp_path)
    assert path.read_text() == "login,passed"
    assert path.parent == tmp_path

def test_stand_from_env(monkeypatch):
    monkeypatch.setenv("STAND_URL", "https://staging.shop")
    assert stand_url() == "https://staging.shop"

def test_env_is_restored():
    assert "STAND_URL" not in os.environ

def test_summary_output(capsys):
    print_summary([("login", "passed"), ("cart", "failed")])
    out, err = capsys.readouterr()
    assert out == "2 tests, 1 failed\n"
    assert err == "failed: cart\n"
Вивід
Output
$ pytest -q test_builtin_fixtures.py
....                                                                     [100%]
4 passed in 0.01s
🔧 Розбір коду: tmp_path — окрема тека для тесту, тож report.csv не конфліктує з іншими. monkeypatch.setenv змінив змінну лише на час одного тесту, а test_env_is_restored підтверджує відновлення. capsys.readouterr() повертає окремо stdout і stderr.
🔧 Code breakdown: tmp_path is a folder of the test's own, so report.csv does not clash with others. monkeypatch.setenv changed the variable for one test only, and test_env_is_restored confirms it was restored. capsys.readouterr() returns stdout and stderr separately.
💡 Простими словами: tmp_path — чиста парта для кожного тесту, monkeypatch — зміни, які самі відкотяться, capsys — диктофон на консоль.
💡 In plain words: tmp_path is a clean desk for every test, monkeypatch is changes that undo themselves, capsys is a recorder on the console.
🗣 Як читати вголос tmp_path дає кожному тесту унікальну тимчасову теку, куди я пишу звіти чи завантажені файли, і паралельні тести не конфліктують. monkeypatch тимчасово змінює змінні середовища, атрибути модулів і словники та гарантовано відкочує все після тесту, навіть після падіння. capsys перехоплює вивід у консоль, щоб перевірити, що друкує скрипт, а для логів беру caplog. З monkeypatch пам'ятаю правило: патчити там, де ім'я шукають, а не там, де його оголошено.
🗣 How to say it out loud tmp_path gives each test a unique temporary folder where I write reports or downloaded files, and parallel tests do not clash. monkeypatch temporarily changes environment variables, module attributes and dicts and reliably rolls everything back after the test, even after a failure. capsys captures console output so I can check what a script prints, and for logs I use caplog. With monkeypatch I remember the rule: patch where the name is looked up, not where it is defined.
41 Де конфігурують pytest, як керувати traceback і що роблять --lf та --ff? Where is pytest configured, how do you control tracebacks, and what do --lf and --ff do?
pytestpytest Middle ▶

Де конфігурація. pytest шукає один файл у такому порядку пріоритету: pytest.ini, потім pyproject.toml з секцією [tool.pytest.ini_options], потім tox.ini і setup.cfg. Знайдений файл визначає rootdir. Типовий вміст: testpaths (де шукати тести), addopts (прапорці за замовчуванням, наприклад -ra --strict-markers), markers, xfail_strict, log_cli, filterwarnings. Фікстури й хуки живуть не тут, а в conftest.py.

Where the config lives. pytest looks for one file in this priority order: pytest.ini, then pyproject.toml with a [tool.pytest.ini_options] table, then tox.ini and setup.cfg. The file it finds sets the rootdir. Typical contents: testpaths (where to look for tests), addopts (default flags such as -ra --strict-markers), markers, xfail_strict, log_cli, filterwarnings. Fixtures and hooks do not live here but in conftest.py.

Traceback. --tb=auto (за замовчуванням) показує повний звіт для першого й останнього кадру, --tb=long — усі кадри повністю, --tb=short — стисло, зручно для логів CI, --tb=line — один рядок на падіння, --tb=no — лише підсумок. -l додає значення локальних змінних. -ra наприкінці виводить підсумок з причинами всіх не-зелених результатів.

Tracebacks. --tb=auto (the default) gives a full report for the first and last frames, --tb=long all frames in full, --tb=short a compact form handy for CI logs, --tb=line one line per failure, --tb=no only the summary. -l adds local variable values. -ra prints a summary with reasons for every non-green result at the end.

--lf і --ff. pytest зберігає результати в .pytest_cache. --lf (last failed) запускає лише тести, що впали минулого разу, — швидкий цикл «виправив і перевірив». --ff (failed first) запускає спершу впалі, потім решту. -x зупиняє запуск на першому падінні, --sw (stepwise) продовжує з того тесту, де зупинились.

--lf and --ff. pytest stores results in .pytest_cache. --lf (last failed) runs only the tests that failed last time — a quick «fix and recheck» loop. --ff (failed first) runs the failed ones first, then the rest. -x stops at the first failure, and --sw (stepwise) resumes from the test where you stopped.

Підводні камені. Якщо в теці лежать і pytest.ini, і pyproject.toml, секція в pyproject ігнорується — звідси «чому мої налаштування не працюють». Кеш не комітять: .pytest_cache має бути в .gitignore.

Pitfalls. If a folder has both pytest.ini and pyproject.toml, the pyproject section is ignored — hence «why are my settings not working». The cache is never committed: .pytest_cache belongs in .gitignore.

pyproject.toml
pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers --tb=short"
xfail_strict = true
markers = ["smoke: critical path"]
tests/test_prices.py
tests/test_prices.py
def test_price_positive():
    assert 10 > 0

def test_discount():
    assert 100 * 0.9 == 90

def test_vat():
    assert round(100 * 1.2, 2) == 121
Вивід
Output
$ pytest -q --tb=line
..F                                                                      [100%]
=================================== FAILURES ===================================
E   assert 120.0 == 121
     +  where 120.0 = round((100 * 1.2), 2)
./tests/test_prices.py:8: assert 120.0 == 121
=========================== short test summary info ============================
FAILED tests/test_prices.py::test_vat - assert 120.0 == 121
1 failed, 2 passed in 0.01s

$ pytest -q --lf --tb=no
F                                                                        [100%]
=========================== short test summary info ============================
FAILED tests/test_prices.py::test_vat - assert 120.0 == 121
1 failed in 0.00s
🔧 Розбір коду: Конфігурація з pyproject.toml підхопилася сама: pytest знайшов tests через testpaths. --tb=line з командного рядка перекрив --tb=short з addopts і дав один рядок на падіння. Другий запуск з --lf виконав лише тест, що впав, а решту позначив як deselected.
🔧 Code breakdown: The config in pyproject.toml was picked up on its own: pytest found tests through testpaths. --tb=line on the command line overrode --tb=short from addopts and gave one line per failure. The second run with --lf executed only the failed test and marked the rest as deselected.
💡 Простими словами: Конфіг — щоб не повторювати прапорці щоразу; --tb — наскільки детально розповідати про падіння; --lf — «покажи лише те, що зламалося».
💡 In plain words: The config saves retyping flags; --tb sets how much detail a failure gets; --lf means «show me only what broke».
🗣 Як читати вголос Конфігурацію pytest я тримаю в pyproject.toml у секції tool.pytest.ini_options, хоча pytest.ini має вищий пріоритет, і якщо він є, pyproject ігнорується. Там testpaths, addopts з -ra і --strict-markers, маркери й xfail_strict. Обсяг traceback регулюю через --tb: short для CI, line, коли падінь багато, long для налагодження. Під час виправлень ганяю --lf, що запускає лише впалі минулого разу тести, або --ff, щоб спершу пройшли вони, а потім решта.
🗣 How to say it out loud I keep the pytest config in pyproject.toml under tool.pytest.ini_options, though pytest.ini takes priority and, if present, pyproject is ignored. It holds testpaths, addopts with -ra and --strict-markers, markers and xfail_strict. I tune the traceback with --tb: short for CI, line when there are many failures, long for debugging. While fixing things I run --lf to rerun only last time's failures, or --ff so they go first and then the rest.
42 Як прискорити й звітувати прогін плагінами pytest-xdist, pytest-rerunfailures, pytest-html та Allure, не приховавши проблем? How do you speed up and report a run with pytest-xdist, pytest-rerunfailures, pytest-html and Allure without hiding problems?
pytestpytest Senior ▶

pytest-xdist. pytest -n auto запускає тести в кількох процесах-воркерах за кількістю ядер. Для I/O-важких наборів (API, UI) прискорення майже лінійне. Умова — незалежність тестів: унікальні дані на тест, жодних спільних файлів і глобального стану. --dist loadfile чи loadgroup з маркером xdist_group тримає пов'язані тести в одному воркері. Сесійні фікстури створюються в кожному воркері окремо.

pytest-xdist. pytest -n auto runs tests in several worker processes, one per core. For I/O-heavy suites (API, UI) the speed-up is nearly linear. The condition is test independence: unique data per test, no shared files and no global state. --dist loadfile or loadgroup with the xdist_group marker keeps related tests on one worker. Session fixtures are created in each worker separately.

pytest-rerunfailures. --reruns 2 --reruns-delay 1 перезапускає тест, що впав, до двох разів. Це компроміс: CI стабільніший, але кожен перезапуск — сигнал нестабільності. Тому перезапуски варто обмежувати (--only-rerun ConnectionError) або ставити маркер flaky лише на відомі тести, і звітувати статус RERUN окремо — інакше flaky-тести назавжди ховаються за зеленим результатом.

pytest-rerunfailures. --reruns 2 --reruns-delay 1 reruns a failed test up to twice. It is a compromise: CI gets more stable, but every rerun is a flakiness signal. So limit reruns (--only-rerun ConnectionError) or put the flaky marker only on known tests, and report the RERUN status separately — otherwise flaky tests hide behind green forever.

Звіти. pytest-html: --html=report.html --self-contained-html дає один HTML-файл з результатами, тривалістю і логами — просто прикріпити як артефакт CI. allure-pytest: --alluredir=allure-results записує сирі результати, а allure serve чи allure generate будує звіт з кроками (allure.step), вкладеннями (скриншоти, тіла відповідей), історією запусків і трендами. Для CI-систем також є вбудований --junitxml=report.xml.

Reports. pytest-html: --html=report.html --self-contained-html produces one HTML file with results, durations and logs — easy to attach as a CI artifact. allure-pytest: --alluredir=allure-results writes raw results, and allure serve or allure generate builds a report with steps (allure.step), attachments (screenshots, response bodies), run history and trends. For CI systems there is also the built-in --junitxml=report.xml.

Як не приховати проблем. Збирай метрику перезапусків, дивись на найповільніші тести через --durations=10, а паралельність вмикай лише тоді, коли тести ізольовані, інакше отримаєш нові «плаваючі» падіння.

How not to hide problems. Track the rerun count, look at the slowest tests with --durations=10, and enable parallelism only once tests are isolated, or you will get new «flaky» failures.

Вісім повільних тестів і один нестабільний
Eight slow tests and one flaky one
import pathlib
import time
import pytest

@pytest.mark.parametrize("n", range(8))
def test_slow_api_call(n):
    time.sleep(0.5)  # імітує очікування мережі

def test_flaky_search():
    marker = pathlib.Path("search-attempted")
    first_attempt = not marker.exists()
    marker.touch()
    assert not first_attempt, "search index was not ready yet"
import pathlib
import time
import pytest

@pytest.mark.parametrize("n", range(8))
def test_slow_api_call(n):
    time.sleep(0.5)  # stands in for network wait

def test_flaky_search():
    marker = pathlib.Path("search-attempted")
    first_attempt = not marker.exists()
    marker.touch()
    assert not first_attempt, "search index was not ready yet"
Вивід
Output
$ pytest -q -k slow
8 passed, 1 deselected in 4.02s

$ pytest -q -n 4 -k slow
8 passed in 2.17s

$ pytest -q --reruns 1 -k flaky -rR
RERUN test_plugins.py::test_flaky_search
PASSED test_plugins.py::test_flaky_search
1 passed, 8 deselected, 1 rerun in 0.02s

$ pytest -q -k slow --html=report.html --self-contained-html
- Generated html report: file://./report.html -
8 passed, 1 deselected in 4.02s
36523 report.html
🔧 Розбір коду: Послідовно вісім тестів по 0,5 с займають близько 4 с, а з -n 4 — близько двох секунд: по дві хвилі на воркер плюс час на запуск самих воркерів. test_flaky_search падає з першої спроби й проходить з другої: у підсумку видно 1 rerun, а -rR перелічує перезапущені тести. pytest-html створив самодостатній report.html.
🔧 Code breakdown: Serially the eight 0.5 s tests take about 4 s, and with -n 4 about two seconds: two waves per worker plus the time to start the workers themselves. test_flaky_search fails on the first attempt and passes on the second: the summary shows 1 rerun, and -rR lists the rerun tests. pytest-html produced a self-contained report.html.
💡 Простими словами: xdist — більше кас у магазині, reruns — «спробуй ще раз» на касі, звіт — чек, де видно, скільки разів пробували.
💡 In plain words: xdist is more checkouts open in the shop, reruns are «try the card again», and the report is the receipt showing how many tries it took.
🗣 Як читати вголос Для швидкості вмикаю pytest-xdist з -n auto, але лише після того, як тести незалежні: унікальні дані, жодного спільного стану, і пам'ятаю, що сесійні фікстури створюються в кожному воркері. pytest-rerunfailures використовую обережно — обмежую кількість спроб і типи помилок, а кількість перезапусків показую у звіті як метрику нестабільності. Для звітів беру pytest-html для простого артефакту CI або Allure, коли потрібні кроки, вкладення й історія, плюс junitxml для самої CI-системи.
🗣 How to say it out loud For speed I enable pytest-xdist with -n auto, but only once tests are independent: unique data and no shared state, and I remember session fixtures are created in every worker. I use pytest-rerunfailures carefully — I limit the number of attempts and the error types, and I show the rerun count in the report as a flakiness metric. For reporting I use pytest-html for a simple CI artifact or Allure when I need steps, attachments and history, plus junitxml for the CI system itself.
43 Як працювати з unittest.mock: patch, MagicMock, side_effect і autospec, і де патчити? How do you work with unittest.mock — patch, MagicMock, side_effect and autospec — and where do you patch?
pytestpytest Senior ▶

Що це. unittest.mock — стандартна бібліотека для підміни залежностей. MagicMock приймає будь-який виклик і атрибут, запам'ятовує, як його викликали, і повертає return_value. side_effect задає поведінку: виняток (side_effect=TimeoutError()), послідовність результатів для кожного виклику ([TimeoutError(), {"ok": True}]) або функцію. Перевірки — assert_called_once_with(...), call_count, call_args_list.

What it is. unittest.mock is the standard library for replacing dependencies. A MagicMock accepts any call and attribute, remembers how it was called and returns return_value. side_effect defines behaviour: an exception (side_effect=TimeoutError()), a sequence of results, one per call ([TimeoutError(), {"ok": True}]), or a function. Checks are assert_called_once_with(...), call_count and call_args_list.

Де патчити. patch("module.name") замінює ім'я там, де його шукають під час виконання, а не там, де воно оголошене. Якщо orders.py робить from gateway import charge, патчити треба orders.charge: gateway.charge вже скопійовано в простір імен orders. Це найчастіша причина «мок не спрацював».

Where to patch. patch("module.name") replaces the name where it is looked up at run time, not where it is defined. If orders.py does from gateway import charge, patch orders.charge: gateway.charge has already been copied into the orders namespace. This is the most common reason for «the mock did not work».

autospec. Звичайний MagicMock приймає що завгодно: тест викличе charge(amount=10), хоча справжня функція вимагає ще й currency, і пройде, а продакшен впаде. patch(..., autospec=True) або create_autospec(func) копіюють сигнатуру оригіналу, і неправильний виклик дає TypeError. spec=Class обмежує атрибути існуючими — друкарська помилка assert_caled_once теж не пройде тихо.

autospec. A plain MagicMock accepts anything: a test calls charge(amount=10), although the real function also requires currency, and it passes while production fails. patch(..., autospec=True) or create_autospec(func) copy the original's signature, so a wrong call raises TypeError. spec=Class limits attributes to existing ones — a typo like assert_caled_once will not slip through quietly either.

Трейдофи. Мок робить тест швидким і детермінованим, але перевіряє взаємодію, а не результат. Надмірні моки фіксують реалізацію, і тест ламається при кожному рефакторингу. Мокати варто межі системи: мережу, час, платіжний шлюз, пошту. Для HTTP часто зручніші спеціалізовані інструменти — responses чи respx — або контрактні тести, щоб мок не розійшовся зі справжнім API.

Trade-offs. A mock makes a test fast and deterministic, but it checks the interaction, not the outcome. Excessive mocks freeze the implementation, and the test breaks on every refactoring. Mock the system's edges: the network, time, a payment gateway, email. For HTTP, dedicated tools — responses or respx — are often more convenient, or contract tests, so the mock does not drift from the real API.

gateway.py і orders.py — код, який тестуємо
gateway.py and orders.py — the code under test
from gateway import charge

def pay(order_id, amount):
    for attempt in range(2):
        try:
            receipt = charge(amount, currency="UAH")
            return {"order": order_id, "receipt": receipt["id"], "attempts": attempt + 1}
        except TimeoutError:
            continue
    return {"order": order_id, "receipt": None, "attempts": 2}
test_orders.py
test_orders.py
from unittest.mock import patch

import orders

def test_retries_after_timeout():
    with patch("orders.charge", autospec=True) as charge:  # патчимо там, де ім'я шукають
        charge.side_effect = [TimeoutError(), {"id": "rc-1"}]
        result = orders.pay(7, 100)
    assert result == {"order": 7, "receipt": "rc-1", "attempts": 2}
    assert charge.call_count == 2
    charge.assert_called_with(100, currency="UAH")

def test_patching_the_wrong_place_hits_the_real_gateway():
    with patch("gateway.charge", return_value={"id": "fake"}):
        result = orders.pay(8, 50)
    assert result["receipt"] == "fake"

def test_autospec_catches_a_wrong_signature():
    with patch("orders.charge", autospec=True) as charge:
        charge(100, currency="UAH", retries=3)
from unittest.mock import patch

import orders

def test_retries_after_timeout():
    with patch("orders.charge", autospec=True) as charge:  # patch where it is looked up
        charge.side_effect = [TimeoutError(), {"id": "rc-1"}]
        result = orders.pay(7, 100)
    assert result == {"order": 7, "receipt": "rc-1", "attempts": 2}
    assert charge.call_count == 2
    charge.assert_called_with(100, currency="UAH")

def test_patching_the_wrong_place_hits_the_real_gateway():
    with patch("gateway.charge", return_value={"id": "fake"}):
        result = orders.pay(8, 50)
    assert result["receipt"] == "fake"

def test_autospec_catches_a_wrong_signature():
    with patch("orders.charge", autospec=True) as charge:
        charge(100, currency="UAH", retries=3)
Вивід
Output
$ pytest -q --tb=line test_orders.py
.FF                                                                      [100%]
=================================== FAILURES ===================================
E   ConnectionError: real payment gateway called from a test
./gateway.py:2: ConnectionError: real payment gateway called from a test
E   TypeError: got an unexpected keyword argument 'retries'
/home/azanir/.local/share/uv/python/cpython-3.13.15-linux-x86_64-gnu/lib/python3.13/inspect.py:3303: TypeError: got an unexpected keyword argument 'retries'
=========================== short test summary info ============================
FAILED test_orders.py::test_patching_the_wrong_place_hits_the_real_gateway - ...
FAILED test_orders.py::test_autospec_catches_a_wrong_signature - TypeError: g...
2 failed, 1 passed in 0.03s
🔧 Розбір коду: side_effect зі списком дає різну поведінку на кожен виклик: спершу таймаут, потім квитанція. Другий тест патчить gateway.charge, але orders уже тримає власне посилання, тож викликається справжній шлюз і летить ConnectionError. Третій тест показує, що autospec=True відхиляє зайвий аргумент retries, якого немає в реальній сигнатурі.
🔧 Code breakdown: side_effect with a list gives different behaviour per call: a timeout first, then a receipt. The second test patches gateway.charge, but orders already holds its own reference, so the real gateway is called and raises ConnectionError. The third test shows autospec=True rejecting the extra retries argument that the real signature does not have.
💡 Простими словами: Мок — дублер актора: autospec стежить, щоб дублер знав ті самі репліки, а patch треба ставити на ту сцену, де грають.
💡 In plain words: A mock is a stand-in actor: autospec makes sure the stand-in knows the same lines, and patch goes on the stage where the play actually runs.
🗣 Як читати вголос MagicMock приймає будь-які виклики й запам'ятовує їх, return_value задає результат, а side_effect — виняток, послідовність відповідей або функцію. Головне правило patch — підміняти ім'я там, де його шукають: якщо модуль імпортував функцію через from, патчу атрибут цього модуля, а не джерело. Я завжди додаю autospec, бо без нього мок прийме неіснуючий аргумент, і тест пройде там, де продакшен впаде. І мокаю лише межі системи — мережу, час, платежі, — щоб тести не фіксували внутрішню реалізацію.
🗣 How to say it out loud A MagicMock accepts any call and remembers it, return_value sets the result, and side_effect sets an exception, a sequence of answers or a function. The main patch rule is to replace the name where it is looked up: if a module imported a function with from, I patch that module's attribute, not the source. I always add autospec, because without it a mock accepts a non-existent argument and the test passes where production would fail. And I mock only the system's edges — network, time, payments — so tests do not freeze the internal implementation.
▶ 5 6
44 Як зробити GET з query-параметрами і POST з JSON-тілом і чим json= відрізняється від data=? How do you make a GET with query parameters and a POST with a JSON body, and how does json= differ from data=?
API-тестиAPI tests Junior ▶

GET з параметрами. requests.get(url, params={"q": "phone", "page": 2}, timeout=10). Бібліотека сама збирає рядок запиту й кодує спецсимволи: пробіл, кирилицю, амперсанд. Значення-список дає повторюваний параметр (tag=a&tag=b), а None параметр пропускає. Ручна склейка URL через f-рядок ламається на першому ж символі, який треба екранувати.

GET with parameters. requests.get(url, params={"q": "phone", "page": 2}, timeout=10). The library builds the query string itself and encodes special characters: spaces, Cyrillic, ampersands. A list value gives a repeated parameter (tag=a&tag=b), and None drops the parameter. Gluing a URL together by hand with an f-string breaks on the first character that needs escaping.

POST з JSON. requests.post(url, json=payload, timeout=10) серіалізує словник у JSON і ставить заголовок Content-Type: application/json.

POST with JSON. requests.post(url, json=payload, timeout=10) serialises the dict to JSON and sets the Content-Type: application/json header.

json= проти data=. data= зі словником кодує тіло як HTML-форму: name=Ann&role=admin з Content-Type: application/x-www-form-urlencoded. data= з рядком чи байтами відправляє тіло як є, без заголовка типу. Типова помилка — data=payload там, де API чекає JSON: сервер отримує форму і відповідає 400 чи 415, а вкладені словники взагалі губляться. Якщо тіло вже є JSON-рядком, передай його в data= і додай заголовок сам.

json= versus data=. data= with a dict encodes the body as an HTML form: name=Ann&role=admin with Content-Type: application/x-www-form-urlencoded. data= with a string or bytes sends the body as is, without a type header. The classic mistake is data=payload where the API expects JSON: the server gets a form and answers 400 or 415, and nested dicts get lost altogether. If the body is already a JSON string, pass it in data= and set the header yourself.

Як перевірити, що саме піде в мережу. requests.Request(...).prepare() будує запит без відправки: видно фінальний URL, заголовки й тіло. Зручно для налагодження і навіть для юніт-тестів клієнта. У відповіді response.request зберігає підготовлений запит, який реально пішов.

How to see what will hit the wire. requests.Request(...).prepare() builds the request without sending it: the final URL, headers and body are all visible. Handy for debugging and even for unit tests of a client. On a response, response.request holds the prepared request that actually went out.

Що саме відправляється і що бачить сервер
What gets sent and what the server sees
import requests

BASE = "https://httpbin.org"

r = requests.get(f"{BASE}/get", params={"q": "café & tea", "tag": ["new", "sale"], "page": None}, timeout=10)
print(r.status_code, r.url)
print("server saw args:", r.json()["args"])

payload = {"name": "Ann", "roles": ["admin"], "address": {"city": "Kyiv"}}
for kind in ("json", "data"):
    prepared = requests.Request("POST", f"{BASE}/post", **{kind: payload}).prepare()
    print(f"{kind}=  Content-Type: {prepared.headers.get('Content-Type')}")
    print(f"       body: {prepared.body!r}")

r = requests.post(f"{BASE}/post", json=payload, timeout=10)
print("server parsed json:", r.json()["json"])
Вивід
Output
$ python requests_basics.py
200 https://httpbin.org/get?q=caf%C3%A9+%26+tea&tag=new&tag=sale
server saw args: {'q': 'café & tea', 'tag': ['new', 'sale']}
json=  Content-Type: application/json
       body: b'{"name": "Ann", "roles": ["admin"], "address": {"city": "Kyiv"}}'
data=  Content-Type: application/x-www-form-urlencoded
       body: 'name=Ann&roles=admin&address=city'
server parsed json: {'address': {'city': 'Kyiv'}, 'name': 'Ann', 'roles': ['admin']}
🔧 Розбір коду: params закодував літеру é, пробіли й амперсанд, повторив tag і відкинув page=None. Підготовлені запити показують різницю: json= дає JSON-рядок і правильний заголовок, data= — форму, у якій вкладений address став рядком address=city, а значення Kyiv зникло. Останній запит httpbin розібрав як JSON без втрат.
🔧 Code breakdown: params encoded the letter é, the spaces and the ampersand, repeated tag and dropped page=None. The prepared requests show the difference: json= gives a JSON string and the right header, data= gives a form in which the nested address became address=city and the value Kyiv disappeared. httpbin parsed the last request as JSON with nothing lost.
💡 Простими словами: json= — «запакуй це як JSON і підпиши коробку»; data= — «відправ як форму» або «відправ як є».
💡 In plain words: json= means «pack this as JSON and label the box»; data= means «send it as a form» or «send it as is».
🗣 Як читати вголос Для GET передаю словник у params, і requests сам кодує параметри, повторює значення зі списку й пропускає None, тож URL руками я не склеюю. Для POST з JSON використовую json дорівнює словник: він серіалізує тіло й ставить Content-Type application json. data зі словником відправляє HTML-форму, і це типова причина відповіді 400, коли API чекає JSON, а вкладені об'єкти ще й губляться. Коли сумніваюсь, що піде в мережу, дивлюсь на prepare або на response.request.
🗣 How to say it out loud For a GET I pass a dict to params, and requests encodes the values, repeats list items and drops None, so I never glue URLs by hand. For a POST with JSON I use json equals the dict: it serialises the body and sets Content-Type application json. data with a dict sends an HTML form, which is a classic cause of a 400 when the API expects JSON, and nested objects get lost too. When in doubt about what goes over the wire, I look at prepare or at response.request.
45 Як дістати тіло відповіді як JSON, перевірити статус-код і що робить raise_for_status()? How do you get the response body as JSON, assert the status code, and what does raise_for_status() do?
API-тестиAPI tests Junior ▶

Тіло. response.json() розбирає тіло в словник чи список. Якщо тіло не JSON (HTML сторінки помилки від балансувальника, порожнє тіло при 204), буде requests.exceptions.JSONDecodeError. Сирий текст — response.text, байти — response.content, заголовки — response.headers (нечутливий до регістру словник).

The body. response.json() parses the body into a dict or list. If the body is not JSON (an error page from a load balancer, an empty body on 204), you get requests.exceptions.JSONDecodeError. The raw text is response.text, the bytes response.content, the headers response.headers (a case-insensitive dict).

Статус у тесті. assert response.status_code == 201 — перевіряй конкретний очікуваний код, а не «будь-який успіх». response.ok означає лише «код менший за 400» і пропустить 200 там, де мав бути 201, чи 202 замість 200. До повідомлення варто додати тіло: assert r.status_code == 201, r.text — у звіті одразу видно, що відповів сервер. Для читабельності є http.HTTPStatus.CREATED або requests.codes.created.

Status in a test. assert response.status_code == 201 — check the specific expected code, not «any success». response.ok only means «code below 400» and lets through a 200 where a 201 was due, or 202 instead of 200. Add the body to the message: assert r.status_code == 201, r.text — the report shows at once what the server said. For readability there is http.HTTPStatus.CREATED or requests.codes.created.

raise_for_status(). Кидає requests.HTTPError для кодів 4xx і 5xx і нічого не робить для решти. Це правильний інструмент для хелперів і підготовчих кроків (створити користувача перед тестом): якщо підготовка не вдалася, тест має впасти одразу з зрозумілою причиною, а не далі з KeyError. У самій перевірці тесту краще явний assert на очікуваний код, бо негативні тести якраз чекають 4xx.

raise_for_status(). Raises requests.HTTPError for 4xx and 5xx codes and does nothing otherwise. It is the right tool for helpers and setup steps (create a user before a test): if setup failed, the test should stop right there with a clear reason rather than later with a KeyError. In the test's own check prefer an explicit assert on the expected code, because negative tests are exactly the ones expecting 4xx.

Підводні камені. requests не кидає виняток на 404 чи 500 сам: без перевірки тест спокійно піде далі. Редиректи виконуються автоматично — response.history показує проміжні відповіді, а allow_redirects=False дозволяє перевірити сам 302.

Pitfalls. requests does not raise on a 404 or 500 by itself: without a check the test happily carries on. Redirects are followed automatically — response.history shows the intermediate responses, and allow_redirects=False lets you check the 302 itself.

Перевірки відповіді в pytest
Response checks in pytest
from http import HTTPStatus

import pytest
import requests

BASE = "https://httpbin.org"

def create_fixture_data(status):
    r = requests.get(f"{BASE}/status/{status}", timeout=10)
    r.raise_for_status()  # підготовка має падати голосно
    return r

def test_json_body_and_headers():
    r = requests.get(f"{BASE}/json", timeout=10)
    assert r.status_code == HTTPStatus.OK, r.text
    assert r.headers["content-type"] == "application/json"
    assert r.json()["slideshow"]["author"] == "Yours Truly"

def test_ok_is_not_enough():
    r = requests.get(f"{BASE}/status/202", timeout=10)
    assert r.ok
    assert r.status_code == HTTPStatus.OK, f"expected 200, got {r.status_code}"

def test_setup_fails_early():
    with pytest.raises(requests.HTTPError, match="503 Server Error"):
        create_fixture_data(503)

def test_html_body_is_not_json():
    r = requests.get(f"{BASE}/html", timeout=10)
    with pytest.raises(requests.exceptions.JSONDecodeError):
        r.json()

def test_redirect_itself():
    r = requests.get(f"{BASE}/redirect-to", params={"url": "/get"}, allow_redirects=False, timeout=10)
    assert r.status_code == HTTPStatus.FOUND
    assert r.headers["Location"] == "/get"
from http import HTTPStatus

import pytest
import requests

BASE = "https://httpbin.org"

def create_fixture_data(status):
    r = requests.get(f"{BASE}/status/{status}", timeout=10)
    r.raise_for_status()  # setup must fail loudly
    return r

def test_json_body_and_headers():
    r = requests.get(f"{BASE}/json", timeout=10)
    assert r.status_code == HTTPStatus.OK, r.text
    assert r.headers["content-type"] == "application/json"
    assert r.json()["slideshow"]["author"] == "Yours Truly"

def test_ok_is_not_enough():
    r = requests.get(f"{BASE}/status/202", timeout=10)
    assert r.ok
    assert r.status_code == HTTPStatus.OK, f"expected 200, got {r.status_code}"

def test_setup_fails_early():
    with pytest.raises(requests.HTTPError, match="503 Server Error"):
        create_fixture_data(503)

def test_html_body_is_not_json():
    r = requests.get(f"{BASE}/html", timeout=10)
    with pytest.raises(requests.exceptions.JSONDecodeError):
        r.json()

def test_redirect_itself():
    r = requests.get(f"{BASE}/redirect-to", params={"url": "/get"}, allow_redirects=False, timeout=10)
    assert r.status_code == HTTPStatus.FOUND
    assert r.headers["Location"] == "/get"
Вивід
Output
$ pytest -q --tb=short test_responses.py
.F...                                                                    [100%]
=================================== FAILURES ===================================
____________________________ test_ok_is_not_enough _____________________________
test_responses.py:22: in test_ok_is_not_enough
    assert r.status_code == HTTPStatus.OK, f"expected 200, got {r.status_code}"
E   AssertionError: expected 200, got 202
E   assert 202 == <HTTPStatus.OK: 200>
E    +  where 202 = <Response [202]>.status_code
E    +  and   <HTTPStatus.OK: 200> = HTTPStatus.OK
=========================== short test summary info ============================
FAILED test_responses.py::test_ok_is_not_enough - AssertionError: expected 20...
1 failed, 4 passed in 2.46s
🔧 Розбір коду: r.headers["content-type"] працює в будь-якому регістрі. test_ok_is_not_enough падає навмисно: r.ok прийняв 202, а точна перевірка — ні, і повідомлення пояснює причину. raise_for_status у хелпері перетворив 503 на HTTPError. allow_redirects=False дозволив перевірити сам 302 і заголовок Location.
🔧 Code breakdown: r.headers["content-type"] works in any letter case. test_ok_is_not_enough fails on purpose: r.ok accepted the 202, the exact check did not, and the message explains why. raise_for_status in the helper turned the 503 into an HTTPError. allow_redirects=False made it possible to check the 302 itself and its Location header.
💡 Простими словами: requests лише приносить відповідь; вирішувати, чи вона правильна, — справа твого assert.
💡 In plain words: requests only fetches the answer; deciding whether it is right is your assert's job.
🗣 Як читати вголос Тіло беру через response.json, пам'ятаючи, що для HTML-сторінки помилки буде JSONDecodeError. Статус перевіряю точним значенням, наприклад двісті один для створення, і додаю в повідомлення текст відповіді, бо response.ok пропустить неправильний успішний код. raise_for_status кидає HTTPError на 4xx і 5xx — я використовую його в підготовчих хелперах, щоб тест падав одразу з ясною причиною, а в самих перевірках пишу явний assert, бо негативні тести чекають саме 4xx.
🗣 How to say it out loud I read the body with response.json, remembering that an HTML error page gives a JSONDecodeError. I check the status against an exact value, like two-oh-one for creation, and include the response text in the message, because response.ok lets a wrong success code through. raise_for_status raises HTTPError on 4xx and 5xx — I use it in setup helpers so the test stops immediately with a clear reason, and in the checks themselves I write an explicit assert, because negative tests expect exactly the 4xx.
46 Навіщо timeout, requests.Session і повтори через HTTPAdapter, і як їх налаштувати, щоб не приховати баги? Why use timeout, requests.Session and retries via HTTPAdapter, and how do you set them up without hiding bugs?
API-тестиAPI tests Senior ▶

timeout. За замовчуванням requests чекає відповіді вічно. Один завислий сервіс — і CI-джоба висить до глобального ліміту в годину. timeout=(3.05, 10) задає окремо час на з'єднання і на читання; це не загальний ліміт на весь запит, а на кожну паузу між байтами. Таймаут має бути в кожному виклику або в обгортці клієнта — ruff навіть має правило S113 для запитів без нього.

timeout. By default requests waits for a response forever. One hung service and the CI job hangs until the global one-hour limit. timeout=(3.05, 10) sets the connect and read times separately; it is not a limit on the whole request but on each pause between bytes. Every call, or the client wrapper, needs a timeout — ruff even has rule S113 for requests without one.

Session. Тримає пул TCP-з'єднань (keep-alive) і не робить TLS-рукостискання на кожен запит — на сотнях API-тестів це помітне прискорення. Сесія також зберігає cookies між запитами і спільні заголовки й авторизацію: session.headers.update(...). Для тестів зручно віддавати сесію з фікстури зі scope session і закривати в teardown.

Session. It keeps a pool of TCP connections (keep-alive) and skips the TLS handshake on each request — across hundreds of API tests that is a noticeable speed-up. A session also keeps cookies between requests plus shared headers and auth: session.headers.update(...). In tests it is handy to hand out a session from a session-scoped fixture and close it in teardown.

Повтори. HTTPAdapter(max_retries=Retry(...)), змонтований на сесію, повторює на рівні транспорту: total — скільки разів, backoff_factor — зростаюча пауза, status_forcelist=[502, 503, 504] — які коди вважати тимчасовими, allowed_methods — лише ідемпотентні методи. POST за замовчуванням не повторюється, і так має лишатися, якщо API не підтримує ключі ідемпотентності.

Retries. HTTPAdapter(max_retries=Retry(...)) mounted on a session retries at the transport level: total — how many times, backoff_factor — a growing pause, status_forcelist=[502, 503, 504] — which codes count as temporary, allowed_methods — idempotent methods only. POST is not retried by default, and it should stay that way unless the API supports idempotency keys.

Як не сховати баги. Повтори — для інфраструктурних збоїв стенду, а не для 500 від вашого сервісу: якщо 500 у forcelist, тест, що проходить з третьої спроби, маскує справжню помилку. Логуй кожну спробу. Врахуй, що з Retry таймаут читання теж повторюється, а після вичерпання спроб прилітає ConnectionError з ReadTimeoutError всередині, а не ReadTimeout — ловити треба базовий RequestException або правильний тип.

How not to hide bugs. Retries are for environment hiccups, not for a 500 from your own service: with 500 in the forcelist, a test passing on the third attempt masks a real error. Log every attempt. Note that with Retry read timeouts are retried too, and once attempts run out you get a ConnectionError wrapping a ReadTimeoutError, not a ReadTimeout — catch the base RequestException or the right type.

Сесія з повторами і таймаутами
A session with retries and timeouts
import logging
import time

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

logging.basicConfig(format="%(name)s: %(message)s")
logging.getLogger("urllib3.util.retry").setLevel(logging.DEBUG)  # логуємо кожну спробу

def make_session():
    retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[502, 503, 504],
                  allowed_methods=["GET", "PUT", "DELETE"])
    session = requests.Session()
    session.mount("https://", HTTPAdapter(max_retries=retry))
    session.headers.update({"Accept": "application/json"})
    return session

with make_session() as session:
    start = time.monotonic()
    try:
        session.get("https://httpbin.org/status/503", timeout=(3.05, 10))
    except requests.exceptions.RetryError as e:
        print(f"503: gave up after {time.monotonic() - start:.1f}s ->", type(e).__name__)

    r = session.post("https://httpbin.org/status/503", timeout=(3.05, 10))
    print("POST is not retried:", r.status_code)

    try:
        session.get("https://httpbin.org/delay/3", timeout=(3.05, 1))
    except requests.exceptions.RequestException as e:
        print("slow endpoint ->", type(e).__name__, "| is ReadTimeout:", isinstance(e, requests.exceptions.ReadTimeout))
import logging
import time

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

logging.basicConfig(format="%(name)s: %(message)s")
logging.getLogger("urllib3.util.retry").setLevel(logging.DEBUG)  # log every retry

def make_session():
    retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[502, 503, 504],
                  allowed_methods=["GET", "PUT", "DELETE"])
    session = requests.Session()
    session.mount("https://", HTTPAdapter(max_retries=retry))
    session.headers.update({"Accept": "application/json"})
    return session

with make_session() as session:
    start = time.monotonic()
    try:
        session.get("https://httpbin.org/status/503", timeout=(3.05, 10))
    except requests.exceptions.RetryError as e:
        print(f"503: gave up after {time.monotonic() - start:.1f}s ->", type(e).__name__)

    r = session.post("https://httpbin.org/status/503", timeout=(3.05, 10))
    print("POST is not retried:", r.status_code)

    try:
        session.get("https://httpbin.org/delay/3", timeout=(3.05, 1))
    except requests.exceptions.RequestException as e:
        print("slow endpoint ->", type(e).__name__, "| is ReadTimeout:", isinstance(e, requests.exceptions.ReadTimeout))
Вивід
Output
$ python session_retry.py 2>&1 | grep -v "connectionpool"
urllib3.util.retry: Incremented Retry for (url='/status/503'): Retry(total=2, connect=None, read=None, redirect=None, status=None)
urllib3.util.retry: Incremented Retry for (url='/status/503'): Retry(total=1, connect=None, read=None, redirect=None, status=None)
urllib3.util.retry: Incremented Retry for (url='/status/503'): Retry(total=0, connect=None, read=None, redirect=None, status=None)
urllib3.util.retry: Incremented Retry for (url='/delay/3'): Retry(total=2, connect=None, read=None, redirect=None, status=None)
urllib3.util.retry: Incremented Retry for (url='/delay/3'): Retry(total=1, connect=None, read=None, redirect=None, status=None)
urllib3.util.retry: Incremented Retry for (url='/delay/3'): Retry(total=0, connect=None, read=None, redirect=None, status=None)
503: gave up after 3.9s -> RetryError
POST is not retried: 503
slow endpoint -> ConnectionError | is ReadTimeout: False
🔧 Розбір коду: Логер urllib3.util.retry на рівні DEBUG показує кожну спробу, а паузи 0,5 → 1 → 2 с разом дають близько чотирьох секунд до RetryError. POST не входить в allowed_methods, тож 503 повертається одразу. Повільний ендпоінт після трьох повторів дає ConnectionError, а не ReadTimeout — перевірка isinstance це підтверджує.
🔧 Code breakdown: The urllib3.util.retry logger at DEBUG shows every attempt, and the 0.5 → 1 → 2 s pauses add up to about four seconds before RetryError. POST is not in allowed_methods, so the 503 comes back at once. After three retries the slow endpoint yields a ConnectionError, not a ReadTimeout — the isinstance check confirms it.
💡 Простими словами: timeout — «не чекай вічно», Session — «не набирай номер щоразу заново», Retry — «передзвони, якщо зірвалось, але не на кожну відмову».
💡 In plain words: timeout means «do not wait forever», Session means «do not redial every time», Retry means «call back if the line dropped, but not after every refusal».
🗣 Як читати вголос Таймаут ставлю завжди, бо за замовчуванням requests чекає вічно, і завислий сервіс вішає весь CI; задаю окремо з'єднання й читання. Session тримає з'єднання відкритими, зберігає cookies і спільні заголовки, тож у тестах віддаю її з сесійної фікстури. Повтори налаштовую через HTTPAdapter з Retry: лише ідемпотентні методи, лише коди 502, 503, 504 і з backoff. П'ятисоті від власного сервісу не повторюю, бо це баг, а кожну спробу логую. І пам'ятаю, що після повторів таймаут приходить як ConnectionError.
🗣 How to say it out loud I always set a timeout, because by default requests waits forever and a hung service freezes the whole CI; I set connect and read separately. A Session keeps connections open and stores cookies and shared headers, so in tests I hand it out from a session fixture. I configure retries through an HTTPAdapter with Retry: idempotent methods only, only codes 502, 503 and 504, with backoff. I do not retry a 500 from our own service, because that is a bug, and I log every attempt. And I remember that after retries a timeout arrives as a ConnectionError.
47 Як передати заголовок авторизації і завантажити файл через requests? How do you pass an authorization header and upload a file with requests?
API-тестиAPI tests Middle ▶

Bearer-токен. headers={"Authorization": f"Bearer {token}"} в окремому запиті або session.headers["Authorization"] = ... для всієї сесії. Токен беруть з фікстури, яка логіниться через API один раз на сесію, а не через UI в кожному тесті.

A Bearer token. headers={"Authorization": f"Bearer {token}"} on a single request, or session.headers["Authorization"] = ... for the whole session. The token comes from a fixture that logs in through the API once per session, not through the UI in every test.

Basic і власна схема. auth=("user", "pass") — HTTP Basic, requests сам закодує пару в Base64. Для нестандартних схем (підпис запиту, токен, що оновлюється) пишуть клас-нащадок requests.auth.AuthBase з методом __call__(self, r), який додає заголовок у кожен запит. Так логіка авторизації живе в одному місці, а тести її не бачать.

Basic and custom schemes. auth=("user", "pass") is HTTP Basic; requests encodes the pair in Base64 itself. For non-standard schemes (request signing, a refreshing token) write a subclass of requests.auth.AuthBase with a __call__(self, r) method that adds the header to every request. The auth logic then lives in one place and tests never see it.

Завантаження файла. files={"file": ("report.csv", fh, "text/csv")} — ключ відповідає назві поля форми, кортеж задає ім'я файла, вміст (відкритий у двійковому режимі файл або байти) і тип. requests сам зберe multipart/form-data з межею. Звичайні поля форми передаються разом через data=.

Uploading a file. files={"file": ("report.csv", fh, "text/csv")} — the key matches the form field name, and the tuple gives the file name, the content (a file opened in binary mode, or bytes) and the type. requests builds multipart/form-data with a boundary itself. Plain form fields go alongside through data=.

Підводні камені. Не став Content-Type вручну при завантаженні файла: без параметра boundary сервер не розбере тіло. Файл відкривай у режимі "rb" і в with, інакше дескриптор лишиться відкритим. Токени не логуй і не друкуй у звіт: маскуй їх у хуках логування. Для негативних тестів перевір і відсутній токен (401), і токен з недостатніми правами (403) — це різні баги.

Pitfalls. Do not set Content-Type by hand for a file upload: without the boundary parameter the server cannot parse the body. Open files in "rb" mode inside with, or the descriptor stays open. Never log or print tokens into a report: mask them in logging hooks. In negative tests check both a missing token (401) and a token without enough rights (403) — they are different bugs.

Три способи авторизації і multipart-завантаження
Three ways to authenticate and a multipart upload
from pathlib import Path

import requests
from requests.auth import AuthBase

BASE = "https://httpbin.org"

class TokenAuth(AuthBase):
    def __init__(self, get_token):
        self.get_token = get_token
    def __call__(self, r):
        r.headers["Authorization"] = f"Bearer {self.get_token()}"
        return r

r = requests.get(f"{BASE}/bearer", headers={"Authorization": "Bearer abc123"}, timeout=10)
print("header:", r.status_code, r.json())
print("no token:", requests.get(f"{BASE}/bearer", timeout=10).status_code)
print("basic:", requests.get(f"{BASE}/basic-auth/qa/secret", auth=("qa", "secret"), timeout=10).json())

with requests.Session() as s:
    s.auth = TokenAuth(lambda: "fresh-token")
    print("custom auth:", s.get(f"{BASE}/bearer", timeout=10).json()["token"])

report = Path("report.csv")
report.write_text("test,status\nlogin,passed\n")
with report.open("rb") as fh:
    r = requests.post(f"{BASE}/post", files={"file": ("report.csv", fh, "text/csv")},
                      data={"run_id": "42"}, timeout=10)
body = r.json()
print("uploaded:", body["files"], body["form"])
print("sent as:", r.request.headers["Content-Type"][:45] + "...")
Вивід
Output
$ python auth_upload.py
header: 200 {'authenticated': True, 'token': 'abc123'}
no token: 401
basic: {'authenticated': True, 'user': 'qa'}
custom auth: fresh-token
uploaded: {'file': 'test,status\nlogin,passed\n'} {'run_id': '42'}
sent as: multipart/form-data; boundary=75ed52d49c84deb...
🔧 Розбір коду: httpbin /bearer відповідає 200 з токеном або 401 без нього. auth=("qa", "secret") проходить Basic-перевірку. TokenAuth викликає функцію отримання токена на кожен запит — туди легко додати оновлення. У завантаженні сервер побачив і файл, і поле run_id, а заголовок з boundary requests зібрав сам.
🔧 Code breakdown: httpbin's /bearer answers 200 with the token or 401 without one. auth=("qa", "secret") passes the Basic check. TokenAuth calls the token getter on every request — an easy place to add refreshing. In the upload the server saw both the file and the run_id field, and requests built the header with the boundary by itself.
💡 Простими словами: Авторизація — це перепустка, яку треба показувати на кожному вході; files= — це конверт, у який requests сам запакує файл за правилами пошти.
💡 In plain words: Auth is a pass you show at every door; files= is an envelope requests packs by the postal rules for you.
🗣 Як читати вголос Bearer-токен передаю заголовком Authorization, найчастіше один раз на сесію, а сам токен отримую в сесійній фікстурі через API. Для Basic є параметр auth з парою логін-пароль, а для нестандартних схем пишу клас від AuthBase, який додає заголовок у кожен запит, тож тести про авторизацію не думають. Файл завантажую через files з кортежем ім'я, вміст, тип, відкриваючи файл у режимі rb, і Content-Type руками не ставлю, бо requests сам додає boundary. Токени в логах маскую.
🗣 How to say it out loud I pass a Bearer token in the Authorization header, usually once per session, and I get the token itself in a session fixture through the API. For Basic there is the auth parameter with a login-password pair, and for non-standard schemes I write an AuthBase subclass that adds the header to every request, so tests never think about auth. I upload files through files with a name, content and type tuple, opening the file in rb mode, and I do not set Content-Type by hand because requests adds the boundary. I mask tokens in logs.
48 Як валідувати схему JSON-відповіді через jsonschema або pydantic і що обрати? How do you validate a JSON response schema with jsonschema or pydantic, and which one should you pick?
API-тестиAPI tests Middle ▶

Навіщо. Перевірка окремих полів ловить те, що ти передбачив. Схема ловить решту: поле змінило тип з числа на рядок, зникло обов'язкове поле, з'явився null там, де його не мало бути, або «зайве» поле з внутрішніми даними. Це дешевий контрактний тест, який варто застосовувати до кожної відповіді.

Why. Checking individual fields catches what you foresaw. A schema catches the rest: a field changed type from number to string, a required field vanished, a null appeared where it should not, or an «extra» field leaks internal data. It is a cheap contract test worth applying to every response.

jsonschema. Схема — це JSON-документ (стандарт JSON Schema), тож її можна взяти прямо з OpenAPI-специфікації і шарити з іншими командами та мовами. Draft202012Validator(schema).iter_errors(body) повертає всі порушення з шляхом до поля, а не лише перше. additionalProperties: false забороняє незадокументовані поля, required — перелік обов'язкових, format перевіряється лише з увімкненим format_checker.

jsonschema. The schema is a JSON document (the JSON Schema standard), so you can take it straight from the OpenAPI spec and share it with other teams and languages. Draft202012Validator(schema).iter_errors(body) returns every violation with the path to the field, not just the first one. additionalProperties: false forbids undocumented fields, required lists mandatory ones, and format is checked only when a format_checker is enabled.

pydantic. Модель — це Python-клас з анотаціями типів. User.model_validate(body) перевіряє й повертає типізований об'єкт, з яким зручно працювати в тесті й IDE: user.address.city замість body["address"]["city"]. ConfigDict(extra="forbid") забороняє зайві поля, strict=True вимикає м'яке приведення типів — без нього рядок "42" тихо стане числом, а для контрактного тесту це небажано.

pydantic. The model is a Python class with type annotations. User.model_validate(body) validates and returns a typed object that is pleasant to use in the test and the IDE: user.address.city instead of body["address"]["city"]. ConfigDict(extra="forbid") forbids extra fields, and strict=True turns off lenient coercion — without it the string "42" silently becomes a number, which a contract test should not allow.

Що обрати. Якщо контракт уже описаний в OpenAPI, jsonschema дає одне джерело правди. Якщо фреймворк на Python і відповіді далі використовуються в тестах, pydantic зручніший і дає типи. Головне — не писати схему «під те, що зараз повертає сервер», бо тоді вона закріпить поточний баг.

Which to pick. If the contract is already described in OpenAPI, jsonschema gives a single source of truth. If the framework is in Python and responses are used further in tests, pydantic is more convenient and gives you types. The key is not to write the schema «to match whatever the server returns now», or it will cement the current bug.

Одна відповідь, два валідатори
One response, two validators
from jsonschema import Draft202012Validator
from pydantic import BaseModel, ConfigDict, ValidationError

body = {"id": "42", "email": "ann@example.com", "address": {"city": None}, "password_hash": "x9f"}

SCHEMA = {
    "type": "object",
    "required": ["id", "email", "address"],
    "additionalProperties": False,
    "properties": {
        "id": {"type": "integer"},
        "email": {"type": "string"},
        "address": {"type": "object", "required": ["city"],
                    "properties": {"city": {"type": "string"}}},
    },
}

for error in sorted(Draft202012Validator(SCHEMA).iter_errors(body), key=str):
    print("jsonschema:", "/".join(map(str, error.absolute_path)) or "(root)", "-", error.message)

class Address(BaseModel):
    model_config = ConfigDict(extra="forbid", strict=True)
    city: str

class User(BaseModel):
    model_config = ConfigDict(extra="forbid", strict=True)
    id: int
    email: str
    address: Address

try:
    User.model_validate(body)
except ValidationError as e:
    for err in e.errors():
        print("pydantic:", ".".join(map(str, err["loc"])), "-", err["msg"])

ok = User.model_validate({"id": 42, "email": "ann@example.com", "address": {"city": "Kyiv"}})
print("typed access:", ok.address.city)
Вивід
Output
$ python schema_checks.py
jsonschema: id - '42' is not of type 'integer'
jsonschema: (root) - Additional properties are not allowed ('password_hash' was unexpected)
jsonschema: address/city - None is not of type 'string'
pydantic: id - Input should be a valid integer
pydantic: address.city - Input should be a valid string
pydantic: password_hash - Extra inputs are not permitted
typed access: Kyiv
🔧 Розбір коду: Обидва валідатори знайшли ті самі три проблеми: id прийшов рядком, city дорівнює null, а password_hash — незадокументоване поле, яке не мало б покидати сервер. iter_errors повертає всі помилки одразу, e.errors() у pydantic — список зі шляхом loc. Після успішної валідації ok.address.city — звичайний типізований атрибут.
🔧 Code breakdown: Both validators found the same three problems: id came as a string, city is null, and password_hash is an undocumented field that should never leave the server. iter_errors returns all errors at once, and pydantic's e.errors() is a list with a loc path. After successful validation ok.address.city is an ordinary typed attribute.
💡 Простими словами: Схема — це трафарет відповіді: усе, що не влазить у контур або стирчить за ним, одразу видно.
💡 In plain words: A schema is a stencil for the response: anything that does not fit the outline or sticks out past it shows at once.
🗣 Як читати вголос Окрім перевірок конкретних значень я валідую схему кожної відповіді, бо так ловлю зміну типу, зниклі поля, неочікуваний null і витік внутрішніх полів. Якщо в команди є OpenAPI, беру jsonschema, щоб схема була одна для всіх, і вмикаю additionalProperties false. Якщо ж фреймворк на Python і з відповіддю далі працюють тести, беру pydantic з extra forbid і strict: отримую ще й типізований об'єкт. Схему пишу з контракту, а не з того, що сервер віддає сьогодні.
🗣 How to say it out loud Beyond checking specific values I validate the schema of every response, which catches a changed type, missing fields, an unexpected null and leaked internal fields. If the team has OpenAPI, I use jsonschema so there is one schema for everyone, and I turn on additionalProperties false. If the framework is in Python and tests keep working with the response, I use pydantic with extra forbid and strict: I get a typed object as a bonus. I write the schema from the contract, not from whatever the server returns today.
49 Коли в API-тестах доречні asyncio і httpx і як не перевантажити стенд паралельними запитами? When do asyncio and httpx fit in API tests, and how do you avoid flooding the environment with parallel requests?
API-тестиAPI tests Senior ▶

Що це. asyncio — кооперативна конкурентність в одному потоці: поки один запит чекає мережу, цикл подій виконує інші. httpx — HTTP-клієнт з API, схожим на requests, який має і синхронний httpx.Client, і асинхронний httpx.AsyncClient, а також підтримку HTTP/2. На відміну від requests, httpx має таймаут за замовчуванням — 5 секунд.

What it is. asyncio is cooperative concurrency in one thread: while one request waits on the network, the event loop runs others. httpx is an HTTP client with an API close to requests that offers both a synchronous httpx.Client and an asynchronous httpx.AsyncClient, plus HTTP/2 support. Unlike requests, httpx has a default timeout of 5 seconds.

Коли доречно. Коли тест мусить зробити багато незалежних запитів: наповнити стенд сотнею записів, перевірити пагінацію всіх сторінок, звірити тисячу ID, або відтворити конкурентний сценарій — дві покупки останнього товару одночасно. asyncio.gather запускає корутини разом, тож 20 запитів по секунді займуть близько секунди, а не двадцять. Для pytest потрібен плагін pytest-asyncio або anyio, щоб писати async def test_....

When it fits. When a test must make many independent requests: seeding an environment with a hundred records, checking every page of pagination, reconciling a thousand IDs, or reproducing a concurrent scenario — two purchases of the last item at the same moment. asyncio.gather runs coroutines together, so 20 one-second requests take about a second instead of twenty. pytest needs the pytest-asyncio or anyio plugin to write async def test_....

Як не перевантажити стенд. Необмежений gather на тисячу запитів — це міні-DDoS тестового середовища, від якого страждають сусідні команди, а тест отримує 429 і стає нестабільним. Обмеж конкурентність через asyncio.Semaphore або httpx.Limits(max_connections=...). return_exceptions=True у gather збирає помилки замість того, щоб перша скасувала решту.

How not to flood the environment. An unbounded gather over a thousand requests is a mini DDoS on a shared test environment that hurts neighbouring teams, while the test gets 429s and turns flaky. Limit concurrency with asyncio.Semaphore or httpx.Limits(max_connections=...). return_exceptions=True in gather collects errors instead of letting the first one cancel the rest.

Трейдофи. Асинхронний код складніше читати й налагоджувати, а одна блокувальна функція (time.sleep, requests) всередині корутини зупиняє весь цикл. Для звичайних API-тестів простіше паралелити процеси через pytest-xdist; async варто залишити для масових операцій і перевірок конкурентності.

Trade-offs. Async code is harder to read and debug, and one blocking call (time.sleep, requests) inside a coroutine stalls the whole loop. For ordinary API tests it is simpler to parallelise processes with pytest-xdist; keep async for bulk operations and concurrency checks.

Послідовно, разом і з обмеженням
Sequential, all at once, and throttled
import asyncio
import time

import httpx

URL = "https://httpbin.org/delay/1"

async def fetch(client, n, limit=None):
    if limit is None:
        return (await client.get(URL, params={"n": n})).status_code
    async with limit:  # не більше N запитів одночасно
        return (await client.get(URL, params={"n": n})).status_code

async def main():
    async with httpx.AsyncClient(timeout=15) as client:
        start = time.monotonic()
        for n in range(3):
            await fetch(client, n)
        print(f"3 sequential:  {time.monotonic() - start:.1f}s")

        start = time.monotonic()
        codes = await asyncio.gather(*(fetch(client, n) for n in range(10)))
        print(f"10 gathered:   {time.monotonic() - start:.1f}s", set(codes))

        limit = asyncio.Semaphore(5)
        start = time.monotonic()
        results = await asyncio.gather(*(fetch(client, n, limit) for n in range(10)), return_exceptions=True)
        print(f"10 with limit 5: {time.monotonic() - start:.1f}s", set(map(str, results)))

asyncio.run(main())
import asyncio
import time

import httpx

URL = "https://httpbin.org/delay/1"

async def fetch(client, n, limit=None):
    if limit is None:
        return (await client.get(URL, params={"n": n})).status_code
    async with limit:  # at most N requests in flight
        return (await client.get(URL, params={"n": n})).status_code

async def main():
    async with httpx.AsyncClient(timeout=15) as client:
        start = time.monotonic()
        for n in range(3):
            await fetch(client, n)
        print(f"3 sequential:  {time.monotonic() - start:.1f}s")

        start = time.monotonic()
        codes = await asyncio.gather(*(fetch(client, n) for n in range(10)))
        print(f"10 gathered:   {time.monotonic() - start:.1f}s", set(codes))

        limit = asyncio.Semaphore(5)
        start = time.monotonic()
        results = await asyncio.gather(*(fetch(client, n, limit) for n in range(10)), return_exceptions=True)
        print(f"10 with limit 5: {time.monotonic() - start:.1f}s", set(map(str, results)))

asyncio.run(main())
Вивід
Output
$ python async_httpx.py
3 sequential:  3.8s
10 gathered:   1.7s {200}
10 with limit 5: 2.8s {'200'}
🔧 Розбір коду: Послідовні запити додаються: три по секунді — понад три секунди. gather запускає десять одночасно, і час близький до одного запиту плюс мережеві накладні. Semaphore(5) пропускає по п'ять — дві хвилі, тобто приблизно вдвічі довше, зате стенд отримує не більше п'яти запитів разом. Точні секунди залежать від мережі до httpbin.
🔧 Code breakdown: Sequential requests add up: three one-second calls take over three seconds. gather launches ten at once, and the time is close to a single request plus network overhead. Semaphore(5) lets five through at a time — two waves, roughly twice as long, but the environment never sees more than five requests at once. The exact seconds depend on the network to httpbin.
💡 Простими словами: asyncio — один офіціант на багато столиків: поки кухня готує, він приймає інші замовлення; семафор — це скільки столиків він узагалі бере.
💡 In plain words: asyncio is one waiter serving many tables: while the kitchen cooks, he takes other orders; the semaphore is how many tables he takes on at all.
🗣 Як читати вголос asyncio дає конкурентність в одному потоці: поки запит чекає мережу, виконуються інші, а httpx має асинхронний клієнт з API як у requests і таймаутом за замовчуванням. Я беру це для масових операцій — наповнити стенд даними, пройти всі сторінки, звірити тисячі записів — і для перевірки гонок, як дві покупки останнього товару. Обов'язково обмежую паралельність семафором або лімітами клієнта, бо інакше тест сам влаштує навантаження на спільний стенд. Для звичайних тестів простіше xdist.
🗣 How to say it out loud asyncio gives concurrency in one thread: while a request waits on the network, others run, and httpx has an async client with a requests-like API and a default timeout. I use it for bulk operations — seeding the environment, walking every page, reconciling thousands of records — and for race checks like two purchases of the last item. I always cap concurrency with a semaphore or client limits, otherwise the test itself load-tests the shared environment. For ordinary tests xdist is simpler.
▶ 6 5
50 Як виглядає базовий сценарій на Playwright for Python із sync API, локаторами та expect? What does a basic Playwright for Python script look like with the sync API, locators and expect?
UI-тестиUI tests Junior ▶

Що це. Playwright for Python — офіційний порт Playwright від Microsoft з двома API: синхронним (playwright.sync_api) і асинхронним. Для тестів майже завжди беруть синхронний: код читається зверху вниз без await. Встановлення — pip install playwright і playwright install chromium, який завантажує сам браузер.

What it is. Playwright for Python is Microsoft's official Playwright port with two APIs: synchronous (playwright.sync_api) and asynchronous. Tests nearly always use the sync one: the code reads top to bottom with no await. Install it with pip install playwright and playwright install chromium, which downloads the browser itself.

Структура. with sync_playwright() as p: запускає драйвер; p.chromium.launch() — браузер (за замовчуванням без вікна); browser.new_page() — вкладку в новому ізольованому контексті. Далі page.goto(url), дії й перевірки.

Structure. with sync_playwright() as p: starts the driver; p.chromium.launch() starts a browser (headless by default); browser.new_page() opens a tab in a fresh isolated context. Then come page.goto(url), the actions and the checks.

Локатори. Рекомендовані — ті, що шукають елемент так, як його бачить користувач: page.get_by_role("button", name="Sign in"), get_by_label("Username"), get_by_text, get_by_test_id. CSS і XPath теж працюють через page.locator(...), але крихкіші. Локатор ледачий: він не шукає елемент одразу, а щоразу знаходить його заново перед дією, тому не буває «застарілого елемента».

Locators. The recommended ones find an element the way a user sees it: page.get_by_role("button", name="Sign in"), get_by_label("Username"), get_by_text, get_by_test_id. CSS and XPath work too through page.locator(...), but they are more brittle. A locator is lazy: it does not look the element up immediately but finds it afresh before every action, so there is no «stale element».

Автоочікування. Перед click чи fill Playwright сам чекає, доки елемент з'явиться, стане видимим, стабільним і доступним. expect(locator).to_have_text(...) повторює перевірку до таймауту (5 с за замовчуванням), тож елемент, що з'являється через 800 мс, перевіряється без жодного sleep.

Auto-waiting. Before click or fill Playwright itself waits until the element is attached, visible, stable and enabled. expect(locator).to_have_text(...) retries the check until the timeout (5 s by default), so an element that appears after 800 ms is checked without any sleep.

Підводні камені. locator.inner_text() і звичайний assert не чекають: вони беруть значення в ту мить, і тест стає нестабільним. Для перевірок використовуй expect.

Pitfalls. locator.inner_text() with a plain assert does not wait: it grabs the value at that instant and the test turns flaky. Use expect for checks.

login_script.py — звичайний скрипт без pytest
login_script.py — a plain script without pytest
from pathlib import Path

from playwright.sync_api import expect, sync_playwright

APP = Path("app.html").resolve().as_uri()

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto(APP)

    page.get_by_label("Username").fill("ann")
    page.get_by_label("Password").fill("secret")
    page.get_by_role("button", name="Sign in").click()

    greeting = page.get_by_test_id("greeting")
    print("right after click:", repr(greeting.inner_text()))  # тут немає очікування
    expect(greeting).to_have_text("Welcome, ann")  # повторює до 5 с
    expect(page.locator("#cart li")).to_have_count(2)
    print("after expect:", repr(greeting.inner_text()), page.locator("#cart li").all_inner_texts())
    browser.close()
from pathlib import Path

from playwright.sync_api import expect, sync_playwright

APP = Path("app.html").resolve().as_uri()

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto(APP)

    page.get_by_label("Username").fill("ann")
    page.get_by_label("Password").fill("secret")
    page.get_by_role("button", name="Sign in").click()

    greeting = page.get_by_test_id("greeting")
    print("right after click:", repr(greeting.inner_text()))  # no waiting here
    expect(greeting).to_have_text("Welcome, ann")  # retries up to 5 s
    expect(page.locator("#cart li")).to_have_count(2)
    print("after expect:", repr(greeting.inner_text()), page.locator("#cart li").all_inner_texts())
    browser.close()
Вивід
Output
$ python login_script.py
right after click: ''
after expect: 'Welcome, ann' ['Keyboard', 'Mouse']
🔧 Розбір коду: get_by_label знаходить поле за текстом <label>, get_by_role — кнопку за роллю та доступною назвою, get_by_test_id — за атрибутом data-testid. Одразу після кліку inner_text() повертає порожній рядок, бо привітання з'являється через 800 мс. expect дочекався тексту й двох елементів кошика без sleep.
🔧 Code breakdown: get_by_label finds a field by its <label> text, get_by_role a button by role and accessible name, get_by_test_id by the data-testid attribute. Right after the click inner_text() returns an empty string, because the greeting appears after 800 ms. expect waited for the text and the two cart items without any sleep.
💡 Простими словами: Локатор — це опис «як знайти», а не знайдений елемент; expect — перевірка, яка терпляче перепитує, доки не дочекається.
💡 In plain words: A locator is a description of «how to find it», not a found element; expect is a check that patiently asks again until it gets its answer.
🗣 Як читати вголос У Playwright for Python я використовую синхронний API: sync_playwright, запуск Chromium, нова сторінка й goto. Елементи шукаю локаторами, що орієнтуються на користувача: get_by_role з назвою кнопки, get_by_label для полів, get_by_test_id, а CSS лишаю для крайніх випадків. Локатори ліниві й перед кожною дією самі чекають, поки елемент стане видимим і доступним. Перевірки роблю через expect, бо вона повторює умову до таймауту, а звичайний assert по inner_text бере значення миттєво і робить тест нестабільним.
🗣 How to say it out loud In Playwright for Python I use the sync API: sync_playwright, launching Chromium, a new page and goto. I find elements with user-facing locators: get_by_role with the button name, get_by_label for fields, get_by_test_id, and I leave CSS for edge cases. Locators are lazy and before every action they wait for the element to be visible and enabled. I do checks with expect, because it retries the condition until the timeout, while a plain assert on inner_text grabs the value instantly and makes the test flaky.
51 Які фікстури дає pytest-playwright і як налаштувати браузер, base_url, трейси та скриншоти? Which fixtures does pytest-playwright provide, and how do you configure the browser, base_url, traces and screenshots?
UI-тестиUI tests Middle ▶

Фікстури. Плагін pytest-playwright дає готові фікстури: playwright і browser зі scope session (браузер запускається один раз), context і page зі scope function (кожен тест отримує чистий контекст без cookies і localStorage попереднього). Тест просто просить page: def test_login(page: Page):. Ніякого ручного запуску й закриття браузера.

Fixtures. The pytest-playwright plugin gives ready fixtures: playwright and browser with session scope (the browser starts once), context and page with function scope (every test gets a clean context without the previous one's cookies and localStorage). A test simply asks for page: def test_login(page: Page):. No manual browser start or shutdown.

Налаштування через CLI. --browser chromium --browser firefox — запуск на кількох рушіях; --headed — з вікном; --slowmo 200 — повільніше для налагодження; --base-url (плагін pytest-base-url) — тоді page.goto("/login") працює відносно стенду; --tracing retain-on-failure, --screenshot only-on-failure і --video retain-on-failure зберігають артефакти лише для впалих тестів у test-results/. Трейс відкривають командою playwright show-trace: там кожен крок, DOM, мережа й консоль.

CLI configuration. --browser chromium --browser firefox runs on several engines; --headed shows a window; --slowmo 200 slows things down for debugging; --base-url (from the pytest-base-url plugin) makes page.goto("/login") relative to the environment; --tracing retain-on-failure, --screenshot only-on-failure and --video retain-on-failure keep artifacts only for failed tests in test-results/. Open a trace with playwright show-trace: every step, the DOM, network and console are there.

Налаштування в коді. Перевизначення фікстури browser_context_args у conftest змінює параметри кожного контексту: розмір вікна, локаль, часову зону, storage_state з уже залогіненою сесією. browser_type_launch_args — параметри запуску браузера.

Configuration in code. Overriding the browser_context_args fixture in conftest changes the settings of every context: viewport, locale, time zone, a storage_state with an already logged-in session. browser_type_launch_args holds the browser launch options.

Підводні камені. Не піднімай scope фікстури page: спільна сторінка між тестами — прямий шлях до залежності від порядку. Логін через UI в кожному тесті повільний; краще один раз отримати storage_state через API і підкладати його в контекст. І yield-фікстури тут — звичайні pytest-фікстури на Python, а не фікстури Playwright Test з TypeScript.

Pitfalls. Do not widen the scope of the page fixture: a page shared between tests leads straight to order dependence. Logging in through the UI in every test is slow; get a storage_state once through the API and feed it to the context instead. And yield fixtures here are ordinary Python pytest fixtures, not TypeScript Playwright Test fixtures.

conftest.py
conftest.py
import pytest

@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    return {**browser_context_args, "viewport": {"width": 1280, "height": 720},
            "locale": "uk-UA", "timezone_id": "Europe/Kyiv"}
test_login_ui.py
test_login_ui.py
from playwright.sync_api import Page, expect

def test_successful_login(page: Page):
    page.goto("/app.html")
    page.get_by_label("Username").fill("ann")
    page.get_by_role("button", name="Sign in").click()
    expect(page.get_by_test_id("greeting")).to_have_text("Welcome, ann")

def test_context_settings(page: Page):
    page.goto("/app.html")
    assert page.evaluate("new Date('2026-07-01T12:00:00Z').getHours()") == 15  # UTC+3 влітку
    assert page.viewport_size == {"width": 1280, "height": 720}

def test_empty_username_is_rejected(page: Page):
    page.goto("/app.html")
    page.get_by_role("button", name="Sign in").click()
    expect(page.get_by_test_id("greeting")).to_have_text("Username is required", timeout=2000)
    expect(page.get_by_test_id("greeting")).to_have_text("Welcome", timeout=1500)
from playwright.sync_api import Page, expect

def test_successful_login(page: Page):
    page.goto("/app.html")
    page.get_by_label("Username").fill("ann")
    page.get_by_role("button", name="Sign in").click()
    expect(page.get_by_test_id("greeting")).to_have_text("Welcome, ann")

def test_context_settings(page: Page):
    page.goto("/app.html")
    assert page.evaluate("new Date('2026-07-01T12:00:00Z').getHours()") == 15  # UTC+3 in summer
    assert page.viewport_size == {"width": 1280, "height": 720}

def test_empty_username_is_rejected(page: Page):
    page.goto("/app.html")
    page.get_by_role("button", name="Sign in").click()
    expect(page.get_by_test_id("greeting")).to_have_text("Username is required", timeout=2000)
    expect(page.get_by_test_id("greeting")).to_have_text("Welcome", timeout=1500)
Вивід
Output
$ pytest -q --browser chromium --base-url http://127.0.0.1:8917 --tracing retain-on-failure --screenshot only-on-failure --tb=line
..F                                                                      [100%]
=================================== FAILURES ===================================
E   AssertionError: Locator expected to have text 'Welcome'
    Actual value: Username is required 
    Call log:
      - Expect "to_have_text" get_by_test_id("greeting") with timeout 1500ms
      - waiting for get_by_test_id("greeting")
        16 × locator resolved to <p data-testid="greeting">Username is required</p>
           - unexpected value "Username is required"
    
    Aria snapshot:
    - paragraph: Username is required
./test_login_ui.py:18: AssertionError: Locator expected to have text 'Welcome'
=========================== short test summary info ============================
FAILED test_login_ui.py::test_empty_username_is_rejected[chromium] - Assertio...
1 failed, 2 passed in 4.56s

$ find test-results -type f
test-results/test-login-ui-py-test-empty-username-is-rejected-chromium/test-failed-1.png
test-results/test-login-ui-py-test-empty-username-is-rejected-chromium/trace.zip
🔧 Розбір коду: Тести не запускають браузер самі — вони просять фікстуру page. browser_context_args розширює стандартні налаштування плагіна, і другий тест підтверджує часову зону та розмір вікна. Зону перевірено через годину, а не через назву: Chromium повертає для неї застарілу назву Europe/Kiev, і порівняння рядків з Europe/Kyiv падало б. Третій тест падає навмисно: для нього в test-results/ з'явилися trace.zip і скриншот, а для зелених тестів артефактів немає. Сторінку віддає локальний http.server, тому goto("/app.html") працює відносно --base-url.
🔧 Code breakdown: The tests do not start a browser themselves — they ask for the page fixture. browser_context_args extends the plugin's default settings, and the second test confirms the time zone and viewport. The zone is checked through the hour rather than its name: Chromium reports the legacy name Europe/Kiev for it, so a string comparison with Europe/Kyiv would fail. The third test fails on purpose: test-results/ now holds a trace.zip and a screenshot for it, while green tests leave no artifacts. A local http.server serves the page, so goto("/app.html") is resolved against --base-url.
💡 Простими словами: pytest-playwright бере на себе браузер і прибирання, а тобі лишається попросити page і написати сценарій.
💡 In plain words: pytest-playwright takes care of the browser and the clean-up; all you do is ask for page and write the scenario.
🗣 Як читати вголос pytest-playwright дає фікстури browser на сесію і context та page на кожен тест, тому кожен тест отримує чистий контекст без cookies попереднього, а браузер запускається один раз. Браузер, режим з вікном і base-url задаю прапорцями командного рядка, а для CI вмикаю трейс і скриншоти лише для впалих тестів — трейс потім відкриваю в show-trace. Локаль, часову зону, розмір вікна чи збережену сесію задаю, перевизначивши browser_context_args у conftest. Логін через UI в кожному тесті замінюю на storage_state.
🗣 How to say it out loud pytest-playwright gives a browser fixture per session and context and page per test, so each test gets a clean context without the previous one's cookies while the browser launches only once. I set the browser, headed mode and base-url with command-line flags, and in CI I turn on traces and screenshots only for failed tests — then I open the trace in show-trace. Locale, time zone, viewport or a saved session I set by overriding browser_context_args in conftest. I replace UI login in every test with a storage_state.
52 Як створити драйвер Selenium у Python і знайти елемент? How do you create a Selenium driver in Python and find an element?
UI-тестиUI tests Junior ▶

Драйвер. webdriver.Chrome(options=options), де options = webdriver.ChromeOptions() з аргументами на кшталт --headless=new чи --window-size=1280,800. З Selenium 4.6 вбудований Selenium Manager сам знаходить або завантажує потрібний chromedriver (а за потреби й сам браузер Chrome for Testing), тож ручне завантаження драйвера і webdriver_manager більше не потрібні. Для Firefox — webdriver.Firefox(), для віддаленого запуску в Grid — webdriver.Remote(command_executor=url, options=options). Драйвер обов'язково закривають через driver.quit(), найкраще в teardown фікстури, інакше процеси браузера накопичуються на агенті CI.

The driver. webdriver.Chrome(options=options), where options = webdriver.ChromeOptions() with arguments like --headless=new or --window-size=1280,800. Since Selenium 4.6 the built-in Selenium Manager finds or downloads the right chromedriver itself (and if needed the Chrome for Testing browser too), so manual driver downloads and webdriver_manager are no longer needed. For Firefox use webdriver.Firefox(), for a remote Grid run webdriver.Remote(command_executor=url, options=options). The driver must be closed with driver.quit(), ideally in a fixture teardown, or browser processes pile up on the CI agent.

Пошук. driver.find_element(By.ID, "username") повертає перший знайдений елемент або кидає NoSuchElementException. find_elements повертає список, порожній, якщо нічого не знайдено, — зручно перевіряти відсутність. Стратегії: By.ID, By.NAME, By.CSS_SELECTOR, By.XPATH, By.LINK_TEXT, By.TAG_NAME. Старі методи find_element_by_id видалено в Selenium 4.3.

Finding. driver.find_element(By.ID, "username") returns the first match or raises NoSuchElementException. find_elements returns a list, empty if nothing matched — handy for checking absence. The strategies are By.ID, By.NAME, By.CSS_SELECTOR, By.XPATH, By.LINK_TEXT and By.TAG_NAME. The old find_element_by_id methods were removed in Selenium 4.3.

Що обрати. Найстабільніші — ID і спеціальні атрибути для тестів ([data-testid=...] через CSS). XPath лишають для пошуку за текстом або по дереву вгору.

What to choose. The most stable are IDs and dedicated test attributes ([data-testid=...] via CSS). Keep XPath for finding by text or walking up the tree.

Підводні камені. find_element не чекає: якщо елемент з'являється асинхронно, одразу буде виняток. Знайдений WebElement може стати «застарілим» (StaleElementReferenceException), якщо сторінка перемалювала DOM, — тоді його шукають заново.

Pitfalls. find_element does not wait: if the element appears asynchronously you get an exception straight away. A found WebElement can go «stale» (StaleElementReferenceException) if the page re-rendered the DOM — then you find it again.

test_selenium_find.py
test_selenium_find.py
from pathlib import Path

import pytest
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.by import By

@pytest.fixture
def driver():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    options.add_argument("--window-size=1280,800")
    drv = webdriver.Chrome(options=options)  # Selenium Manager сам знаходить драйвер
    yield drv
    drv.quit()

def test_find_strategies(driver):
    driver.get(Path("app.html").resolve().as_uri())
    assert driver.find_element(By.ID, "username").get_attribute("name") == "username"
    assert driver.find_element(By.CSS_SELECTOR, "button[type=submit]").text == "Sign in"
    assert driver.find_element(By.XPATH, "//label[text()='Password']").get_attribute("for") == "password"
    assert driver.find_elements(By.CSS_SELECTOR, "#cart li") == []
    with pytest.raises(NoSuchElementException):
        driver.find_element(By.ID, "logout")
from pathlib import Path

import pytest
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.by import By

@pytest.fixture
def driver():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    options.add_argument("--window-size=1280,800")
    drv = webdriver.Chrome(options=options)  # Selenium Manager finds the driver
    yield drv
    drv.quit()

def test_find_strategies(driver):
    driver.get(Path("app.html").resolve().as_uri())
    assert driver.find_element(By.ID, "username").get_attribute("name") == "username"
    assert driver.find_element(By.CSS_SELECTOR, "button[type=submit]").text == "Sign in"
    assert driver.find_element(By.XPATH, "//label[text()='Password']").get_attribute("for") == "password"
    assert driver.find_elements(By.CSS_SELECTOR, "#cart li") == []
    with pytest.raises(NoSuchElementException):
        driver.find_element(By.ID, "logout")
Вивід
Output
$ pytest -q test_selenium_find.py
.                                                                        [100%]
1 passed in 0.65s
🔧 Розбір коду: Фікстура створює драйвер з опціями і закриває його в teardown після yield. Тест проходить чотирма стратегіями: By.ID, CSS за атрибутом, XPath за текстом мітки й find_elements, що повертає порожній список для кошика. Пошук неіснуючого #logout кидає NoSuchElementException.
🔧 Code breakdown: The fixture creates the driver with options and closes it in teardown after yield. The test goes through four strategies: By.ID, CSS by attribute, XPath by label text, and find_elements returning an empty list for the cart. Looking for the non-existent #logout raises NoSuchElementException.
💡 Простими словами: find_element — «дай мені його або скажи, що його немає»; find_elements — «дай усіх, кого знайдеш, навіть якщо нікого».
💡 In plain words: find_element means «give it to me or tell me it is missing»; find_elements means «give me all you find, even if that is nobody».
🗣 Як читати вголос Драйвер створюю через webdriver.Chrome з ChromeOptions, наприклад headless і розмір вікна, а драйвер з Selenium 4.6 підтягує Selenium Manager, тож webdriver_manager уже не потрібен. Створюю його в pytest-фікстурі з yield і закриваю через quit у teardown. Елементи шукаю через find_element з By: ID, CSS, XPath для тексту. find_element кидає NoSuchElementException, а find_elements повертає порожній список, і його я використовую для перевірки відсутності. Пам'ятаю, що find_element не чекає на асинхронні елементи.
🗣 How to say it out loud I create the driver with webdriver.Chrome and ChromeOptions, say headless and a window size, and since Selenium 4.6 Selenium Manager fetches the driver, so webdriver_manager is no longer needed. I create it in a pytest fixture with yield and close it with quit in teardown. I find elements with find_element and By: ID, CSS, XPath for text. find_element raises NoSuchElementException, while find_elements returns an empty list, which I use to check absence. I remember that find_element does not wait for asynchronous elements.
53 Як виглядає явне очікування в Selenium на Python і чому time.sleep() — погана практика? What does an explicit wait look like in Selenium with Python, and why is time.sleep() bad practice?
UI-тестиUI tests Middle ▶

Явне очікування. WebDriverWait(driver, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "[data-testid=greeting]"))) перевіряє умову кожні 0,5 с і повертає результат, щойно вона виконалась, або кидає TimeoutException через 10 с. Готові умови з expected_conditions: element_to_be_clickable, text_to_be_present_in_element, invisibility_of_element_located, url_contains, number_of_windows_to_be. Власна умова — будь-яка функція, що приймає драйвер: until(lambda d: len(d.find_elements(...)) == 2).

An explicit wait. WebDriverWait(driver, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "[data-testid=greeting]"))) checks the condition every 0.5 s and returns the result as soon as it holds, or raises TimeoutException after 10 s. Ready-made conditions in expected_conditions include element_to_be_clickable, text_to_be_present_in_element, invisibility_of_element_located, url_contains and number_of_windows_to_be. A custom condition is any function taking the driver: until(lambda d: len(d.find_elements(...)) == 2).

Чому sleep шкодить. Фіксована пауза завжди або задовга, або закоротка. Якщо поставити 3 с, а елемент з'являється за 0,8 с, кожен тест марнує дві секунди — на тисячі кроків це години CI. Якщо стенд під навантаженням відповів за 3,5 с, тест падає — і ти отримуєш flaky, який «лікують» ще довшим sleep. Явне очікування чекає рівно стільки, скільки треба, і має верхню межу.

Why sleep hurts. A fixed pause is always either too long or too short. Set 3 s while the element appears in 0.8 s and every test wastes two seconds — across thousands of steps that is hours of CI. If the loaded environment answered in 3.5 s, the test fails — and you get a flaky test that people «cure» with an even longer sleep. An explicit wait waits exactly as long as needed and has an upper bound.

Implicit wait. driver.implicitly_wait(10) змушує кожен find_element чекати появи елемента. Змішувати його з явними очікуваннями не рекомендують: таймаути складаються непередбачувано, а перевірка відсутності елемента щоразу чекає повний час.

Implicit wait. driver.implicitly_wait(10) makes every find_element wait for the element to appear. Mixing it with explicit waits is discouraged: timeouts add up unpredictably, and checking that an element is absent waits the full time every time.

Підводні камені. Чекай на стан, потрібний наступному кроку (клікабельність, текст), а не просто на наявність у DOM. І не загортай у WebDriverWait весь тест — лише конкретну умову.

Pitfalls. Wait for the state the next step needs (clickable, has text), not merely for presence in the DOM. And do not wrap the whole test in a WebDriverWait — only the specific condition.

sleep проти явного очікування
sleep versus an explicit wait
import time
from pathlib import Path

import pytest
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

@pytest.fixture
def driver():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    drv = webdriver.Chrome(options=options)
    drv.get(Path("app.html").resolve().as_uri())
    drv.find_element(By.ID, "username").send_keys("ann")
    drv.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
    yield drv
    drv.quit()

def test_without_wait_fails(driver):
    with pytest.raises(NoSuchElementException):
        driver.find_element(By.CSS_SELECTOR, "#cart li")

def test_fixed_sleep(driver):
    start = time.monotonic()
    time.sleep(3)
    assert driver.find_element(By.CSS_SELECTOR, "[data-testid=greeting]").text == "Welcome, ann"
    print(f"\n  sleep: {time.monotonic() - start:.1f}s")

def test_explicit_wait(driver):
    start = time.monotonic()
    wait = WebDriverWait(driver, timeout=10, poll_frequency=0.2)
    greeting = wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, "[data-testid=greeting]")))
    wait.until(lambda d: len(d.find_elements(By.CSS_SELECTOR, "#cart li")) == 2)
    assert greeting.text == "Welcome, ann"
    print(f"\n  explicit wait: {time.monotonic() - start:.1f}s")
Вивід
Output
$ pytest -q -s test_waits.py
.
  sleep: 3.0s
.
  explicit wait: 0.9s
.
3 passed in 5.62s
🔧 Розбір коду: Фікстура логіниться й віддає драйвер одразу після кліку. find_element без очікування падає, бо список кошика ще порожній. time.sleep(3) проходить, але завжди коштує три секунди. WebDriverWait з poll_frequency=0.2 повертається приблизно через 0,8 с, а лямбда-умова дочікується двох елементів кошика.
🔧 Code breakdown: The fixture logs in and hands over the driver right after the click. find_element without waiting fails because the cart list is still empty. time.sleep(3) passes but always costs three seconds. WebDriverWait with poll_frequency=0.2 returns after about 0.8 s, and the lambda condition waits for two cart items.
💡 Простими словами: sleep — «чекаю три хвилини, навіть якщо автобус уже приїхав»; явне очікування — «чекаю, доки приїде, але не довше десяти».
💡 In plain words: sleep means «I wait three minutes even if the bus has already come»; an explicit wait means «I wait until it comes, but no longer than ten».
🗣 Як читати вголос Явне очікування — це WebDriverWait з таймаутом і умовою з expected_conditions, наприклад видимість чи клікабельність елемента, або власна лямбда. Воно опитує умову і повертається, щойно вона виконалась. time.sleep шкодить двічі: коли пауза задовга, ми марнуємо час на кожному кроці, а коли закоротка, тест падає на повільному стенді й стає flaky. Implicit wait з явними не змішую, бо таймаути складаються непередбачувано. Чекаю завжди на той стан, який потрібен наступній дії.
🗣 How to say it out loud An explicit wait is a WebDriverWait with a timeout and a condition from expected_conditions, like an element being visible or clickable, or a custom lambda. It polls the condition and returns as soon as it holds. time.sleep hurts twice: when the pause is too long we waste time on every step, and when it is too short the test fails on a slow environment and becomes flaky. I do not mix implicit and explicit waits, because the timeouts add up unpredictably. I always wait for the state the next action needs.
54 Як виглядає Page Object у Python і що в ньому не можна робити? What does a Page Object look like in Python, and what must it not do?
UI-тестиUI tests Middle ▶

Що це. Page Object — клас, який інкапсулює сторінку або її компонент: зберігає локатори в одному місці й дає методи з бізнес-назвами — login(user, password), add_to_cart(item). Тест тоді читається як сценарій, а коли змінюється верстка, правка потрібна в одному класі, а не в сотні тестів.

What it is. A Page Object is a class that encapsulates a page or one of its components: it keeps the locators in one place and offers methods with business names — login(user, password), add_to_cart(item). The test then reads like a scenario, and when the markup changes, the fix goes into one class rather than a hundred tests.

Як це зробити в Python. Клас приймає page (Playwright) чи driver (Selenium) в __init__, локатори оголошує атрибутами або @property, дії — методами. Метод, що веде на іншу сторінку, повертає її об'єкт: login() повертає CartPage, і тест будує ланцюжок. Об'єкти сторінок зручно віддавати з pytest-фікстур: def login_page(page): return LoginPage(page). Спільна логіка (відкрити URL, дочекатися завантаження) йде в базовий клас.

How to do it in Python. The class takes a page (Playwright) or a driver (Selenium) in __init__, declares locators as attributes or @property, and actions as methods. A method that leads to another page returns that page's object: login() returns a CartPage, and the test builds a chain. Page objects are conveniently handed out by pytest fixtures: def login_page(page): return LoginPage(page). Shared logic (open a URL, wait for load) goes into a base class.

Чого не робити. Не клади перевірки результату в Page Object: assert належить тесту, бо лише тест знає, що очікувати в цьому сценарії. Page Object може надавати стан (greeting, items) і власні очікування готовності сторінки. Не повертай з методів сирі WebElement для кліків у тесті — інакше знову розтягнеш локатори по тестах. Не роби «божественний» клас на весь сайт: краще дрібні компоненти (хедер, модальне вікно), які складаються.

What not to do. Do not put result checks into the Page Object: an assert belongs to the test, because only the test knows what to expect in its scenario. A Page Object may expose state (greeting, items) and its own readiness waits. Do not return raw WebElements for the test to click — you would spread locators across tests again. Do not build a «god» class for the whole site: small components (header, modal) that compose are better.

Підводні камені. time.sleep усередині методів Page Object ховається від рев'ю і сповільнює всі тести разом. Очікування мають бути явними або вбудованими, як автоочікування Playwright.

Pitfalls. A time.sleep inside Page Object methods hides from review and slows every test at once. Waits must be explicit or built in, like Playwright's auto-waiting.

test_page_object.py — Playwright з фікстурою
test_page_object.py — Playwright with a fixture
from pathlib import Path

import pytest
from playwright.sync_api import Page, expect

class CartPage:
    def __init__(self, page: Page):
        self.page = page
        self.items = page.locator("#cart li")

    def item_names(self):
        expect(self.items.first).to_be_visible()  # очікування готовності, а не перевірка
        return self.items.all_inner_texts()

class LoginPage:
    URL = Path("app.html").resolve().as_uri()

    def __init__(self, page: Page):
        self.page = page
        self.username = page.get_by_label("Username")
        self.password = page.get_by_label("Password")
        self.submit = page.get_by_role("button", name="Sign in")
        self.greeting = page.get_by_test_id("greeting")

    def open(self):
        self.page.goto(self.URL)
        return self

    def login(self, user, password):
        self.username.fill(user)
        self.password.fill(password)
        self.submit.click()
        return CartPage(self.page)

@pytest.fixture
def login_page(page: Page):
    return LoginPage(page).open()

def test_user_sees_cart_after_login(login_page):
    cart = login_page.login("ann", "secret")
    expect(login_page.greeting).to_have_text("Welcome, ann")
    assert cart.item_names() == ["Keyboard", "Mouse"]
from pathlib import Path

import pytest
from playwright.sync_api import Page, expect

class CartPage:
    def __init__(self, page: Page):
        self.page = page
        self.items = page.locator("#cart li")

    def item_names(self):
        expect(self.items.first).to_be_visible()  # readiness wait, not a check
        return self.items.all_inner_texts()

class LoginPage:
    URL = Path("app.html").resolve().as_uri()

    def __init__(self, page: Page):
        self.page = page
        self.username = page.get_by_label("Username")
        self.password = page.get_by_label("Password")
        self.submit = page.get_by_role("button", name="Sign in")
        self.greeting = page.get_by_test_id("greeting")

    def open(self):
        self.page.goto(self.URL)
        return self

    def login(self, user, password):
        self.username.fill(user)
        self.password.fill(password)
        self.submit.click()
        return CartPage(self.page)

@pytest.fixture
def login_page(page: Page):
    return LoginPage(page).open()

def test_user_sees_cart_after_login(login_page):
    cart = login_page.login("ann", "secret")
    expect(login_page.greeting).to_have_text("Welcome, ann")
    assert cart.item_names() == ["Keyboard", "Mouse"]
Вивід
Output
$ pytest -q test_page_object.py
.                                                                        [100%]
1 passed in 1.87s
🔧 Розбір коду: Локатори створюються один раз у __init__, а тест їх не бачить. login повертає CartPage, тож тест читається як ланцюжок дій. Фікстура login_page отримує page від pytest-playwright і відкриває сторінку. Єдині expect і assert з очікуваними значеннями — у самому тесті.
🔧 Code breakdown: Locators are created once in __init__, and the test never sees them. login returns a CartPage, so the test reads as a chain of actions. The login_page fixture receives page from pytest-playwright and opens the page. The only expect and assert with expected values live in the test itself.
💡 Простими словами: Page Object — пульт від сторінки: кнопки з людськими назвами, а дроти всередині сховані.
💡 In plain words: A Page Object is a remote control for a page: buttons with human names, wires hidden inside.
🗣 Як читати вголос Page Object у мене — клас, що приймає page або driver, тримає локатори в одному місці й дає методи з бізнес-назвами, як login чи add_to_cart. Методи, що ведуть на іншу сторінку, повертають її об'єкт, а самі об'єкти я віддаю з фікстур. Головне правило — жодних assert в Page Object: перевірки належать тесту, а сторінка лише надає стан і чекає своєї готовності. Не віддаю тесту сирі елементи й не роблю один гігантський клас — краще дрібні компоненти, які складаються.
🗣 How to say it out loud For me a Page Object is a class that takes a page or driver, keeps the locators in one place and offers methods with business names like login or add_to_cart. Methods that lead to another page return its object, and I hand the objects out from fixtures. The main rule is no asserts in the Page Object: checks belong to the test, while the page only exposes state and waits for its own readiness. I do not give tests raw elements and I do not build one giant class — small composable components are better.
▶ 7 6
55 Як правильно читати текстовий файл і чим pathlib.Path кращий за рядкові шляхи? How do you read a text file correctly, and why is pathlib.Path better than string paths?
Дані й файлиData and files Junior ▶

Правильне читання. with open(path, encoding="utf-8") as f: — три важливі речі. with гарантує закриття файла навіть при помилці. Явне кодування потрібне, бо за замовчуванням Python бере кодування системи: на Linux це UTF-8, а на Windows часто cp1251 чи cp1252, і той самий тест із кириличними даними падає лише в колеги на Windows з UnicodeDecodeError. Великий файл читають по рядках — for line in f: — а не f.read() цілком. Для файлів з маркером BOM від Excel є кодування utf-8-sig.

Reading correctly. with open(path, encoding="utf-8") as f: — three things matter. with guarantees the file is closed even on an error. An explicit encoding is needed because by default Python uses the system encoding: UTF-8 on Linux, but often cp1251 or cp1252 on Windows, so the same test with Cyrillic data fails only for a colleague on Windows with UnicodeDecodeError. Read a big file line by line — for line in f: — not all at once with f.read(). For files with an Excel BOM there is the utf-8-sig encoding.

pathlib. Path — об'єкт шляху замість рядка. Оператор / склеює частини правильно для будь-якої ОС: Path("data") / "users.json". Є готові методи: read_text, write_text, exists, mkdir(parents=True, exist_ok=True), glob("*.json"), suffix, stem, parent. Код читається краще, ніж з os.path.join і os.listdir.

pathlib. A Path is a path object instead of a string. The / operator joins parts correctly for any OS: Path("data") / "users.json". It comes with ready methods: read_text, write_text, exists, mkdir(parents=True, exist_ok=True), glob("*.json"), suffix, stem, parent. The code reads better than with os.path.join and os.listdir.

Шляхи в тестах. Відносний шлях рахується від поточної теки процесу, а не від файла тесту: запусти pytest з іншої теки — і open("data/users.json") не знайде файл. Надійно будувати шлях від модуля: Path(__file__).parent / "data" / "users.json".

Paths in tests. A relative path is resolved from the process's current folder, not from the test file: start pytest from another folder and open("data/users.json") cannot find the file. The reliable way is to build the path from the module: Path(__file__).parent / "data" / "users.json".

Підводні камені. Режим "w" одразу обнуляє файл, навіть якщо потім нічого не записано. Бінарні файли (картинки, PDF) відкривають у "rb", і тоді кодування не вказують.

Pitfalls. Mode "w" truncates the file immediately, even if nothing gets written afterwards. Binary files (images, PDFs) are opened in "rb", and then no encoding is given.

Кодування, pathlib і шлях від модуля
Encoding, pathlib and a path relative to the module
from pathlib import Path

DATA = Path(__file__).parent / "data"
DATA.mkdir(exist_ok=True)
users = DATA / "users.txt"
users.write_text("Zoë\nŁukasz\n", encoding="utf-8")

try:
    users.read_text(encoding="cp1252")  # так може вчинити типове кодування Windows
except UnicodeDecodeError as e:
    print("cp1252:", type(e).__name__)
print("latin-1 guess:", users.read_text(encoding="latin-1").split()[0])

with users.open(encoding="utf-8") as f:
    for number, line in enumerate(f, start=1):
        print(number, line.rstrip())

print(users.name, users.suffix, users.stem, users.parent.name, users.exists())
print(sorted(p.name for p in DATA.glob("*.txt")))
from pathlib import Path

DATA = Path(__file__).parent / "data"
DATA.mkdir(exist_ok=True)
users = DATA / "users.txt"
users.write_text("Zoë\nŁukasz\n", encoding="utf-8")

try:
    users.read_text(encoding="cp1252")  # what a Windows default might do
except UnicodeDecodeError as e:
    print("cp1252:", type(e).__name__)
print("latin-1 guess:", users.read_text(encoding="latin-1").split()[0])

with users.open(encoding="utf-8") as f:
    for number, line in enumerate(f, start=1):
        print(number, line.rstrip())

print(users.name, users.suffix, users.stem, users.parent.name, users.exists())
print(sorted(p.name for p in DATA.glob("*.txt")))
Вивід
Output
$ python files_demo.py
cp1252: UnicodeDecodeError
latin-1 guess: Zoë
1 Zoë
2 Łukasz
users.txt .txt users data True
['users.txt']
🔧 Розбір коду: UTF-8 байти літери «Ł» містять байт, якого немає в cp1252, тож читання дає UnicodeDecodeError. latin-1 читає будь-які байти без помилки, але «Zoë» перетворюється на кракозябри — це гірше, бо тест мовчки працює з неправильними даними. Path(__file__).parent прив'язує теку data до файла скрипта. enumerate(f, start=1) читає файл по рядках з номерами.
🔧 Code breakdown: The UTF-8 bytes of «Ł» include a byte that cp1252 does not define, so reading raises UnicodeDecodeError. latin-1 reads any bytes without an error, but «Zoë» turns into mojibake — which is worse, because the test silently works with wrong data. Path(__file__).parent ties the data folder to the script file. enumerate(f, start=1) reads the file line by line with numbers.
💡 Простими словами: Завжди кажи Python, якою «мовою» записаний файл, і шукай файл відносно тесту, а не відносно того, звідки тебе запустили.
💡 In plain words: Always tell Python which «language» the file is written in, and look for the file relative to the test, not to wherever you were started from.
🗣 Як читати вголос Текстовий файл я відкриваю через with і завжди з encoding utf-8, бо типове кодування залежить від системи, і на Windows тест з кириличними даними може впасти або, гірше, прочитати кракозябри. Великі файли читаю по рядках. Шляхи будую через pathlib: оператор ділення склеює частини для будь-якої ОС, і є зручні методи read_text, glob, mkdir. Тестові дані шукаю від Path від __file__, бо відносний шлях рахується від теки запуску, і тест ламається, якщо pytest запустили з іншого місця.
🗣 How to say it out loud I open a text file with with and always with encoding utf-8, because the default encoding depends on the system, and on Windows a test with Cyrillic data may fail or, worse, read mojibake. I read big files line by line. I build paths with pathlib: the division operator joins parts for any OS, and there are handy methods like read_text, glob and mkdir. I locate test data from Path of __file__, because a relative path is resolved from the launch folder and the test breaks when pytest is started elsewhere.
56 Як прочитати JSON з файла і чим json.dump() відрізняється від json.dumps()? How do you read JSON from a file, and how does json.dump() differ from json.dumps()?
Дані й файлиData and files Junior ▶

Чотири функції. Літера s у кінці означає string. json.load(f) читає JSON з відкритого файла, json.loads(text) — з рядка. json.dump(obj, f) пише об'єкт у файл, json.dumps(obj) повертає рядок. Тож читання тестових даних — with open(path, encoding="utf-8") as f: data = json.load(f), а тіло відповіді, отримане як текст, — json.loads(response.text), хоча з requests простіше response.json().

Four functions. The trailing s stands for string. json.load(f) reads JSON from an open file, json.loads(text) from a string. json.dump(obj, f) writes an object to a file, json.dumps(obj) returns a string. So reading test data is with open(path, encoding="utf-8") as f: data = json.load(f), and a response body received as text is json.loads(response.text), although with requests response.json() is simpler.

Корисні параметри. indent=2 — читабельний вивід для звітів і фікстур. ensure_ascii=False — кирилиця лишається кирилицею, а не \u041e. sort_keys=True — стабільний порядок ключів, щоб дифи знімків не «стрибали». default=str — спосіб серіалізувати datetime, Decimal чи UUID, які JSON не знає.

Useful parameters. indent=2 gives readable output for reports and fixtures. ensure_ascii=False keeps Cyrillic as Cyrillic rather than \u041e. sort_keys=True gives a stable key order so snapshot diffs do not «jump». default=str is a way to serialise datetime, Decimal or UUID, which JSON does not know.

Відповідність типів. Об'єкт JSON стає dict, масив — list, true/false/null — True/False/None. Кортеж при записі стає масивом і назад повертається списком. Ключі словника в JSON завжди рядки: {1: "a"} після запису й читання стане {"1": "a"} — класична причина падіння порівняння «до і після».

Type mapping. A JSON object becomes a dict, an array a list, true/false/null become True/False/None. A tuple is written as an array and comes back as a list. Dict keys in JSON are always strings: {1: "a"} written and read back becomes {"1": "a"} — a classic cause of a failing «before and after» comparison.

Підводні камені. Числа з плаваючою крапкою втрачають точність — для грошей JSON з API розбирають з parse_float=Decimal. Поламаний JSON дає json.JSONDecodeError з номером рядка й колонки, що зручно для повідомлень про помилку в тестових даних.

Pitfalls. Floating-point numbers lose precision — for money, parse JSON from the API with parse_float=Decimal. Broken JSON raises json.JSONDecodeError with line and column numbers, which is handy for error messages about bad test data.

Запис, читання і сюрпризи з типами
Writing, reading and type surprises
import json
from datetime import date
from decimal import Decimal
from pathlib import Path

order = {"id": 1, "customer": "Zoë", "tags": ("new", "vip"), 2: "two", "created": date(2026, 9, 29)}

text = json.dumps(order, ensure_ascii=False, default=str, sort_keys=False)
print("dumps ->", text)
print("ascii ->", json.dumps({"customer": "Zoë"}))

path = Path("order.json")
with path.open("w", encoding="utf-8") as f:
    json.dump(order, f, ensure_ascii=False, indent=2, default=str)
with path.open(encoding="utf-8") as f:
    loaded = json.load(f)
print("round trip equal:", loaded == order, "| keys:", list(loaded), "| tags:", loaded["tags"])

print(json.loads('{"total": 0.1}', parse_float=Decimal))
try:
    json.loads('{"id": 1,}')
except json.JSONDecodeError as e:
    print("broken:", e)
Вивід
Output
$ python json_demo.py
dumps -> {"id": 1, "customer": "Zoë", "tags": ["new", "vip"], "2": "two", "created": "2026-09-29"}
ascii -> {"customer": "Zo\u00eb"}
round trip equal: False | keys: ['id', 'customer', 'tags', '2', 'created'] | tags: ['new', 'vip']
{'total': Decimal('0.1')}
broken: Illegal trailing comma before end of object: line 1 column 9 (char 8)
🔧 Розбір коду: dumps повертає рядок, а dump пише у файл. ensure_ascii=False зберіг літеру ë, без нього вона стала escape-послідовністю \u00eb. Після запису й читання ключ 2 став рядком "2", кортеж — списком, дата — рядком, тому порівняння з оригіналом дає False. parse_float=Decimal читає суму без похибки.
🔧 Code breakdown: dumps returns a string and dump writes to a file. ensure_ascii=False kept the letter ë; without it the letter became the escape sequence \u00eb. After writing and reading, the key 2 became the string "2", the tuple a list and the date a string, so comparing with the original gives False. parse_float=Decimal reads the amount without rounding error.
💡 Простими словами: s у кінці — «працюю з рядком», без s — «працюю з файлом».
💡 In plain words: An s at the end means «I work with a string», no s means «I work with a file».
🗣 Як читати вголос Літера s у назві означає рядок: load і dump працюють з файлом, loads і dumps — з рядком. Тестові дані читаю через json.load з файла, відкритого з utf-8, а для звітів і знімків пишу dump з indent, ensure_ascii False і sort_keys, щоб дифи були стабільні. Пам'ятаю про типи: ключі в JSON завжди рядки, кортеж повернеться списком, а дату чи Decimal треба серіалізувати через default. Для грошей з API беру parse_float Decimal.
🗣 How to say it out loud The letter s in the name means string: load and dump work with a file, loads and dumps with a string. I read test data with json.load from a file opened as utf-8, and for reports and snapshots I write dump with indent, ensure_ascii False and sort_keys so diffs stay stable. I keep the types in mind: JSON keys are always strings, a tuple comes back as a list, and a date or Decimal needs serialising through default. For money from an API I use parse_float Decimal.
57 Як тримати тестові дані в CSV чи YAML і підключати їх до parametrize? How do you keep test data in CSV or YAML and feed it into parametrize?
Дані й файлиData and files Middle ▶

Навіщо виносити дані. Коли випадків десятки, а їх готують аналітики чи мануальні тестувальники, таблиця читається краще за Python-код. Тест лишається один, а нові випадки додають без програмування. Популярні формати — CSV для плоских таблиць і YAML для вкладених структур.

Why move data out. When there are dozens of cases, prepared by analysts or manual testers, a table reads better than Python code. There is still one test, and new cases are added without programming. The popular formats are CSV for flat tables and YAML for nested structures.

CSV. csv.DictReader повертає кожен рядок як словник за заголовками. Файл відкривають з newline="" (так радить документація модуля csv) і з явним кодуванням. Усі значення приходять рядками: "200", "true", порожній рядок замість відсутнього значення — перетворення типів треба робити явно.

CSV. csv.DictReader returns each row as a dict keyed by the headers. Open the file with newline="" (as the csv module docs advise) and an explicit encoding. Every value arrives as a string: "200", "true", an empty string instead of a missing value — type conversion must be explicit.

YAML. Пакет PyYAML: завжди yaml.safe_load, а не yaml.load з небезпечним завантажувачем, бо той може створювати довільні Python-об'єкти з файла. YAML сам розпізнає числа, булеві значення й списки, але має пастки: no, off і yes у YAML 1.1 (саме його реалізує PyYAML) стають булевими, тож код країни Норвегії NO перетвориться на False. Рядки, схожі на числа, як "0123", беруть у лапки.

YAML. The PyYAML package: always yaml.safe_load, never yaml.load with an unsafe loader, because that can build arbitrary Python objects from the file. YAML recognises numbers, booleans and lists itself, but has traps: no, off and yes in YAML 1.1 (which PyYAML implements) become booleans, so Norway's country code NO turns into False. Strings that look like numbers, like "0123", go in quotes.

Підключення до pytest. Функція-завантажувач повертає список pytest.param(..., id=...), і його передають у parametrize. id беруть з колонки case, щоб у звіті був зміст, а не номер рядка. Дані читаються під час збирання тестів, тому помилка у файлі проявиться як помилка збирання — це краще, ніж тихий пропуск.

Wiring into pytest. A loader function returns a list of pytest.param(..., id=...), which goes into parametrize. Take the id from a case column so the report shows meaning rather than a row number. The data is read at collection time, so a mistake in the file shows up as a collection error — better than a silent skip.

data/login_cases.csv
data/login_cases.csv
case,username,password,expected_status
valid user,ann,secret,200
wrong password,ann,nope,401
empty username,,secret,400
data/countries.yaml
data/countries.yaml
- case: ukraine
  code: UA
  vat: 20
- case: norway unquoted
  code: NO
  vat: 25
- case: norway quoted
  code: "NO"
  vat: 25
test_data_driven.py
test_data_driven.py
import csv
from pathlib import Path

import pytest
import yaml

DATA = Path(__file__).parent / "data"

def login(username, password):
    if not username:
        return 400
    return 200 if password == "secret" else 401

def csv_cases(name):
    with open(DATA / name, newline="", encoding="utf-8") as f:
        return [pytest.param(row, id=row["case"]) for row in csv.DictReader(f)]

def yaml_cases(name):
    with open(DATA / name, encoding="utf-8") as f:
        return [pytest.param(item, id=item["case"]) for item in yaml.safe_load(f)]

@pytest.mark.parametrize("row", csv_cases("login_cases.csv"))
def test_login(row):
    assert login(row["username"], row["password"]) == int(row["expected_status"])

@pytest.mark.parametrize("country", yaml_cases("countries.yaml"))
def test_country_code_is_text(country):
    assert isinstance(country["code"], str), f"code parsed as {country['code']!r}"
Вивід
Output
$ pytest -v --tb=line test_data_driven.py | grep -E "PASSED|FAILED|^E|passed|failed"
test_data_driven.py::test_login[valid user] PASSED                       [ 16%]
test_data_driven.py::test_login[wrong password] PASSED                   [ 33%]
test_data_driven.py::test_login[empty username] PASSED                   [ 50%]
test_data_driven.py::test_country_code_is_text[ukraine] PASSED           [ 66%]
test_data_driven.py::test_country_code_is_text[norway unquoted] FAILED   [ 83%]
test_data_driven.py::test_country_code_is_text[norway quoted] PASSED     [100%]
E   AssertionError: code parsed as False
FAILED test_data_driven.py::test_country_code_is_text[norway unquoted] - Asse...
========================= 1 failed, 5 passed in 0.04s ==========================
🔧 Розбір коду: csv.DictReader дає словники, а int(row["expected_status"]) явно перетворює рядок. id тестів узято з колонки case. Випадок «norway unquoted» падає: safe_load прочитав NO без лапок як False, а в лапках — як рядок.
🔧 Code breakdown: csv.DictReader yields dicts, and int(row["expected_status"]) converts the string explicitly. Test ids come from the case column. The «norway unquoted» case fails: safe_load read the unquoted NO as False, and the quoted one as a string.
💡 Простими словами: Дані — у таблиці, логіка — в одному тесті; а в YAML усе, що схоже на «так/ні» чи число, бери в лапки.
💡 In plain words: Data in a table, logic in one test; and in YAML, quote anything that looks like yes/no or a number.
🗣 Як читати вголос Коли випадків багато, я виношу їх у CSV або YAML і пишу функцію, що повертає список pytest.param з id з колонки case, а потім передаю його в parametrize. CSV читаю через DictReader з newline порожнім рядком і utf-8 і пам'ятаю, що всі значення там рядки. YAML читаю тільки через safe_load і знаю про пастку YAML 1.1: NO без лапок стає False, тож коди країн і рядки з нулями на початку беру в лапки. Помилка в даних падає при збиранні, а не губиться.
🗣 How to say it out loud When there are many cases I move them into CSV or YAML and write a function that returns a list of pytest.param with ids from the case column, then pass it to parametrize. I read CSV with DictReader, newline as an empty string and utf-8, and remember every value is a string. I read YAML only with safe_load and know the YAML 1.1 trap: an unquoted NO becomes False, so I quote country codes and strings with leading zeros. A mistake in the data fails at collection instead of getting lost.
58 Як прочитати змінну середовища зі значенням за замовчуванням і організувати конфігурацію стенду? How do you read an environment variable with a default, and how do you organise environment configuration?
Дані й файлиData and files Junior ▶

Читання. os.environ.get("BASE_URL", "http://localhost:8080") повертає значення або дефолт. os.getenv — те саме. os.environ["API_TOKEN"] кидає KeyError, якщо змінної немає, — це правильна поведінка для обов'язкових секретів: краще впасти на старті з назвою змінної, ніж отримати 401 у сотні тестів.

Reading. os.environ.get("BASE_URL", "http://localhost:8080") returns the value or the default. os.getenv is the same. os.environ["API_TOKEN"] raises KeyError when the variable is missing — the right behaviour for mandatory secrets: better to fail at start-up with the variable's name than to get a 401 in a hundred tests.

Типи. Значення завжди рядок. "0" і "false" — непорожні, тобто істинні, тож bool(os.getenv("HEADLESS")) — помилка. Булеві й числові значення розбирають явно: os.getenv("HEADLESS", "true").lower() in {"1", "true", "yes"}, int(os.getenv("WORKERS", "4")).

Types. The value is always a string. "0" and "false" are non-empty, hence truthy, so bool(os.getenv("HEADLESS")) is a bug. Parse booleans and numbers explicitly: os.getenv("HEADLESS", "true").lower() in {"1", "true", "yes"}, int(os.getenv("WORKERS", "4")).

Організація. Уся конфігурація читається в одному місці — модуль config.py або сесійна фікстура — і перетворюється на об'єкт з типами, наприклад frozen dataclass чи pydantic-settings. Тести не звертаються до os.environ напряму. Стенд вибирають однією змінною (TEST_ENV=staging), а секрети приходять з CI (GitHub Secrets) або локального файла .env, який не комітять і завантажують через python-dotenv.

Organisation. All configuration is read in one place — a config.py module or a session fixture — and turned into a typed object, such as a frozen dataclass or pydantic-settings. Tests never touch os.environ directly. The environment is picked by one variable (TEST_ENV=staging), and secrets come from CI (GitHub Secrets) or a local .env file that is never committed and is loaded with python-dotenv.

Підводні камені. Змінна, прочитана на рівні модуля при імпорті, не побачить monkeypatch.setenv у тесті — читай конфігурацію у функції чи фікстурі. Не друкуй конфігурацію цілком у лог, бо разом з нею туди потраплять токени.

Pitfalls. A variable read at module level on import will not see a monkeypatch.setenv in a test — read configuration inside a function or fixture. Never dump the whole configuration into a log, because the tokens go along with it.

config.py — одне місце для налаштувань стенду
config.py — one place for environment settings
import os
from dataclasses import dataclass

STANDS = {"local": "http://localhost:8080", "staging": "https://staging.shop.test"}

@dataclass(frozen=True)
class Settings:
    base_url: str
    headless: bool
    workers: int
    api_token: str

    def __repr__(self):
        return f"Settings(base_url={self.base_url!r}, headless={self.headless}, workers={self.workers}, api_token='***')"

def env_bool(name, default):
    return os.getenv(name, default).strip().lower() in {"1", "true", "yes", "on"}

def load_settings():
    return Settings(
        base_url=STANDS[os.getenv("TEST_ENV", "local")],
        headless=env_bool("HEADLESS", "true"),
        workers=int(os.getenv("WORKERS", "4")),
        api_token=os.environ["API_TOKEN"],
    )

if __name__ == "__main__":
    print("bool('false') is", bool("false"))
    try:
        load_settings()
    except KeyError as e:
        print("missing required variable:", e)
    os.environ.update(API_TOKEN="s3cr3t", TEST_ENV="staging", HEADLESS="false")
    print(load_settings())
Вивід
Output
$ python config.py
bool('false') is True
missing required variable: 'API_TOKEN'
Settings(base_url='https://staging.shop.test', headless=False, workers=4, api_token='***')
🔧 Розбір коду: os.getenv з дефолтом — для необов'язкових значень, os.environ["API_TOKEN"] — для обов'язкового секрету, і його відсутність дає зрозумілий KeyError. env_bool розбирає рядок явно, бо bool("false") дорівнює True. Власний __repr__ маскує токен, щоб налаштування можна було безпечно логувати.
🔧 Code breakdown: os.getenv with a default is for optional values, os.environ["API_TOKEN"] for a mandatory secret, whose absence gives a clear KeyError. env_bool parses the string explicitly, because bool("false") is True. A custom __repr__ masks the token so the settings can be logged safely.
💡 Простими словами: Змінна середовища — завжди рядок; обов'язкове читай через квадратні дужки, необов'язкове — через get з дефолтом.
💡 In plain words: An environment variable is always a string; read the mandatory ones with square brackets and the optional ones with get and a default.
🗣 Як читати вголос Необов'язкову змінну читаю через os.getenv з дефолтом, а обов'язковий секрет — через os.environ з квадратними дужками, щоб прогін падав одразу з назвою змінної. Значення завжди рядки, тому булеві розбираю явно: рядок «false» істинний. Усю конфігурацію збираю в одному місці — модулі config або сесійній фікстурі — в незмінний об'єкт з типами, стенд вибираю однією змінною, секрети беру з CI або з .env, якого немає в git. І не логую токени.
🗣 How to say it out loud I read an optional variable with os.getenv and a default, and a mandatory secret with os.environ and square brackets so the run fails at once with the variable's name. Values are always strings, so I parse booleans explicitly: the string «false» is truthy. I gather all configuration in one place — a config module or a session fixture — into an immutable typed object, pick the environment with one variable, and take secrets from CI or from a .env that is not in git. And I never log tokens.
59 Чому в тестовому фреймворку варто використовувати logging, а не print, і як його налаштувати? Why should a test framework use logging instead of print, and how do you set it up?
Дані й файлиData and files Middle ▶

Проблема print. print не має рівнів, часу, назви модуля і не вимикається без правки коду. pytest перехоплює stdout, тож повідомлення з'являються лише у звіті про падіння, та й то без структури.

The trouble with print. print has no levels, no timestamps, no module name, and cannot be turned off without editing code. pytest captures stdout, so messages appear only in a failure report, and even then without structure.

Як влаштований logging. Кожен модуль створює свій логер: log = logging.getLogger(__name__). Рівні: DEBUG — деталі на кшталт тіл запитів, INFO — кроки сценарію, WARNING — підозрілі, але не фатальні речі (повтор запиту), ERROR — збої. Налаштування робиться один раз у точці входу, а не в модулях: формат, рівень, куди писати (консоль, файл). Логування з параметрами — log.info("created user %s", user_id) — форматує рядок лише якщо повідомлення справді буде записане.

How logging works. Each module creates its own logger: log = logging.getLogger(__name__). Levels: DEBUG for details like request bodies, INFO for scenario steps, WARNING for suspicious but non-fatal things (a retried request), ERROR for failures. Configuration happens once at the entry point, not in modules: format, level, destination (console, file). Logging with arguments — log.info("created user %s", user_id) — formats the string only if the message is actually written.

Інтеграція з pytest. pytest сам збирає записи logging і показує секцію «Captured log call» для впалих тестів. log_cli = true і log_cli_level = INFO у конфігурації виводять логи в реальному часі, log_file — у файл для артефактів CI. Фікстура caplog дозволяє перевірити, що код залогував попередження.

pytest integration. pytest collects logging records itself and shows a «Captured log call» section for failed tests. log_cli = true and log_cli_level = INFO in the config stream logs live, and log_file writes them to a file for CI artifacts. The caplog fixture lets you check that code logged a warning.

Підводні камені. logging.basicConfig нічого не робить, якщо кореневий логер уже налаштований (а pytest його налаштовує), — тому в тестах рівнем керують через конфігурацію pytest або caplog.set_level. Сторонні бібліотеки (urllib3) на DEBUG дуже балакучі — їхній рівень піднімають окремо. І жодних токенів і паролів у логах: маскуй їх у своїх хелперах.

Pitfalls. logging.basicConfig does nothing if the root logger is already configured (and pytest configures it), so in tests the level is controlled through pytest config or caplog.set_level. Third-party libraries (urllib3) are very chatty at DEBUG — raise their level separately. And no tokens or passwords in logs: mask them in your helpers.

api_client.py
api_client.py
import logging

log = logging.getLogger(__name__)

def create_user(name, attempt_limit=2):
    for attempt in range(1, attempt_limit + 1):
        if attempt < attempt_limit:
            log.warning("create_user %s: attempt %d got 503, retrying", name, attempt)
            continue
        log.info("created user %s", name)
        return {"name": name}
test_logging.py і pytest.ini
test_logging.py and pytest.ini
import logging

from api_client import create_user

def test_create_user_logs_retry(caplog):
    caplog.set_level(logging.INFO, logger="api_client")
    assert create_user("ann") == {"name": "ann"}
    assert [r.levelname for r in caplog.records] == ["WARNING", "INFO"]
    assert "retrying" in caplog.text

def test_failure_shows_captured_log():
    create_user("bob")
    assert False, "look at the captured log below"
pytest.ini
pytest.ini
[pytest]
log_level = INFO
log_format = %(asctime)s %(levelname)-7s %(name)s: %(message)s
log_date_format = %H:%M:%S
Вивід
Output
$ pytest -q --tb=no test_logging.py
.F                                                                       [100%]
1 failed, 1 passed in 0.01s

$ pytest -q test_logging.py::test_failure_shows_captured_log   (only the log section)
------------------------------ Captured log call -------------------------------
hh:mm:ss WARNING api_client: create_user bob: attempt 1 got 503, retrying
hh:mm:ss INFO    api_client: created user bob
=========================== short test summary info ============================
🔧 Розбір коду: Модуль пише в свій логер api_client з параметрами %s і %d. caplog.records дає список записів з рівнями, caplog.text — увесь текст. Для впалого тесту pytest показав секцію «Captured log call» з форматом з pytest.ini; час у виводі замінено на hh:mm:ss, щоб він не змінювався від запуску до запуску.
🔧 Code breakdown: The module writes to its own api_client logger with %s and %d arguments. caplog.records is a list of records with levels, caplog.text is all the text. For the failed test pytest showed a «Captured log call» section in the format from pytest.ini; the time in the output was replaced with hh:mm:ss so it does not change between runs.
💡 Простими словами: print — крик у порожнечу; logging — записи в журналі з датою, автором і позначкою важливості.
💡 In plain words: print is shouting into the void; logging is entries in a journal with a date, an author and an importance mark.
🗣 Як читати вголос У фреймворку я використовую logging замість print, бо він має рівні, час і назву модуля, і його можна вмикати та вимикати конфігурацією. У кожному модулі створюю логер через getLogger від __name__, пишу з параметрами через відсоток, а налаштовую все в одному місці. pytest сам показує захоплені логи для впалих тестів, log_cli виводить їх наживо, а log_file зберігає як артефакт CI. caplog дозволяє перевірити, що код попередив про повтор. Балакучі бібліотеки приглушую, а секрети маскую.
🗣 How to say it out loud In a framework I use logging instead of print, because it has levels, timestamps and module names and can be switched on and off by configuration. In each module I create a logger with getLogger of __name__, log with percent-style arguments, and configure everything in one place. pytest itself shows captured logs for failed tests, log_cli streams them live, and log_file keeps them as a CI artifact. caplog lets me check that code warned about a retry. I quieten chatty libraries and mask secrets.
60 Як працювати з datetime і часовими зонами через zoneinfo, щоб тести не ламалися двічі на рік? How do you work with datetime and time zones via zoneinfo so tests do not break twice a year?
Дані й файлиData and files Middle ▶

Naive і aware. datetime.now() повертає «наївний» час без зони — просто цифри на годиннику машини. datetime.now(timezone.utc) — «обізнаний» момент з зоною. Порівнювати naive з aware не можна — буде TypeError. Правило для тестів: усередині працюємо з aware-часом в UTC, у зону користувача переводимо лише для відображення.

Naive and aware. datetime.now() returns a «naive» time with no zone — just the digits on the machine's clock. datetime.now(timezone.utc) is an «aware» instant with a zone. Comparing naive with aware raises TypeError. The rule for tests: internally work with aware times in UTC, and convert to the user's zone only for display.

zoneinfo. Стандартний модуль з Python 3.9 з базою IANA: ZoneInfo("Europe/Kyiv"). На Linux і macOS база системна, а на Windows потрібен пакет tzdata. dt.astimezone(ZoneInfo(...)) переводить момент у іншу зону. Перехід на літній час zoneinfo враховує сам: у ніч переходу доба триває 23 або 25 годин, а «додати 24 години» і «наступного дня о тій самій годині» дають різний результат.

zoneinfo. A standard module since Python 3.9 with the IANA database: ZoneInfo("Europe/Kyiv"). On Linux and macOS the database comes from the system, while Windows needs the tzdata package. dt.astimezone(ZoneInfo(...)) converts an instant into another zone. zoneinfo handles daylight saving itself: on the night of the switch a day lasts 23 or 25 hours, and «add 24 hours» and «same time next day» give different results.

Парсинг з API. datetime.fromisoformat("2026-03-29T01:30:00Z") з Python 3.11 розуміє суфікс Z і більшість варіантів ISO 8601. Порівнюй моменти, а не рядки: "2026-03-29T03:30:00+03:00" і "2026-03-29T00:30:00Z" — той самий момент, хоча рядки різні.

Parsing from an API. datetime.fromisoformat("2026-03-29T01:30:00Z") understands the Z suffix and most ISO 8601 variants since Python 3.11. Compare instants, not strings: "2026-03-29T03:30:00+03:00" and "2026-03-29T00:30:00Z" are the same moment although the strings differ.

Стабільні тести. Тест, що залежить від «зараз», падає в ніч переходу, 31-го числа чи опівночі в CI, який працює в UTC. Час передають у функцію параметром або заморожують бібліотеками freezegun чи time-machine. Для вимірювання тривалості беруть time.monotonic(), а не різницю datetime.now(), бо системний годинник може стрибнути.

Stable tests. A test that depends on «now» fails on the night of the switch, on the 31st, or at midnight in a CI that runs in UTC. Pass the time into the function as a parameter or freeze it with freezegun or time-machine. Measure durations with time.monotonic(), not a difference of datetime.now(), because the system clock can jump.

Перехід на літній час у Києві
The daylight saving switch in Kyiv
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

KYIV = ZoneInfo("Europe/Kyiv")

before = datetime(2026, 3, 28, 12, 0, tzinfo=KYIV)
plus_24h = (before.astimezone(timezone.utc) + timedelta(hours=24)).astimezone(KYIV)
next_day = before + timedelta(days=1)  # арифметика «за настінним годинником»
print("before:  ", before.isoformat())
print("+24h:    ", plus_24h.isoformat())
print("next day:", next_day.isoformat(), "| real hours between:", (next_day - before.astimezone(timezone.utc)) / timedelta(hours=1))

a = datetime.fromisoformat("2026-03-29T03:30:00+03:00")
b = datetime.fromisoformat("2026-03-29T00:30:00Z")
print("same instant:", a == b, "| same string:", a.isoformat() == b.isoformat())

try:
    datetime.now() < datetime.now(timezone.utc)
except TypeError as e:
    print("TypeError:", e)
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

KYIV = ZoneInfo("Europe/Kyiv")

before = datetime(2026, 3, 28, 12, 0, tzinfo=KYIV)
plus_24h = (before.astimezone(timezone.utc) + timedelta(hours=24)).astimezone(KYIV)
next_day = before + timedelta(days=1)  # wall-clock arithmetic
print("before:  ", before.isoformat())
print("+24h:    ", plus_24h.isoformat())
print("next day:", next_day.isoformat(), "| real hours between:", (next_day - before.astimezone(timezone.utc)) / timedelta(hours=1))

a = datetime.fromisoformat("2026-03-29T03:30:00+03:00")
b = datetime.fromisoformat("2026-03-29T00:30:00Z")
print("same instant:", a == b, "| same string:", a.isoformat() == b.isoformat())

try:
    datetime.now() < datetime.now(timezone.utc)
except TypeError as e:
    print("TypeError:", e)
Вивід
Output
$ python time_zones.py
before:   2026-03-28T12:00:00+02:00
+24h:     2026-03-29T13:00:00+03:00
next day: 2026-03-29T12:00:00+03:00 | real hours between: 23.0
same instant: True | same string: False
TypeError: can't compare offset-naive and offset-aware datetimes
🔧 Розбір коду: У ніч на 29 березня 2026 Київ переходить з UTC+2 на UTC+3. Додавання 24 годин через UTC дає 13:00 наступного дня, а + timedelta(days=1) до aware-часу рахує за настінним годинником — 12:00, і між ними лише 23 реальні години. Два ISO-рядки з різними зсувами — той самий момент. Порівняння naive з aware кидає TypeError.
🔧 Code breakdown: On the night of 29 March 2026 Kyiv moves from UTC+2 to UTC+3. Adding 24 hours via UTC gives 13:00 the next day, while + timedelta(days=1) on an aware time works by the wall clock — 12:00, with only 23 real hours in between. Two ISO strings with different offsets are the same instant. Comparing naive with aware raises TypeError.
💡 Простими словами: Зберігай і порівнюй у UTC, показуй у зоні користувача, а «зараз» у тестах передавай параметром.
💡 In plain words: Store and compare in UTC, display in the user's zone, and pass «now» into tests as a parameter.
🗣 Як читати вголос Я розрізняю наївний datetime без зони і обізнаний з зоною, і в тестах працюю лише з обізнаним часом в UTC, бо порівняння наївного з обізнаним дає TypeError, а наївний час залежить від машини. Зони беру з zoneinfo, на Windows додаю tzdata. Пам'ятаю про перехід на літній час: плюс доба і плюс двадцять чотири години в цю ніч різні. Рядки з API розбираю через fromisoformat і порівнюю моменти, а не рядки. «Зараз» передаю параметром або заморожую через freezegun чи time-machine.
🗣 How to say it out loud I separate a naive datetime without a zone from an aware one with a zone, and in tests I use only aware UTC times, because comparing naive with aware raises TypeError and naive time depends on the machine. I take zones from zoneinfo and add tzdata on Windows. I remember daylight saving: plus one day and plus twenty-four hours differ on that night. I parse API strings with fromisoformat and compare instants, not strings. I pass «now» as a parameter or freeze it with freezegun or time-machine.
▶ 8 6
61 Що таке віртуальне середовище, як зафіксувати версії залежностей і навіщо це тестовому проєкту? What is a virtual environment, how do you pin dependency versions, and why does a test project need it?
Середовище й CIEnvironment and CI Junior ▶

venv. python -m venv .venv створює ізольовану копію інтерпретатора з власною текою пакетів. Після source .venv/bin/activate (на Windows — .venv\Scripts\activate) команди python і pip працюють усередині неї. Так проєкти з різними версіями pytest чи Selenium не заважають один одному і системному Python. Теку .venv не комітять — її додають у .gitignore.

venv. python -m venv .venv creates an isolated copy of the interpreter with its own package folder. After source .venv/bin/activate (on Windows .venv\Scripts\activate) the python and pip commands work inside it. Projects with different versions of pytest or Selenium then do not interfere with each other or with the system Python. The .venv folder is never committed — it goes into .gitignore.

Встановлення й фіксація. pip install -r requirements.txt ставить усе зі списку. Точні версії записують через ==: requests==2.32.3. pip freeze > requirements.txt знімає всі встановлені пакети разом з транзитивними залежностями (urllib3, idna, certifi), тобто фіксує все дерево. Сучасніший підхід — окремо вказати прямі залежності з діапазонами в pyproject.toml, а lock-файл з точними версіями генерувати інструментом: pip-tools (pip-compile), uv lock чи Poetry.

Installing and pinning. pip install -r requirements.txt installs everything on the list. Exact versions are written with ==: requests==2.32.3. pip freeze > requirements.txt captures every installed package along with the transitive dependencies (urllib3, idna, certifi), i.e. pins the whole tree. The more modern approach is to list direct dependencies with ranges in pyproject.toml and generate a lock file with exact versions using a tool: pip-tools (pip-compile), uv lock or Poetry.

Навіщо тестам. Без фіксації сьогоднішній зелений прогін завтра стане червоним без жодної зміни коду: вийшла нова мінорна версія pytest-плагіна чи Selenium, і CI зламався посеред релізу. З фіксацією збірка відтворювана: у CI, у колеги і через пів року ставляться ті самі версії. Оновлення тоді роблять свідомо, окремим pull request, наприклад через Dependabot чи Renovate.

Why tests need it. Without pinning, today's green run turns red tomorrow with no code change: a new minor version of a pytest plugin or Selenium came out and CI broke in the middle of a release. With pinning the build is reproducible: CI, a colleague and you in six months install the same versions. Upgrades then happen deliberately, in a separate pull request, for example via Dependabot or Renovate.

Підводні камені. pip freeze у «забрудненому» середовищі потягне у файл усе зайве, тому його запускають у чистому venv. pip install без активованого venv ставить пакети глобально. А --require-hashes додатково захищає від підміни пакета.

Pitfalls. pip freeze in a «dirty» environment drags everything extra into the file, so run it in a clean venv. pip install without an activated venv installs packages globally. And --require-hashes additionally protects against a swapped package.

Чисте середовище, одна пряма залежність і freeze
A clean environment, one direct dependency and freeze
python -m venv .venv
.venv/bin/pip install -q requests==2.32.3
.venv/bin/pip freeze > requirements.txt
cat requirements.txt
.venv/bin/pip install -q -r requirements.txt && echo "reinstall from the lock: ok"
Вивід
Output
$ python -m venv .venv
$ .venv/bin/pip install -q requests==2.32.3
$ .venv/bin/pip freeze > requirements.txt && cat requirements.txt
certifi==2026.7.22
charset-normalizer==3.5.1
idna==3.20
requests==2.32.3
urllib3==2.8.0
$ .venv/bin/pip install -q -r requirements.txt
reinstall from the lock: ok
🔧 Розбір коду: Ми встановили лише requests, а pip freeze записав п'ять пакетів: з ним прийшли certifi, charset-normalizer, idna і urllib3. Саме транзитивні залежності найчастіше ламають збірку, тому lock-файл фіксує все дерево. Повторне встановлення з файла в CI дає ті самі версії.
🔧 Code breakdown: We installed only requests, yet pip freeze recorded five packages: certifi, charset-normalizer, idna and urllib3 came along with it. Transitive dependencies are what most often break a build, so the lock file pins the whole tree. Reinstalling from the file in CI yields the same versions.
💡 Простими словами: venv — окрема кімната для кожного проєкту, а lock-файл — точний список покупок, щоб щоразу купувати те саме.
💡 In plain words: A venv is a separate room for each project, and a lock file is an exact shopping list so you buy the same things every time.
🗣 Як читати вголос Віртуальне середовище — це ізольований Python з власними пакетами, створюю його через python -m venv, і проєкти з різними версіями бібліотек не заважають один одному. Залежності ставлю з requirements.txt, а версії фіксую точно, разом з транзитивними: freeze у чистому середовищі або, краще, lock-файл через pip-tools чи uv. Для тестового проєкту це критично, бо інакше новий реліз плагіна зламає CI без жодної зміни в коді. Оновлюю залежності свідомо, окремими pull request.
🗣 How to say it out loud A virtual environment is an isolated Python with its own packages; I create it with python -m venv, and projects with different library versions stop interfering. I install dependencies from requirements.txt and pin exact versions, including transitive ones: freeze in a clean environment or, better, a lock file from pip-tools or uv. For a test project this is critical, because otherwise a new plugin release breaks CI without any code change. I upgrade dependencies deliberately, in separate pull requests.
62 Що таке pyproject.toml, як структурувати тестовий фреймворк як пакет і навіщо блок if __name__ == "__main__"? What is pyproject.toml, how do you structure a test framework as a package, and why the if __name__ == "__main__" block?
Середовище й CIEnvironment and CI Senior ▶

pyproject.toml. Єдиний файл конфігурації проєкту за PEP 518 і PEP 621. [build-system] каже, чим збирати пакет; [project] — назву, версію, requires-python, залежності й необов'язкові групи (ui, dev); [project.scripts] — консольні команди. Там же живуть налаштування інструментів: [tool.pytest.ini_options], [tool.ruff], [tool.mypy]. Він замінює setup.py, setup.cfg і розкидані конфіги.

pyproject.toml. The single project configuration file under PEP 518 and PEP 621. [build-system] says what builds the package; [project] gives the name, version, requires-python, dependencies and optional groups (ui, dev); [project.scripts] declares console commands. Tool settings live there too: [tool.pytest.ini_options], [tool.ruff], [tool.mypy]. It replaces setup.py, setup.cfg and scattered config files.

Структура фреймворку. Код фреймворку — окремий пакет у src/ (клієнти API, Page Object, моделі, фабрики даних, конфігурація), тести — у tests/ з підтеками за шарами (api, ui, contract) і своїми conftest.py. Пакет ставлять у venv через pip install -e ".[dev,ui]", і тести імпортують його як звичайну бібліотеку: from shop_qa.clients import UsersClient. src-layout не дає тестам випадково імпортувати код з поточної теки замість встановленого пакета, а редагований режим підхоплює зміни без перевстановлення.

Framework layout. The framework code is a separate package under src/ (API clients, Page Objects, models, data factories, configuration), and tests live in tests/ with subfolders per layer (api, ui, contract) and their own conftest.py. The package is installed into the venv with pip install -e ".[dev,ui]", and tests import it like any library: from shop_qa.clients import UsersClient. The src layout stops tests from accidentally importing code from the current folder instead of the installed package, and editable mode picks up changes without reinstalling.

__name__. Коли файл запускають як скрипт (python file.py або python -m pkg.module), його __name__ дорівнює "__main__"; коли імпортують — назві модуля. Блок if __name__ == "__main__": виконується лише при запуску. Так утиліта засіву тестових даних працює і як команда, і як модуль, який pytest-фікстура імпортує без побічних ефектів. Для multiprocessing на Windows і macOS цей захист обов'язковий.

__name__. When a file runs as a script (python file.py or python -m pkg.module), its __name__ is "__main__"; when imported, it is the module name. The if __name__ == "__main__": block runs only on direct execution. That way a test-data seeding utility works both as a command and as a module a pytest fixture imports with no side effects. For multiprocessing on Windows and macOS this guard is mandatory.

Трейдофи. Пакет з pyproject — трохи більше церемонії, ніж «теки з файлами», зате зникають хаки з sys.path, фреймворк можна версіонувати й перевикористати в кількох репозиторіях.

Trade-offs. A package with pyproject is a bit more ceremony than «folders of files», but sys.path hacks disappear and the framework can be versioned and reused across repositories.

Структура репозиторію
Repository layout
shop-qa/
├── pyproject.toml
├── src/shop_qa/
│   ├── __init__.py
│   ├── clients/          # API-клієнти поверх requests.Session
│   ├── pages/            # Page Object-и
│   ├── models.py         # pydantic-моделі відповідей
│   └── seed.py           # засів тестових даних, він же CLI-команда
└── tests/
    ├── conftest.py       # налаштування, сесія, токен
    ├── api/
    └── ui/               # conftest.py з browser_context_args
shop-qa/
├── pyproject.toml
├── src/shop_qa/
│   ├── __init__.py
│   ├── clients/          # API clients over requests.Session
│   ├── pages/            # Page Objects
│   ├── models.py         # pydantic response models
│   └── seed.py           # test data seeding, also a CLI command
└── tests/
    ├── conftest.py       # settings, session, auth token
    ├── api/
    └── ui/               # conftest.py with browser_context_args
pyproject.toml
pyproject.toml
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"

[project]
name = "shop-qa"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["requests>=2.32", "pydantic>=2.7"]

[project.optional-dependencies]
ui = ["pytest-playwright>=0.5"]
dev = ["pytest>=8", "ruff", "mypy"]

[project.scripts]
shop-qa-seed = "shop_qa.seed:main"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
src/shop_qa/seed.py
src/shop_qa/seed.py
import sys

print(f"loading {__file__.rsplit('/', 1)[-1]}, __name__ = {__name__!r}")

def seed(env: str) -> list[str]:
    return [f"{env}-user-{n}" for n in range(1, 3)]

def main() -> None:
    env = sys.argv[1] if len(sys.argv) > 1 else "local"
    print("seeded:", seed(env))

if __name__ == "__main__":
    main()
Вивід
Output
$ pip install --no-deps -e .   # done
$ python -m shop_qa.seed staging
loading seed.py, __name__ = '__main__'
seeded: ['staging-user-1', 'staging-user-2']
$ python -c "import shop_qa.seed"
loading seed.py, __name__ = 'shop_qa.seed'
$ shop-qa-seed ci
loading seed.py, __name__ = 'shop_qa.seed'
seeded: ['ci-user-1', 'ci-user-2']
🔧 Розбір коду: Після pip install -e . пакет імпортується з будь-якої теки, а [project.scripts] створив команду shop-qa-seed. При запуску через -m __name__ дорівнює "__main__", і main() виконується. При імпорті __name__ — "shop_qa.seed", тож засіву немає. Консольна команда викликає main() напряму. --no-deps тут лише пришвидшує приклад; у реальному проєкті залежності ставляться разом з пакетом.
🔧 Code breakdown: After pip install -e . the package imports from any folder, and [project.scripts] created the shop-qa-seed command. Run through -m, __name__ is "__main__" and main() runs. On import __name__ is "shop_qa.seed", so nothing gets seeded. The console command calls main() directly. --no-deps only speeds up the example; in a real project dependencies install together with the package.
💡 Простими словами: pyproject.toml — паспорт проєкту, src/ — сам фреймворк, tests/ — його користувачі, а __main__ — табличка «виконується лише при прямому запуску».
💡 In plain words: pyproject.toml is the project's passport, src/ is the framework, tests/ are its users, and __main__ is a sign saying «runs only when started directly».
🗣 Як читати вголос pyproject.toml — це єдиний файл проєкту: система збирання, метадані, залежності з групами на кшталт ui і dev, консольні команди й налаштування pytest, ruff і mypy. Фреймворк я роблю пакетом у src: клієнти API, Page Object, моделі й фабрики, а тести лежать окремо в tests з conftest по шарах. Пакет ставлю через pip install -e, і тести імпортують його без хаків з sys.path. Блок if __name__ дорівнює __main__ дозволяє модулю бути і командою, і бібліотекою без побічних ефектів при імпорті.
🗣 How to say it out loud pyproject.toml is the single project file: build system, metadata, dependencies with groups like ui and dev, console commands, and settings for pytest, ruff and mypy. I build the framework as a package in src: API clients, Page Objects, models and factories, while tests sit separately in tests with a conftest per layer. I install the package with pip install -e, and tests import it without sys.path hacks. The if __name__ equals __main__ block lets a module be both a command and a library with no side effects on import.
63 Що таке PEP 8, які домовленості щодо імен і які інструменти якості коду стандартні для Python? What is PEP 8, what are the naming conventions, and which code quality tools are standard for Python?
Середовище й CIEnvironment and CI Junior ▶

PEP 8. Офіційний стиль коду Python: відступ у 4 пробіли, рядок до 79 символів (команди часто домовляються про 100–120), два порожні рядки між функціями верхнього рівня, імпорти на початку файла групами — стандартна бібліотека, сторонні пакети, свій код. Порівняння з None через is, без голого except:.

PEP 8. Python's official style guide: 4-space indentation, lines up to 79 characters (teams often agree on 100–120), two blank lines between top-level functions, imports at the top grouped as standard library, third-party packages, your own code. Compare with None using is, no bare except:.

Імена. Функції, методи, змінні та модулі — snake_case (create_user, test_login_with_expired_token). Класи — PascalCase (LoginPage, ApiError). Константи — UPPER_SNAKE_CASE (BASE_URL, DEFAULT_TIMEOUT). Підкреслення на початку (_parse) означає «внутрішнє, не для зовнішнього використання». Назва тесту має описувати поведінку, а не номер кейсу: test_checkout_rejects_expired_card краще за test_case_17.

Names. Functions, methods, variables and modules use snake_case (create_user, test_login_with_expired_token). Classes use PascalCase (LoginPage, ApiError). Constants use UPPER_SNAKE_CASE (BASE_URL, DEFAULT_TIMEOUT). A leading underscore (_parse) means «internal, not for outside use». A test name should describe behaviour, not a case number: test_checkout_rejects_expired_card beats test_case_17.

Інструменти. Лінтер ruff — дуже швидкий, написаний на Rust, замінює flake8, isort, pyupgrade і десятки плагінів; вміє виправляти частину проблем сам (ruff check --fix). Форматер black або сумісний з ним ruff format знімає суперечки про стиль: код форматується однаково в усіх. mypy перевіряє типи. Запускають їх у pre-commit і в CI, щоб рев'ю було про логіку, а не про пробіли.

Tools. The ruff linter is very fast, written in Rust, and replaces flake8, isort, pyupgrade and dozens of plugins; it can fix some problems itself (ruff check --fix). The black formatter, or the compatible ruff format, ends style debates: code is formatted the same for everyone. mypy checks types. They run in pre-commit and in CI so that reviews are about logic, not spaces.

Корисні правила ruff для тестового коду. B006 — змінюване значення за замовчуванням, E711 — порівняння з None через ==, E722 — голий except, S113 — запит без таймауту, F401 — невикористаний імпорт, PT — набір правил саме для pytest.

Useful ruff rules for test code. B006 — a mutable default argument, E711 — comparing to None with ==, E722 — a bare except, S113 — a request without a timeout, F401 — an unused import, PT — a rule set specifically for pytest.

bad_helpers.py — усе, що зловить ruff
bad_helpers.py — everything ruff will catch
import os
import requests

def CreateUser(name, roles=[]):
    try:
        r = requests.post("https://shop.test/users", json={"name": name, "roles": roles})
    except:
        return None
    if r.json().get("id") == None:
        return None
    return r.json()
Вивід
Output
$ ruff check --select E,F,B,N,S113 bad_helpers.py
bad_helpers.py:1:8: F401 [*] 'os' imported but unused
bad_helpers.py:4:5: N802 Function name 'CreateUser' should be lowercase
bad_helpers.py:4:28: B006 Do not use mutable data structures for argument defaults
bad_helpers.py:6:13: S113 Probable use of 'requests' call without timeout
bad_helpers.py:6:89: E501 Line too long (89 > 88)
bad_helpers.py:7:5: E722 Do not use bare 'except'
bad_helpers.py:9:30: E711 Comparison to 'None' should be 'cond is None'
Found 7 errors.
[*] 1 fixable with the '--fix' option (2 hidden fixes can be enabled with the '--unsafe-fixes' option).
🔧 Розбір коду: Ruff знайшов сім проблем у дев'яти рядках: невикористаний os (F401), ім'я функції не в snake_case (N802), змінюваний дефолт (B006), запит без таймауту (S113), задовгий рядок за типового ліміту 88 (E501), голий except (E722) і == None (E711). Ключ --select вмикає набори правил; у проєкті їх записують у [tool.ruff.lint] у pyproject.toml. Ruff бере імена у зворотні апострофи, у виводі вище вони замінені на звичайні.
🔧 Code breakdown: Ruff found seven problems in nine lines: the unused os (F401), a function name not in snake_case (N802), a mutable default (B006), a request without a timeout (S113), a line over the default limit of 88 (E501), a bare except (E722) and == None (E711). The --select flag enables rule sets; in a project they go into [tool.ruff.lint] in pyproject.toml. Ruff wraps names in back quotes; in the output above they were replaced with plain apostrophes.
💡 Простими словами: PEP 8 — правила дорожнього руху для коду, а ruff і black — камери й автопілот, які стежать за ними замість людей.
💡 In plain words: PEP 8 is the highway code for code, and ruff and black are the cameras and the autopilot that enforce it instead of people.
🗣 Як читати вголос PEP 8 — це офіційний стиль Python: чотири пробіли, обмеження довжини рядка, порядок імпортів, is None замість дорівнює None. Функції й змінні пишу в snake_case, класи в PascalCase, константи великими літерами, а назви тестів описують поведінку. З інструментів беру ruff як лінтер, бо він швидкий і замінює flake8 з плагінами, black або ruff format для форматування і mypy для типів. Усе це запускаю в pre-commit і CI, щоб рев'ю було про логіку, а не про пробіли.
🗣 How to say it out loud PEP 8 is Python's official style: four spaces, a line length limit, import order, is None instead of equals None. I write functions and variables in snake_case, classes in PascalCase, constants in capitals, and test names describe behaviour. For tools I use ruff as the linter, because it is fast and replaces flake8 with its plugins, black or ruff format for formatting, and mypy for types. I run all of it in pre-commit and CI so reviews are about logic, not whitespace.
64 Що таке type hints, що робить mypy і чи перевіряє Python типи під час виконання? What are type hints, what does mypy do, and does Python check types at run time?
Середовище й CIEnvironment and CI Middle ▶

Type hints. Анотації типів у сигнатурах і змінних: def get_user(user_id: int) -> User | None:. Для колекцій — list[str], dict[str, int], для необов'язкового значення — str | None (синтаксис з Python 3.10), для словника з фіксованими ключами — TypedDict, для «будь-чого з методом» — Protocol. Python їх не перевіряє під час виконання: get_user("42") запуститься, анотація — лише документація для людей і інструментів.

Type hints. Type annotations in signatures and variables: def get_user(user_id: int) -> User | None:. For collections use list[str], dict[str, int]; for an optional value str | None (syntax since Python 3.10); for a dict with fixed keys TypedDict; for «anything with this method» Protocol. Python does not check them at run time: get_user("42") runs, and the annotation is only documentation for people and tools.

mypy. Статичний аналізатор, який читає анотації і знаходить помилки без запуску коду: виклик з неправильним типом, метод на значенні, що може бути None, неіснуючий атрибут, пропущений return. У тестовому фреймворку він ловить ті помилки, які інакше проявилися б лише в рідкісній гілці через тиждень. strict = true вмикає найсуворіший режим, але в наявний проєкт його вводять поступово: спершу нові модулі.

mypy. A static analyser that reads annotations and finds errors without running the code: a call with the wrong type, a method on a value that may be None, a non-existent attribute, a missing return. In a test framework it catches mistakes that would otherwise surface only in a rare branch a week later. strict = true enables the strictest mode, but in an existing project it is introduced gradually: new modules first.

Навіщо QA. Анотований клієнт API й моделі дають автодоповнення в IDE, а рефакторинг (перейменування поля моделі) mypy перевірить у всіх тестах разом. Перевірка під час виконання — окрема задача, для неї є pydantic.

Why QA cares. An annotated API client and models give IDE autocompletion, and a refactoring (renaming a model field) is checked by mypy across all tests at once. Run-time checking is a separate job, and pydantic handles it.

Підводні камені. Any вимикає перевірку, і один Any у базовому хелпері «заражає» все, що з нього виходить. Бібліотеки без анотацій потребують пакетів types-... (наприклад, types-requests), інакше mypy попередить про відсутні стаби. # type: ignore пишуть лише з кодом помилки й поясненням.

Pitfalls. Any switches checking off, and one Any in a base helper «infects» everything that flows out of it. Libraries without annotations need types-... packages (for example types-requests), otherwise mypy warns about missing stubs. Write # type: ignore only with an error code and an explanation.

typed_client.py
typed_client.py
from typing import TypedDict

class UserJson(TypedDict):
    id: int
    email: str
    nickname: str | None

def find_user(users: list[UserJson], user_id: int) -> UserJson | None:
    return next((u for u in users if u["id"] == user_id), None)

def display_name(user: UserJson) -> str:
    return user["nickname"].upper()

users: list[UserJson] = [{"id": 1, "email": "ann@example.com", "nickname": None}]
found = find_user(users, "1")
print(found["email"])
print("runtime does not care:", find_user(users, "1"))
Вивід
Output
$ python typed_client.py
TypeError: 'NoneType' object is not subscriptable

$ mypy typed_client.py
typed_client.py:12: error: Item "None" of "str | None" has no attribute "upper"  [union-attr]
typed_client.py:15: error: Argument 2 to "find_user" has incompatible type "str"; expected "int"  [arg-type]
typed_client.py:16: error: Value of type "UserJson | None" is not indexable  [index]
typed_client.py:17: error: Argument 2 to "find_user" has incompatible type "str"; expected "int"  [arg-type]
Found 4 errors in 1 file (checked 1 source file)
🔧 Розбір коду: Під час виконання Python спокійно приймає рядок "1" замість числа — пошук просто нічого не знаходить, і found["email"] падає з TypeError на None. mypy знаходить чотири помилки до запуску: виклик upper() на значенні, що може бути None, неправильний тип аргументу в обох викликах find_user і звернення до ключа в результаті, який може бути None.
🔧 Code breakdown: At run time Python happily accepts the string "1" instead of a number — the search just finds nothing, and found["email"] fails with a TypeError on None. mypy finds four errors before running: calling upper() on a value that may be None, the wrong argument type in both find_user calls, and indexing a result that may be None.
💡 Простими словами: Анотації — це підписи на коробках; Python їх не читає, а mypy читає й сварить, якщо в коробку з написом «int» поклали рядок.
💡 In plain words: Annotations are labels on boxes; Python does not read them, while mypy does and complains when a string goes into a box labelled «int».
🗣 Як читати вголос Type hints — це анотації типів у сигнатурах: list від str, str або None, TypedDict для JSON з відомими ключами. Python під час виконання їх не перевіряє, тож виклик з рядком замість числа запуститься і впаде десь далі. mypy читає анотації статично й до запуску знаходить неправильні аргументи, методи на значенні, що може бути None, і неіснуючі атрибути. У фреймворку я анотую клієнти й моделі, вмикаю strict поступово, ставлю stubs для бібліотек, а для перевірки під час виконання беру pydantic.
🗣 How to say it out loud Type hints are type annotations in signatures: list of str, str or None, TypedDict for JSON with known keys. Python does not check them at run time, so a call with a string instead of a number runs and fails somewhere later. mypy reads the annotations statically and before running finds wrong arguments, methods on values that may be None, and missing attributes. In a framework I annotate clients and models, enable strict gradually, install stubs for libraries, and use pydantic for run-time validation.
65 Що таке GIL і що обрати для CPU-важкої та для I/O-роботи: потоки, процеси чи asyncio? What is the GIL, and what should you pick for CPU-heavy and for I/O work: threads, processes or asyncio?
Середовище й CIEnvironment and CI Senior ▶

GIL. Global Interpreter Lock у CPython — блокування, через яке в одному процесі Python-байткод у будь-який момент виконує лише один потік. Він спрощує керування пам'яттю інтерпретатора, але означає, що потоки не пришвидшують обчислення на чистому Python. GIL відпускається під час очікування вводу-виводу (мережа, диск, time.sleep) і в багатьох C-розширеннях. З Python 3.13 існує окрема збірка без GIL (free-threaded, PEP 703): у 3.13 експериментальна, у 3.14 офіційно підтримувана, але не типова.

The GIL. The Global Interpreter Lock in CPython is a lock that lets only one thread in a process execute Python bytecode at any moment. It simplifies the interpreter's memory management but means threads do not speed up pure-Python computation. The GIL is released while waiting on I/O (network, disk, time.sleep) and in many C extensions. Since Python 3.13 there is a separate GIL-free build (free-threaded, PEP 703): experimental in 3.13, officially supported in 3.14, but not the default.

I/O-робота. API-тести, UI-тести, завантаження файлів — це здебільшого очікування. Потоки (ThreadPoolExecutor) тут працюють добре, бо під час очікування GIL вільний. asyncio робить те саме в одному потоці й масштабується до тисяч одночасних з'єднань, але потребує асинхронних бібліотек (httpx, aiohttp) і async-коду наскрізь.

I/O work. API tests, UI tests and file downloads are mostly waiting. Threads (ThreadPoolExecutor) work well here, because the GIL is free while waiting. asyncio does the same in one thread and scales to thousands of simultaneous connections, but needs async libraries (httpx, aiohttp) and async code all the way through.

CPU-робота. Генерація великих наборів даних, хешування, обробка зображень, парсинг гігабайтних логів — тут потрібні процеси (ProcessPoolExecutor, multiprocessing): кожен має свій інтерпретатор і свій GIL, тож ядра працюють паралельно. Ціна — запуск процесів і серіалізація аргументів і результатів через pickle.

CPU work. Generating big data sets, hashing, image processing, parsing gigabyte logs — these need processes (ProcessPoolExecutor, multiprocessing): each has its own interpreter and its own GIL, so the cores work in parallel. The price is process start-up and serialising arguments and results through pickle.

У контексті тестів. pytest-xdist паралелить саме процесами, тому він обходить GIL і для CPU-частини тестів. Для масових HTTP-викликів усередині одного тесту — потоки або asyncio. Потоки не роблять код потокобезпечним: спільний список чи лічильник без блокування (threading.Lock) дає гонки навіть з GIL, бо += — не атомарна операція.

In the context of tests. pytest-xdist parallelises with processes, so it sidesteps the GIL for CPU-bound test work too. For bulk HTTP calls inside one test use threads or asyncio. Threads do not make code thread-safe: a shared list or counter without a lock (threading.Lock) races even with the GIL, because += is not atomic.

Та сама робота трьома способами
The same work three ways
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor

def cpu_task(n: int) -> int:
    return sum(i * i for i in range(n))

def io_task(_: int) -> None:
    time.sleep(0.5)  # імітує мережевий виклик

def timed(label, pool_cls, func, jobs, arg):
    start = time.perf_counter()
    if pool_cls is None:
        for _ in range(jobs):
            func(arg)
    else:
        with pool_cls(max_workers=jobs) as pool:
            list(pool.map(func, [arg] * jobs))
    print(f"{label:<22}{time.perf_counter() - start:5.2f}s")

if __name__ == "__main__":  # обов'язково для пулу процесів
    timed("cpu, sequential", None, cpu_task, 4, 3_000_000)
    timed("cpu, 4 threads", ThreadPoolExecutor, cpu_task, 4, 3_000_000)
    timed("cpu, 4 processes", ProcessPoolExecutor, cpu_task, 4, 3_000_000)
    timed("io, sequential", None, io_task, 4, 0)
    timed("io, 4 threads", ThreadPoolExecutor, io_task, 4, 0)
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor

def cpu_task(n: int) -> int:
    return sum(i * i for i in range(n))

def io_task(_: int) -> None:
    time.sleep(0.5)  # stands in for a network call

def timed(label, pool_cls, func, jobs, arg):
    start = time.perf_counter()
    if pool_cls is None:
        for _ in range(jobs):
            func(arg)
    else:
        with pool_cls(max_workers=jobs) as pool:
            list(pool.map(func, [arg] * jobs))
    print(f"{label:<22}{time.perf_counter() - start:5.2f}s")

if __name__ == "__main__":  # required for process pools
    timed("cpu, sequential", None, cpu_task, 4, 3_000_000)
    timed("cpu, 4 threads", ThreadPoolExecutor, cpu_task, 4, 3_000_000)
    timed("cpu, 4 processes", ProcessPoolExecutor, cpu_task, 4, 3_000_000)
    timed("io, sequential", None, io_task, 4, 0)
    timed("io, 4 threads", ThreadPoolExecutor, io_task, 4, 0)
Вивід
Output
$ python gil_demo.py
cpu, sequential        0.55s
cpu, 4 threads         1.29s
cpu, 4 processes       0.19s
io, sequential         2.00s
io, 4 threads          0.50s
🔧 Розбір коду: Для CPU-задачі чотири потоки навіть повільніші за послідовне виконання — GIL пускає до байткоду один потік за раз, а перемикання між потоками коштує часу. Чотири процеси дають помітне прискорення, бо кожен має власний GIL. Для задачі з очікуванням потоки скорочують час з двох секунд до пів секунди. Блок if __name__ == "__main__" потрібен, бо дочірні процеси імпортують цей модуль повторно.
🔧 Code breakdown: For the CPU task four threads are even slower than sequential execution — the GIL lets one thread at a time run bytecode, and switching between threads costs time. Four processes give a clear speed-up because each has its own GIL. For the waiting task threads cut the time from two seconds to half a second. The if __name__ == "__main__" block is needed because child processes import this module again.
💡 Простими словами: GIL — один мікрофон на всю кімнату: чекати відповіді можуть усі разом, а говорити — лише по черзі; хочеш говорити паралельно — бери кілька кімнат, тобто процесів.
💡 In plain words: The GIL is one microphone for the whole room: everyone can wait for answers together, but only one can talk at a time; to talk in parallel you need several rooms, that is, processes.
🗣 Як читати вголос GIL у CPython дозволяє лише одному потоку виконувати Python-байткод у процесі, тому для обчислень потоки не дають прискорення, а для очікування мережі й диска дають, бо GIL тоді відпускається. Тож API й UI-тести, де ми здебільшого чекаємо, добре паралеляться потоками або asyncio, а важкі обчислення — генерацію даних чи парсинг великих логів — я виношу в процеси. pytest-xdist працює процесами. Знаю про free-threaded збірку з 3.13 і пам'ятаю, що GIL не робить код потокобезпечним.
🗣 How to say it out loud The GIL in CPython lets only one thread per process execute Python bytecode, so threads give no speed-up for computation but do help while waiting on network and disk, because the GIL is released then. So API and UI tests, which mostly wait, parallelise well with threads or asyncio, while heavy computation — data generation or parsing big logs — I move into processes. pytest-xdist uses processes. I know about the free-threaded build since 3.13 and remember that the GIL does not make code thread-safe.
66 Як побудувати CI для Python-тестів у GitHub Actions з матрицею версій, кешем і звітами? How do you build CI for Python tests in GitHub Actions with a version matrix, caching and reports?
Середовище й CIEnvironment and CI Senior ▶

Каркас. Workflow у .github/workflows/tests.yml запускається на pull_request і push у main, а нічна регресія — за розкладом schedule. Спершу окрема швидка джоба лінтера (ruff, mypy), потім тести. Кроки: actions/checkout, actions/setup-python з cache: pip (кеш прив'язується до хешу requirements), встановлення залежностей, playwright install --with-deps chromium для UI, запуск pytest.

The skeleton. A workflow in .github/workflows/tests.yml runs on pull_request and push to main, plus a nightly regression on a schedule. First a separate fast linter job (ruff, mypy), then tests. The steps: actions/checkout, actions/setup-python with cache: pip (the cache is keyed on the requirements hash), installing dependencies, playwright install --with-deps chromium for UI, running pytest.

Матриця. strategy.matrix розмножує джобу: версії Python 3.11–3.13, ОС, браузери. exclude прибирає зайві комбінації, include додає окремі. fail-fast: false не скасовує решту комбінацій після першого падіння — інакше не видно, чи баг лише на одній версії. Змінні матриці доступні як ${{ matrix.python-version }}.

The matrix. strategy.matrix multiplies the job: Python versions 3.11–3.13, operating systems, browsers. exclude drops unwanted combinations and include adds individual ones. fail-fast: false does not cancel the other combinations after the first failure — otherwise you cannot tell whether a bug hits only one version. Matrix values are available as ${{ matrix.python-version }}.

Звіти й артефакти. pytest пише --junitxml і HTML чи Allure-результати, трейси Playwright. Крок actions/upload-artifact з if: always() зберігає їх навіть після падіння — саме тоді вони й потрібні. Секрети (токени стенду) — лише через secrets, ніколи в коді. concurrency скасовує застарілі запуски на тій самій гілці, а timeout-minutes обмежує завислу джобу.

Reports and artifacts. pytest writes --junitxml and HTML or Allure results, plus Playwright traces. An actions/upload-artifact step with if: always() keeps them even after a failure — which is exactly when you need them. Secrets (environment tokens) come only through secrets, never in code. concurrency cancels outdated runs on the same branch, and timeout-minutes caps a hung job.

Трейдофи. Кожна вісь матриці множить час і вартість CI. Повну матрицю запускають уночі, а на pull request — мінімальну: одну версію Python і smoke-набір через -m smoke. Шардинг великого набору між джобами дає пришвидшення, коли одного агента з -n auto уже мало.

Trade-offs. Every matrix axis multiplies CI time and cost. Run the full matrix at night, and a minimal one on pull requests: one Python version and the smoke set via -m smoke. Sharding a big suite across jobs pays off once a single agent with -n auto is no longer enough.

.github/workflows/tests.yml
.github/workflows/tests.yml
name: tests
on:
  pull_request:
  push:
    branches: [main]
  schedule:
    - cron: "0 2 * * *"
concurrency:
  group: tests-${{ github.ref }}
  cancel-in-progress: true
jobs:
  api:
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
        python-version: ["3.11", "3.12", "3.13"]
        exclude:
          - os: windows-latest
            python-version: "3.11"
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip
      - run: pip install -r requirements.txt
      - run: pytest -n auto -m "not ui" --junitxml=reports/junit.xml --html=reports/report.html
        env:
          API_TOKEN: ${{ secrets.STAGING_API_TOKEN }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: reports-${{ matrix.os }}-py${{ matrix.python-version }}
          path: reports/
expand_matrix.py — перевірка матриці до пушу
expand_matrix.py — checking the matrix before pushing
from itertools import product
from pathlib import Path

import yaml

workflow = yaml.safe_load(Path(".github/workflows/tests.yml").read_text())
print("trigger keys:", list(workflow)[:3])  # YAML 1.1 читає ключ on як True

matrix = workflow["jobs"]["api"]["strategy"]["matrix"]
excluded = [tuple(e.values()) for e in matrix.get("exclude", [])]
jobs = [combo for combo in product(matrix["os"], matrix["python-version"]) if combo not in excluded]
for os_name, py in jobs:
    print(f"  {os_name:<15} python {py}")
print(len(jobs), "jobs, fail-fast:", workflow["jobs"]["api"]["strategy"]["fail-fast"])
from itertools import product
from pathlib import Path

import yaml

workflow = yaml.safe_load(Path(".github/workflows/tests.yml").read_text())
print("trigger keys:", list(workflow)[:3])  # YAML 1.1 reads the key on as True

matrix = workflow["jobs"]["api"]["strategy"]["matrix"]
excluded = [tuple(e.values()) for e in matrix.get("exclude", [])]
jobs = [combo for combo in product(matrix["os"], matrix["python-version"]) if combo not in excluded]
for os_name, py in jobs:
    print(f"  {os_name:<15} python {py}")
print(len(jobs), "jobs, fail-fast:", workflow["jobs"]["api"]["strategy"]["fail-fast"])
Вивід
Output
$ python expand_matrix.py
trigger keys: ['name', True, 'concurrency']
  ubuntu-latest   python 3.11
  ubuntu-latest   python 3.12
  ubuntu-latest   python 3.13
  windows-latest  python 3.12
  windows-latest  python 3.13
5 jobs, fail-fast: False
🔧 Розбір коду: Матриця 2 ОС × 3 версії дає шість комбінацій, exclude прибирає Windows з Python 3.11 — лишається п'ять джоб. if: always() завантажує звіти й після падіння, а secrets передає токен без запису в код. Скрипт показує ще одну пастку: PyYAML за правилами YAML 1.1 читає ключ on як булеве True, тому в інструментах, що розбирають workflow, ключ беруть у лапки або шукають True.
🔧 Code breakdown: A matrix of 2 OSes × 3 versions gives six combinations, and exclude drops Windows with Python 3.11 — five jobs remain. if: always() uploads reports even after a failure, and secrets passes the token without writing it into code. The script shows one more trap: under YAML 1.1 rules PyYAML reads the on key as the boolean True, so tools that parse workflows quote the key or look for True.
💡 Простими словами: Матриця — таблиця «на чому перевіряємо», артефакти — докази, які CI зберігає навіть тоді, коли все пішло не так.
💡 In plain words: The matrix is a table of «what we test on», and artifacts are the evidence CI keeps even when everything went wrong.
🗣 Як читати вголос У GitHub Actions я роблю окрему швидку джобу для ruff і mypy, а тести запускаю матрицею: версії Python від 3.11 до 3.13 і потрібні ОС, з fail-fast false, щоб бачити всі комбінації. setup-python з кешем pip прискорює встановлення, секрети йдуть лише через secrets, а звіти junit, HTML і трейси завантажую артефактом з if always, бо після падіння вони найпотрібніші. На pull request ганяю мінімальну матрицю й smoke, повну — вночі за розкладом, і додаю concurrency та timeout, щоб завислі запуски не їли хвилини.
🗣 How to say it out loud In GitHub Actions I keep a separate fast job for ruff and mypy, and run tests as a matrix: Python 3.11 to 3.13 and the needed OSes, with fail-fast false so I see every combination. setup-python with pip caching speeds up installs, secrets go only through secrets, and I upload junit, HTML and traces as artifacts with if always, since they matter most after a failure. On pull requests I run a minimal matrix and smoke, the full one nightly on a schedule, and I add concurrency and a timeout so hung runs do not eat minutes.
▶ 9 8
67 Як на лайв-кодингу перевірити, що два рядки є анаграмами, і які тести до цього написати? How do you check in live coding that two strings are anagrams, and which tests do you write for it?
Лайв-кодингLive coding Junior ▶

Уточни умову. Перш ніж писати код, спитай: чи враховувати регістр, пробіли й розділові знаки («Dormitory» і «Dirty room» — анаграми?), чи бувають не лише латинські літери. Інтерв'юер часто навмисно лишає це відкритим, щоб подивитися, чи ти спитаєш. Потім нормалізуй рядки: casefold() (сильніша за lower() для Unicode) і фільтр ch.isalnum().

Clarify the task. Before writing code ask: should case, spaces and punctuation count («Dormitory» and «Dirty room» — anagrams?), can there be non-Latin letters. Interviewers often leave this open on purpose to see whether you ask. Then normalise the strings: casefold() (stronger than lower() for Unicode) and a ch.isalnum() filter.

Два рішення. Порівняти відсортовані символи: sorted(a) == sorted(b) — O(n log n), коротко й очевидно. Порівняти лічильники: Counter(a) == Counter(b) — O(n), і це варто назвати як оптимізацію. На рядках довжиною в кілька десятків символів різниця непомітна, тож обидва варіанти приймають; важливо пояснити складність.

Two solutions. Compare sorted characters: sorted(a) == sorted(b) — O(n log n), short and obvious. Compare counters: Counter(a) == Counter(b) — O(n), worth naming as the optimisation. On strings a few dozen characters long the difference is invisible, so both are accepted; what matters is explaining the complexity.

Тести. Покажи мислення тестувальника — саме цього чекають від QA на лайв-кодингу: позитивний випадок, різна довжина, ті самі літери з різною кількістю ("aab" і "abb" — сортування ловить це, а порівняння множин — ні), регістр, пробіли й розділові знаки, порожні рядки, Unicode. Оформи їх через parametrize з id.

Tests. Show a tester's thinking — that is exactly what a QA live-coding round looks for: the positive case, different lengths, the same letters in different counts ("aab" and "abb" — sorting catches it, comparing sets does not), case, spaces and punctuation, empty strings, Unicode. Write them with parametrize and ids.

Підводні камені. set(a) == set(b) — типова неправильна відповідь: вона ігнорує кількість літер. І не забувай, що рядок сам собі анаграма — вирішіть з інтерв'юером, чи це прийнятно.

Pitfalls. set(a) == set(b) is the classic wrong answer: it ignores how many times each letter occurs. And remember a string is an anagram of itself — agree with the interviewer whether that counts.

test_anagram.py
test_anagram.py
from collections import Counter

import pytest

def normalize(text: str) -> str:
    return "".join(ch for ch in text.casefold() if ch.isalnum())

def is_anagram(a: str, b: str) -> bool:
    return Counter(normalize(a)) == Counter(normalize(b))

def is_anagram_by_set(a: str, b: str) -> bool:  # класична неправильна відповідь
    return set(normalize(a)) == set(normalize(b))

CASES = [
    pytest.param("listen", "silent", True, id="simple"),
    pytest.param("Dormitory", "Dirty room!", True, id="case and punctuation"),
    pytest.param("aab", "abb", False, id="same letters, other counts"),
    pytest.param("abc", "abcd", False, id="different length"),
    pytest.param("", "", True, id="empty"),
    pytest.param("Straße", "STRASSE", True, id="casefold turns ß into ss"),
]

@pytest.mark.parametrize("a, b, expected", CASES)
def test_is_anagram(a, b, expected):
    assert is_anagram(a, b) is expected

@pytest.mark.parametrize("a, b, expected", CASES)
def test_set_version(a, b, expected):
    assert is_anagram_by_set(a, b) is expected
from collections import Counter

import pytest

def normalize(text: str) -> str:
    return "".join(ch for ch in text.casefold() if ch.isalnum())

def is_anagram(a: str, b: str) -> bool:
    return Counter(normalize(a)) == Counter(normalize(b))

def is_anagram_by_set(a: str, b: str) -> bool:  # the classic wrong answer
    return set(normalize(a)) == set(normalize(b))

CASES = [
    pytest.param("listen", "silent", True, id="simple"),
    pytest.param("Dormitory", "Dirty room!", True, id="case and punctuation"),
    pytest.param("aab", "abb", False, id="same letters, other counts"),
    pytest.param("abc", "abcd", False, id="different length"),
    pytest.param("", "", True, id="empty"),
    pytest.param("Straße", "STRASSE", True, id="casefold turns ß into ss"),
]

@pytest.mark.parametrize("a, b, expected", CASES)
def test_is_anagram(a, b, expected):
    assert is_anagram(a, b) is expected

@pytest.mark.parametrize("a, b, expected", CASES)
def test_set_version(a, b, expected):
    assert is_anagram_by_set(a, b) is expected
Вивід
Output
$ pytest -q --tb=no -rf test_anagram.py
........F...                                                             [100%]
=========================== short test summary info ============================
FAILED test_anagram.py::test_set_version[same letters, other counts] - Assert...
1 failed, 11 passed in 0.01s
🔧 Розбір коду: normalize приводить регістр через casefold і викидає все, крім літер і цифр. Counter порівнює кількість кожного символу за O(n). Та сама таблиця випадків проганяється через версію з множинами, і вона падає на «aab/abb»: множина не бачить кількості літер. casefold перетворює ß на ss, тому «Straße» і «STRASSE» — анаграми, а з lower() вони б не збіглися.
🔧 Code breakdown: normalize folds case with casefold and drops everything but letters and digits. Counter compares the count of each character in O(n). The same case table runs against the set-based version, which fails on «aab/abb»: a set cannot see letter counts. casefold turns ß into ss, which is why «Straße» and «STRASSE» are anagrams, while with lower() they would not match.
💡 Простими словами: Анаграма — ті самі літери в тій самій кількості; «ті самі літери» без кількості — це вже не анаграма.
💡 In plain words: An anagram means the same letters in the same numbers; «the same letters» without the numbers is not an anagram.
🗣 Як читати вголос Спершу я уточнюю умову: чи зважати на регістр, пробіли й розділові знаки. Потім нормалізую рядки через casefold і фільтр isalnum і порівнюю два Counter — це лінійний час, а варіант з сортуванням даю як простішу альтернативу за n log n. Одразу пишу тести через parametrize: позитивний випадок, різна довжина, ті самі літери в різній кількості, порожні рядки, Unicode. Окремо згадую, що порівняння множин — типова помилка, бо воно ігнорує кількість літер.
🗣 How to say it out loud First I clarify the task: should case, spaces and punctuation count. Then I normalise the strings with casefold and an isalnum filter and compare two Counters — that is linear time, and I offer the sorting version as a simpler n log n alternative. I write tests straight away with parametrize: the positive case, different lengths, the same letters in different counts, empty strings, Unicode. I point out separately that comparing sets is a classic mistake, because it ignores letter counts.
68 Як прибрати дублікати зі списку, зберігши порядок, і що робити з нехешованими елементами? How do you remove duplicates from a list while keeping order, and what about unhashable items?
Лайв-кодингLive coding Junior ▶

Найкоротше. list(dict.fromkeys(items)). Ключі словника унікальні, а з Python 3.7 словник гарантовано зберігає порядок вставки, тож перше входження кожного елемента лишається на своєму місці. Складність O(n).

The shortest way. list(dict.fromkeys(items)). Dict keys are unique, and since Python 3.7 a dict is guaranteed to keep insertion order, so the first occurrence of each item stays in place. Complexity O(n).

Чому не set. list(set(items)) теж прибирає дублі, але порядок після нього не визначений. Тест, що на цьому покладається, проходить локально й падає в CI — класичний прихований flaky.

Why not set. list(set(items)) also drops duplicates, but the order afterwards is undefined. A test relying on it passes locally and fails in CI — a classic hidden flaky test.

Явний цикл. Інтерв'юер часто просить «без вбудованих трюків»: цикл з множиною seen і перевіркою if key not in seen. Він же природно розширюється до дедуплікації за ключем: залишити по одному користувачу на email, де словники самі по собі не хешуються. Параметр key робить функцію універсальною, як у sorted. Нормалізація ключа (email.casefold()) вирішує, чи вважати Ann@X.io і ann@x.io одним записом, — це питання до вимог, його варто поставити вголос.

An explicit loop. Interviewers often ask for it «without built-in tricks»: a loop with a seen set and an if key not in seen check. It naturally extends to deduplicating by a key: keep one user per email, where the dicts themselves are not hashable. A key parameter makes the function generic, as in sorted. Normalising the key (email.casefold()) decides whether Ann@X.io and ann@x.io are one record — a requirements question worth asking aloud.

Нехешовані елементи. Список словників не можна покласти в множину — буде TypeError: unhashable type. Рішення: дедуплікувати за хешованим ключем або перетворити запис на кортеж (tuple(sorted(d.items()))). Наївне if x not in result по списку працює з будь-чим, але це O(n²).

Unhashable items. A list of dicts cannot go into a set — you get TypeError: unhashable type. The fix is to deduplicate by a hashable key or turn each record into a tuple (tuple(sorted(d.items()))). A naive if x not in result over a list works with anything, but it is O(n²).

У тестах. Та сама ідея допомагає знайти самі дублікати у відповіді API — порівняй довжину до й після.

In tests. The same idea helps find the duplicates themselves in an API response — compare the length before and after.

dedupe.py
dedupe.py
from collections.abc import Callable, Hashable, Iterable
from typing import Any

def dedupe(items: Iterable[Any], key: Callable[[Any], Hashable] | None = None) -> list[Any]:
    seen: set[Hashable] = set()
    result = []
    for item in items:
        marker = item if key is None else key(item)
        if marker not in seen:
            seen.add(marker)
            result.append(item)
    return result

tags = ["smoke", "api", "smoke", "ui", "api"]
print(list(dict.fromkeys(tags)))
print(dedupe(tags))

users = [
    {"id": 1, "email": "Ann@X.io"},
    {"id": 2, "email": "bob@x.io"},
    {"id": 3, "email": "ann@x.io"},
]
try:
    set(users)
except TypeError as e:
    print("set of dicts:", e)
print([u["id"] for u in dedupe(users, key=lambda u: u["email"].casefold())])

ids = [u["id"] for u in users] + [2]
print("duplicates in response:", len(ids) != len(set(ids)))
Вивід
Output
$ python dedupe.py
['smoke', 'api', 'ui']
['smoke', 'api', 'ui']
set of dicts: unhashable type: 'dict'
[1, 2]
duplicates in response: True
🔧 Розбір коду: dict.fromkeys і цикл з seen дають той самий порядок перших входжень. Множина словників падає з TypeError, тому для користувачів передаємо key, і casefold робить email нечутливим до регістру — лишаються id 1 і 2. Остання перевірка — швидкий спосіб помітити дублікати у відповіді.
🔧 Code breakdown: dict.fromkeys and the seen loop give the same order of first occurrences. A set of dicts fails with TypeError, so for users we pass a key, and casefold makes the email case-insensitive — ids 1 and 2 remain. The last check is a quick way to spot duplicates in a response.
💡 Простими словами: Пам'ятай, кого вже бачив, і пропускай лише новеньких — у тому порядку, в якому вони прийшли.
💡 In plain words: Remember whom you have already seen and let only newcomers through — in the order they arrived.
🗣 Як читати вголос Найкоротше рішення — list від dict.fromkeys: ключі унікальні, а словник зберігає порядок вставки, тож перші входження лишаються на місці за лінійний час. Множину я тут не беру, бо вона губить порядок, і тест на цьому стає нестабільним. Якщо просять без трюків, пишу цикл з множиною seen, і додаю параметр key, щоб дедуплікувати, наприклад, користувачів за email, бо самі словники не хешуються. Заодно питаю, чи вважати email з різним регістром однаковими.
🗣 How to say it out loud The shortest solution is list of dict.fromkeys: keys are unique and a dict keeps insertion order, so first occurrences stay in place in linear time. I avoid a set here because it loses order, and a test relying on it becomes unstable. If asked to skip the tricks, I write a loop with a seen set and add a key parameter to deduplicate, say, users by email, since the dicts themselves are not hashable. I also ask whether emails differing only in case count as the same.
69 Як порахувати частоту слів у тексті і вивести топ-N найчастіших? How do you count word frequencies in a text and print the top N?
Лайв-кодингLive coding Junior ▶

Рішення. Counter(re.findall(r"[^\W\d_]+", text.casefold())).most_common(n). Регулярний вираз бере послідовності літер у будь-якій мові (\w в Python 3 розуміє Unicode, а виключення цифр і підкреслення лишає тільки літери), casefold зводить «The» і «the» до одного слова, Counter рахує за O(n), а most_common сортує.

The solution. Counter(re.findall(r"[^\W\d_]+", text.casefold())).most_common(n). The regular expression grabs runs of letters in any language (\w in Python 3 understands Unicode, and excluding digits and the underscore leaves only letters), casefold folds «The» and «the» into one word, Counter counts in O(n) and most_common sorts.

Питання до умови. Що таке слово: чи рахувати «don't» одним словом, числа, слова через дефіс? Чи ігнорувати стоп-слова? Що робити з однаковою частотою? most_common при рівній кількості лишає порядок першої появи, а якщо потрібен алфавітний порядок серед рівних, сортують явно ключем (-count, word). Ці питання показують, що ти думаєш про граничні випадки, як тестувальник.

Questions about the task. What is a word: does «don't» count as one, what about numbers and hyphenated words? Should stop words be ignored? What about ties? most_common keeps first-seen order among equal counts, and if you need alphabetical order among ties, sort explicitly with the key (-count, word). These questions show you think about edge cases like a tester.

Без Counter. Якщо просять «вручну» — словник з counts[word] = counts.get(word, 0) + 1 і sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:n].

Without Counter. If asked to do it «by hand» — a dict with counts[word] = counts.get(word, 0) + 1 and sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:n].

Великі файли. Для логу на гігабайти не читай файл цілком: counter.update(...) по рядках тримає в пам'яті лише словник частот.

Big files. For a gigabyte log do not read the file whole: counter.update(...) line by line keeps only the frequency dict in memory.

Підводні камені. text.split() лишає розділові знаки прилиплими до слів: «test,» і «test» стануть різними словами. А lower() замість casefold() для німецької ß чи грецької сигми дасть різні ключі для того самого слова.

Pitfalls. text.split() leaves punctuation stuck to words: «test,» and «test» become different words. And lower() instead of casefold() gives different keys for the same word with German ß or the Greek sigma.

word_count.py
word_count.py
import re
from collections import Counter

TEXT = """The test failed. The retry passed, the report said: test passed!
A test is a test; QA 2 QA_team tests."""

WORD = re.compile(r"[^\W\d_]+")

def top_words(text: str, n: int) -> list[tuple[str, int]]:
    counts = Counter(WORD.findall(text.casefold()))
    return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:n]

print(top_words(TEXT, 3))
print("naive split:", Counter(TEXT.lower().split()).most_common(3))

counts: dict[str, int] = {}
for word in WORD.findall(TEXT.casefold()):
    counts[word] = counts.get(word, 0) + 1
print("by hand:", counts["test"], counts["passed"], "| qa:", counts["qa"])
Вивід
Output
$ python word_count.py
[('test', 4), ('the', 3), ('a', 2)]
naive split: [('the', 3), ('test', 3), ('a', 2)]
by hand: 4 2 | qa: 2
🔧 Розбір коду: Регулярний вираз [^\W\d_]+ означає «одна або більше літер»: подвійне заперечення виключає не-словесні символи, цифри й підкреслення. Сортування за (-count, word) дає спадання частоти і алфавіт серед рівних. Наївний split бачить «test», «test;» і «failed.» як різні слова, тому його топ неправильний. QA_team розпалося на «qa» і «team», тож «qa» порахувалося двічі.
🔧 Code breakdown: The regular expression [^\W\d_]+ means «one or more letters»: the double negation excludes non-word characters, digits and the underscore. Sorting by (-count, word) gives descending frequency and alphabetical order among ties. A naive split sees «test», «test;» and «failed.» as different words, so its top list is wrong. QA_team split into «qa» and «team», so «qa» was counted twice.
💡 Простими словами: Спершу вирішити, що таке «слово», потім звести регістр — і лише тоді рахувати.
💡 In plain words: First decide what a «word» is, then fold the case — and only then count.
🗣 Як читати вголос Я спершу уточнюю, що вважати словом, як поводитись з регістром, числами й апострофами і як розв'язувати нічию. Потім витягаю слова регулярним виразом, бо split лишає розділові знаки при словах, зводжу регістр через casefold і рахую Counter за лінійний час. Для стабільного топу сортую за мінус частотою і самим словом. Якщо просять без Counter, роблю словник з get і нулем. Для великих файлів оновлюю лічильник по рядках, не читаючи файл цілком.
🗣 How to say it out loud First I clarify what counts as a word, how to treat case, numbers and apostrophes, and how to break ties. Then I extract words with a regular expression, because split leaves punctuation attached, fold case with casefold and count with a Counter in linear time. For a stable top list I sort by minus the count and then the word. If asked to skip Counter, I use a dict with get and a zero. For big files I update the counter line by line without reading the whole file.
70 Як написати FizzBuzz чисто і що інтерв'юер насправді перевіряє цим завданням? How do you write FizzBuzz cleanly, and what is the interviewer really checking with it?
Лайв-кодингLive coding Junior ▶

Умова. Для чисел від 1 до n: кратні 3 — «Fizz», кратні 5 — «Buzz», кратні обом — «FizzBuzz», інакше саме число.

The task. For numbers 1 to n: multiples of 3 give «Fizz», multiples of 5 «Buzz», multiples of both «FizzBuzz», otherwise the number itself.

Чисте рішення. Функція, що повертає значення для одного числа, окремо від виводу: так її можна протестувати. Перевірка «кратне 15» — першою, інакше до неї справа не дійде. Варіант зі складанням рядка (("Fizz" if n % 3 == 0 else "") + ("Buzz" if n % 5 == 0 else "") or str(n)) легко розширити правилом «кратне 7 — Bazz» без комбінаторного вибуху умов, і це варто згадати як дизайнерське рішення.

A clean solution. A function that returns the value for one number, separate from printing, so it can be tested. The «multiple of 15» check goes first, or it is never reached. The string-building variant (("Fizz" if n % 3 == 0 else "") + ("Buzz" if n % 5 == 0 else "") or str(n)) extends easily with a «multiple of 7 — Bazz» rule without a combinatorial explosion of conditions, and it is worth naming as a design choice.

Що перевіряють насправді. Не алгоритм, а те, як ти працюєш: чи уточнюєш межі (від 1 чи від 0, включно з n?), чи відокремлюєш логіку від print, чи називаєш речі зрозуміло, і — для QA найважливіше — чи пишеш тести: граничні 1, 3, 5, 15, 30, звичайне число, некоректний вхід (0, від'ємне число, рядок). Хороший кандидат сам каже, які класи еквівалентності бачить: кратні 3 без 5, кратні 5 без 3, кратні обом, решта.

What is really being checked. Not the algorithm but how you work: do you clarify the bounds (from 1 or 0, inclusive of n?), do you separate logic from print, do you name things clearly, and — most important for QA — do you write tests: the boundaries 1, 3, 5, 15, 30, an ordinary number, invalid input (0, a negative number, a string). A strong candidate names the equivalence classes unprompted: multiples of 3 but not 5, of 5 but not 3, of both, and the rest.

Підводні камені. Ланцюжок if n % 3 ... elif n % 5 ... elif n % 15 з перевіркою на 15 останньою ніколи не дасть «FizzBuzz». Функція, що лише друкує, повертає None і не тестується без перехоплення виводу.

Pitfalls. A chain if n % 3 ... elif n % 5 ... elif n % 15 with the 15 check last never produces «FizzBuzz». A function that only prints returns None and cannot be tested without capturing output.

test_fizzbuzz.py
test_fizzbuzz.py
import pytest

RULES = ((3, "Fizz"), (5, "Buzz"))

def fizzbuzz(n: int) -> str:
    if not isinstance(n, int) or n < 1:
        raise ValueError(f"expected a positive integer, got {n!r}")
    return "".join(word for divisor, word in RULES if n % divisor == 0) or str(n)

def fizzbuzz_range(limit: int) -> list[str]:
    return [fizzbuzz(n) for n in range(1, limit + 1)]

@pytest.mark.parametrize("n, expected", [(1, "1"), (3, "Fizz"), (5, "Buzz"), (15, "FizzBuzz"),
                                         (30, "FizzBuzz"), (98, "98"), (99, "Fizz"), (100, "Buzz")])
def test_single_values(n, expected):
    assert fizzbuzz(n) == expected

@pytest.mark.parametrize("bad", [0, -3, "15", 2.5])
def test_invalid_input(bad):
    with pytest.raises(ValueError):
        fizzbuzz(bad)

def test_first_fifteen():
    print("\n  " + " ".join(fizzbuzz_range(15)))
    assert fizzbuzz_range(15)[-1] == "FizzBuzz"
Вивід
Output
$ pytest -q -s test_fizzbuzz.py
............
  1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz
.
13 passed in 0.01s
🔧 Розбір коду: Правила зібрано в кортеж RULES: нове правило — один рядок без нових гілок if. "".join(...) or str(n) повертає число, якщо жодне правило не спрацювало, бо порожній рядок хибний. Тести покривають межі класів еквівалентності та некоректний вхід, а fizzbuzz_range відокремлює генерацію від друку.
🔧 Code breakdown: The rules live in the RULES tuple: a new rule is one line with no new if branches. "".join(...) or str(n) falls back to the number when no rule matched, because an empty string is falsy. The tests cover the boundaries of the equivalence classes and invalid input, and fizzbuzz_range keeps generation apart from printing.
💡 Простими словами: FizzBuzz — не про ділення на 3 і 5, а про те, чи ти питаєш, розділяєш логіку й вивід і тестуєш межі.
💡 In plain words: FizzBuzz is not about dividing by 3 and 5 but about whether you ask questions, split logic from output and test the boundaries.
🗣 Як читати вголос Спершу уточнюю межі: з одиниці й до n включно, і що робити з нулем чи від'ємним числом. Пишу функцію для одного числа, яка повертає рядок, окремо від друку, а правила тримаю в кортежі, щоб нове правило додавалося одним рядком. Далі одразу тести: класи еквівалентності — кратні трьом, п'яти, обом і решта, — межі на кшталт 15 і 30 та некоректний вхід. Для QA-співбесіди саме тести й питання до умови важать більше, ніж сам код.
🗣 How to say it out loud First I clarify the bounds: from one up to n inclusive, and what to do with zero or a negative number. I write a function for one number that returns a string, separate from printing, and keep the rules in a tuple so a new rule is one line. Then tests right away: the equivalence classes — multiples of three, of five, of both, and the rest — boundaries like 15 and 30, and invalid input. In a QA interview the tests and the questions about the task weigh more than the code itself.
71 Як розібрати лог-файл: порахувати помилки за ендпоінтами і знайти найповільніші запити? How do you parse a log file: count errors per endpoint and find the slowest requests?
Лайв-кодингLive coding Middle ▶

Підхід. Читай файл по рядках генератором, щоб не залежати від розміру. Кожен рядок розбирай регулярним виразом з іменованими групами — (?P<status>\d{3}) — і отримуй словник через match.groupdict(). Скомпілюй вираз один раз через re.compile. Агрегуй у Counter і defaultdict(list), а для топ-N найповільніших бери heapq.nlargest, який не сортує всі мільйони рядків.

Approach. Read the file line by line with a generator so size does not matter. Parse each line with a regular expression with named groups — (?P<status>\d{3}) — and get a dict via match.groupdict(). Compile the expression once with re.compile. Aggregate into a Counter and a defaultdict(list), and for the top N slowest use heapq.nlargest, which does not sort all the millions of lines.

Некоректні рядки. Реальні логи містять багаторядкові стек-трейси, обрізані рядки й сміття. Не падай на першому ж — рахуй їх окремо й показуй у звіті, щоб парсер не «з'їдав» дані мовчки. Про це варто сказати інтерв'юеру навіть без питання.

Malformed lines. Real logs contain multi-line stack traces, truncated lines and junk. Do not fail on the first one — count them separately and show them in the report, so the parser does not silently swallow data. It is worth telling the interviewer this without being asked.

Уточнення умови. Що вважати помилкою: лише 5xx чи 4xx теж? Чи групувати /users/42 і /users/7 як один ендпоінт? Зазвичай так — ID замінюють шаблоном {id}, інакше кожен користувач стане окремим «ендпоінтом». Які одиниці часу в полі тривалості?

Clarifying the task. What counts as an error: only 5xx or 4xx too? Should /users/42 and /users/7 be one endpoint? Usually yes — IDs are replaced with a {id} template, otherwise every user becomes a separate «endpoint». What unit is the duration field in?

Навіщо це QA. Такі скрипти пишуть, щоб після навантажувального тесту знайти ендпоінти з найбільшою кількістю 5xx, перевірити, що нічна регресія не залишила помилок у логах сервісу, або автоматично прикласти зведення до баг-репорту.

Why QA needs it. Scripts like this find the endpoints with the most 5xx after a load test, check that a nightly regression left no errors in the service logs, or attach a summary to a bug report automatically.

Підводні камені. split() замість регулярного виразу ламається на полі з пробілами (user agent). Жадібний .* у виразі захоплює більше, ніж треба.

Pitfalls. split() instead of a regular expression breaks on fields containing spaces (the user agent). A greedy .* in the expression captures more than it should.

access.log
access.log
2026-09-29T10:00:01Z GET /api/users/42 200 35ms
2026-09-29T10:00:02Z POST /api/orders 201 180ms
2026-09-29T10:00:03Z GET /api/users/7 500 1200ms
Traceback (most recent call last):
2026-09-29T10:00:04Z GET /api/orders/9 404 12ms
2026-09-29T10:00:05Z POST /api/orders 503 3050ms
2026-09-29T10:00:06Z GET /api/users/42 500 950ms
parse_log.py
parse_log.py
import heapq
import re
from collections import Counter
from collections.abc import Iterator
from pathlib import Path

LINE = re.compile(r"^(?P<ts>\S+) (?P<method>[A-Z]+) (?P<path>\S+) (?P<status>\d{3}) (?P<ms>\d+)ms$")
ID = re.compile(r"/\d+(?=/|$)")

def parse(path: Path) -> Iterator[dict]:
    with path.open(encoding="utf-8") as f:
        for number, line in enumerate(f, start=1):
            match = LINE.match(line.rstrip("\n"))
            if match is None:
                yield {"malformed": number}
                continue
            row = match.groupdict()
            row["endpoint"] = f"{row['method']} {ID.sub('/{id}', row['path'])}"
            row["status"], row["ms"] = int(row["status"]), int(row["ms"])
            yield row

rows = list(parse(Path("access.log")))
good = [r for r in rows if "malformed" not in r]
errors = Counter(r["endpoint"] for r in good if r["status"] >= 500)
slowest = heapq.nlargest(2, good, key=lambda r: r["ms"])

print("5xx per endpoint:", errors.most_common())
print("slowest:", [(r["endpoint"], r["ms"]) for r in slowest])
print("malformed lines:", [r["malformed"] for r in rows if "malformed" in r])
Вивід
Output
$ python parse_log.py
5xx per endpoint: [('GET /api/users/{id}', 2), ('POST /api/orders', 1)]
slowest: [('POST /api/orders', 3050), ('GET /api/users/{id}', 1200)]
malformed lines: [4]
🔧 Розбір коду: Іменовані групи роблять розбір читабельним: groupdict() одразу дає словник. ID.sub замінює числові сегменти на {id}, тож обидва запити до користувачів потрапили в один ендпоінт з двома 5xx. heapq.nlargest бере два найповільніші без повного сортування, а рядок трейсбеку №4 не зламав розбір і потрапив у звіт як некоректний.
🔧 Code breakdown: Named groups keep the parsing readable: groupdict() gives a dict straight away. ID.sub replaces numeric segments with {id}, so both user requests landed in one endpoint with two 5xx. heapq.nlargest takes the two slowest without a full sort, and the traceback on line 4 did not break parsing but was reported as malformed.
💡 Простими словами: Читай рядок за рядком, розбирай шаблоном, складай у лічильники — і не викидай мовчки те, що не розібралося.
💡 In plain words: Read line by line, parse with a pattern, pile into counters — and never silently throw away what did not parse.
🗣 Як читати вголос Я читаю лог генератором по рядках, щоб розмір файла не мав значення, і розбираю кожен рядок скомпільованим регулярним виразом з іменованими групами. ID у шляхах замінюю шаблоном, щоб запити до різних користувачів рахувалися як один ендпоінт. Помилки агрегую в Counter, найповільніші беру через heapq.nlargest без повного сортування. Некоректні рядки, як трейсбеки, не пропускаю мовчки, а рахую й показую. І спершу уточнюю, що вважати помилкою — лише 5xx чи 4xx теж.
🗣 How to say it out loud I read the log with a generator line by line so file size does not matter, and parse each line with a compiled regular expression with named groups. I replace IDs in paths with a template so requests for different users count as one endpoint. I aggregate errors in a Counter and take the slowest with heapq.nlargest without a full sort. I never skip malformed lines such as tracebacks silently — I count and report them. And first I clarify what counts as an error — only 5xx or 4xx as well.
72 Як спроєктувати pytest-фреймворк для API й UI тестів з нуля? How do you design a pytest framework for API and UI tests from scratch?
Лайв-кодингLive coding Senior ▶

Шари. Конфігурація (стенд, секрети, таймаути) — один типізований об'єкт з оточення. Транспорт — requests.Session з таймаутом, повторами на 502–504 і логуванням. Клієнти API за доменами (UsersClient, OrdersClient), що повертають pydantic-моделі й перевіряють схему. Сторінки й компоненти UI — Page Object поверх Playwright. Фабрики тестових даних з унікальними значеннями. Тести — лише сценарії й перевірки, без HTTP-деталей і локаторів.

Layers. Configuration (environment, secrets, timeouts) — one typed object read from the environment. Transport — a requests.Session with a timeout, retries on 502–504 and logging. API clients per domain (UsersClient, OrdersClient) returning pydantic models and checking the schema. UI pages and components — Page Objects on top of Playwright. Test data factories with unique values. Tests hold only scenarios and checks, with no HTTP details and no locators.

Фікстури як граф залежностей. settings і http — scope session, токен авторизації — теж сесійний і отримується через API. user_factory — scope function, створює дані через API й видаляє все створене в teardown. UI-тести отримують вже залогінений контекст через storage_state, збережений з API-логіну, а не логіняться через форму. conftest по шарах: корінь — спільне, tests/api і tests/ui — своє.

Fixtures as a dependency graph. settings and http have session scope, and the auth token is session-scoped too and obtained through the API. user_factory has function scope, creates data through the API and deletes everything it created in teardown. UI tests get an already logged-in context through a storage_state saved from an API login instead of logging in through the form. conftest files per layer: the root holds shared things, tests/api and tests/ui their own.

Незалежність і паралельність. Кожен тест створює власні дані з унікальними ідентифікаторами, тож тести не залежать від порядку і з першого дня готові до pytest -n auto. Маркери smoke, api, ui з --strict-markers керують тим, що запускається на pull request і вночі.

Independence and parallelism. Each test creates its own data with unique identifiers, so tests do not depend on order and are ready for pytest -n auto from day one. smoke, api and ui markers with --strict-markers control what runs on a pull request and at night.

Звіти й діагностика. Allure чи pytest-html з кроками, тілами запитів і відповідей (з маскуванням токенів), трейси Playwright лише для впалих тестів, junit для CI. Якість самого фреймворку — ruff, mypy і юніт-тести на хелпери.

Reports and diagnostics. Allure or pytest-html with steps, request and response bodies (with tokens masked), Playwright traces only for failed tests, junit for CI. The framework's own quality — ruff, mypy and unit tests for helpers.

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

Trade-offs. Do not build «a framework for its own sake»: abstractions arrive when the third similar case shows up. Mind the pyramid: most checks are API and contract tests, UI covers only the key end-to-end scenarios.

conftest.py — граф фікстур (API підмінено фейком, щоб приклад запускався офлайн)
conftest.py — the fixture graph (the API is faked so the example runs offline)
import itertools
import uuid
from dataclasses import dataclass

import pytest

@dataclass(frozen=True)
class Settings:
    base_url: str = "https://staging.shop.test"
    timeout: float = 10.0

class FakeUsersApi:  # замість UsersClient поверх requests.Session
    ids = itertools.count(1)
    def __init__(self):
        self.users = {}
    def create(self, email):
        user_id = next(self.ids)
        self.users[user_id] = email
        return user_id
    def delete(self, user_id):
        self.users.pop(user_id, None)

@pytest.fixture(scope="session")
def settings():
    return Settings()

@pytest.fixture(scope="session")
def users_api(settings):
    return FakeUsersApi()

@pytest.fixture(scope="session")
def auth_token(users_api):
    return "token-from-api-login"

@pytest.fixture
def user_factory(users_api, auth_token):
    created = []
    def make(prefix="qa"):
        user_id = users_api.create(f"{prefix}-{uuid.uuid4().hex[:8]}@example.com")
        created.append(user_id)
        return user_id
    yield make
    for user_id in created:
        users_api.delete(user_id)
import itertools
import uuid
from dataclasses import dataclass

import pytest

@dataclass(frozen=True)
class Settings:
    base_url: str = "https://staging.shop.test"
    timeout: float = 10.0

class FakeUsersApi:  # stands in for UsersClient over requests.Session
    ids = itertools.count(1)
    def __init__(self):
        self.users = {}
    def create(self, email):
        user_id = next(self.ids)
        self.users[user_id] = email
        return user_id
    def delete(self, user_id):
        self.users.pop(user_id, None)

@pytest.fixture(scope="session")
def settings():
    return Settings()

@pytest.fixture(scope="session")
def users_api(settings):
    return FakeUsersApi()

@pytest.fixture(scope="session")
def auth_token(users_api):
    return "token-from-api-login"

@pytest.fixture
def user_factory(users_api, auth_token):
    created = []
    def make(prefix="qa"):
        user_id = users_api.create(f"{prefix}-{uuid.uuid4().hex[:8]}@example.com")
        created.append(user_id)
        return user_id
    yield make
    for user_id in created:
        users_api.delete(user_id)
tests/api/test_users.py
tests/api/test_users.py
import pytest

@pytest.mark.api
def test_two_users_do_not_collide(user_factory, users_api):
    first, second = user_factory(), user_factory()
    assert users_api.users[first] != users_api.users[second]

@pytest.mark.api
def test_factory_cleans_up_after_previous_test(users_api):
    assert users_api.users == {}
Вивід
Output
$ pytest -q --setup-show -m api tests/api/test_users.py

SETUP    S settings
SETUP    S users_api (fixtures used: settings)
SETUP    S auth_token (fixtures used: users_api)
        SETUP    F user_factory (fixtures used: auth_token, users_api)
        tests/api/test_users.py::test_two_users_do_not_collide (fixtures used: auth_token, settings, user_factory, users_api) .
        TEARDOWN F user_factory
        tests/api/test_users.py::test_factory_cleans_up_after_previous_test (fixtures used: settings, users_api) .
TEARDOWN S auth_token
TEARDOWN S users_api
TEARDOWN S settings
2 passed in 0.01s
🔧 Розбір коду: --setup-show показує граф: settings, users_api і auth_token створюються один раз на сесію (S), user_factory — для кожного тесту (F) і в teardown видаляє все створене. Фабрика генерує унікальний email через uuid4, тож тести не конфліктують ні між собою, ні під xdist. Другий тест підтверджує, що після першого не лишилося даних.
🔧 Code breakdown: --setup-show reveals the graph: settings, users_api and auth_token are created once per session (S), and user_factory per test (F), deleting everything it created in teardown. The factory generates a unique email with uuid4, so tests do not collide with each other or under xdist. The second test confirms nothing was left behind by the first.
💡 Простими словами: Тест каже «що перевірити», фікстури й клієнти знають «як підготувати», а конфіг — «де саме».
💡 In plain words: The test says «what to check», fixtures and clients know «how to prepare», and the config knows «where».
🗣 Як читати вголос Я б розділив фреймворк на шари: типізована конфігурація з оточення, транспорт на Session з таймаутами й повторами, доменні клієнти API з pydantic-моделями, Page Object для UI і фабрики даних. Тести містять лише сценарій і перевірки. Фікстури будую графом: налаштування, сесія й токен — на сесію, фабрики з прибиранням — на тест, а UI отримує залогінений storage_state з API. Кожен тест створює унікальні дані, тож паралельний запуск працює одразу. Звіти — Allure чи HTML з трейсами для впалих тестів.
🗣 How to say it out loud I would split the framework into layers: typed configuration from the environment, transport on a Session with timeouts and retries, domain API clients with pydantic models, Page Objects for UI, and data factories. Tests hold only the scenario and checks. I build fixtures as a graph: settings, session and token per session, factories with clean-up per test, and UI gets a logged-in storage_state from the API. Every test creates unique data, so parallel runs work from the start. Reports are Allure or HTML with traces for failed tests.
73 Набір тестів іде 40 хвилин. Як ти його прискориш? The test suite takes 40 minutes. How would you speed it up?
Лайв-кодингLive coding Senior ▶

Спершу виміряти. pytest --durations=20 показує найповільніші тести й окремо фази setup і teardown — часто час з'їдає не тест, а фікстура. --setup-show видає, яка фікстура створюється частіше, ніж треба. У CI варто зберігати junit з тривалостями й дивитися тренд.

Measure first. pytest --durations=20 lists the slowest tests with setup and teardown phases shown separately — often the time goes not into the test but into a fixture. --setup-show reveals which fixture is built more often than it should be. In CI keep the junit file with durations and watch the trend.

Типові знахідки та ліки. Фіксовані time.sleep — замінити на очікування умови. Логін через UI в кожному тесті — один логін через API і storage_state. Дорога фікстура зі scope function, яку тести лише читають, — підняти scope. Підготовка даних через UI — перенести на API або прямо в базу. Зовнішні залежності (пошта, платіжний шлюз) — мок-сервер. Зайві UI-тести, що перевіряють логіку, яку можна перевірити API-тестом, — переписати нижче по піраміді.

Typical findings and cures. Fixed time.sleep calls — replace with waiting for a condition. A UI login in every test — one API login and a storage_state. An expensive function-scoped fixture that tests only read — widen its scope. Data set up through the UI — move it to the API or straight into the database. External dependencies (email, a payment gateway) — a mock server. Surplus UI tests checking logic an API test could cover — rewrite them lower in the pyramid.

Паралельність. pytest -n auto на одному агенті, потім шардинг між кількома джобами CI. Умова — незалежні тести з унікальними даними; інакше паралельність додасть flaky-падінь.

Parallelism. pytest -n auto on one agent, then sharding across several CI jobs. The condition is independent tests with unique data; otherwise parallelism adds flaky failures.

Організація запусків. На кожен pull request — smoke за 5–10 хвилин через маркери, повна регресія — вночі. Вибір тестів за зміненими модулями дає ще більше, але потребує карти залежностей. -x і --ff скорочують цикл зворотного зв'язку.

Organising runs. On every pull request a 5–10 minute smoke run via markers, full regression at night. Selecting tests by changed modules gives even more but needs a dependency map. -x and --ff shorten the feedback loop.

Як говорити на співбесіді. Структура «виміряв → знайшов вузькі місця → виправив найдорожче → паралелив → зафіксував бюджет часу». Цифри з досвіду переконують найкраще: «прибрали UI-логін з 300 тестів — мінус 12 хвилин».

How to say it in the interview. The structure is «measured → found the bottlenecks → fixed the most expensive → parallelised → pinned a time budget». Numbers from experience convince best: «we removed UI login from 300 tests — minus 12 minutes».

test_slow_suite.py — де насправді йде час
test_slow_suite.py — where the time really goes
import time

import pytest

@pytest.fixture
def logged_in_via_ui():
    time.sleep(0.6)  # логін через UI в кожному тесті
    return "session-cookie"

@pytest.fixture(scope="session")
def logged_in_via_api():
    time.sleep(0.6)  # той самий логін раз на сесію
    return "session-cookie"

@pytest.mark.parametrize("n", range(3))
def test_profile_ui_login(logged_in_via_ui, n):
    time.sleep(0.1)

@pytest.mark.parametrize("n", range(3))
def test_profile_api_login(logged_in_via_api, n):
    time.sleep(0.1)

def test_report_waits_for_export():
    time.sleep(1.0)  # фіксоване очікування задачі, що триває 0,2 с
import time

import pytest

@pytest.fixture
def logged_in_via_ui():
    time.sleep(0.6)  # UI login in every test
    return "session-cookie"

@pytest.fixture(scope="session")
def logged_in_via_api():
    time.sleep(0.6)  # the same login once per session
    return "session-cookie"

@pytest.mark.parametrize("n", range(3))
def test_profile_ui_login(logged_in_via_ui, n):
    time.sleep(0.1)

@pytest.mark.parametrize("n", range(3))
def test_profile_api_login(logged_in_via_api, n):
    time.sleep(0.1)

def test_report_waits_for_export():
    time.sleep(1.0)  # fixed wait for a job that takes 0.2 s
Вивід
Output
$ pytest -q --durations=6 test_slow_suite.py | grep -v "^$"
.......                                                                  [100%]
============================= slowest 6 durations ==============================
1.00s call     test_slow_suite.py::test_report_waits_for_export
0.60s setup    test_slow_suite.py::test_profile_ui_login[0]
0.60s setup    test_slow_suite.py::test_profile_api_login[0]
0.60s setup    test_slow_suite.py::test_profile_ui_login[1]
0.60s setup    test_slow_suite.py::test_profile_ui_login[2]
0.10s call     test_slow_suite.py::test_profile_ui_login[0]
7 passed in 4.02s
🔧 Розбір коду: --durations розділяє фази: для UI-логіну кожен тест платить 0,6 с на setup, тоді як сесійна фікстура з API-логіном платить один раз (решта її тестів у список навіть не потрапила). Найдорожчий окремий тест — фіксоване очікування в 1 с для задачі, що триває 0,2 с. Це і є список того, що виправляти першим.
🔧 Code breakdown: --durations separates the phases: with UI login every test pays 0.6 s in setup, while the session fixture with API login pays once (its other tests did not even make the list). The most expensive single test is a fixed 1 s wait for a job that takes 0.2 s. That is the list of what to fix first.
💡 Простими словами: Не вгадуй, де повільно, — поміряй; найчастіше час з'їдають підготовка і sleep, а не самі перевірки.
💡 In plain words: Do not guess what is slow — measure; most often setup and sleeps eat the time, not the checks themselves.
🗣 Як читати вголос Я почну з вимірювань: durations покаже найповільніші тести й окремо setup, бо часто винна фікстура, а setup-show покаже, що створюється занадто часто. Далі прибираю найдорожче: sleep замінюю очікуванням умови, UI-логін — логіном через API і storage_state, дорогі фікстури, які лише читають, піднімаю на сесію, а перевірки логіки переношу з UI на API. Потім вмикаю xdist і шардинг у CI і ділю запуски: smoke на pull request, повна регресія вночі. І фіксую бюджет часу, щоб набір не розрісся знову.
🗣 How to say it out loud I start by measuring: durations shows the slowest tests with setup separated, because a fixture is often the culprit, and setup-show shows what gets built too often. Then I remove the most expensive things: sleeps become condition waits, UI login becomes an API login with storage_state, expensive read-only fixtures move to session scope, and logic checks move from UI to API. Then I enable xdist and CI sharding and split the runs: smoke on pull requests, full regression at night. And I pin a time budget so the suite does not bloat again.
74 Як знайти причину flaky-тестів і зробити їх надійними, а не просто додати перезапуск? How do you find the cause of flaky tests and make them reliable instead of just adding reruns?
Лайв-кодингLive coding Senior ▶

Типові причини. Час: фіксований sleep замість очікування, асинхронна обробка на бекенді, анімації. Спільний стан: сесійна фікстура зі змінюваними даними, глобальні змінні, спільний тестовий користувач. Порядок: тест покладається на дані, створені іншим тестом. Колізії даних під xdist: однакові email чи назви. Середовище: нестабільний стенд, ліміти запитів, зовнішні сервіси. Недетермінованість: random без seed, «зараз» і часові зони, порядок множини чи результатів без ORDER BY.

Typical causes. Timing: a fixed sleep instead of a wait, asynchronous processing on the backend, animations. Shared state: a session fixture with mutable data, global variables, a shared test user. Order: a test relies on data created by another test. Data collisions under xdist: identical emails or names. Environment: an unstable stand, rate limits, external services. Non-determinism: random without a seed, «now» and time zones, set order or results without ORDER BY.

Як відтворити. Прогнати підозрілий тест багато разів поспіль (pytest-repeat з --count 50 або параметризація по range), у випадковому порядку (pytest-randomly), паралельно (-n 4), окремо і в складі набору. Якщо падає лише в наборі — шукай спільний стан або порядок. Збирай артефакти з кожного падіння: трейси, логи, тіла відповідей, seed.

How to reproduce. Run the suspect test many times in a row (pytest-repeat with --count 50, or parametrising over a range), in random order (pytest-randomly), in parallel (-n 4), alone and within the suite. If it fails only within the suite, look for shared state or ordering. Collect artifacts from every failure: traces, logs, response bodies, the seed.

Як лікувати. Замінити sleep на очікування умови з таймаутом і опитуванням (expect у Playwright, WebDriverWait, власний wait_until для API). Унікальні дані на тест через фабрики з uuid. Вузький scope для всього, що змінюється. Детермінізм: фіксований seed, заморожений час, явне сортування. Для справді зовнішніх збоїв — повтор на транспортному рівні з логуванням.

How to cure. Replace sleep with a condition wait with a timeout and polling (expect in Playwright, WebDriverWait, your own wait_until for an API). Unique data per test through factories with uuid. A narrow scope for anything that changes. Determinism: a fixed seed, frozen time, explicit sorting. For truly external hiccups, a transport-level retry with logging.

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

Process. A rerun is a temporary crutch, not a fix: every flaky test gets a ticket, an owner and a quarantine marker that keeps it out of the blocking suite until it is fixed. The flakiness metric (the share of reruns) is visible on a dashboard and must go down.

test_flaky_export.py — 30 повторів sleep і очікування умови
test_flaky_export.py — 30 repeats of sleep versus a condition wait
import random
import threading
import time

import pytest

def start_export():
    job = {"status": "running"}
    delay = random.uniform(0.05, 0.3)  # бекенд працює різний час
    threading.Timer(delay, lambda: job.update(status="done")).start()
    return job

def wait_until(predicate, timeout=2.0, interval=0.02):
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if predicate():
            return
        time.sleep(interval)
    raise TimeoutError(f"condition not met within {timeout}s")

@pytest.mark.parametrize("attempt", range(30))
def test_export_with_fixed_sleep(attempt):
    job = start_export()
    time.sleep(0.2)
    assert job["status"] == "done"

@pytest.mark.parametrize("attempt", range(30))
def test_export_with_wait_until(attempt):
    job = start_export()
    wait_until(lambda: job["status"] == "done")
import random
import threading
import time

import pytest

def start_export():
    job = {"status": "running"}
    delay = random.uniform(0.05, 0.3)  # the backend takes a variable time
    threading.Timer(delay, lambda: job.update(status="done")).start()
    return job

def wait_until(predicate, timeout=2.0, interval=0.02):
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if predicate():
            return
        time.sleep(interval)
    raise TimeoutError(f"condition not met within {timeout}s")

@pytest.mark.parametrize("attempt", range(30))
def test_export_with_fixed_sleep(attempt):
    job = start_export()
    time.sleep(0.2)
    assert job["status"] == "done"

@pytest.mark.parametrize("attempt", range(30))
def test_export_with_wait_until(attempt):
    job = start_export()
    wait_until(lambda: job["status"] == "done")
Вивід
Output
$ pytest -q --tb=no -k fixed_sleep test_flaky_export.py
15 failed, 15 passed, 30 deselected in 6.07s
$ pytest -q --tb=no -k wait_until test_flaky_export.py
30 passed, 30 deselected in 5.15s
🔧 Розбір коду: Бекенд завершує експорт за 0,05–0,3 с, тож фіксований sleep(0.2) проходить лише тоді, коли задача встигла, — у цьому запуску впала половина з 30 повторів, а в наступному кількість буде іншою. wait_until опитує умову кожні 20 мс і виходить, щойно статус став done: 30 з 30 зелені, і в середньому навіть швидше за sleep. Параметризація по range(30) — простий спосіб прогнати тест багато разів без плагінів.
🔧 Code breakdown: The backend finishes the export in 0.05–0.3 s, so a fixed sleep(0.2) passes only when the job happened to be done — in this run half of the 30 repeats failed, and the next run will give a different number. wait_until polls every 20 ms and returns as soon as the status is done: 30 of 30 green, and on average even faster than the sleep. Parametrising over range(30) is a simple way to run a test many times without plugins.
💡 Простими словами: Flaky-тест — це не невезіння, а невідома залежність від часу, порядку чи даних; перезапуск лише ховає її.
💡 In plain words: A flaky test is not bad luck but an unknown dependency on time, order or data; a rerun only hides it.
🗣 Як читати вголос Для мене flaky-тест — це прихована залежність: від часу, спільного стану, порядку, даних чи стенду. Спершу відтворюю: ганяю тест десятки разів, у випадковому порядку й паралельно, окремо і в наборі, і збираю трейси й логи з кожного падіння. Лікую причину: sleep замінюю очікуванням умови з таймаутом, дані роблю унікальними на тест, звужую scope змінюваних фікстур, фіксую seed і час. Перезапуск допускаю лише тимчасово — з задачею, власником, карантинним маркером і метрикою, яка має падати.
🗣 How to say it out loud To me a flaky test is a hidden dependency: on time, shared state, order, data or the environment. First I reproduce it: run the test dozens of times, in random order and in parallel, alone and within the suite, and collect traces and logs from every failure. I cure the cause: sleeps become condition waits with a timeout, data becomes unique per test, mutable fixtures get a narrow scope, the seed and time get pinned. I allow reruns only temporarily — with a ticket, an owner, a quarantine marker and a metric that must go down.