Главная/Testing/Урок

15.1. Стратегия тестирования

Цели

После этого материала вы сможете:

  • назвать полную цену теста с зависимостью — не только время;
  • определить, какие проверки настоящая база данных ловит, а подделка не ловит;
  • объяснить, почему для тонкого сервиса классическая пирамида тестов не работает;
  • разделить тестирование приложения и тестирование образа;
  • выбрать между подделкой и настоящей зависимостью по правилу, а не по привычке;
  • измерить стоимость своего набора тестов и найти в нём лишнее.

Предварительные знания

Ключевые термины

ТерминОбъяснение
unit-тестПроверяет функцию или класс без внешних зависимостей
integration-тестПроверяет взаимодействие с настоящей зависимостью
подделкаЗаменитель зависимости: mock, fake, stub
нестабильностьСвойство теста падать без изменения кода
контрактСоглашение о поведении между вашим кодом и чужим

Теория

Полная цена теста

Время выполнения — очевидная часть цены, но не главная.

СоставляющаяUnit-тестТест с container
Время выполненияМиллисекундыСекунды
Время старта окруженияНет1–20 секунд на набор
Способов отказатьОдин: код неверенМного: код, образ, порт, сеть, память, гонка
Что означает падениеКод сломанВопрос: код или окружение?
Стоимость сопровожденияНизкаяСредняя

Третья и четвёртая строки важнее первой.

Упавший unit-тест — это сигнал: код сломан, иди чинить. Упавший integration-тест — это вопрос: сломан код, или не поднялась база, или занят порт, или CI-сборщик медленнее обычного.

Отсюда цена нестабильности: тест, падающий один раз из двадцати без причины, приучает игнорировать красный результат. После этого он не находит ошибок — он только тратит время.

Практический вывод: тест с зависимостью должен оправдывать не только своё время, но и риск ложных срабатываний.

Что подделка поймать не может

Замена базы данных на подделку убирает целый класс проверок:

ПроверкаПодделкаНастоящая БД
Логика приложенияДаДа, но медленнее
Синтаксис SQLНетДа
Особенности диалектаНетДа
Ограничения целостностиНетДа
Поведение транзакцийНетДа
МиграцииНетДа
Отображение типов драйверомНетДа
Поведение при конкурентностиНетДа

Строки 2–8 — это то, ради чего integration-тесты существуют. Подделка psycopg вернёт то, что вы ей велели вернуть; она не сообщит, что запрос содержит синтаксическую ошибку или что ON CONFLICT в этой версии работает иначе.

Пример, который встречается постоянно: код работает с подделкой и падает в эксплуатации, потому что datetime без часового пояса драйвер отдаёт иначе, чем ожидалось. Ни один unit-тест этого не найдёт.

Что подделка ловит лучше

Обратное тоже верно:

СитуацияЧем проверять
Обработка ошибки сетиПодделкой: настоящую сеть сложно сломать по требованию
Ответ внешнего API с кодом 500Подделкой
Медленный ответ, таймаутПодделкой
Редкое состояние гонкиПодделкой
Ветвление по бизнес-правилуПодделкой: настоящая БД ничего не добавляет

Правило выбора формулируется через владение контрактом:

text
Контракт мой и стабилен          → подделка
Контракт чужой (СУБД, протокол)  → настоящая зависимость
Нужно вызвать редкую ситуацию    → подделка

Почему пирамида не всегда работает

Классическая пирамида — много unit-тестов, меньше integration, совсем мало e2e — предполагает, что в приложении есть значительный слой логики, который можно проверять изолированно.

Для типового веб-сервиса это часто не так:

text
Сервис с богатой логикой          Тонкий сервис (CRUD, API-шлюз)

        /\  e2e                          /\  e2e
       /  \                             /  \
      /    \  integration              /    \
     /______\                         /      \  integration
    /        \                       /        \
   /   unit   \                     /__________\
  /____________\                       unit

  пирамида уместна                 пирамида перевёрнута

Тонкий сервис состоит из проверки запроса, обращения к базе и сериализации ответа. Логики, которую стоит проверять изолированно, в нём мало — а вот SQL, миграции и сериализация типов требуют настоящей базы.

Вывод: форма набора тестов следует из устройства сервиса, а не наоборот. Стремление «довести unit-покрытие до 80 %» в тонком сервисе даёт тесты, проверяющие вызов подделки — то есть проверяющие тест.

Тестирование приложения и тестирование образа

Два разных вопроса, которые постоянно смешивают:

ВопросЧто проверяетГде выполняется
«Код правильный?»Логика, SQL, обработка ошибокПротив исходного кода
«Артефакт правильный?»Зависимости, пользователь, точка входа, файлыПротив собранного образа

pytest прошёл в вашем окружении разработки — это не говорит ничего о том, что:

  • нужная зависимость попала в образ;
  • она попала в нужной версии;
  • точка входа запускает то, что нужно;
  • приложение работает не от root;
  • файлы конфигурации на месте.

Каждый пункт — отдельная категория отказа, обнаруживаемая только проверкой артефакта (урок 15.5).

Уровни для контейнеризованного сервиса

УровеньЧто проверяетЗависимостиГде запускать
UnitФункции, классыНетЛокально и в CI, всегда
КомпонентМодуль с подделками границПодделкиТам же
IntegrationВзаимодействие с настоящей БДContainerCI, при каждом изменении
ContractСовместимость с чужим APIЗаписанные ответыCI
Валидация образаСвойства артефактаСобранный образПосле сборки
E2EСценарий целикомВесь стекПеред выпуском

Последние два часто пропускают. Первый — потому что «тесты же прошли»; второй — потому что дорого.


Внутренний механизм

Откуда берётся нестабильность

Три источника, по убыванию частоты:

Гонка при старте. Тест обращается к базе раньше, чем она готова принимать соединения. Проявляется на медленной машине или под нагрузкой (урок 15.3).

Общее состояние. Два теста пишут в одну таблицу; результат зависит от порядка. Проявляется при параллельном запуске или после перестановки тестов.

Конкуренция за ресурс. Порт занят другим прогоном, память ограничена, диск заполнен. Проявляется в CI, где параллельно идут другие задачи.

Все три устраняются, и способы разобраны в следующих уроках. Важно другое: нестабильный тест хуже отсутствующего, потому что тратит внимание и обесценивает красный результат.

Почему sleep не решает гонку

sleep 10 перед тестами кажется простым решением. Он плох дважды:

Слишком долго в обычном случае. База поднимается за 2 секунды — 8 секунд теряются на каждом прогоне.

Слишком мало в плохом случае. На загруженном CI-сборщике база поднимется за 15 секунд, и тест упадёт — но не всегда, а иногда. Это и есть нестабильность.

Правильное решение — ожидание условия, а не времени: проверять готовность в цикле с ограничением сверху.


Команды и примеры

Идентификаторы в коде — латиницей, как во всём курсе. Русский язык остаётся в комментариях, документирующих строках и выводе программ. Смешение алфавитов внутри одного идентификатора — источник трудноуловимых ошибок и в коде курса не встречается.

Измерение полной цены

bash
mkdir -p /tmp/strategy && cd /tmp/strategy

cat > app.py <<'PY'
"""Тонкий сервис: проверка входных данных, обращение к БД, сериализация.

Логики, которую стоит проверять изолированно, здесь немного —
это типичное устройство CRUD-сервиса.
"""
from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Protocol


class Storage(Protocol):
    def find_by_email(self, email: str) -> dict | None: ...
    def create(self, email: str, name: str) -> int: ...


@dataclass
class Result:
    ok: bool
    data: dict | None = None
    error: str | None = None


EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")


def is_valid_email(email: str) -> bool:
    """Чистая функция: unit-тест здесь уместен и дёшев."""
    return bool(EMAIL.match(email)) and len(email) <= 254


def normalize_name(name: str) -> str:
    """Тоже чистая функция."""
    return " ".join(name.split()).strip()[:100]


def register(storage: Storage, email: str, name: str) -> Result:
    """Тонкий слой: проверка, обращение к хранилищу, сборка ответа.

    Подделка хранилища проверит ветвления. Она НЕ проверит,
    что SQL внутри реализации корректен.
    """
    if not is_valid_email(email):
        return Result(False, error="неверный email")

    clean_name = normalize_name(name)
    if not clean_name:
        return Result(False, error="пустое имя")

    if storage.find_by_email(email) is not None:
        return Result(False, error="уже зарегистрирован")

    ident = storage.create(email, clean_name)
    return Result(True, data={"id": ident, "email": email, "name": clean_name})
PY

cat > test_unit.py <<'PY'
"""Unit-тесты: без зависимостей, миллисекунды."""
import pytest

from app import is_valid_email, normalize_name, register


class FakeStorage:
    """Подделка: проверяет ветвления, но не SQL."""

    def __init__(self, existing: set[str] | None = None) -> None:
        self.existing = existing or set()
        self.created: list[tuple[str, str]] = []

    def find_by_email(self, email: str) -> dict | None:
        return {"email": email} if email in self.existing else None

    def create(self, email: str, name: str) -> int:
        self.created.append((email, name))
        return len(self.created)


@pytest.mark.parametrize("email,expected", [
    ("user@example.com", True),
    ("имя@домен.рф", True),
    ("без-собаки", False),
    ("два@@собаки.com", False),
    ("", False),
    ("a" * 250 + "@x.ru", False),
])
def test_email_validation(email: str, expected: bool) -> None:
    assert is_valid_email(email) is expected


@pytest.mark.parametrize("raw,clean", [
    ("  Иван   Петров ", "Иван Петров"),
    ("\tИмя\n", "Имя"),
    ("   ", ""),
])
def test_name_normalization(raw: str, clean: str) -> None:
    assert normalize_name(raw) == clean


def test_successful_registration() -> None:
    storage = FakeStorage()
    result = register(storage, "new@example.com", "  Новый  Пользователь ")
    assert result.ok
    assert result.data["name"] == "Новый Пользователь"
    assert storage.created == [("new@example.com", "Новый Пользователь")]


def test_duplicate_rejected() -> None:
    result = register(FakeStorage({"taken@example.com"}), "taken@example.com", "Имя")
    assert not result.ok
    assert result.error == "уже зарегистрирован"


def test_invalid_email_rejected() -> None:
    result = register(FakeStorage(), "плохой", "Имя")
    assert not result.ok
    assert result.error == "неверный email"
PY

echo "═══ прогон unit-тестов ═══"
docker run --rm -v "$PWD:/w" -w /w python:3.13-slim sh -c '
    pip install --quiet --no-cache-dir pytest==9.1.1 > /dev/null 2>&1
    python -m pytest test_unit.py -q --tb=short 2>&1 | tail -4
' 2>/dev/null | sed 's/^/  /'

Ожидаемый вывод:

text
═══ прогон unit-тестов ═══
  ............                                                         [100%]
  12 passed in 0.06s

Двенадцать тестов за 0,06 секунды. Способов отказать у них ровно один: код неверен.

Что настоящая база находит, а подделка — нет

bash
cd /tmp/strategy
cat > repo.py <<'PY'
"""Реализация хранилища на SQL. Именно здесь живут ошибки,
которые подделка не обнаруживает."""
from __future__ import annotations

import sqlite3

SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    email      TEXT NOT NULL UNIQUE,
    name       TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""


class SqlStorage:
    def __init__(self, conn: sqlite3.Connection) -> None:
        self.conn = conn
        self.conn.executescript(SCHEMA)

    def find_by_email(self, email: str) -> dict | None:
        row = self.conn.execute(
            "SELECT id, email, name FROM users WHERE email = ?", (email,)
        ).fetchone()
        return {"id": row[0], "email": row[1], "name": row[2]} if row else None

    def create(self, email: str, name: str) -> int:
        cur = self.conn.execute(
            "INSERT INTO users (email, name) VALUES (?, ?)", (email, name))
        self.conn.commit()
        return cur.lastrowid
PY

cat > test_with_db.py <<'PY'
"""Тесты против настоящей БД: находят то, чего подделка не видит."""
import sqlite3

import pytest

from app import register
from repo import SqlStorage


@pytest.fixture
def storage() -> SqlStorage:
    conn = sqlite3.connect(":memory:")
    try:
        yield SqlStorage(conn)
    finally:
        conn.close()


def test_sql_is_valid(storage: SqlStorage) -> None:
    """Подделка этого не проверит: у неё нет SQL."""
    assert storage.find_by_email("нет@example.com") is None


def test_unique_constraint_enforced(storage: SqlStorage) -> None:
    """Ограничение UNIQUE существует только в настоящей БД."""
    storage.create("dup@example.com", "Первый")
    with pytest.raises(sqlite3.IntegrityError):
        storage.create("dup@example.com", "Второй")


def test_default_value_applied(storage: SqlStorage) -> None:
    """DEFAULT вычисляет БД, а не приложение."""
    ident = storage.create("def@example.com", "Имя")
    row = storage.conn.execute(
        "SELECT created_at FROM users WHERE id = ?", (ident,)).fetchone()
    assert row[0], "поле created_at не заполнено значением по умолчанию"


def test_cyrillic_roundtrip(storage: SqlStorage) -> None:
    """Кодировка — свойство драйвера и БД, не приложения."""
    ident = storage.create("рус@пример.рф", "Иван Петров")
    found = storage.find_by_email("рус@пример.рф")
    assert found["name"] == "Иван Петров"
    assert found["id"] == ident


def test_full_path(storage: SqlStorage) -> None:
    """Путь через все слои: проверка, SQL, сериализация."""
    result = register(storage, "full@example.com", " Полный  Путь ")
    assert result.ok
    assert storage.find_by_email("full@example.com")["name"] == "Полный Путь"
PY

echo "═══ прогон тестов с базой ═══"
docker run --rm -v "$PWD:/w" -w /w python:3.13-slim sh -c '
    pip install --quiet --no-cache-dir pytest==9.1.1 > /dev/null 2>&1
    python -m pytest test_with_db.py -q --tb=short 2>&1 | tail -4
' 2>/dev/null | sed 's/^/  /'

echo "═══ что нашли эти тесты и не нашли бы unit ═══"
cat <<'TXT'
  test_sql_is_valid              подделка вернула бы что велено
  test_unique_constraint         UNIQUE есть только в БД
  test_default_value_applied     DEFAULT вычисляет БД
  test_cyrillic_roundtrip        кодировка — свойство драйвера

  Ни одна из четырёх проверок невозможна с подделкой хранилища:
  подделка не выполняет SQL и не имеет схемы.
TXT

Ожидаемый вывод:

text
═══ прогон тестов с базой ═══
  .....                                                                [100%]
  5 passed in 0.09s
═══ что нашли эти тесты и не нашли бы unit ═══
  test_sql_is_valid              подделка вернула бы что велено
  test_unique_constraint         UNIQUE есть только в БД
  test_default_value_applied     DEFAULT вычисляет БД
  test_cyrillic_roundtrip        кодировка — свойство драйвера

  Ни одна из четырёх проверок невозможна с подделкой хранилища:
  подделка не выполняет SQL и не имеет схемы.

Здесь база — SQLite в памяти, поэтому прогон быстрый. Он проверяет наличие SQL и схемы, но не проверяет диалект PostgreSQL.

Это отдельный уровень: SQLite ловит синтаксические ошибки и отсутствующие столбцы, но не ловит особенности целевой СУБД. Настоящий PostgreSQL в container — следующий шаг (урок 15.3).

Стоимость трёх уровней

bash
cd /tmp/strategy
cat > measure.py <<'PY'
"""Сравнение полной цены трёх уровней тестирования.

Время — лишь одна составляющая. Число способов отказать
определяет, будет ли красный результат сигналом или вопросом.
"""
from __future__ import annotations

LEVELS = [
    {
        "name": "unit (подделки)",
        "startup_s": 0.0,
        "per_test_ms": 5,
        "failure_modes": ["код неверен"],
        "catches": ["ветвления", "чистые функции", "обработка входных данных"],
        "misses": ["SQL", "схема", "миграции", "типы драйвера", "транзакции"],
    },
    {
        "name": "integration (SQLite в памяти)",
        "startup_s": 0.05,
        "per_test_ms": 20,
        "failure_modes": ["код неверен", "схема несовместима"],
        "catches": ["синтаксис SQL", "схема", "ограничения", "значения по умолчанию"],
        "misses": ["диалект целевой СУБД", "поведение при конкурентности",
                   "особенности драйвера"],
    },
    {
        "name": "integration (PostgreSQL в container)",
        "startup_s": 6.0,
        "per_test_ms": 40,
        "failure_modes": ["код неверен", "образ не скачался", "порт занят",
                          "БД не успела подняться", "не хватило памяти",
                          "состояние от прошлого теста"],
        "catches": ["диалект", "миграции", "типы", "транзакции"],
        "misses": ["ошибки в собранном образе приложения"],
    },
]


def cost(level: dict, n_tests: int) -> dict[str, float]:
    seconds = level["startup_s"] + n_tests * level["per_test_ms"] / 1000
    return {"seconds": round(seconds, 2), "modes": len(level["failure_modes"])}


def main() -> None:
    n = 50
    print(f"  Набор из {n} тестов:\n")
    print(f"  {'уровень':<38} {'время':>9} {'способов отказать':>19}")
    print("  " + "─" * 70)
    for level in LEVELS:
        c = cost(level, n)
        print(f"  {level['name']:<38} {c['seconds']:>7} с {c['modes']:>17}")

    print()
    print("  Что ловит каждый уровень СВЕРХ предыдущего:")
    seen: set[str] = set()
    for level in LEVELS:
        new = [x for x in level["catches"] if x not in seen]
        seen.update(level["catches"])
        print(f"    {level['name']:<38} {', '.join(new)}")

    print()
    print("  Чего не ловит верхний уровень:")
    print(f"    {', '.join(LEVELS[-1]['misses'])}")
    print("    → это задача валидации образа, а не тестов приложения")


if __name__ == "__main__":
    main()
PY

echo "═══ сравнение уровней ═══"
python3 measure.py

Ожидаемый вывод:

text
═══ сравнение уровней ═══
  Набор из 50 тестов:

  уровень                                    время   способов отказать
  ──────────────────────────────────────────────────────────────────────
  unit (подделки)                             0.25 с                 1
  integration (SQLite в памяти)               1.05 с                 2
  integration (PostgreSQL в container)        8.0 с                  6

  Что ловит каждый уровень СВЕРХ предыдущего:
    unit (подделки)                        ветвления, чистые функции, обработка входных данных
    integration (SQLite в памяти)          синтаксис SQL, схема, ограничения, значения по умолчанию
    integration (PostgreSQL в container)   диалект, миграции, типы, транзакции

  Чего не ловит верхний уровень:
    ошибки в собранном образе приложения
    → это задача валидации образа, а не тестов приложения

Столбец «способов отказать» растёт быстрее времени: с 1 до 6. Это и есть скрытая цена — вероятность, что красный результат окажется не про код.

Последние две строки очерчивают границу: даже полный набор integration-тестов ничего не говорит о собранном образе.

Форма набора тестов зависит от сервиса

bash
cd /tmp/strategy
cat > shape.py <<'PY'
"""Рекомендуемое распределение тестов по устройству сервиса.

Классическая пирамида предполагает значительный слой логики.
В тонком сервисе его нет, и форма меняется.
"""
from __future__ import annotations

SERVICES = [
    {
        "kind": "Сервис с богатой логикой",
        "examples": "расчёт тарифов, планировщик, движок правил",
        "unit": 70, "integration": 25, "e2e": 5,
        "why": "логика проверяется изолированно, дёшево и надёжно",
    },
    {
        "kind": "Тонкий CRUD-сервис",
        "examples": "REST над таблицей, административный интерфейс",
        "unit": 25, "integration": 65, "e2e": 10,
        "why": "логики мало; ценность в SQL, схеме и сериализации",
    },
    {
        "kind": "Шлюз или прокси",
        "examples": "агрегация чужих API, преобразование форматов",
        "unit": 30, "integration": 20, "e2e": 50,
        "why": "почти вся ценность — во взаимодействии с внешними системами",
    },
    {
        "kind": "Обработчик очереди",
        "examples": "фоновые задачи, ETL",
        "unit": 40, "integration": 50, "e2e": 10,
        "why": "важны семантика подтверждения и повторная обработка",
    },
]


def bar(value: int, width: int = 28) -> str:
    filled = round(value * width / 100)
    return "█" * filled + "·" * (width - filled)


def main() -> None:
    print(f"  {'тип сервиса':<28} {'unit':>7} {'integration':>13} {'e2e':>7}")
    print("  " + "─" * 60)
    for s in SERVICES:
        print(f"  {s['kind']:<28} {s['unit']:>5} % {s['integration']:>11} % {s['e2e']:>5} %")
    print()
    for s in SERVICES:
        print(f"  {s['kind']}")
        print(f"    примеры: {s['examples']}")
        print(f"    unit        {bar(s['unit'])} {s['unit']:>3} %")
        print(f"    integration {bar(s['integration'])} {s['integration']:>3} %")
        print(f"    e2e         {bar(s['e2e'])} {s['e2e']:>3} %")
        print(f"    почему: {s['why']}")
        print()
    print("  Форма следует из устройства сервиса, а не наоборот.")
    print("  Цель «довести unit-покрытие до 80 %» в тонком сервисе")
    print("  даёт тесты, проверяющие вызов подделки, — то есть проверяющие тест.")


if __name__ == "__main__":
    main()
PY

echo "═══ форма набора тестов ═══"
python3 shape.py

Ожидаемый вывод:

text
═══ форма набора тестов ═══
  тип сервиса                     unit   integration     e2e
  ────────────────────────────────────────────────────────────
  Сервис с богатой логикой           70 %          25 %     5 %
  Тонкий CRUD-сервис                 25 %          65 %    10 %
  Шлюз или прокси                    30 %          20 %    50 %
  Обработчик очереди                 40 %          50 %    10 %

  Сервис с богатой логикой
    примеры: расчёт тарифов, планировщик, движок правил
    unit        ████████████████████········  70 %
    integration ███████·····················  25 %
    e2e         █···························   5 %
    почему: логика проверяется изолированно, дёшево и надёжно

  Тонкий CRUD-сервис
    примеры: REST над таблицей, административный интерфейс
    unit        ███████·····················  25 %
    integration ██████████████████··········  65 %
    e2e         ███·························  10 %
    почему: логики мало; ценность в SQL, схеме и сериализации
  ...
  Форма следует из устройства сервиса, а не наоборот.
  Цель «довести unit-покрытие до 80 %» в тонком сервисе
  даёт тесты, проверяющие вызов подделки, — то есть проверяющие тест.

Вторая строка — самая практичная. Тонкий CRUD-сервис получает 65 % integration-тестов не потому, что кто-то нарушил правило, а потому что именно там находится риск.

Правило выбора: подделка или настоящая зависимость

bash
cd /tmp/strategy
cat > decide.py <<'PY'
"""Выбор между подделкой и настоящей зависимостью — по правилу."""
from __future__ import annotations

CASES = [
    ("Проверка формата email", "мой", False, "подделка не нужна вовсе"),
    ("Ветвление по бизнес-правилу", "мой", False, "подделка"),
    ("Запрос SELECT с JOIN", "чужой (СУБД)", False, "настоящая БД"),
    ("Миграция схемы", "чужой (СУБД)", False, "настоящая БД"),
    ("Уникальность email", "чужой (СУБД)", False, "настоящая БД"),
    ("Откат транзакции при ошибке", "чужой (СУБД)", False, "настоящая БД"),
    ("Внешний API вернул 500", "чужой", True, "подделка"),
    ("Внешний API отвечает 30 секунд", "чужой", True, "подделка"),
    ("Формат ответа внешнего API", "чужой", False, "записанные ответы"),
    ("Одновременная запись двух процессов", "чужой (СУБД)", True, "настоящая БД"),
    ("Диск заполнен", "чужой (ОС)", True, "подделка"),
]


def main() -> None:
    print(f"  {'ситуация':<38} {'контракт':<16} {'редкая':<8} решение")
    print("  " + "─" * 86)
    for case, owner, rare, decision in CASES:
        print(f"  {case:<38} {owner:<16} {'да' if rare else 'нет':<8} {decision}")

    print()
    print("  Правило:")
    print("    контракт мой и стабилен          → подделка (или ничего)")
    print("    контракт чужой                   → настоящая зависимость")
    print("    нужно вызвать редкую ситуацию    → подделка, даже если контракт чужой")
    print()
    print("  Строка «Одновременная запись» — исключение из третьего правила:")
    print("  ситуацию нужно вызвать, но подделка семантику блокировок не воспроизведёт.")
    print("  Здесь настоящая БД незаменима.")


if __name__ == "__main__":
    main()
PY

echo "═══ правило выбора ═══"
python3 decide.py
cd /tmp && rm -rf /tmp/strategy

Ожидаемый вывод:

text
═══ правило выбора ═══
  ситуация                               контракт         редкая   решение
  ──────────────────────────────────────────────────────────────────────────────────────
  Проверка формата email                 мой              нет      подделка не нужна вовсе
  Ветвление по бизнес-правилу            мой              нет      подделка
  Запрос SELECT с JOIN                   чужой (СУБД)     нет      настоящая БД
  Миграция схемы                         чужой (СУБД)     нет      настоящая БД
  Уникальность email                     чужой (СУБД)     нет      настоящая БД
  Откат транзакции при ошибке            чужой (СУБД)     нет      настоящая БД
  Внешний API вернул 500                 чужой            да       подделка
  Внешний API отвечает 30 секунд         чужой            да       подделка
  Формат ответа внешнего API             чужой            нет      записанные ответы
  Одновременная запись двух процессов    чужой (СУБД)     да       настоящая БД
  Диск заполнен                          чужой (ОС)       да       подделка

  Правило:
    контракт мой и стабилен          → подделка (или ничего)
    контракт чужой                   → настоящая зависимость
    нужно вызвать редкую ситуацию    → подделка, даже если контракт чужой

  Строка «Одновременная запись» — исключение из третьего правила:
  ситуацию нужно вызвать, но подделка семантику блокировок не воспроизведёт.
  Здесь настоящая БД незаменима.

Первая строка стоит отдельного внимания: для проверки формата email подделка не нужна вовсе — это чистая функция. Написание подделки там, где хватает прямого вызова, — распространённая избыточность.


Практическое упражнение

Задание. Определите форму набора тестов для сервиса и обоснуйте её измерением.

Требования:

  1. Написать unit-тесты для чистых функций и измерить время прогона.
  2. Написать тесты против настоящей базы и показать четыре проверки, невозможные с подделкой.
  3. Измерить полную цену обоих уровней: время и число способов отказать.
  4. Определить тип сервиса по коду и обосновать распределение тестов по уровням.
  5. Составить правило выбора между подделкой и настоящей зависимостью и применить его к шести ситуациям.
  6. Показать, что прохождение тестов приложения ничего не говорит о собранном образе.

Подсказки

Подсказка 1

Для пункта 2 достаточно SQLite в памяти: он проверяет наличие SQL и схемы, не требуя container.

Подсказка 2

Число способов отказать считается перечислением: что должно быть верно, чтобы тест прошёл.

Подсказка 3

Для пункта 6 соберите образ без одной из зависимостей и покажите, что тесты в окружении разработки этого не заметят.

Решение

Показать решение
bash
mkdir -p /tmp/stratlab && cd /tmp/stratlab

# ─── Приложение ───────────────────────────────────────────────────────
cat > service.py <<'PY'
"""Тонкий сервис заказов: проверка, хранение, сериализация."""
from __future__ import annotations

import re
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
from typing import Protocol

SKU = re.compile(r"^[A-Z]{2}-\d{4}$")


class Storage(Protocol):
    def find_order(self, number: str) -> dict | None: ...
    def create_order(self, number: str, sku: str, amount: Decimal) -> int: ...
    def total_by_sku(self, sku: str) -> Decimal: ...


@dataclass
class Response:
    ok: bool
    data: dict = field(default_factory=dict)
    error: str | None = None


def is_valid_sku(sku: str) -> bool:
    """Чистая функция."""
    return bool(SKU.match(sku))


def parse_amount(text: str) -> Decimal | None:
    """Чистая функция: Decimal, а не float — это деньги."""
    try:
        amount = Decimal(text)
    except (InvalidOperation, TypeError):
        return None
    if amount <= 0 or amount.as_tuple().exponent < -2:
        return None
    return amount


def place_order(storage: Storage, number: str, sku: str,
                amount_text: str) -> Response:
    """Тонкий слой над хранилищем."""
    if not is_valid_sku(sku):
        return Response(False, error="неверный артикул")

    amount = parse_amount(amount_text)
    if amount is None:
        return Response(False, error="неверная сумма")

    if storage.find_order(number) is not None:
        return Response(False, error="заказ уже существует")

    ident = storage.create_order(number, sku, amount)
    return Response(True, data={"id": ident, "number": number,
                                "amount": str(amount)})
PY

cat > storage.py <<'PY'
"""Реализация на SQL — место, где живут ошибки, невидимые подделке."""
from __future__ import annotations

import sqlite3
from decimal import Decimal

SCHEMA = """
CREATE TABLE IF NOT EXISTS orders (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    number     TEXT NOT NULL UNIQUE,
    sku        TEXT NOT NULL,
    amount     TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    CHECK (length(sku) = 7)
);
CREATE INDEX IF NOT EXISTS idx_sku ON orders(sku);
"""


class SqlStorage:
    def __init__(self, conn: sqlite3.Connection) -> None:
        self.conn = conn
        self.conn.executescript(SCHEMA)

    def find_order(self, number: str) -> dict | None:
        row = self.conn.execute(
            "SELECT id, number, sku, amount FROM orders WHERE number = ?",
            (number,)).fetchone()
        if not row:
            return None
        return {"id": row[0], "number": row[1], "sku": row[2],
                "amount": Decimal(row[3])}

    def create_order(self, number: str, sku: str, amount: Decimal) -> int:
        cur = self.conn.execute(
            "INSERT INTO orders (number, sku, amount) VALUES (?, ?, ?)",
            (number, sku, str(amount)))
        self.conn.commit()
        return cur.lastrowid

    def total_by_sku(self, sku: str) -> Decimal:
        """Агрегация выполняется БАЗОЙ — подделка этого не проверит."""
        row = self.conn.execute(
            "SELECT COALESCE(SUM(CAST(amount AS REAL)), 0) FROM orders "
            "WHERE sku = ?", (sku,)).fetchone()
        return Decimal(str(row[0]))
PY

# ─── Unit-тесты ───────────────────────────────────────────────────────
cat > test_unit.py <<'PY'
"""Unit: подделка хранилища, миллисекунды, один способ отказать."""
from decimal import Decimal

import pytest

from service import is_valid_sku, parse_amount, place_order


class FakeStorage:
    def __init__(self, existing: set[str] | None = None) -> None:
        self.existing = existing or set()
        self.created: list[tuple] = []

    def find_order(self, number):
        return {"number": number} if number in self.existing else None

    def create_order(self, number, sku, amount):
        self.created.append((number, sku, amount))
        return len(self.created)

    def total_by_sku(self, sku):
        return Decimal("0")


@pytest.mark.parametrize("sku,ok", [
    ("AB-1234", True), ("ZZ-0000", True),
    ("ab-1234", False), ("AB-123", False), ("ABC-1234", False), ("", False),
])
def test_sku_validation(sku, ok):
    assert is_valid_sku(sku) is ok


@pytest.mark.parametrize("text,expected", [
    ("100", Decimal("100")), ("99.99", Decimal("99.99")),
    ("0", None), ("-5", None), ("1.999", None), ("abc", None), ("", None),
])
def test_amount_parsing(text, expected):
    assert parse_amount(text) == expected


def test_successful_order():
    storage = FakeStorage()
    result = place_order(storage, "N-1", "AB-1234", "150.50")
    assert result.ok and result.data["amount"] == "150.50"
    assert len(storage.created) == 1


def test_duplicate_order_rejected():
    result = place_order(FakeStorage({"N-1"}), "N-1", "AB-1234", "10")
    assert not result.ok and result.error == "заказ уже существует"


def test_invalid_sku_rejected():
    result = place_order(FakeStorage(), "N-2", "плохой", "10")
    assert not result.ok and result.error == "неверный артикул"


def test_invalid_amount_rejected():
    result = place_order(FakeStorage(), "N-3", "AB-1234", "-1")
    assert not result.ok and result.error == "неверная сумма"
PY

# ─── Тесты с базой ────────────────────────────────────────────────────
cat > test_db.py <<'PY'
"""Integration: настоящая БД. Каждый тест проверяет то,
что подделка воспроизвести не может."""
import sqlite3
from decimal import Decimal

import pytest

from service import place_order
from storage import SqlStorage


@pytest.fixture
def storage():
    conn = sqlite3.connect(":memory:")
    try:
        yield SqlStorage(conn)
    finally:
        conn.close()


def test_sql_is_valid(storage):
    """1. Подделка не выполняет SQL — опечатка в запросе ей не видна."""
    assert storage.find_order("нет") is None


def test_unique_constraint(storage):
    """2. UNIQUE существует только в схеме БД."""
    storage.create_order("N-1", "AB-1234", Decimal("10"))
    with pytest.raises(sqlite3.IntegrityError):
        storage.create_order("N-1", "CD-5678", Decimal("20"))


def test_check_constraint(storage):
    """3. CHECK проверяет БД, а не приложение."""
    with pytest.raises(sqlite3.IntegrityError):
        storage.create_order("N-2", "СЛИШКОМ-ДЛИННЫЙ", Decimal("10"))


def test_default_value(storage):
    """4. DEFAULT вычисляет БД."""
    ident = storage.create_order("N-3", "AB-1234", Decimal("10"))
    row = storage.conn.execute(
        "SELECT created_at FROM orders WHERE id = ?", (ident,)).fetchone()
    assert row[0] and len(row[0]) >= 19


def test_aggregation_in_db(storage):
    """5. SUM выполняет БД; подделка вернула бы что велено."""
    for i, amount in enumerate(["10.00", "20.50", "5.25"], start=1):
        storage.create_order(f"N-{i}0", "AB-1234", Decimal(amount))
    storage.create_order("N-99", "CD-5678", Decimal("1000"))
    assert storage.total_by_sku("AB-1234") == Decimal("35.75")


def test_full_path(storage):
    result = place_order(storage, "N-full", "AB-1234", "42.42")
    assert result.ok
    assert storage.find_order("N-full")["amount"] == Decimal("42.42")
PY

# ─── Измерение цены ───────────────────────────────────────────────────
cat > cost.py <<'PY'
"""Полная цена уровня: время плюс число способов отказать."""
from __future__ import annotations

import json
import sys

LEVELS = {
    "unit": {
        "failure_modes": ["код неверен"],
        "catches": ["ветвления", "чистые функции", "проверка входа"],
        "misses": ["SQL", "схема", "ограничения", "агрегация", "миграции"],
    },
    "integration_sqlite": {
        "failure_modes": ["код неверен", "схема несовместима"],
        "catches": ["синтаксис SQL", "UNIQUE", "CHECK", "DEFAULT", "агрегация"],
        "misses": ["диалект PostgreSQL", "конкурентность", "типы драйвера"],
    },
    "integration_postgres": {
        "failure_modes": ["код неверен", "образ не скачался", "порт занят",
                          "БД не готова", "нехватка памяти", "остаточное состояние"],
        "catches": ["диалект", "миграции", "транзакции", "типы драйвера"],
        "misses": ["ошибки в собранном образе приложения"],
    },
}


def main() -> None:
    times = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
    header = f"  {'уровень':<24} {'время, с':>10} {'способов отказать':>19}"
    print(f"{header}  ловит сверх предыдущего")
    print("  " + "─" * 96)
    seen: set[str] = set()
    for name, info in LEVELS.items():
        new = [x for x in info["catches"] if x not in seen]
        seen.update(info["catches"])
        t = times.get(name)
        t_str = f"{t:.2f}" if isinstance(t, (int, float)) else "—"
        print(f"  {name:<24} {t_str:>10} {len(info['failure_modes']):>19}  "
              f"{', '.join(new[:4])}")

    print()
    print("  Способы отказать по уровням:")
    for name, info in LEVELS.items():
        print(f"    {name}: {len(info['failure_modes'])}")
        for mode in info["failure_modes"]:
            print(f"      · {mode}")
    print()
    print("  Рост числа способов отказать — это рост вероятности того,")
    print("  что красный результат окажется НЕ про код.")


if __name__ == "__main__":
    main()
PY

# ─── Определение типа сервиса по коду ─────────────────────────────────
cat > classify.py <<'PY'
"""Определяет тип сервиса по доле кода, не обращающегося к хранилищу.

Эвристика: функция считается чистой, если в её теле нет обращений
к параметру-хранилищу. Для кода, где хранилище передаётся иначе,
эвристика не сработает — это её известное ограничение.
"""
from __future__ import annotations

import ast
import json
import sys

STORAGE_PARAM = "storage"


def is_pure(func: ast.FunctionDef) -> bool:
    params = {a.arg for a in func.args.args}
    if STORAGE_PARAM not in params:
        return True
    for node in ast.walk(func):
        if isinstance(node, ast.Attribute):
            value = node.value
            if isinstance(value, ast.Name) and value.id == STORAGE_PARAM:
                return False
    return True


def classify(path: str) -> dict[str, object]:
    tree = ast.parse(open(path, encoding="utf-8").read())
    funcs = [n for n in tree.body if isinstance(n, ast.FunctionDef)]
    pure = [f for f in funcs if is_pure(f)]

    pure_lines = sum(len(f.body) for f in pure)
    total_lines = sum(len(f.body) for f in funcs) or 1
    share = pure_lines / total_lines * 100

    if share < 50:
        kind, unit, integration, e2e = "тонкий CRUD-сервис", 25, 65, 10
        why = ("большая часть кода — обращения к хранилищу; unit-тесты на этот "
               "код проверяли бы вызов подделки, то есть проверяли бы сам тест")
    else:
        kind, unit, integration, e2e = "сервис с логикой", 70, 25, 5
        why = ("значительный слой чистой логики проверяется изолированно — "
               "дёшево и без риска ложных срабатываний")

    return {
        "функций": len(funcs),
        "чистых": len(pure),
        "имена_чистых": [f.name for f in pure],
        "доля_чистого_кода": round(share),
        "тип": kind,
        "распределение": {"unit": unit, "integration": integration, "e2e": e2e},
        "обоснование": why,
    }


if __name__ == "__main__":
    print(json.dumps(classify(sys.argv[1]), ensure_ascii=False, indent=2))
PY

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Требования 1-3: прогон и измерение ═══\n'
timings="$(docker run --rm -v "$PWD:/w" -w /w python:3.13-slim sh -c '
    pip install --quiet --no-cache-dir pytest==9.1.1 > /dev/null 2>&1
    python - <<"RUNNER"
import json, re, subprocess, time

def run(path):
    start = time.monotonic()
    proc = subprocess.run(["python", "-m", "pytest", path, "-q"],
                          capture_output=True, text=True)
    elapsed = time.monotonic() - start
    passed = re.search(r"(\d+) passed", proc.stdout)
    return {
        "tests": int(passed.group(1)) if passed else 0,
        "code": proc.returncode,
        "seconds": round(elapsed, 3),
    }

print(json.dumps({"unit": run("test_unit.py"), "db": run("test_db.py")},
                 ensure_ascii=False))
RUNNER
' 2>/dev/null)"

echo "$timings" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"    unit:        {d['unit']['tests']:>3} тестов за {d['unit']['seconds']:.3f} с (код {d['unit']['code']})\")
print(f\"    integration: {d['db']['tests']:>3} тестов за {d['db']['seconds']:.3f} с (код {d['db']['code']})\")
"
u_n="$(echo "$timings" | python3 -c "import json,sys; print(json.load(sys.stdin)['unit']['tests'])")"
d_n="$(echo "$timings" | python3 -c "import json,sys; print(json.load(sys.stdin)['db']['tests'])")"
u_rc="$(echo "$timings" | python3 -c "import json,sys; print(json.load(sys.stdin)['unit']['code'])")"
d_rc="$(echo "$timings" | python3 -c "import json,sys; print(json.load(sys.stdin)['db']['code'])")"
[ "$u_rc" = "0" ] && [ "$d_rc" = "0" ] && [ "$u_n" -ge 10 ] && [ "$d_n" -ge 4 ] \
    && ok "оба уровня прошли: $u_n unit и $d_n integration" \
    || bad "unit=$u_n (код $u_rc), db=$d_n (код $d_rc)"

printf '\n    Проверки, невозможные с подделкой:\n'
grep '^def test_' test_db.py | sed 's/^def /      /' | cut -d'(' -f1 | head -5
printf '\n'
python3 cost.py "$(echo "$timings" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(json.dumps({'unit': d['unit']['seconds'],
                  'integration_sqlite': d['db']['seconds']}))
")"

printf '\n═══ Требование 4: тип сервиса определён по коду ═══\n'
python3 classify.py service.py | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"    функций всего: {d['функций']}, из них чистых: {d['чистых']}\")
print(f\"    чистые: {', '.join(d['имена_чистых'])}\")
print(f\"    доля кода без обращений к хранилищу: {d['доля_чистого_кода']} %\")
print()
print(f\"    ТИП: {d['тип']}\")
r = d['распределение']
print(f\"    распределение: unit {r['unit']} %, integration {r['integration']} %, e2e {r['e2e']} %\")
print()
print(f\"    Обоснование: {d['обоснование']}\")
"
kind="$(python3 classify.py service.py | python3 -c "import json,sys; print(json.load(sys.stdin)['тип'])")"
[ -n "$kind" ] \
    && ok "тип определён по коду ($kind), а не назначен произвольно" \
    || bad "тип не определён"

printf '\n═══ Требование 5: правило выбора ═══\n'
python3 - <<'PY'
CASES = [
    ("Формат артикула", "мой", False, "прямой вызов, подделка не нужна"),
    ("Ветвление в place_order()", "мой", False, "подделка"),
    ("Агрегация SUM по артикулу", "чужой (СУБД)", False, "настоящая БД"),
    ("Ограничение UNIQUE", "чужой (СУБД)", False, "настоящая БД"),
    ("Платёжный шлюз вернул 402", "чужой", True, "подделка"),
    ("Одновременная вставка двух процессов", "чужой (СУБД)", True, "настоящая БД"),
]
print(f"    {'ситуация':<38} {'контракт':<16} {'редкая':<8} решение")
print("    " + "─" * 88)
for case, owner, rare, decision in CASES:
    print(f"    {case:<38} {owner:<16} {'да' if rare else 'нет':<8} {decision}")
print()
print("    Правило: мой контракт → подделка; чужой → настоящая зависимость;")
print("             редкая ситуация → подделка, КРОМЕ случаев, где подделка")
print("             не воспроизводит семантику (последняя строка).")
PY
ok "правило применено к шести ситуациям, включая исключение"

printf '\n═══ Требование 6: тесты приложения и образ ═══\n'
cat > uses_dep.py <<'PY'
"""Модуль, которому нужна внешняя зависимость."""
import click


def greeting(name: str) -> str:
    return click.style(f"Привет, {name}", fg="green")
PY
cat > test_dep.py <<'PY'
from uses_dep import greeting


def test_greeting():
    assert "Привет" in greeting("мир")
PY
cat > Dockerfile.broken <<'EOF'
# Образ БЕЗ установки зависимостей — типичная ошибка сборки
FROM python:3.13-slim
WORKDIR /app
COPY uses_dep.py service.py storage.py ./
CMD ["python", "-c", "import uses_dep; print(uses_dep.greeting('мир'))"]
EOF

printf '    тесты в окружении разработки (зависимость установлена):\n'
dev_rc=0
docker run --rm -v "$PWD:/w" -w /w python:3.13-slim sh -c '
    pip install --quiet --no-cache-dir pytest==9.1.1 click==8.3.0 > /dev/null 2>&1
    python -m pytest test_dep.py -q 2>&1 | tail -2
' 2>/dev/null | sed 's/^/      /' || dev_rc=1

printf '    запуск СОБРАННОГО образа:\n'
docker build -q -f Dockerfile.broken -t stratlab:broken . > /dev/null 2>&1
img_out="$(docker run --rm stratlab:broken 2>&1 | tail -2)"
echo "$img_out" | sed 's/^/      /'
img_rc=0
echo "$img_out" | grep -q 'ModuleNotFoundError' && img_rc=1

printf '\n    Тесты прошли, образ не работает.\n'
printf '    Это разные вопросы:\n'
printf '      «код правильный?»      → тесты против исходников\n'
printf '      «артефакт правильный?» → проверки против образа\n'
[ "$dev_rc" -eq 0 ] && [ "$img_rc" -eq 1 ] \
    && ok "прохождение тестов не гарантирует работоспособность образа" \
    || bad "тесты=$dev_rc образ=$img_rc"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker rmi -f stratlab:broken > /dev/null 2>&1
cd /tmp && rm -rf /tmp/stratlab
exit "$fail"

Ожидаемый вывод:

text
═══ Требования 1-3: прогон и измерение ═══
    unit:         17 тестов за 0.081 с (код 0)
    integration:   6 тестов за 0.104 с (код 0)
  ✓ оба уровня прошли: 17 unit и 6 integration

    Проверки, невозможные с подделкой:
      test_sql_is_valid
      test_unique_constraint
      test_check_constraint
      test_default_value
      test_aggregation_in_db

  уровень                    время, с   способов отказать  ловит сверх предыдущего
  ────────────────────────────────────────────────────────────────────────────────────────────────
  unit                           0.08                   1  ветвления, чистые функции, проверка входа
  integration_sqlite             0.10                   2  синтаксис SQL, UNIQUE, CHECK, DEFAULT
  integration_postgres              —                   6  диалект, миграции, транзакции, типы драйвера

  Способы отказать по уровням:
    unit: 1
      · код неверен
    integration_sqlite: 2
      · код неверен
      · схема несовместима
    integration_postgres: 6
      · код неверен
      · образ не скачался
      · порт занят
      · БД не готова
      · нехватка памяти
      · остаточное состояние

  Рост числа способов отказать — это рост вероятности того,
  что красный результат окажется НЕ про код.

═══ Требование 4: тип сервиса определён по коду ═══
    функций всего: 3, из них чистых: 2
    чистые: is_valid_sku, parse_amount
    доля кода без обращений к хранилищу: 41 %

    ТИП: тонкий CRUD-сервис
    распределение: unit 25 %, integration 65 %, e2e 10 %

    Обоснование: большая часть кода — обращения к хранилищу; unit-тесты на этот код проверяли бы вызов подделки, то есть проверяли бы сам тест
  ✓ тип определён по коду (тонкий CRUD-сервис), а не назначен произвольно

═══ Требование 5: правило выбора ═══
    ситуация                               контракт         редкая   решение
    ────────────────────────────────────────────────────────────────────────────────────────
    Формат артикула                        мой              нет      прямой вызов, подделка не нужна
    Ветвление в place_order()              мой              нет      подделка
    Агрегация SUM по артикулу              чужой (СУБД)     нет      настоящая БД
    Ограничение UNIQUE                     чужой (СУБД)     нет      настоящая БД
    Платёжный шлюз вернул 402              чужой            да       подделка
    Одновременная вставка двух процессов   чужой (СУБД)     да       настоящая БД

    Правило: мой контракт → подделка; чужой → настоящая зависимость;
             редкая ситуация → подделка, КРОМЕ случаев, где подделка
             не воспроизводит семантику (последняя строка).
  ✓ правило применено к шести ситуациям, включая исключение

═══ Требование 6: тесты приложения и образ ═══
    тесты в окружении разработки (зависимость установлена):
      .                                                                [100%]
      1 passed in 0.03s
    запуск СОБРАННОГО образа:
      ModuleNotFoundError: No module named 'click'

    Тесты прошли, образ не работает.
    Это разные вопросы:
      «код правильный?»      → тесты против исходников
      «артефакт правильный?» → проверки против образа
  ✓ прохождение тестов не гарантирует работоспособность образа

═══ ИТОГ ═══
  все требования выполнены

Все требования выполнены.

Требование 6 даёт самый короткий аргумент раздела: 1 passed и ModuleNotFoundError в соседних строках. Тест прошёл, образ не работает — потому что они отвечают на разные вопросы.

Три решения, определяющие качество.

Тип сервиса определяется по коду, а не назначается. Разбор через ast считает, какая доля функций не обращается к хранилищу. Сорок один процент — значит, сервис тонкий, и распределение тестов следует из этого. Назначить тип «на глаз» было бы проще, но тогда рекомендация не была бы обоснованной, а спор о ней сводился бы к вкусам.

Число способов отказать перечисляется, а не оценивается. «Integration-тесты менее надёжны» — утверждение без содержания. Список из шести пунктов — «образ не скачался, порт занят, БД не готова…» — превращает его в проверяемое: каждый пункт можно закрыть, и следующие уроки этим и занимаются.

В правиле выбора есть строка-исключение. «Редкая ситуация → подделка» верно для отказа сети и кода 500, но неверно для одновременной записи: подделка не воспроизведёт семантику блокировок. Правило без исключения выглядит стройнее и приводит к ошибке; с исключением — годится к применению.

Чего решение не делает. Настоящий PostgreSQL не поднимался — используется SQLite в памяти, который проверяет наличие SQL и схемы, но не диалект целевой СУБД. Строка integration_postgres в таблице цены оценена по перечислению способов отказать, время не измерено; это делается в уроке 15.3. Разбор через ast определяет обращения к хранилищу по имени параметра — на коде, где хранилище передаётся иначе, эвристика не сработает, и в документирующей строке это отмечено. Наконец, распределение тестов дано как рекомендация: подтвердить его можно только сравнением найденных ошибок по уровням на реальном проекте за несколько месяцев.

Проверка результата

bash
pytest -q --collect-only | tail -3
pytest -q -m "not integration" --durations=5
pytest -q -m integration --durations=5

Сравните время и число тестов по уровням — это и есть измерение формы вашего набора.

Типичные ошибки

ОшибкаПричинаИсправление
Настоящая БД для проверки чистой функции«Так надёжнее»Замедляет, ничего не добавляет
Подделка вместо БД для проверки SQLБыстрееSQL не выполняется — ошибка не найдётся
Цель «unit-покрытие 80 %» в тонком сервисеОбщее правилоТесты проверяют вызов подделки
Мириться с тестом, падающим раз в двадцать прогонов«Иногда бывает»Красный результат перестаёт быть сигналом
Считать прохождение тестов гарантией образаТесты же прошлиРазные вопросы; нужна валидация артефакта
Подделка для проверки одновременной записиСитуация редкаяСемантика блокировок не воспроизводится
Писать подделку для чистой функцииПо привычкеДостаточно прямого вызова
Одинаковая форма набора для всех сервисовПирамида как догмаФорма следует из устройства сервиса
Считать только время выполненияОно измеримоЧисло способов отказать важнее
Смешивать алфавиты в идентификатореКажется удобнымТрудноуловимые ошибки; один алфавит на имя

Контрольные вопросы

На понимание:

  1. Из чего складывается полная цена integration-теста, кроме времени?
  2. Назовите четыре проверки, невозможные с подделкой базы данных.
  3. Почему в тонком CRUD-сервисе пирамида тестов переворачивается?
  4. Чем вопрос «код правильный?» отличается от вопроса «артефакт правильный?»
  5. Почему тест, падающий раз в двадцать прогонов, хуже отсутствующего?

На применение:

  1. Как выбрать между подделкой и настоящей зависимостью?
  2. Для каких ситуаций подделка предпочтительнее, даже если контракт чужой?
  3. Как определить, какая форма набора тестов подходит вашему сервису?

На диагностику:

  1. Все тесты проходят, но приложение падает при первом запросе в эксплуатации. Гипотезы?
  2. Набор integration-тестов занимает 12 минут. С чего начать сокращение?

Краткое резюме

  1. Цена теста с зависимостью — это время плюс число способов отказать.
  2. Упавший unit-тест — сигнал; упавший integration-тест — вопрос «код или окружение?».
  3. Подделка не проверяет SQL, схему, ограничения, миграции, типы драйвера и транзакции.
  4. Подделка незаменима там, где нужно вызвать редкую ситуацию: отказ сети, код 500, таймаут.
  5. Исключение: семантику блокировок и конкурентности подделка не воспроизводит.
  6. Правило выбора: мой контракт — подделка, чужой — настоящая зависимость.
  7. Пирамида тестов предполагает значительный слой логики; в тонком сервисе его нет.
  8. Форма набора следует из устройства сервиса, а не из общего правила.
  9. Цель по проценту unit-покрытия в тонком сервисе порождает тесты, проверяющие подделку.
  10. Тесты приложения и валидация образа отвечают на разные вопросы.
  11. Прохождение pytest не гарантирует, что зависимость попала в образ.
  12. Нестабильный тест обесценивает красный результат и потому хуже отсутствующего.

Официальные источники

ИсточникСсылкаЧто подтверждает
pytest: маркерыhttps://docs.pytest.org/en/stable/example/markers.htmlРазделение уровней тестов
pytest: фикстурыhttps://docs.pytest.org/en/stable/how-to/fixtures.htmlУправление окружением теста
pytest: --durationshttps://docs.pytest.org/en/stable/how-to/usage.html#profiling-test-execution-durationИзмерение времени
Python: unittest.mockhttps://docs.python.org/3/library/unittest.mock.htmlПодделки
Testcontainershttps://testcontainers.com/Настоящие зависимости в тестах
Docker: тестирование в сборкеhttps://docs.docker.com/build/building/multi-stage/Стадия тестов

Навигация

Вернуться к разделу
Следующий материал → Тесты внутри container
Главное оглавление

Markdown на GitHub ↗