test_*.py запускай через pytest -q, решту — через python. Приклади з requests звертаються до публічного сервісу httpbin.org, тому для них потрібен інтернет; приклади з Playwright і Selenium відкривають локальну HTML-сторінку й мережі не потребують.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
Що це. 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.
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,)))
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 ловить спробу використати нехешований список як ключ. Останній рядок показує, що вміст кортежу може змінитися, якщо там лежить список.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.Що це. == порівнює значення: викликає __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.
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")
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.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.Що відбувається. Значення за замовчуванням обчислюється один раз — коли виконується рядок 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).
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"))
['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 створює новий список лише тоді, коли аргументу не передали.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.Що це. У контексті 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.
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)
[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 обчислює лише потрібне.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.Присвоєння — не копія. 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.
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)
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 створює новий рядок на кожній ітерації.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.Зрізи. Синтаксис 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.
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)
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 символів.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.Що це. 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.
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)
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 = } друкує сам вираз, знак рівності та результат.:.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.Що це. Коли 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.
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())
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, і значення живе між викликами.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.Чотири базові. 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».
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})
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) показує ключі словника в порядку вставки.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.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.
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)
'' 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 дає той самий результат без імпорту.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.Що це. 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.
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
$ 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).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.Що це. 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.
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))
[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) потрібен, бо порядок множини не визначений.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.Що це. 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.
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)
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 лишається старим.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.Різниця. 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.
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
$ 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 показує, на якому індексі списки розійшлися.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.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.
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")])))
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) розпаковує рядки як окремі аргументи і перевертає таблицю.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.Два поняття. Ітерований об'єкт (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».
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)
[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 з генератором знаходить перший збіг і зупиняється.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.Що це. Функція з 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.
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")
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 запросив тільки другу сторінку, до третьої справа не дійшла.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.Що це. 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.
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)))
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), де час пішов назад.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.Що це. У визначенні функції *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.
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)
{'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) забороняє передати роль позиційно.{**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.Що це. Декоратор — функція, яка приймає функцію і повертає іншу функцію, зазвичай обгортку з додатковою поведінкою. Запис @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.
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__)
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 з порожньою документацією.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.Що він робить. Повторює виклик, якщо той кинув один із переданих типів винятків, з паузою, що зростає (експоненційний 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.
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)
$ 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 подвоює паузу.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.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__.
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)
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.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.Що це. Методи з подвійним підкресленням з обох боків — «магічні»: 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.
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"])
$ 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__, а не адреси в пам'яті.__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.@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.
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)
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.@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.Що це. Декоратор з модуля 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.
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
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 пройшов: анотації — не валідація.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.Що це. 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.
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"]
$ pytest -q test_duck.py
.. [100%]
2 passed in 0.00s
FakeUsersApi не наслідує UsersApi, але має метод з тією самою сигнатурою, тому mypy приймає його в greeting, а isinstance працює завдяки @runtime_checkable. save_report пише в StringIO так само, як у файл, бо потребує лише write.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.Структура. 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.
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)
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.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.Чому голий 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.
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
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 на «все добре».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».Що це. Об'єкт з методами __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.
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")
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 ігнорує лише вказаний тип помилки.@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.Правила пошуку. Без аргументів 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.
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
def test_login():
assert False
$ 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.--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 переписує їхні інструкції 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.
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"
$ 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
status і в total, де 0.1 + 0.2 дало 0.30000000000000004. pytest.approx порівнює з допуском. Повідомлення після коми в третьому тесті з'являється першим рядком помилки.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.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.
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 == {}
$ 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 у зворотному порядку. Другий тест отримує новий порожній клієнт.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.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.
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"
$ 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 скорочує звіт про падіння до одного рядка.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.П'ять рівнів. 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.
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
$ 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 дає кожному тесту власну копію, тож зміна в одному не видна в наступному.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.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.
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}")
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"
def test_users_endpoint(base_url):
assert base_url == "https://shop.test/api/v2"
$ pytest -q -s
>> test_users_endpoint
.
>> test_home_page
.
2 passed in 0.01s
base_url у tests/api/conftest.py просить однойменну батьківську фікстуру й дописує шлях, тому API-тест бачить /api/v2, а UI-тест (tests/ui/test_home.py з тією ж перевіркою без суфікса) — корінь. Autouse-фікстура друкує назву кожного тесту без явного запиту.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.Що це. Декоратор запускає ту саму тестову функцію кілька разів з різними даними: @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.
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-")
$ 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 =========================
pytest.param(..., id=...). Випадок з великими літерами позначено xfail, але валідатор його пропускає, тому статус XPASS — сигнал, що баг, можливо, вже виправлено. indirect=True передав admin і viewer у фікстуру через request.param.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.Що це. 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.
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)
$ 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: зняття в межах балансу не кидає помилку.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.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.
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
$ 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 з причинами наприкінці.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.Що це. Маркер — мітка на тесті: @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]
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
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
$ 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», і тест не випаде з набору непомітно.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.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().
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"
$ 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.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.Де конфігурація. 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.
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers --tb=short"
xfail_strict = true
markers = ["smoke: critical path"]
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
$ 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.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.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.
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"
$ 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
-n 4 — близько двох секунд: по дві хвилі на воркер плюс час на запуск самих воркерів. test_flaky_search падає з першої спроби й проходить з другої: у підсумку видно 1 rerun, а -rR перелічує перезапущені тести. pytest-html створив самодостатній report.html.-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.Що це. 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.
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}
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)
$ 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, якого немає в реальній сигнатурі.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.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.
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"])
$ 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 без втрат.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.Тіло. 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.
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"
$ 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.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.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.
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))
$ 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 це підтверджує.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.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.
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] + "...")
$ 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...
/bearer відповідає 200 з токеном або 401 без нього. auth=("qa", "secret") проходить Basic-перевірку. TokenAuth викликає функцію отримання токена на кожен запит — туди легко додати оновлення. У завантаженні сервер побачив і файл, і поле run_id, а заголовок з boundary requests зібрав сам./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.Навіщо. Перевірка окремих полів ловить те, що ти передбачив. Схема ловить решту: поле змінило тип з числа на рядок, зникло обов'язкове поле, з'явився 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.
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)
$ 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 — звичайний типізований атрибут.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.Що це. 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.
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())
$ python async_httpx.py
3 sequential: 3.8s
10 gathered: 1.7s {200}
10 with limit 5: 2.8s {'200'}
gather запускає десять одночасно, і час близький до одного запиту плюс мережеві накладні. Semaphore(5) пропускає по п'ять — дві хвилі, тобто приблизно вдвічі довше, зате стенд отримує не більше п'яти запитів разом. Точні секунди залежать від мережі до httpbin.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.Що це. 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.
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()
$ 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.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.Фікстури. Плагін 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.
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"}
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)
$ 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.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.Драйвер. 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.
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")
$ pytest -q test_selenium_find.py
. [100%]
1 passed in 0.65s
yield. Тест проходить чотирма стратегіями: By.ID, CSS за атрибутом, XPath за текстом мітки й find_elements, що повертає порожній список для кошика. Пошук неіснуючого #logout кидає NoSuchElementException.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.Явне очікування. 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.
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")
$ 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 с, а лямбда-умова дочікується двох елементів кошика.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.Що це. 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.
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"]
$ pytest -q test_page_object.py
. [100%]
1 passed in 1.87s
__init__, а тест їх не бачить. login повертає CartPage, тож тест читається як ланцюжок дій. Фікстура login_page отримує page від pytest-playwright і відкриває сторінку. Єдині expect і assert з очікуваними значеннями — у самому тесті.__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.Правильне читання. 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.
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")))
$ python files_demo.py
cp1252: UnicodeDecodeError
latin-1 guess: Zoë
1 Zoë
2 Łukasz
users.txt .txt users data True
['users.txt']
UnicodeDecodeError. latin-1 читає будь-які байти без помилки, але «Zoë» перетворюється на кракозябри — це гірше, бо тест мовчки працює з неправильними даними. Path(__file__).parent прив'язує теку data до файла скрипта. enumerate(f, start=1) читає файл по рядках з номерами.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.Чотири функції. Літера 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.
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)
$ 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 читає суму без похибки.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.Навіщо виносити дані. Коли випадків десятки, а їх готують аналітики чи мануальні тестувальники, таблиця читається краще за 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.
case,username,password,expected_status
valid user,ann,secret,200
wrong password,ann,nope,401
empty username,,secret,400
- case: ukraine
code: UA
vat: 20
- case: norway unquoted
code: NO
vat: 25
- case: norway quoted
code: "NO"
vat: 25
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}"
$ 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, а в лапках — як рядок.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.Читання. 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.
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())
$ 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__ маскує токен, щоб налаштування можна було безпечно логувати.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.Проблема 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.
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}
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]
log_level = INFO
log_format = %(asctime)s %(levelname)-7s %(name)s: %(message)s
log_date_format = %H:%M:%S
$ 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, щоб він не змінювався від запуску до запуску.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.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.
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)
$ 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
+ timedelta(days=1) до aware-часу рахує за настінним годинником — 12:00, і між ними лише 23 реальні години. Два ISO-рядки з різними зсувами — той самий момент. Порівняння naive з aware кидає TypeError.+ 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.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.
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"
$ 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 дає ті самі версії.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.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.
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
[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"
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()
$ 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 тут лише пришвидшує приклад; у реальному проєкті залежності ставляться разом з пакетом.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.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.
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()
$ 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).
os (F401), ім'я функції не в snake_case (N802), змінюваний дефолт (B006), запит без таймауту (S113), задовгий рядок за типового ліміту 88 (E501), голий except (E722) і == None (E711). Ключ --select вмикає набори правил; у проєкті їх записують у [tool.ruff.lint] у pyproject.toml. Ruff бере імена у зворотні апострофи, у виводі вище вони замінені на звичайні.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.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.
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"))
$ 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)
"1" замість числа — пошук просто нічого не знаходить, і found["email"] падає з TypeError на None. mypy знаходить чотири помилки до запуску: виклик upper() на значенні, що може бути None, неправильний тип аргументу в обох викликах find_user і звернення до ключа в результаті, який може бути None."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.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.
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)
$ 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
if __name__ == "__main__" потрібен, бо дочірні процеси імпортують цей модуль повторно.if __name__ == "__main__" block is needed because child processes import this module again.Каркас. 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.
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/
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"])
$ 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
exclude прибирає Windows з Python 3.11 — лишається п'ять джоб. if: always() завантажує звіти й після падіння, а secrets передає токен без запису в код. Скрипт показує ще одну пастку: PyYAML за правилами YAML 1.1 читає ключ on як булеве True, тому в інструментах, що розбирають workflow, ключ беруть у лапки або шукають True.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.Уточни умову. Перш ніж писати код, спитай: чи враховувати регістр, пробіли й розділові знаки («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.
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
$ 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() вони б не збіглися.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.Найкоротше. 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.
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)))
$ 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. Остання перевірка — швидкий спосіб помітити дублікати у відповіді.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.Рішення. 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.
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"])
$ 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» порахувалося двічі.[^\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.Умова. Для чисел від 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.
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"
$ 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 відокремлює генерацію від друку.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.Підхід. Читай файл по рядках генератором, щоб не залежати від розміру. Кожен рядок розбирай регулярним виразом з іменованими групами — (?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.
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
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])
$ 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 не зламав розбір і потрапив у звіт як некоректний.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.Шари. Конфігурація (стенд, секрети, таймаути) — один типізований об'єкт з оточення. Транспорт — 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.
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)
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 == {}
$ 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. Другий тест підтверджує, що після першого не лишилося даних.--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.Спершу виміряти. 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».
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
$ 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 с. Це і є список того, що виправляти першим.--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 замість очікування, асинхронна обробка на бекенді, анімації. Спільний стан: сесійна фікстура зі змінюваними даними, глобальні змінні, спільний тестовий користувач. Порядок: тест покладається на дані, створені іншим тестом. Колізії даних під 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.
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")
$ 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
sleep(0.2) проходить лише тоді, коли задача встигла, — у цьому запуску впала половина з 30 повторів, а в наступному кількість буде іншою. wait_until опитує умову кожні 20 мс і виходить, щойно статус став done: 30 з 30 зелені, і в середньому навіть швидше за sleep. Параметризація по range(30) — простий спосіб прогнати тест багато разів без плагінів.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.