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

Тестирование кода в Python: от основ к автоматической проверке

Тестирование кода в Python: от основ к автоматической проверке

Надёжный код — это не только правильно работающий код, но и код, поведение которого можно проверить автоматически. Тестирование — неотъемлемая часть профессиональной разработки, обеспечивающая качество, стабильность и поддерживаемость приложений.

В этой статье вы научитесь писать автоматизированные тесты на Python с использованием встроенных и сторонних инструментов, узнаете, как проверять логику без реальных вызовов к внешним сервисам, и сможете уверенно покрывать свой код тестами.


Зачем нужны тесты?

Тесты — это маленькие программы, которые проверяют, что ваш код работает так, как ожидается. Они позволяют:

  • Обнаруживать ошибки до того, как код попадёт в продакшн
  • Рефакторить код без страха сломать существующую функциональность
  • Документировать поведение — тесты показывают, как должен работать код
  • Автоматизировать проверку — один запуск вместо ручного тестирования десятков сценариев

Тестирование особенно важно в критически важных системах, где ошибки могут привести к финансовым потерям, нарушению безопасности или ухудшению пользовательского опыта.


Модуль unittest: стандартный фреймворк тестирования

Python поставляется со встроенным модулем unittest (вдохновлённым JUnit), который позволяет писать структурированные тесты без установки дополнительных библиотек.

Базовая структура теста

Вот пример базового теста с функцией расчёта скидки. Обратите внимание на способ запуска тестов — он работает во всех средах, включая Jupyter Notebook:

import unittest

def calculate_discount(price: float, discount_percent: float) -> float:
    """Рассчитывает цену со скидкой"""
    if discount_percent < 0 or discount_percent > 100:
        raise ValueError("Скидка должна быть между 0 и 100%")
    return price * (1 - discount_percent / 100)

class TestDiscountCalculator(unittest.TestCase):
    """Тесты для функции calculate_discount"""

    def test_valid_discount(self):
        """Проверка корректного расчёта скидки"""
        result = calculate_discount(100.0, 10.0)
        self.assertEqual(result, 90.0)

    def test_zero_discount(self):
        """Проверка нулевой скидки"""
        result = calculate_discount(100.0, 0.0)
        self.assertEqual(result, 100.0)

    def test_max_discount(self):
        """Проверка максимальной скидки (100%)"""
        result = calculate_discount(100.0, 100.0)
        self.assertEqual(result, 0.0)

    def test_invalid_discount(self):
        """Проверка некорректного значения скидки"""
        with self.assertRaises(ValueError):
            calculate_discount(100.0, 150.0)

# Универсальный способ запуска тестов (работает везде)
if __name__ == "__main__":
    suite = unittest.TestLoader().loadTestsFromTestCase(TestDiscountCalculator)
    runner = unittest.TextTestRunner(verbosity=2)
    runner.run(suite)

Результат выполнения в терминале:

test_invalid_discount (__main__.TestDiscountCalculator) ... ok
test_max_discount (__main__.TestDiscountCalculator) ... ok
test_valid_discount (__main__.TestDiscountCalculator) ... ok
test_zero_discount (__main__.TestDiscountCalculator) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.001s

OK

Основные принципы:

  • Каждый тест — метод класса, унаследованного от unittest.TestCase
  • Имена тестов начинаются с test_
  • Используются методы утверждений: assertEqual, assertTrue, assertRaises и др.

Запуск тестов

Из командной строки для файла test_calculator.py:

python -m unittest test_calculator.py
# или для запуска всех тестов в директории
python -m unittest discover

Мокирование с unittest.mock

Что такое мокирование?

Мокирование (mocking) — это техника тестирования, при которой реальные объекты или зависимости заменяются на контролируемые заглушки (mock-объекты). Это позволяет:

  • Тестировать код в изоляции от внешних систем
  • Эмулировать различные сценарии (успешные, ошибочные)
  • Избежать побочных эффектов (запись в файлы, изменение БД)
  • Проверить, как именно вызывались зависимости

Когда мокировать? Когда используешь:

  • Внешние API и веб-сервисы
  • Базы данных
  • Файловая система
  • Сетевые соединения
  • Случайные значения (random)
  • Текущее время (datetime.now())

Пример: мокирование API-вызова

Представьте, что у вас есть функция получения прогноза погоды, которая обращается к внешнему API:

import requests
from unittest.mock import patch, MagicMock

def get_weather(city: str) -> dict:
    """Получает информацию о погоде из внешнего API"""
    response = requests.get(f"https://api.weather.com/city/{city}")
    return response.json()

# Тест с мокированием
class TestWeatherAPI(unittest.TestCase):

    @patch("requests.get")  # Мокаем requests.get
    def test_get_weather(self, mock_get):
        # Настраиваем мок
        mock_response = MagicMock()
        mock_response.json.return_value = {
            "city": "Moscow",
            "temperature": 22,
            "condition": "sunny"
        }
        mock_get.return_value = mock_response

        # Вызываем функцию
        result = get_weather("Moscow")

        # Проверяем результат
        self.assertEqual(result["temperature"], 22)
        self.assertEqual(result["condition"], "sunny")

        # Проверяем, что requests.get был вызван с правильным URL
        mock_get.assert_called_once_with("https://api.weather.com/city/Moscow")

# Универсальный способ запуска (работает везде)
if __name__ == "__main__":
    suite = unittest.TestLoader().loadTestsFromTestCase(TestWeatherAPI)
    runner = unittest.TextTestRunner(verbosity=2)
    runner.run(suite)

Результат выполнения в терминале:

test_get_weather (__main__.TestWeatherAPI) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

Ключевые идеи мокирования:

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

Декоратор @patch заменяет указанный объект на мок на время выполнения теста. После завершения теста оригинальный объект восстанавливается, что гарантирует изоляцию тестов.


pytest: современный и мощный фреймворк

Хотя unittest отлично подходит для базовых задач, большинство Python-разработчиков сегодня используют pytest — более лаконичный, гибкий и выразительный фреймворк.

Установка

pip install pytest

Перепишем наш первый тест на pytest

def calculate_discount(price: float, discount_percent: float) -> float:
    """Рассчитывает цену со скидкой"""
    if discount_percent < 0 or discount_percent > 100:
        raise ValueError("Скидка должна быть между 0 и 100%")
    return price * (1 - discount_percent / 100)

def test_valid_discount():
    """Проверка корректного расчёта скидки"""
    result = calculate_discount(100.0, 10.0)
    assert result == 90.0

def test_invalid_discount():
    """Проверка некорректного значения скидки"""
    with pytest.raises(ValueError):
        calculate_discount(100.0, 150.0)

Результат выполнения в терминале (pytest):

============================= test session starts =============================
platform win32 -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /path/to/project
collected 2 items

test_discount.py ..                                                      [100%]

============================== 2 passed in 0.02s ==============================

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

  • Не нужно наследоваться от класса — достаточно функций с префиксом test_
  • Используется стандартный assert — ошибки показываются понятно
  • Автоматически находит тесты в файлах test_*.py или *_test.py

Запуск:

pytest
# или с подробным выводом
pytest -v

Фикстуры (fixtures) в pytest

Фикстуры — это функции, которые подготавливают данные или ресурсы для тестов: подключение к БД, временные файлы, инициализация объектов и т.д.

Пример: фикстура для временного файла

import pytest
import tempfile
import os

@pytest.fixture
def temp_file():
    """Создаёт временный файл для тестов"""
    # Создаём временный файл
    with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f:
        f.write("test data")
        file_path = f.name
    
    # Передаём путь в тест
    yield file_path
    
    # Удаляем файл после теста
    os.unlink(file_path)

def test_read_file(temp_file):
    """Проверяем чтение данных из файла"""
    with open(temp_file, 'r') as f:
        content = f.read()
    
    assert content == "test data"

Результат выполнения в терминале:

============================= test session starts =============================
platform win32 -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /path/to/project
collected 1 item

test_file.py .                                                           [100%]

============================== 1 passed in 0.01s ==============================

Особенности фикстур:

  • Определяются с декоратором @pytest.fixture
  • Передаются в тест как аргументы по имени
  • Могут выполнять очистку после теста (с помощью yield)
  • Бывают разных "областей действия" (scope="function", "session" и др.)

Параметризация тестов

Часто нужно проверить одну и ту же логику на множестве входных данных. Вместо дублирования кода используйте параметризацию.

С pytest.mark.parametrize

import pytest

@pytest.mark.parametrize("input_str, expected", [
    ("hello", "HELLO"),
    ("world", "WORLD"),
    ("123", "123"),
    ("", ""),
    ("Python3", "PYTHON3"),
])
def test_uppercase_conversion(input_str, expected):
    """Проверяем преобразование строки в верхний регистр"""
    result = input_str.upper()
    assert result == expected

Результат выполнения в терминале:

============================= test session starts =============================
platform win32 -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /path/to/project
collected 5 items

test_string.py .....                                                     [100%]

============================== 5 passed in 0.01s ==============================

Теперь один тест запустится 5 раз с разными данными, и pytest покажет, какой именно случай упал.

Почему это важно?

  • Экономия кода
  • Полное покрытие граничных случаев
  • Чёткое разделение "вход → ожидаемый выход"

Практические примеры

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

class Calculator:
    """Простой калькулятор"""
    
    def add(self, a: float, b: float) -> float:
        return a + b
    
    def multiply(self, a: float, b: float) -> float:
        return a * b

# Тесты с pytest
def test_calculator_add():
    calc = Calculator()
    assert calc.add(2, 3) == 5
    assert calc.add(-1, 1) == 0

def test_calculator_multiply():
    calc = Calculator()
    assert calc.multiply(2, 3) == 6
    assert calc.multiply(0, 5) == 0

Результат выполнения в терминале:

============================= test session starts =============================
platform win32 -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /path/to/project
collected 2 items

test_calculator.py ..                                                    [100%]

============================== 2 passed in 0.01s ==============================

Мокирование базы данных

class UserDatabase:
    """Класс для работы с базой данных пользователей"""
    
    def __init__(self, db_connection):
        self.db = db_connection
    
    def get_user(self, user_id: int) -> dict:
        """Получает пользователя по ID"""
        cursor = self.db.cursor()
        cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
        return cursor.fetchone()

# Тест с мокированием
from unittest.mock import MagicMock

def test_get_user():
    # Создаём мок подключения к БД
    mock_db = MagicMock()
    mock_cursor = MagicMock()
    mock_db.cursor.return_value = mock_cursor
    mock_cursor.fetchone.return_value = {"id": 1, "name": "John Doe"}
    
    # Создаём объект с моком
    user_db = UserDatabase(mock_db)
    
    # Вызываем метод
    result = user_db.get_user(1)
    
    # Проверяем результат
    assert result["name"] == "John Doe"
    
    # Проверяем вызов SQL
    mock_cursor.execute.assert_called_once_with(
        "SELECT * FROM users WHERE id = %s", (1,)
    )

Результат выполнения в терминале (pytest):

============================= test session starts =============================
platform win32 -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /path/to/project
collected 1 item

test_database.py .                                                       [100%]

============================== 1 passed in 0.01s ==============================

Как измерить качество тестов?

Используйте покрытие кода (code coverage), чтобы увидеть, какие строки кода были выполнены во время тестов:

pip install pytest-cov

# Запуск с отчётом о покрытии
pytest --cov=my_module tests/

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

Пример вывода отчёта о покрытии:

----------- coverage: platform win32, python 3.11.4 -----------
Name                  Stmts   Miss  Cover
-----------------------------------------
my_module/__init__.py     0      0   100%
my_module/calculator.py   8      0   100%
-----------------------------------------
TOTAL                     8      0   100%

========================== 2 passed in 0.15s ==========================

Цель — не 100% покрытие любой ценой, а покрытие ключевой бизнес-логики и критических участков кода.


Лучшие практики

  • Тестируйте поведение, а не реализацию — тесты должны проверять что, а не как
  • Один тест — одна проверка — каждый тест должен проверять один конкретный сценарий
  • Используйте осмысленные имена — из имени теста должно быть понятно, что он проверяет
  • Тестируйте граничные случаи — пустые значения, очень большие/маленькие числа, специальные символы
  • Поддерживайте тесты в актуальном состоянии — устаревшие тесты хуже, чем их отсутствие

Итог: что вы теперь умеете

После изучения этой статьи вы можете:

  • ✅ Писать базовые тесты с помощью unittest и pytest
  • ✅ Мокировать внешние зависимости (requests, API, файлы) с помощью unittest.mock
  • ✅ Использовать фикстуры для подготовки тестовых данных
  • ✅ Параметризовать тесты для проверки множества сценариев
  • ✅ Интегрировать тесты в любой Python-проект

Чек-лист для проверки усвоения материала

Проверяемый навык / знаниеСтатус
1 Я понимаю, зачем нужны автоматические тесты и какие преимущества они дают.
Я могу написать простой тест с помощью unittest.
Я понимаю разницу между unittest и pytest.
2 Я умею использовать assertEqual, assertTrue, assertFalse.
Я умею проверять исключения с помощью assertRaises.
Я понимаю, как работает стандартный assert в pytest.
3 Я понимаю, что такое мокирование и зачем оно нужно в тестировании.
Я умею использовать @patch для мокирования функций и методов.
Я умею настраивать поведение моков и проверять их вызовы.
4 Я понимаю, что такое фикстуры и зачем они нужны.
Я умею создавать простые фикстуры с помощью @pytest.fixture.
Я умею использовать yield в фикстурах для очистки ресурсов.
5 Я умею параметризовать тесты с помощью @pytest.mark.parametrize.
Я понимаю, как тестировать граничные случаи через параметризацию.
6 Я могу написать тесты для функции, работающей с файлами.
Я могу написать тесты для функции, обращающейся к внешнему API.
Я умею тестировать классы и их методы.
7 Я умею запускать тесты с измерением покрытия кода.
Я понимаю, как интерпретировать отчёты о покрытии и что считать критичным.

Практическое задание для закрепления

  1. Выберите функцию из вашего текущего проекта или напишите новую (например, функцию валидации email, калькулятор среднего значения).
  2. Напишите тесты с использованием pytest:
    • Проверьте нормальные сценарии
    • Проверьте граничные случаи
    • Проверьте обработку ошибок
  3. Если функция зависит от внешних ресурсов (файлы, API, БД), замокируйте эти зависимости.
  4. Параметризируйте тесты, чтобы проверить несколько наборов входных данных.
  5. Запустите тесты и убедитесь, что они проходят успешно.
  6. Измерьте покрытие кода и проанализируйте результаты.

Тестирование — это навык, который развивается с практикой. Начните с малого, и со временем вы научитесь писать эффективные и поддерживаемые тесты, которые будут надёжно защищать ваш код от регрессий.

Совет: Пишите тесты не как формальность, а как инструмент для понимания вашего кода. Хорошие тесты читаются как документация и помогают другим разработчикам понять, как работает ваша система.

Удачного тестирования! 

Конспект:
Пятница, 12 декабря 2025
Тестирование кода в Python: от основ к автоматической проверке