Python · Тестування з pytest · Середній

conftest.py та спільні фікстури

5 завдань

Діліться фікстурами між тестовими файлами через conftest.py без жодних імпортів.

Спільне використання фікстур через conftest.py

#
## Проблема дублювання фікстур Коли ваш тест-сьют розростається по кількох файлах, вам захочеться мати однакові фікстури в декількох місцях -- `sample_user`, підключення до БД, токен аутентифікації. Дублювання -- це тягар обслуговування; імпорт з іншого тестового файлу є незручним і порушує модель виявлення pytest. `conftest.py` вирішує це чисто. Це спеціальний файл, який pytest виявляє автоматично. Будь-яка фікстура, визначена в ньому, доступна для всіх тестів у тій самій директорії та всіх піддиректоріях -- **без жодного імпорту**. ## Як pytest виявляє conftest.py Коли pytest збирає тест за шляхом `tests/unit/test_users.py`, він завантажує кожен `conftest.py`, знайдений від кореневої директорії до директорії тестового файлу: ``` project/ ├── conftest.py ← завантажується 1-м (фікстури для всього проекту) └── tests/ ├── conftest.py ← завантажується 2-м (фікстури тест-сьюту) └── unit/ ├── conftest.py ← завантажується 3-м (фікстури юніт-тестів) └── test_users.py ← тестовий файл ``` Фікстури на кожному рівні доступні для всіх тестів нижче цього рівня. Тестовий файл ніколи не імпортує з conftest -- pytest автоматично впроваджує фікстури за іменем параметра. ## Кілька conftest-файлів і пріоритет перевизначення Якщо одне й те саме ім'я фікстури з'являється у кількох conftest-файлах, перемагає **найближчий** -- той, що ближчий до тестового файлу. Це дозволяє визначити розумний дефолт у кореневому файлі і перевизначити його для конкретного піддерева: ``` project/ ├── conftest.py # base_url = 'https://prod.example.com' └── tests/ ├── conftest.py # base_url = 'https://staging.example.com' ← перевизначає для tests/ └── local/ └── conftest.py # base_url = 'http://localhost:8000' ← перевизначає для local/ ``` Тести у `tests/local/` використовують `localhost`; все інше у `tests/` -- staging. ## Scope фікстур у conftest Scope працює так само, як і у фікстурах у файлах, але session-scoped фікстури у conftest особливо потужні -- вони виконуються один раз для всього pytest-сеансу: ```python # conftest.py import pytest import json @pytest.fixture(scope='session') def product_catalog(): with open('tests/fixtures/products.json') as f: return json.load(f) # завантажується один раз; спільне для всіх тестів ``` Це правильне місце для ресурсомісткого налаштування: автентифіковані HTTP-сесії, підключення до БД, завантаження великих датасетів. Виконайте один раз, поділіться результатом. ## Час життя scope | Scope | Створюється | Знищується | Найкраще для | |-------|------------|------------|--------------| | `function` (за замовчуванням) | перед кожним тестом | після кожного тесту | ізольований стан на тест | | `class` | перед першим тестом у класі | після останнього тесту у класі | спільне налаштування на рівні класу | | `module` | перед першим тестом у файлі | після останнього тесту у файлі | ресурси на рівні файлу | | `session` | перед першим тестом запуску | після останнього тесту запуску | ресурсомістке глобальне налаштування | ## conftest -- це не модуль для імпорту Ніколи не пишіть `from conftest import my_fixture`. pytest автоматично обробляє виявлення та впровадження. Імпорт з conftest обходить цей механізм і створює непомітні баги. ## yield-фікстури для teardown `yield` у фікстурі розділяє налаштування та teardown. Код після `yield` виконується, коли закінчується scope фікстури -- навіть якщо тест провалився: ```python @pytest.fixture(scope='session') def db_connection(): conn = open_connection() yield conn # тут виконується код тестів; conn впроваджується conn.close() # виконується після останнього тесту, що використовує цю фікстуру ```

conftest.py у реальному проекті

#
## Структура проекту ``` project/ ├── conftest.py └── tests/ ├── conftest.py ├── test_products.py └── api/ ├── conftest.py └── test_orders.py ``` ## conftest.py (корінь проекту) -- фікстури для всього проекту ```python # conftest.py import pytest import requests BASE_URL = 'https://apilearn.tukas.dev' @pytest.fixture(scope='session') def base_url(): return BASE_URL @pytest.fixture(scope='session') def catalog(base_url): '''Завантажити список продуктів один раз для всього тестового сеансу.''' resp = requests.get(f'{base_url}/api/products/') resp.raise_for_status() return resp.json()['results'] ``` ## tests/conftest.py -- спільні фікстури аутентифікації ```python # tests/conftest.py import pytest import requests @pytest.fixture(scope='session') def auth_token(base_url): '''Увійти один раз і повторно використовувати токен для всіх тестів сеансу.''' resp = requests.post(f'{base_url}/api/auth/token/', json={ 'username': 'testuser', 'password': 'TestUser2024!', }) resp.raise_for_status() return resp.json()['token'] @pytest.fixture def auth_headers(auth_token): return {'Authorization': f'Token {auth_token}'} ``` ## tests/test_products.py -- жодних імпортів з conftest не потрібно ```python # tests/test_products.py import requests def test_products_list(base_url): # з кореневого conftest.py resp = requests.get(f'{base_url}/api/products/') assert resp.status_code == 200 def test_catalog_not_empty(catalog): # session-scoped; завантажується один раз assert len(catalog) > 0 def test_profile_requires_auth(base_url, auth_headers): # з tests/conftest.py resp = requests.get(f'{base_url}/api/users/profile/', headers=auth_headers) assert resp.status_code == 200 ``` ## tests/api/conftest.py -- перевизначення на рівні директорії ```python # tests/api/conftest.py import pytest @pytest.fixture(scope='session') def base_url(): '''Перевизначення для інтеграційних тестів: звертатися до локального сервера.''' return 'http://localhost:8000' ``` Тести всередині `tests/api/` автоматично використовують `http://localhost:8000`. Тести в інших місцях продовжують бачити `https://apilearn.tukas.dev`. Перевизначення непомітне -- перемагає найближчий conftest. ## Фікстура з налаштуванням та teardown через yield ```python # tests/conftest.py import pytest import requests @pytest.fixture def created_item(base_url, auth_headers): '''Створити тестовий елемент перед тестом; видалити його після.''' resp = requests.post( f'{base_url}/api/cart/items/', json={'product_id': 1, 'quantity': 1}, headers=auth_headers, ) resp.raise_for_status() item_id = resp.json()['id'] yield item_id # тест отримує item_id requests.delete( # teardown: завжди виконується, навіть якщо тест провалився f'{base_url}/api/cart/items/{item_id}/', headers=auth_headers, ) ``` ## Непряма фікстура: parametrize через conftest Фікстура, що зчитує свій параметр з `request.param`, може керуватися через `parametrize`: ```python # conftest.py @pytest.fixture def role_headers(request, base_url): '''Увійти під різними ролями користувача.''' role = request.param # 'admin' або 'viewer' resp = requests.post(f'{base_url}/api/auth/token/', json={ 'username': f'{role}user', 'password': 'TestUser2024!', }) return {'Authorization': f'Token {resp.json()["token"]}'} # тестовий файл @pytest.mark.parametrize('role_headers', ['admin', 'viewer'], indirect=True) def test_access(base_url, role_headers): resp = requests.get(f'{base_url}/api/products/', headers=role_headers) assert resp.status_code == 200 ```

Довідник: conftest.py

#
**Виявлення:** pytest завантажує `conftest.py` від кореневої директорії до директорії тестового файлу. Імпорт не потрібен -- фікстури впроваджуються за іменем параметра. **Пріоритет перевизначення:** перемагає найближчий conftest. `tests/unit/conftest.py` перевизначає `tests/conftest.py` для тестів всередині `tests/unit/`. **Час життя scope фікстури:** | Scope | `setup` виконується | `teardown` виконується | |-------|---------------------|------------------------| | `function` | перед кожним тестом | після кожного тесту | | `class` | перед першим методом | після останнього методу | | `module` | перед першим тестом у файлі | після останнього тесту у файлі | | `session` | один раз за запуск `pytest` | у самому кінці | **yield-фікстура (налаштування + teardown):** ```python @pytest.fixture(scope='session') def resource(): obj = setup() yield obj # тести отримують obj teardown(obj) # виконується, коли закінчується scope ``` **Фікстура, що залежить від іншої фікстури:** ```python @pytest.fixture def auth_headers(auth_token): # auth_token -- це інша фікстура return {'Authorization': f'Token {auth_token}'} ``` **Непряма параметризація:** ```python @pytest.fixture def user(request): return create_user(role=request.param) @pytest.mark.parametrize('user', ['admin', 'viewer'], indirect=True) def test_access(user): ... ``` **Ніколи** не пишіть `from conftest import fixture` -- pytest обробляє це автоматично.
01

Спільна фікстура для двох тестових файлів

#

Створіть `conftest.py` у корені проекту з фікстурою `sample_users`, що повертає список з трьох словників користувачів (кожен з ключами `'name'` і `'email'`). Створіть `test_users_a.py` з тестом, що перевіряє `len(sample_users) == 3`, та `test_users_b.py` з тестом, що перевіряє наявність ключа `'email'` у кожному словнику. Жоден тестовий файл не повинен імпортувати щось з `conftest.py`. Запустіть `pytest -v` і переконайтеся, що обидва тести проходять.

# conftest.py
import pytest

@pytest.fixture
def sample_users():
    # поверніть список з 3 словників, кожен з ключами 'name' і 'email'
    pass


# test_users_a.py
def test_users_count(sample_users):
    # assert len(sample_users) == 3
    pass


# test_users_b.py
def test_users_have_email(sample_users):
    # перевірте наявність 'email' у кожному користувачі
    pass
Рішення
# conftest.py
import pytest

@pytest.fixture
def sample_users():
    return [
        {'name': 'Alice', 'email': '[email protected]'},
        {'name': 'Bob',   'email': '[email protected]'},
        {'name': 'Carol', 'email': '[email protected]'},
    ]


# test_users_a.py
def test_users_count(sample_users):
    assert len(sample_users) == 3


# test_users_b.py
def test_users_have_email(sample_users):
    for user in sample_users:
        assert 'email' in user
02

Session-scoped фікстура, що завантажує файл один раз

#

Створіть `settings.json` з вмістом `{"debug": true, "page_size": 10}`. Створіть `conftest.py` з `session`-scoped фікстурою `config`, яка читає цей файл і виводить `"Loading config"` перед поверненням словника. Напишіть дві тест-функції у двох окремих файлах -- обидві використовують `config`. Запустіть `pytest -v -s` і переконайтеся, що `"Loading config"` з'являється у виводі рівно один раз.

# settings.json (створіть цей файл):
# {"debug": true, "page_size": 10}


# conftest.py
import pytest
import json

@pytest.fixture(scope='session')
def config():
    print('Loading config')
    # відкрийте 'settings.json', розпарсіть JSON, поверніть словник
    pass


# test_a.py
def test_debug_mode(config):
    # assert config['debug'] is True
    pass


# test_b.py
def test_page_size(config):
    # assert config['page_size'] == 10
    pass
Рішення
# settings.json:
# {"debug": true, "page_size": 10}


# conftest.py
import pytest
import json

@pytest.fixture(scope='session')
def config():
    print('Loading config')
    with open('settings.json') as f:
        return json.load(f)


# test_a.py
def test_debug_mode(config):
    assert config['debug'] is True


# test_b.py
def test_page_size(config):
    assert config['page_size'] == 10
03

Перевизначення фікстури на рівні директорії

#

Створіть кореневий `conftest.py` з фікстурою `base_url`, що повертає `'https://apilearn.tukas.dev'`. Створіть піддиректорію `local/` з власним `conftest.py`, що перевизначає `base_url` як `'http://localhost:8000'`. Напишіть `test_root.py` у корені та `local/test_local.py` у піддиректорії -- кожен повинен виводити `base_url` з `-s`. Запустіть `pytest -v -s` і переконайтеся, що в кожному тесті відображається правильний URL.

# conftest.py (корінь проекту)
import pytest

@pytest.fixture
def base_url():
    # поверніть URL продакшн
    pass


# local/conftest.py
import pytest

@pytest.fixture
def base_url():
    # перевизначення: повернути URL localhost
    pass


# test_root.py
def test_root_url(base_url):
    print(f'  root test sees: {base_url}')
    # assert base_url == 'https://apilearn.tukas.dev'
    pass


# local/test_local.py
def test_local_url(base_url):
    print(f'  local test sees: {base_url}')
    # assert base_url == 'http://localhost:8000'
    pass
Рішення
# conftest.py (корінь проекту)
import pytest

@pytest.fixture
def base_url():
    return 'https://apilearn.tukas.dev'


# local/conftest.py
import pytest

@pytest.fixture
def base_url():
    return 'http://localhost:8000'


# test_root.py
def test_root_url(base_url):
    print(f'  root test sees: {base_url}')
    assert base_url == 'https://apilearn.tukas.dev'


# local/test_local.py
def test_local_url(base_url):
    print(f'  local test sees: {base_url}')
    assert base_url == 'http://localhost:8000'
04

Непряма фікстура з request.param

#

Створіть `conftest.py` з фікстурою `scaled`, яка читає `request.param` (число) і повертає `request.param * 10`. Напишіть параметризований тест з `indirect=True` та параметрами `[2, 5, 7]`. Кожен запуск тесту повинен отримувати `20`, `50` і `70` відповідно -- перевірте це. Запустіть `pytest -v` і переконайтеся, що у виводі з'являються три окремі тест-кейси.

# conftest.py
import pytest

@pytest.fixture
def scaled(request):
    # поверніть request.param, помножений на 10
    pass


# test_scale.py
import pytest

@pytest.mark.parametrize('scaled', [2, 5, 7], indirect=True)
def test_scaled_value(scaled):
    # перевірте, що scaled дорівнює 20, 50 або 70 залежно від переданого param
    pass
Рішення
# conftest.py
import pytest

@pytest.fixture
def scaled(request):
    return request.param * 10


# test_scale.py
import pytest

@pytest.mark.parametrize('scaled', [2, 5, 7], indirect=True)
def test_scaled_value(scaled):
    assert scaled in (20, 50, 70)
05

Фікстура з yield: налаштування і teardown

#

Створіть `conftest.py` з фікстурою `temp_file`, яка: (1) створює `Path('test_output.txt')` і записує до нього `'hello from fixture'`, виводячи `'SETUP'`; потім (2) передає шлях через `yield`; потім (3) видаляє файл і виводить `'TEARDOWN'`. Напишіть тест, що читає файл і перевіряє його вміст. Запустіть `pytest -v -s`, щоб побачити SETUP і TEARDOWN навколо тесту.

# conftest.py
import pytest
from pathlib import Path

@pytest.fixture
def temp_file():
    path = Path('test_output.txt')
    # запишіть 'hello from fixture' до path, виведіть 'SETUP'
    # yield path
    # видаліть файл, виведіть 'TEARDOWN'
    pass


# test_tempfile.py
def test_reads_fixture_file(temp_file):
    # прочитайте temp_file і перевірте вміст == 'hello from fixture'
    pass
Рішення
# conftest.py
import pytest
from pathlib import Path

@pytest.fixture
def temp_file():
    path = Path('test_output.txt')
    print('SETUP')
    path.write_text('hello from fixture')
    yield path
    print('TEARDOWN')
    path.unlink(missing_ok=True)


# test_tempfile.py
def test_reads_fixture_file(temp_file):
    content = temp_file.read_text()
    assert content == 'hello from fixture'