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

Тестируем классы и ошибки

20 минут

До этого момента мы тестировали только функции. Но в реальных проектах вы будете работать с классами и обрабатывать ошибки.

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

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

Убедитесь, что понимаете:

# Базовый класс
class Calculator:
    def add(self, a, b):
        return a + b
 
# Базовое исключение
def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

Если классы или исключения для вас новость — изучите основы Python перед продолжением.

Тестирование методов классов

Создайте класс для тестирования

# src/user.py
 
class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age
 
    def greet(self):
        """Возвращает приветствие"""
        return f"Hello, I'm {self.name}!"
 
    def is_adult(self):
        """Проверяет совершеннолетие"""
        return self.age >= 18
 
    def celebrate_birthday(self):
        """Увеличивает возраст на 1"""
        self.age += 1
        return self.age

Напишите тесты

# tests/test_user.py
 
from src.user import User
 
def test_user_creation():
    """Тест создания пользователя"""
    user = User("Alice", 25)
 
    assert user.name == "Alice"
    assert user.age == 25
 
def test_greet():
    """Тест метода приветствия"""
    user = User("Bob", 30)
 
    greeting = user.greet()
    assert greeting == "Hello, I'm Bob!"
 
def test_is_adult_true():
    """Совершеннолетний пользователь"""
    user = User("Alice", 25)
 
    assert user.is_adult() == True
 
def test_is_adult_false():
    """Несовершеннолетний пользователь"""
    user = User("Charlie", 16)
 
    assert user.is_adult() == False
 
def test_celebrate_birthday():
    """Тест дня рождения"""
    user = User("Alice", 25)
 
    new_age = user.celebrate_birthday()
 
    assert new_age == 26
    assert user.age == 26  # Проверяем что state изменился

Запускаем:

pytest tests/test_user.py -v

Результат:

test_user_creation PASSED                                            [ 20%]
test_greet PASSED                                                    [ 40%]
test_is_adult_true PASSED                                           [ 60%]
test_is_adult_false PASSED                                          [ 80%]
test_celebrate_birthday PASSED                                      [100%]
 
============================= 5 passed in 0.02s =============================

✅ Все методы класса протестированы!

Тестирование исключений: pytest.raises

Почему нужно тестировать ошибки?

Плохой код не должен молча провалиться — он должен выбросить понятную ошибку.

Пример: Деление на ноль должно выбросить ValueError, а не вернуть странное значение.

Создайте код с валидацией

# src/validator.py
 
def validate_email(email):
    """Проверяет email"""
    if "@" not in email:
        raise ValueError("Email must contain @")
 
    if "." not in email:
        raise ValueError("Email must contain domain")
 
    return True
 
def validate_age(age):
    """Проверяет возраст"""
    if age < 0:
        raise ValueError("Age cannot be negative")
 
    if age > 150:
        raise ValueError("Age too high")
 
    return True
 
def divide(a, b):
    """Делит a на b"""
    if b == 0:
        raise ZeroDivisionError("Cannot divide by zero")
 
    return a / b

Тестируем исключения с pytest.raises

# tests/test_validator.py
 
import pytest
from src.validator import validate_email, validate_age, divide
 
def test_validate_email_missing_at():
    """Email без @ должен выбросить ошибку"""
    with pytest.raises(ValueError):
        validate_email("invalid.email.com")
 
def test_validate_email_missing_domain():
    """Email без домена должен выбросить ошибку"""
    with pytest.raises(ValueError):
        validate_email("test@")
 
def test_validate_age_negative():
    """Отрицательный возраст должен выбросить ошибку"""
    with pytest.raises(ValueError):
        validate_age(-5)
 
def test_validate_age_too_high():
    """Слишком большой возраст должен выбросить ошибку"""
    with pytest.raises(ValueError):
        validate_age(200)
 
def test_divide_by_zero():
    """Деление на ноль должно выбросить ошибку"""
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

Что делает pytest.raises:

  • ✅ Тест ПРОЙДЁТ, если код выбросил ожидаемое исключение
  • ❌ Тест ПРОВАЛИТСЯ, если исключение НЕ было выброшено
  • ❌ Тест ПРОВАЛИТСЯ, если выброшено ДРУГОЕ исключение

Запускаем:

pytest tests/test_validator.py -v

Результат:

test_validate_email_missing_at PASSED                               [ 20%]
test_validate_email_missing_domain PASSED                           [ 40%]
test_validate_age_negative PASSED                                   [ 60%]
test_validate_age_too_high PASSED                                   [ 80%]
test_divide_by_zero PASSED                                          [100%]
 
============================= 5 passed in 0.01s =============================

Проверка сообщения об ошибке

Можно проверить не только ТИП исключения, но и ТЕКСТ ошибки:

def test_validate_email_with_message():
    """Проверяем и тип, и текст ошибки"""
    with pytest.raises(ValueError) as exc_info:
        validate_email("invalid.com")
 
    # Проверяем текст ошибки
    assert str(exc_info.value) == "Email must contain @"
 
def test_divide_by_zero_message():
    """Проверяем сообщение при делении на ноль"""
    with pytest.raises(ZeroDivisionError) as exc_info:
        divide(10, 0)
 
    assert "Cannot divide by zero" in str(exc_info.value)

Зачем проверять сообщение?

  • Пользователи видят это сообщение при ошибке
  • Хорошее сообщение помогает понять ЧТО пошло не так
  • Тест защищает от случайного изменения текста ошибки

Практический пример: BankAccount

Создайте класс с валидацией

# src/bank_account.py
 
class BankAccount:
    def __init__(self, balance=0):
        if balance < 0:
            raise ValueError("Initial balance cannot be negative")
 
        self.balance = balance
 
    def deposit(self, amount):
        """Пополнить счёт"""
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
 
        self.balance += amount
        return self.balance
 
    def withdraw(self, amount):
        """Снять деньги"""
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")
 
        if amount > self.balance:
            raise ValueError("Insufficient funds")
 
        self.balance -= amount
        return self.balance

Напишите полный набор тестов

# tests/test_bank_account.py
 
import pytest
from src.bank_account import BankAccount
 
# Тесты счастливого пути
def test_create_account():
    """Создание счёта с балансом"""
    account = BankAccount(100)
    assert account.balance == 100
 
def test_deposit():
    """Пополнение счёта"""
    account = BankAccount(100)
    new_balance = account.deposit(50)
 
    assert new_balance == 150
    assert account.balance == 150
 
def test_withdraw():
    """Снятие денег"""
    account = BankAccount(100)
    new_balance = account.withdraw(30)
 
    assert new_balance == 70
    assert account.balance == 70
 
# Тесты ошибок
def test_negative_initial_balance():
    """Нельзя создать счёт с отрицательным балансом"""
    with pytest.raises(ValueError) as exc_info:
        BankAccount(-100)
 
    assert "cannot be negative" in str(exc_info.value)
 
def test_deposit_negative():
    """Нельзя пополнить на отрицательную сумму"""
    account = BankAccount(100)
 
    with pytest.raises(ValueError):
        account.deposit(-50)
 
def test_withdraw_more_than_balance():
    """Нельзя снять больше чем есть на счёте"""
    account = BankAccount(100)
 
    with pytest.raises(ValueError) as exc_info:
        account.withdraw(200)
 
    assert "Insufficient funds" in str(exc_info.value)
 
def test_withdraw_negative():
    """Нельзя снять отрицательную сумму"""
    account = BankAccount(100)
 
    with pytest.raises(ValueError):
        account.withdraw(-50)

Запускаем:

pytest tests/test_bank_account.py -v

Результат:

============================= 7 passed in 0.02s =============================

Типичные ошибки

Ошибка #1: Забыли использовать pytest.raises

# ❌ ПЛОХО — тест провалится, потому что исключение не поймано
def test_divide_by_zero_bad():
    divide(10, 0)  # Это выбросит ошибку и тест упадёт!
# ✅ ХОРОШО
def test_divide_by_zero_good():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

Ошибка #2: Неправильный тип исключения

# ❌ ПЛОХО — ожидаем ValueError, но выбрасывается ZeroDivisionError
def test_wrong_exception():
    with pytest.raises(ValueError):  # Ожидаем ValueError
        divide(10, 0)  # Но выбрасывается ZeroDivisionError!
 
# Тест провалится: "DID NOT RAISE ValueError"

Ошибка #3: Код не выбрасывает исключение

# ❌ ПЛОХО — забыли raise в коде
def bad_validate_age(age):
    if age < 0:
        print("Age cannot be negative")  # Только print!
        # Забыли raise!
    return True
 
def test_bad_validation():
    with pytest.raises(ValueError):
        bad_validate_age(-5)  # Исключение НЕ выбросится!
 
# Тест провалится: "DID NOT RAISE ValueError"

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

  • Тестирование методов классов — создание объектов в тестах
  • pytest.raises — проверка исключений
  • Проверка error messages — через exc_info.value
  • Тестирование edge cases — негативные сценарии (ошибки)
  • Счастливый путь VS ошибки — оба важны!

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

Отлично! Теперь вы умеете тестировать классы и ошибки. Но как писать чистые тесты, которые легко читать и поддерживать?

Переходите к уроку 3: AAA-паттерн: тесты которые ловят баги

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

  • Arrange-Act-Assert структуру
  • Как писать понятные assert messages
  • Один тест = одна концепция
  • Когда использовать фикстуры вместо inline setup

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

Исключение должно выбрасываться внутри блока with pytest.raises с правильным типом

Проверьте:

  • В коде есть raise нужного типа (ValueError, ZeroDivisionError и т.д.)
  • Тип в pytest.raises совпадает с ожидаемым
  • Вызов, который должен упасть, находится внутри блока with pytest.raises

Проверьте импорт, создание объекта и название метода

Проверьте:

  • Класс импортирован правильно: from src.user import User
  • Экземпляр создан перед обращением к методам
  • Имя метода без опечаток: user.greet(), а не user.greeet()

Посмотрите реальное сообщение ошибки и сравнивайте с нужной подстрокой

Проверьте:

  • Выведите текст: print(exc_info.value)
  • Сравнивайте через in: assert "Email must contain @" in str(exc_info.value)
with pytest.raises(ValueError) as exc_info:
  validate_email("test")

print(f"Actual message: {exc_info.value}") # Посмотрите что там
assert "Email must contain @" in str(exc_info.value)
Тестируем классы и ошибки — Pytest с нуля: Первые тесты за 2.5 часа — Potapov.me