Фикстуры для Flask-тестов: conftest, клиент и временная база
Шестнадцатый урок расширения Flask: каждый тест создаёт клиент сам — пора зафиксировать подготовку в фикстурах, вынести её в conftest.py и раздавать тестам временные базы через tmp_path.
Редакция Питоники
В прошлом уроке ты научился писать тесты для Flask через test_client: запрос к приложению без сети, проверка статуса и JSON. Тесты получались честными, но за каждым из них тянулся один и тот же хвост: строка client = app.test_client(). Два теста — две копии. Десять тестов — десять копий. Это та самая копипаста, с которой pytest борется штатным инструментом: фикстурами. В уроке про фикстуры pytest ты их уже видел на словарях и списках — теперь применяем к настоящему Flask-приложению.
План урока такой: сначала заворачиваем создание клиента в фикстуру; затем выносим её в conftest.py, чтобы её видели все файлы набора; и наконец решаем проблему, до которой руки доходят у каждого: тесты с базой данных. Базу каждому тесту будет выдавать встроенная фикстура tmp_path — и это тихо решит проблему изоляции тестов друг от друга.
Проблема: клиент создаётся в каждом тесте
Посмотри на типичный тест-файл Flask-приложения. Приложение маленькое: API со списком задач. Два теста, и в каждом — собственная строка создания клиента. Запусти блок: здесь пишутся оба файла, и pytest прогоняет их дважды — сначала с копипастой, потом с фикстурой:
from pathlib import Path
import sys
Path("app_tasks.py").write_text('''from flask import Flask, jsonify
app = Flask(__name__)
TASKS = [
{"id": 1, "title": "Купить кофе", "done": False},
{"id": 2, "title": "Написать тесты", "done": True},
]
@app.route("/api/tasks")
def tasks():
return jsonify(TASKS)
''', encoding="utf-8")
# шаг 1: клиент создаётся в каждом тесте честной копипастой
Path("test_tasks.py").write_text('''import sys
sys.path.insert(0, "") # песочнице нужен явный путь к текущей папке
from app_tasks import app
def test_two_tasks():
client = app.test_client() # копия первая
resp = client.get("/api/tasks")
assert resp.status_code == 200
assert len(resp.get_json()) == 2
def test_first_title():
client = app.test_client() # копия вторая
resp = client.get("/api/tasks")
assert resp.get_json()[0]["title"] == "Купить кофе"
''', encoding="utf-8")
for name in ("app_tasks", "test_tasks"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_tasks.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
# шаг 2: та же подготовка, но в фикстуре
Path("test_tasks.py").write_text('''import sys
sys.path.insert(0, "") # песочнице нужен явный путь к текущей папке
from app_tasks import app
import pytest
@pytest.fixture
def client():
return app.test_client()
def test_two_tasks(client):
resp = client.get("/api/tasks")
assert resp.status_code == 200
assert len(resp.get_json()) == 2
def test_first_title(client):
resp = client.get("/api/tasks")
assert resp.get_json()[0]["title"] == "Купить кофе"
''', encoding="utf-8")
for name in ("test_tasks",):
sys.modules.pop(name, None)
rc = pytest.main(["test_tasks.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.. [100%] 2 passed in 0.03s exit: 0 .. [100%] 2 passed in 0.01s exit: 0
Оба прогона зелёные — смысл не в результате, а в форме. Шаг 1: у каждого теста своя строка client = app.test_client(), размеченная комментариями «копия первая» и «копия вторая». Шаг 2: строка одна, живёт в функции client под декоратором @pytest.fixture, а тесты просто объявляют аргумент с этим именем. Pytest видит аргумент client, находит фикстуру с таким же именем, вызывает её и передаёт готового клиента в тест. Тест не вызывает фикстуру сам, не импортирует её — только называет.
За кадром осталась одна деталь, которая для тестов критична: pytest вызывает фикстуру заново для каждого теста. test_two_tasks получил собственного клиента, test_first_title — своего. Для test_client это не принципиально (клиент ничего не хранит), но запомни это свойство — через пару разделов оно станет главной защитой наших тестов.
# GET: адрес и, при необходимости, строка запроса
resp = client.get("/api/tasks")
resp = client.get("/api/tasks", query_string={"done": "true"})
# POST с JSON-телом - так тесты отправляют данные формы/API
resp = client.post("/api/notes", json={"text": "Купить кофе"})
# проверка ответа
resp.status_code # 200, 201, 400, 404...
resp.get_json() # тело ответа как объект Python
resp.data # тело ответа как bytes
conftest.py: один клиент на весь набор
Фикстура в тест-файле обслуживает только свой файл. Как только тест-файлов становится два — тесты задач и тесты на отсутствие маршрутов, — фикстуру пришлось бы копировать. Решение ты знаешь из урока про conftest.py: файл с особым именем, который pytest исполняет сам, а его фикстуры раздаёт всем тестам папки без единого импорта. Заводим conftest.py и переносим клиента туда:
from pathlib import Path
import sys
Path("conftest.py").write_text('''import sys
sys.path.insert(0, "") # песочнице нужен явный путь к текущей папке
import pytest
from app_tasks import app
@pytest.fixture
def client():
return app.test_client()
''', encoding="utf-8")
Path("test_tasks.py").write_text('''def test_two_tasks(client):
resp = client.get("/api/tasks")
assert resp.status_code == 200
assert len(resp.get_json()) == 2
def test_first_title(client):
resp = client.get("/api/tasks")
assert resp.get_json()[0]["title"] == "Купить кофе"
''', encoding="utf-8")
Path("test_health.py").write_text('''def test_root_is_404(client):
# корневой страницы в приложении нет - значит, 404
assert client.get("/").status_code == 404
''', encoding="utf-8")
for name in ("conftest", "test_tasks", "test_health"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_tasks.py", "test_health.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.04s exit: 0
Три точки, 3 passed: два файла с тестами и ни одной строки дублирования. Дома проект выглядит так — четыре файла рядом, pytest в терминале собирает их сам:
shop-tests/
app_tasks.py # приложение, которое тестируем
conftest.py # фикстура client для всех тестов папки
test_tasks.py # тесты API задач
test_health.py # тесты на отсутствие маршрутов
Ошибка имён: fixture 'clien' not found
Механизм «имя аргумента — адрес фикстуры» ломается об опечатки. Напишешь в тесте clien вместо client — pytest не найдёт фикстуру с таким именем и пометит тест ошибкой. Сымитируем — перепишем test_tasks.py с пропущенной буквой:
from pathlib import Path
import sys
Path("test_tasks.py").write_text('''def test_two_tasks(clien): # опечатка: пропущена t на конце
resp = clien.get("/api/tasks")
assert resp.status_code == 200
''', encoding="utf-8")
for name in ("test_tasks",):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_tasks.py", "-q", "--no-header", "--tb=no", "-p", "no:cacheprovider"])
print("exit:", int(rc))
E [100%] =========================== short test summary info =========================== ERROR test_tasks.py::test_two_tasks 1 error in 0.02s exit: 1
На месте точки — буква E: тест не упал на проверке, он вообще не смог запуститься, потому что подготовка сорвалась. Это принципиальное отличие от F: F — проверка не прошла, E — тест даже не добрался до неё. Код выхода 1 подсказывает CI, что набор красный. Лечится правкой одного имени — и лучше сразу приучиться читать строку ERROR в сводке: там назван файл, тест и причина.
Временная база: tmp_path вместо файла в проекте
Теперь настоящий вызов. У приложения появляется база: тестировать API заметок без базы нельзя — POST должен попадать в SQLite, а GET — читать оттуда. Наивный путь — завести один файл notes.db рядом с тестами и указывать на него всем тестам. Путь этот ломается о два факта: записи одного теста остаются в базе для следующего (тесты начинают зависеть от порядка запуска), а после прогона в репозитории лежит мусорный файл с тестовыми данными.
Приложение для этого раздела — маршрут /api/notes, который читает таблицу notes и добавляет в неё записи. База указывается через config, поэтому каждый тест сможет получить собственный файл:
import sqlite3
from flask import Flask, jsonify, request
app = Flask(__name__)
@app.route("/api/notes", methods=["GET", "POST"])
def notes():
db = sqlite3.connect(app.config["DATABASE"])
if request.method == "POST":
db.execute(
"INSERT INTO notes (text) VALUES (?)",
(request.get_json()["text"],),
)
db.commit()
rows = db.execute("SELECT id, text FROM notes").fetchall()
db.close()
return jsonify([{"id": r[0], "text": r[1]} for r in rows])
Фикстура client принимает аргумент tmp_path — встроенную фикстуру pytest, о которой рассказывалось в уроке про встроенные фикстуры: pytest сам создаёт уникальную временную папку и передаёт путь объектом pathlib.Path. Одна фикстура просит другую — цепочка, которую pytest разруливает сам:
from pathlib import Path
import sys
Path("app_notes.py").write_text('''import sqlite3
from flask import Flask, jsonify, request
app = Flask(__name__)
@app.route("/api/notes", methods=["GET", "POST"])
def notes():
db = sqlite3.connect(app.config["DATABASE"])
if request.method == "POST":
db.execute(
"INSERT INTO notes (text) VALUES (?)",
(request.get_json()["text"],),
)
db.commit()
rows = db.execute("SELECT id, text FROM notes").fetchall()
db.close()
return jsonify([{"id": r[0], "text": r[1]} for r in rows])
''', encoding="utf-8")
Path("conftest.py").write_text('''import sqlite3
import sys
sys.path.insert(0, "") # песочнице нужен явный путь к текущей папке
import pytest
from app_notes import app
@pytest.fixture
def client(tmp_path):
# каждому тесту - своя база во временной папке pytest
app.config["DATABASE"] = str(tmp_path / "notes.db")
db = sqlite3.connect(app.config["DATABASE"])
db.execute("CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT)")
db.commit()
db.close()
return app.test_client()
''', encoding="utf-8")
Path("test_notes.py").write_text('''def test_create_note(client):
resp = client.post("/api/notes", json={"text": "Купить кофе"})
assert resp.get_json() == [{"id": 1, "text": "Купить кофе"}]
def test_start_empty(client):
# общая база утечёт заметкой из прошлого теста - свежая не утечёт
assert client.get("/api/notes").get_json() == []
''', encoding="utf-8")
for name in ("app_notes", "conftest", "test_notes"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_notes.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.. [100%] 2 passed in 0.06s exit: 0
Смотри внимательно на порядок. Первый тест положил в базу заметку. Второй тест потребовал пустой список — и прошёл. Это возможно только потому, что фикстура client вызвалась для него заново и получила собственный tmp_path: новая папка, новый файл notes.db, пустая таблица. Переставь мысленно эти тесты на общую базу — второй тест упал бы с AssertError, потому что чужая заметка осталась бы в таблице. Фикстура отдаёт каждому тесту собственный клиент и собственную базу: тесты не знают друг о друге и потому не могут сломать соседний тест.
tmp_path — это pathlib.Path на временную папку, уникальную для текущего теста: pytest нумерует их и прибирает за прогоном. Поэтому в фикстуре работает обычная запись tmp_path / "notes.db", а в config путь уходит строкой через str(). Никакого файла в репозитории после прогона не остаётся — база жила во временной папке и умерла вместе с ней.
@pytest.fixture
def app(tmp_path):
# приложение с настройками под тесты
app.config["DATABASE"] = str(tmp_path / "notes.db")
return app
@pytest.fixture
def client(app):
# фикстура принимает другую фикстуру аргументом
return app.test_client()
| Инструмент | Где живёт | Что обслуживает |
|---|---|---|
| Фикстура в test-файле | рядом со своими тестами | тесты одного файла |
| Фикстура в conftest.py | общая папка набора | все тесты папки без импортов |
| tmp_path | встроена в pytest | уникальная временная папка каждому тесту |
| app.config["DATABASE"] | на приложении | точка, куда фикстура подставляет путь базы |
Итоги
Набор тестов приобрёл инфраструктуру: клиент создаётся один раз в фикстуре и живёт в conftest.py, каждому тесту выдаётся собственная временная база через tmp_path, а опечатка в имени фикстуры честно видна как ERROR в сводке. Тесты перестали зависеть от порядка запуска — а это главный признак взрослого набора.
Осталась одна трещина: conftest импортирует модуль приложения целиком — и все тесты делят один глобальный объект app. Положишь в него тестовые данные — они протекут в соседний тест. Лечится паттерном «фабрика приложений»: в следующем уроке ты напишешь create_app(), которая собирает свежее приложение на каждый вызов, и зафикстуришь его сборку. Карта всего курса — самоучитель Flask.
Фикстура отдаёт каждому тесту собственный клиент и собственную базу: тесты не знают друг о друге и потому не могут сломать соседний тест.
Сначала предскажи ответ в голове — это главный навык программиста.
calls = []
def client():
calls.append("создан")
return {"app": "tasks"}
a = client() # тест один
b = client() # тест два
print(len(calls), a is b)
fixtures = {}
def register(name, obj):
fixtures[name] = obj
register("client", "клиент из conftest")
def resolve(arg):
if arg in fixtures:
return fixtures[arg]
return "fixture '" + arg + "' not found"
print(resolve("clien"))
counter = 0
def tmp_path():
global counter
counter += 1
return "tmp/test-" + str(counter)
bases = [tmp_path(), tmp_path(), tmp_path()]
print(len(set(bases)), len(bases))
1. Как тест получает клиента из фикстуры client?
2. Почему фикстуру client выносят в conftest.py?
3. Тест объявил аргумент clien, а фикстура называется client. Что будет?
4. Что даёт тестам фикстура tmp_path в цепочке client(tmp_path)?
5. Первый тест записал заметку в базу, второй требует пустой список. Почему набор зелёный?
6. Чем буква E в отчёте отличается от F?
Собери набор с фикстурой в conftest.py: приложение с маршрутом /api/ping, который отвечает JSON {'ping': 'pong'}, собери прямо внутри фикстуры client и верни из неё test_client. В test_ping.py напиши тест test_ping_ok(client): статус 200 и поле ping равно «pong». Запусти файл и напечатай код выхода.
Как сделать фикстуру для test_client во Flask?
Опиши функцию с декоратором @pytest.fixture, которая возвращает app.test_client(), а в тестах объявляй аргумент с этим именем: def test_list(client). Pytest сам вызовет фикстуру и подставит свежего клиента каждому тесту. Общую фикстуру для всех файлов набора перенеси в conftest.py.
Как изолировать тесты Flask, которые пишут в базу данных?
Не давайте тестам общий файл базы. Примите в фикстуре встроенную фикстуру pytest tmp_path, положите базу в эту временную папку и подставьте путь через app.config: каждый тест получит собственную пустую базу, а после прогона файлы не останутся. Записи одного теста перестанут зависеть от порядка запуска.
Почему pytest пишет fixture 'client' not found?
Аргумент теста не совпал ни с одной фикстурой: либо опечатка в имени (clien вместо client), либо фикстура объявлена в другом тест-файле и не видна отсюда. Проверьте имя буква в букву и перенесите общую фикстуру в conftest.py — pytest подхватит её для всех тестов папки сам.
Можно ли создавать test_client один раз на весь набор?
Технически да — через @pytest.fixture(scope="session"), но вместе с клиентом вы делаете общим и приложение с его config: записи в базу одного теста увидит следующий. Пока тесты пишут в базу или меняют config, держите фикстуры в области по умолчанию — свежий экземпляр каждому тесту. Экономия миллисекунд не стоит зависимых тестов.
Понравился урок? Сошлитесь на него
«Фикстура отдаёт каждому тесту собственный клиент и собственную базу: тесты не знают друг о друге и потому не могут сломать соседний тест.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
Flask · Урок 15
Тестирование Flask: pytest и test_client
Пятнадцатый урок курса Flask — мост в мир автотестов: проверки API из предыдущих уроков становятся тест-функциями pytest с assert, а app.test_client() работает внутри теста без сервера и сети.
pytest · Урок 5
Фикстуры pytest: @pytest.fixture для подготовки данных
Одна и та же корзина собирается в каждом тесте заново — знакомая копипаста. Фикстура описывает подготовку один раз, а pytest сам передаёт результат в тест по имени.
pytest · Урок 15
conftest.py: общие фикстуры для всей папки
Фикстуры перестали быть домашними: conftest.py делает их общими для всей папки — без единого импорта, силами самого pytest.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
pytest · Урок 17
Тесты для JSON и файлов: tmp_path на практике
Сохранить заказы в JSON, прочитать обратно и не разочароваться: round-trip тесты, кириллица с encoding utf-8, битые файлы через pytest.raises и tmp_path — папка, которая достаётся каждому тесту своя.
pytest тестирование файловpytest tmp_path
pytest · Урок 20
Финальный проект: полный тест-сьют модуля работы с заказами
Выпускной: модуль заказов со скидками, классом Order, JSON и валютой — и полный сьют из шестнадцати тестов, который держит его весь целиком и гоняется одной командой.
pytest финальный проектpytest тест-сьют
Flask · Урок 20
Проект: сервис заметок с тестами — фабрика, blueprint и pytest
Финал расширения: собираем всё выученное в одно приложение — JSON API заметок на фабрике create_app с блюпринтом, дымовой прогон через test_client и полный pytest-набор на 200, 201, 400 и 404.
flask проект api заметок тестыflask api заметок пример
Проверьте знания по Flask
В челлендже — 20 задач по Flask, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.
Тест по Flask: 20 задач