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

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

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

Фикстуры для 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
Песочница пишет app_tasks.py и test_tasks.py в текущую папку и запускает pytest программно. Дома запиши эти файлы рядом и запусти pytest в терминале — вывод будет тем же.

Оба прогона зелёные — смысл не в результате, а в форме. Шаг 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
Шпаргалка, а не запускаемый код: ровно эти четыре приёма покрывают все тесты курса. Статус и JSON - главная пара: сначала проверяем код ответа, потом содержимое.

conftest.py: один клиент на весь набор

Фикстура в тест-файле обслуживает только свой файл. Как только тест-файлов становится два — тесты задач и тесты на отсутствие маршрутов, — фикстуру пришлось бы копировать. Решение ты знаешь из урока про conftest.py: файл с особым именем, который pytest исполняет сам, а его фикстуры раздаёт всем тестам папки без единого импорта. Заводим conftest.py и переносим клиента туда:

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
В тест-файлах больше нет ни импорта приложения, ни строки создания клиента — только аргумент client. Всё подготовительное хозяйство переехало в conftest.py.

Три точки, 3 passed: два файла с тестами и ни одной строки дублирования. Дома проект выглядит так — четыре файла рядом, pytest в терминале собирает их сам:

Раскладка файлов набора
shop-tests/
  app_tasks.py       # приложение, которое тестируем
  conftest.py        # фикстура client для всех тестов папки
  test_tasks.py      # тесты API задач
  test_health.py     # тесты на отсутствие маршрутов
Схема папки, а не код: один модуль приложения, один conftest.py и сколько угодно test-файлов. Тесты из conftest.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
Флаг --tb=no прячет трассировку. Убери его — и появится точная причина: fixture 'clien' not found. Совпадение имён должно быть точным, буква в букву.

На месте точки — буква E: тест не упал на проверке, он вообще не смог запуститься, потому что подготовка сорвалась. Это принципиальное отличие от F: F — проверка не прошла, E — тест даже не добрался до неё. Код выхода 1 подсказывает CI, что набор красный. Лечится правкой одного имени — и лучше сразу приучиться читать строку ERROR в сводке: там назван файл, тест и причина.

Временная база: tmp_path вместо файла в проекте

Теперь настоящий вызов. У приложения появляется база: тестировать API заметок без базы нельзя — POST должен попадать в SQLite, а GET — читать оттуда. Наивный путь — завести один файл notes.db рядом с тестами и указывать на него всем тестам. Путь этот ломается о два факта: записи одного теста остаются в базе для следующего (тесты начинают зависеть от порядка запуска), а после прогона в репозитории лежит мусорный файл с тестовыми данными.

Приложение для этого раздела — маршрут /api/notes, который читает таблицу notes и добавляет в неё записи. База указывается через config, поэтому каждый тест сможет получить собственный файл:

app_notes.py — приложение с SQLite
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])
Код честный, но модуль в браузере не запускается — он нужен pytest-прогону целиком, следующий блок его пишет и запускает. config["DATABASE"] задаётся снаружи — этим мы и воспользуемся.

Фикстура 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
test_create_note идёт первым и пишет заметку; test_start_empty идёт вторым и требует пустой список. Зелёный прогон и есть доказательство изоляции.

Смотри внимательно на порядок. Первый тест положил в базу заметку. Второй тест потребовал пустой список — и прошёл. Это возможно только потому, что фикстура 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()
Фрагмент без запуска: pytest сам построит цепочку - сначала app, затем 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))
Проверь себя
0 / 6

1. Как тест получает клиента из фикстуры client?

2. Почему фикстуру client выносят в conftest.py?

3. Тест объявил аргумент clien, а фикстура называется client. Что будет?

4. Что даёт тестам фикстура tmp_path в цепочке client(tmp_path)?

5. Первый тест записал заметку в базу, второй требует пустой список. Почему набор зелёный?

6. Чем буква E в отчёте отличается от F?

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

Собери набор с фикстурой в conftest.py: приложение с маршрутом /api/ping, который отвечает JSON {'ping': 'pong'}, собери прямо внутри фикстуры client и верни из неё test_client. В test_ping.py напиши тест test_ping_ok(client): статус 200 и поле ping равно «pong». Запусти файл и напечатай код выхода.

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

Как сделать фикстуру для 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

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

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

Проверьте знания по Flask

В челлендже — 20 задач по Flask, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.

Тест по Flask: 20 задач