- Писать тестовые функции для pytest и читать его вывод
- Проверять исключения и дробные числа с помощью
pytest.raisesиpytest.approx - Сокращать повторяющийся код с помощью фикстур и
parametrize
Ты исправляешь маленькую ошибку в функции скидки, а через неделю кто-то обнаруживает, что цены в корзине неверные. Проверять всё вручную после каждого изменения невозможно. Автоматические тесты — небольшие функции, которые вызывают твой код и проверяют результаты; сотни таких тестов выполняются за секунду при каждом изменении кода. В Python фактический стандарт для этого — pytest: тесты — обычные функции с обычными операторами assert.
Первые тесты
Установи pytest в виртуальное окружение проекта: python -m pip install pytest. Он собирает тесты автоматически: файлы с именами test_*.py или *_test.py, а в них — функции, имена которых начинаются с test_. Вот проверяемый код и его тесты:
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.pyfrom 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.99test_pricing.py$ 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(). Следующий запуск сразу показывает, что сломалось, с настоящими значениями:
$ 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Перед запуском тестового модуля pytest переписывает его операторы assert так, чтобы неудачная проверка показывала значения всех частей выражения: здесь — что apply_discount(19.99, 10) вернула 17.991. Поэтому pytest не нужны специальные методы вроде assertEqual — достаточно обычного assert.
Как работает средство запуска тестов
За pytest нет никакой магии. Тест — функция, которая возбуждает AssertionError, если что-то не так; средство запуска собирает функции, вызывает каждую и подсчитывает результаты. Мини-версия ниже делает то же самое в нескольких строках — и показывает, что мы теряем без переписывания assert: обычный assert сообщает только то, что мы написали сами:
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:
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 — новая временная папка для каждого теста:
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 превращает каждый кортеж в отдельный тест со своим именем, поэтому при неудаче видно, какой именно случай сломался:
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$ 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) так, чтобы все тесты ниже прошли: регистр букв, пробелы и знаки препинания нужно игнорировать.
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.
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.