Deep Engineering

MEASUREMENT

bench/testing/basics.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/python/unit-testing
How to run it
python3.11 bench/testing/basics.py
python3.12 bench/testing/basics.py
python3.13 bench/testing/basics.py
python3.14 bench/testing/basics.py

python3.13 -m venv .venv-pytest
.venv-pytest/bin/pip install pytest==8.4.2
.venv-pytest/bin/python bench/testing/pytest_run.py
.venv-pytest/bin/python bench/testing/checkout_run.py

python3.11 bench/testing/practice.py
python3.12 bench/testing/practice.py
python3.13 bench/testing/practice.py
python3.14 bench/testing/practice.py

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для урока «Тестирование»

Скрипт Что делает
basics.py наблюдения на стандартной библиотеке: FAIL против ERROR, Mock и его поля, опечатки в именах проверок, где патчить, Mock против create_autospec, порядок тестов и setUp, таблица Mock / spec / spec_set / autospec (раздел 7)
pytest_run.py запускает pytest_examples.py под pytest и печатает вывод без времени и путей: разложенный assert, parametrize, фикстура с yield, pytest.raises, исключение в теле теста (FAILED) и упавшая фикстура (ERROR)
pytest_examples.py примеры тестов pytest; четыре не проходят нарочно — три FAILED и один ERROR. Назван не test_*.py, чтобы pytest не подхватывал его сам
checkout.py, test_checkout.py полный зелёный пример урока: рабочий код и тесты к нему (результат — главное утверждение, вызов mock — дополнительное; parametrize, фикстура). Запуск — python -m pytest -v в каталоге с обоими файлами
checkout_run.py копирует оба файла в пустой каталог, запускает python -m pytest, затем портит копию тремя способами и запускает снова
practice.py источник ответов двух практических задач урока
python3.11 bench/testing/basics.py
python3.12 bench/testing/basics.py
python3.13 bench/testing/basics.py
python3.14 bench/testing/basics.py

python3.13 -m venv .venv-pytest
.venv-pytest/bin/pip install pytest==8.4.2
.venv-pytest/bin/python bench/testing/pytest_run.py
.venv-pytest/bin/python bench/testing/checkout_run.py

python3.11 bench/testing/practice.py
python3.12 bench/testing/practice.py
python3.13 bench/testing/practice.py
python3.14 bench/testing/practice.py

Записи в runs/: без суффикса — CPython 3.13.7, остальные — с суффиксом версии (3.11.15, 3.12.3, 3.14.7); pytest_run.txt и checkout_run.txt — CPython 3.13.7 и pytest 8.4.2; всё — linux/amd64. basics*.txt перезапущены 14.10.2026 после добавления раздела 7 — разделы 1–6 не изменились.

Что получилось

FAIL против ERROR: assertEqual(add(2, 2), 5) — FAIL, голый assert — тоже FAIL (AssertionError), add(2, "2") — ERROR с TypeError; итого тестов 4, failures 2, errors 1.

pytest: 9 тестов — 3 failed, 5 passed, 1 error. Исключение в теле теста (test_type_error) — FAILED, упавшая фикстура (test_with_broken_fixture) — ERROR; тесты идут в порядке файла.

Практика: Mock(return_value=10), вызванный как m(1) и m(2), — 10 10 2 и call(2); в TestCase с пятью тестами setUp вызван 5 раз.

Опечатки в проверках (mock ни разу не вызывали): asert_called_once и assret_called_once — AttributeError на всех версиях; called_once — на 3.11 проходит молча, с 3.12 — AttributeError; was_called_once — молча на всех версиях.

Где патчить: под patch('time.time') модуль с import time видит подмену, модуль с from time import time — нет; patch('app_from.time') подменяет.

Спецификация: голый Mock принял charge(100) без валюты и метод chrage; create_autospec ответил TypeError: missing a required argument: 'currency' и AttributeError.

Порядок: объявлены test_zebra, test_apple, test_mango — выполнены apple, mango, zebra; setUpClass — 1 раз, setUp — 3.

Остальные строки вывода одинаковы на 3.11–3.14.

Mock / spec / spec_set / autospec (раздел 7, добавлен 14.10.2026 по внешнему аудиту, замечание PY-TEST-01; одинаково на 3.11–3.14). Несуществующее имя m.chrage: голый Mock — принято, остальные три — AttributeError. Присваивание m.extra = 1: AttributeError только у spec_set (и у create_autospec(..., spec_set=True)). Вызов m.charge(100) без валюты: Mock(), Mock(spec=…) и Mock(spec_set=…) — принято, create_autospec — TypeError: missing a required argument: 'currency'. m.was_called_once(): голый Mock — принято, со спецификацией любого вида — AttributeError.

Полный пример (checkout_run.txt): как есть — 4 passed. Дефект «скидка прибавляется» — 1 failed (assert 130 == 70). Дефект «charge без валюты»: с фикстурой на create_autospec — 4 failed (TypeError), с фикстурой на Mock(spec=PaymentClient) — 1 failed, 3 passed: тесты, проверяющие только результат, дефект не видят, его ловит лишь assert_called_once_with.

Script

240 lines
"""Тестирование: unittest и unittest.mock — что на самом деле проверяется.

Только стандартная библиотека, чтобы скрипт работал на всех версиях без
установки пакетов. pytest — в pytest_run.py, отдельно.

СЕМЬ СЮЖЕТОВ:

1. FAIL ПРОТИВ ERROR. Упавшая проверка и исключение в тесте — разные
   исходы, и unittest их различает.

2. MOCK. Любой атрибут Mock — тоже Mock; вызовы записываются; return_value
   и side_effect задают поведение.

3. ОПЕЧАТКА В ASSERT-МЕТОДЕ. Mock создаёт атрибут на любое имя, поэтому
   опечатка в имени проверки может превратить проверку в пустой вызов.
   Часть опечаток Mock ловит — и с 3.12 больше, чем раньше.

4. PATCH ТАМ, ГДЕ ИМЯ ИЩУТ. `from time import time` в модуле копирует ссылку
   (урок «Модули и импорт»), и patch("time.time") до неё не доходит.

5. SPEC И AUTOSPEC. Mock без спецификации принимает любой вызов и любой
   атрибут; с create_autospec неверная сигнатура и опечатка в атрибуте —
   ошибки.

6. ПОРЯДОК И ПОДГОТОВКА. unittest запускает тесты по алфавиту, а не в
   порядке объявления; setUp вызывается перед каждым тестом,
   setUpClass — один раз на класс.

7. MOCK, SPEC, SPEC_SET, AUTOSPEC — ПО СТРОКАМ. Какие проверки включает
   каждый вид: существование атрибута, присваивание нового атрибута,
   сигнатура вызова, опечатка в имени проверки. Главное: Mock(spec=…)
   ограничивает имена, но сигнатуру НЕ проверяет — `charge(100)` без
   валюты он принимает; проверяет её только create_autospec. Раздел
   добавлен в конец 14.10.2026 по внешнему аудиту (PY-TEST-01), чтобы не
   сдвигать разделы 1–6.

Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import io
import os
import sys
import tempfile
import textwrap
import unittest
from unittest import mock

print("PY", sys.version.split()[0])
print()


def run_case(case_cls):
    """Прогнать TestCase и вернуть исходы по тестам — без времени и путей."""
    suite = unittest.defaultTestLoader.loadTestsFromTestCase(case_cls)
    # Имена — до запуска: suite.run() освобождает выполненные тесты.
    outcomes = {test._testMethodName: "ok" for test in suite}
    result = unittest.TestResult()
    suite.run(result)
    for test, tb in result.failures:
        outcomes[test._testMethodName] = "FAIL: " + tb.strip().splitlines()[-1]
    for test, tb in result.errors:
        outcomes[test._testMethodName] = "ERROR: " + tb.strip().splitlines()[-1]
    return result, outcomes


# ── 1 ─────────────────────────────────────────────────────────────────────
print("1) unittest: FAIL против ERROR")


def add(a, b):
    return a + b


class AddTest(unittest.TestCase):
    def test_passes(self):
        self.assertEqual(add(2, 2), 4)

    def test_fails(self):
        self.assertEqual(add(2, 2), 5)

    def test_errors(self):
        self.assertEqual(add(2, "2"), 4)

    def test_bare_assert(self):
        assert add(2, 2) == 5


result, outcomes = run_case(AddTest)
for name, outcome in outcomes.items():
    print(f"   {name:<17}-> {outcome}")
print(f"   итого: тестов {result.testsRun}, failures {len(result.failures)}, errors {len(result.errors)}")
print()

# ── 2 ─────────────────────────────────────────────────────────────────────
print("2) Mock: атрибуты, вызовы, return_value, side_effect")
api = mock.Mock()
print("   api.get_user(1)                ->", type(api.get_user(1)).__name__)
print("   api.get_user.call_args         ->", api.get_user.call_args)
print("   api.get_user.call_count        ->", api.get_user.call_count)
api.get_user.assert_called_once_with(1)
print("   assert_called_once_with(1)     -> прошло")
api.get_user.return_value = {"id": 1}
print("   return_value = {...}; вызов    ->", api.get_user(1))
api.fetch.side_effect = [10, 20, TimeoutError("нет ответа")]
got = [api.fetch(), api.fetch()]
try:
    api.fetch()
except TimeoutError as e:
    got.append(f"TimeoutError: {e}")
print("   side_effect=[10, 20, исключение] ->", got)
print()

# ── 3 ─────────────────────────────────────────────────────────────────────
print("3) опечатка в имени проверки (mock ни разу не вызывали)")
for name in ["assert_called_once", "asert_called_once", "assret_called_once", "called_once", "was_called_once"]:
    m = mock.Mock()
    try:
        getattr(m, name)()
        outcome = "прошло молча — проверки не было"
    except AttributeError as e:
        outcome = f"AttributeError: {e}"
    except AssertionError as e:
        outcome = f"AssertionError: {e}"
    print(f"   m.{name + '()':<22}-> {outcome}")
print()

# ── 4 ─────────────────────────────────────────────────────────────────────
print("4) patch: подменять там, где имя ищут")
tmp = tempfile.mkdtemp()
with open(os.path.join(tmp, "app_import.py"), "w", encoding="utf-8") as f:
    f.write(textwrap.dedent("""
        import time

        def now():
            return time.time()
    """))
with open(os.path.join(tmp, "app_from.py"), "w", encoding="utf-8") as f:
    f.write(textwrap.dedent("""
        from time import time

        def now():
            return time()
    """))
sys.path.insert(0, tmp)
sys.dont_write_bytecode = True
import app_from  # noqa: E402
import app_import  # noqa: E402

with mock.patch("time.time", return_value=42.0):
    print("   patch('time.time'):     app_import.now() ->", app_import.now(),
          "| app_from.now() == 42.0 ->", app_from.now() == 42.0)
with mock.patch("app_from.time", return_value=42.0):
    print("   patch('app_from.time'): app_from.now()   ->", app_from.now())
print()

# ── 5 ─────────────────────────────────────────────────────────────────────
print("5) спецификация: Mock против create_autospec")


class PaymentClient:
    def charge(self, amount, currency):
        raise NotImplementedError


loose = mock.Mock()
strict = mock.create_autospec(PaymentClient, instance=True)


def attempt(fn):
    try:
        fn()
        return "принято"
    except (TypeError, AttributeError) as e:
        return f"{type(e).__name__}: {e}"


print("   Mock().charge(100)                ->", attempt(lambda: loose.charge(100)))
print("   autospec.charge(100)              ->", attempt(lambda: strict.charge(100)))
print("   autospec.charge(100, 'RUB')       ->", attempt(lambda: strict.charge(100, "RUB")))
print("   Mock().chrage(100, 'RUB')         ->", attempt(lambda: loose.chrage(100, "RUB")))
print("   autospec.chrage(100, 'RUB')       ->", attempt(lambda: strict.chrage(100, "RUB")))
print()

# ── 6 ─────────────────────────────────────────────────────────────────────
print("6) порядок тестов и подготовка")
calls = []


class OrderTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        calls.append("setUpClass")

    def setUp(self):
        calls.append("setUp")

    def test_zebra(self):
        calls.append("test_zebra")

    def test_apple(self):
        calls.append("test_apple")

    def test_mango(self):
        calls.append("test_mango")


run_case(OrderTest)
print("   объявлены: test_zebra, test_apple, test_mango")
print("   выполнены:", [c for c in calls if c.startswith("test_")])
print("   setUpClass:", calls.count("setUpClass"), "| setUp:", calls.count("setUp"))

# ── 7 ─────────────────────────────────────────────────────────────────────
print()
print("7) Mock, spec, spec_set, autospec: какие проверки включает каждый")
print("   (у PaymentClient один метод charge(amount, currency); mock на каждую строку свежий)")
kinds = [
    ("Mock()", lambda: mock.Mock()),
    ("Mock(spec=PaymentClient)", lambda: mock.Mock(spec=PaymentClient)),
    ("Mock(spec_set=PaymentClient)", lambda: mock.Mock(spec_set=PaymentClient)),
    ("create_autospec(PaymentClient, instance=True)", lambda: mock.create_autospec(PaymentClient, instance=True)),
]


def assign_extra(m):
    m.extra = 1


rows = [
    ("существование атрибута: m.chrage", lambda m: m.chrage),
    ("присваивание нового атрибута: m.extra = 1", assign_extra),
    ("сигнатура вызова: m.charge(100) без currency", lambda m: m.charge(100)),
    ("опечатка в проверке: m.was_called_once()", lambda m: m.was_called_once()),
]
for title, action in rows:
    print("   " + title)
    for label, make in kinds:
        m = make()
        print(f"      {label:<46}->", attempt(lambda: action(m)))
strict_set = mock.create_autospec(PaymentClient, instance=True, spec_set=True)
print("   create_autospec(..., spec_set=True): m.extra = 1 ->", attempt(lambda: assign_extra(strict_set)))