Стратегии тестирования
Пирамида тестирования
Пирамида тестирования — модель, определяющая оптимальное соотношение между типами тестов:
/\
/ \ E2E тесты
/ UI \ (мало, медленные, дорогие)
/------\
/ \ Интеграционные тесты
/ Integr. \ (средне, проверяют связки)
/------------\
/ \ Юнит-тесты
/ Unit \ (много, быстрые, дешёвые)
/------------------\
| Уровень | Количество | Скорость | Стоимость | Что проверяет |
|---|---|---|---|---|
| Unit | Много (70-80%) | Миллисекунды | Низкая | Отдельные функции, классы |
| Integration | Средне (15-20%) | Секунды | Средняя | Взаимодействие компонентов |
| E2E | Мало (5-10%) | Минуты | Высокая | Весь пользовательский сценарий |
Unit-тесты
Юнит-тесты проверяют одну единицу кода (функцию, метод, класс) в изоляции от внешних зависимостей.
Характеристики хорошего юнит-теста (F.I.R.S.T.):
- Fast — быстрый (миллисекунды)
- Independent — не зависит от других тестов
- Repeatable — одинаковый результат при каждом запуске
- Self-validating — автоматически определяет pass/fail
- Timely — написан вовремя (до или вместе с кодом)
# Хороший юнит-тест: изолированный, быстрый, понятный
from dataclasses import dataclass
import pytest
@dataclass
class PriceCalculator:
"""Calculate prices with discounts and tax."""
tax_rate: float = 0.20 # 20% VAT
def calculate(self, base_price: float, discount_percent: float = 0) -> float:
if base_price < 0:
raise ValueError("Цена не может быть отрицательной")
if not 0 <= discount_percent <= 100:
raise ValueError("Скидка должна быть от 0 до 100")
discounted = base_price * (1 - discount_percent / 100)
return round(discounted * (1 + self.tax_rate), 2)
# Tests — each tests ONE thing
@pytest.fixture
def calculator() -> PriceCalculator:
return PriceCalculator(tax_rate=0.20)
def test_price_without_discount(calculator) -> None:
assert calculator.calculate(100.0) == 120.0
def test_price_with_discount(calculator) -> None:
assert calculator.calculate(100.0, discount_percent=10) == 108.0
def test_zero_price(calculator) -> None:
assert calculator.calculate(0.0) == 0.0
def test_negative_price_raises(calculator) -> None:
with pytest.raises(ValueError, match="отрицательной"):
calculator.calculate(-50.0)
def test_invalid_discount_raises(calculator) -> None:
with pytest.raises(ValueError, match="от 0 до 100"):
calculator.calculate(100.0, discount_percent=150)
Шаблон Arrange-Act-Assert (AAA)
def test_user_registration() -> None:
# Arrange — подготовка
repository = {} # Simple fake
user_data = {"name": "Алексей", "email": "[email protected]"}
# Act — действие
user_id = len(repository) + 1
repository[user_id] = user_data
# Assert — проверка
assert user_id == 1
assert repository[1]["name"] == "Алексей"
Интеграционные тесты
Интеграционные тесты проверяют взаимодействие нескольких компонентов: сервис + БД, сервис + API, несколько сервисов вместе.
import pytest
import sqlite3
from dataclasses import dataclass
@dataclass
class TodoRepository:
"""Repository with real database."""
db_path: str
def _get_connection(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def create_table(self) -> None:
with self._get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done BOOLEAN DEFAULT FALSE
)
""")
def add(self, title: str) -> int:
with self._get_connection() as conn:
cursor = conn.execute(
"INSERT INTO todos (title) VALUES (?)",
(title,),
)
return cursor.lastrowid
def get_all(self) -> list[dict]:
with self._get_connection() as conn:
rows = conn.execute("SELECT * FROM todos").fetchall()
return [dict(row) for row in rows]
def mark_done(self, todo_id: int) -> None:
with self._get_connection() as conn:
conn.execute(
"UPDATE todos SET done = TRUE WHERE id = ?",
(todo_id,),
)
# Integration test — tests real DB interaction
@pytest.fixture
def todo_repo(tmp_path) -> TodoRepository:
"""Create repository with temporary SQLite database."""
db_path = str(tmp_path / "test.db")
repo = TodoRepository(db_path=db_path)
repo.create_table()
return repo
def test_add_and_retrieve(todo_repo: TodoRepository) -> None:
"""Integration: add + get_all work together."""
todo_repo.add("Купить молоко")
todo_repo.add("Написать тесты")
todos = todo_repo.get_all()
assert len(todos) == 2
assert todos[0]["title"] == "Купить молоко"
def test_mark_as_done(todo_repo: TodoRepository) -> None:
"""Integration: add + mark_done + get_all."""
todo_id = todo_repo.add("Задача")
todo_repo.mark_done(todo_id)
todos = todo_repo.get_all()
assert todos[0]["done"] == 1 # SQLite stores True as 1
Покрытие кода (Coverage)
Покрытие показывает, какой процент кода выполняется тестами:
# Install
pip install pytest-cov
# Run with coverage
pytest --cov=src --cov-report=term-missing
# HTML report
pytest --cov=src --cov-report=html
# Minimum coverage threshold
pytest --cov=src --cov-fail-under=80
# pyproject.toml
[tool.coverage.run]
source = ["src"]
omit = ["*/tests/*", "*/migrations/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
fail_under = 80
show_missing = true
Важно: 100% покрытие не означает отсутствие багов. Покрытие показывает, какой код выполняется, но не гарантирует правильность проверок.
Property-Based Testing (Hypothesis)
Вместо конкретных примеров hypothesis генерирует тысячи случайных тестовых данных, проверяя свойства кода:
from hypothesis import given, strategies as st
def sort_list(items: list[int]) -> list[int]:
"""Sort a list of integers."""
return sorted(items)
# Property: sorted list has same length as input
@given(st.lists(st.integers()))
def test_sort_preserves_length(items: list[int]) -> None:
result = sort_list(items)
assert len(result) == len(items)
# Property: sorted list is actually sorted
@given(st.lists(st.integers()))
def test_sort_is_ordered(items: list[int]) -> None:
result = sort_list(items)
for i in range(len(result) - 1):
assert result[i] <= result[i + 1]
# Property: encode-decode is identity
@given(st.text())
def test_encode_decode_roundtrip(text: str) -> None:
assert text.encode("utf-8").decode("utf-8") == text
# Property: addition is commutative
@given(st.integers(), st.integers())
def test_addition_commutative(a: int, b: int) -> None:
assert a + b == b + a
TDD — Test-Driven Development
TDD — методология, где тесты пишутся до кода:
Цикл Red-Green-Refactor
1. RED: Напиши падающий тест
2. GREEN: Напиши минимальный код, чтобы тест прошёл
3. REFACTOR: Улучши код, сохраняя зелёные тесты
import pytest
# Step 1: RED — write failing test
def test_new_stack_is_empty() -> None:
stack = Stack()
assert stack.is_empty() is True
# Step 2: GREEN — minimal implementation
class Stack:
def __init__(self) -> None:
self._items: list = []
def is_empty(self) -> bool:
return len(self._items) == 0
def push(self, item) -> None:
self._items.append(item)
def pop(self):
if not self._items:
raise IndexError("Pop from empty stack")
return self._items.pop()
def size(self) -> int:
return len(self._items)
# Step 3: More tests
def test_push_makes_non_empty() -> None:
stack = Stack()
stack.push(42)
assert stack.is_empty() is False
def test_pop_returns_last_pushed() -> None:
stack = Stack()
stack.push(1)
stack.push(2)
assert stack.pop() == 2
def test_pop_empty_raises() -> None:
stack = Stack()
with pytest.raises(IndexError):
stack.pop()
def test_size_after_operations() -> None:
stack = Stack()
assert stack.size() == 0
stack.push("a")
stack.push("b")
assert stack.size() == 2
stack.pop()
assert stack.size() == 1
Стратегия выбора тестов
| Ситуация | Тип теста | Пример |
|---|---|---|
| Чистая функция | Unit | Калькулятор, валидация |
| Класс с логикой | Unit + моки | Сервис с зависимостями |
| Работа с БД | Integration | Repository + SQLite |
| REST API | Integration | TestClient + реальные сервисы |
| Пользовательский сценарий | E2E | Регистрация -> заказ -> оплата |
| Математические свойства | Property-based | Сортировка, сериализация |