Тестирование кода играет весомую роль в жизненном цикле разработки программного обеспечения. Для разработчиков Python существует богатый выбор инструментов, позволяющих создавать надёжные и поддерживаемые приложения. Грамотно построенные тесты не только помогают выявлять ошибки на ранних этапах, но и служат своеобразной документацией, демонстрирующей ожидаемое поведение программы.
В центре системы тестирования Python находится PyTest — фреймворк, который выделяется среди аналогов своей простотой и гибкостью. В отличие от стандартного модуля unittest, PyTest минимизирует количество шаблонного кода, необходимого для написания тестов. Он предлагает богатый набор опций и плагинов, что делает его мощным инструментом как для небольших проектов, так и для корпоративных приложений.
Python | 1
2
3
| # Пример теста с использованием PyTest
def test_simple_addition():
assert 1 + 1 == 2 |
|
Модуль unittest.mock, входящий в стандартную библиотеку Python, предоставляет функциональность для замены частей системы объектами-заглушками во время тестирования. Это особенно полезно, когда тестируемый код взаимодействует с внешними системами — базами данных, веб-сервисами или файловой системой. Использование моков позволяет изолировать тестируемый компонент и сделать тесты более предсказуемыми.
Python | 1
2
3
4
5
6
| from unittest.mock import patch
def test_function_with_external_dependency():
with patch('module.external_function') as mock_func:
mock_func.return_value = 'mocked_value'
# Код теста, использующий внешнюю функцию |
|
Подход Test-Driven Development (TDD) — это методология разработки, при которой тесты пишутся до написания самого кода. TDD формирует цикл "красный-зелёный-рефакторинг": сначала пишется тест, который не проходит (красный), затем минимальное количество кода для прохождения этого теста (зелёный), и наконец, улучшение кода без изменения его поведения (рефакторинг).
Экосистема инструментов тестирования в Python не ограничивается PyTest и unittest.mock. Существуют и другие библиотеки, ориентированные на различные аспекты тестирования:
Nose и Nose2 — расширения для unittest, упрощающие написание и запуск тестов,
Hypothesis — библиотека для property-based testing, генерирующая тестовые данные,
Tox — инструмент для тестирования Python-пакетов на разных версиях Python,
Behave и pytest-bdd — фреймворки для Behavior-Driven Development (BDD).
Автоматизированное тестирование становится ещё более ценным, когда оно интегрировано в процесс непрерывной интеграции и доставки (CI/CD). Такая интеграция позволяет автоматически запускать тесты при каждом коммите в репозиторий, обеспечивая раннее обнаружение проблем и поддерживая высокое качество кода.
Несмотря на кажущуюся простоту, эффективное тестирование требует понимания различных техник и паттернов. Неправильно построенные тесты могут быть хрупкими, медленными или не выявлять реальных проблем в коде. В этой статье мы рассмотрим лучшие практики и приёмы для создания надёжных, информативных и поддерживаемых тестов с использованием PyTest, Mock и подхода TDD.
PyTest как основа тестового фреймворка
PyTest — мощный и в то же время элегантный фреймворк для тестирования в Python, который за последние годы стал де-факто стандартом в сообществе разработчиков. Его популярность объясняется не только простотой использования, но и богатой функциональностью, которая сочетается с минималистичным подходом к написанию тестов.
Отличия от стандартного unittest
В отличие от встроенного модуля unittest, который построен на основе JUnit и требует создания классов для группировки тестов, PyTest позволяет использовать простые функции. Это сразу убирает лишние уровни абстракции и делает код тестов более читаемым.
Python | 1
2
3
4
5
6
7
8
9
10
| # Тест с использованием unittest
import unittest
class TestStringMethods(unittest.TestCase):
def test_upper(self):
self.assertEqual('foo'.upper(), 'FOO')
# Тот же тест с использованием PyTest
def test_upper():
assert 'foo'.upper() == 'FOO' |
|
Ещё одно ключевое преимущество — использование обычных утверждений Python (assert ) вместо специальных методов для проверки условий (assertEqual , assertTrue и т.д.). PyTest автоматически перехватывает исключения при провале теста и предоставляет подробную информацию о том, что пошло не так, включая значения переменных. PyTest также отличается интеллектуальным обнаружением тестов. Фреймворк автоматически находит файлы с названиями, начинающимися или заканчивающися на test_ или _test , и в них ищет функции и классы с аналогичными именами. Это устраняет необходимость в бойлерплейт-коде для регистрации тестов.
Система фикстур и их преимущества
Фикстуры — одна из самых мощных возможностей PyTest. Они обеспечивают модульный, масштабируемый подход к настройке предварительных условий для тестов и очистке ресурсов после их выполнения.
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| import pytest
import tempfile
import os
@pytest.fixture
def temp_file():
# Настройка: создаём временный файл
fd, path = tempfile.mkstemp()
with os.fdopen(fd, 'w') as f:
f.write('hello world')
# Передаём путь к файлу в тест
yield path
# Очистка: удаляем файл после теста
os.unlink(path)
def test_read_file(temp_file):
with open(temp_file, 'r') as f:
content = f.read()
assert content == 'hello world' |
|
В этом примере фикстура temp_file создаёт временный файл перед тестом, передаёт путь к нему в функцию теста и затем удаляет файл после завершения теста. Благодаря ключевому слову yield , очистка происходит независимо от результата теста, даже если он завершается с ошибкой.
Фикстуры в PyTest могут иметь различные области видимости:
function (по умолчанию): фикстура вызывается один раз для каждого теста, который её использует,
class: один раз для каждого класса тестов,
module: один раз для модуля,
session: один раз на сессию тестирования.
Python | 1
2
3
4
5
6
7
| @pytest.fixture(scope="module")
def database_connection():
# Настройка соединения с базой данных
conn = create_connection()
yield conn
# Закрытие соединения
conn.close() |
|
Такой подход разительно отличается от установки и очистки среды в setUp() и tearDown() методах в unittest, предлагая гораздо большую гибкость и возможность повторного использования.
Параметризация тестов
Параметризация — ещё одна сильная сторона PyTest, позволяющая запускать один и тот же тест с разными входными данными. Это уменьшает дублирование кода и увеличивает охват тестами без написания дополнительных функций.
Python | 1
2
3
4
5
6
7
| @pytest.mark.parametrize("test_input,expected", [
("3+5", 8),
("2+4", 6),
("6*9", 54),
])
def test_eval(test_input, expected):
assert eval(test_input) == expected |
|
В этом примере функция test_eval будет запущена три раза с разными наборами входных данных и ожидаемых результатов. Если какой-то из тестов не проходит, отчёт PyTest чётко укажет, какая именно комбинация параметров вызвала проблему. Параметризация может применяться как к отдельным тестам, так и к фикстурам:
Python | 1
2
3
4
5
6
7
| @pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def db_engine(request):
return create_engine(request.param)
def test_database_operations(db_engine):
# Тест будет запущен для каждого типа базы данных
assert db_engine.connect() |
|
Плагины PyTest для расширения функциональности
Экосистема PyTest включает сотни плагинов, которые расширяют базовую функциональность фреймворка. Вот некоторые из наиболее полезных:
pytest-cov: интеграция с инструментом покрытия кода coverage.py,
pytest-xdist: параллельное выполнение тестов на нескольких процессорах или даже машинах,
pytest-django: интеграция с Django для упрощения тестирования веб-приложений,
pytest-mock: предоставляет фикстуру mocker , упрощающую использование библиотеки unittest.mock,
pytest-timeout: добавляет поддержку таймаутов для предотвращения зависаний тестов.
Установка плагинов обычно выполняется через pip:
После установки многие плагины автоматически интегрируются с PyTest, добавляя новые опции командной строки, фикстуры или другую функциональность.
Python | 1
2
3
4
5
6
| # Использование pytest-mock
def test_with_mock(mocker):
# mocker - это фикстура, предоставляемая плагином pytest-mock
mock_function = mocker.patch('module.function')
mock_function.return_value = 42
assert module.function() == 42 |
|
Настройка конфигурации PyTest для различных проектов
PyTest предлагает гибкие варианты конфигурации для адаптации к различным проектам. Настройки могут быть заданы в нескольких местах:
1. Файл pytest.ini (или pyproject.toml, или tox.ini) в корне проекта:
Code | 1
2
3
4
5
6
| [pytest]
addopts = -xvs --cov=myproject
testpaths = tests
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: integration tests |
|
2. Файл conftest.py, который может размещаться на разных уровнях иерархии директорий с тестами:
Python | 1
2
3
4
5
6
7
8
9
10
11
| # conftest.py
import pytest
# Определение пользовательских маркеров
def pytest_configure(config):
config.addinivalue_line("markers", "webtest: mark a test as a webtest")
# Определение общих фикстур для всех тестов в директории
@pytest.fixture(scope="session")
def app_config():
return {"api_key": "test_key", "environment": "testing"} |
|
3. Хуки (hooks) для настройки поведения PyTest для конкретных случаев:
Python | 1
2
3
4
5
6
7
8
| # В conftest.py
def pytest_runtest_setup(item):
# Код, который будет выполнен перед каждым тестом
pass
def pytest_collection_modifyitems(config, items):
# Можно изменить порядок выполнения тестов или пропустить некоторые из них
items.reverse() # Выполнять тесты в обратном порядке |
|
Такая многоуровневая система конфигурации позволяет создавать тонко настроенные среды тестирования для проектов любого размера и сложности, от простых скриптов до крупных, многокомпонентных систем.
PyTest также позволяет создавать пользовательские маркеры для категоризации тестов и последующего выборочного запуска:
Python | 1
2
3
4
| @pytest.mark.slow
def test_complex_calculation():
# Длительные вычисления
pass |
|
Такие тесты можно запускать отдельно или исключать из обычного набора:
Bash | 1
2
| pytest -m slow # Запустить только тесты с маркером slow
pytest -m "not slow" # Запустить все тесты, кроме медленных |
|
За счёт этой архитектуры PyTest создаёт экосистему, которая удовлетворяет потребности как новичков, так и опытных разработчиков, обеспечивая простоту для простых случаев и масштабируемость для сложных. PyTest также предлагает расширенные возможности для работы с исключениями, что делает тестирование защитного программирования более удобным. Разработчик может проверить, вызывает ли код определённое исключение, используя менеджер контекста pytest.raises :
Python | 1
2
3
4
5
6
7
8
9
| def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_division_by_zero():
with pytest.raises(ValueError) as excinfo:
divide(1, 0)
assert "Cannot divide by zero" in str(excinfo.value) |
|
Эта конструкция не только проверяет, был ли поднят правильный тип исключения, но и позволяет исследовать его содержимое для более точных проверок.
При написании сложных тестов могут возникать ситуации, когда нужно временно пропустить отдельные тесты или даже целые модули. PyTest предлагает несколько маркеров для этих целей:
Python | 1
2
3
4
5
6
7
8
9
10
11
| @pytest.mark.skip(reason="Временно сломан из-за рефакторинга базы данных")
def test_database_integration():
# Код теста
@pytest.mark.skipif(sys.platform == "darwin", reason="Не работает на macOS")
def test_windows_specific_feature():
# Код теста, который выполнится только на не-Darwin платформах
@pytest.mark.xfail(reason="Известная ошибка, над исправлением работаем")
def test_expected_to_fail():
# Тест, который пока должен проваливаться |
|
Такие аннотации делают код тестов самодокументируемым и помогают поддерживать чистый CI/CD, позволяя игнорировать некоторые проблемы до их решения.
Одна из уникальных особенностей PyTest — возможность создавать временные директории для тестов. Фикстура tmpdir создаёт временную директорию, которая автоматически удаляется после завершения теста:
Python | 1
2
3
4
5
6
| def test_create_file(tmpdir):
# tmpdir это объект py.path.local
p = tmpdir.join("test.txt")
p.write("content")
assert p.read() == "content"
assert len(tmpdir.listdir()) == 1 |
|
PyTest также предлагает фикстуру tmpdir_factory с более широкой областью видимости для создания временных директорий, которые должны сохраняться между тестами.
Расширенные техники отладки тестов
Когда тесты проваливаются, быстрая идентификация причины становится критичной. PyTest предлагает несколько мощных инструментов для отладки:
1. Подробные отчёты об ошибках: По умолчанию PyTest показывает детальные сравнения ожидаемых и фактических значений в случае провала теста.
2. Интерактивная отладка с опцией --pdb: Запуск pytest --pdb автоматически активирует отладчик Python при возникновении ошибки:
Bash | 1
| pytest --pdb test_file.py |
|
3. Выборочный запуск тестов: PyTest позволяет запускать только те тесты, которые провалились в предыдущем запуске, что существенно ускоряет циклы отладки:
Bash | 1
2
| pytest --lf # Запуск только проваленных в прошлый раз тестов
pytest --ff # Сначала запуск проваленных тестов, затем всех остальных |
|
4. Подробные трассировки с опцией -v: Увеличение уровня подробности вывода:
Для случаев, когда требуется более глубокое понимание процесса выполнения теста, можно использовать встроенный инструмент для вывода отладочной информации:
Python | 1
2
3
4
| def test_complicated_process():
result = process_data()
import pdb; pdb.set_trace() # Точка останова для отладки
assert result.status == 'success' |
|
Организация тестов в больших проектах
По мере роста проекта, число тестов может достигать сотен и тысяч. PyTest предлагает несколько паттернов для организации такого количества тестов:
1. Группировка тестов по модулям и пакетам: Распределение тестов по файлам и директориям, отражающим структуру исходного кода:
Python | 1
2
3
4
5
6
7
8
| tests/
├── unit/
│ ├── test_models.py
│ └── test_utils.py
├── integration/
│ └── test_api.py
└── functional/
└── test_user_flows.py |
|
2. Использование нескольких conftest.py файлов: Размещение файлов conftest.py на разных уровнях иерархии директорий позволяет определять фикстуры с разным уровнем доступности:
Python | 1
2
3
4
5
6
7
8
9
| # tests/conftest.py - общие фикстуры для всех тестов
@pytest.fixture(scope="session")
def app():
return create_app()
# tests/unit/conftest.py - фикстуры только для модульных тестов
@pytest.fixture
def mock_database():
# ... |
|
3. Именование тестов по уровню абстракции: Префиксы или суффиксы могут указывать на уровень абстракции теста:
Python | 1
2
3
4
5
6
7
| # Модульные тесты
def test_unit_user_validation():
# ...
# Интеграционные тесты
def test_integration_user_registration():
# ... |
|
PyTest также позволяет группировать тесты внутри классов, что может быть полезно для логического объединения связанных тестов:
Python | 1
2
3
4
5
6
7
8
9
| class TestUserAuthentication:
def test_login(self):
# ...
def test_logout(self):
# ...
def test_password_reset(self):
# ... |
|
Важно отметить, что в отличие от unittest, классы тестов PyTest не обязаны наследоваться от какого-либо базового класса.
Автоматическое тестирование свойств
Интересный подход к тестированию, который хорошо сочетается с PyTest — это проверка свойств системы с использованием библиотеки Hypothesis. Это позволяет описывать свойства, которым должен удовлетворять код, и Hypothesis генерирует тестовые данные, стремясь найти контрпример:
Python | 1
2
3
4
5
6
7
| from hypothesis import given
import hypothesis.strategies as st
@given(st.lists(st.integers()))
def test_reversing_twice_gives_original_list(xs):
# Свойство: двойной реверс списка должен давать исходный список
assert list(reversed(list(reversed(xs)))) == xs |
|
Такой подход может выявить пограничные случаи и ошибки, которые сложно предсказать при написании обычных тестов.
PyTest, благодаря своей гибкости и богатой экосистеме, становится не просто фреймворком для запуска тестов, а целостной платформой, которая формирует современные подходы к тестированию в Python. Интеграция с другими инструментами и библиотеками расширяет его возможности далеко за пределы базовой функциональности, делая его центральным элементом стратегий обеспечения качества в Python-проектах.
pytest тестирование Flask приложения с тестовой базой данных proj/webapp/utils/test_fixtures.py
import os
import pytest
from config import basedir
from... Простое тестирование класса с PyTest С помощи библиотеки PyTest написать тесты для класса Rectangle. Конструктор класса принимает на... Простое тестирование с pytest Уже кучу раз перепробовал. Никак не могу понять в чем заключается ошибка и каких тестов не хватает... Тестирование функций класса с помощью PyTest Не получается написать тест к функциям из класса с помощью PyTest, если точнее, то не могу передать...
Продвинутые техники организации тестовых наборов с помощью PyTest
С ростом сложности проектов приходит необходимость в более изощренных подходах к организации тестов. PyTest предлагает множество продвинутых техник, которые делают работу с большими тестовыми наборами более управляемой и эффективной.
Группировка тестов с помощью наследования
Одна из элегантных возможностей PyTest — организация тестов с использованием иерархии классов. Это позволяет избежать дублирования кода и создавать хорошо структурированные тестовые наборы:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| class TestBase:
def setup_method(self):
self.resource = create_expensive_resource()
def teardown_method(self):
self.resource.cleanup()
class TestFeatureA(TestBase):
def test_a1(self):
assert self.resource.operation_a1() == expected_result
def test_a2(self):
assert self.resource.operation_a2() == expected_result
class TestFeatureB(TestBase):
def test_b1(self):
assert self.resource.operation_b1() == expected_result |
|
Такой подход особенно полезен, когда нескольким группам тестов требуется общая настройка и очистка среды.
Создание пользовательских маркеров с метаданными
Стандартные маркеры PyTest могут быть расширены дополнительными метаданными, которые могут использоваться для контроля выполнения тестов или генерации отчётов:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # В conftest.py
def pytest_configure(config):
config.addinivalue_line(
"markers", "component(name): mark test with specific component name"
)
config.addinivalue_line(
"markers", "priority(level): mark test with priority level"
)
# В тестах
@pytest.mark.component("authentication")
@pytest.mark.priority("high")
def test_user_login():
# ... |
|
Для фильтрации тестов по этим меткам можно использовать:
Bash | 1
| pytest -m "component('authentication') and priority('high')" |
|
Составные фикстуры с зависимостями
В сложных тестовых окружениях фикстуры часто должны зависеть друг от друга. PyTest позволяет определять такие отношения элегантным способом:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| @pytest.fixture
def user_data():
return {"username": "testuser", "password": "secure123"}
@pytest.fixture
def authenticated_user(user_data, api_client):
response = api_client.login(
user_data["username"],
user_data["password"]
)
token = response.json()["token"]
api_client.set_auth_header(token)
return api_client
def test_protected_resource(authenticated_user):
response = authenticated_user.get("/api/protected-resource")
assert response.status_code == 200 |
|
Здесь фикстура authenticated_user автоматически получает результаты фикстур user_data и api_client , демонстрируя, как PyTest может создавать граф зависимостей фикстур.
Фабрики фикстур для динамической настройки
Иногда требуется настраивать фикстуры непосредственно в тестовой функции. Фабрики фикстур позволяют создавать объекты с нужной конфигурацией прямо в тесте:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| @pytest.fixture
def make_user():
def _make_user(name, role, active=True):
return {
"name": name,
"role": role,
"active": active,
"created_at": datetime.now()
}
return _make_user
def test_admin_permissions(make_user):
admin = make_user("admin", "administrator")
regular_user = make_user("user", "standard")
# Проверяем права доступа для разных ролей
assert check_permission(admin, "delete_user") is True
assert check_permission(regular_user, "delete_user") is False |
|
Этот паттерн особенно полезен при тестировании с множеством вариаций одного типа объекта.
Динамическое генерирование тестов
PyTest поддерживает динамическое создание тестовых случаев во время сбора тестов, что может быть полезно для автоматического генерирования однотипных тестов на основе внешних данных:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| def read_test_cases():
# Чтение тестовых случаев из файла, базы данных и т.д.
return [
("case_1", {"input": "foo", "expected": "FOO"}),
("case_2", {"input": "bar", "expected": "BAR"}),
# ...
]
def pytest_generate_tests(metafunc):
if "test_case" in metafunc.fixturenames:
test_cases = read_test_cases()
metafunc.parametrize("test_case", test_cases, ids=[t[0] for t in test_cases])
def test_string_transformation(test_case):
case_id, data = test_case
result = transform_string(data["input"])
assert result == data["expected"] |
|
Управление порядком выполнения тестов
Хотя PyTest не гарантирует фиксированный порядок выполнения тестов (и это обычно хорошо), иногда требуется определённая последовательность. Плагин pytest-ordering предоставляет маркеры для явного указания порядка:
Python | 1
2
3
4
5
6
7
8
9
10
11
| @pytest.mark.order(1)
def test_database_setup():
# Настройка базы данных
@pytest.mark.order(2)
def test_data_insertion():
# Вставка данных
@pytest.mark.order(3)
def test_data_query():
# Запрос данных |
|
Альтернативно, можно использовать хук pytest_collection_modifyitems для сортировки тестов:
Python | 1
2
3
4
| # В conftest.py
def pytest_collection_modifyitems(items):
# Сортировка тестов по имени
items.sort(key=lambda x: x.name) |
|
Группировка тестов с помощью меток и условий
Для сложных проектов полезно комбинировать несколько стратегий фильтрации, например:
Python | 1
2
3
4
5
6
7
| @pytest.mark.skipif(
sys.platform == "win32" and sys.version_info < (3, 8),
reason="Тест работает только на Windows с Python 3.8+"
)
@pytest.mark.integration
def test_windows_specific_integration():
# ... |
|
Такие тесты можно запускать с комплексными выражениями:
Bash | 1
| pytest -m "integration and not slow" |
|
Интеграция с системами управления тестами
Для крупных проектов PyTest может интегрироваться с внешними системами управления тестами через плагины. Например, плагин pytest-testrail связывает тесты с TestRail:
Python | 1
2
3
4
| @pytest.mark.testrail_id(12345)
def test_feature():
# Результаты теста будут отправлены в TestRail
pass |
|
Аналогичные плагины существуют для Jira, Zephyr и других систем.
Организация тестов по уровням
Эффективной практикой является организация тестов по уровням абстракции или скорости:
Python | 1
2
3
4
5
6
7
8
9
10
| tests/
├── fast/
│ ├── test_unit_a.py
│ └── test_unit_b.py
├── medium/
│ ├── test_integration_a.py
│ └── test_integration_b.py
└── slow/
├── test_e2e_a.py
└── test_e2e_b.py |
|
Это позволяет легко запускать только быстрые тесты во время разработки:
А полный набор тестов можно запускать на CI-сервере.
Тестирование различных комбинаций параметров
Для тестирования множества комбинаций параметров можно использовать кросс-параметризацию:
Python | 1
2
3
4
5
6
| @pytest.mark.parametrize("db_type", ["mysql", "postgresql", "sqlite"])
@pytest.mark.parametrize("auth_method", ["basic", "oauth", "token"])
@pytest.mark.parametrize("data_format", ["json", "xml"])
def test_api_integration(db_type, auth_method, data_format):
# Этот тест будет запущен для всех комбинаций параметров (27 запусков)
pass |
|
Такой подход позволяет протестировать множество сценариев без дублирования кода.
Обработка асинхронного кода в PyTest: особенности и решения
Асинхронное программирование стало неотъемлемой частью экосистемы Python, особенно с внедрением синтаксиса async/await в Python 3.5. Но с новыми возможностями пришли и новые вызовы — как правильно тестировать асинхронный код? Ведь стандартные подходы к тестированию здесь не всегда работают. Основная проблема тестирования асинхронного кода — необходимость запускать и ожидать выполнения корутин в цикле событий. Обычные функции тестов не могут просто использовать await без соответствующего окружения. К счастью, в экосистеме PyTest есть специальные инструменты для решения этих задач.
Работа с плагином pytest-asyncio
Ключевой инструмент для тестирования асинхронного кода — плагин pytest-asyncio, который предоставляет необходимую инфраструктуру для запуска асинхронных тестов:
Python | 1
| pip install pytest-asyncio |
|
После установки плагина можно писать асинхронные тесты, используя маркер @pytest.mark.asyncio :
Python | 1
2
3
4
5
6
| import pytest
@pytest.mark.asyncio
async def test_async_function():
result = await some_async_function()
assert result == expected_value |
|
Плагин автоматически создаёт цикл событий для каждого теста и корректно обрабатывает асинхронные функции.
Асинхронные фикстуры
Помимо асинхронных тестов, pytest-asyncio поддерживает и асинхронные фикстуры, что является мощным инструментом для настройки тестового окружения:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| import pytest
@pytest.fixture
async def async_database_connection():
# Асинхронное создание соединения
conn = await create_async_connection()
yield conn
# Асинхронное закрытие соединения
await conn.close()
@pytest.mark.asyncio
async def test_async_query(async_database_connection):
result = await async_database_connection.execute("SELECT * FROM users")
assert len(result) > 0 |
|
Обратите внимание на важный момент: когда асинхронная фикстура используется в синхронной тестовой функции, PyTest автоматически запускает цикл событий для выполнения фикстуры, но сам тест остаётся синхронным.
Работа с несколькими циклами событий
При тестировании сложных асинхронных систем может возникнуть необходимость в использовании нескольких циклов событий. Плагин pytest-asyncio предлагает механизм для управления этим через параметры маркера:
Python | 1
2
3
4
5
6
7
8
9
| @pytest.mark.asyncio(scope="session")
async def test_with_session_loop():
# Использует один цикл событий на всю сессию
pass
@pytest.mark.asyncio(scope="function")
async def test_with_function_loop():
# Использует новый цикл событий для каждой функции
pass |
|
Это особенно полезно при работе с приложениями, которые могут изменять глобальное состояние цикла событий.
Тестирование асинхронных HTTP-клиентов и серверов
Для тестирования асинхронных веб-приложений и клиентов (например, на основе aiohttp или FastAPI) существуют специализированные инструменты:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
@pytest.fixture
async def api_client():
app = web.Application()
app.router.add_get('/api/data', handle_get_data)
server = TestServer(app)
client = TestClient(server)
await client.start_server()
yield client
await client.close()
@pytest.mark.asyncio
async def test_api_endpoint(api_client):
resp = await api_client.get('/api/data')
assert resp.status == 200
data = await resp.json()
assert 'results' in data |
|
Мокирование асинхронных функций
Мокирование асинхронных функций имеет свои особенности. Unittest.mock может использоваться для замены асинхронных функций, но требует дополнительной настройки для корректной работы с ключевым словом await :
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| import pytest
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_async_with_mock():
with patch('module.async_function', new_callable=AsyncMock) as mock_func:
mock_func.return_value = 'mocked_result'
# При вызове через await будет возвращен настроенный результат
result = await module.async_function()
assert result == 'mocked_result'
# Проверка, что функция была вызвана
mock_func.assert_called_once() |
|
Отладка асинхронных тестов
Отладка асинхронных тестов может быть сложнее, чем синхронных, из-за неявного потока выполнения. Для этого можно использовать расширенные инструменты трассировки:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| import logging
import asyncio
@pytest.mark.asyncio
async def test_with_debug():
# Включаем отладочный режим для asyncio
logging.getLogger("asyncio").setLevel(logging.DEBUG)
# Задаём имя текущей задачи для лучшей диагностики
asyncio.current_task().set_name("my_test_task")
# Вывод всех запущенных задач
for task in asyncio.all_tasks():
print(f"Running task: {task.get_name()}")
result = await async_function_under_test()
assert result == expected |
|
Типичные проблемы и решения
1. Незавершенные задачи: Если тест создаёт задачи, но не дожидается их завершения, могут возникать предупреждения или непредсказуемое поведение. Решение — всегда использовать await для задач или явно отменять их:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| @pytest.mark.asyncio
async def test_task_cleanup():
# Неправильно: задача остаётся висеть
asyncio.create_task(background_job())
# Правильно: явно дожидаемся завершения
task = asyncio.create_task(background_job())
try:
# Тестовый код
result = await some_function()
assert result == expected
finally:
# Отменяем задачу, если она ещё выполняется
if not task.done():
task.cancel()
# Ждём завершения, игнорируя отмену
try:
await task
except asyncio.CancelledError:
pass |
|
2. Эффект гонки (race condition): Асинхронные тесты могут страдать от недетерминированного порядка выполнения. Для предсказуемых результатов используйте синхронизацию:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| @pytest.mark.asyncio
async def test_with_sync_point():
event = asyncio.Event()
async def controlled_task():
# Сигнализируем о достижении точки синхронизации
event.set()
# Другие операции...
task = asyncio.create_task(controlled_task())
# Ждём достижения точки синхронизации
await event.wait()
# Теперь можно выполнять проверки
assert system_state == expected_state
await task # Дожидаемся завершения задачи |
|
Тестирование асинхронного кода требует особого внимания к деталям и понимания работы цикла событий Python. Использование специализированных инструментов, таких как pytest-asyncio, значительно упрощает эту задачу, позволяя сосредоточиться на логике тестов, а не на особенностях асинхронного выполнения.
Использование Mock-объектов
Тестирование изолированных частей кода — важнейший аспект разработки надёжного программного обеспечения. Но что делать, если тестируемый код зависит от внешних сервисов, медленных баз данных или нестабильных API? Здесь на помощь приходят mock-объекты — специальные заглушки, имитирующие поведение реальных компонентов системы.
Принципы мокирования в Python
В основе мокирования лежит простая идея: заменить реальный объект его имитацией с предсказуемым поведением. Это позволяет контролировать взаимодействия между компонентами системы и тестировать код в изоляции. В стандартной библиотеке Python для этих целей предусмотрен модуль unittest.mock .
Python | 1
2
3
4
5
6
7
8
9
10
11
12
| from unittest.mock import Mock
# Создаём простой мок-объект
database = Mock()
database.get_user.return_value = {"id": 1, "name": "Alice"}
# Теперь вызов метода возвращает заданное значение
user = database.get_user(1)
assert user["name"] == "Alice"
# Проверяем, что метод был вызван с нужными аргументами
database.get_user.assert_called_with(1) |
|
Mock и его более функциональный собрат MagicMock — универсальные классы, создающие объекты, которые записывают историю взаимодействий с ними и могут быть настроены на возврат специфических значений или вызов определённых исключений.
Патчинг функций и методов
Чтобы заменить реальный объект мок-объектом в тестируемом коде, используется механизм патчинга. Функция patch() из unittest.mock возвращает контекстный менеджер, который временно заменяет указанный объект и автоматически восстанавливает его после завершения блока:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| from unittest.mock import patch
def test_weather_service():
# Заменяем реальный метод API на мок
with patch('weather_app.get_current_temperature') as mock_get_temp:
# Настраиваем мок на возврат конкретного значения
mock_get_temp.return_value = 25.5
# Код, который использует этот API
from weather_app import WeatherService
service = WeatherService()
report = service.generate_report('Moscow')
# Проверяем результаты
assert "25.5°C" in report
mock_get_temp.assert_called_once_with('Moscow') |
|
`patch()` можно использовать и как декоратор функции или метода:
Python | 1
2
3
4
| @patch('weather_app.get_current_temperature')
def test_weather_service_decorated(mock_get_temp):
mock_get_temp.return_value = 25.5
# ...остальной код теста... |
|
При патчинге методов класса важно правильно указывать путь для патчинга. Патчить нужно точку, где объект *используется*, а не где он определен:
Python | 1
2
3
4
5
| # Неверно! Патчинг места определения метода
@patch('models.User.get_full_name')
# Верно! Патчинг места использования метода
@patch('app.views.User.get_full_name') |
|
Типичные ошибки при работе с моками
При использовании моков легко допустить ошибки, которые могут привести к ложному чувству безопасности или сложно диагностируемым проблемам:
1. Излишнее мокирование. Чрезмерное использование моков приводит к тестам, которые проверяют только то, как ваш код вызывает другие функции, а не что он действительно работает правильно.
Python | 1
2
3
4
5
6
| # Слишком много моков — тест проверяет реализацию, а не поведение
@patch('module.function1')
@patch('module.function2')
@patch('module.function3')
def test_over_mocked(mock3, mock2, mock1):
# ...тест, который по сути проверяет только последовательность вызовов |
|
2. Неправильный порядок аргументов в декораторах. При использовании нескольких @patch декораторов, моки передаются в тестовую функцию в обратном порядке (снизу вверх).
3. Отсутствие сброса моков. Если мок используется в нескольких тестах, не забывайте сбрасывать его состояние с помощью mock.reset_mock() .
4. Мокирование не того объекта. Особенно часто это случается при работе с импортированными объектами.
Стратегии мокирования внешних API и баз данных
Мокирование внешних зависимостей требует особого подхода. Вот несколько распространённых стратегий:
1. Уровень абстракции. Создавайте абстрактные интерфейсы для взаимодействия с внешними системами, а затем мокируйте эти интерфейсы:
Python | 1
2
3
4
5
6
7
| class DatabaseInterface:
def get_user(self, user_id):
pass # Реальная реализация взаимодействует с БД
class MockDatabase(DatabaseInterface):
def get_user(self, user_id):
return {"id": user_id, "name": "Test User"} |
|
2. Использование фабрик. Создавайте фабрики, которые возвращают соответствующие реализации в зависимости от контекста:
Python | 1
2
3
4
5
| def get_database(testing=False):
if testing:
return MockDatabase()
else:
return RealDatabase() |
|
3. Моделирование ошибок и задержек. При мокировании внешних API полезно эмулировать сетевые ошибки и задержки:
Python | 1
2
3
4
5
6
7
8
| def test_api_timeout():
with patch('requests.get') as mock_get:
mock_get.side_effect = requests.Timeout("Connection timed out")
# Проверяем корректную обработку таймаута
response = client.fetch_data()
assert response["status"] == "error"
assert "timeout" in response["message"] |
|
Использование spy-объектов для проверки взаимодействий
Иногда требуется не полностью заменить объект, а лишь отслеживать взаимодействия с ним. Для этого используются spy-объекты:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
| from unittest.mock import MagicMock
# Создаём реальный объект
real_calculator = Calculator()
# Создаём spy, делегирующий вызовы реальному объекту
spy_calculator = MagicMock(wraps=real_calculator)
# Вызов метода через spy приведёт к вызову реального метода,
# но при этом информация о вызове будет сохранена
result = spy_calculator.add(2, 3)
assert result == 5 # Реальное вычисление
spy_calculator.add.assert_called_with(2, 3) # Проверка аргументов |
|
Этот подход особенно полезен, когда нужно сохранить реальное поведение объекта, но при этом проверить, как именно с ним взаимодействуют.
Уровни мокирования и их применение
Существует несколько уровней глубины мокирования, каждый со своим назначением:
1. Мок метод/функцию: когда нужно контролировать конкретный вызов
Python | 1
2
| with patch('module.function') as mock_func:
mock_func.return_value = 'mocked value' |
|
2. Мок класс: когда нужно контролировать создание экземпляров
Python | 1
2
| with patch('module.SomeClass') as MockClass:
MockClass.return_value.method.return_value = 'mocked result' |
|
3. Мок модуль: когда нужно контролировать целый набор функций и классов
Python | 1
2
| with patch.dict('sys.modules', {'expensive_module': Mock()}):
# Любые импорты expensive_module вернут мок |
|
Выбор правильного уровня мокирования существенно влияет на эффективность и читаемость тестов. Важно стремиться к минимально необходимому уровню мокирования, который решает конкретную задачу изоляции тестируемого кода.
Альтернативы стандартной библиотеке unittest.mock: сравнительный анализ
Хотя unittest.mock является частью стандартной библиотеки Python и предлагает богатую функциональность, существует ряд альтернативных решений для мокирования, которые могут лучше подходить для конкретных задач или предпочтений разработчиков. Рассмотрим основные альтернативы и их особенности.
pytest-mock: удобная интеграция с PyTest
Плагин pytest-mock предоставляет фикстуру mocker , которая упрощает использование возможностей unittest.mock в тестах PyTest:
Python | 1
2
3
4
5
6
7
| def test_with_pytest_mock(mocker):
# Более удобный синтаксис, чем standard unittest.mock
mock_function = mocker.patch('module.function')
mock_function.return_value = 42
assert module.function() == 42
mock_function.assert_called_once() |
|
Основные преимущества pytest-mock :- Автоматическая отмена патчей после теста.
- Удобный доступ ко всем возможностям стандартного
unittest.mock .
- Дополнительный метод
spy , упрощающий создание шпионов.
- Улучшенные сообщения об ошибках, интегрированные со стилем отчётов PyTest.
flexmock: более выразительный синтаксис
Библиотека flexmock предлагает альтернативный, более декларативный подход к мокированию:
Python | 1
2
3
4
5
6
7
8
| from flexmock import flexmock
def test_with_flexmock():
# Создание мока с цепочкой методов
flexmock(module).should_receive('function').and_return(42).once()
assert module.function() == 42
# Проверка автоматически выполняется при завершении теста |
|
Особенности flexmock :- Более лаконичный синтаксис для сложных сценариев мокирования.
- Встроенная верификация ожиданий без явных проверок.
- Поддержка частичного мокирования объектов.
- Интеграция с различными тестовыми фреймворками.
doublex: изящное мокирования с использованием контекстных менеджеров
doublex — это библиотека, вдохновлённая Java-фреймворком jMock, которая использует контекстные менеджеры для определения ожидаемого поведения:
Python | 1
2
3
4
5
6
7
8
9
| from doublex import Spy, assert_that, called
def test_with_doublex():
with Spy(SomeClass) as spy:
spy.method(ANY_ARG).returns(42)
result = spy.method('test')
assert result == 42
assert_that(spy.method, called().with_args('test')) |
|
doublex привлекателен для разработчиков, предпочитающих декларативный стиль определения поведения моков.
responses: специализированное решение для HTTP-запросов
Когда речь идёт о мокировании HTTP-запросов, библиотека responses предлагает более специализированный API, чем общие инструменты мокирования:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| import responses
import requests
@responses.activate
def test_api_call():
responses.add(
responses.GET,
'https://api.example.com/data',
json={'key': 'value'},
status=200
)
resp = requests.get('https://api.example.com/data')
assert resp.json() == {'key': 'value'}
assert len(responses.calls) == 1 |
|
Преимущества специализированных библиотек:
API, заточенный под конкретный тип взаимодействий
Более глубокая интеграция с мокируемыми службами
Дополнительные возможности отладки и проверки
Сравнительный анализ библиотек мокирования
Code | 1
2
3
4
5
6
7
| | Библиотека | Синтаксическая краткость | Интеграция с PyTest | Специфические возможности | Поддержка сообщества |
|------------|--------------------------|---------------------|---------------------------|----------------------|
| unittest.mock | Средняя | Через pytest-mock | Универсальность | Очень высокая (стандартная библиотека) |
| pytest-mock | Высокая | Нативная | Автоочистка патчей | Высокая |
| flexmock | Очень высокая | Хорошая | Декларативный стиль | Средняя |
| doublex | Средняя | Средняя | BDD-подобный синтаксис | Низкая |
| responses | Высокая для HTTP | Хорошая | Специфичные для HTTP | Высокая для своей ниши | |
|
Когда выбирать альтернативы unittest.mock?
1. При работе с PyTest: pytest-mock обеспечивает более плавную интеграцию и синтаксический сахар.
2. Для HTTP-взаимодействий: специализированные библиотеки как responses или httpretty предлагают более удобные API.
3. При предпочтении декларативного стиля: flexmock может предоставить более читаемый код для сложных сценариев.
4. Для специфических требований: некоторые библиотеки предлагают уникальные возможности — например, VCR.py позволяет записывать и воспроизводить реальные HTTP-взаимодействия.
Выбор инструмента мокирования часто сводится к личным предпочтениям команды и специфическим требованиям проекта. Для большинства случаев стандартная библиотека unittest.mock или её обёртка pytest-mock предоставляют достаточную функциональность, но знание альтернатив расширяет арсенал разработчика тестов и позволяет выбрать наиболее подходящий инструмент для конкретной задачи.
Мокирование объектно-реляционных моделей в Django и SQLAlchemy
Работа с базами данных — одна из самых распространённых задач в веб-разработке на Python. Django и SQLAlchemy предоставляют мощные ORM-системы, которые упрощают взаимодействие с БД. Однако в контексте тестирования прямое взаимодействие с базой может создавать ряд проблем: тесты становятся медленными, менее изолированными и более сложными в настройке. Мокирование ORM-моделей решает эти проблемы, позволяя имитировать взаимодействие с базой данных без фактического выполнения запросов.
Стратегии мокирования в Django
Django имеет встроенную систему для тестирования с использованием тестовой БД, но иногда требуется более глубокая изоляция:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| from unittest.mock import patch, MagicMock
from myapp.models import User
def test_user_profile_view():
# Создаём мок-объект модели
mock_user = MagicMock(spec=User)
mock_user.username = "testuser"
mock_user.email = "test@example.com"
# Патчим метод objects.get()
with patch("myapp.models.User.objects.get") as mock_get:
mock_get.return_value = mock_user
# Тестируем представление
response = client.get("/user/profile/testuser/")
assert response.status_code == 200
assert "testuser" in str(response.content) |
|
Для мокирования QuerySet в Django необходимо учитывать его ленивую природу и цепочечные вызовы:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| def test_active_users_list():
# Создаём список мок-пользователей
mock_users = [
MagicMock(spec=User, username="user1", is_active=True),
MagicMock(spec=User, username="user2", is_active=True)
]
# Мокируем QuerySet с методами фильтрации
with patch("myapp.models.User.objects.filter") as mock_filter:
# Настраиваем результат фильтрации
mock_filter.return_value = mock_users
# Добавляем типичные методы QuerySet
mock_filter.return_value.count = lambda: len(mock_users)
mock_filter.return_value.exists = lambda: bool(mock_users)
from myapp.views import get_active_users
result = get_active_users()
assert len(result) == 2 |
|
Особенности мокирования в SQLAlchemy
SQLAlchemy имеет другую архитектуру, с более явным разделением сессий и запросов:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| from unittest.mock import patch, MagicMock
from sqlalchemy.orm import Session
from myapp.models import User
from myapp.database import db_session
def test_find_user_service():
# Создаём мок-пользователя
mock_user = MagicMock()
mock_user.id = 1
mock_user.username = "sqlalchemy_user"
# Патчим метод query сессии
with patch.object(db_session, "query") as mock_query:
# Настраиваем цепочку вызовов
mock_query.return_value.filter.return_value.first.return_value = mock_user
# Тестируем сервис
from myapp.services import UserService
service = UserService()
user = service.find_by_username("sqlalchemy_user")
assert user.username == "sqlalchemy_user"
mock_query.assert_called_once_with(User) |
|
Для случаев с транзакциями часто нужно мокировать весь объект сессии:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
| def test_create_user_transaction():
# Создаём мок всей сессии
mock_session = MagicMock(spec=Session)
with patch("myapp.database.db_session", mock_session):
from myapp.services import UserCreationService
service = UserCreationService()
service.create_user("new_user", "password123")
# Проверяем вызовы методов сессии
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once() |
|
Сложные случаи и их решения
Мокирование связанных объектов в Django
Когда требуется имитировать связанные объекты через ForeignKey или ManyToMany:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| def test_order_with_items():
# Мокируем заказ со связанными товарами
mock_order = MagicMock(spec=Order)
mock_order.id = 101
mock_order.user.username = "customer"
# Мокируем связанные объекты через ManyToMany
mock_items = [
MagicMock(spec=OrderItem, product__name="Laptop", quantity=1),
MagicMock(spec=OrderItem, product__name="Mouse", quantity=2)
]
mock_order.items.all.return_value = mock_items
with patch("myapp.models.Order.objects.get") as mock_get:
mock_get.return_value = mock_order
# Тестируем представление с доступом к связанным объектам
response = client.get("/orders/101/")
assert "Laptop" in str(response.content)
assert "Mouse" in str(response.content) |
|
Мокирование JOIN-запросов в SQLAlchemy
SQLAlchemy позволяет создавать сложные JOIN-запросы, мокирование которых требует особого подхода:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| def test_orders_by_user_join():
# Мокируем результат JOIN-запроса
mock_results = [
(MagicMock(spec=User, username="customer"),
MagicMock(spec=Order, id=1, total=199.99))
]
with patch.object(db_session, "query") as mock_query:
# Настраиваем цепочку с JOIN
join_chain = mock_query.return_value.join.return_value
filter_chain = join_chain.filter.return_value
filter_chain.all.return_value = mock_results
# Тестируем сервис с JOIN-запросом
from myapp.services import ReportService
service = ReportService()
report = service.get_user_orders_report("customer")
assert len(report) == 1
assert report[0][1].total == 199.99 |
|
Лучшие практики
1. Используйте spec: Параметр spec в MagicMock обеспечивает типобезопасность и предотвращает вызовы несуществующих методов.
2. Точечное мокирование: Патчите только то, что необходимо, избегая чрезмерного мокирования.
3. Сохраняйте баланс: Комбинируйте юнит-тесты с моками и интеграционные тесты с реальной БД для полного покрытия.
4. Учитывайте особенности ORM: Помните о ленивой загрузке в Django и сессионной модели SQLAlchemy при создании моков.
Мокирование ORM-моделей — мощный инструмент, который при правильном применении позволяет создавать быстрые, надёжные и изолированные тесты для кода, взаимодействующего с базами данных.
TDD на практике
Test-Driven Development (TDD) — это методология разработки программного обеспечения, которая переворачивает традиционный подход "сначала код, потом тесты" с ног на голову. TDD предлагает начинать не с реализации функциональности, а с написания тестов для неё.
Философия "сначала тесты"
В основе TDD лежит простая, но революционная идея: тесты должны выступать не только инструментом проверки, но и спецификацией требований. Когда разработчик начинает с тестов, он вынужден ясно представлять ожидаемое поведение системы ещё до написания хотя бы строчки кода. Такой подход даёт несколько важных преимуществ:- Улучшение дизайна кода, так как тесты естественным образом подталкивают к более модульной архитектуре.
- Снижение дефектов, поскольку ошибки обнаруживаются раньше.
- Создание актуальной документации в виде тестов, которая не устаревает.
- Большая уверенность при рефакторинге и изменении кода.
Цикл "красный-зелёный-рефакторинг"
Работа в TDD строится вокруг итеративного цикла из трёх фаз:
1. Красный: Написание неработающего теста. Тест должен быть минимальным и чётко выражать одно ожидаемое поведение. На этом этапе тест, естественно, проваливается, так как реализации ещё нет.
Python | 1
2
3
4
| def test_calculator_add():
calculator = Calculator()
result = calculator.add(2, 3)
assert result == 5 |
|
2. Зелёный: Написание минимально достаточного кода для прохождения теста. Здесь ключевое слово — "минимальный". Не нужно усложнять, думать о будущих требованиях или красоте кода. Главное — заставить тест пройти.
Python | 1
2
3
| class Calculator:
def add(self, a, b):
return a + b # Минимальная реализация! |
|
3. Рефакторинг: Улучшение кода при сохранении его функциональности. Тесты должны по-прежнему проходить, что даёт уверенность в безопасности изменений.
Этот цикл повторяется для каждой новой возможности, постепенно наращивая функциональность продукта.
Практический пример TDD в Python
Рассмотрим пример разработки простого класса для работы с текстом с использованием TDD:
Python | 1
2
3
4
5
6
| # Шаг 1: Пишем тест (Красный)
def test_word_counter_empty_string():
counter = WordCounter("")
assert counter.count() == 0
# Запуск: тест проваливается, класса WordCounter ещё нет |
|
Создаём минимальную реализацию для прохождения теста:
Python | 1
2
3
4
5
6
7
8
9
| # Шаг 2: Минимальная реализация (Зелёный)
class WordCounter:
def __init__(self, text):
self.text = text
def count(self):
return 0
# Запуск: тест проходит! |
|
Добавляем ещё один тест:
Python | 1
2
3
4
| # Шаг 3: Расширяем тестирование (Красный)
def test_word_counter_single_word():
counter = WordCounter("Python")
assert counter.count() == 1 |
|
Обновляем реализацию:
Python | 1
2
3
4
5
6
7
8
9
| # Шаг 4: Обновляем реализацию (Зелёный)
class WordCounter:
def __init__(self, text):
self.text = text
def count(self):
if not self.text:
return 0
return 1 |
|
И ещё один тест:
Python | 1
2
3
4
| # Шаг 5: Добавляем более сложный случай (Красный)
def test_word_counter_multiple_words():
counter = WordCounter("Python is awesome")
assert counter.count() == 3 |
|
Реализация для прохождения всех тестов:
Python | 1
2
3
4
5
6
7
8
9
10
11
| # Шаг 6: Окончательная реализация (Зелёный)
class WordCounter:
def __init__(self, text):
self.text = text
def count(self):
if not self.text:
return 0
return len(self.text.split())
# Шаг 7: Рефакторинг (не требуется в данном простом примере) |
|
Рефакторинг с опорой на тесты
Настоящая сила TDD проявляется при рефакторинге. Имея полное покрытие тестами, можно смело менять внутреннюю структуру кода, будучи уверенным, что его внешнее поведение остаётся прежним:
Python | 1
2
3
4
5
6
7
8
9
10
| # Рефакторинг: оптимизируем хранение текста
class WordCounter:
def __init__(self, text):
self._words = text.split() if text else []
def count(self):
return len(self._words)
def get_word_frequency(self, word):
return self._words.count(word) |
|
Добавив новый метод, мы должны сразу написать для него тесты:
Python | 1
2
3
4
5
| def test_word_frequency():
counter = WordCounter("test test example")
assert counter.get_word_frequency("test") == 2
assert counter.get_word_frequency("example") == 1
assert counter.get_word_frequency("missing") == 0 |
|
Техника Outside-In TDD
Outside-In TDD (или лондонская школа TDD) предлагает начинать разработку с внешних интерфейсов и постепенно двигаться к внутренним компонентам, используя моки для имитации ещё не реализованных частей:
Python | 1
2
3
4
5
6
7
| def test_document_analyzer():
# Начинаем с теста высокого уровня
analyzer = DocumentAnalyzer("sample.txt")
report = analyzer.generate_report()
assert report["total_words"] > 0
assert "most_common_word" in report |
|
Затем мокируем зависимости:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| def test_document_analyzer_with_mocks(mocker):
# Мокируем зависимость
mock_counter = mocker.Mock()
mock_counter.count.return_value = 100
mock_counter.most_common.return_value = ("the", 15)
# Внедряем мок
mocker.patch("document_analyzer.WordCounter", return_value=mock_counter)
analyzer = DocumentAnalyzer("sample.txt")
report = analyzer.generate_report()
assert report["total_words"] == 100
assert report["most_common_word"] == "the" |
|
Этот подход особенно эффективен для больших систем, где разработка ведётся сверху вниз, от пользовательских сценариев к деталям реализации.
TDD — это целостная философия разработки, которая меняет мышление и подход к созданию программ. Даже если вы не следуете TDD в чистом виде, понимание его принципов и применение их в подходящих ситуациях может существенно повысить качество кода и уменьшить количество дефектов.
История появления и эволюция TDD в разработке ПО
История TDD начинается задолго до того, как этот термин был официально введён в оборот. Несмотря на то, что Test-Driven Development кажется современной методологией, его корни уходят глубоко в прошлое программирования.
В 1960-х годах в проекте NASA «Меркурий» уже использовались приёмы, напоминающие TDD. Инженеры сначала писали точные спецификации ожидаемого поведения программ, а затем создавали код, реализующий эти спецификации. Разумеется, формализованной методологии тогда не существовало, но сам принцип «сначала думай, потом пиши» уже применялся. Настоящий прорыв произошёл в конце 1990-х, когда Кент Бек начал формулировать концепцию Extreme Programming (XP). В рамках этой методологии Бек описал практику «тест-фёрст», которая позже эволюционировала в то, что мы сегодня называем TDD. В своей знаменитой книге "Extreme Programming Explained" (1999) Бек впервые представил широкой публике идею о том, что тесты должны писаться до кода. В 2000 году Бек развил эти идеи в книге "Test-Driven Development by Example", где детально изложил принципы и практики TDD, включая знаменитый цикл «красный-зелёный-рефакторинг». Эта работа считается фундаментальной для современного понимания TDD.
Интересно, что сам Бек не претендовал на абсолютное авторство идеи. Он признавал, что многие элементы TDD были известны и раньше. Например, принцип небольших итераций и постоянной проверки система разрабатывалась ещё в 1970-х годах Томом Гилбом в его методологии Evolutionary Delivery.
К середине 2000-х годов TDD получил широкое распространие, чему способствовал общий интерес к гибким методологиям разработки. Появился целый ряд фреймворков модульного тестирования, таких как JUnit для Java, созданный Беком совместно с Эрихом Гаммой. Показательна история принятия TDD в Microsoft — компании, традиционно ориентированной на более формальные процессы. В 2005 году Microsoft начала эксперименты с TDD, и к 2008 году эта практика стала частью официальных рекомендаций для команд разработки. Этот пример иллюстрирует, как методология из мира экстремального программирования проникла даже в корпоративную культуру.
Параллельно с распространением TDD, методология начала естественным образом эволюционировать. Появились различные «школы» и интерпретации:
1. Классическая (Детройтская) школа TDD — фокусируется на тестировании реального поведения объектов и минимальном использовании моков.
2. Лондонская школа TDD (или «мокистская») — активно использует мокирование и двойников объектов, начиная с тестирования взаимодействий между компонентами.
3. ATDD (Acceptance Test-Driven Development) — расширение TDD на уровень приёмочных тестов, часто с использованием сценариев поведения в формате Gherkin.
К 2010 году сформировалось критическое отношение к TDD. Некоторые известные разработчики, включая создателя Ruby on Rails Дэвида Хайнемайера Хэнссона, высказали сомнения в универсальной эффективности методологии. В 2014 году Хэнссон даже выступил с провокационной речью "TDD is Dead", что вызвало жаркие дебаты в сообществе. Эта критика привела не к отказу от TDD, а к его более зрелому пониманию. Сегодня мало кто рассматривает TDD как серебряную пулю или догматический подход, применимый во всех ситуациях. Вместо этого TDD воспринимается как мощный инструмент, который следует использовать осмысленно, с учётом контекста разработки.
В последнее десятилетие наблюдается интеграция TDD с другими практиками разработки, такими как DevOps и непрерывная интеграция. Автоматизированые тесты, написанные в стиле TDD, стали неотъемлемой частью пайплайнов CI/CD, обеспечивая раннее обнаружение регрессий. Современная трактовка TDD зачастую менее догматична, чем изначально предложенная Беком. Многие команды адаптируют TDD под свои нужды, сохраняя ключевую идею — тесты должны направлять процесс разработки, а не просто проверять работу уже написанного кода.
Комбинирование TDD с методологией BDD для улучшения качества тестов
Behavior-Driven Development (BDD) — это методология, которая расширяет концепцию TDD, смещая фокус с тестирования технической реализации на проверку соответствия бизнес-требованиям. BDD устраняет разрыв между техническим и бизнес-языком, создавая спецификации, понятные всем заинтересованным сторонам. В отличие от классического TDD, где тесты пишутся на языке программирования, BDD-спецификации часто создаются на естественном языке (обычно с использованием формата Gherkin) с конструкциями "Given-When-Then" (Дано-Когда-Тогда):
Python | 1
2
3
4
5
6
| Feature: Аутентификация пользователя
Scenario: Успешный вход в систему
Given пользователь находится на странице входа
When он вводит правильное имя пользователя "user" и пароль "password"
Then система перенаправляет его на главную страницу
And отображает приветственное сообщение |
|
Комбинирование BDD и TDD создаёт многоуровневый подход к обеспечению качества:
1. BDD на верхнем уровне определяет пользовательские сценарии и бизнес-требования.
2. TDD на нижнем уровне обеспечивает правильную техническую реализацию каждого компонента.
Практическая интеграция BDD и TDD
В контексте Python, инструменты pytest-bdd и behave позволяют реализовать BDD-подход, интегрируя его с экосистемой PyTest:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| # Шаг 1: BDD-спецификация в features/login.feature
# Feature: Аутентификация пользователя
# Scenario: Успешный вход в систему
# Given пользователь находится на странице входа
# When он вводит правильное имя пользователя "user" и пароль "password"
[H2] Then система перенаправляет его на главную страницу[/H2]
# Шаг 2: Реализация шагов BDD с использованием pytest-bdd
from pytest_bdd import scenarios, given, when, then
from app.authentication import AuthService
scenarios('features/login.feature')
@given('пользователь находится на странице входа')
def login_page():
return AuthService()
@when('он вводит правильное имя пользователя "user" и пароль "password"')
def enter_credentials(login_page):
return login_page.login("user", "password")
@then('система перенаправляет его на главную страницу')
def check_redirection(login_page):
assert login_page.current_page == "home" |
|
Параллельно с этим разработчик использует TDD для создания внутренних компонентов:
Python | 1
2
3
4
5
6
7
8
9
10
11
| # Шаг 3: TDD для внутренней реализации AuthService
def test_auth_service_validates_credentials():
service = AuthService()
assert service.validate_credentials("user", "password") is True
assert service.validate_credentials("user", "wrong") is False
def test_auth_service_creates_session_on_login():
service = AuthService()
result = service.login("user", "password")
assert result.session_id is not None
assert result.user_id == service.get_user_id("user") |
|
Преимущества объединения подходов
1. Полное покрытие требований: BDD гарантирует соответствие бизнес-требованиям, а TDD обеспечивает корректность технической реализации.
2. Улучшенная коммуникация: BDD-спецификации понятны всем участникам проекта, включая не-технических специалистов.
3. Двойная документация: BDD-сценарии документируют пользовательские истории, а TDD-тесты — внутреннее устройство системы.
4. Разделение уровней тестирования: BDD фокусируется на функциональном, а TDD — на модульном уровне, что создаёт многослойную стратегию обеспечения качества.
В Python эти подходы особенно хорошо сочетаются благодаря гибкости тестовых фреймворков. Например, можно использовать pytest-bdd для реализации BDD-части и стандартный PyTest для TDD:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
| # conftest.py: Интеграция BDD и TDD
import pytest
from pytest_bdd import given
@pytest.fixture
def auth_service():
from app.authentication import AuthService
return AuthService()
@given('пользователь прошел аутентификацию', target_fixture='authenticated_user')
def authenticated_user(auth_service):
return auth_service.login("user", "password") |
|
Такая интеграция позволяет повторно использовать фикстуры и инфраструктуру тестов между BDD и TDD частями, что уменьшает дублирование кода и упрощает поддержку тестов.
BDD и TDD не противоречат, а дополняют друг друга, создавая более целостный подход к разработке и тестированию. Комбинируя эти методологии, команды могут обеспечить как соответствие бизнес-требованиям, так и техническое качество своих решений.
Интеграция с CI/CD
Автоматизированное тестирование становится по-настоящему эффективным, когда оно интегрировано в процесс непрерывной интеграции и доставки (CI/CD). Такая интеграция позволяет обнаруживать проблемы на ранних этапах и гарантировать, что каждое изменение кода проходит полный набор тестов до его внедрения в рабочую среду.
Автоматизация запуска тестов
Основное преимущество CI/CD — автоматическое выполнение тестов при каждом изменении кода. Большинство современных CI-платформ (GitHub Actions, GitLab CI, Jenkins, Circle CI) легко интегрируются с PyTest:
YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| # .github/workflows/tests.yml для GitHub Actions
name: Run Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov
- name: Test with pytest
run: |
pytest --cov=myapp |
|
Такая конфигурация запускает тесты автоматически при каждом пуше в основные ветки или создании pull request. Вы можете настроить различные триггеры: по расписанию, при изменении конкретных файлов или тегировании релиза.
Анализ покрытия кода
Измерение покрытия кода тестами — важный метрический показатель качества. Плагин pytest-cov интегрирует инструмент coverage.py с PyTest и генерирует детальные отчёты:
Bash | 1
| pytest --cov=myapp --cov-report=xml --cov-report=html |
|
В CI-пайплайнах полезно настраивать пороговые значения покрытия, при недостижении которых сборка будет считаться неуспешной:
YAML | 1
2
3
| name: Test with coverage check
run: |
pytest --cov=myapp --cov-fail-under=85 |
|
Многие CI-системы имеют встроенную поддержку для визуализации отчётов о покрытии. Например, в GitHub можно использовать action codecov/codecov-action для загрузки и отображения подробной статистики:
YAML | 1
2
3
4
| name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml |
|
Стратегии для параллельного выполнения тестов
С ростом количества тестов время их выполнения может стать узким местом в CI/CD-пайплайне. Решением является параллельное выполнение тестов, для чего отлично подходит плагин pytest-xdist:
YAML | 1
2
3
| name: Run tests in parallel
run: |
pytest -n auto --dist=loadfile |
|
Опция -n auto автоматически определяет оптимальное количество процессов на основе доступных CPU, а --dist=loadfile распределяет тесты по файлам между процессами.
Для более сложных сценариев можно использовать стратегию разделения тестов на группы (sharding):
YAML | 1
2
3
4
5
6
7
8
9
10
| jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
# ...
- name: Run tests (sharded)
run: |
pytest --splits 4 --group ${{ matrix.shard }} |
|
Этот подход требует дополнительного плагина pytest-split или ручного разделения тестов на группы.
Матричное тестирование на разных версиях Python
Одно из главных преимуществ CI — возможность тестировать код на различных версиях Python и окружениях. Матричное тестирование позволяет одновременно запускать тесты на нескольких конфигурациях:
YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| jobs:
test:
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11']
os: [ubuntu-latest, windows-latest, macOS-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
# Остальные шаги... |
|
Для локального тестирования на разных версиях Python перед отправкой в CI можно использовать инструмент tox :
Python | 1
2
3
4
5
6
7
8
| # tox.ini
[tox]
envlist = py38,py39,py310,py311
isolated_build = True
[testenv]
deps = pytest
commands = pytest {posargs:tests} |
|
Запуск tox локально проверит совместимость кода со всеми указанными версиями Python, что экономит время ожидания результатов в CI.
Кеширование зависимостей для ускорения CI
Установка зависимостей Python может занимать значительное время в CI. Кеширование уже установленных пакетов существенно ускоряет этот процесс:
YAML | 1
2
3
4
5
6
7
| name: Cache pip dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip- |
|
Артефакты тестирования
Сохранение результатов тестов и отчётов о покрытии в виде артефактов CI позволяет анализировать их даже после завершения процесса:
YAML | 1
2
3
4
5
6
7
8
9
10
11
| name: Generate test reports
run: |
pytest --junitxml=junit/test-results.xml --cov=myapp --cov-report=xml
name: Upload test results
uses: actions/upload-artifact@v3
with:
name: test-reports
path: |
junit/test-results.xml
coverage.xml |
|
Интеграция тестирования в CI/CD — не просто техническая деталь, а фундаментальное изменение культуры разработки. Автоматическое выполнение тестов на каждый коммит обеспечивает непрерывную обратную связь о качестве кода, что позволяет командам быстрее итерировать и с большей уверенностью выпускать обновления.
Полноценная интеграция тестов в CI/CD требует времени для настройки, но многократно окупается через снижение количества ошибок, попадающих в production, и уменьшение трудозатрат на ручное тестирование. Современные инструменты делают эту интеграцию доступной даже для небольших проектов и индивидуальных разработчиков.
Настройка оптимального пайплайна тестирования в GitHub Actions и GitLab CI
Создание эффективного пайплайна тестирования в современных CI/CD системах требует детального понимания особенностей каждой платформы. GitHub Actions и GitLab CI представляют собой мощные инструменты, которые при правильной настройке могут значительно ускорить процесс тестирования и повысить его надёжность.
Базовая настройка в GitHub Actions
GitHub Actions использует файлы YAML, расположенные в директории .github/workflows/ . Вот пример базовой конфигурации для Python-проекта:
YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| name: Python Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov
- name: Run tests
run: pytest --cov=myapp |
|
Эта конфигурация запускает тесты при каждом пуше в основные ветки или при создании pull request.
Оптимизация в GitLab CI
GitLab CI использует файл .gitlab-ci.yml в корне проекта. Вот эквивалентная конфигурация:
YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| stages:
- test
python-test:
stage: test
image: python:3.10
script:
- pip install -r requirements.txt
- pip install pytest pytest-cov
- pytest --cov=myapp
only:
- main
- develop
- merge_requests |
|
Ускорение пайплайнов с помощью кеширования
Кеширование зависимостей существенно сокращает время выполнения пайплайна. В GitHub Actions это делается так:
YAML | 1
2
3
4
5
6
7
| name: Cache pip packages
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip- |
|
В GitLab CI кеширование настраивается следующим образом:
YAML | 1
2
3
4
5
6
7
8
| python-test:
# ...
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .pip-cache/
before_script:
- pip install --cache-dir=.pip-cache -r requirements.txt |
|
Параллельное выполнение тестов
Для ускорения процесса тестирования на больших проектах используется параллельное выполнение. В GitHub Actions:
YAML | 1
2
3
4
5
6
7
8
9
| jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
# ...
- name: Run tests
run: pytest --splits 4 --group ${{ matrix.shard }} |
|
В GitLab CI:
YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| .test-template: &test-template
stage: test
script:
- pip install -r requirements.txt
- pytest tests/group_${GROUP_NUM}
test-group-1:
<<: *test-template
variables:
GROUP_NUM: 1
test-group-2:
<<: *test-template
variables:
GROUP_NUM: 2 |
|
Визуализация результатов тестирования
GitHub Actions позволяет публиковать результаты тестов через специальные actions:
YAML | 1
2
3
4
| name: Publish Test Results
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: junit-reports/**/*.xml |
|
GitLab CI имеет встроенную поддержку отчётов JUnit:
YAML | 1
2
3
4
5
6
7
| python-test:
# ...
script:
- pytest --junitxml=report.xml
artifacts:
reports:
junit: report.xml |
|
Матричное тестирование в разных окружениях
Часто требуется проверить совместимость с разными версиями Python:
YAML | 1
2
3
4
5
6
7
8
9
10
11
12
| # GitHub Actions
jobs:
test:
strategy:
matrix:
python-version: [3.8, 3.9, 3.10, 3.11]
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }} |
|
YAML | 1
2
3
4
5
6
7
8
9
10
11
12
| # GitLab CI
.test-template: &test-template
script:
- pytest
test-py38:
<<: *test-template
image: python:3.8
test-py39:
<<: *test-template
image: python:3.9 |
|
Особенности выбора между платформами
Обе платформы обладают сильными сторонами:- GitHub Actions более интегрирован с экосистемой GitHub и имеет богатый маркетплейс готовых actions.
- GitLab CI предлагает более полный CI/CD опыт в рамках одной платформы с расширенными возможностями управления артефактами.
При выборе платформы стоит учитывать не только технические возможности, но и существующую инфраструктуру команды, привычки разработчиков и интеграцию с другими инструментами.
Оптимальный пайплайн тестирования должен быть быстрым, надёжным и информативным. Комбинирование перечисленных техник — кеширования зависимостей, параллельного выполнения, матричного тестирования — позволяет достичь этих целей независимо от выбранной платформы CI/CD.
TDD/BDD и Python Всем привет. Начали с подругой изучать Python(ей - для работы разработчиком, мне - для работы... Несколько простых, но непонятных моментов касаемо PyTest 1) Есть ли какой-то best practice касаемо написания тестов в стиле "проверить что при таких-то... Передать list в pytest.fixture Всем привет!
Пытаюсь написать тестирующую функцию (у которой относительно сложная реализация).
... Как поднять приложение «Flask» в рамках тестирования при запуске автотестов (Pytest)? Читая документацию - не могу разобрать как реализовать, чтобы в начале тестового цикла... Функция setup и pytest Приветствую!
Собственно ситуация, когда не использую функцию setup() со своим словарем,... Не делаются скриншоты в pytest Не делаются скриншоты в pytest
Подскажите в чем может быть дело. Тут либо вместо request надо... Как тестировать в pytest класс сервиса работающий с БД через SQLAlchemy Приложение на fastapi
Есть модуль services с классом UserAccessControlsService в котором инитится... allure pytest как скрыть "log stdout stderr" Как скрыть в алюре вывод этих фалов?
Сейчас запускаю pytest вот так:
pytest... Как запустить celery таску в pytest классе Есть условная celery таска
class CreateNotifications(Task):
name = 'create-notifications'
... aiohttp + pytest. Проблемы с использованием фикстур Смотрю документацию на https://python-dependency-injector.ets-labs.org/tutorials/aiohttp.html#tests... pytest FileNotFoundError file_workers/py
def read_from_file(filepath):
with open(filepath, 'r') as f_o:
... FastAPI и pytest Использовал инструкцию https://www.fastapitutorial.com/blog/unit-testing-in-fastapi/
но что-то не...
|