W3docs

Модульное тестирование Python с pytest

Изучите pytest с нуля: утверждения, фикстуры, параметризация тестов и организация набора с conftest.py.

pytest — самый популярный фреймворк тестирования Python. Он позволяет писать небольшие, читаемые тест-функции с использованием обычных операторов assert — без шаблонных классов — и при этом масштабируется до сложных наборов тестов с общими фикстурами, параметризацией и плагинами.

В этой главе рассматривается всё необходимое для тестирования кода Python с pytest: установка, написание первого теста, утверждения и ожидаемые исключения, фикстуры, параметризация, организация тестов с conftest.py, полезные параметры командной строки и наиболее распространённые подводные камни.

Почему pytest?

Python поставляется с модулем unittest, так зачем использовать pytest?

Возможностьunittestpytest
Синтаксис тестовКласс + методОбычная функция
Утвержденияself.assertEqual(a, b)assert a == b
ФикстурыsetUp / tearDown@pytest.fixture (компонуемые)
ПараметризацияРучной цикл@pytest.mark.parametrize
Экосистема плагиновМинимальная1 000+ плагинов (coverage, mock и др.)

pytest также запускает тесты в стиле unittest без изменений, поэтому вы можете переходить на него постепенно.

Установка

pytest не входит в стандартную библиотеку. Установите его с помощью pip внутри виртуального окружения:

python -m venv .venv
source .venv/bin/activate     # Windows: .venv\Scripts\activate
pip install pytest

Проверьте установку:

pytest --version
# pytest 8.x.x

Смотрите Python pip, если вам нужно освежить знания об управлении пакетами.

Первый тест

pytest автоматически обнаруживает файлы с тестами. По умолчанию он ищет:

  • Файлы с именами test_*.py или *_test.py
  • Функции, имена которых начинаются с test_

Создайте math_utils.py с простой функцией:

# math_utils.py

def add(a, b):
    return a + b

Теперь создайте test_math_utils.py в той же директории:

# test_math_utils.py
from math_utils import add

def test_add_positive_numbers():
    assert add(2, 3) == 5

def test_add_negative_numbers():
    assert add(-1, 1) == 0

def test_add_zeros():
    assert add(0, 0) == 0

Запустите тесты:

pytest test_math_utils.py

Вывод:

collected 3 items

test_math_utils.py ...                                                 [100%]

3 passed in 0.01s

Каждая точка соответствует одному пройденному тесту. Упавший тест печатает F и показывает полную разницу утверждений.

Утверждения

pytest перезаписывает обычные операторы assert во время сбора тестов, чтобы при сбоях показывать подробную разницу — не нужны специальные методы утверждений.

def test_assertion_diff():
    result = [1, 2, 4]
    expected = [1, 2, 3]
    assert result == expected   # pytest shows exactly where lists differ

Вывод при сбое выглядит так:

AssertionError: assert [1, 2, 4] == [1, 2, 3]
  At index 2: 4 != 3

Сравнение чисел с плавающей точкой

Никогда не сравнивайте числа с плавающей точкой с помощью == — ошибки округления делают это ненадёжным. Используйте pytest.approx:

import pytest
import math

def circle_area(r):
    return math.pi * r * r

def test_circle_area():
    assert circle_area(5) == pytest.approx(78.53981633974483)

pytest.approx принимает необязательный допуск abs или rel:

assert 0.1 + 0.2 == pytest.approx(0.3, abs=1e-9)

Тестирование ожидаемых исключений

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

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

Аргумент match — это регулярное выражение, проверяемое по сообщению исключения. Если исключение не возникает, pytest помечает тест как провалившийся — это гарантирует обнаружение регрессий, при которых обработка ошибок была случайно удалена.

Смотрите Python Try...Except для более глубокого изучения обработки исключений, и Raising Exceptions для понимания, как их вызывать намеренно.

Параметризация: запуск одного теста с множеством входных данных

@pytest.mark.parametrize позволяет запускать одну и ту же логику теста для нескольких наборов данных без написания цикла:

import pytest
from math_utils import add

@pytest.mark.parametrize("a, b, expected", [
    (2,  3,  5),
    (-1, 1,  0),
    (0,  0,  0),
    (10, -5, 5),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

pytest генерирует отдельный тест-кейс для каждого кортежа и отчитывается по каждому отдельно:

test_math_utils.py::test_add[2-3-5] PASSED
test_math_utils.py::test_add[-1-1-0] PASSED
test_math_utils.py::test_add[0-0-0] PASSED
test_math_utils.py::test_add[10--5-5] PASSED

Это гораздо чище ручного цикла — отдельные сбои изолированы и легко поддаются идентификации.

Фикстуры

Фикстура — это функция, декорированная @pytest.fixture, которая обеспечивает общую подготовку (и опциональный сброс состояния) для тестов. Вместо повторения кода настройки в каждом тесте вы объявляете фикстуру один раз и внедряете её по имени как параметр теста.

Базовая фикстура

import pytest

class UserStore:
    def __init__(self):
        self.users = []

    def add_user(self, name):
        self.users.append(name)

    def count(self):
        return len(self.users)

@pytest.fixture
def store():
    return UserStore()

def test_empty_store(store):
    assert store.count() == 0

def test_add_user(store):
    store.add_user("Alice")
    assert store.count() == 1

pytest видит, что test_add_user имеет параметр store, ищет фикстуру с таким именем, вызывает её и передаёт результат. Каждый тест получает свежий экземпляр фикстуры — изменения в одном тесте никогда не перетекают в другой.

Фикстуры со сбросом состояния (yield)

Используйте yield внутри фикстуры, чтобы разделить её на настройку (до yield) и сброс состояния (после yield). Это гарантирует, что очистка всегда выполняется, даже если тест провалился:

import pytest
import tempfile
import os

@pytest.fixture
def temp_file():
    fd, path = tempfile.mkstemp(suffix=".txt")
    os.close(fd)
    yield path           # test receives the path here
    if os.path.exists(path):
        os.unlink(path)  # always runs after the test

def test_write_to_temp_file(temp_file):
    with open(temp_file, "w") as f:
        f.write("hello")
    with open(temp_file) as f:
        assert f.read() == "hello"

Область видимости фикстуры

По умолчанию фикстуры создаются и уничтожаются один раз на каждую тест-функцию. Вы можете расширить область видимости, чтобы сократить затратную настройку:

ОбластьСоздаётся один раз для
"function" (по умолчанию)Каждой тест-функции
"class"Каждого тест-класса
"module"Каждого тест-файла
"session"Всего тестового прогона
@pytest.fixture(scope="session")
def database_connection():
    conn = create_db_connection()
    yield conn
    conn.close()

Используйте область "session" для дорогостоящих ресурсов, таких как соединения с базой данных или серверные процессы. Используйте область "function" (по умолчанию) для всего, что изменяет состояние.

Встроенные фикстуры

pytest поставляется с несколькими встроенными фикстурами, которые можно использовать без импорта:

  • tmp_pathpathlib.Path, указывающий на временный каталог, уникальный для теста.
  • monkeypatch — заменяет атрибуты, переменные окружения или записи словарей на время теста, а затем автоматически восстанавливает их.
  • capsys — захватывает вывод stdout / stderr, чтобы вы могли делать утверждения по напечатанному тексту.
def greet(name):
    print(f"Hello, {name}!")

def test_greet_output(capsys):
    greet("World")
    captured = capsys.readouterr()
    assert captured.out == "Hello, World!\n"

Использование monkeypatch

monkeypatch — идиоматический способ замены внешних зависимостей в тестах без сторонней библиотеки мокирования:

import time

def get_timestamp():
    return time.time()

def test_get_timestamp(monkeypatch):
    monkeypatch.setattr(time, "time", lambda: 1_000_000.0)
    assert get_timestamp() == 1_000_000.0

После теста time.time восстанавливается до исходной реализации. Смотрите Python Decorators, если хотите понять, как работает @pytest.fixture под капотом.

Организация тестов с conftest.py

Если фикстура нужна тестам в нескольких файлах, поместите её в conftest.py. pytest автоматически обнаруживает файлы conftest.py и делает их фикстуры доступными для всех тестов в той же директории и ниже — без необходимости импорта.

project/
├── conftest.py          # shared fixtures live here
├── test_users.py
├── test_orders.py
└── utils/
    ├── conftest.py      # fixtures scoped to this subdirectory
    └── test_helpers.py
# conftest.py
import pytest

@pytest.fixture
def admin_user():
    return {"name": "Admin", "role": "admin", "active": True}
# test_users.py  — no import needed; pytest injects admin_user automatically
def test_admin_is_active(admin_user):
    assert admin_user["active"] is True

Тесты на основе классов

Вы можете группировать связанные тесты в класс. В отличие от unittest.TestCase, классы pytest не требуют наследования:

class TestCalculator:
    def test_add(self):
        assert 2 + 2 == 4

    def test_multiply(self):
        assert 3 * 4 == 12

    def test_subtract(self):
        assert 10 - 3 == 7

Классы полезны для группировки тестов, имеющих общую логическую тему. Избегайте классов, если группировка не несёт реальной пользы — плоские функции проще.

Метки: пропуск тестов и пользовательские метки

Система меток pytest позволяет аннотировать тесты метаданными для избирательного выполнения.

Пропуск теста

import pytest
import sys

@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
    assert False

@pytest.mark.skipif(sys.platform == "win32", reason="Linux only")
def test_linux_feature():
    assert True

Пользовательские метки

Зарегистрируйте пользовательские метки в pytest.ini (или pyproject.toml), чтобы помечать тесты по категориям:

# pytest.ini
[pytest]
markers =
    slow: marks tests as slow (deselect with -m "not slow")
    integration: marks integration tests
@pytest.mark.slow
def test_large_dataset():
    ...

Запустить только медленные тесты:

pytest -m slow

Запустить всё, кроме медленных тестов:

pytest -m "not slow"

Полезные параметры командной строки

pytest                          # run all discovered tests
pytest test_math_utils.py       # run a specific file
pytest test_math_utils.py::test_add  # run one test by name
pytest -v                       # verbose: show each test name
pytest -x                       # stop on first failure
pytest --tb=short               # shorter traceback (default is long)
pytest -k "add"                 # run tests whose name contains "add"
pytest --lf                     # re-run only last-failing tests
pytest -q                       # quiet: minimal output

Покрытие тестами

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

pip install pytest-cov
pytest --cov=math_utils --cov-report=term-missing

Вывод добавляет столбец покрытия, показывающий, какие строки не были затронуты:

Name            Stmts   Miss  Cover   Missing
---------------------------------------------
math_utils.py       2      0   100%

Стремитесь к высокому покрытию критической бизнес-логики, но не гонитесь за 100% — тестирование тривиальных геттеров зачастую добавляет шум без пользы.

Распространённые ошибки

1. Фикстура не найдена. Если pytest сообщает fixture 'foo' not found, проверьте, что фикстура находится в conftest.py или в том же файле, и что функция декорирована @pytest.fixture.

2. Ошибки импорта во время сбора. Если pytest не может импортировать ваш модуль, он выдаёт ошибку до запуска каких-либо тестов. Запустите python -c "import your_module" для диагностики.

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

4. assert во вспомогательных функциях. Если вы вызываете вспомогательную функцию из теста, и эта функция содержит assert, убедитесь, что её имя начинается с assert_ (соглашение pytest), чтобы pytest перезаписал утверждение для более информативного сообщения об ошибке.

5. Смешивание unittest.TestCase и фикстур pytest. pytest запускает тесты unittest.TestCase, но вы не можете внедрять фикстуры pytest в методы TestCase. Используйте либо классы в стиле pytest, либо методы настройки unittest — но не оба подхода одновременно.

Практика

Практика
Which decorator marks a pytest function as a fixture?
Which decorator marks a pytest function as a fixture?
Практика
What does pytest.approx() help you do in tests?
What does pytest.approx() help you do in tests?
Практика
Where should you put fixtures that need to be shared across multiple test files?
Where should you put fixtures that need to be shared across multiple test files?
Was this page helpful?