Skip to main content
Back to course
Pytest с нуля: Первые тесты за 2.5 часа
4 / 850%

AAA-паттерн: тесты которые ловят баги

25 минут

Вы уже умеете писать тесты. Но работающий тест и хороший тест — это разные вещи.

Цель: Научиться писать тесты, которые легко читать, поддерживать и которые находят баги раньше, чем их увидят пользователи.

Вы точно готовы?

Убедитесь, что вы прошли предыдущие уроки:

# Вы должны уметь написать такой тест
def test_user_age():
    user = User("Alice", 25)
    assert user.is_adult() == True

Если что-то непонятно — вернитесь к урокам 0-2.

Проблема: плохие тесты

Реальный пример плохого теста:

def test_stuff():
    u = User("Alice", 25)
    assert u.greet() == "Hello, I'm Alice!"
    u.celebrate_birthday()
    assert u.age == 26
    u2 = User("Bob", 16)
    assert not u2.is_adult()
    # ... ещё 10 assert'ов

Что не так:

  • ❌ Непонятное название (test_stuff)
  • ❌ Проверяет сразу несколько вещей
  • ❌ Когда тест падает — непонятно ЧТО сломалось
  • ❌ Сложно модифицировать

Хороший тест должен:

  • ✅ Иметь понятное название
  • ✅ Проверять ОДНУ концепцию
  • ✅ Чётко показывать ЧТО сломалось
  • ✅ Легко читаться и поддерживаться

AAA Pattern: Arrange-Act-Assert

AAA — это структура теста из 3 частей:

  1. Arrange (Подготовка) — создаём данные
  2. Act (Действие) — вызываем тестируемый код
  3. Assert (Проверка) — проверяем результат

Плохой тест: всё смешано

def test_bad():
    user = User("Alice", 25)
    greeting = user.greet()
    assert greeting == "Hello, I'm Alice!"
    user.celebrate_birthday()
    assert user.age == 26

Проблема: Непонятно где setup, где действие, где проверка.

Хороший тест: чёткая структура

def test_user_greeting():
    # Arrange: подготовка данных
    user = User("Alice", 25)
 
    # Act: выполнение действия
    greeting = user.greet()
 
    # Assert: проверка результата
    assert greeting == "Hello, I'm Alice!"

Почему лучше:

  • ✅ Видно ЧТО тестируем (greet())
  • ✅ Видно КАК готовим данные (создаём пользователя)
  • ✅ Видно ЧТО проверяем (приветствие)

Ещё примеры AAA

def test_bank_account_deposit():
    # Arrange
    account = BankAccount(initial_balance=100)
 
    # Act
    new_balance = account.deposit(50)
 
    # Assert
    assert new_balance == 150
    assert account.balance == 150
 
 
def test_bank_account_insufficient_funds():
    # Arrange
    account = BankAccount(initial_balance=50)
 
    # Act & Assert (для исключений можно объединить)
    with pytest.raises(ValueError) as exc_info:
        account.withdraw(100)
 
    assert "Insufficient funds" in str(exc_info.value)

Понятные assert messages

Проблема: непонятные ошибки

Плохой assert:

def test_discount():
    price = calculate_discount(100, 0.1)
    assert price == 90

Когда тест падает:

AssertionError: assert 91 == 90

Непонятно:

  • Что за 91?
  • Что за 90?
  • Откуда взялась скидка 0.1?

Решение: assert с сообщением

def test_discount_with_message():
    # Arrange
    original_price = 100
    discount_rate = 0.1
    expected_price = 90
 
    # Act
    actual_price = calculate_discount(original_price, discount_rate)
 
    # Assert
    assert actual_price == expected_price, (
        f"Discount calculation failed: "
        f"expected {expected_price} but got {actual_price} "
        f"for price {original_price} with {discount_rate * 100}% discount"
    )

Когда тест падает:

AssertionError: Discount calculation failed: expected 90 but got 91
for price 100 with 10.0% discount

✅ Теперь понятно ЧТО пошло не так!

Примеры хороших assert messages

def test_user_adult_status():
    # Arrange
    user = User("Alice", 17)
 
    # Act
    is_adult = user.is_adult()
 
    # Assert
    assert is_adult == False, (
        f"User with age {user.age} should NOT be adult"
    )
 
 
def test_list_contains_item():
    # Arrange
    shopping_list = ["apples", "bread", "milk"]
    item = "bread"
 
    # Act & Assert
    assert item in shopping_list, (
        f"Expected '{item}' to be in {shopping_list}"
    )

One Concept Per Test

Проблема: тест проверяет слишком много

# ❌ ПЛОХО — тестирует И greeting, И birthday, И adult status
def test_user_all_methods():
    user = User("Alice", 17)
 
    # Проверяем greeting
    assert user.greet() == "Hello, I'm Alice!"
 
    # Проверяем birthday
    user.celebrate_birthday()
    assert user.age == 18
 
    # Проверяем adult status
    assert user.is_adult() == True

Когда тест падает на greet():

  • ❌ Остальные проверки НЕ запустятся
  • ❌ Непонятно, сломан только greet() или что-то ещё

Решение: отдельный тест для каждой концепции

# ✅ ХОРОШО — каждый тест проверяет ОДНУ вещь
 
def test_user_greeting():
    """Тест приветствия"""
    # Arrange
    user = User("Alice", 17)
 
    # Act
    greeting = user.greet()
 
    # Assert
    assert greeting == "Hello, I'm Alice!"
 
 
def test_user_celebrates_birthday():
    """Тест увеличения возраста"""
    # Arrange
    user = User("Alice", 17)
 
    # Act
    new_age = user.celebrate_birthday()
 
    # Assert
    assert new_age == 18
    assert user.age == 18
 
 
def test_user_becomes_adult_after_birthday():
    """Тест: после 18-го дня рождения становится взрослым"""
    # Arrange
    user = User("Alice", 17)
 
    # Act
    user.celebrate_birthday()  # Теперь 18
 
    # Assert
    assert user.is_adult() == True, (
        f"User should be adult after turning {user.age}"
    )

Преимущества:

  • ✅ Если greet() сломан — другие тесты всё равно запустятся
  • ✅ По названию теста понятно ЧТО сломалось
  • ✅ Легко добавить новые тесты

Именование тестов: описывайте поведение

Плохие названия

# ❌ ПЛОХО
def test_1():
    pass
 
def test_user():
    pass
 
def test_function():
    pass

Проблема: Не понятно ЧТО тестируется.

Хорошие названия

# ✅ ХОРОШО — описывает ПОВЕДЕНИЕ
 
def test_user_greets_with_their_name():
    """Приветствие содержит имя пользователя"""
    pass
 
def test_deposit_increases_balance():
    """Пополнение увеличивает баланс"""
    pass
 
def test_withdraw_fails_when_insufficient_funds():
    """Снятие выбрасывает ошибку при недостатке средств"""
    pass
 
def test_user_becomes_adult_at_18():
    """Пользователь становится взрослым в 18 лет"""
    pass

Formula для названий

Pattern: test_[что]_[делает]_[при_каких_условиях]

Примеры:

  • test_discount_applies_correctly_for_vip_users
  • test_login_fails_with_wrong_password
  • test_email_sends_when_order_confirmed
  • test_cache_expires_after_one_hour

Практический пример: до и после

До: плохой тест

def test_shopping_cart():
    cart = ShoppingCart()
    cart.add_item("apple", 1.50)
    cart.add_item("bread", 2.00)
    assert cart.total() == 3.50
    cart.apply_discount(0.1)
    assert cart.total() == 3.15
    cart.remove_item("apple")
    assert cart.total() == 1.80

После: хорошие тесты

def test_empty_cart_has_zero_total():
    """Пустая корзина имеет итог 0"""
    # Arrange
    cart = ShoppingCart()
 
    # Act
    total = cart.total()
 
    # Assert
    assert total == 0, "Empty cart should have total of 0"
 
 
def test_adding_items_increases_total():
    """Добавление товаров увеличивает итог"""
    # Arrange
    cart = ShoppingCart()
 
    # Act
    cart.add_item("apple", price=1.50)
    cart.add_item("bread", price=2.00)
    total = cart.total()
 
    # Assert
    assert total == 3.50, (
        f"Expected total 3.50 for apple(1.50) + bread(2.00), got {total}"
    )
 
 
def test_discount_reduces_total_by_percentage():
    """Скидка уменьшает итог на процент"""
    # Arrange
    cart = ShoppingCart()
    cart.add_item("apple", price=1.50)
    cart.add_item("bread", price=2.00)
    original_total = 3.50
    discount = 0.1  # 10%
    expected_total = 3.15
 
    # Act
    cart.apply_discount(discount)
    actual_total = cart.total()
 
    # Assert
    assert actual_total == expected_total, (
        f"10% discount on {original_total} should give {expected_total}, "
        f"but got {actual_total}"
    )
 
 
def test_removing_item_decreases_total():
    """Удаление товара уменьшает итог"""
    # Arrange
    cart = ShoppingCart()
    cart.add_item("apple", price=1.50)
    cart.add_item("bread", price=2.00)
    cart.apply_discount(0.1)  # Total now 3.15
 
    # Act
    cart.remove_item("apple")
    total = cart.total()
 
    # Assert
    # After removing apple (1.35 after discount), only bread remains (1.80)
    assert total == 1.80, (
        f"After removing discounted apple (1.35), "
        f"only bread (1.80) should remain, got {total}"
    )

Что вы изучили

  • AAA Pattern — структура Arrange-Act-Assert
  • Понятные assert messages — сообщения для отладки
  • One concept per test — один тест = одна проверка
  • Хорошие названия тестов — описание поведения
  • Тесты как документация — код объясняет сам себя

Следующий урок

Отлично! Теперь вы пишете чистые тесты. Но что если нужно протестировать одну функцию с 10 разными входными данными? Копировать тест 10 раз?

Переходите к уроку 4: Parametrize: один тест — десятки сценариев

В следующем уроке вы узнаете:

  • @pytest.mark.parametrize для multiple inputs
  • Как сократить 10 тестов до одного
  • Именование параметризованных тестов
  • Когда НЕ использовать parametrize

Устранение неисправностей

Держите один тест для одной концепции и делите сценарии

Проверьте:

  • Разбейте большой тест на несколько мелких по поведению
  • Используйте AAA-структуру, чтобы ясно видеть Arrange/Act/Assert

Давайте осмысленные названия, описывающие поведение и условия

Проверьте:

  • Следуйте шаблону: test_[что]_[делает]_[при_условиях]
  • Оставляйте тест сфокусированным на одном ожидании

Добавьте сообщение в assert с ожидаемым и фактическим значениями

Проверьте:

  • Формулируйте контекст: f"Expected {expected} but got {actual}"
  • Включайте входные данные в сообщение, если это помогает отладке

Вынесите повторяющийся setup в фикстуры — подробно в уроке 6

Проверьте:

  • Создайте общие функции/фикстуры для подготовки данных
  • Подключайте их в тестах вместо копипасты
AAA-паттерн: тесты которые ловят баги — Pytest с нуля: Первые тесты за 2.5 часа — Potapov.me