Тесты: unittest и pytest
Зачем тесты
Тест — код, который проверяет другой код. Тесты:
- ловят ошибки до пользователей;
- позволяют рефакторить без страха — если всё зелёное, поведение не сломано;
- служат документацией — показывают, как пользоваться кодом;
- обязательны в любой команде: без тестов код-ревью не пройдёт.
Структура теста: Arrange — Act — Assert
def apply_discount(price: float, percent: int) -> float:
if not 0 <= percent <= 100:
raise ValueError("процент от 0 до 100")
return round(price * (100 - percent) / 100, 2)
def test_apply_discount():
# Arrange — подготовить данные
price, percent = 1000, 15
# Act — выполнить действие
result = apply_discount(price, percent)
# Assert — проверить результат
assert result == 850
test_apply_discount()
print("тест пройден")
unittest — встроенный фреймворк
import unittest, io
def apply_discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError("процент от 0 до 100")
return round(price * (100 - percent) / 100, 2)
class TestDiscount(unittest.TestCase):
def setUp(self): # перед КАЖДЫМ тестом
self.price = 1000
def test_regular(self):
self.assertEqual(apply_discount(self.price, 15), 850)
def test_zero_and_full(self):
self.assertEqual(apply_discount(self.price, 0), 1000)
self.assertEqual(apply_discount(self.price, 100), 0)
def test_invalid_percent(self):
with self.assertRaises(ValueError):
apply_discount(self.price, 150)
def test_float(self):
self.assertAlmostEqual(apply_discount(0.3, 10), 0.27)
def test_broken(self): # специально падающий тест
self.assertEqual(apply_discount(self.price, 10), 950)
stream = io.StringIO()
result = unittest.TextTestRunner(stream=stream, verbosity=2).run(
unittest.defaultTestLoader.loadTestsFromTestCase(TestDiscount))
print(stream.getvalue())
| Метод | Проверяет |
|---|---|
assertEqual(a, b) / assertNotEqual |
a == b |
assertTrue(x) / assertFalse(x) |
истинность |
assertIs(a, b) / assertIsNone(x) |
идентичность |
assertIn(a, b) |
a in b |
assertIsInstance(obj, cls) |
тип |
assertRaises(Exc) |
исключение (как контекстный менеджер) |
assertAlmostEqual(a, b) |
равенство float с точностью |
Запуск: python -m unittest (найдёт test*.py), python -m unittest tests.test_orders -v. Django-тесты (manage.py test) построены на unittest.
pytest — стандарт индустрии
pytest — сторонний пакет (pip install pytest), но им пользуется большинство проектов. Тесты — обычные функции с обычным assert:
# tests/test_discount.py
import pytest
from shop.pricing import apply_discount
def test_regular():
assert apply_discount(1000, 15) == 850
def test_invalid_percent():
with pytest.raises(ValueError, match="от 0 до 100"):
apply_discount(1000, 150)
def test_float():
assert apply_discount(0.3, 10) == pytest.approx(0.27)
При падении pytest показывает подробности — он переписывает assert при импорте тестов:
$ pytest -q
F..
================================ FAILURES ================================
______________________________ test_regular ______________________________
def test_regular():
> assert apply_discount(1000, 10) == 950
E assert 900.0 == 950
E + where 900.0 = apply_discount(1000, 10)
tests/test_discount.py:6: AssertionError
1 failed, 2 passed in 0.03s
Как это выглядит в этом курсе
Задания с кодом проверяются так же: ваш код + набор assert. При ошибке вы видите выражение, фактическое и ожидаемое значение — как в pytest.
Обнаружение тестов
pytest собирает файлы test_*.py или *_test.py, в них — функции test_* и классы Test* (без __init__) с методами test_*.
Запуск
pytest # все тесты
pytest tests/test_orders.py # один файл
pytest tests/test_orders.py::test_total # один тест
pytest -k "discount and not slow" # по подстроке имени
pytest -x # остановиться на первом падении
pytest --lf # только упавшие в прошлый раз
pytest -v / -q # подробно / кратко
pytest -s # не перехватывать print
pytest --pdb # отладчик в месте падения
pytest -n auto # параллельно (плагин pytest-xdist)
pytest --cov=shop # покрытие (плагин pytest-cov)
Маркеры
import sys
import pytest
@pytest.mark.skip(reason="ждём исправления API")
def test_external_api(): ...
@pytest.mark.skipif(sys.platform == "win32", reason="только Linux")
def test_signals(): ...
@pytest.mark.xfail(reason="известный баг #123")
def test_rounding(): ...
@pytest.mark.slow # свой маркер: pytest -m "not slow"
def test_big_import(): ...
Что тестировать
def grade(score: int) -> str:
if not 0 <= score <= 100:
raise ValueError("score 0..100")
if score >= 90:
return "A"
if score >= 75:
return "B"
if score >= 60:
return "C"
return "F"
# классы эквивалентности: по одному представителю
cases = {95: "A", 80: "B", 65: "C", 30: "F"}
# граничные значения: самые частые места ошибок
cases |= {100: "A", 90: "A", 89: "B", 75: "B", 74: "C", 60: "C", 59: "F", 0: "F"}
for score, expected in cases.items():
assert grade(score) == expected, (score, grade(score), expected)
for bad in (-1, 101): # некорректный ввод
try:
grade(bad)
raise AssertionError(f"нет ошибки для {bad}")
except ValueError:
pass
print(f"проверено {len(cases) + 2} случаев")
Чек-лист:
- счастливый путь — типичный корректный ввод;
- граничные значения — 0, 1, пустой список, максимум, ровно на пороге;
- некорректный ввод — исключения и сообщения об ошибках;
- особые случаи —
None, дубликаты, юникод, отрицательные числа; - регрессия — каждый найденный баг сначала воспроизводится тестом, потом чинится.
Тестируйте поведение, а не реализацию
Тест проверяет, что функция делает (вход → выход, побочные эффекты), а не как (какие приватные методы вызвала). Тогда рефакторинг не ломает тесты.
Вопрос на собеседовании
«Почему pytest, а не unittest?» — Меньше шаблонного кода (функции и обычный assert), подробные сообщения об ошибках, мощные фикстуры с внедрением зависимостей, параметризация, огромная экосистема плагинов (cov, xdist, asyncio, django). При этом pytest умеет запускать и unittest-тесты.
Итог
- Тест = Arrange → Act → Assert; хороший тест быстрый, независимый, детерминированный.
- unittest:
TestCase,setUp,assertEqual,assertRaises. - pytest: функции
test_*, обычныйassert,pytest.raises,pytest.approx, маркеры, гибкий запуск. - Тестируйте классы эквивалентности, границы, ошибки и регрессии; проверяйте поведение, а не реализацию.
Практика решено 0 из 5
Есть функция is_leap(year) — високосный ли год. Напишите тесты для неё:
функцию test_is_leap(), содержащую assert-ы для всех важных случаев.
Проверка запустит ваши тесты на правильной реализации (они должны пройти)
и на трёх реализациях с ошибками (каждая должна быть поймана хотя бы одним assert).
Какие тесты проверяют код (2)
test_is_leap() # на правильной реализации тесты должны пройти
def run_with(impl):
global is_leap
saved = is_leap
is_leap = impl
try:
test_is_leap()
return False
except AssertionError:
return True
finally:
is_leap = saved
assert run_with(lambda y: y % 4 == 0), "Ваши тесты не ловят ошибку: забыто правило про века (1900 — не високосный)"
assert run_with(lambda y: y % 4 == 0 and y % 100 != 0), "Ваши тесты не ловят ошибку: забыто правило 400 (2000 — високосный)"
assert run_with(lambda y: y % 2 == 0), "Ваши тесты не ловят ошибку: обычные годы (например, 2022) считаются високосными"
def is_leap(year):
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
def test_is_leap():
assert is_leap(2024) # делится на 4
assert not is_leap(2023) # не делится на 4
assert not is_leap(1900) # делится на 100, но не на 400
assert is_leap(2000) # делится на 400
Напишите класс тестов TestStack(unittest.TestCase) для готового класса Stack с тремя методами:
- test_push_pop — положили 1 и 2, pop() вернул 2, потом 1;
- test_len — после двух push длина 2;
- test_pop_empty — pop() пустого стека бросает IndexError (используйте assertRaises).
Каждый тест создаёт стек в setUp.
Какие тесты проверяют код (2)
import unittest, io
suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestStack)
names = sorted(t._testMethodName for t in suite)
assert names == ["test_len", "test_pop_empty", "test_push_pop"], f"Нужны три теста, найдено: {names}"
result = unittest.TextTestRunner(stream=io.StringIO()).run(suite)
assert result.wasSuccessful(), f"Тесты падают: {result.failures + result.errors}"
assert "setUp" in TestStack.__dict__, "Создавайте стек в setUp"
import unittest
class Stack:
def __init__(self):
self.items = []
def push(self, x):
self.items.append(x)
def pop(self):
return self.items.pop()
def __len__(self):
return len(self.items)
class TestStack(unittest.TestCase):
def setUp(self):
self.stack = Stack()
def test_push_pop(self):
self.stack.push(1)
self.stack.push(2)
self.assertEqual(self.stack.pop(), 2)
self.assertEqual(self.stack.pop(), 1)
def test_len(self):
self.stack.push(1)
self.stack.push(2)
self.assertEqual(len(self.stack), 2)
def test_pop_empty(self):
with self.assertRaises(IndexError):
self.stack.pop()
Чем assert a == b в pytest лучше self.assertEqual(a, b) в unittest?
Как pytest находит тесты по умолчанию?
| будет собран | не будет собран | |
|---|---|---|
файл test_orders.py, функция test_total() |
||
файл orders_test.py, функция test_total() |
||
файл orders.py, функция test_total() |
||
класс TestOrder с методом test_pay |
||
класс OrderTests с методом test_pay |
||
функция check_total() в test_orders.py |
Какие свойства у хорошего модульного теста?
Войдите, чтобы отмечать пройденные уроки.