Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 7 из 20 Средний 35 мин 130 XP

Область видимости фикстур: scope и autouse

Сколько раз создаётся фикстура? По умолчанию — для каждого теста, но scope="module" экономит до одного раза на файл, а autouse применяет её вовсе без указания в аргументах.

Редакция Питоники

Из пятого урока мы вынесли правило: каждому тесту — свежий экземпляр фикстуры. Это безопасно, но не всегда дёшево: соединение с базой, запуск тестового сервера, чтение большого файла — каждое из них дорого, и создавать их по разу на тест расточительно. У pytest есть ручка и для этого: аргумент scope у декоратора фикстуры. Плюс вторая ручка — autouse — для фикстур, которые должны применяться ко всем тестам без просьб. Разберём обе с доказательствами, а не на словах.

По умолчанию: свежий экземпляр на каждый тест

Как доказать, что фикстура создаётся заново для каждого теста? Счётчиком. Положим в тестовый файл словарь-счётчик и будем увеличивать его в фикстуре — а тесты проверят его значение в момент выполнения.

счётчик доказывает: фикстура отработала дважды
from pathlib import Path

Path("test_scope.py").write_text("""import pytest

calls = {"cart": 0}

@pytest.fixture
def cart():
    calls["cart"] += 1
    return ["хлеб"]

def test_first(cart):
    assert cart == ["хлеб"]
    assert calls["cart"] == 1

def test_second(cart):
    assert cart == ["хлеб"]
    assert calls["cart"] == 2
""", encoding="utf-8")

import pytest

rc = pytest.main(["test_scope.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
Вывод
..                                                                       [100%]
2 passed in 0.01s
exit: 0

Оба теста зелёные, и по assert'ам видна механика: в первом тесте счётчик равен единице — фикстура отработала один раз. Во втором — двойке: pytest снова вызвал фикстуру, свежую корзину получил второй тест, а счётчик честно это задокументировал. Это и есть значение scope по умолчанию — "function": один вызов на одну тест-функцию.

scope="module": один вызов на файл

Теперь та же схема, но фикстуре задан scope="module" — «один экземпляр на модуль», то есть на весь тестовый файл. Счётчик снова в деле — ожидания у тестов другие:

счётчик держится на единице: один вызов на файл
from pathlib import Path

Path("test_scope.py").write_text("""import pytest

calls = {"orders": 0}

@pytest.fixture(scope="module")
def orders():
    calls["orders"] += 1
    return 3

def test_orders_count(orders):
    assert orders == 3
    assert calls["orders"] == 1

def test_orders_positive(orders):
    assert orders > 0
    assert calls["orders"] == 1
""", encoding="utf-8")

import pytest

rc = pytest.main(["test_scope.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
Вывод
..                                                                       [100%]
2 passed in 0.01s
exit: 0

Два теста прошли, и в обоих счётчик равен единице: фикстура выполнилась один раз на весь файл, а её результат раздаётся всем тестам модуля. Для дорогой подготовки это прямая экономия: одно соединение вместо тридцати, один запуск сервера вместо тридцати. Полный список значений scope небольшой:

scopeФикстура живётТипичное применение
"function" (по умолчанию)один тестданные, которые тест вправе менять
"module"весь тестовый файлдорогая подготовка: файл, конфиг, набор записей
"session"весь прогон всех файловсамое дорогое: соединение, тестовый сервер

autouse: фикстура без аргумента

Обе фикстуры выше тесты принимали аргументом. А если подготовка нужна всем тестам файла, и перечислять её в каждом — шум? Флаг autouse=True включает фикстуру автоматически: тесты её не объявляют, а она всё равно выполняется перед каждым из них. Классика жанра — лог: записать, какой тест запустился.

autouse пишет лог за всех тестов
from pathlib import Path

Path("test_log.py").write_text("""import pytest

LOG = []

@pytest.fixture(autouse=True)
def note(request):
    LOG.append(request.node.name)

def test_sum():
    assert sum([1, 2, 3]) == 6
    assert LOG == ["test_sum"]

def test_len():
    assert len("pytest") == 6
    assert LOG[-1] == "test_len"
""", encoding="utf-8")

import pytest

rc = pytest.main(["test_log.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
Вывод
..                                                                       [100%]
2 passed in 0.01s
exit: 0

Две точки, 2 passed, и ни один тест не упомянул note в аргументах. Разбор трюка: autouse=True включает фикстуру в прогон каждого теста файла, а аргумент request — встроенный объект pytest, через который фикстура узнаёт контекст: request.node.name вернул имя запускаемого теста. Лог зафиксировал оба имени в порядке выполнения — снова сверху вниз, как в четвёртом уроке.

Когда общее состояние опасно

Теперь цена вопроса. Фикстура со scope="module" создаётся один раз — значит, все тесты файла получают один и тот же объект. Пока объект только читают, всё хорошо. Стоит его изменить — изменения доживут до следующих тестов. Проведём опыт: module-корзина, первый тест кладёт в неё товар, второй проверяет, что в ней оказалось.

утечка состояния: молоко дожило до соседа
from pathlib import Path

Path("test_leak.py").write_text("""import pytest

@pytest.fixture(scope="module")
def cart():
    return ["хлеб"]

def test_add_item(cart):
    cart.append("молоко")
    assert cart == ["хлеб", "молоко"]

def test_sees_leftovers(cart):
    assert cart == ["хлеб", "молоко"]
""", encoding="utf-8")

import pytest

rc = pytest.main(["test_leak.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
Вывод
..                                                                       [100%]
2 passed in 0.01s
exit: 0

Оба теста зелёные — и это плохая новость, замаскированная под хорошую. Второй тест увидел молоко, которое добавил первый: изменяемое состояние протекло между тестами. Набор стал зависимым от порядка: поменяй тесты местами — второй упадёт, потому что молока ещё нет. С фикстурой по умолчанию этот опыт невозможен: каждому тесту досталась бы своя корзина только с хлебом.

Сводим обе темы: scope и yield умеют работать в паре. Module-фикстура с уборкой выполняет код после yield один раз — после всех тестов файла. Это доказуемо тем же способом, каким мы проверяли уборку в шестом уроке: фикстура пишет файл на teardown, а скрипт проверяет его после прогона.

teardown module-фикстуры: после всех тестов файла
from pathlib import Path

Path("test_scope.py").write_text("""from pathlib import Path

import pytest

@pytest.fixture(scope="module")
def orders():
    yield 3
    Path("module_end.txt").write_text("фикстура закрылась после всех тестов файла", encoding="utf-8")

def test_orders_count(orders):
    assert orders == 3

def test_orders_positive(orders):
    assert orders > 0
""", encoding="utf-8")

import pytest

rc = pytest.main(["test_scope.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print(Path("module_end.txt").read_text(encoding="utf-8"))
print("exit:", int(rc))
Вывод
..                                                                       [100%]
2 passed in 0.01s
фикстура закрылась после всех тестов файла
exit: 0

Файл появился — значит, teardown module-фикстуры сработал, и сработал ровно один раз: после обоих тестов. Так строят долгоживущие ресурсы: соединение открывается на файл, а закрывается после последнего теста, даже если часть тестов упала — механизм уборки из шестого урока действует и на широком scope.

scope="session": один экземпляр на весь прогон
@pytest.fixture(scope="session")
def engine():
    return "соединение с базой"

@pytest.fixture(scope="session")
def api():
    return "запущенный тестовый сервер"
Фрагмент без запуска: session-фикстуры создаются один раз на весь прогон — до первого теста, который их запросил, где бы он ни лежал. В песочнице хватает function и module, session — инструмент настоящих проектов.

Широкий scope и autouse тоже сочетаются: @pytest.fixture(autouse=True, scope="module") — сквозная логика, выполняемая один раз на файл. Например, засечь время прогона файла целиком и записать в лог:

autouse с широким scope и уборкой
import time
import pytest

TIMES = []

@pytest.fixture(autouse=True, scope="module")
def stopwatch():
    started = time.monotonic()
    yield
    TIMES.append(round(time.monotonic() - started, 2))
Фрагмент без запуска: фикстура без аргументов включается сама, а благодаря scope="module" — один раз на файл. Подготовка до yield, запись времени после.

И последнее в сегодняшней программе: фикстуры не обязаны жить в самом тестовом файле. Вынеси их в conftest.py рядом с тестами — pytest подхватит файл сам, без импортов, а флаги scope и autouse работают там же. Это разыменовывает руки: общая подготовка папки описывается один раз, а не копируется по файлам.

conftest.py: общие фикстуры со scope
# conftest.py - лежит в папке с тестами
import pytest

@pytest.fixture(scope="module")
def config():
    return {"env": "test"}

@pytest.fixture(autouse=True)
def guard():
    yield
    # уборка после каждого теста папки
Фрагмент без запуска: pytest находит conftest.py сам. Полный разбор общих фикстур — в пятнадцатом уроке курса.

Что дальше

Ты умеешь управлять временем жизни фикстур: function — по умолчанию, module — на файл, session — на прогон; autouse — для сквозной логики без аргументов; и знаешь цену широкого scope — общее изменяемое состояние. Фикстуры освоены. Дальше параметризация: один тест и список случаев вместо десятка копий — второй мегаштабирующий приём pytest. А в одиннадцатом уроке встретимся со встроенными фикстурами capsys и tmp_path, которые pytest даёт бесплатно.

Scope — это обмен: платишь общим состоянием, получаешь экономию на подготовке. Покупай только то, что не мутирует.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

calls = 0

def fresh():
    global calls
    calls += 1
    return calls

first = fresh()
second = fresh()
print(first, second)
cache = {}

def once(key, make):
    if key not in cache:
        cache[key] = make()
    return cache[key]

first = once("db", lambda: "соединение")
second = once("db", lambda: "соединение")
print(first is second)
shared = ["хлеб"]
log = []

def use(item):
    shared.append(item)
    log.append(len(shared))

use("молоко")
use("сыр")
print(log)
Проверь себя
0 / 6

1. Какой scope у фикстуры по умолчанию?

2. Сколько раз выполнится фикстура со scope="module" в файле из пяти тестов?

3. Что делает autouse=True у фикстуры?

4. Чем опасна изменяемая фикстура со scope="module"?

5. Что разумно выносить в scope="session"?

6. Что такое request в фикстуре note(request) из урока?

Карточки терминов
Запомнено: 0 / 6
Практика

Собери файл test_pool.py с фикстурой pool со scope="module", которая возвращает список ["соединение"] и считает свои вызовы в словаре made. Напиши два теста: test_pool_alive проверяет содержимое и что made["pool"] == 1, test_pool_reused — первый элемент и снова единицу счётчика. Запусти и напечатай код выхода.

practice.py
Вопросы и ответы по уроку

Сколько раз создаётся фикстура в pytest?

По умолчанию — один раз для каждого теста, который её принял: это scope="function". Аргументом scope декоратора время жизни расширяется: scope="module" — один экземпляр на весь тестовый файл, scope="session" — один на весь прогон. Доказать легко счётчиком: фикстура увеличивает счётчик, а тесты проверяют его значение — при module они увидят единицу.

Что такое autouse-фикстура и когда её применять?

Это фикстура с декоратором @pytest.fixture(autouse=True): pytest применяет её ко всем тестам файла автоматически, без объявления в аргументах. Применение — сквозная логика, нужная каждому тесту: журнал запусков, замер времени, сброс окружения. Через аргумент request фикстура узнаёт имя теста и другой контекст. Для данных конкретных тестов autouse избыточен — там уместны обычные фикстуры по имени.

Когда фикстуре со scope="module" или "session" становится опасно?

Когда её результат изменяем. Все тесты области получают один объект, и мутация из первого теста доживает до второго: набор молча становится зависимым от порядка выполнения — работает сейчас и падает после перестановки тестов. Правило: широким scope снабжают только дорогую неизменяемую подготовку — соединение, конфиг, прочитанный справочник; всё, что тесты меняют, живёт со scope по умолчанию.

Чем scope="module" отличается от scope="session"?

Границей времени жизни: module-фикстура создаётся один раз на тестовый файл и пересоздаётся в следующем файле, session-фикстура живёт весь прогон от первого до последнего теста всех файлов. На практике session достаётся самому дорогому — соединению с базой или запущенному тестовому серверу; в песочнице браузерных уроков почти всегда хватает function и module.

Понравился урок? Сошлитесь на него

«Scope — это обмен: платишь общим состоянием, получаешь экономию на подготовке. Покупай только то, что не мутирует.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.