Тест — функция с assert; всё остальное pytest делает сам
assert так, чтобы pytest их нашёлshop.py магазина «Канцтовары»: скачать. В практике те же приёмы применяются к функциям «Задачника».Модуль shop.py считает заказ: скидку, доставку, итог. Требования к нему записаны в описаниях функций — для тестировщика это и есть спецификация.
"""Расчёт заказа магазина «Канцтовары» — учебный модуль разделов 03–05."""
import datetime as dt
import requests
FREE_DELIVERY_FROM = 3000
SMS_URL = "https://sms.example.ru/api/send"
def today():
return dt.date.today()
def discount(total):
"""Скидка 10% на заказ от 5000 ₽ включительно."""
if total > 5000:
return round(total * 0.10, 2)
return 0
def delivery(total, city="Санкт-Петербург"):
"""Доставка бесплатна от 3000 ₽; иначе 300 ₽ по Санкт-Петербургу и 500 ₽ в другие города."""
if total >= FREE_DELIVERY_FROM:
return 0
return 300 if city == "Санкт-Петербург" else 500
def parse_amount(text):
"""Сумма из строки: «1 250,50» → 1250.5. Не число или меньше нуля — ValueError."""
value = float(text.replace(" ", "").replace(",", "."))
if value < 0:
raise ValueError(f"сумма не может быть отрицательной: {text}")
return value
def order_total(items, city="Санкт-Петербург"):
"""items — список пар (цена, количество). Итог: сумма − скидка + доставка."""
total = sum(price * qty for price, qty in items)
return round(total - discount(total) + delivery(total, city), 2)
def wednesday_bonus(total):
"""По средам дополнительная скидка 5%."""
if today().weekday() == 2:
return round(total * 0.05, 2)
return 0
def save_receipt(path, items):
"""Записать чек: строка на товар и итог."""
lines = [f"{price:.2f} x {qty}" for price, qty in items]
lines.append(f"Итого: {order_total(items):.2f}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def send_sms(phone, text):
"""Отправить SMS через внешний сервис. True — сервис принял сообщение."""
r = requests.post(SMS_URL, json={"phone": phone, "text": text}, timeout=5)
return r.status_code == 200
Нам пока нужны три функции: discount — «скидка 10% на заказ от 5000 ₽ включительно», delivery — «бесплатно от 3000 ₽, иначе 300 или 500 ₽», parse_amount — «строка в число, отрицательное — ошибка». Остальные понадобятся в разделе 05.
Тест в pytest — обычная функция, имя которой начинается с test_. Внутри — вызов проверяемого кода и assert с ожидаемым результатом. Никаких классов и специальных методов сравнения не нужно.
import shop
def test_no_discount_below_5000():
assert shop.discount(4999.99) == 0
def test_discount_at_5000():
assert shop.discount(5000) == 500
def test_discount_above_5000():
assert shop.discount(8000) == 800
Каждый тест проверяет одно правило и назван так, чтобы по имени было понятно, какое: test_discount_at_5000 — «скидка ровно на 5000». Когда тест упадёт, имя первым попадёт в отчёт, поэтому test_1 и test_discount — плохие имена.
Значения взяты по технике граничных значений из курса ручного тестирования: 4999,99 — сразу под границей, 5000 — граница, 8000 — внутри класса. Тест-дизайн не меняется от того, что проверку выполняет машина.
Правила поиска по умолчанию:
test_*.py или *_test.py;test_* (и методы test_* в классах Test*);testpaths.Проект раздела и его настройки:
$ find . -name "*.py" -o -name "*.ini" | sort
./compare/test_compare.py
./pytest.ini
./shop.py
./tests/test_delivery.py
./tests/test_discount.py
./tests/test_parse.py
[pytest]
testpaths = tests
pythonpath = .
testpaths = tests — искать тесты только в папке tests; pythonpath = . — корень проекта доступен для импорта, поэтому в тестах работает import shop. Что pytest нашёл, показывает --collect-only — полезно, когда «тест не запускается»: значит, он не найден, обычно из-за имени.
$ pytest --collect-only -q
tests/test_delivery.py::test_free_from_3000
tests/test_delivery.py::test_spb_below_3000
tests/test_delivery.py::test_other_city_below_3000
tests/test_discount.py::test_no_discount_below_5000
tests/test_discount.py::test_discount_at_5000
tests/test_discount.py::test_discount_above_5000
tests/test_parse.py::test_parse_with_space_and_comma
tests/test_parse.py::test_parse_not_a_number
tests/test_parse.py::test_parse_negative_message
9 tests collected in 0.12s
Запускаем все тесты:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.13.5, pytest-9.2.0, pluggy-1.7.0
rootdir: ~/qa
configfile: pytest.ini
testpaths: tests
plugins: metadata-3.1.1, playwright-0.10.0, locust-2.47.0, html-4.2.0
collected 9 items
tests/test_delivery.py ... [ 33%]
tests/test_discount.py .F. [ 66%]
tests/test_parse.py ... [100%]
=================================== FAILURES ===================================
____________________________ test_discount_at_5000 _____________________________
def test_discount_at_5000():
> assert shop.discount(5000) == 500
E assert 0 == 500
E + where 0 = <function discount at 0x7f22f0e0b100>(5000)
E + where <function discount at 0x7f22f0e0b100> = shop.discount
tests/test_discount.py:9: AssertionError
=========================== short test summary info ============================
FAILED tests/test_discount.py::test_discount_at_5000 - assert 0 == 500
========================= 1 failed, 8 passed in 0.19s ==========================
Тест скидки ровно на 5000 упал. pytest переписал assert так, что виден каждый шаг: discount(5000) вернула 0, а ожидалось 500. Строка tests/test_discount.py:9 — место проверки. Теперь смотрим в код: if total > 5000 — строго больше, а требование говорит «от 5000 включительно». Это дефект в программе, а не в тесте: ожидание опирается на требование.
Именно такие дефекты на границах автотест находит лучше всего: один раз написанная проверка границы будет повторяться при каждом изменении кода.
Если сравниваются словари или списки, pytest показывает не всё значение, а различия. Учебный пример с двумя расхождениями:
def test_order_summary():
summary = {"total": 5000, "discount": 0, "delivery": 0, "items": ["ручка", "тетрадь"]}
assert summary == {"total": 5000, "discount": 500, "delivery": 0, "items": ["ручка", "блокнот"]}
$ pytest compare -q
F [100%]
=================================== FAILURES ===================================
______________________________ test_order_summary ______________________________
def test_order_summary():
summary = {"total": 5000, "discount": 0, "delivery": 0, "items": ["ручка", "тетрадь"]}
> assert summary == {"total": 5000, "discount": 500, "delivery": 0, "items": ["ручка", "блокнот"]}
E AssertionError: assert {'total': 500...', 'тетрадь']} == {'total': 500...', 'блокнот']}
E
E Omitting 2 identical items, use -vv to show
E Differing items:
E {'discount': 0} != {'discount': 500}
E {'items': ['ручка', 'тетрадь']} != {'items': ['ручка', 'блокнот']}
E Use -v to get more diff
compare/test_compare.py:3: AssertionError
=========================== short test summary info ============================
FAILED compare/test_compare.py::test_order_summary - AssertionError: assert {...
1 failed in 0.05s
Совпадающие ключи pytest пропустил («Omitting 2 identical items»), различающиеся показал парами. С ключом -vv он выведет оба словаря полностью. Совет из практики: одна проверка на тест, если её можно выделить. Тест с одним большим сравнением отвечает «что-то не так в сводке», а три теста — «скидка не та» и «список товаров не тот».
Если по требованию функция обязана отказаться работать — проверяют, что она выбросила исключение. Для этого pytest.raises:
import pytest
import shop
def test_parse_with_space_and_comma():
assert shop.parse_amount("1 250,50") == 1250.5
def test_parse_not_a_number():
with pytest.raises(ValueError):
shop.parse_amount("тысяча")
def test_parse_negative_message():
with pytest.raises(ValueError, match="не может быть отрицательной"):
shop.parse_amount("-10")
Тест проходит, только если внутри блока with возникло исключение указанного типа. Не возникло — тест падает с сообщением DID NOT RAISE. Параметр match дополнительно проверяет текст сообщения регулярным выражением: так проверяют, что ошибка «та самая», а не случайная.
| Команда | Что запускает |
|---|---|
pytest tests/test_parse.py | Один файл |
pytest tests/test_parse.py::test_parse_not_a_number | Один тест по полному имени |
pytest -k delivery | Тесты, в имени которых есть «delivery»; можно -k "discount and not above" |
pytest -x | Остановиться на первом падении |
pytest --lf | Только упавшие в прошлый раз (last failed) |
pytest -v / -q | Подробнее / короче |
$ pytest -v -k delivery
============================= test session starts ==============================
platform linux -- Python 3.13.5, pytest-9.2.0, pluggy-1.7.0 -- ~/qa/.venv/bin/python3
cachedir: .pytest_cache
metadata: {'Python': '3.13.5', 'Platform': 'Linux-6.12.95+deb13-amd64-x86_64-with-glibc2.41', 'Packages': {'pytest': '9.2.0', 'pluggy': '1.7.0'}, 'Plugins': {'metadata': '3.1.1', 'playwright': '0.10.0', 'locust': '2.47.0', 'html': '4.2.0'}}
rootdir: ~/qa
configfile: pytest.ini
testpaths: tests
plugins: metadata-3.1.1, playwright-0.10.0, locust-2.47.0, html-4.2.0
collecting ... collected 9 items / 6 deselected / 3 selected
tests/test_delivery.py::test_free_from_3000 PASSED [ 33%]
tests/test_delivery.py::test_spb_below_3000 PASSED [ 66%]
tests/test_delivery.py::test_other_city_below_3000 PASSED [100%]
======================= 3 passed, 6 deselected in 0.12s ========================
$ pytest -q -x
....F
=================================== FAILURES ===================================
____________________________ test_discount_at_5000 _____________________________
def test_discount_at_5000():
> assert shop.discount(5000) == 500
E assert 0 == 500
E + where 0 = <function discount at 0x7f87b360f060>(5000)
E + where <function discount at 0x7f87b360f060> = shop.discount
tests/test_discount.py:9: AssertionError
=========================== short test summary info ============================
FAILED tests/test_discount.py::test_discount_at_5000 - assert 0 == 500
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 4 passed in 0.17s
$ pytest -q --lf
F [100%]
=================================== FAILURES ===================================
____________________________ test_discount_at_5000 _____________________________
def test_discount_at_5000():
> assert shop.discount(5000) == 500
E assert 0 == 500
E + where 0 = <function discount at 0x7f8d8e0f7060>(5000)
E + where <function discount at 0x7f8d8e0f7060> = shop.discount
tests/test_discount.py:9: AssertionError
=========================== short test summary info ============================
FAILED tests/test_discount.py::test_discount_at_5000 - assert 0 == 500
1 failed in 0.16s
Обычный рабочий цикл: прогнали всё, что-то упало — разбираемся и исправляем, перезапускаем --lf, пока не станет зелёным, затем снова весь набор: исправление могло сломать соседнее.
Исправим > на >= и прогоним всё:
$ grep -n "if total" shop.py
16: if total >= 5000:
23: if total >= FREE_DELIVERY_FROM:
$ pytest -q
......... [100%]
9 passed in 0.12s
Тесты держат отдельно от кода и раскладывают по тому, что проверяют. Для «Задачника» к концу курса получится так:
qa-tasker/
├── tasker.py проверяемое приложение
├── client.py клиент API для тестов
├── conftest.py общие фикстуры (раздел 04)
├── pytest.ini настройки pytest
├── requirements.txt пакеты
└── tests/
├── unit/ модульные: функции tasker.py (03–05)
├── api/ API (06–07)
└── ui/ браузер (08–09)
Один файл — одна тема: test_fields.py — проверки полей, test_dates.py — сроки и просрочка. Файл на 400 тестов так же неудобен, как тест на 40 проверок.
Модульные тесты «Задачника» импортируют его функции напрямую: import tasker, затем tasker.validate_title("…"). Сервер при этом не запускается — код лишь читается как модуль. Какие функции там есть, видно в самом файле tasker.py: раздел «проверки полей» и функции рядом с ним.
test_* в файле test_*.py с assert; имя теста называет проверяемое правилоpytest.raises проверяет обязательную ошибку, match — её текст-k, -x, --lf ускоряют работу; перед сдачей — всегда полный прогон