Перейти к содержимому
k11

k11. Тесты pytest и заглушки

k11. Тесты pytest и заглушки

UAV Python Pro · Конспект K11 · Модуль M1. Python intensive: тесты pytest и заглушки · Редакция 1.0 · После CHECKPOINT-K10

До сих пор вы проверяли программы, запуская их и глядя на печать в консоли. Такой способ быстро перестаёт масштабироваться: после каждого изменения функции классификации напряжения или загрузки JSON приходится вручную вспоминать все крайние случаи. На этом уроке вы научитесь фиксировать ожидаемое поведение в виде автоматических тестов с помощью pytest, проверять исключения через pytest.raises и подменять обращение к диску с помощью unittest.mock. Это прямой навык для кода вокруг автопилота: сначала проверяют логику на столе, затем подключают симулятор и железо.

Автоматический тест — это программа, которая вызывает ваш код с заранее заданными входами и проверяет, что результат совпал с ожиданием. Если результат другой, тестовый прогон завершается с ошибкой и указывает место расхождения. Заглушка (mock) позволяет не открывать реальный файл, порт или канал MAVLink, а подставить контролируемый ответ. Так проверяют правила и разбор данных без риска для борта.

Содержание

1. Цели урока

После изучения этого урока вы будете уметь выполнять следующие действия.

  • Вы установите пакет pytest в виртуальное окружение командой python -m pip install pytest.
  • Вы напишете тестовые функции с префиксом test_ и проверками через assert.
  • Вы проверите, что функция поднимает ожидаемое исключение, с помощью pytest.raises.
  • Вы подмените Path.read_text заглушкой MagicMock и проверите разбор JSON без файла на диске.
  • Вы запустите набор тестов командой python -m pytest ... -v и прочитаете отчёт passed/failed.
  • Вы объясните, почему чистые функции из предыдущих уроков особенно удобны для unit-тестов.
Связь с разработкой программного обеспечения для БПЛА. Перед тем как отправлять команду автопилоту или доверять разбору телеметрии на бортовом компьютере-компаньоне, логику проверяют на столе. Тест не заменяет стенд и симулятор, но отсекает целый класс ошибок «поменяли порог — сломали соседний случай» ещё до полёта в SITL.
↑ К оглавлению

2. Словарь урока

ТерминПростыми словами
Unit-тест Автоматическая проверка небольшого фрагмента логики (часто одной функции) с известными входами и ожидаемым выходом.
pytest Популярный инструмент запуска тестов для Python: сам находит функции test_* и показывает отчёт.
assert Оператор Python: «убедись, что условие истинно; иначе возбуди AssertionError».
Фикстура (fixture) Подготовка общего окружения для нескольких тестов (на этом уроке достаточно знать термин; глубоко не уходим).
Mock / заглушка Объект, который притворяется зависимостью (файлом, сокетом, датчиком) и отдаёт заранее заданные ответы.
MagicMock Гибкая заглушка из модуля unittest.mock.
Регрессия Поломка ранее работавшего поведения после изменения кода. Тесты как раз ловят регрессии.
↑ К оглавлению

3. Зачем тесты в разработке для БПЛА

Функция вроде classify_voltage выглядит короткой, но у неё есть границы: значение на пороге, значение чуть ниже порога, критический диапазон. Человек при ручной проверке легко забывает один крайний случай. Автоматический тест фиксирует эти случаи в коде и повторяет их за секунды после каждого изменения.

Для кода, который читает файлы конфигурации или будет читать канал MAVLink, важна ещё одна идея: не всё нужно проверять «вживую». Разбор JSON и выбор команды по высоте можно проверить с заглушкой. Реальный симулятор и стенд остаются следующим контуром проверки, а не единственным.

↑ К оглавлению

4. Установка pytest и первый assert

pytest — инструмент, который находит тестовые функции, запускает их и сообщает, какие проверки прошли. В виртуальном окружении его устанавливают так:

python -m pip install pytest

Имена тестовых функций принято начинать с префикса test_. Внутри теста оператор assert проверяет условие. Если условие ложно, pytest помечает тест как failed и показывает значения, которые не совпали.

Чистый код

# Пример: простейший тест с assert
from k11_battery import classify_voltage

def test_classify_ok():
    assert classify_voltage(15.0) == "OK"
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    from k11_battery import classify_voltage
    # → Импортируем чистую функцию, которую проверяем.
    
    def test_classify_ok():
    # → Имя теста начинается с test_ — так pytest находит функцию автоматически.
    
        assert classify_voltage(15.0) == "OK"
    # → assert проверяет условие. Если оно ложно, тест падает с сообщением об ошибке.

Запуск одного файла:

python -m pytest k11_task1_test_battery.py -v

Флаг -v включает подробный список имён тестов. Успешный прогон печатает passed для каждой функции.

Где запускать. Команды pytest в этом уроке удобно выполнять из каталога code/M01_python, чтобы импорты k11_battery и k11_loader находились без дополнительной настройки пути. В PyCharm можно указать Working directory на эту папку.
↑ К оглавлению

5. Проверка исключений: pytest.raises

Не всякая правильная функция всегда возвращает «успешное» значение. Иногда правильное поведение — поднять исключение. Конструкция pytest.raises как раз проверяет, что внутри блока возникло исключение нужного типа.

Чистый код

# Пример: ожидание исключения
import pytest
from k11_battery import alt_cm_to_m

def test_alt_cm_rejects_negative():
    with pytest.raises(ValueError):
        alt_cm_to_m(-1)
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    import pytest
    # → Нужен pytest.raises.
    
    from k11_battery import alt_cm_to_m
    # → Функция, которая при неверном входе поднимает ValueError.
    
    def test_alt_cm_rejects_negative():
    # → Тест на отказ при отрицательной высоте.
    
        with pytest.raises(ValueError):
    # → Контекстный менеджер: внутри блока ожидается именно ValueError.
    
            alt_cm_to_m(-1)
    # → Вызов, который должен завершиться исключением. Если исключения не будет, тест провален.
Связь с K08. Вы уже поднимали ValueError и собственные исключения. Теперь то же поведение закрепляется автоматической проверкой: если кто-то уберёт raise, тест сразу упадёт.
↑ К оглавлению

6. Заглушки: unittest.mock

Mock, или заглушка, — это объект, который подменяет зависимость. Вместо реального файла на диске тест подставляет объект, у которого метод read_text возвращает заранее заданную строку. Так проверяют логику load_mode без создания временных файлов и без риска «не тот каталог».

Класс MagicMock из модуля unittest.mock создаёт такую заглушку. Параметр spec=Path ограничивает интерфейс «как у Path», чтобы случайно не обратиться к несуществующему методу.

Чистый код

# Пример: mock Path.read_text без реального файла на диске
from pathlib import Path
from unittest.mock import MagicMock
from k11_loader import load_mode

def test_load_mode_ok():
    fake_path = MagicMock(spec=Path)
    fake_path.read_text.return_value = '{"mode": "GUIDED", "altitude_m": 10}'
    assert load_mode(fake_path) == "GUIDED"
    fake_path.read_text.assert_called_once_with(encoding="utf-8")
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    from pathlib import Path
    # → Нужен только как описание интерфейса для spec=Path.
    
    from unittest.mock import MagicMock
    # → MagicMock создаёт объект-заглушку с настраиваемым поведением.
    
    from k11_loader import load_mode
    # → Функция, которая вызывает path.read_text и разбирает JSON.
    
    def test_load_mode_ok():
    # → Успешный сценарий загрузки.
    
        fake_path = MagicMock(spec=Path)
    # → Заглушка «как Path»: есть методы Path, но без реального диска.
    
        fake_path.read_text.return_value = '{"mode": "GUIDED", "altitude_m": 10}'
    # → Говорим заглушке: при вызове read_text верни эту JSON-строку.
    
        assert load_mode(fake_path) == "GUIDED"
    # → Проверяем, что load_mode вернул поле mode.
    
        fake_path.read_text.assert_called_once_with(encoding="utf-8")
    # → Дополнительно проверяем, что read_text вызвали один раз с UTF-8.
Что mock не заменяет. Заглушка не доказывает, что автопилот в SITL ответит так же. Она доказывает, что ваш код правильно разбирает известный вход и вызывает зависимость с ожидаемыми аргументами. Это другой, более ранний уровень уверенности.
↑ К оглавлению

7. Как разложить модуль и тесты

На K11 учебные файлы лежат рядом в code/M01_python:

code/M01_python/
  k11_battery.py              # функции под тестом
  k11_loader.py               # загрузка JSON
  k11_task1_test_battery.py   # тесты battery
  k11_task2_test_loader_mock.py

В более крупных проектах тесты часто выносят в каталог tests/, а пакет с кодом — в src/. Для модуля M1 достаточно соседства файлов и ясных имён. Главное правило: тестируемая логика живёт отдельно от тестов, а «скрипт-демо с print» не смешивается с assert без нужды.

Почему это стыкуется с K05. Чистые функции вроде classify_voltage и alt_cm_to_m тестировать просто: вход → выход, без потока, без порта, без окна. Функции с побочными эффектами тестируют через заглушки или через проверку вызовов mock, как в задаче с read_text.
↑ К оглавлению

8. Типичные ошибки

ОшибкаЧто происходитКак следует рассуждать
Тестовая функция названа без префикса test_ pytest её не запускает Имена: test_...
Запуск pytest не из того каталога ModuleNotFoundError: k11_battery Working directory = code/M01_python или настройка PYTHONPATH
pytest не установлен в venv No module named pytest python -m pip install pytest в том же интерпретаторе, что и проект
В тесте забыли assert Тест «зелёный», но ничего не проверил Каждый тест должен содержать явную проверку
Mock настроен, но assert на вызов не сделан Можно не заметить, что зависимость не вызвали Для важных взаимодействий используйте assert_called_once_with
Тест зависит от реального файла на машине автора У другого человека путь другой — тест красный Либо mock, либо временный файл внутри теста (на M1 достаточно mock)
↑ К оглавлению

9. Практика

В карточках ниже в аккордеонах лежит полный текст файлов. Он совпадает с содержимым code/M01_python/. Сначала установите pytest, затем скопируйте или сверьте модули и тесты, затем запустите прогон.

Задача 11.1. Модуль battery под тестами

Файл практики: k11_battery.py.

Разместите в проекте модуль с функциями classify_voltage и alt_cm_to_m. Это код, который будут проверять тесты, а не «скрипт с print».

Ожидаемый результат. Файл импортируется без ошибок: from k11_battery import classify_voltage.

  • Полный текст файла k11_battery.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k11_battery.py в проекте PyCharm (виртуальное окружение) и запустите программу. Ниже приведён полный учебный текст файла с комментариями.

    # k11_battery.py
    # Учебный модуль под тесты K11 (не драйвер реального датчика).
    
    def classify_voltage(voltage_v: float, low_v: float = 14.4, critical_v: float = 14.0) -> str:
        """Вернуть учебную метку OK / LOW / CRITICAL по напряжению."""
        if voltage_v >= low_v:
            return "OK"
        if voltage_v >= critical_v:
            return "LOW"
        return "CRITICAL"
    
    
    def alt_cm_to_m(alt_cm: int) -> float:
        """Перевести высоту из сантиметров в метры."""
        if alt_cm < 0:
            raise ValueError(f"alt_cm must be >= 0, got {alt_cm}")
        return alt_cm / 100.0

Задача 11.2. Тесты battery

Файл практики: k11_task1_test_battery.py.

Напишите тесты на OK/LOW/CRITICAL, на перевод сантиметров в метры и на ValueError при отрицательной высоте.

Ожидаемый результат. Команда python -m pytest k11_task1_test_battery.py -v показывает все тесты passed.

  • Полный текст файла k11_task1_test_battery.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k11_task1_test_battery.py в проекте PyCharm (виртуальное окружение) и запустите программу. Ниже приведён полный учебный текст файла с комментариями.

    # k11_task1_test_battery.py
    # Цель: первые unit-тесты pytest для чистых функций.
    # Запуск из каталога code/M01_python:
    #   python -m pytest k11_task1_test_battery.py -v
    
    import pytest
    
    from k11_battery import alt_cm_to_m, classify_voltage
    
    
    def test_classify_ok():
        assert classify_voltage(15.0) == "OK"
    
    
    def test_classify_low():
        assert classify_voltage(14.2) == "LOW"
    
    
    def test_classify_critical():
        assert classify_voltage(13.5) == "CRITICAL"
    
    
    def test_alt_cm_to_m():
        assert alt_cm_to_m(1530) == 15.3
    
    
    def test_alt_cm_rejects_negative():
        with pytest.raises(ValueError):
            alt_cm_to_m(-1)

Задача 11.3. Модуль loader

Файл практики: k11_loader.py.

Разместите функцию load_mode(path), которая читает UTF-8 текст, разбирает JSON и возвращает поле mode.

Ожидаемый результат. Импорт from k11_loader import load_mode проходит успешно.

  • Полный текст файла k11_loader.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k11_loader.py в проекте PyCharm (виртуальное окружение) и запустите программу. Ниже приведён полный учебный текст файла с комментариями.

    # k11_loader.py
    # Загрузка JSON-телеметрии с диска — объект для mock Path/read в тестах.
    
    import json
    from pathlib import Path
    
    
    def load_mode(path: Path) -> str:
        """Прочитать JSON-файл и вернуть поле mode как str.
    
        Ожидается объект вида {"mode": "GUIDED", ...}.
        """
        text = path.read_text(encoding="utf-8")
        data = json.loads(text)
        if "mode" not in data:
            raise KeyError("mode")
        return str(data["mode"])

Задача 11.4. Тесты loader с MagicMock

Файл практики: k11_task2_test_loader_mock.py.

Подмените Path.read_text. Проверьте успешный mode, отсутствие ключа mode (KeyError) и повреждённый JSON.

Ожидаемый результат. pytest по файлу k11_task2_test_loader_mock.py — все passed; для успешного теста есть assert_called_once_with(encoding="utf-8").

  • Полный текст файла k11_task2_test_loader_mock.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k11_task2_test_loader_mock.py в проекте PyCharm (виртуальное окружение) и запустите программу. Ниже приведён полный учебный текст файла с комментариями.

    # k11_task2_test_loader_mock.py
    # Цель: mock объекта Path — не читать реальный диск.
    # Запуск:
    #   python -m pytest k11_task2_test_loader_mock.py -v
    
    from pathlib import Path
    from unittest.mock import MagicMock
    
    import pytest
    
    from k11_loader import load_mode
    
    
    def test_load_mode_ok():
        fake_path = MagicMock(spec=Path)
        fake_path.read_text.return_value = '{"mode": "GUIDED", "altitude_m": 10}'
        assert load_mode(fake_path) == "GUIDED"
        fake_path.read_text.assert_called_once_with(encoding="utf-8")
    
    
    def test_load_mode_missing_key():
        fake_path = MagicMock(spec=Path)
        fake_path.read_text.return_value = '{"altitude_m": 10}'
        with pytest.raises(KeyError):
            load_mode(fake_path)
    
    
    def test_load_mode_bad_json():
        fake_path = MagicMock(spec=Path)
        fake_path.read_text.return_value = "{not-json"
        with pytest.raises(Exception):
            # json.JSONDecodeError — подкласс Exception
            load_mode(fake_path)

Задача 11.5. Напоминание команды прогона

Файл практики: k11_task3_run_pytest_note.py.

Откройте файл-памятку и выполните команды установки pytest и совместного прогона обоих наборов тестов из комментариев.

Ожидаемый результат. В конце прогона pytest: all passed для task1 и task2.

  • Полный текст файла k11_task3_run_pytest_note.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k11_task3_run_pytest_note.py в проекте PyCharm (виртуальное окружение) и запустите программу. Ниже приведён полный учебный текст файла с комментариями.

    # k11_task3_run_pytest_note.py
    # Напоминание команд (выполните в venv, каталог code/M01_python).
    #
    # python -m pip install pytest
    # python -m pytest k11_task1_test_battery.py k11_task2_test_loader_mock.py -v
    #
    # Ожидается: все тесты passed.
    
    print("Install pytest in venv if needed, then run the pytest command from comments above.")

Задача 11.6. Команда прогона (обязательно выполнить)

В терминале с активным venv и текущим каталогом code/M01_python выполните:

# Каталог: code/M01_python  (venv активен)
python -m pip install pytest
python -m pytest k11_task1_test_battery.py k11_task2_test_loader_mock.py -v

Задача 11.7. Сохранение в Git

git add code/M01_python/k11_*.py
git commit -m "K11: pytest unit tests and Path mock"
↑ К оглавлению

10. Проверьте себя

  1. Чем автоматический unit-тест отличается от ручного запуска скрипта с print?
  2. Почему имена тестовых функций начинают с test_?
  3. Что делает оператор assert в тесте?
  4. Зачем нужен pytest.raises? Приведите пример из высоты в сантиметрах.
  5. Что такое mock и зачем в тесте loader подменяют read_text?
  6. Какую пользу даёт assert_called_once_with(encoding="utf-8")?
  7. Почему чистые функции удобнее тестировать, чем функцию, которая сразу открывает порт автопилота?
  8. Тест «зелёный», но в нём нет assert. В чём проблема?
↑ К оглавлению

11. Чек-лист самопроверки

  • Я установил pytest в venv через python -m pip install pytest.
  • Я понимаю роль assert и префикса test_.
  • Я написал и прогнал тесты для classify_voltage и alt_cm_to_m.
  • Я проверил исключение через pytest.raises.
  • Я использовал MagicMock для Path.read_text и проверил load_mode.
  • Я могу объяснить, что mock не заменяет лётный стенд и SITL.
  • Команда python -m pytest k11_task1_test_battery.py k11_task2_test_loader_mock.py -v завершается successfully (все passed).
  • Я сделал git commit по K11.
Результат. Когда пункты закрыты, напишите ментору: K11 чек-лист закрыт. Далее следует урок K12 — мини-проект CLI «Fake GCS» и контрольная точка G1.
↑ К оглавлению

12. Что дальше

  • Предыдущий урок: K10 · Потоки, asyncio и очередь сообщений
  • Текущий урок: K11 · Тесты pytest и заглушки
  • Следующий урок: K12 · Мини-проект CLI Fake GCS (гейт G1)
Стиль изложения. Текст следует голосу внимательного преподавателя. Кодовые сущности в прозе оформлены тегом code. Вводный alert излагает мысль без клишированного заголовка. Служебные врезки про особенности публикации в шапке отсутствуют. Dual-code с подписью «Чистый код». Стандарт: docs/05_EDITOR_STYLE_GUIDE.md.
↑ К оглавлению

 

 

Воскресенье, 06 сентября 2026
k11. Тесты pytest и заглушки