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-тестов.
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 для каждой функции.
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) # → Вызов, который должен завершиться исключением. Если исключения не будет, тест провален.
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.
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 без нужды.
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. Проверьте себя
- Чем автоматический unit-тест отличается от ручного запуска скрипта с print?
- Почему имена тестовых функций начинают с
test_? - Что делает оператор
assertв тесте? - Зачем нужен
pytest.raises? Приведите пример из высоты в сантиметрах. - Что такое mock и зачем в тесте loader подменяют
read_text? - Какую пользу даёт
assert_called_once_with(encoding="utf-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.