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

Тестируем классы: состояние, методы и инварианты объекта.

Инвариант — условие, которое всегда должно выполняться для корректного объекта (например, баланс счёта не может стать отрицательным).

Чем тест класса отличается от теста функции

У класса есть состояние, и методы его меняют. Поэтому тестируют не одно «вход → выход», а сценарии: создали объект → вызвали методы → проверили, что состояние стало правильным. Удобно создавать свежий объект в setUp, чтобы каждый тест стартовал с чистого листа.

Пример: банковский счёт

Класс с пополнением, снятием и запретом уйти в минус. Тесты проверяют и нормальное поведение, и инвариант.

import unittest

class Account:
    def __init__(self, balance=0):
        self.balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("сумма должна быть положительной")
        self.balance += amount

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("недостаточно средств")
        self.balance -= amount

class TestAccount(unittest.TestCase):
    def setUp(self):
        self.acc = Account(balance=100)   # свежий счёт на каждый тест

    def test_initial_balance(self):
        self.assertEqual(self.acc.balance, 100)

    def test_deposit_increases_balance(self):
        self.acc.deposit(50)
        self.assertEqual(self.acc.balance, 150)

    def test_withdraw_decreases_balance(self):
        self.acc.withdraw(40)
        self.assertEqual(self.acc.balance, 60)

    def test_cannot_overdraw(self):
        # Инвариант: нельзя снять больше, чем есть
        with self.assertRaises(ValueError):
            self.acc.withdraw(1000)
        # И баланс при этом не изменился
        self.assertEqual(self.acc.balance, 100)

unittest.main(argv=[''], exit=False, verbosity=2)

Вывод:

test_cannot_overdraw (__main__.TestAccount.test_cannot_overdraw) ... ok
test_deposit_increases_balance (__main__.TestAccount.test_deposit_increases_balance) ... ok
test_initial_balance (__main__.TestAccount.test_initial_balance) ... ok
test_withdraw_decreases_balance (__main__.TestAccount.test_withdraw_decreases_balance) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.001s

OK

Тест test_cannot_overdraw делает две проверки одного поведения: что вылетает исключение и что состояние не испортилось. Это законно — обе проверки про один сценарий «попытка перерасхода».

Тестируйте поведение, а не реализацию

Проверяйте, что делает класс через публичный интерфейс, а не как он устроен внутри. Если тест лезет в приватные детали (self.acc._internal_list[3]), он сломается при любом рефакторинге, даже если поведение не изменилось. Хороший тест переживает переписывание внутренностей.

Проверка цепочки действий

Тест может моделировать небольшой сценарий из нескольких шагов — так проверяют, что состояние корректно накапливается:

import unittest

class Stack:
    def __init__(self):
        self._items = []
    def push(self, x):
        self._items.append(x)
    def pop(self):
        return self._items.pop()
    def size(self):
        return len(self._items)

class TestStack(unittest.TestCase):
    def test_push_pop_sequence(self):
        s = Stack()
        s.push(1)
        s.push(2)
        s.push(3)
        self.assertEqual(s.size(), 3)
        self.assertEqual(s.pop(), 3)   # LIFO: последний вошёл — первый вышел
        self.assertEqual(s.pop(), 2)
        self.assertEqual(s.size(), 1)

unittest.main(argv=[''], exit=False, verbosity=2)

Вывод:

test_push_pop_sequence (__main__.TestStack.test_push_pop_sequence) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Здесь мы проверяем через публичные push/pop/size, не заглядывая в _items. Внутреннее хранилище можно заменить — тест останется валидным.

С чего начинать тестировать класс

Удобный порядок: сначала проверьте начальное состояние только что созданного объекта (что после __init__ поля имеют ожидаемые значения), затем каждый публичный метод по отдельности на «счастливом пути», и только потом — нарушения инвариантов и ошибки. Так вы движетесь от простого к сложному и быстро получаете базовое покрытие. Если у класса много методов, не пытайтесь покрыть всё одним гигантским тестом — дробите по методам и сценариям.

Объекты в роли зависимостей

Часто класс принимает другой объект в конструкторе (репозиторий, сервис, логгер). Тестировать такой класс с настоящими зависимостями тяжело и медленно. Решение — подставить вместо них тест-дублёр (мок), о котором подробно пойдёт речь в разделе про unittest.mock. Пока запомните принцип: класс легче тестировать, когда его зависимости приходят снаружи (через конструктор или аргументы), а не создаются жёстко внутри. Это называется внедрением зависимостей и делает код тестируемым.

Итог

  • У классов есть состояние — тестируем сценарии «создать → вызвать методы → проверить».
  • Свежий объект в setUp обеспечивает изоляцию.
  • Проверяйте инварианты (что нельзя нарушить) и через исключения тоже.
  • Тестируйте поведение через публичный интерфейс, а не приватные детали.
Проверьте себя
1. Чем тест класса отличается от теста функции?
AНичем
BУ класса есть состояние — тестируют сценарии: создать объект, вызвать методы, проверить состояние
CКласс нельзя тестировать
DКлассы тестируют только в pytest
2. Почему лучше тестировать через публичный интерфейс, а не приватные детали?
AТак быстрее
BТест на приватных деталях сломается при рефакторинге, даже если поведение не изменилось
CПриватные поля недоступны
DЭто требование unittest
3. Что такое инвариант объекта?
AЕго имя
BУсловие, которое всегда должно выполняться (например, баланс не уходит в минус)
CСписок методов
DВремя создания
Поддержать проект