Раздел 03 — pytest: первые тесты

Тест — функция с assert; всё остальное pytest делает сам

Прогресс курса Раздел 3 из 12

Что вы освоите в этом разделе

6 академических часов: 2 теории, 4 практики. Примеры — на учебном модуле shop.py магазина «Канцтовары»: скачать. В практике те же приёмы применяются к функциям «Задачника».
01

Проверяемый код

Модуль 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.

02

Первый тест

Тест в 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 — внутри класса. Тест-дизайн не меняется от того, что проверку выполняет машина.

03

Как pytest находит тесты

Правила поиска по умолчанию:

Проект раздела и его настройки:

$ 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
04

Первое падение

Запускаем все тесты:

$ 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 включительно». Это дефект в программе, а не в тесте: ожидание опирается на требование.

Именно такие дефекты на границах автотест находит лучше всего: один раз написанная проверка границы будет повторяться при каждом изменении кода.

05

Сравнение сложных значений

Если сравниваются словари или списки, 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 он выведет оба словаря полностью. Совет из практики: одна проверка на тест, если её можно выделить. Тест с одним большим сравнением отвечает «что-то не так в сводке», а три теста — «скидка не та» и «список товаров не тот».

06

Проверка исключений

Если по требованию функция обязана отказаться работать — проверяют, что она выбросила исключение. Для этого 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 дополнительно проверяет текст сообщения регулярным выражением: так проверяют, что ошибка «та самая», а не случайная.

07

Запуск выборочно

КомандаЧто запускает
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
08

Структура проекта с тестами

Тесты держат отдельно от кода и раскладывают по тому, что проверяют. Для «Задачника» к концу курса получится так:

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 ускоряют работу; перед сдачей — всегда полный прогон
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 02: Python для автотестов Все разделы Раздел 04: Фикстуры, параметризация, маркеры