Тестирование классов: методы, состояние и Test-классы
Корзина магазина как подопытная: фикстура раздаёт каждому тесту свежий объект, Test-класс наводит порядок в методах — и ни одно состояние не протекает.
Редакция Питоники
К середине курса у тебя уже хороший арсенал: фикстуры готовят данные, параметризация множит кейсы, conftest.py раздаёт фикстуры папке. Но до сих пор мы тестировали функции — а в настоящих проектах код живёт в классах: корзина магазина, пользователь сессии, парсер документа. У класса нет одного входа и выхода, зато есть методы и состояние: объект создаётся, меняется от вызова к вызову, и тестировать нужно оба слоя. Разберём на сквозном примере — корзине интернет-магазина — как держать объект в фикстуре, почему каждому тесту нужна своя корзина и зачем тесты методов группируют в Test-классы.
Подопытный: класс Cart
Вот минимальная корзина: метод add_item кладёт товар с ценой, total считает сумму. Двух методов достаточно, чтобы обсудить всё, что важно в тестировании классов.
class Cart:
def __init__(self):
self.items = [] # список пар (товар, цена)
def add_item(self, name, price):
self.items.append((name, price))
def total(self):
return sum(price for _, price in self.items)
cart = Cart()
print("Новая корзина:", cart.total())
cart.add_item("Кофе", 350)
cart.add_item("Чай", 120)
print("После покупок:", cart.total())
print("Позиций:", len(cart.items))
Новая корзина: 0 После покупок: 470 Позиций: 2
Классика жанра: создать объект, дёрнуть метод, глазками глянуть на вывод. Работает один раз — при написании. Через месяц после правки метода total никто не станет руками прогонять этот сценарий, и баг спокойно доедет до продакшена. Автотест делает то же самое, но проверку берёт на себя: assert cart.total() == 470 — и глазки больше не нужны.
Обрати внимание, что класс тестировался снаружи: только через публичные методы, без подглядывания во внутренний список. Это не прихоть — так тесты переживают рефакторинг. Завтра ты переименуешь items в lines и заменишь список пар на словарь — тесты, которые проверяли поведение (total после двух покупок), останутся зелёными, а тесты, влезавшие в кишки, пришлось бы переписывать. Правило то же, что с функциями: проверяй контракт, а не устройство.
Объект как фикстура: каждому тесту — свежая корзина
Каждому тесту нужна корзина. Напрашивается создать её один раз на модуле — и это ловушка, до которой мы дойдём ниже. Правильный путь — фикстура: она собирает объект заново для каждого теста, и тесты оказываются в полной изоляции.
from pathlib import Path
import sys
Path("cart_mod.py").write_text('''class Cart:
def __init__(self):
self.items = []
def add_item(self, name, price):
self.items.append((name, price))
def total(self):
return sum(price for _, price in self.items)
''', encoding="utf-8")
Path("conftest.py").write_text('''import pytest
from cart_mod import Cart
@pytest.fixture
def cart():
return Cart()
''', encoding="utf-8")
Path("test_cart.py").write_text('''def test_empty_cart(cart):
assert cart.total() == 0
def test_add_one(cart):
cart.add_item("Чай", 120)
assert cart.total() == 120
def test_add_two(cart):
cart.add_item("Чай", 120)
cart.add_item("Кофе", 350)
assert cart.total() == 470
''', encoding="utf-8")
for name in ("cart_mod", "conftest", "test_cart"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_cart.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.01s exit: 0
Три теста — три разные корзины. test_empty_cart получил пустую и проверил нулевую сумму; test_add_one положил чай и увидел 120; test_add_two собрал свою пару товаров. Если бы корзина была одна на всех, test_add_two увидел бы уже не пустой список, а чужой чай — и правильное утверждение total == 470 не выполнилось бы. Фикстура со scope по умолчанию «function» делает ровно то, что нужно классу с состоянием: новый объект на каждый тест. Заметь и вторую выгоду: тесты ничего не знают об устройстве Cart — им не важно, список там внутри или словарь; они проверяют поведение, а значит, переживут любой рефакторинг класса.
А если общий объект действительно нужен?
Иногда дорогой объект оправданно создаётся один раз: подключение к настоящей тестовой базе, тяжёлый парсер документа, клиент внешнего сервиса. Для этого фикстуры умеют менять время жизни параметром scope — и это легальный способ делить состояние, в отличие от объекта на модуле.
| scope фикстуры | Сколько живёт объект | Годится для Cart? |
|---|---|---|
| function (по умолчанию) | один тест — один объект | да: корзина меняется от теста к тесту |
| module | все тесты одного файла | нет: состояние протечёт между тестами |
| session | весь прогон сьета | нет, кроме неизменяемых тяжёлых клиентов |
Рабочее правило: скоуп по умолчанию, пока объект дешёвый. Cart создаётся за микросекунды — никакой причины делить его между тестами не существует. Поднимай scope на module или session только когда замер показал, что создание объекта съедает заметную часть прогона, и только если тесты не меняют объект: читают — пожалуйста, пишут — ищи другой путь.
Тестируем состояние после операций
Метод вернул правильное число — хорошо. Но у класса с состоянием есть второй слой проверок: как изменились данные объекта после операции. Корзина обязана не только посчитать сумму, но и честно запомнить позиции.
from pathlib import Path
import sys
Path("cart_mod.py").write_text('''class Cart:
def __init__(self):
self.items = []
def add_item(self, name, price):
self.items.append((name, price))
def total(self):
return sum(price for _, price in self.items)
''', encoding="utf-8")
Path("conftest.py").write_text('''import pytest
from cart_mod import Cart
@pytest.fixture
def cart():
return Cart()
''', encoding="utf-8")
Path("test_state.py").write_text('''def test_total_grows(cart):
cart.add_item("Чай", 120)
cart.add_item("Кофе", 350)
assert cart.total() == 470
cart.add_item("Сироп", 90)
assert cart.total() == 560
def test_items_keep_names(cart):
cart.add_item("Кофе", 350)
assert cart.items == [("Кофе", 350)]
def test_still_empty(cart):
assert cart.items == []
''', encoding="utf-8")
for name in ("cart_mod", "conftest", "test_state"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_state.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.02s exit: 0
Обрати внимание на test_total_grows: два assert в одном тесте — это один сценарий «добавили, добавили, ещё добавили» с контрольными точками по дороге. А вот смешивать в одном тесте сценарий корзины и сценарий, скажем, скидок, не стоит: упадёт один assert — неизвестно, какая половина класса сломана. Правило: один тест — одна причина падения.
| Что проверяем у класса | Как | Пример из Cart |
|---|---|---|
| возврат метода | assert метод(...) == ожидание | cart.total() == 470 |
| состояние после операции | assert поле объекта | cart.items == [("Кофе", 350)] |
| пустой старт | свежий объект из фикстуры | cart.total() == 0 |
| накопление операций | несколько вызовов в одном тесте | 470, затем 560 |
Test-классы: группировка тестов методов
Когда методов становится много, тесты наводят порядок классами: pytest собирает любой класс с именем на Test и запускает его методы с префиксом test_. Такой класс — не ООП в полном смысле, а папка для тестов: экземпляр не создаётся, self — это сам класс, и состояние между методами через него не хранят.
from pathlib import Path
import sys
Path("cart_mod.py").write_text('''class Cart:
def __init__(self):
self.items = []
def add_item(self, name, price):
self.items.append((name, price))
def total(self):
return sum(price for _, price in self.items)
''', encoding="utf-8")
Path("conftest.py").write_text('''import pytest
from cart_mod import Cart
@pytest.fixture
def cart():
return Cart()
''', encoding="utf-8")
Path("test_grouped.py").write_text('''class TestCart:
def test_starts_empty(self, cart):
assert cart.total() == 0
def test_total_after_add(self, cart):
cart.add_item("Чай", 120)
assert cart.total() == 120
def test_items_grow(self, cart):
cart.add_item("Кофе", 350)
assert len(cart.items) == 1
''', encoding="utf-8")
for name in ("cart_mod", "conftest", "test_grouped"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_grouped.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.02s exit: 0
Фикстура пробралась и в методы: pytest подставляет её аргументом метода test_starts_empty, self при этом остаётся классом. Зачем группировка нужна: отчёт читается адресами — test_grouped.py::TestCart::test_total_after_add сразу говорит, какой метод корзины сломался; команды запускают выборочно — pytest test_cart.py::TestCart; и фикстуры можно объявлять прямо в Test-классе, тогда они достанутся только его методам.
class TestCart:
def __init__(self): # pytest не собирает такой класс!
self.limit = 1000
def test_limit(self, cart):
assert cart.total() <= self.limit
Куда же деваться данным, которые нужны всем методам группы? В фикстуру, объявленную прямо внутри Test-класса: её увидят только методы этого класса, а чужие тест-файлы — нет. Это локальная фикстура в миниатюре: та же механика, что у фикстур из conftest.py, только область видимости — один класс.
class TestCart:
@pytest.fixture
def limit(self):
return 1000 # увидят только методы TestCart
def test_within_limit(self, cart, limit):
cart.add_item("Ноутбук", limit - 1)
assert cart.total() <= limit
def test_total_exceeds(self, cart, limit):
cart.add_item("Ноутбук", limit + 1)
assert cart.total() > limit
Второй житель Test-класса — методы-хелперы без префикса test_. pytest их не запускает: это твой личный инструмент, который тесты зовут сами. Классический пример — сборка корзины с заранее известным наполнением, чтобы не повторять три строки add_item в каждом тесте.
class TestCart:
def filled_cart(self, cart):
cart.add_item("Чай", 120)
cart.add_item("Кофе", 350)
return cart # хелпер, а не тест: нет префикса test_
def test_total_of_filled(self, cart):
assert self.filled_cart(cart).total() == 470
def test_items_count(self, cart):
assert len(self.filled_cart(cart).items) == 2
Падение в Test-классе: читаем адрес
Уроним один метод группы и посмотрим, как pytest показывает, КТО именно сломался.
from pathlib import Path
import sys
Path("cart_mod.py").write_text('''class Cart:
def __init__(self):
self.items = []
def add_item(self, name, price):
self.items.append((name, price))
def total(self):
return sum(price for _, price in self.items)
''', encoding="utf-8")
Path("conftest.py").write_text('''import pytest
from cart_mod import Cart
@pytest.fixture
def cart():
return Cart()
''', encoding="utf-8")
Path("test_fail.py").write_text('''class TestCart:
def test_total(self, cart):
cart.add_item("Кофе", 350)
assert cart.total() == 999
''', encoding="utf-8")
for name in ("cart_mod", "conftest", "test_fail"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_fail.py", "-q", "--no-header", "--tb=no", "-p", "no:cacheprovider"])
print("exit:", int(rc))
F [100%] =========================== short test summary info =========================== FAILED test_fail.py::TestCart::test_total - assert 350 == 999 1 failed in 0.02s exit: 1
Строка FAILED test_fail.py::TestCart::test_total - assert 350 == 999 — вся диагностика в одном адресе: файл, Test-класс, метод, а после тире — что ожидалось и что вышло. По адресу тест запускается точечно, без всей группы. Это и есть плата за порядок: сгруппированные тесты не просто лежат красиво, они адресуются до конкретного метода конкретного класса. И последнее: Test-классы можно вкладывать друг в друга и наследовать, но в реальных проектах это редкость — один уровень группировки почти всегда достаточен, а наследование тестов связывает файлы крепче, чем хочется.
Что дальше
Соберём урок в короткий чек-лист — возвращайся к нему, когда достанешь из проекта очередной класс:
- объект класса — в фикстуру: каждому тесту свежий экземпляр, никакого объекта на модуле;
- проверяй поведение: возврат метода и состояние после операции — двумя assert-ами, если это один сценарий;
- тесты методов группируй в Test-класс — отчёт начнёт адресоваться до конкретного метода;
- забудь про __init__ в Test-классе: подготовка — это фикстура, локальная или из conftest.py;
- хелперы без префикса test_ оставайся в классе на правах служебного кода — в отчёт они не попадут.
Класс перестал быть пугающим: фикстура раздаёт свежие объекты, состояние проверяется тем же assert, что и возврат метода, Test-класс наводит адресацию в отчёте. Тот же приём — объект в фикстуре — станет основой финального проекта: там класс Order из модуля заказов получит собственные тесты, а общие объекты переедут в conftest.py. Следующий урок — про то, как тестировать классы, которые пишут и читают файлы: JSON-документы и временные папки tmp_path.
Тесты класса проверяют два слоя: что метод вернул и как изменилось состояние — и то и другое ловится обычным assert.
Сначала предскажи ответ в голове — это главный навык программиста.
class Accumulator:
def __init__(self):
self.values = []
def add(self, x):
self.values.append(x)
def total(self):
return sum(self.values)
acc = Accumulator()
acc.add(10)
acc.add(32)
print(acc.total(), len(acc.values))
# conftest.py
import pytest
from cart_mod import Cart
@pytest.fixture
def cart():
return Cart()
# test_cart.py
def test_first(cart):
cart.add_item("Чай", 120)
assert cart.total() == 120
def test_second(cart):
assert cart.items == []
# pytest.main(["test_cart.py", "-q", "--no-header"])
class TestCart:
def test_a(self):
assert True
def helper(self):
return 1
def test_b(self):
assert True
# pytest.main(["test_cart.py", "-q", "--no-header"])
1. Почему тестам класса нужна фикстура, а не общий объект на уровне модуля?
2. Какие методы Test-класса pytest соберёт как тесты?
3. Что произойдёт, если объявить __init__ в Test-классе?
4. Как в отчёте pytest адресуется тест метода класса?
5. Тест проверяет корзину: добавил товар, проверил total, добавил ещё, снова проверил. Это хороший тест?
Протестируй класс Stack из модуля stack_mod: метод push кладёт элемент, pop снимает верхний и возвращает его, empty возвращает True для пустого стека. Напиши conftest.py с фикстурой stack и test_stack.py с Test-классом TestStack: один тест проверяет, что новый стек пуст, второй — что push меняет empty на False, третий — что pop возвращает последний положенный элемент. Запусти файл и напечатай код выхода.
Как тестировать методы класса в pytest?
Создай объект (удобнее всего фикстурой), вызови метод и проверь результат assert-ом: cart.add_item("Чай", 120); assert cart.total() == 120. Для группировки положи тесты методов в Test-класс — pytest соберёт его методы с префиксом test_ и покажет их в отчёте адресами вида test_cart.py::TestCart::test_total.
Почему нельзя создавать тестируемый объект один раз на весь файл с тестами?
Все тесты файла получат один и тот же объект: первый тест изменит его состояние, и остальные стартуют не с чистого листа. Тесты начнут зависеть от порядка запуска — поодиночке проходят, вместе падают. Фикстура решает это радикально: она собирает новый объект для каждого теста, поэтому правки одного теста не видны соседям.
Нужен ли self и конструктор в Test-классе pytest?
self нужен в сигнатуре методов — это обычный Python, а вот конструктор __init__ в Test-классе запрещён: pytest не станет собирать класс с __init__ и предупредит cannot collect test class. Состояние между тестами через self не хранят; данные готовят фикстурами, которые pytest подставляет аргументом в каждый метод test_.
Как запустить тесты только одного класса или одного метода?
По адресу из отчёта: pytest test_cart.py::TestCart запустит все методы класса, а pytest test_cart.py::TestCart::test_total — один конкретный метод. Двойное двоеточие разделяет уровни: файл, класс, метод. Так точечно перезапускают упавший тест, не гоняя весь сьют.
Понравился урок? Сошлитесь на него
«Фикстура со scope по умолчанию «function» делает ровно то, что нужно классу с состоянием: новый объект на каждый тест.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
pytest · Урок 5
Фикстуры pytest: @pytest.fixture для подготовки данных
Одна и та же корзина собирается в каждом тесте заново — знакомая копипаста. Фикстура описывает подготовку один раз, а pytest сам передаёт результат в тест по имени.
pytest · Урок 15
conftest.py: общие фикстуры для всей папки
Фикстуры перестали быть домашними: conftest.py делает их общими для всей папки — без единого импорта, силами самого pytest.
pytest · Урок 17
Тесты для JSON и файлов: tmp_path на практике
Сохранить заказы в JSON, прочитать обратно и не разочароваться: round-trip тесты, кириллица с encoding utf-8, битые файлы через pytest.raises и tmp_path — папка, которая достаётся каждому тесту своя.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Flask · Урок 15
Тестирование Flask: pytest и test_client
Пятнадцатый урок курса Flask — мост в мир автотестов: проверки API из предыдущих уроков становятся тест-функциями pytest с assert, а app.test_client() работает внутри теста без сервера и сети.
flask тестирование pytest test_clientтестирование flask api pytest
Flask · Урок 16
Фикстуры для Flask-тестов: conftest, клиент и временная база
Шестнадцатый урок расширения Flask: каждый тест создаёт клиент сам — пора зафиксировать подготовку в фикстурах, вынести её в conftest.py и раздавать тестам временные базы через tmp_path.
pytest flask фикстуры conftestфикстура test_client flask
pytest · Урок 1
Первый тест на pytest: assert, запуск и первый отчёт
Первая тест-функция на обычном assert: pytest сам находит её, запускает и печатает отчёт — точка, 1 passed и код выхода 0. Всё прямо в браузере.
pytest первый тестpytest assert