Я знаю, что разработчики по-разному относятся к тестированию программного обеспечения. Вот некоторые примеры подхода к тестам, которые встречались мне за время работы:

  • На своей первой работе я просто не писал тесты, не зная ничего о них и не понимая, как это делается?

  • Полтора года проработал в компании, где разработчики не писали юнит(модульные) или интеграционные тесты. Всё тестировалось тестировщиками с помощью какого-то BDD фреймворка для тестов, и ручным тестированием.

  • Сейчас я третий год работаю в компании, где мы стараемся писать код по TDD. Тесты в таком подходе появляются ещё до реализации функционала. Реже наоборот. Но тест пишем всегда.

К чему я это всё? Я работал в разных условиях, и каждый из подходов имеет свои преимущества и недостатки. Даже код без тестов имеет место, но скорее в ваших личных пет проектах, в рамках проверки гипотезы или желания как можно скорее написать какой-то кусочек программы.

С каждым годом моё отношение к тестам немного меняется и, так сказать, "устаканивается", но одно остаётся неизменным: я считаю, что без тестов нельзя! Нельзя гарантировать работоспособность коммерческого программного обеспечения. Не говоря уже о том, что даже тесты не гарантируют этого на 100%. Они лишь подтверждают то, что в ряде протестированных нами случаев приложение с большой вероятностью должно работать, как ожидается.

В этой статье я бы хотел затронуть тестирование на python c помощью pytest.

Что пишут сами разработчики pytest: Фреймворк pytest упрощает написание небольших, легко читаемых тестов и может масштабироваться для поддержки сложного функционального тестирования приложений и библиотек.

Опустим момент с установкой pytest, я думаю, каждый желающий сможет справиться с этим сам. Я бы хотел остановиться на конкретных примерах кода, показывающих возможности этого фреймворка.

  1. Самый простой тест(Hello, world!):

def test_answer() -> None:
    assert 2 + 2 == 4

# Запускаем все тесты
# $ pytest .
============ test session starts ===========================
platform linux -- Python 3.10.12, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/user/project
plugins: anyio-4.3.0
collected 1 item                                                                                                                                                                                                       

test_any.py .                                         [100%]

============ 1 passed in 0.00s =============================

Попробуем сломать тест:

def test_answer() -> None:
    assert 2 + 2 == 5

# Запускаем все тесты
# $ pytest .
============ test session starts ===========================
platform linux -- Python 3.10.12, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/user/project
plugins: anyio-4.3.0
collected 1 item                                                                                                                                                                                                       

test_any.py F                                                                                                                                                                                                    [100%]

============ FAILURES ======================================
____________ test_answer ___________________________________

    def test_answer():
>       assert 2 + 2 == 5
E       assert (2 + 2) == 5

test_any.py:2: AssertionError
============ short test summary info =======================
FAILED test_any.py::test_answer - assert (2 + 2) == 5
============ 1 failed in 0.01s =============================

давайте напишем что-то более осмысленное:

# Функция вычисляющая факториал числа
def factorial(n: int) -> int:
    if n in [0, 1]:
        return 1
    return n * factorial(n - 1)


def test_factorial() -> None:
    expected = 120
    
    got = factorial(5)
    
    assert expected == got

# $ pytest . ->  1 passed in 0.00s

Как можно заметить по коду, наша функция, вычисляющая факториал, имеет два возможных поведения(заход в условие или его пропуск), и первое из них может наступить при двух разных значениях. Итого, чтобы полностью убедиться в работоспособности этой функции нам нужно 3 теста:

def test_factorial_return_one_if_number_eq_zero() -> None:
    expected = 1
    
    got = factorial(0)
    
    assert expected == got


def test_factorial_return_one_if_number_eq_one() -> None:
    expected = 1
    
    got = factorial(1)
    
    assert expected == got


def test_factorial_with_five() -> None:
    expected = 120
    
    got = factorial(5)
    
    assert expected == got

# $ pytest . ->  3 passed in 0.01s

Думаю кто-то из вас заметил, что все функции одинаковые по своей сути и коду. Разница заключается только в значениях. Вообще, в любых более менее сложных местах я стараюсь избегать группировки тестов, если они проверяют разные "ветки" поведения, но кажется, что здесь это вполне уместно, и pytest нам с радостью с этим поможет с помощью pytest.mark.parametrize:

@pytest.mark.parametrize(
    ("number", "expected"),
    [
        (0, 1),
        (1, 1),
        (5, 120),
    ],
)
def test_factorial(number: int, expected: int) -> None:
    got = factorial(number)
    
    assert expected == got

# $ pytest . ->  3 passed in 0.01s

Если вы хотите использовать функционал группировки тестов, но иметь больший контроль над каждым из тестов, то на помощь вам придёт pytest.param:

@pytest.mark.parametrize(
    ("number", "expected"),
    [
        pytest.param(0, 1, id="return one if number equal zero"),
        (1, 1),
        (5, 120),
        pytest.param(
            100,
            93326215443944152681699238856266700490715968264381621468592963895217599993229915608941463976156518286253697920827223758251185210916864000000000000000000000000,
            marks=pytest.mark.skip(reason="Slow test"),
        ),
    ],
)
def test_factorial(number: int, expected: int) -> None:
    got = factorial(number)
    
    assert expected == got

# $ pytest . ->  3 passed, 1 skipped in 0.01s

Мы уже дважды встретили pytest.mark. Давайте рассмотрим немного подробнее его популярные применения:

  1. pytest.mark.parametrize - поможет, чтобы сгруппировать тесты. Будьте осторожны, если указать несколько parametrize декораторов, то вы можете получить комбинаторный взрыв?:

@pytest.mark.parametrize("number1", [1, 2, 3])
@pytest.mark.parametrize("number2", [4, 5, 6])
@pytest.mark.parametrize("number3", [7, 8, 9])
def test_sum_from_builtins(number1: int, number2: int, number3: int) -> None:
    got = sum([number1, number2, number3])
    
    assert got == number1 + number2 + number3

# $ pytest . ->  27 passed in 0.02s

# Входные данные
# 1 - [7, 4, 1]
# 2 - [7, 4, 2]
# 3 - [7, 4, 3]
# 4 - [7, 5, 1]
# ...
# 27 - [9, 6, 3]
  1. pytest.mark.skip - даёт возможность пропускать требуемые тесты. Хороший пример, когда эта маркировка хорошо обоснована и выглядит лучше, чем временное комментирование теста - это интеграционный тест, который перестал работать, но не ломает общей функциональности приложения:

@pytest.mark.skip(
  reason="Известная ошибка в версии библиотеки 1.2.3, исправление ожидается в следующем релизе",
)
def test_some_function() -> None:
    assert some_function() == expected_result

Ещё есть skipif. Суть та же, но можно установить условие:

@pytest.mark.skipif(
    sys.platform == "win32", 
    reason="Тест не поддерживается на Windows",
)
def test_unix_specific_function() -> None:
    assert unix_specific_function() == expected_result

В примере выше тест не будет выполняться на машине того, кто использует windows в качестве операционной системы. В этом нет ничего страшного, если таких тестов немного, и в CI тесты всё равно будут выполняться на требуемой ОС. Это может создать проблемы, если тому, кто работает на windows нужно будет писать такой тест(ы).

  1. pytest.mark.xfail - помеченные им тесты должны завершаться неперехваченной ошибкой. В моей практике встречались тесты, которые были завязаны на удалённую базу данных(не являюсь ценителем таких тестов). В один прекрасный момент в удалённой тестовой БД поменялись данные, и нам нужно было ждать накатки свежих, корректных данных. Как раз тогда мы и использовали данную маркировку, чтобы не удалять требуемый тест и иметь возможность выпускать новые версии приложения:

@pytest.mark.xfail(
    reason="Нужно дождаться, пока накатят новые акционные "
           "цены после НГ(неделя максимум думаю).",
)
class TestDomainPrices:
  ...

Одна из самых важных фич pytest это, конечно же, фикстуры(pytest.fixture). И вместо того, чтобы показать вам именно pytest.mark.usefixtures я хочу заострить внимание в целом на механизме фикстур в pytest.

Пример тестирования без фикстур:

class PriceManager:
    def __init__(
        self,
        x_price_source: PriceSource,
        y_price_source: PriceSource,
    ) -> None:
        ...
    
    def get_price(self, product: Product) -> Decimal | None:
        if product.type == "x":
            return self.x_price_source.get(product)
        elif product.type == "y":
            return self.y_price_source.get(product)
        else:
            return None


class TestPriceManager:
    def test_get_price_if_product_type_eq_x(self) -> None:
        product = Product(type="x")
        price_manager = PriceManager(
            x_price_source=StubXPriceSource(return_result=Decimal("150.00")),
            y_price_source=StubYPriceSource(return_result=Decimal("220.00")),
        )
        
        got = price_manager.get_price(product)
        
        assert got == Decimal("150.00")
        
    def test_get_price_if_product_type_eq_y(self) -> None:
        product = Product(type="y")
        price_manager = PriceManager(
            x_price_source=StubXPriceSource(return_result=Decimal("150.00")),
            y_price_source=StubYPriceSource(return_result=Decimal("220.00")),
        )
        
        got = price_manager.get_price(product)
        
        assert got == Decimal("220.00")

    def test_get_price_if_product_type_unknown(self) -> None:
        product = Product(type="unknown_product_type")
        price_manager = PriceManager(
            x_price_source=StubXPriceSource(return_result=Decimal("150.00")),
            y_price_source=StubYPriceSource(return_result=Decimal("220.00")),
        )
        
        got = price_manager.get_price(product)
        
        assert got == None

Обратите внимание, что в наших юнит-тестах каждый раз нужно по новой создавать PriceManager. Решением в лоб, будет фабрика для PriceManager:

def price_manager() -> PriceManager:
    return PriceManager(
        x_price_source=StubXPriceSource(return_result=Decimal("150.00")),
        y_price_source=StubYPriceSource(return_result=Decimal("220.00")),
    )

    
class TestPriceManager:
    def test_get_price_if_product_type_eq_x(self) -> None:
        product = Product(type="x")
        # Я специально не сделал так: price_manager.get_price(product),
        # потому что, это смешало бы шаги подготовки данных для теста 
        # и непосредственного тестирования объекта.
        # подход называется: AAA(arrange act assert) - всем советую ?
        price_manager = price_manager()
        
        got = price_manager.get_price(product)
        
        assert got == Decimal("150.00")
        
    ...

С помощью pytest можно сделать эту фабрику более гибкой:

@pytest.fixture()
def price_manager() -> PriceManager:
    return PriceManager(
        x_price_source=StubXPriceSource(return_result=Decimal("150.00")),
        y_price_source=StubYPriceSource(return_result=Decimal("220.00")),
    )

    
class TestPriceManager:
    # pytest сам внедрит(DI) во время теста требуемые(price_manager) зависимости. 
    def test_get_price_if_product_type_eq_x(self, price_manager: PriceManager) -> None:
        product = Product(type="x")
        
        got = price_manager.get_price(product)
        
        assert got == Decimal("150.00")
        
    ...

У вас может возникнуть логичный вопрос: "Чем использование сторонней библиотеки лучше, чем то, что мы сделали ранее?". Далее я как раз собираюсь ответить на него.

Давайте рассмотрим некоторые из преимуществ использования фикстуры вместо фабричных функций:

  1. Фикстурами могут пользоваться множество тестов, не импортируя их.

# conftest.py

import pytest


@pytest.fixture()
def five() -> int:
    return 5
# first_test_module.py

def test_first(five: int) -> None:
    ...
# second_test_module.py

def test_second(five: int) -> None:
    ...
  1. Для фикстуры можно указать, как часто она будет выполняться:

# Возможные значения: "session", "package", "module", "class", "function"
# По умолчанию установлено значение "function"

# scope="session" указывает на то, что эта фикстура 
# будет выполнена единожды за весь сеанс тестирования 
@pytest.fixture(scope="session")
def crate_test_db() -> None:
    ...


# scope="function" указывает на то, что эта фикстура 
# будет выполняться для каждой тестовой функции 
@pytest.fixture(scope="function")
def async_session() -> AyncSession:
    ...
  1. Одни фикстуры могут использовать другие. Вкупе с первым пунктом можно делать сложные иерархии фикстур, не беспокоясь о куче импортов и не засорять код явными вызовами a(), b(), ... :

@pytest.fixture()
def a() -> None:
    ...

@pytest.fixture()
def b(a: None) -> None:
    ...

@pytest.fixture()
def c(b: None, fixture_from_another_file: None) -> None:
    ...
  1. Можно включить автоиспользование требуемых фикстур и с требуемой частотой:

# Эта фикстура будет автоматически запускаться 
# перед каждым тестом и очищать тестовую базу данных
@pytest.fixture(scope="function", autouse=True)
def clear_test_db() -> None:
    ...

У вас мог возникнуть логичный вопрос: "Как передать в фикстуру аргументы?", ведь не всегда нам достаточно базового поведения. Есть два варианта:

  1. С помощью parametrize + inderect=True:

class MyTester:
    def __init__(self, x: int, y: int) -> None:
        self._x = x
        self._y = y

    def sum(self) -> int:
        return self._x + self._y

@pytest.fixture()
def tester(request) -> MyTester:
    return MyTester(request.param[0], request.param[1])

class TestIt:
    @pytest.mark.parametrize('tester', [[1, 2], [3, 0]], indirect=True)
    def test_tc1(self, tester) -> None:
       assert 3 == tester.sum()

# $ pytest . -> 2 passed in 0.00s
  1. С помощью всё того же parametrize(не самый явный способ):

@pytest.fixture()
def my_tester(test_data: list[int]) -> MyTester:
    return MyTester(test_data[0], test_data[1])

class TestIt:
    @pytest.mark.parametrize('test_data', [[1, 2], [3, 0], [2, 1]])
    def test_tc1(self, my_tester: MyTester):
       assert 3 == my_tester.sum()

# $ pytest . -> 3 passed in 0.00s

В таком варианте, скорее всего, ваша idea будет намекать вам, что, что-то вы делаете не так:

Вы конечно можете добавить test_data в аргументы функции, но это будет выглядеть не менее странно:

class TestIt:
    @pytest.mark.parametrize('test_data', [[1, 2], [3, 0], [2, 1]])
    # test_data не используется в самом коде теста. 
    def test_tc1(self, my_tester: MyTester, test_data: list[int]):
       assert 3 == my_tester.sum()

В завершении первой части хочется разбавить эту кучу декораторов, чем-то не менее важным.

Ни для кого, не секрет, что в нашем коде могут присутствовать исключения, которые возникают по нашей(или нет) воле. Например, перехватили ошибку от библиотеки в инфраструктурном коде, и в бизнес-логику передали уже что-то не касающееся сторонней библиотеки:

class SQLAlchemyTariffRepository(BaseTariffRepository):
    def __init__(self, session: AsyncSession) -> None:
        self._session = session

    async def get(self, id: int) -> Tariff:
        async with self._session as session:
            result = await session.execute(
                select(TariffModel).filter_by(id=id),
            )
            try:
                tariff_model = result.one()[0]
            except NoResultFound:
                raise TariffDoesNotExist(f"{id=}")
            return tariff_model.to_entity()

Как правильно протестировать такой код? Очень просто!

# Ещё один полезный маркировщик, чтобы асинхронные тесты работали
@pytest.mark.asyncio()
class TestSQLAlchemyTariffRepository:
    async def test_get_raise_exception_if_tariff_does_not_exist(
        self,
        async_session: AsyncSession,
        sqlalchemy_tariff_repository: SQLAlchemyTariffRepository,
    ) -> None:
        UNKNOWN_TARIFF_ID = 999

        with pytest.raises(TariffDoesNotExist) as error:
          await sqlalchemy_tariff_repository.get(UNKNOWN_TARIFF_ID)

        assert error.value.args == (f"id={UNKNOWN_TARIFF_ID}",)

Также в завершение хочется сказать "Не бойтесь тестов. Тесты - это ваша опора и документация сервиса!"

Спасибо всем, кто осилил статью! Если вам нужно больше информации про pytest, прошу дать мне знать(комментарии или "лайки").

Комментарии (15)


  1. IvanZaycev0717
    10.08.2024 13:53

    Добавлю к статье - откуда брать данные для тестирования. Можно, конечно, самим придумать, но есть сторонние библиотеки faker и mimesis. Я бы рекомендовал mimesis - там отличная документация и тестовые данные на любой вкус и цвет: будь то картинки, номера телефонов, IP-адреса и почти все что только можно


    1. KarmanovichDev Автор
      10.08.2024 13:53

      Я стараюсь тестовые данные писать сам. Тесты == документация к использованию моего приложения. Часто можно показать пользователю(другому разработчику) более реальные пути использования приложения в добавок к покрытию тестами.

      В любом случае ваш совет имеет место быть и может многим показать как упросить себе жизнь, спасибо!


  1. Gadd
    10.08.2024 13:53

    А зачем объединять тесты в классы? Кроме лишнего отступа это ничего не даёт.
    В питоне, если нет внутреннего состояния - нет смысла объединять группу функций в объект с методами


    1. KarmanovichDev Автор
      10.08.2024 13:53
      +1

      Тестовый класс объединяет в себе тесты тестируемого класса(тесты связаны одной целью).

      Есть примеры, когда нет состояния, но есть класс. Например, при применении паттерна абстрактная фабрика. Или обычная фабрика, которая умеет из разных входных данных создавать или разные виды объекта. Не единственные примеры. Первое, что пришло в голову.

      Искать очень удобно, когда тестовый класс называется точно так же, как класс, который он тестирует с припиской тест.

      Декораторы от pytest можно навесить на класс и они применяться к каждому тесту внутри класса, что позволит не весить его на каждый метод.

      Также фикстуры можно создавать единожды на класс.

      Думаю, если подумать, и/или погуглить можно ещё найти причин каких-то.


    1. KarmanovichDev Автор
      10.08.2024 13:53

      Делать без класса не плохо. Тут скорее мне нравится объединять в классы тесты.


      1. Gadd
        10.08.2024 13:53

        В качестве контейнера для тестов может служить не только класс, но и модуль. Но ладно, это уже вкусовщина, у каждого свои фломастеры :-)


        1. Andrey_Solomatin
          10.08.2024 13:53
          +1

          Класс это часть имени теста и иногда удобно сгруппировать тесты вместе чтобы не дублировать общее имя в каждой тестовой функции.


  1. Andrey_Solomatin
    10.08.2024 13:53

    Обзор фикстур без yield не будет полным. Очень удобно подчищать за собой. https://docs.pytest.org/en/6.2.x/fixture.html#yield-fixtures-recommended

    @pytest.fixture
    def sending_user(mail_admin):
        user = mail_admin.create_user()
        yield user
        mail_admin.delete_user(user)


    1. KarmanovichDev Автор
      10.08.2024 13:53

      Решил что для первой части будет достаточно )


  1. Andrey_Solomatin
    10.08.2024 13:53

    Планируете рассказать про вcтроенные фикстуры, raises, approx и monkeypatch vs unittest.mock.patch?


    1. KarmanovichDev Автор
      10.08.2024 13:53

      Про raises упомянул в самом конце статьи.

      Насчёт approx - постараюсь не забыть и упомянуть.

      Насчёт последнего - сравнивать не планировал библиотеки в рамках этой серии статей, но рассказывать про возможности патчинга и создания тестовых дублёров планирую.

      Причин не помню, но сам обычно использую pytest-mock библиотеку для этих вещей, ну или пишу тестовые дублёры сам.


      1. Andrey_Solomatin
        10.08.2024 13:53

        Как я понимаю monkeypatch появился намого раньше mock.patch. Сейчас я до конца не определился, что именно использовать и использую их в перемешку.


    1. KarmanovichDev Автор
      10.08.2024 13:53

      Про raises упомянул в самом конце статьи.

      Насчёт approx - постараюсь не забыть и упомянуть.

      Насчёт последнего - сравнивать не планировал библиотеки в рамках этой серии статей, но рассказывать про возможности патчинга и создания тестовых дублёров планирую.

      Причин не помню, но сам обычно использую pytest-mock библиотеку для этих вещей, ну или пишу тестовые дублёры сам.


  1. Andrey_Solomatin
    10.08.2024 13:53

    Я бы еще про линтеры упомянул.

    - ruff https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt
    - плагин для flake8 https://pypi.org/project/flake8-pytest-style/


    1. KarmanovichDev Автор
      10.08.2024 13:53

      Кажется, что код-стайл это дело каждого(каждой команды). Особенно когда он выходит за рамки стандартных линтеров и является доп. библиотеками.