15.1. Стратегия тестирования
Цели
После этого материала вы сможете:
- назвать полную цену теста с зависимостью — не только время;
- определить, какие проверки настоящая база данных ловит, а подделка не ловит;
- объяснить, почему для тонкого сервиса классическая пирамида тестов не работает;
- разделить тестирование приложения и тестирование образа;
- выбрать между подделкой и настоящей зависимостью по правилу, а не по привычке;
- измерить стоимость своего набора тестов и найти в нём лишнее.
Предварительные знания
- 6.12. Тестирование — запуск
pytestв образе; - 9.4. Healthchecks и зависимости;
- рабочее знание
pytest: фикстуры, маркеры, параметризация.
Ключевые термины
| Термин | Объяснение |
|---|---|
unit-тест | Проверяет функцию или класс без внешних зависимостей |
integration-тест | Проверяет взаимодействие с настоящей зависимостью |
подделка | Заменитель зависимости: mock, fake, stub |
нестабильность | Свойство теста падать без изменения кода |
контракт | Соглашение о поведении между вашим кодом и чужим |
Теория
Полная цена теста
Время выполнения — очевидная часть цены, но не главная.
| Составляющая | Unit-тест | Тест с container |
|---|---|---|
| Время выполнения | Миллисекунды | Секунды |
| Время старта окружения | Нет | 1–20 секунд на набор |
| Способов отказать | Один: код неверен | Много: код, образ, порт, сеть, память, гонка |
| Что означает падение | Код сломан | Вопрос: код или окружение? |
| Стоимость сопровождения | Низкая | Средняя |
Третья и четвёртая строки важнее первой.
Упавший unit-тест — это сигнал: код сломан, иди чинить. Упавший integration-тест — это вопрос: сломан код, или не поднялась база, или занят порт, или CI-сборщик медленнее обычного.
Отсюда цена нестабильности: тест, падающий один раз из двадцати без причины, приучает игнорировать красный результат. После этого он не находит ошибок — он только тратит время.
Практический вывод: тест с зависимостью должен оправдывать не только своё время, но и риск ложных срабатываний.
Что подделка поймать не может
Замена базы данных на подделку убирает целый класс проверок:
| Проверка | Подделка | Настоящая БД |
|---|---|---|
| Логика приложения | Да | Да, но медленнее |
| Синтаксис SQL | Нет | Да |
| Особенности диалекта | Нет | Да |
| Ограничения целостности | Нет | Да |
| Поведение транзакций | Нет | Да |
| Миграции | Нет | Да |
| Отображение типов драйвером | Нет | Да |
| Поведение при конкурентности | Нет | Да |
Строки 2–8 — это то, ради чего integration-тесты существуют. Подделка psycopg вернёт то, что вы ей велели вернуть; она не сообщит, что запрос содержит синтаксическую ошибку или что ON CONFLICT в этой версии работает иначе.
Пример, который встречается постоянно: код работает с подделкой и падает в эксплуатации, потому что datetime без часового пояса драйвер отдаёт иначе, чем ожидалось. Ни один unit-тест этого не найдёт.
Что подделка ловит лучше
Обратное тоже верно:
| Ситуация | Чем проверять |
|---|---|
| Обработка ошибки сети | Подделкой: настоящую сеть сложно сломать по требованию |
| Ответ внешнего API с кодом 500 | Подделкой |
| Медленный ответ, таймаут | Подделкой |
| Редкое состояние гонки | Подделкой |
| Ветвление по бизнес-правилу | Подделкой: настоящая БД ничего не добавляет |
Правило выбора формулируется через владение контрактом:
Контракт мой и стабилен → подделка
Контракт чужой (СУБД, протокол) → настоящая зависимость
Нужно вызвать редкую ситуацию → подделка
Почему пирамида не всегда работает
Классическая пирамида — много unit-тестов, меньше integration, совсем мало e2e — предполагает, что в приложении есть значительный слой логики, который можно проверять изолированно.
Для типового веб-сервиса это часто не так:
Сервис с богатой логикой Тонкий сервис (CRUD, API-шлюз)
/\ e2e /\ e2e
/ \ / \
/ \ integration / \
/______\ / \ integration
/ \ / \
/ unit \ /__________\
/____________\ unit
пирамида уместна пирамида перевёрнута
Тонкий сервис состоит из проверки запроса, обращения к базе и сериализации ответа. Логики, которую стоит проверять изолированно, в нём мало — а вот SQL, миграции и сериализация типов требуют настоящей базы.
Вывод: форма набора тестов следует из устройства сервиса, а не наоборот. Стремление «довести unit-покрытие до 80 %» в тонком сервисе даёт тесты, проверяющие вызов подделки — то есть проверяющие тест.
Тестирование приложения и тестирование образа
Два разных вопроса, которые постоянно смешивают:
| Вопрос | Что проверяет | Где выполняется |
|---|---|---|
| «Код правильный?» | Логика, SQL, обработка ошибок | Против исходного кода |
| «Артефакт правильный?» | Зависимости, пользователь, точка входа, файлы | Против собранного образа |
pytest прошёл в вашем окружении разработки — это не говорит ничего о том, что:
- нужная зависимость попала в образ;
- она попала в нужной версии;
- точка входа запускает то, что нужно;
- приложение работает не от root;
- файлы конфигурации на месте.
Каждый пункт — отдельная категория отказа, обнаруживаемая только проверкой артефакта (урок 15.5).
Уровни для контейнеризованного сервиса
| Уровень | Что проверяет | Зависимости | Где запускать |
|---|---|---|---|
| Unit | Функции, классы | Нет | Локально и в CI, всегда |
| Компонент | Модуль с подделками границ | Подделки | Там же |
| Integration | Взаимодействие с настоящей БД | Container | CI, при каждом изменении |
| Contract | Совместимость с чужим API | Записанные ответы | CI |
| Валидация образа | Свойства артефакта | Собранный образ | После сборки |
| E2E | Сценарий целиком | Весь стек | Перед выпуском |
Последние два часто пропускают. Первый — потому что «тесты же прошли»; второй — потому что дорого.
Внутренний механизм
Откуда берётся нестабильность
Три источника, по убыванию частоты:
Гонка при старте. Тест обращается к базе раньше, чем она готова принимать соединения. Проявляется на медленной машине или под нагрузкой (урок 15.3).
Общее состояние. Два теста пишут в одну таблицу; результат зависит от порядка. Проявляется при параллельном запуске или после перестановки тестов.
Конкуренция за ресурс. Порт занят другим прогоном, память ограничена, диск заполнен. Проявляется в CI, где параллельно идут другие задачи.
Все три устраняются, и способы разобраны в следующих уроках. Важно другое: нестабильный тест хуже отсутствующего, потому что тратит внимание и обесценивает красный результат.
Почему sleep не решает гонку
sleep 10 перед тестами кажется простым решением. Он плох дважды:
Слишком долго в обычном случае. База поднимается за 2 секунды — 8 секунд теряются на каждом прогоне.
Слишком мало в плохом случае. На загруженном CI-сборщике база поднимется за 15 секунд, и тест упадёт — но не всегда, а иногда. Это и есть нестабильность.
Правильное решение — ожидание условия, а не времени: проверять готовность в цикле с ограничением сверху.
Команды и примеры
Идентификаторы в коде — латиницей, как во всём курсе. Русский язык остаётся в комментариях, документирующих строках и выводе программ. Смешение алфавитов внутри одного идентификатора — источник трудноуловимых ошибок и в коде курса не встречается.
Измерение полной цены
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/^/ /'
Ожидаемый вывод:
═══ прогон unit-тестов ═══
............ [100%]
12 passed in 0.06s
Двенадцать тестов за 0,06 секунды. Способов отказать у них ровно один: код неверен.
Что настоящая база находит, а подделка — нет
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
Ожидаемый вывод:
═══ прогон тестов с базой ═══
..... [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).
Стоимость трёх уровней
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
Ожидаемый вывод:
═══ сравнение уровней ═══
Набор из 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-тестов ничего не говорит о собранном образе.
Форма набора тестов зависит от сервиса
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
Ожидаемый вывод:
═══ форма набора тестов ═══
тип сервиса 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-тестов не потому, что кто-то нарушил правило, а потому что именно там находится риск.
Правило выбора: подделка или настоящая зависимость
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
Ожидаемый вывод:
═══ правило выбора ═══
ситуация контракт редкая решение
──────────────────────────────────────────────────────────────────────────────────────
Проверка формата email мой нет подделка не нужна вовсе
Ветвление по бизнес-правилу мой нет подделка
Запрос SELECT с JOIN чужой (СУБД) нет настоящая БД
Миграция схемы чужой (СУБД) нет настоящая БД
Уникальность email чужой (СУБД) нет настоящая БД
Откат транзакции при ошибке чужой (СУБД) нет настоящая БД
Внешний API вернул 500 чужой да подделка
Внешний API отвечает 30 секунд чужой да подделка
Формат ответа внешнего API чужой нет записанные ответы
Одновременная запись двух процессов чужой (СУБД) да настоящая БД
Диск заполнен чужой (ОС) да подделка
Правило:
контракт мой и стабилен → подделка (или ничего)
контракт чужой → настоящая зависимость
нужно вызвать редкую ситуацию → подделка, даже если контракт чужой
Строка «Одновременная запись» — исключение из третьего правила:
ситуацию нужно вызвать, но подделка семантику блокировок не воспроизведёт.
Здесь настоящая БД незаменима.
Первая строка стоит отдельного внимания: для проверки формата email подделка не нужна вовсе — это чистая функция. Написание подделки там, где хватает прямого вызова, — распространённая избыточность.
Практическое упражнение
Задание. Определите форму набора тестов для сервиса и обоснуйте её измерением.
Требования:
- Написать unit-тесты для чистых функций и измерить время прогона.
- Написать тесты против настоящей базы и показать четыре проверки, невозможные с подделкой.
- Измерить полную цену обоих уровней: время и число способов отказать.
- Определить тип сервиса по коду и обосновать распределение тестов по уровням.
- Составить правило выбора между подделкой и настоящей зависимостью и применить его к шести ситуациям.
- Показать, что прохождение тестов приложения ничего не говорит о собранном образе.
Подсказки
Подсказка 1
Для пункта 2 достаточно SQLite в памяти: он проверяет наличие SQL и схемы, не требуя container.
Подсказка 2
Число способов отказать считается перечислением: что должно быть верно, чтобы тест прошёл.
Подсказка 3
Для пункта 6 соберите образ без одной из зависимостей и покажите, что тесты в окружении разработки этого не заметят.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Требования 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 определяет обращения к хранилищу по имени параметра — на коде, где хранилище передаётся иначе, эвристика не сработает, и в документирующей строке это отмечено. Наконец, распределение тестов дано как рекомендация: подтвердить его можно только сравнением найденных ошибок по уровням на реальном проекте за несколько месяцев.
Проверка результата
pytest -q --collect-only | tail -3
pytest -q -m "not integration" --durations=5
pytest -q -m integration --durations=5
Сравните время и число тестов по уровням — это и есть измерение формы вашего набора.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| Настоящая БД для проверки чистой функции | «Так надёжнее» | Замедляет, ничего не добавляет |
| Подделка вместо БД для проверки SQL | Быстрее | SQL не выполняется — ошибка не найдётся |
| Цель «unit-покрытие 80 %» в тонком сервисе | Общее правило | Тесты проверяют вызов подделки |
| Мириться с тестом, падающим раз в двадцать прогонов | «Иногда бывает» | Красный результат перестаёт быть сигналом |
| Считать прохождение тестов гарантией образа | Тесты же прошли | Разные вопросы; нужна валидация артефакта |
| Подделка для проверки одновременной записи | Ситуация редкая | Семантика блокировок не воспроизводится |
| Писать подделку для чистой функции | По привычке | Достаточно прямого вызова |
| Одинаковая форма набора для всех сервисов | Пирамида как догма | Форма следует из устройства сервиса |
| Считать только время выполнения | Оно измеримо | Число способов отказать важнее |
| Смешивать алфавиты в идентификаторе | Кажется удобным | Трудноуловимые ошибки; один алфавит на имя |
Контрольные вопросы
На понимание:
- Из чего складывается полная цена integration-теста, кроме времени?
- Назовите четыре проверки, невозможные с подделкой базы данных.
- Почему в тонком CRUD-сервисе пирамида тестов переворачивается?
- Чем вопрос «код правильный?» отличается от вопроса «артефакт правильный?»
- Почему тест, падающий раз в двадцать прогонов, хуже отсутствующего?
На применение:
- Как выбрать между подделкой и настоящей зависимостью?
- Для каких ситуаций подделка предпочтительнее, даже если контракт чужой?
- Как определить, какая форма набора тестов подходит вашему сервису?
На диагностику:
- Все тесты проходят, но приложение падает при первом запросе в эксплуатации. Гипотезы?
- Набор integration-тестов занимает 12 минут. С чего начать сокращение?
Краткое резюме
- Цена теста с зависимостью — это время плюс число способов отказать.
- Упавший unit-тест — сигнал; упавший integration-тест — вопрос «код или окружение?».
- Подделка не проверяет SQL, схему, ограничения, миграции, типы драйвера и транзакции.
- Подделка незаменима там, где нужно вызвать редкую ситуацию: отказ сети, код 500, таймаут.
- Исключение: семантику блокировок и конкурентности подделка не воспроизводит.
- Правило выбора: мой контракт — подделка, чужой — настоящая зависимость.
- Пирамида тестов предполагает значительный слой логики; в тонком сервисе его нет.
- Форма набора следует из устройства сервиса, а не из общего правила.
- Цель по проценту unit-покрытия в тонком сервисе порождает тесты, проверяющие подделку.
- Тесты приложения и валидация образа отвечают на разные вопросы.
- Прохождение
pytestне гарантирует, что зависимость попала в образ. - Нестабильный тест обесценивает красный результат и потому хуже отсутствующего.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| pytest: маркеры | https://docs.pytest.org/en/stable/example/markers.html | Разделение уровней тестов |
| pytest: фикстуры | https://docs.pytest.org/en/stable/how-to/fixtures.html | Управление окружением теста |
pytest: --durations | https://docs.pytest.org/en/stable/how-to/usage.html#profiling-test-execution-duration | Измерение времени |
Python: unittest.mock | https://docs.python.org/3/library/unittest.mock.html | Подделки |
| Testcontainers | https://testcontainers.com/ | Настоящие зависимости в тестах |
| Docker: тестирование в сборке | https://docs.docker.com/build/building/multi-stage/ | Стадия тестов |
Навигация
Вернуться к разделу
Следующий материал → Тесты внутри container
Главное оглавление