Тестирование кода в 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 | Я умею запускать тесты с измерением покрытия кода. | |
| Я понимаю, как интерпретировать отчёты о покрытии и что считать критичным. |
Практическое задание для закрепления
- Выберите функцию из вашего текущего проекта или напишите новую (например, функцию валидации email, калькулятор среднего значения).
- Напишите тесты с использованием
pytest:- Проверьте нормальные сценарии
- Проверьте граничные случаи
- Проверьте обработку ошибок
- Если функция зависит от внешних ресурсов (файлы, API, БД), замокируйте эти зависимости.
- Параметризируйте тесты, чтобы проверить несколько наборов входных данных.
- Запустите тесты и убедитесь, что они проходят успешно.
- Измерьте покрытие кода и проанализируйте результаты.
Тестирование — это навык, который развивается с практикой. Начните с малого, и со временем вы научитесь писать эффективные и поддерживаемые тесты, которые будут надёжно защищать ваш код от регрессий.
Совет: Пишите тесты не как формальность, а как инструмент для понимания вашего кода. Хорошие тесты читаются как документация и помогают другим разработчикам понять, как работает ваша система.
Удачного тестирования!
