Python · Тестирование с pytest · Продвинутый

Фабрики фикстур и тестовые данные

5 задач

Создавайте гибкие тестовые данные с помощью фабричных фикстур и библиотеки Faker.

Паттерн фабрики: фикстуры, создающие данные по требованию

#
## Ограничение фикстур с фиксированными значениями Обычная фикстура возвращает одно фиксированное значение. Каждый тест, запрашивающий её, получает один и тот же объект: ```python @pytest.fixture def user(): return {'username': 'alice', 'email': '[email protected]', 'role': 'admin'} ``` Это нормально, когда одному тесту нужен один пользователь. Ситуация ломается, как только нужна вариативность: ```python def test_duplicate_username(user): user_a = user # {'username': 'alice', ...} user_b = user # тот же словарь -- нет способа получить другого пользователя assert user_a['username'] != user_b['username'] # ПАДАЕТ: 'alice' == 'alice' ``` Можно написать отдельные фикстуры (`user_alice`, `user_bob`, `user_guest`), но это ведёт к разрастанию фикстур -- десятки почти одинаковых фикстур для каждой комбинации полей. ## Паттерн фабрики: фикстура, возвращающая вызываемый объект Вместо возврата значения фикстура возвращает *функцию-фабрику*. Тесты вызывают фабрику с аргументами, чтобы создать столько настроенных объектов, сколько нужно: ```python @pytest.fixture def make_user(): def _factory(username='alice', email=None, role='user'): if email is None: email = f'{username}@example.com' return {'username': username, 'email': email, 'role': role} return _factory ``` ```python def test_duplicate_username(make_user): user_a = make_user(username='alice') user_b = make_user(username='bob') assert user_a['username'] != user_b['username'] # ПРОХОДИТ ``` Фикстура (`make_user`) запускается один раз и возвращает функцию-фабрику. Каждый вызов `make_user(...)` внутри теста создаёт новый словарь с запрошенными полями, используя разумные дефолты для всего неуказанного. ## Почему дефолты важны Хорошие дефолты фабрики позволяют тестовому коду выражать только *релевантную* вариативность: ```python def test_admin_can_delete(make_user): admin = make_user(role='admin') # здесь важна только роль assert can_delete(admin) is True def test_guest_cannot_delete(make_user): guest = make_user(role='guest') assert can_delete(guest) is False ``` Поля `username` и `email` не важны для этих тестов. С дефолтами их не нужно указывать. Если нужно 50 тест-кейсов, каждый с разной ролью, не придётся писать 50 фикстур -- достаточно параметризовать вызов фабрики. ## Faker: реалистичные тестовые данные Библиотека `faker` генерирует реалистичные фиктивные данные -- имена, email, адреса, телефоны, lorem ipsum: ```bash pip install faker ``` ```python from faker import Faker fake = Faker() print(fake.name()) # 'Emily Rodriguez' print(fake.email()) # '[email protected]' print(fake.user_name()) # 'jsmith42' print(fake.pyint(min_value=1, max_value=1000)) # 847 ``` Интеграция Faker в фикстуру-фабрику даёт уникальные реалистичные значения по умолчанию: ```python from faker import Faker import pytest @pytest.fixture def make_user(): fake = Faker() def _factory(**kwargs): defaults = { 'username': fake.user_name(), 'email': fake.email(), 'first_name': fake.first_name(), 'last_name': fake.last_name(), } defaults.update(kwargs) return defaults return _factory ``` Теперь каждый вызов `make_user()` производит уникального пользователя с разными сгенерированными именем пользователя и email, избегая случайных коллизий данных между тестами. ## Воспроизводимость: посев Faker По умолчанию вывод Faker случаен. Если тест падает, воспроизведение ошибки требует тех же данных. Посейте Faker в начале тестовой сессии для воспроизводимого вывода: ```python @pytest.fixture(scope='session', autouse=True) def seed_faker(): Faker.seed(42) # одно зерно -> одна последовательность фиктивных данных при каждом запуске ``` С фиксированным зерном последовательность генерируемых значений детерминирована: третий вызов `make_user()` в тест-сьюте всегда производит то же имя пользователя. Без зерна случайные данные в тестах могут вызывать непостоянные падения, которые сложно воспроизвести. ## Когда использовать фабрики vs обычные фикстуры | Ситуация | Используйте | |---|---| | Каждому тесту нужен один и тот же объект | Обычный `@pytest.fixture` | | Тестам нужны объекты с небольшими вариациями | Фабричная фикстура | | Тесты создают несколько объектов одного типа | Фабричная фикстура | | Сложные вложенные структуры со многими дефолтами | Фабричная фикстура | | Простое скалярное значение (URL, словарь конфига, число) | Обычный `@pytest.fixture` | Паттерн фабрики добавляет уровень косвенности. Используйте его, когда эта косвенность окупается -- когда тестам действительно нужны разные экземпляры, а не просто разные ссылки на один. ## Компоновка фабрик Фабрики могут вызывать другие фабрики для построения вложенных структур: ```python @pytest.fixture def make_order(make_user, make_product): def _factory(num_items=1, **kwargs): defaults = { 'user': make_user(), 'items': [make_product() for _ in range(num_items)], 'status': 'pending', } defaults.update(kwargs) return defaults return _factory ``` ```python def test_order_total(make_order, make_product): product = make_product(price=10.00) order = make_order(items=[product, product]) assert calculate_total(order) == 20.00 ``` Фикстура `make_order` зависит от `make_user` и `make_product` -- стандартная цепочка фикстур. pytest автоматически разрешает граф зависимостей.

Фабричные фикстуры на практике: пользователи, продукты и Faker

#
## Настройка проекта ```bash pip install faker pytest ``` ## Пример 1: Базовая фабрика make_user ```python # conftest.py import pytest @pytest.fixture def make_user(): created = [] def _factory(username='testuser', email=None, role='user', age=25): if email is None: email = f'{username}@example.com' user = {'username': username, 'email': email, 'role': role, 'age': age} created.append(user) return user return _factory ``` Фабрика отслеживает всех созданных пользователей в `created` -- полезно, если нужно удалить их при завершении (например, удалить из базы данных после теста). Для чистых словарей завершение не нужно, но паттерн отслеживания становится необходимым, когда фабрики создают реальные строки БД или API-ресурсы. ```python # test_users.py def test_user_defaults(make_user): user = make_user() assert user['username'] == 'testuser' assert user['email'] == '[email protected]' assert user['role'] == 'user' def test_user_custom_role(make_user): admin = make_user(username='admin', role='admin') assert admin['role'] == 'admin' def test_two_users_in_one_test(make_user): buyer = make_user(username='buyer') seller = make_user(username='seller') assert buyer['username'] != seller['username'] ``` ## Пример 2: Интеграция Faker для реалистичных данных ```python # conftest.py import pytest from faker import Faker @pytest.fixture(scope='session', autouse=True) def seed_faker(): Faker.seed(0) # воспроизводимость между запусками @pytest.fixture def make_user(): fake = Faker() def _factory(**kwargs): username = fake.user_name() defaults = { 'username': username, 'email': fake.email(), 'first_name': fake.first_name(), 'last_name': fake.last_name(), 'bio': fake.text(max_nb_chars=100), } defaults.update(kwargs) return defaults return _factory ``` ```python # test_faker_users.py def test_users_have_unique_usernames(make_user): users = [make_user() for _ in range(5)] usernames = [u['username'] for u in users] assert len(set(usernames)) == len(usernames) # все уникальны def test_email_override(make_user): user = make_user(email='[email protected]') assert user['email'] == '[email protected]' assert 'username' in user # остальные поля по-прежнему генерирует Faker ``` Фикстура сессии с `autouse=True` инициализирует Faker один раз в начале запуска. Каждый последующий вызов `fake.user_name()` производит одну и ту же последовательность при каждом запуске -- критично для воспроизведения падений тестов. ## Пример 3: Фабрика make_product ```python # conftest.py (продолжение) @pytest.fixture def make_product(): fake = Faker() def _factory(**kwargs): defaults = { 'name': fake.catch_phrase(), 'slug': fake.slug(), 'price': round(fake.pyfloat(min_value=1, max_value=999, right_digits=2), 2), 'in_stock': True, } defaults.update(kwargs) return defaults return _factory ``` ```python # test_products.py def test_three_products(make_product): products = [ make_product(name='Widget A', price=9.99), make_product(name='Widget B', price=19.99), make_product(name='Widget C', price=4.99), ] assert len(products) == 3 assert all(p['in_stock'] for p in products) total = sum(p['price'] for p in products) assert round(total, 2) == 34.97 def test_out_of_stock(make_product): product = make_product(in_stock=False) assert product['in_stock'] is False ``` ## Пример 4: Фабрика в сочетании с parametrize ```python # test_roles.py import pytest @pytest.mark.parametrize('role,can_admin', [ ('admin', True), ('editor', False), ('viewer', False), ]) def test_admin_permission(make_user, role, can_admin): user = make_user(role=role) assert check_admin_permission(user) is can_admin def check_admin_permission(user): return user['role'] == 'admin' ``` `parametrize` поставляет вариативность; фабрика создаёт объект. Эта комбинация читабельнее, чем фикстура, пытающаяся внутренне встроить все вариации. ## Пример 5: Вложенная фабрика (заказ с позициями) ```python # conftest.py (продолжение) @pytest.fixture def make_order(make_user, make_product): def _factory(num_items=1, **kwargs): defaults = { 'user': make_user(), 'items': [make_product() for _ in range(num_items)], 'status': 'pending', 'discount': 0.0, } defaults.update(kwargs) return defaults return _factory ``` ```python # test_orders.py def test_order_item_count(make_order): order = make_order(num_items=3) assert len(order['items']) == 3 def test_order_with_custom_item(make_order, make_product): expensive = make_product(price=999.99) order = make_order(items=[expensive]) assert order['items'][0]['price'] == 999.99 ```

Справочная карточка: фабричные фикстуры и Faker

#
## Шаблон фабричной фикстуры ```python import pytest from faker import Faker @pytest.fixture(scope='session', autouse=True) def seed_faker(): Faker.seed(0) # убрать для случайных данных при каждом запуске @pytest.fixture def make_<model>(): fake = Faker() def _factory(**kwargs): defaults = { 'field1': fake.some_provider(), 'field2': 'sensible_default', } defaults.update(kwargs) # аргументы вызывающего кода имеют приоритет return defaults return _factory ``` ## Faker: часто используемые провайдеры | Провайдер | Пример вывода | |---|---| | `fake.name()` | `'Emily Rodriguez'` | | `fake.first_name()` | `'Emily'` | | `fake.last_name()` | `'Rodriguez'` | | `fake.user_name()` | `'emily_r42'` | | `fake.email()` | `'[email protected]'` | | `fake.slug()` | `'fast-blue-widget'` | | `fake.text(max_nb_chars=100)` | lorem ipsum предложение | | `fake.url()` | `'https://example.com/path'` | | `fake.pyfloat(min_value=1, max_value=100, right_digits=2)` | `47.83` | | `fake.pyint(min_value=1, max_value=1000)` | `342` | | `fake.boolean()` | `True` / `False` | | `fake.uuid4()` | `'3d...a1'` | ## Паттерны использования ```python # Один объект user = make_user() # С переопределениями admin = make_user(role='admin') # Несколько объектов products = [make_product() for _ in range(5)] # Вложенный order = make_order(num_items=3) # В сочетании с parametrize @pytest.mark.parametrize('role', ['admin', 'editor', 'viewer']) def test_role(make_user, role): user = make_user(role=role) ... ``` ## Когда использовать каждый подход | Потребность | Решение | |---|---| | Один фиксированный объект для всех тестов | Обычный `@pytest.fixture` | | Несколько объектов с вариациями | Фабричная фикстура | | Реалистично выглядящие данные | Фабрика + Faker | | Воспроизводимые случайные данные | `Faker.seed(n)` в autouse фикстуре сессии | | Вложенные структуры | Компоновка фабрик (фабрика вызывает другую фабрику) |
01

Написать фабричную фикстуру make_user

#

Напишите pytest-фикстуру `make_user`, возвращающую функцию-фабрику. Фабрика должна: - Принимать именованные аргументы: `username`, `email`, `role`, `age` - Использовать разумные дефолты: `username='testuser'`, `email` выводится из username если не задан, `role='user'`, `age=25` - Возвращать словарь со всеми четырьмя полями Напишите два теста: 1. `test_default_user` -- создать пользователя без аргументов, проверить `role == 'user'` и что email содержит username 2. `test_two_users` -- создать двух пользователей с разными username, проверить что они разные

# test_factory_basic.py
import pytest


@pytest.fixture
def make_user():
    def _factory(username='testuser', email=None, role='user', age=25):
        # если email равен None, вывести его из username
        ...
        return ...
    return _factory


def test_default_user(make_user):
    user = make_user()
    assert user['role'] == 'user'
    assert ...  # email содержит username


def test_two_users(make_user):
    a = make_user(username='alice')
    b = make_user(username='bob')
    assert ...
Решение
# test_factory_basic.py
import pytest


@pytest.fixture
def make_user():
    def _factory(username='testuser', email=None, role='user', age=25):
        if email is None:
            email = f'{username}@example.com'
        return {'username': username, 'email': email, 'role': role, 'age': age}
    return _factory


def test_default_user(make_user):
    user = make_user()
    assert user['role'] == 'user'
    assert user['username'] in user['email']


def test_two_users(make_user):
    a = make_user(username='alice')
    b = make_user(username='bob')
    assert a['username'] != b['username']
02

Добавить Faker для реалистичных генерируемых данных

#

Установите `faker` и обновите `make_user` для генерации реалистичных фиктивных данных по умолчанию: - `username` из `fake.user_name()` - `email` из `fake.email()` - `first_name` и `last_name` из `fake.first_name()` / `fake.last_name()` Все поля должны по-прежнему быть переопределяемыми через именованные аргументы. Добавьте autouse фикстуру с session scope, вызывающую `Faker.seed(0)` для воспроизводимого вывода. Напишите тест `test_users_have_unique_emails`, создающий 5 пользователей без аргументов и проверяющий, что все их email различны.

# test_faker_factory.py
import pytest
from faker import Faker


@pytest.fixture(scope='session', autouse=True)
def seed_faker():
    Faker.seed(0)


@pytest.fixture
def make_user():
    fake = Faker()

    def _factory(**kwargs):
        defaults = {
            'username': ...,
            'email': ...,
            'first_name': ...,
            'last_name': ...,
        }
        defaults.update(kwargs)
        return defaults

    return _factory


def test_users_have_unique_emails(make_user):
    users = [make_user() for _ in range(5)]
    emails = [u['email'] for u in users]
    assert len(set(emails)) == len(emails)
Решение
# test_faker_factory.py
import pytest
from faker import Faker


@pytest.fixture(scope='session', autouse=True)
def seed_faker():
    Faker.seed(0)


@pytest.fixture
def make_user():
    fake = Faker()

    def _factory(**kwargs):
        defaults = {
            'username': fake.user_name(),
            'email': fake.email(),
            'first_name': fake.first_name(),
            'last_name': fake.last_name(),
        }
        defaults.update(kwargs)
        return defaults

    return _factory


def test_users_have_unique_emails(make_user):
    users = [make_user() for _ in range(5)]
    emails = [u['email'] for u in users]
    assert len(set(emails)) == len(emails)
03

Написать фабрику make_product и создать несколько продуктов

#

Напишите фабричную фикстуру `make_product`. Каждый продукт должен иметь: - `name` -- строку (используйте Faker или простой дефолт вроде `'Product'`) - `slug` -- выводится из name если не задан (строчные буквы, пробелы -> дефисы) - `price` -- float, дефолт `9.99` - `in_stock` -- bool, дефолт `True` Напишите `test_three_products(make_product)`, который: - Создаёт 3 продукта с ценами `5.00`, `10.00`, `20.00` - Проверяет, что все три в наличии - Проверяет, что сумма их цен равна `35.00`

# test_products_factory.py
import pytest


@pytest.fixture
def make_product():
    def _factory(name='Test Product', slug=None, price=9.99, in_stock=True):
        if slug is None:
            slug = name.lower().replace(' ', '-')
        return {'name': name, 'slug': slug, 'price': price, 'in_stock': in_stock}
    return _factory


def test_three_products(make_product):
    products = [
        make_product(price=...),
        make_product(price=...),
        make_product(price=...),
    ]
    assert all(...)
    assert sum(p['price'] for p in products) == ...
Решение
# test_products_factory.py
import pytest


@pytest.fixture
def make_product():
    def _factory(name='Test Product', slug=None, price=9.99, in_stock=True):
        if slug is None:
            slug = name.lower().replace(' ', '-')
        return {'name': name, 'slug': slug, 'price': price, 'in_stock': in_stock}
    return _factory


def test_three_products(make_product):
    products = [
        make_product(price=5.00),
        make_product(price=10.00),
        make_product(price=20.00),
    ]
    assert all(p['in_stock'] for p in products)
    assert sum(p['price'] for p in products) == pytest.approx(35.00)
04

Написать фабрику для вложенной структуры

#

Напишите фабричную фикстуру `make_order`, зависящую от `make_user` и `make_product`. Заказ должен быть словарём с: - `user` -- словарь пользователя (из `make_user()`) - `items` -- список словарей продуктов (дефолт: один продукт из `make_product()`) - `status` -- строка, дефолт `'pending'` Напишите два теста: 1. `test_order_default_has_one_item` -- создать заказ с дефолтами, проверить `len(order['items']) == 1` 2. `test_order_custom_items` -- создать два продукта явно, передать их как `items`, проверить что у заказа 2 элемента

# test_order_factory.py
import pytest


@pytest.fixture
def make_user():
    def _factory(**kwargs):
        defaults = {'username': 'testuser', 'email': '[email protected]', 'role': 'user'}
        defaults.update(kwargs)
        return defaults
    return _factory


@pytest.fixture
def make_product():
    def _factory(**kwargs):
        defaults = {'name': 'Widget', 'price': 9.99, 'in_stock': True}
        defaults.update(kwargs)
        return defaults
    return _factory


@pytest.fixture
def make_order(make_user, make_product):
    def _factory(**kwargs):
        defaults = {
            'user': ...,
            'items': [...],
            'status': 'pending',
        }
        defaults.update(kwargs)
        return defaults
    return _factory


def test_order_default_has_one_item(make_order):
    order = make_order()
    assert len(order['items']) == 1


def test_order_custom_items(make_order, make_product):
    p1 = make_product(name='A')
    p2 = make_product(name='B')
    order = make_order(items=[p1, p2])
    assert len(order['items']) == 2
Решение
# test_order_factory.py
import pytest


@pytest.fixture
def make_user():
    def _factory(**kwargs):
        defaults = {'username': 'testuser', 'email': '[email protected]', 'role': 'user'}
        defaults.update(kwargs)
        return defaults
    return _factory


@pytest.fixture
def make_product():
    def _factory(**kwargs):
        defaults = {'name': 'Widget', 'price': 9.99, 'in_stock': True}
        defaults.update(kwargs)
        return defaults
    return _factory


@pytest.fixture
def make_order(make_user, make_product):
    def _factory(**kwargs):
        defaults = {
            'user': make_user(),
            'items': [make_product()],
            'status': 'pending',
        }
        defaults.update(kwargs)
        return defaults
    return _factory


def test_order_default_has_one_item(make_order):
    order = make_order()
    assert len(order['items']) == 1


def test_order_custom_items(make_order, make_product):
    p1 = make_product(name='A')
    p2 = make_product(name='B')
    order = make_order(items=[p1, p2])
    assert len(order['items']) == 2
05

Скомбинировать фабричную фикстуру с parametrize

#

Используя фабричную фикстуру `make_user`, напишите параметризованный тест, проверяющий функцию `has_access(user, resource)`. Реализуйте `has_access(user, resource)`: - Возвращает `True` если `user['role'] == 'admin'` или `resource == 'public'` - Возвращает `False` иначе Параметризуйте `test_access` этими кейсами (role, resource, expected): - `('admin', 'secret', True)` - `('user', 'public', True)` - `('user', 'secret', False)` - `('guest', 'private', False)` Используйте `make_user(role=role)` внутри тела теста для создания пользователя.

# test_access.py
import pytest


def has_access(user, resource):
    ...


@pytest.fixture
def make_user():
    def _factory(**kwargs):
        defaults = {'username': 'testuser', 'role': 'user'}
        defaults.update(kwargs)
        return defaults
    return _factory


@pytest.mark.parametrize('role,resource,expected', [
    ('admin', 'secret', True),
    ('user',  'public', True),
    ('user',  'secret', False),
    ('guest', 'private', False),
])
def test_access(make_user, role, resource, expected):
    user = make_user(role=role)
    assert has_access(user, resource) is expected
Решение
# test_access.py
import pytest


def has_access(user, resource):
    return user['role'] == 'admin' or resource == 'public'


@pytest.fixture
def make_user():
    def _factory(**kwargs):
        defaults = {'username': 'testuser', 'role': 'user'}
        defaults.update(kwargs)
        return defaults
    return _factory


@pytest.mark.parametrize('role,resource,expected', [
    ('admin', 'secret', True),
    ('user',  'public', True),
    ('user',  'secret', False),
    ('guest', 'private', False),
])
def test_access(make_user, role, resource, expected):
    user = make_user(role=role)
    assert has_access(user, resource) is expected