Организация тестирования программ на Python
Методики контроля качества Python программ
Проверка кода помогает фиксировать поведение функций, обнаруживать регрессии и документировать ожидаемые результаты. Для разных задач подходят разные инструменты: от встроенного unittest до pytest, doctest, mock и coverage.
Основной подход: pytest
pytest дает лаконичный синтаксис, автоматический поиск тестов и богатые отчеты. Минимальный проект состоит из модуля с кодом и файла с проверками.
# calc.py
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ValueError('division by zero')
return a / b
тестирование программы python (тестирование программы python)
# test_calc.py
import pytest
from calc import add, divide
def test_add():
assert add(2, 3) == 5
def test_divide_zero():
with pytest.raises(ValueError):
divide(1, 0)
Запуск выполняется командой pytest -q. pytest сам находит файлы test_*.py и функции test_*.
2 passed in 0.01s
Шаги настройки: установка через pip install pytest, размещение тестов рядом с кодом или в каталоге tests, запуск из корня проекта. Для настройки путей можно создать pytest.ini или pyproject.toml.
Типичные проблемы: import error при запуске из другого каталога, конфликт имен test, кеш .pytest_cache. Решение: запуск через python -m pytest, настройка pythonpath в pytest.ini, очистка кеша.
Как проверять код без сторонних библиотек через unittest?
unittest входит в стандартную библиотеку. Он подходит для проектов с ограничениями на зависимости и для унаследованных тестов.
# test_calc_unittest.py
import unittest
from calc import add, divide
class TestCalc(unittest.TestCase):
def test_add(self):
self.assertEqual(add(2, 2), 4)
def test_divide_zero(self):
with self.assertRaises(ValueError):
divide(1, 0)
if __name__ == '__main__':
unittest.main()
Запуск: python -m unittest discover -v. Класс наследуется от TestCase, проверки выражаются методами assertEqual, assertRaises и другими.
test_divide_zero (test_calc_unittest.TestCalc) ... ok test_add (test_calc_unittest.TestCalc) ... ok ---------------------------------------------------------------------- Ran 2 tests in 0.001s OK
Проблемы: многословность, отсутствие параметризации, неочевидный вывод при падении. Решение: комбинировать unittest с pytest или использовать subTest для нескольких наборов данных.
Как проверять примеры в docstring и документации?
doctest выполняет код из докстрок. Такой вариант полезен для библиотек, где примеры служат документацией и одновременно тестами.
# math_ops.py
def square(x):
'''
>>> square(3)
9
>>> square(-1)
1
'''
return x * x
if __name__ == '__main__':
import doctest
doctest.testmod()
Запуск: python -m doctest -v math_ops.py. Ожидаемый вывод сравнивается с фактическим с точностью до пробелов и переводов строк.
Trying:
square(3)
Expecting:
9
ok
Trying:
square(-1)
Expecting:
1
ok
1 items had no tests:
__main__
1 items passed all tests:
2 tests in __main__.square
2 tests in 2 items.
2 passed and 0 failed.
Test passed.
Проблемы: хрупкость к форматированию, сложность проверки исключений, несовпадение float. Решение: использовать ELLIPSIS, NORMALIZE_WHITESPACE, округление или assertAlmostEqual в pytest.
Как изолировать сеть, базу данных и время при проверке?
mock и monkeypatch заменяют внешние зависимости управляемыми объектами. Цель: тест не зависит от сети, состояния БД или текущего времени.
# user_service.py
import requests
def get_user(user_id):
response = requests.get(f'https://api.example.com/users/{user_id}')
response.raise_for_status()
return response.json()
# test_user_service.py
import requests
from user_service import get_user
def test_get_user(monkeypatch):
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {'id': 1, 'name': 'Anna'}
def fake_get(url):
assert url.endswith('/users/1')
return FakeResponse()
monkeypatch.setattr(requests, 'get', fake_get)
assert get_user(1)['name'] == 'Anna'
Запуск: pytest test_user_service.py -q. Подмена выполняется на время теста, после завершения исходная функция восстанавливается.
1 passed in 0.02s
Проблемы: патч не того объекта, избыточное мокирование, потеря проверки реального контракта. Решение: патчить там, где имя импортировано, проверять аргументы, дополнять тесты интеграционными сценариями.
Как измерить полноту проверок через coverage?
coverage.py показывает, какие строки и ветви кода выполнялись во время тестов. Это помогает найти непокрытые сценарии.
pip install coverage
coverage run -m pytest
coverage report -m
coverage html
После запуска создается отчет в терминале и каталог htmlcov. В настройках можно включить branch coverage и исключить миграции, тесты, сгенерированный код.
Name Stmts Miss Cover Missing -------------------------------------------- calc.py 6 0 100% test_calc.py 7 0 100% -------------------------------------------- TOTAL 13 0 100%
Проблемы: погоня за 100%, игнорирование ветвей, медленный отчет. Решение: использовать --branch, задавать разумный порог, не считать покрытие единственной метрикой качества.
Как проверять свойства алгоритмов на случайных данных?
Hypothesis генерирует примеры на основе стратегий. Подход полезен для функций кодирования, парсеров, математических преобразований.
from hypothesis import given, strategies as st
def encode(s):
return s.encode('utf-8')
def decode(b):
return b.decode('utf-8')
@given(st.text())
def test_roundtrip(s):
assert decode(encode(s)) == s
Запуск: pytest test_hypothesis.py -q. При падении Hypothesis сохраняет минимальный пример в каталоге .hypothesis/examples.
1 passed in 0.15s
Проблемы: медленные тесты, нестабильные примеры, слишком широкие стратегии. Решение: ограничивать размер данных, задавать deadline, использовать assume для фильтрации.
Расширенные примеры проверки Python кода
Параметризация и идентификаторы тестов
Параметризация позволяет одной функцией проверить несколько наборов данных. pytest формирует отдельные тесты с понятными идентификаторами.
# test_param.py
import pytest
from calc import add
@pytest.mark.parametrize(
'a, b, expected',
[
(1, 2, 3),
(-1, 1, 0),
(0, 0, 0),
(10, 20, 30),
],
ids=['positive', 'zero_sum', 'zeros', 'large'],
)
def test_add(a, b, expected):
assert add(a, b) == expected
Запуск: pytest test_param.py -v. Каждый набор отображается отдельной строкой.
test_param.py::test_add[positive] PASSED test_param.py::test_add[zero_sum] PASSED test_param.py::test_add[zeros] PASSED test_param.py::test_add[large] PASSED
Фикстуры с временными файлами
Фикстура tmp_path создает изолированный каталог для каждого теста. Это исключает влияние предыдущих запусков.
# test_files.py
import json
def save_json(path, data):
path.write_text(json.dumps(data), encoding='utf-8')
def load_json(path):
return json.loads(path.read_text(encoding='utf-8'))
def test_save_and_load(tmp_path):
file_path = tmp_path / 'data.json'
payload = {'name': 'Anna', 'age': 30}
save_json(file_path, payload)
assert load_json(file_path) == payload
Запуск: pytest test_files.py -q. Каталог удаляется после теста.
1 passed in 0.01s
Асинхронные проверки
Для async функций нужен pytest-asyncio или anyio. Плагин добавляет маркер asyncio.
# test_async.py
import asyncio
import pytest
async def fetch_value():
await asyncio.sleep(0.01)
return 42
@pytest.mark.asyncio
async def test_fetch_value():
value = await fetch_value()
assert value == 42
Установка: pip install pytest-asyncio. Запуск: pytest test_async.py -q.
1 passed in 0.03s
Ошибка: async def functions are not natively supported. Решение: установить плагин и включить asyncio_mode = auto в pytest.ini.
Подмена текущего времени
Модуль freezegun фиксирует дату и время. Такой прием полезен для проверки сроков, кеша и расписаний.
# test_time.py
from datetime import datetime
from freezegun import freeze_time
def is_new_year():
return datetime.now().month == 1 and datetime.now().day == 1
@freeze_time('2025-01-01')
def test_is_new_year():
assert is_new_year() is True
@freeze_time('2025-01-02')
def test_not_new_year():
assert is_new_year() is False
Установка: pip install freezegun. Запуск: pytest test_time.py -q.
2 passed in 0.02s
Сравнение снимков через syrupy
Снимки сохраняют эталонный вывод и сравнивают его при следующих запусках. Инструмент удобен для JSON, HTML и текстовых отчетов.
# test_snapshot.py
from syrupy.assertion import SnapshotAssertion
def render_user(user):
return '{} ({})'.format(user['name'], user['role'])
def test_render_user(snapshot: SnapshotAssertion):
user = {'name': 'Anna', 'role': 'admin'}
assert render_user(user) == snapshot
Первый запуск создает файл снимка, последующие сравнивают результат. Обновление выполняется флагом --snapshot-update.
1 passed in 0.02s
Проверка CLI через subprocess
Для программ с аргументами командной строки помогает запуск через subprocess. Такой тест проверяет реальный интерфейс.
# cli.py
import argparse
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--name', required=True)
args = parser.parse_args()
print(f'Hello, {args.name}!')
if __name__ == '__main__':
main()
# test_cli.py
import subprocess
import sys
def test_cli_name():
result = subprocess.run(
[sys.executable, 'cli.py', '--name', 'Anna'],
capture_output=True,
text=True,
check=True,
)
assert result.stdout.strip() == 'Hello, Anna!'
Запуск: pytest test_cli.py -q. Проверяются код возврата, stdout и stderr.
1 passed in 0.08s
Проблемы: зависимость от рабочего каталога, разные пути к интерпретатору, медленный запуск. Решение: использовать sys.executable, cwd и check=True.
CI через GitHub Actions
Непрерывная интеграция запускает тесты при каждом изменении. Пример workflow устанавливает зависимости и вызывает pytest.
# .github/workflows/tests.yml
name: Python tests
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install pytest pytest-cov
- run: pytest --cov=.
Результат: статус проверки отображается в pull request. При падении тестов merge блокируется правилами ветки.
============================= test session starts ============================= collected 12 items test_calc.py ............ [100%] ============================== 12 passed in 0.45s ==============================