Модульное тестирование Python с pytest
Изучите pytest с нуля: утверждения, фикстуры, параметризация тестов и организация набора с conftest.py.
pytest — самый популярный фреймворк тестирования Python. Он позволяет писать небольшие, читаемые тест-функции с использованием обычных операторов assert — без шаблонных классов — и при этом масштабируется до сложных наборов тестов с общими фикстурами, параметризацией и плагинами.
В этой главе рассматривается всё необходимое для тестирования кода Python с pytest: установка, написание первого теста, утверждения и ожидаемые исключения, фикстуры, параметризация, организация тестов с conftest.py, полезные параметры командной строки и наиболее распространённые подводные камни.
Почему pytest?
Python поставляется с модулем unittest, так зачем использовать pytest?
| Возможность | unittest | pytest |
|---|---|---|
| Синтаксис тестов | Класс + метод | Обычная функция |
| Утверждения | 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) == expectedpytest генерирует отдельный тест-кейс для каждого кортежа и отчитывается по каждому отдельно:
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() == 1pytest видит, что 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_path—pathlib.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 — но не оба подхода одновременно.