Перейти к содержимому
К программе курса
Pytest с нуля: тесты, которые реально работают
4 / 1136%

🐛 Тесты, которые ловят настоящие баги: AAA и assert

25 минут

⚠️ Урок устарел. Свежая версия в курсе pytest-basics: AAA-паттерн: тесты которые ловят баги.

Научитесь находить баги ДО пользователей

❌ Сейчас: «Мой код работал, а в production сломался!»
✅ После урока: «Я знаю, какие тесты писать, чтобы баги не доходили до пользователей»

Увидите разницу между «тест проходит» и «тест полезен»
Научитесь думать как «ломатель»: граничные случаи и неверные данные
Поймёте AAA как инструмент мышления, а не просто форматирование

Демонстрация: тест, который поймал бы production-баг

# ❌ Баг: BankAccount позволяет уйти в минус
class BankAccount:
    def __init__(self, balance=0):
        self.balance = balance
 
    def withdraw(self, amount):
        self.balance -= amount  # нет проверки на лимит
 
# ✅ Исправленный код
class BankAccountFixed:
    def __init__(self, balance=0, max_balance=10_000):
        self.balance = balance
        self.max_balance = max_balance
 
    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("Not enough money")
        self.balance -= amount
 
    def deposit(self, amount):
        if self.balance + amount > self.max_balance:
            raise ValueError(f"Balance cannot exceed {self.max_balance}")
        self.balance += amount
import pytest
 
def test_cannot_withdraw_to_negative_balance():
    account = BankAccount()
    account.balance = 100
    with pytest.raises(ValueError, match="Not enough money"):
        account.withdraw(200)  # 100 - 200 = -100 🚨
    assert account.balance == 100  # баланс не изменился
 
def test_deposit_respects_max_balance():
    account = BankAccountFixed(balance=9_500, max_balance=10_000)
    with pytest.raises(ValueError, match="cannot exceed"):
        account.deposit(600)  # 9_500 + 600 = 10_100 > 10_000

AAA: не про выравнивание, а про мышление

  • Arrange — подготовить всё: данные, окружение, моки. После этого шага тестируемая функция готова к вызову.
  • Act — одно действие, один вызов. Если действий два, это уже два теста.
  • Assert — проверка результата: возврат, изменённое состояние или побочный эффект.
def test_withdraw_reduces_balance():
    # ARRANGE — готовим данные
    account = BankAccountFixed(balance=200)
    amount = 50
 
    # ACT — одно действие
    account.withdraw(amount)
 
    # ASSERT — проверяем результат
    assert account.balance == 150

Один тест — одно действие

# ❌ Два действия — непонятно, что сломалось
def test_add_and_complete_task():
    manager = TaskManager()
    manager.add("task 1")
    manager.complete(0)
    assert manager.all() == []
 
# ✅ Разносим
def test_add_task():
    manager = TaskManager()
    manager.add("task 1")
    assert manager.all() == ["task 1"]
 
def test_complete_task_by_index():
    manager = TaskManager()
    manager.add("task 1")
    manager.complete(0)
    assert manager.all() == []

Думайте как ломатель: 5 типов граничных случаев

  1. Пустые значения: None, "", []
  2. Крайние числа: 0, отрицательные, «слишком большие»
  3. Неправильные типы: строка вместо числа
  4. Повторения: два одинаковых вызова подряд
  5. Состояние системы: пустой баланс, заполненный список

Примеры для BankAccount:

def test_deposit_negative_amount():
    account = BankAccountFixed()
    with pytest.raises(ValueError):
        account.deposit(-100)
 
def test_deposit_zero_amount():
    account = BankAccountFixed()
    with pytest.raises(ValueError):
        account.deposit(0)
 
def test_withdraw_from_empty_account():
    account = BankAccountFixed()
    with pytest.raises(ValueError, match="Not enough money"):
        account.withdraw(100)

Минимальные assert-паттерны (ничего лишнего)

import pytest
from src.task_manager import TaskManager
 
def test_assert_patterns():
    manager = TaskManager()
    manager.add("task 1")
 
    # ✅ Проверка наличия/отсутствия в коллекции
    assert "task 1" in manager.all()
    assert "task 2" not in manager.all()
 
    # ✅ Исключение с текстом
    with pytest.raises(ValueError, match="empty"):
        manager.add("")
 
    # ✅ Тип — когда критично для логики
    assert isinstance(manager.all(), list)

Анти‑паттерн: «мега-тест»

# ❌ Слишком много ответственности
def test_task_manager_does_everything():
    manager = TaskManager()
    manager.add("task 1")
    manager.add("task 2")
    manager.complete(0)
    manager.complete(1)
    with pytest.raises(ValueError):
        manager.add("")  # валидация + завершение + дубли — всё вместе
 
# ✅ Разбиваем
def test_task_manager_adds_task():
    manager = TaskManager()
    manager.add("task 1")
    assert manager.all() == ["task 1"]
 
def test_task_manager_validates_empty_text():
    manager = TaskManager()
    with pytest.raises(ValueError):
        manager.add("")
 
def test_task_manager_completes_by_index():
    manager = TaskManager()
    manager.add("task 1")
    manager.complete(0)
    assert manager.all() == []

Уровни сложности

🎯 Базовый: один тест — одно действие, AAA, базовые assert'ы.
🚀 Продвинутый: 5 типов граничных случаев, шпаргалка вопросов, анти-паттерны и их разбиение.

Практика

Найдите баги в TaskManager:

class TaskManager:
    def __init__(self):
        self.tasks = []
 
    def add_task(self, text):
        self.tasks.append(text)  # 🐛 валидация отсутствует
 
    def complete_task(self, index):
        del self.tasks[index]  # 🐛 нет проверки границ

Напишите 3 теста, которые словят баги:

def test_add_task_empty_text_raises_error():
    """Пустая задача должна вызывать ValueError"""
    manager = TaskManager()
    with pytest.raises(ValueError):
        manager.add_task("")
 
def test_complete_task_invalid_index_raises_error():
    """Несуществующий индекс должен вызывать IndexError"""
    manager = TaskManager()
    manager.add_task("task 1")
    with pytest.raises(IndexError):
        manager.complete_task(999)
 
def test_add_task_long_text_works():
    """Длинный текст (1000+ символов) сохраняется полностью"""
    manager = TaskManager()
    long_text = "x" * 1500
    manager.add_task(long_text)
    assert manager.tasks[0] == long_text

Шпаргалка: 5 вопросов для каждого теста

Держите под рукой, когда пишете тесты.

📝 Про входные данные

  • Что если передать None?
  • Пустая строка/список?
  • Очень длинные данные?
  • Спецсимволы?
  • Неправильный тип?

🔢 Про числа

  • Ноль?
  • Отрицательные?
  • Очень большие?
  • Дробные?

🕒 Про состояние

  • Функция вызывается дважды?
  • Данные уже существуют?
  • «Пограничное» состояние системы?

Чеклист: проверьте тест перед коммитом

  • Одно поведение — тест проверяет конкретную функциональность
  • Понятное имя — test_что_проверяем_при_каких_условиях
  • Полный Arrange — все данные подготовлены до вызова функции
  • Чистый Act — одна строка вызова тестируемой функции
  • Точный Assert — проверяет результат, а не процесс
  • Граничные случаи — есть проверки краёв и ошибок
  • Ясность при падении — по сообщению понятно, что сломалось

Бонус: покажите тест коллеге — если понял за 10 секунд, тест хороший.

Теперь вы не просто запускаете тесты — вы проверяете качество кода до того, как баги увидят пользователей. 🎯

🐛 Тесты, которые ловят настоящие баги: AAA и assert — Pytest с нуля: тесты, которые реально работают — Potapov.me