Python · Тестирование с pytest · Экспертный
Архитектура большого тест-сьюта
Организуйте большие тест-сьюты для скорости, изоляции и удобства поддержки.
Краткое введение в тему и пояснения перед упражнениями (упражнения ниже):
Архитектура тестов в среднем Django-проекте
#Справочная карточка архитектуры тестов
#Упражнения:
Реорганизовать плоский каталог тестов в unit/ и integration/
#У вас есть плоский каталог `tests/` с такими файлами: ``` tests/ +-- test_models.py <- тесты моделей Product и Order в памяти (без БД) +-- test_services.py <- тесты чистых вычислительных функций (без БД) +-- test_api.py <- тесты API-эндпоинтов с реальной базой данных +-- test_db_queries.py <- тесты методов запросов к БД (нужна БД) ``` Реорганизуйте это в следующую структуру. Напишите новые пути файлов и `pytest.ini`, устанавливающий `testpaths = tests` чтобы pytest автоматически находил тесты. Целевая структура: ``` tests/ +-- conftest.py <- пустой пока что (просто создать файл) +-- unit/ | +-- conftest.py <- пустой пока что | +-- test_models.py | +-- test_services.py +-- integration/ +-- conftest.py <- пустой пока что +-- test_api.py +-- test_db_queries.py ``` Также: какая команда запускает только интеграционные тесты после реорганизации?
# Перечислите новые пути файлов (по одному на строку): # tests/conftest.py # ... # Содержимое pytest.ini: # [pytest] # testpaths = ... # Команда для запуска только интеграционных тестов: # pytest ...
Решение
# Новые пути файлов после реорганизации:
# tests/conftest.py
# tests/unit/conftest.py
# tests/unit/test_models.py
# tests/unit/test_services.py
# tests/integration/conftest.py
# tests/integration/test_api.py
# tests/integration/test_db_queries.py
# pytest.ini (создайте этот файл в корне проекта):
# [pytest]
# testpaths = tests
# Команда для запуска только интеграционных тестов:
# pytest tests/integration/
Зарегистрировать метки и применить их к тестам
#У вас есть этот `pytest.ini` и два тестовых файла. Ваша задача: 1. Добавить `addopts = --strict-markers` в `pytest.ini` 2. Зарегистрировать три метки: `unit`, `integration`, `slow` 3. Применить метки к тестовым файлам через `pytestmark` (на уровне модуля) ```ini # pytest.ini -- текущее состояние [pytest] testpaths = tests ``` ```python # tests/unit/test_pricing.py def test_discount_applied(): assert apply_discount(100, 0.1) == 90 def test_negative_discount_raises(): with pytest.raises(ValueError): apply_discount(100, -0.1) ``` ```python # tests/integration/test_orders.py def test_order_saved_to_db(db): order = Order.objects.create(total=100) assert Order.objects.count() == 1 def test_large_order_sends_email(db, mailoutbox): # занимает 2 секунды Order.objects.create(total=10000) assert len(mailoutbox) == 1 ``` Напишите обновлённый `pytest.ini` и оба тестовых файла с добавленным `pytestmark`. Пометьте `test_large_order_sends_email` как `integration` и `slow` одновременно.
# pytest.ini
[pytest]
testpaths = tests
# добавить addopts и markers здесь
# tests/unit/test_pricing.py
import pytest
# добавить pytestmark здесь
def test_discount_applied():
assert apply_discount(100, 0.1) == 90
def test_negative_discount_raises():
with pytest.raises(ValueError):
apply_discount(100, -0.1)
# tests/integration/test_orders.py
import pytest
# добавить pytestmark здесь
def test_order_saved_to_db(db):
order = Order.objects.create(total=100)
assert Order.objects.count() == 1
def test_large_order_sends_email(db, mailoutbox):
Order.objects.create(total=10000)
assert len(mailoutbox) == 1
Решение
# pytest.ini
[pytest]
testpaths = tests
addopts = --strict-markers
markers =
unit: быстрые тесты без I/O
integration: тесты использующие базу данных или сеть
slow: тесты выполняющиеся более 1 секунды
# tests/unit/test_pricing.py
import pytest
pytestmark = [pytest.mark.unit]
def test_discount_applied():
assert apply_discount(100, 0.1) == 90
def test_negative_discount_raises():
with pytest.raises(ValueError):
apply_discount(100, -0.1)
# tests/integration/test_orders.py
import pytest
pytestmark = [pytest.mark.integration]
def test_order_saved_to_db(db):
order = Order.objects.create(total=100)
assert Order.objects.count() == 1
@pytest.mark.slow
def test_large_order_sends_email(db, mailoutbox):
Order.objects.create(total=10000)
assert len(mailoutbox) == 1
Построить иерархию conftest.py с фикстурами разной области
#Создайте три файла `conftest.py` для тест-сьюта с такой структурой: ``` tests/ +-- conftest.py +-- unit/ | +-- conftest.py +-- integration/ +-- conftest.py ``` Требования: - `tests/conftest.py`: определить фикстуру `app_config` с **областью session**, возвращающую словарь `{"env": "test", "debug": False}`. Эта фикстура должна быть доступна всем тестам. - `tests/unit/conftest.py`: определить фикстуру `calculator` с **областью function**, возвращающую новый экземпляр `Calculator()`. Доступна только юнит-тестам. - `tests/integration/conftest.py`: определить фикстуру `db_session` с **областью function**, которая печатает `"opening db"` перед yield строки `"db_connection"` и печатает `"closing db"` после. Доступна только интеграционным тестам. Также напишите тестовую функцию в `tests/unit/test_calc.py`, использующую обе `app_config` и `calculator`, и тестовую функцию в `tests/integration/test_db.py`, использующую обе `app_config` и `db_session`.
# tests/conftest.py import pytest # фикстура app_config с областью session здесь # tests/unit/conftest.py import pytest # фикстура calculator с областью function здесь # tests/integration/conftest.py import pytest # фикстура db_session с областью function здесь # tests/unit/test_calc.py # использовать app_config и calculator # tests/integration/test_db.py # использовать app_config и db_session
Решение
# tests/conftest.py
import pytest
@pytest.fixture(scope='session')
def app_config():
return {"env": "test", "debug": False}
# tests/unit/conftest.py
import pytest
class Calculator:
def add(self, a, b):
return a + b
@pytest.fixture
def calculator():
return Calculator()
# tests/integration/conftest.py
import pytest
@pytest.fixture
def db_session():
print("opening db")
yield "db_connection"
print("closing db")
# tests/unit/test_calc.py
def test_add(app_config, calculator):
assert app_config["env"] == "test"
assert calculator.add(2, 3) == 5
# tests/integration/test_db.py
def test_connect(app_config, db_session):
assert app_config["env"] == "test"
assert db_session == "db_connection"
Написать Makefile с целями для тестирования
#Напишите `Makefile` для проекта с такой структурой тестов: ``` tests/ +-- unit/ +-- integration/ ``` И таким `pytest.ini`: ```ini [pytest] testpaths = tests addopts = --strict-markers markers = unit: быстрые тесты integration: тесты базы данных slow: тесты дольше 1 секунды ``` Makefile должен иметь четыре цели: | Цель | Что запускает | |---|---| | `make test-unit` | Только юнит-тесты, тихий вывод | | `make test-integration` | Только интеграционные тесты, тихий вывод | | `make test-fast` | Юнит + интеграционные, исключая медленные, тихий вывод | | `make test-ci` | Полный сьют с покрытием (src/), провал ниже 80%, тихий вывод | Все цели должны быть объявлены как `.PHONY`.
# Makefile .PHONY: ... test-unit: ... test-integration: ... test-fast: ... test-ci: ...
Решение
# Makefile
.PHONY: test-unit test-integration test-fast test-ci
test-unit:
pytest tests/unit/ -q
test-integration:
pytest tests/integration/ -q
test-fast:
pytest tests/unit/ tests/integration/ -m "not slow" -q
test-ci:
pytest --cov=src --cov-report=term-missing --cov-fail-under=80 -q
Найти и исправить зависимость от порядка тестов
#Следующий тестовый файл проходит когда тесты запускаются в порядке по умолчанию, но падает при другом порядке. Представьте запуск в обратном порядке: ``` test_empty_cart_total → test_remove_item_from_cart → test_cart_total → test_add_item_to_cart ``` Найдите зависимость от порядка и исправьте её так чтобы тесты проходили в любом порядке. **Подсказка:** Установите `pytest-randomly` и запустите `pytest --randomly-seed=0` чтобы убедиться что ваше исправление работает независимо от порядка. ```python # test_cart.py import pytest _cart = [] # общее состояние на уровне модуля def test_add_item_to_cart(): _cart.append({"id": 1, "name": "Widget", "qty": 2}) assert len(_cart) == 1 def test_cart_total(): total = sum(item["qty"] * 10 for item in _cart) assert total == 20 # 2 x 10 def test_remove_item_from_cart(): _cart.clear() assert len(_cart) == 0 def test_empty_cart_total(): total = sum(item["qty"] * 10 for item in _cart) assert total == 0 ``` **Часть 1 -- Определить:** Какой тест падает первым при обратном порядке? Почему? **Часть 2 -- Исправить:** Перепишите файл так чтобы все четыре теста были независимы. Используйте pytest-фикстуру вместо `_cart` на уровне модуля.
# Часть 1 -- Какой тест падает первым в обратном порядке и почему?
# (напишите ответ в комментарии)
#
# Первым падает тест: ...
# Причина: ...
# Часть 2 -- Исправленный test_cart.py
import pytest
# определить фикстуру cart здесь
def test_add_item_to_cart(cart):
pass # реализовать
def test_cart_total(cart):
pass # реализовать
def test_remove_item_from_cart(cart):
pass # реализовать
def test_empty_cart_total(cart):
pass # реализовать
Решение
# Часть 1 -- Какой тест падает первым в обратном порядке?
#
# Первым падает тест: test_cart_total
# Причина: В обратном порядке выполнение:
# test_empty_cart_total -> test_remove_item_from_cart -> test_cart_total -> test_add_item_to_cart
# test_empty_cart_total запускается первым; _cart уже пуст, total == 0 -- случайно ПРОХОДИТ.
# test_remove_item_from_cart очищает уже пустой список -- ПРОХОДИТ.
# test_cart_total запускается следующим; _cart всё ещё пуст, total == 0, а не 20 -- ПАДАЕТ.
#
# Корневая причина: _cart -- список на уровне модуля, общий для всех тестов.
# Тесты неявно коммуницируют через него, поэтому результаты зависят от порядка запуска.
# Часть 2 -- Исправленный test_cart.py
import pytest
@pytest.fixture
def cart():
return []
def test_add_item_to_cart(cart):
cart.append({"id": 1, "name": "Widget", "qty": 2})
assert len(cart) == 1
def test_cart_total(cart):
cart.append({"id": 1, "name": "Widget", "qty": 2})
total = sum(item["qty"] * 10 for item in cart)
assert total == 20
def test_remove_item_from_cart(cart):
cart.append({"id": 1, "name": "Widget", "qty": 2})
cart.clear()
assert len(cart) == 0
def test_empty_cart_total(cart):
total = sum(item["qty"] * 10 for item in cart)
assert total == 0