Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 16 из 20 Средний 30 мин 120 XP

Тестирование классов: методы, состояние и Test-классы

Корзина магазина как подопытная: фикстура раздаёт каждому тесту свежий объект, Test-класс наводит порядок в методах — и ни одно состояние не протекает.

Редакция Питоники

К середине курса у тебя уже хороший арсенал: фикстуры готовят данные, параметризация множит кейсы, conftest.py раздаёт фикстуры папке. Но до сих пор мы тестировали функции — а в настоящих проектах код живёт в классах: корзина магазина, пользователь сессии, парсер документа. У класса нет одного входа и выхода, зато есть методы и состояние: объект создаётся, меняется от вызова к вызову, и тестировать нужно оба слоя. Разберём на сквозном примере — корзине интернет-магазина — как держать объект в фикстуре, почему каждому тесту нужна своя корзина и зачем тесты методов группируют в Test-классы.

Подопытный: класс Cart

Вот минимальная корзина: метод add_item кладёт товар с ценой, total считает сумму. Двух методов достаточно, чтобы обсудить всё, что важно в тестировании классов.

класс Cart из рабочего кода
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
Обычный Python без pytest: создали объект, поманипулировали, посмотрели. Именно эти шаги и станут тестами.

Классика жанра: создать объект, дёрнуть метод, глазками глянуть на вывод. Работает один раз — при написании. Через месяц после правки метода total никто не станет руками прогонять этот сценарий, и баг спокойно доедет до продакшена. Автотест делает то же самое, но проверку берёт на себя: assert cart.total() == 470 — и глазки больше не нужны.

Обрати внимание, что класс тестировался снаружи: только через публичные методы, без подглядывания во внутренний список. Это не прихоть — так тесты переживают рефакторинг. Завтра ты переименуешь items в lines и заменишь список пар на словарь — тесты, которые проверяли поведение (total после двух покупок), останутся зелёными, а тесты, влезавшие в кишки, пришлось бы переписывать. Правило то же, что с функциями: проверяй контракт, а не устройство.

Объект как фикстура: каждому тесту — свежая корзина

Каждому тесту нужна корзина. Напрашивается создать её один раз на модуле — и это ловушка, до которой мы дойдём ниже. Правильный путь — фикстура: она собирает объект заново для каждого теста, и тесты оказываются в полной изоляции.

conftest.py с фикстурой cart + три теста
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
Класс лежит в модуле cart_mod.py — как в настоящем проекте. Фикстура cart из conftest.py собирает свежий объект каждому тесту, подробно про conftest — в прошлом уроке.

Три теста — три разные корзины. 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 проверяет объект дважды в одном тесте: состояние после двух операций и после третьей. Это нормальная практика — один сценарий, несколько контрольных точек.

Обрати внимание на test_total_grows: два assert в одном тесте — это один сценарий «добавили, добавили, ещё добавили» с контрольными точками по дороге. А вот смешивать в одном тесте сценарий корзины и сценарий, скажем, скидок, не стоит: упадёт один assert — неизвестно, какая половина класса сломана. Правило: один тест — одна причина падения.

Что проверяем у классаКакПример из Cart
возврат методаassert метод(...) == ожиданиеcart.total() == 470
состояние после операцииassert поле объектаcart.items == [("Кофе", 350)]
пустой стартсвежий объект из фикстурыcart.total() == 0
накопление операцийнесколько вызовов в одном тесте470, затем 560

Test-классы: группировка тестов методов

Когда методов становится много, тесты наводят порядок классами: pytest собирает любой класс с именем на Test и запускает его методы с префиксом test_. Такой класс — не ООП в полном смысле, а папка для тестов: экземпляр не создаётся, self — это сам класс, и состояние между методами через него не хранят.

TestCart: тесты, сгруппированные по классу
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
Методы test_ внутри класса собираются так же, как свободные функции. Фикстура cart приходит аргументом — точно так же, как в функции без класса.

Фикстура пробралась и в методы: pytest подставляет её аргументом метода test_starts_empty, self при этом остаётся классом. Зачем группировка нужна: отчёт читается адресами — test_grouped.py::TestCart::test_total_after_add сразу говорит, какой метод корзины сломался; команды запускают выборочно — pytest test_cart.py::TestCart; и фикстуры можно объявлять прямо в Test-классе, тогда они достанутся только его методам.

ошибка: конструктор в Test-классе
class TestCart:
    def __init__(self):            # pytest не собирает такой класс!
        self.limit = 1000

    def test_limit(self, cart):
        assert cart.total() <= self.limit
Тест из этого класса не запустится никогда. В отчёте — предупреждение cannot collect test class 'TestCart' because it has a __init__ constructor и no tests ran. Данные готовь фикстурой.

Куда же деваться данным, которые нужны всем методам группы? В фикстуру, объявленную прямо внутри Test-класса: её увидят только методы этого класса, а чужие тест-файлы — нет. Это локальная фикстура в миниатюре: та же механика, что у фикстур из conftest.py, только область видимости — один класс.

фикстура внутри Test-класса
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
Фрагмент тест-файла: фикстура limit объявлена в классе и приходит методам аргументом наравне с cart из conftest.py. Собери этот файл сам и убедись: оба теста зелёные.

Второй житель Test-класса — методы-хелперы без префикса test_. pytest их не запускает: это твой личный инструмент, который тесты зовут сами. Классический пример — сборка корзины с заранее известным наполнением, чтобы не повторять три строки add_item в каждом тесте.

хелпер внутри Test-класса
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
Вызов self.filled_cart(cart) — обычный вызов метода. Хелпер не попадает в отчёт: у него нет префикса test_, pytest собирает только тесты.

Падение в Test-классе: читаем адрес

Уроним один метод группы и посмотрим, как pytest показывает, КТО именно сломался.

падение внутри TestCart
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
Адрес теста — файл, класс, метод через двойное двоичное двоеточие. По нему тест запускают точечно: pytest test_fail.py::TestCart::test_total. Трассировку спрятал --tb=no.

Строка 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"])
Проверь себя
0 / 5

1. Почему тестам класса нужна фикстура, а не общий объект на уровне модуля?

2. Какие методы Test-класса pytest соберёт как тесты?

3. Что произойдёт, если объявить __init__ в Test-классе?

4. Как в отчёте pytest адресуется тест метода класса?

5. Тест проверяет корзину: добавил товар, проверил total, добавил ещё, снова проверил. Это хороший тест?

Карточки терминов
Запомнено: 0 / 6
Практика

Протестируй класс Stack из модуля stack_mod: метод push кладёт элемент, pop снимает верхний и возвращает его, empty возвращает True для пустого стека. Напиши conftest.py с фикстурой stack и test_stack.py с Test-классом TestStack: один тест проверяет, что новый стек пуст, второй — что push меняет empty на False, третий — что pop возвращает последний положенный элемент. Запусти файл и напечатай код выхода.

practice.py
Вопросы и ответы по уроку

Как тестировать методы класса в 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.