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 scope в 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 для завершения `yield` в фикстуре разделяет инициализацию и завершение. Код после `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): # из root conftest.py resp = requests.get(f'{base_url}/api/products/') assert resp.status_code == 200 def test_catalog_not_empty(catalog): # session scope; загружается один раз 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. ## Фикстура с инициализацией и завершением через 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( # завершение: выполняется всегда, даже при падении теста f'{base_url}/api/cart/items/{item_id}/', headers=auth_headers, ) ``` ## Косвенная фикстура: параметризация через 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 (инициализация + завершение):** ```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):
    # проверить 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 scope, загружающая файл один раз

#

Создайте `settings.json` с содержимым `{"debug": true, "page_size": 10}`. Создайте `conftest.py` с фикстурой `config` с `session` scope, которая читает этот файл и выводит `"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):
    # проверить config['debug'] is True
    pass


# test_b.py
def test_page_size(config):
    # проверить 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}')
    # проверить base_url == 'https://apilearn.tukas.dev'
    pass


# local/test_local.py
def test_local_url(base_url):
    print(f'  local test sees: {base_url}')
    # проверить 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 в зависимости от переданного параметра
    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: инициализация и завершение

#

Создайте `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'