Модульное тестирование в Python: Полное руководство с примерами

Тестирование на Python с использованием pytest, TDD, мокирования и покрытия кода

Содержимое страницы

Модульное тестирование гарантирует, что ваш код на Python работает правильно и продолжает работать по мере развития проекта. Это всеобъемлющее руководство охватывает все, что вам нужно знать о модульном тестировании в Python, от базовых концепций до продвинутых техник.

Модульное тестирование Python

Почему модульное тестирование важно

Модульное тестирование предоставляет множество преимуществ для разработчиков Python:

  • Раннее обнаружение ошибок: Найдите ошибки до того, как они попадут в продакшен
  • Качество кода: Заставляет вас писать модульный, тестируемый код
  • Уверенность при рефакторинге: Вносите изменения, зная, что тесты обнаружат регрессии
  • Документация: Тесты служат исполняемой документацией о том, как должен работать код
  • Быстрое развитие: Автоматизированные тесты быстрее, чем ручное тестирование
  • Лучший дизайн: Написание тестируемого кода ведет к лучшей архитектуре

Понимание основ модульного тестирования

Что такое модульный тест?

Модульный тест проверяет самую маленькую тестируемую часть приложения (обычно функцию или метод) в изоляции. Он должен быть:

  • Быстрым: Запускаться за миллисекунды
  • Изолированным: Независимым от других тестов и внешних систем
  • Повторяемым: Давать одинаковые результаты каждый раз
  • Самопроверяемым: Проходить или не проходить явно без ручного осмотра
  • Своевременным: Написанным до или вместе с кодом

Пирамида тестирования

Здоровый набор тестов следует пирамиде тестирования:

           /\
          /  \     E2E Тесты (Немного)
         /____\
        /      \   Интеграционные тесты (Некоторые)
       /________\
      /          \ Модульные тесты (Много)
     /____________\

Модульные тесты составляют основу - их много, они быстрые и дают быструю обратную связь.

Сравнение фреймворков тестирования Python

unittest: Встроенный фреймворк

Стандартная библиотека Python включает unittest, вдохновленную JUnit:

import unittest

class TestCalculator(unittest.TestCase):
    def setUp(self):
        """Запускается перед каждым тестом"""
        self.calc = Calculator()

    def tearDown(self):
        """Запускается после каждого теста"""
        self.calc = None

    def test_addition(self):
        result = self.calc.add(2, 3)
        self.assertEqual(result, 5)

    def test_division_by_zero(self):
        with self.assertRaises(ZeroDivisionError):
            self.calc.divide(10, 0)

if __name__ == '__main__':
    unittest.main()

Преимущества:

  • Встроен, не требует установки
  • Хорош для разработчиков, знакомых с xUnit фреймворками
  • Подходит для корпоративного использования, хорошо установлен

Недостатки:

  • Сложная синтаксис с шаблонным кодом
  • Требует классов для организации тестов
  • Менее гибкое управление фикстурами

pytest: Современный выбор

pytest - самый популярный сторонний фреймворк тестирования:

import pytest

def test_addition():
    calc = Calculator()
    assert calc.add(2, 3) == 5

def test_division_by_zero():
    calc = Calculator()
    with pytest.raises(ZeroDivisionError):
        calc.divide(10, 0)

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
])
def test_addition_parametrized(a, b, expected):
    calc = Calculator()
    assert calc.add(a, b) == expected

Преимущества:

  • Простая, питоническая синтаксис
  • Мощная система фикстур
  • Отличное сообщество плагинов
  • Лучшая отчетность об ошибках
  • Встроенное параметрическое тестирование

Недостатки:

  • Требует установки
  • Менее знаком разработчикам из других языков

Установка:

pip install pytest pytest-cov pytest-mock

Написание первых модульных тестов

Давайте создадим простой пример с нуля, используя Test-Driven Development (TDD). Если вы новичок в Python или вам нужна быстрая справка по синтаксису и языковым конструкциям, ознакомьтесь с нашей Шпаргалкой по Python для всеобъемлющего обзора основ Python.

Пример: Утилиты для строк

Шаг 1: Сначала напишите тест (Red)

Создайте test_string_utils.py:

import pytest
from string_utils import reverse_string, is_palindrome, count_vowels

def test_reverse_string():
    assert reverse_string("hello") == "olleh"
    assert reverse_string("") == ""
    assert reverse_string("a") == "a"

def test_is_palindrome():
    assert is_palindrome("racecar") == True
    assert is_palindrome("hello") == False
    assert is_palindrome("") == True
    assert is_palindrome("A man a plan a canal Panama") == True

def test_count_vowels():
    assert count_vowels("hello") == 2
    assert count_vowels("HELLO") == 2
    assert count_vowels("xyz") == 0
    assert count_vowels("") == 0

Шаг 2: Напишите минимальный код для прохождения (Green)

Создайте string_utils.py:

def reverse_string(s: str) -> str:
    """Развернуть строку"""
    return s[::-1]

def is_palindrome(s: str) -> bool:
    """Проверить, является ли строка палиндромом (игнорируя регистр и пробелы)"""
    cleaned = ''.join(s.lower().split())
    return cleaned == cleaned[::-1]

def count_vowels(s: str) -> int:
    """Подсчитать гласные в строке"""
    return sum(1 for char in s.lower() if char in 'aeiou')

Шаг 3: Запустите тесты

pytest test_string_utils.py -v

Вывод:

test_string_utils.py::test_reverse_string PASSED
test_string_utils.py::test_is_palindrome PASSED
test_string_utils.py::test_count_vowels PASSED

Продвинутые техники тестирования

Использование фикстур для настройки тестов

Фикстуры предоставляют повторно используемую настройку и очистку тестов:

import pytest
from database import Database

@pytest.fixture
def db():
    """Создать тестовую базу данных"""
    database = Database(":memory:")
    database.create_tables()
    yield database  # Предоставить тесту
    database.close()  # Очистка после теста

@pytest.fixture
def sample_users(db):
    """Добавить образцовых пользователей в базу данных"""
    db.add_user("Alice", "alice@example.com")
    db.add_user("Bob", "bob@example.com")
    return db

def test_get_user(sample_users):
    user = sample_users.get_user_by_email("alice@example.com")
    assert user.name == "Alice"

def test_user_count(sample_users):
    assert sample_users.count_users() == 2

Области фикстур

Управляйте временем жизни фикстур с помощью областей:

@pytest.fixture(scope="function")  # По умолчанию: запускается для каждого теста
def func_fixture():
    return create_resource()

@pytest.fixture(scope="class")  # Один раз на класс тестов
def class_fixture():
    return create_resource()

@pytest.fixture(scope="module")  # Один раз на модуль
def module_fixture():
    return create_resource()

@pytest.fixture(scope="session")  # Один раз на сессию тестов
def session_fixture():
    return create_expensive_resource()

Мокирование внешних зависимостей

Используйте мокирование для изоляции кода от внешних зависимостей:

from unittest.mock import Mock, patch, MagicMock
import requests

class WeatherService:
    def get_temperature(self, city):
        response = requests.get(f"https://api.weather.com/{city}")
        return response.json()["temp"]

# Тест с моком
def test_get_temperature():
    service = WeatherService()

    # Мокировать функцию requests.get
    with patch('requests.get') as mock_get:
        # Настроить мок-ответ
        mock_response = Mock()
        mock_response.json.return_value = {"temp": 72}
        mock_get.return_value = mock_response

        # Тест
        temp = service.get_temperature("Boston")
        assert temp == 72

        # Проверить вызов
        mock_get.assert_called_once_with("https://api.weather.com/Boston")

Использование плагина pytest-mock

pytest-mock предоставляет более чистую синтаксис:

def test_get_temperature(mocker):
    service = WeatherService()

    # Мокирование с использованием pytest-mock
    mock_response = mocker.Mock()
    mock_response.json.return_value = {"temp": 72}
    mocker.patch('requests.get', return_value=mock_response)

    temp = service.get_temperature("Boston")
    assert temp == 72

Параметрическое тестирование

Тестируйте несколько сценариев эффективно:

import pytest

@pytest.mark.parametrize("input,expected", [
    ("", True),
    ("a", True),
    ("ab", False),
    ("aba", True),
    ("racecar", True),
    ("hello", False),
])
def test_is_palindrome_parametrized(input, expected):
    assert is_palindrome(input) == expected

@pytest.mark.parametrize("number,is_even", [
    (0, True),
    (1, False),
    (2, True),
    (-1, False),
    (-2, True),
])
def test_is_even(number, is_even):
    assert (number % 2 == 0) == is_even

Тестирование исключений и обработки ошибок

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("Нельзя делить на ноль")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError, match="Нельзя делить на ноль"):
        divide(10, 0)

def test_divide_by_zero_with_message():
    with pytest.raises(ValueError) as exc_info:
        divide(10, 0)
    assert "ноль" in str(exc_info.value).lower()

# Тестирование, что исключение не возникает
def test_divide_success():
    result = divide(10, 2)
    assert result == 5.0

Тестирование асинхронного кода

Тестирование асинхронного кода является важным для современных приложений Python, особенно при работе с API, базами данных или сервисами ИИ. Вот как тестировать асинхронные функции:

import pytest
import asyncio

async def fetch_data(url):
    """Асинхронная функция для получения данных"""
    await asyncio.sleep(0.1)  # Симуляция вызова API
    return {"status": "success"}

@pytest.mark.asyncio
async def test_fetch_data():
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "success"

@pytest.mark.asyncio
async def test_fetch_data_with_mock(mocker):
    # Мокировать асинхронную функцию
    mock_fetch = mocker.AsyncMock(return_value={"status": "mocked"})
    mocker.patch('module.fetch_data', mock_fetch)

    result = await fetch_data("https://api.example.com")
    assert result["status"] == "mocked"

Для практических примеров тестирования асинхронного кода с сервисами ИИ см. наше руководство по интеграции Ollama с Python, которое включает стратегии тестирования для взаимодействий с LLM.

Покрытие кода

Измерьте, сколько вашего кода протестировано:

Использование pytest-cov

# Запуск тестов с покрытием
pytest --cov=myproject tests/

# Генерация HTML отчета
pytest --cov=myproject --cov-report=html tests/

# Показ пропущенных строк
pytest --cov=myproject --cov-report=term-missing tests/

Конфигурация покрытия

Создайте .coveragerc:

[run]
source = myproject
omit =
    */tests/*
    */venv/*
    */__pycache__/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:
    if TYPE_CHECKING:
    @abstractmethod

Лучшие практики покрытия

  1. Стремитесь к 80%+ покрытию для критических путей кода
  2. Не фанатейте над 100% - сосредоточьтесь на осмысленных тестах
  3. Тестируйте крайние случаи, а не только счастливые пути
  4. Исключайте шаблонный код из отчетов о покрытии
  5. Используйте покрытие как руководство, а не как цель

Организация тестов и структура проекта

Рекомендуемая структура

myproject/
├── myproject/
│   ├── __init__.py
│   ├── module1.py
│   ├── module2.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py          # Общие фикстуры
│   ├── test_module1.py
│   ├── test_module2.py
│   ├── test_utils.py
│   └── integration/
│       ├── __init__.py
│       └── test_integration.py
├── pytest.ini
├── requirements.txt
└── requirements-dev.txt

conftest.py для общих фикстур

# tests/conftest.py
import pytest
from myproject.database import Database

@pytest.fixture(scope="session")
def test_db():
    """Сеансовая тестовая база данных"""
    db = Database(":memory:")
    db.create_schema()
    yield db
    db.close()

@pytest.fixture
def clean_db(test_db):
    """Очищенная база данных для каждого теста"""
    test_db.clear_all_tables()
    return test_db

Конфигурация pytest.ini

[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
    -v
    --strict-markers
    --cov=myproject
    --cov-report=term-missing
    --cov-report=html
markers =
    slow: помечает тесты как медленные
    integration: помечает тесты как интеграционные
    unit: помечает тесты как модульные

Лучшие практики модульного тестирования

1. Следуйте шаблону AAA

Arrange-Act-Assert делает тесты понятными:

def test_user_creation():
    # Arrange
    username = "john_doe"
    email = "john@example.com"

    # Act
    user = User(username, email)

    # Assert
    assert user.username == username
    assert user.email == email
    assert user.is_active == True

2. Одна проверка на тест (Рекомендация, а не правило)

# Хорошо: Сфокусированный тест
def test_user_username():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"

def test_user_email():
    user = User("john_doe", "john@example.com")
    assert user.email == "john@example.com"

# Также приемлемо: Связанные проверки
def test_user_creation():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"
    assert user.email == "john@example.com"
    assert isinstance(user.created_at, datetime)

3. Используйте описательные имена тестов

# Плохо
def test_user():
    pass

# Хорошо
def test_user_creation_with_valid_data():
    pass

def test_user_creation_fails_with_invalid_email():
    pass

def test_user_password_is_hashed_after_setting():
    pass

4. Тестируйте крайние случаи и границы

def test_age_validation():
    # Валидные случаи
    assert validate_age(0) == True
    assert validate_age(18) == True
    assert validate_age(120) == True

    # Граничные случаи
    assert validate_age(-1) == False
    assert validate_age(121) == False

    # Крайние случаи
    with pytest.raises(TypeError):
        validate_age("18")
    with pytest.raises(TypeError):
        validate_age(None)

5. Держите тесты независимыми

# Плохо: Тесты зависят от порядка
counter = 0

def test_increment():
    global counter
    counter += 1
    assert counter == 1

def test_increment_again():  # Не проходит, если запущен отдельно
    global counter
    counter += 1
    assert counter == 2

# Хорошо: Тесты независимы
def test_increment():
    counter = Counter()
    counter.increment()
    assert counter.value == 1

def test_increment_multiple_times():
    counter = Counter()
    counter.increment()
    counter.increment()
    assert counter.value == 2

6. Не тестируйте детали реализации

# Плохо: Тестирование реализации
def test_sort_uses_quicksort():
    sorter = Sorter()
    assert sorter.algorithm == "quicksort"

# Хорошо: Тестирование поведения
def test_sort_returns_sorted_list():
    sorter = Sorter()
    result = sorter.sort([3, 1, 2])
    assert result == [1, 2, 3]

7. Тестируйте реальные сценарии использования

При тестировании библиотек, которые обрабатывают или преобразуют данные, сосредоточьтесь на реальных сценариях. Например, если вы работаете с веб-скрапингом или преобразованием контента, ознакомьтесь с нашим руководством по преобразованию HTML в Markdown с помощью Python, которое включает стратегии тестирования и сравнительные бенчмарки для различных библиотек преобразования.

Интеграция с непрерывной интеграцией

Пример GitHub Actions

# .github/workflows/tests.yml
name: Тесты

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.9, 3.10, 3.11, 3.12]

    steps:
    - uses: actions/checkout@v3

    - name: Настройка Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}

    - name: Установка зависимостей
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt        

    - name: Запуск тестов
      run: |
        pytest --cov=myproject --cov-report=xml        

    - name: Загрузка покрытия
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

Тестирование серверных функций

При тестировании AWS Lambda функций или серверных приложений рассмотрите стратегии интеграционного тестирования наряду с модульными тестами. Наше руководство по созданию двурежимной AWS Lambda с Python и Terraform охватывает подходы к тестированию для серверных Python приложений, включая как тестировать обработчики Lambda, потребителей SQS и интеграции с API Gateway.

Чек-лист лучших практик тестирования

  • Пишите тесты до или вместе с кодом (TDD)
  • Держите тесты быстрыми (< 1 секунда на тест)
  • Делайте тесты независимыми и изолированными
  • Используйте описательные имена тестов
  • Следуйте шаблону AAA (Arrange-Act-Assert)
  • Тестируйте крайние случаи и условия ошибок
  • Мокируйте внешние зависимости
  • Стремитесь к 80%+ покрытию кода
  • Запускайте тесты в CI/CD пайплайне
  • Регулярно проверяйте и рефакторите тесты
  • Документируйте сложные сценарии тестирования
  • Используйте фикстуры для общего набора
  • Параметризуйте похожие тесты
  • Держите тесты простыми и читаемыми

Общие шаблоны тестирования

Тестирование классов

class TestUser:
    @pytest.fixture
    def user(self):
        return User("john_doe", "john@example.com")

    def test_username(self, user):
        assert user.username == "john_doe"

    def test_email(self, user):
        assert user.email == "john@example.com"

    def test_full_name(self, user):
        user.first_name = "John"
        user.last_name = "Doe"
        assert user.full_name() == "John Doe"

Тестирование с временными файлами

import pytest
from pathlib import Path

@pytest.fixture
def temp_file(tmp_path):
    """Создание временного файла"""
    file_path = tmp_path / "test_file.txt"
    file_path.write_text("test content")
    return file_path

def test_read_file(temp_file):
    content = temp_file.read_text()
    assert content == "test content"

Тестирование генерации файлов

При тестировании кода, который генерирует файлы (например, PDF, изображения или документы), используйте временные директории и проверяйте свойства файлов:

@pytest.fixture
def temp_output_dir(tmp_path):
    """Предоставление временной выходной директории"""
    output_dir = tmp_path / "output"
    output_dir.mkdir()
    return output_dir

def test_pdf_generation(temp_output_dir):
    pdf_path = temp_output_dir / "output.pdf"
    generate_pdf(pdf_path, content="Test")

    assert pdf_path.exists()
    assert pdf_path.stat().st_size > 0

Для всесторонних примеров тестирования генерации PDF см. наше руководство по генерации PDF в Python, которое охватывает стратегии тестирования для различных библиотек PDF.

Тестирование с Monkeypatch

def test_environment_variable(monkeypatch):
    monkeypatch.setenv("API_KEY", "test_key_123")
    assert os.getenv("API_KEY") == "test_key_123"

def test_module_attribute(monkeypatch):
    monkeypatch.setattr("module.CONSTANT", 42)
    assert module.CONSTANT == 42

Полезные ссылки и ресурсы

Связанные ресурсы

Основы Python и лучшие практики

  • Шпаргалка по Python - Полное руководство по синтаксису Python, структурам данных и распространенным паттернам

Тестирование конкретных случаев использования Python

Тестирование серверных и облачных решений


Модульное тестирование - это важный навык для разработчиков на Python. Независимо от того, выбираете ли вы unittest или pytest, главное - писать тесты последовательно, поддерживать их в актуальном состоянии и интегрировать в рабочий процесс разработки. Начните с простых тестов, постепенно внедряйте продвинутые техники, такие как мокирование и фикстуры, и используйте инструменты покрытия кода для выявления не протестированных участков.