Подготовка среды и создание первого теста
Юнит-тестирование и принцип изоляции
Юнит-тест представляет собой автоматизированную проверку отдельной единицы программного кода. В контексте разработки программных продуктов такой единицей обычно выступает функция, метод класса или небольшой модуль логики. Основная цель юнит-теста заключается в подтверждении того, что конкретный участок кода выполняет свои задачи корректно при заданных входных данных.
Процесс создания юнит-тестов неразрывно связан с циклом разработки. Разработчик пишет код функции, затем сразу создает тест для проверки его работы. Тест фиксирует ожидаемое поведение системы. Если изменения в коде нарушают логику работы функции, тест сигнализирует об ошибке. Такой подход позволяет обнаруживать регрессии на ранних стадиях. Регрессия означает появление новых ошибок в ранее работавшем функционале после внесения изменений.
Ключевым принципом юнит-тестирования является изоляция. Изоляция требует проверки функции в отрыве от внешних зависимостей. Внешние зависимости включают базы данных, сетевые запросы к API, файловую систему, время и случайные генераторы чисел. При наличии таких зависимостей тест перестает быть быстрым и надежным. Скорость выполнения падает из-за ожидания ответа от сети или диска. Надежность снижается из-за нестабильности внешней среды. Например, база данных может временно недоступна, а сервер может вернуть ошибку.
Изолированный тест гарантирует, что проверка касается только логики конкретной функции. Результат теста зависит исключительно от переданных аргументов и внутренней реализации проверяемого кода. Это упрощает поиск причин сбоя. Если тест падает, разработчик знает, что проблема находится внутри тестируемой функции, а не в работе базы данных или сети.
Настройка окружения и структура проекта
Для проведения практики используется язык программирования Python и фреймворк pytest.
Pytest обеспечивает удобный синтаксис написания тестов и автоматическое их обнаружение. Установка инструмента происходит через менеджер пакетов pip. Команда установки выглядит следующим образом:
pip install pytest
Создание структуры проекта начинается с формирования директории для приложения и поддиректории для тестов. Рекомендуется разделять исходный код и тестовый код в разные папки. Это делает проект чистым и понятным.
Структура файлов выглядит так:
project_root/
├── app/
│ └── calculator.py
├── tests/
│ ├── __init__.py
│ └── test_calculator.py
└── requirements.txt
Файл calculator.py содержит логику приложения. Файл test_calculator.py содержит набор тестов для этой логики. Директория tests должна содержать файл __init__.py, чтобы Python воспринимал её как пакет. Файл requirements.txt хранит список установленных зависимостей проекта.
Переход в корневую директорию проекта и запуск команды pytest автоматически находит все файлы, начинающиеся с test_ или заканчивающиеся _test.py. Pytest сканирует содержимое этих файлов в поисках функций, имена которых начинаются с test_. Найденные функции выполняются как тестовые кейсы.
Первая функция и базовый тест
Разработка начинается с создания простой функции сложения двух чисел. Этот пример демонстрирует базовую логику и возможность ее проверки. Функция принимает два аргумента и возвращает их сумму.
Содержимое файла app/calculator.py:
def add(a, b):
return a + b
Функция add имеет два параметра a и b. Оператор return передает результат вычисления вызывающему коду. Код функции минималистичен, но он полностью соответствует требованиям для юнит-тестирования.
Создание теста для этой функции выполняется в файле tests/test_calculator.py. Тест должен импортировать функцию из основного модуля и вызывать её с известными значениями. Результат вызова сравнивается с ожидаемым значением.
Содержимое файла tests/test_calculator.py:
from app.calculator import add
def test_add_positive_numbers():
result = add(2, 3)
assert result == 5
Тестовая функция test_add_positive_numbers импортирует функцию add из модуля app.calculator. Вызов add(2, 3) возвращает значение 5. Утверждение assert проверяет равенство полученного результата и ожидаемого значения 5. Если условие истинно, тест проходит успешно. Если условие ложно, pytest сообщает об ошибке и показывает разницу между фактическим и ожидаемым результатом.
Запуск теста осуществляется командой из корневой директории проекта:
pytest
При успешном выполнении вывод терминала показывает зеленую галочку и статус passed. Пример вывода:
tests/test_calculator.py . [100%]
1 passed in 0.01s
Значок точки указывает на пройденный тест. Статус 1 passed подтверждает выполнение одного теста без ошибок. Время выполнения 0.01s демонстрирует высокую скорость работы юнит-теста.
Демонстрация падения теста
Изменение поведения функции позволяет увидеть работу механизма тестирования в действии. Искусственное изменение кода функции приводит к тому, что она начинает возвращать неверное значение. Это моделирует ситуацию ошибки в разработке или регрессию.
Измененная версия функции add в файле app/calculator.py:
def add(a, b):
return a - b
В этом случае функция выполняет вычитание вместо сложения. Логика программы изменилась, но тестовый код остался прежним. Запуск теста снова вызывает механизм проверки.
Результат запуска команды pytest:
tests/test_calculator.py F [100%]
=================================== FAILURES ===================================
___________________________ test_add_positive_numbers ___________________________
def test_add_positive_numbers():
result = add(2, 3)
> assert result == 5
E assert -1 == 5
tests/test_calculator.py:6: AssertionError
Знак F в выводе обозначает провал теста (failed). Сообщение AssertionError указывает на нарушение условия утверждения. Строка E assert -1 == 5 показывает фактическое значение -1 и ожидаемое значение 5. Разница между ними очевидна. Тест выявил несоответствие между поведением функции и заявленными требованиями.
Это поведение критически важно для процесса разработки. Автоматическая проверка мгновенно сигнализирует о проблеме. Разработчик получает точную информацию о том, где произошло отклонение. Исправление ошибки возвращается к исходному коду функции:
def add(a, b):
return a + b
Повторный запуск теста возвращает статус passed. Система гарантирует, что исправление вернуло функциональность к правильному состоянию.
Работа с моками и внешними зависимостями
Реальные функции часто зависят от внешних ресурсов. База данных, внешний API или файловая система создают проблемы при тестировании. Эти ресурсы могут работать медленно, быть недоступными или возвращать случайные данные. Использование реальных ресурсов делает тесты медленными и ненадежными.
Мок (mock) — это объект, который имитирует поведение реальной зависимости. Мок предоставляет предопределенные ответы на вызовы методов. Это позволяет изолировать тестируемую функцию от внешнего мира. Тест работает только с логикой функции, а не с состоянием базы данных или сетью.
Библиотека unittest.mock входит в стандартную поставку Python. Она предоставляет инструменты для создания мок-объектов. Пример использования моков демонстрирует ситуацию, когда функция запрашивает данные из базы данных.
Предположим, существует функция get_user_data, которая обращается к базе данных для получения информации о пользователе.
Содержимое файла app/user_service.py:
import sqlite3
class DatabaseConnection:
def __init__(self, db_name):
self.db_name = db_name
def fetch_user(self, user_id):
# Имитация запроса к БД
# В реальности здесь был бы SQL запрос
if user_id == 1:
return {"id": 1, "name": "Alice"}
return None
def get_user_data(user_id):
db = DatabaseConnection("users.db")
data = db.fetch_user(user_id)
if data is None:
raise ValueError(f"User {user_id} not found")
return data["name"]
Функция get_user_data создает соединение с базой данных и запрашивает пользователя. При отсутствии данных выбрасывается исключение. Тестирование этого кода с реальной базой данных потребует создания таблицы и заполнения её данными. Это усложняет процесс.
Создание мок-объекта для класса DatabaseConnection позволяет заменить реальное взаимодействие с базой данных на фиктивное поведение.
Содержимое файла tests/test_user_service.py:
from unittest.mock import Mock, patch
from app.user_service import get_user_data
@patch('app.user_service.DatabaseConnection')
def test_get_user_data_with_mock(mock_db_class):
mock_instance = Mock()
mock_instance.fetch_user.return_value = {"id": 1, "name": "Bob"}
mock_db_class.return_value = mock_instance
result = get_user_data(1)
assert result == "Bob"
mock_instance.fetch_user.assert_called_once_with(1)
Аннотация @patch заменяет класс DatabaseConnection внутри модуля app.user_service на мок-объект. Параметр mock_db_class в тестовой функции представляет собой этот замененный класс. Внутри теста создается экземпляр мок-объекта mock_instance. Метод fetch_user этого экземпляра настроен на возврат фиктивного словаря с именем "Bob".
Вызов get_user_data(1) теперь использует мок вместо реального подключения к базе данных. Функция получает имя "Bob" и возвращает его. Утверждение assert result == "Bob" проверяет корректность возврата. Метод assert_called_once_with подтверждает, что функция действительно обратилась к моку с нужным аргументом.
Такой подход гарантирует, что тест выполняется быстро и не зависит от наличия базы данных. Изменение логики базы данных не влияет на тесты сервиса, если интерфейс взаимодействия остается неизменным. Моки позволяют тестировать сложные сценарии, включая обработку ошибок и краевые случаи, без необходимости настройки реального окружения.