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: fast tests with no I/O
integration: tests that use the database or network
slow: tests that take more than 1 second
# 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 з фікстурами різних scope
#Створіть три файли `conftest.py` для тест-сьюту з такою структурою: ``` tests/ ├── conftest.py ├── unit/ │ └── conftest.py └── integration/ └── conftest.py ``` Вимоги: - `tests/conftest.py`: визначте фікстуру з **scope сесії** `app_config`, що повертає dict `{"env": "test", "debug": False}`. Ця фікстура має бути доступна всім тестам. - `tests/unit/conftest.py`: визначте фікстуру з **scope функції** `calculator`, що повертає новий екземпляр `Calculator()`. Доступна лише для юніт-тестів. - `tests/integration/conftest.py`: визначте фікстуру з **scope функції** `db_session`, що виводить `"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 з scope сесії тут # tests/unit/conftest.py import pytest # фікстура calculator з scope функції тут # tests/integration/conftest.py import pytest # фікстура db_session з scope функції тут # 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: fast tests integration: database tests slow: tests over 1 second ``` 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