Easy📖Теория5 min

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

Пирамида тестирования, unit/integration/e2e, coverage, property-based testing и TDD

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

Пирамида тестирования

Пирамида тестирования — модель, определяющая оптимальное соотношение между типами тестов:

         /\
        /  \         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 Сортировка, сериализация

Проверь себя

🧪

Согласно пирамиде тестирования, каких тестов должно быть больше всего?

🧪

Чем интеграционный тест отличается от юнит-теста?

🧪

Что означает 90% покрытие кода тестами?

🧪

Что означает цикл Red-Green-Refactor в TDD?