Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 09.01 · 18 мин
Средний
pytestTest discoveryAAA patternArrange Act Assertplain assertassert rewritingrootdirconftest.py boundary
Требуемые знания:

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 система.

В этом уроке:

  1. Why pytest — pragmatic over unittest.
  2. Test discovery — что pytest автоматически находит и почему.
  3. AAA pattern — Arrange / Act / Assert — universal test structure.
  4. Plain assert vs unittest.TestCase.assertEqualpytest rewrites assert AST.
  5. 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

Что мы выкинули:

  1. import unittest — pytest не требует import.
  2. class TestAdd(unittest.TestCase): — функция-теста достаточно.
  3. self.assertEqual(...) — plain assert достаточно.
  4. 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.pyTest files в любой директории под rootdir (recursive)
Test* class в test fileTest class (без __init__ метода!)
test_* function в test file ИЛИ test classTest 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 (упрощённо):

  1. Start с args (или CWD если args empty).
  2. Walk вверх до first directory с одним из: pyproject.toml, pytest.ini, setup.cfg, tox.ini, setup.py.
  3. Эта директория — 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:

  1. Один Act per test. Если test делает два calls — это два теста (или first call — Arrange для второго).
  2. No conditional assertions. if x: assert ...; else: assert ... — это two tests.
  3. 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.pyAssertionRewritingHook 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 == actualassert result == 5 (Assert)
testCases array с input/expectedOutputpytest.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.


Ключевые выводы

  1. pytest — pragmatic standard для Python testing. Zero boilerplate vs unittest: функции вместо classes, plain assert вместо assertEqual.
  2. Test discovery — pytest collect’ит test_*.py / *_test.py files + Test* classes (без __init__!) + test_* functions. Имена — главный сигнал.
  3. rootdir определяется по pyproject.toml / pytest.ini / setup.cfg marker. Always include [tool.pytest.ini_options] для project-grade hygiene.
  4. AAA pattern — Arrange / Act / Assert — universal structure. Один Act per test, no conditional assertions, visible empty lines.
  5. Plain assert — pytest rewrites assert AST (_pytest/assertion/rewrite.py) для rich diffs (показывает structure при failure). assertEqual не нужен.
  6. Browser challenge framingdef solve(args): + testCases array параллелит pytest test function + pytest.mark.parametrize table. Same shape, разные surfaces.

Дальше — fixtures (урок 02): setup/teardown через decorator, scope hierarchy, и climax cross-link к M06 урок 05 — yield-style fixtures уже знаем через @contextmanager.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7