Перейти к содержанию
Educora
Продвинутый20 мин23 / 42

Тестирование с pytest

Пиши автоматические тесты: как pytest находит тесты, переписывание `assert`, проверка исключений и дробных чисел, фикстуры, `tmp_path` и `parametrize`.

Проверь себя
В этом уроке ты узнаешь
  • Писать тестовые функции для pytest и читать его вывод
  • Проверять исключения и дробные числа с помощью pytest.raises и pytest.approx
  • Сокращать повторяющийся код с помощью фикстур и parametrize

Ты исправляешь маленькую ошибку в функции скидки, а через неделю кто-то обнаруживает, что цены в корзине неверные. Проверять всё вручную после каждого изменения невозможно. Автоматические тесты — небольшие функции, которые вызывают твой код и проверяют результаты; сотни таких тестов выполняются за секунду при каждом изменении кода. В Python фактический стандарт для этого — pytest: тесты — обычные функции с обычными операторами assert.

Первые тесты

Установи pytest в виртуальное окружение проекта: python -m pip install pytest. Он собирает тесты автоматически: файлы с именами test_*.py или *_test.py, а в них — функции, имена которых начинаются с test_. Вот проверяемый код и его тесты:

Python
def apply_discount(price: float, percent: float) -> float:
    if not 0 <= percent <= 100:
        raise ValueError('percent must be between 0 and 100')
    return round(price * (1 - percent / 100), 2)
pricing.py
Python
from pricing import apply_discount

def test_no_discount():
    assert apply_discount(80, 0) == 80

def test_quarter_off():
    assert apply_discount(80, 25) == 60

def test_rounding():
    assert apply_discount(19.99, 10) == 17.99
test_pricing.py
Terminal
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.13.6, pytest-8.3.4, pluggy-1.5.0
rootdir: /home/aysel/shop
collected 3 items

test_pricing.py ...                                                      [100%]

============================== 3 passed in 0.02s ===============================

Каждая точка — пройденный тест. Теперь представь, что кто-то «упростил» apply_discount и убрал round(). Следующий запуск сразу показывает, что сломалось, с настоящими значениями:

Terminal
$ pytest -q
..F                                                                      [100%]
=================================== FAILURES ===================================
________________________________ test_rounding _________________________________

    def test_rounding():
>       assert apply_discount(19.99, 10) == 17.99
E       assert 17.991 == 17.99
E        +  where 17.991 = apply_discount(19.99, 10)

test_pricing.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_rounding - assert 17.991 == 17.99
1 failed, 2 passed in 0.03s
Определение
Переписывание assert

Перед запуском тестового модуля pytest переписывает его операторы assert так, чтобы неудачная проверка показывала значения всех частей выражения: здесь — что apply_discount(19.99, 10) вернула 17.991. Поэтому pytest не нужны специальные методы вроде assertEqual — достаточно обычного assert.

Как работает средство запуска тестов

За pytest нет никакой магии. Тест — функция, которая возбуждает AssertionError, если что-то не так; средство запуска собирает функции, вызывает каждую и подсчитывает результаты. Мини-версия ниже делает то же самое в нескольких строках — и показывает, что мы теряем без переписывания assert: обычный assert сообщает только то, что мы написали сами:

Python
def apply_discount(price, percent):
    if not 0 <= percent <= 100:
        raise ValueError('percent must be between 0 and 100')
    return round(price * (1 - percent / 100), 2)

def test_quarter_off():
    assert apply_discount(80, 25) == 60

def test_rounding():
    assert apply_discount(19.99, 10) == 17.99

def test_wrong_expectation():
    assert apply_discount(50, 10) == 40, 'expected 40'

tests = [f for name, f in list(globals().items()) if name.startswith('test_')]
passed = 0
for test in tests:
    try:
        test()
    except AssertionError as e:
        print(f'FAILED {test.__name__}: {e}')
    else:
        passed += 1
        print(f'PASSED {test.__name__}')
print(f'{passed} passed, {len(tests) - passed} failed')
▸ Ожидаемый результат
PASSED test_quarter_off
PASSED test_rounding
FAILED test_wrong_expectation: expected 40
2 passed, 1 failed

Что проверять? Опытные разработчики ищут для каждой функции четыре вида случаев:

  • Типичные значения — повседневная работа функции: apply_discount(80, 25).
  • Граничные случаи — 0, 100, пустой список, список из одного элемента; ошибки чаще всего прячутся здесь.
  • Неверный ввод — отрицательный процент, процент больше 100: функция должна возбуждать понятное исключение.
  • Регрессии — на каждую найденную ошибку пишется воспроизводящий её тест, чтобы она больше не вернулась.

Исключения и дробные числа

Проверять ошибочные случаи так же важно, как и нормальные. **pytest.raises** — контекстный менеджер: тест проходит, только если блок возбуждает указанное исключение; match дополнительно ищет в сообщении об ошибке регулярное выражение. Дробные числа сравнивают через **pytest.approx**, ведь в двоичной арифметике 0.1 + 0.2 == 0.3 даёт False:

Python
import pytest
from pricing import apply_discount

def test_invalid_percent():
    with pytest.raises(ValueError, match='between 0 and 100'):
        apply_discount(50, 120)

def test_float_sum():
    assert 0.1 + 0.2 == pytest.approx(0.3)

Фикстуры: готовые данные для тестов

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

Python
import pytest
from cart import Cart

@pytest.fixture
def cart():
    c = Cart()
    c.add('tea', 3.5)
    c.add('bread', 0.8)
    return c

def test_total(cart):
    assert cart.total() == pytest.approx(4.3)

def test_remove(cart):
    cart.remove('tea')
    assert cart.items == {'bread': 0.8}

def test_save(cart, tmp_path):
    path = tmp_path / 'cart.json'
    cart.save(path)
    assert path.exists()

test_remove изменил свою корзину, но на test_total это не влияет — он получил собственную копию. Фикстура может и не возвращать значение, а отдавать его через yield: код после yield выполняется после теста, как выходная часть контекстного менеджера, — естественное место, чтобы закрыть соединение с базой данных. Фикстуры, нужные во многих файлах, помещают в файл conftest.py, который pytest загружает автоматически.

parametrize: один тест, много случаев

Вместо того чтобы копировать тест для каждого набора входных данных, перечисли случаи в **@pytest.mark.parametrize**. pytest превращает каждый кортеж в отдельный тест со своим именем, поэтому при неудаче видно, какой именно случай сломался:

Python
import pytest
from pricing import apply_discount

@pytest.mark.parametrize('price, percent, expected', [
    (100, 0, 100),
    (100, 15, 85),
    (80, 25, 60),
    (19.99, 10, 17.99),
])
def test_apply_discount(price, percent, expected):
    assert apply_discount(price, percent) == expected
Terminal
$ pytest -v test_pricing.py
============================= test session starts ==============================
platform linux -- Python 3.13.6, pytest-8.3.4, pluggy-1.5.0 -- /home/aysel/shop/.venv/bin/python
cachedir: .pytest_cache
rootdir: /home/aysel/shop
collected 4 items

test_pricing.py::test_apply_discount[100-0-100] PASSED                   [ 25%]
test_pricing.py::test_apply_discount[100-15-85] PASSED                   [ 50%]
test_pricing.py::test_apply_discount[80-25-60] PASSED                    [ 75%]
test_pricing.py::test_apply_discount[19.99-10-17.99] PASSED              [100%]

============================== 4 passed in 0.02s ===============================
КомандаЧто делает
pytest -xостанавливается на первой неудаче
pytest -k discountзапускает только тесты, в имени которых есть discount
pytest --lfперезапускает только тесты, упавшие в прошлый раз
pytest -q / pytest -vкраткий / подробный вывод
pytest --cov=shopпокрытие кода тестами (плагин pytest-cov)
Задание

Напиши функцию is_palindrome(text) так, чтобы все тесты ниже прошли: регистр букв, пробелы и знаки препинания нужно игнорировать.

Задание · Python
def is_palindrome(text):
    # keep only letters and digits, ignore case, compare with the reversed version
    return False

def test_simple():
    assert is_palindrome('level')

def test_mixed_case():
    assert is_palindrome('Anna')

def test_phrase():
    assert is_palindrome('Was it a car or a cat I saw?')

def test_not_palindrome():
    assert not is_palindrome('python')

for name, test in list(globals().items()):
    if name.startswith('test_'):
        test()
        print('PASSED', name)
▸ Ожидаемый результат
PASSED test_simple
PASSED test_mixed_case
PASSED test_phrase
PASSED test_not_palindrome
Задание

Напиши простую версию pytest.raises: контекстный менеджер raises(exc_type) печатает ok: <Name> was raised и подавляет исключение, если блок его возбудил, а если исключения не было — возбуждает AssertionError.

Задание · Python
from contextlib import contextmanager

@contextmanager
def raises(exc_type):
    # yield inside try; handle exc_type in except; raise AssertionError in else
    yield

def grade(score):
    if not 0 <= score <= 100:
        raise ValueError('score must be between 0 and 100')
    return 'pass' if score >= 50 else 'fail'

with raises(ValueError):
    grade(120)

try:
    with raises(ValueError):
        grade(70)
except AssertionError as e:
    print('test failed:', e)
▸ Ожидаемый результат
ok: ValueError was raised
test failed: ValueError was not raised

Главное

  • pytest собирает файлы test_*.py и функции test_*; тест падает, если возбуждает исключение, обычно AssertionError.
  • Благодаря переписыванию assert обычный assert показывает значения, из-за которых тест упал.
  • pytest.raises(Error, match=...) проверяет исключения, а pytest.approx сравнивает дробные числа.
  • Фикстуры дают каждому тесту свежие подготовленные данные; tmp_path — встроенная фикстура, а фикстуры с yield выполняют очистку.
  • @pytest.mark.parametrize превращает одну тестовую функцию во множество именованных случаев.

Проверь себя

Вопросов: 10. Каждый правильный ответ приносит XP.

1 / 10
Какие функции pytest по умолчанию собирает как тесты?