Организация тестирования программ на 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 ==============================

Тестирование программы Python - comments

En
тестирование программы python (python)