Test discovery, AAA pattern, plain assert
Это первый урок Module 08 — введение в pytest, de-facto standard testing tool в Python ecosystem (pypistats показывает >100M downloads/month в 2026). pytest — production-ready replacement legacy unittest: меньше boilerplate, plain assert, fixtures-as-arguments, parametrize-tables, обширная plugin система.
В этом уроке:
- Why pytest — pragmatic over
unittest. - Test discovery — что pytest автоматически находит и почему.
- AAA pattern — Arrange / Act / Assert — universal test structure.
- Plain
assertvsunittest.TestCase.assertEqual—pytestrewrites assert AST. - Browser challenge framing — function-call
solve(...)mode = pytest делаетassertза вас.
Why pytest — pragmatic over unittest
Сравним unittest (stdlib, since 2001) и pytest (third-party, 2009+) на одной задаче — проверить, что add(2, 3) == 5:
unittest style (boilerplate):
# test_add_unittest.py
import unittest
def add(a: int, b: int) -> int:
return a + b
class TestAdd(unittest.TestCase):
def test_simple(self):
self.assertEqual(add(2, 3), 5)
def test_negative(self):
self.assertEqual(add(-1, 1), 0)
if __name__ == '__main__':
unittest.main()
pytest style (zero boilerplate):
# test_add_pytest.py
def add(a: int, b: int) -> int:
return a + b
def test_simple():
assert add(2, 3) == 5
def test_negative():
assert add(-1, 1) == 0
Что мы выкинули:
import unittest— pytest не требует import.class TestAdd(unittest.TestCase):— функция-теста достаточно.self.assertEqual(...)— plainassertдостаточно.if __name__ == '__main__': unittest.main()— pytest discovery находит функции автоматически.
Pragmatic pytest ставит функции-тесты в центр. Class-based tests тоже поддерживаются (когда нужен shared state), но default — это module-level functions.
Cite: pytest 8 docs — How to write tests; pytest 8.x — текущая major line (Python 3.9-3.13 supported per docs).
Test discovery — что pytest автоматически находит
При запуске pytest в проекте, pytest walks из rootdir (директория с pyproject.toml / pytest.ini / setup.cfg) и collects items по правилам:
| Pattern | Что pytest collect’ит |
|---|---|
test_*.py или *_test.py | Test files в любой директории под rootdir (recursive) |
Test* class в test file | Test class (без __init__ метода!) |
test_* function в test file ИЛИ test class | Test function/method |
Конкретные имена:
project/
├── pyproject.toml ← rootdir marker
├── tests/
│ ├── __init__.py ← optional; src-layout works either way
│ ├── test_calc.py ← collected (test_*.py)
│ ├── calc_test.py ← collected (*_test.py)
│ ├── helpers.py ← NOT collected (no test_ / _test pattern)
│ └── test_models.py
│ ├── def test_add() ← collected (test_*)
│ ├── def helper_fn() ← NOT collected (нет test_ префикс)
│ ├── class TestUser ← collected (Test*)
│ │ ├── def test_create() ← collected
│ │ └── def helper() ← NOT collected
│ └── class UserSpec ← NOT collected (нет Test* префикс)
└── src/mypkg/calc.py ← NOT a test; import target only
Discovery rules (хранятся в _pytest/main.py):
- File
helpers.pyбезtest_префикса — invisible для pytest. - Function
helper_fn()безtest_префикса — invisible, даже если file collected. - Class
Test*must NOT have__init__method (иначе pytest emits warning и skips). Используйте fixtures илиclass.setup_methodесли нужно state.
Pragmatic rule: прежде чем писать тест, убедитесь, что file называется test_X.py ИЛИ X_test.py, и функция начинается с test_. Этого достаточно для discovery.
Cite: pytest 8 docs — How tests are discovered.
rootdir + conftest.py boundary
rootdir — это корень test session, определяемый pytest при startup. Algorithm (упрощённо):
- Start с
args(или CWD если args empty). - Walk вверх до first directory с одним из:
pyproject.toml,pytest.ini,setup.cfg,tox.ini,setup.py. - Эта директория —
rootdir.
conftest.py — special file, который pytest автоматически загружает для каждой test directory. Fixtures и hooks из conftest.py доступны во всех test files под этой директорией (recursively). Это рассмотрим в уроке 06.
Pragmatic rule: для production-проекта всегда имейте pyproject.toml с [tool.pytest.ini_options] секцией — это фиксирует rootdir явно (a избегаете confusion если запускаете pytest из subdirectory).
AAA pattern — Arrange / Act / Assert
AAA — universal test structure, не специфичная для pytest. Тест разбит на три logical секции с пустыми строками между ними:
def test_user_age_validation():
# Arrange — подготовка inputs / fixtures / state
raw_input = '42'
# Act — call the function under test
result = parse_age(raw_input)
# Assert — verify behavior
assert result == 42
Каждая секция отвечает на один вопрос:
- Arrange: Что нужно подготовить, чтобы запустить function?
- Act: Какой один call мы тестируем?
- Assert: Какой observable behavior мы verify’им?
Pragmatic rules:
- Один Act per test. Если test делает два calls — это два теста (или first call — Arrange для второго).
- No conditional assertions.
if x: assert ...; else: assert ...— это two tests. - Visible empty lines между Arrange/Act/Assert — улучшает scan-ability.
Counter-example (плохо — что тестируется?):
def test_user():
user = User(name='Alice', age=30)
assert user.name == 'Alice'
user.age += 1
assert user.age == 31
user.greet() # side-effect
assert user.greeted_count == 1
Здесь три different behaviors в одном тесте. Если first assert падает — мы не узнаём, что с другими. Лучше — три focused tests.
Better:
def test_user_name_is_set_at_construction():
# Arrange + Act
user = User(name='Alice', age=30)
# Assert
assert user.name == 'Alice'
def test_user_age_can_be_incremented():
# Arrange
user = User(name='Alice', age=30)
# Act
user.age += 1
# Assert
assert user.age == 31
def test_greet_increments_greeted_count():
# Arrange
user = User(name='Alice', age=30)
# Act
user.greet()
# Assert
assert user.greeted_count == 1
Три теста — три independent failure messages, easier debugging.
Plain assert vs unittest.assertEqual — assert rewriting
unittest требует self-method per assertion type:
self.assertEqual(a, b)
self.assertTrue(x)
self.assertIn(item, container)
self.assertRaises(ValueError, func, args)
self.assertAlmostEqual(a, b, places=7)
pytest использует plain assert + Python expression:
assert a == b
assert x
assert item in container
# raises — see lesson 04 (pytest.raises)
assert abs(a - b) < 1e-7
Magic: pytest rewrites assert AST на load — добавляет introspection, чтобы при failure показать structure значения (rich diff), а не просто AssertionError. Cite _pytest/assertion/rewrite.py — AssertionRewritingHook import hook.
Empirical — failure message:
# test_data.py
def test_dict_equal():
a = {'name': 'Alice', 'age': 30}
b = {'name': 'Alice', 'age': 31}
assert a == b
Запуск pytest test_data.py:
================================== FAILURES ==================================
________________________________ test_dict_equal _________________________________
def test_dict_equal():
a = {'name': 'Alice', 'age': 30}
b = {'name': 'Alice', 'age': 31}
> assert a == b
E AssertionError: assert {'name': 'Alice', 'age': 30} == {'name': 'Alice', 'age': 31}
E Differing items:
E {'age': 30} != {'age': 31}
E Use -v to get more diff
pytest показал что именно отличается (age field differs 30 != 31). Без assert rewriting было бы просто AssertionError. Это — главная причина, почему plain assert в pytest достаточен для production-grade testing.
Pragmatic rule: используйте plain assert. Никаких assertEqual/assertTrue нет в pytest API — их специально нет.
Cite: pytest 8 docs — assert rewriting; _pytest/assertion/rewrite.py — internal AST rewriter.
Browser challenge framing — solve(...) IS pytest делает assert за вас
Pyodide (browser Python) не может запустить pytest напрямую — pytest не входит в Pyodide stdlib. Поэтому в browser challenges Module 08 мы используем платформенный test harness, который педагогически играет роль pytest.
Function-call mode (src/lib/testRunner.ts buildPyHarness):
# Пользователь пишет:
def solve(args):
a, b = args
return a + b
# Платформа автоматически (testRunner.ts:70-77) wrap'ает:
def solve(args):
a, b = args
return a + b
# --- Test harness (injected by platform) ---
__test_result = solve((2, 3))
print(__test_result)
Параллель с pytest:
| Browser challenge | Эквивалент в pytest |
|---|---|
def solve(args): | def test_X(): (test function) |
__test_result = solve((2, 3)) | result = your_function(2, 3) (Act) |
expectedOutput == actual | assert result == 5 (Assert) |
testCases array с input/expectedOutput | pytest.mark.parametrize table (см. урок 03) |
Pragmatic insight: the shape of testing — input → call → expected output — universal. Whether в browser (function-call mode) или local (pytest -v) или CI (pytest --cov) — паттерн тот же.
В Module 08: мы будем использовать function-call mode для practical challenges (lesson 04 typed exception, lesson 05 mock direct), и Pattern 4 (string-analysis triple-quote — lesson 06 conftest.py) когда нужно демонстрировать pytest syntax без runtime execution.
Cite: src/lib/testRunner.ts lines 70-77 — buildPyHarness wraps user solve(args) с __test_result = solve(<input>); print(__test_result).
Recipe — full AAA test function example
Production-grade test function:
# test_calc.py
def add(a: int, b: int) -> int:
return a + b
def test_add_returns_sum_of_two_positive_ints():
"""add(2, 3) returns 5. AAA pattern."""
# Arrange
a, b = 2, 3
expected = 5
# Act
result = add(a, b)
# Assert
assert result == expected
def test_add_handles_negative_numbers():
"""add(-1, 1) returns 0."""
# Arrange
a, b = -1, 1
# Act
result = add(a, b)
# Assert
assert result == 0
def test_add_returns_zero_when_both_zero():
"""add(0, 0) returns 0 — boundary case."""
# Arrange + Act
result = add(0, 0)
# Assert
assert result == 0
Запуск:
pytest test_calc.py -v
Expected output:
test_calc.py::test_add_returns_sum_of_two_positive_ints PASSED
test_calc.py::test_add_handles_negative_numbers PASSED
test_calc.py::test_add_returns_zero_when_both_zero PASSED
============================== 3 passed in 0.02s ==============================
Test names — sentence-style descriptions (test_<subject>_<expected_behavior>). Это даёт readable output: failure log читается как specification.
Ключевые выводы
- pytest — pragmatic standard для Python testing. Zero boilerplate vs
unittest: функции вместо classes, plainassertвместоassertEqual. - Test discovery — pytest collect’ит
test_*.py/*_test.pyfiles +Test*classes (без__init__!) +test_*functions. Имена — главный сигнал. - rootdir определяется по
pyproject.toml/pytest.ini/setup.cfgmarker. Always include[tool.pytest.ini_options]для project-grade hygiene. - AAA pattern — Arrange / Act / Assert — universal structure. Один Act per test, no conditional assertions, visible empty lines.
- Plain
assert— pytest rewrites assert AST (_pytest/assertion/rewrite.py) для rich diffs (показывает structure при failure).assertEqualне нужен. - Browser challenge framing —
def solve(args):+testCasesarray параллелит pytest test function +pytest.mark.parametrizetable. Same shape, разные surfaces.
Дальше — fixtures (урок 02): setup/teardown через decorator, scope hierarchy, и climax cross-link к M06 урок 05 — yield-style fixtures уже знаем через @contextmanager.