Фабрика приложений: паттерн create_app
Семнадцатый урок расширения Flask: один глобальный app протекает состоянием между тестами. Паттерн «фабрика приложений» собирает свежий экземпляр с блюпринтом и конфигом на каждый тест.
Редакция Питоники
В прошлом уроке мы научили тесты получать клиента из фикстуры и работали на временных базах. Но в фундаменте осталась трещина, которую мы честно отложили: все наши тесты импортируют один и тот же объект app из модуля приложения. Пока приложение возвращает константы — ничего страшного. Как только в нём появляется изменяемое состояние, тесты начинают друг другу мешать. Сегодня разберём, почему так происходит, и выучим паттерн, через который проходят все серьёзные Flask-проекты: фабрику приложений create_app().
Проблема: состояние течёт через глобальный app
Гостевая книга: список записей хранится прямо в модуле приложения, POST дописывает в него, GET возвращает. Смотри, что сделает с таким приложением набор из двух невинных тестов — первый добавляет запись, второй требует, чтобы список начинался с пустого:
from pathlib import Path
import sys
Path("app_guestbook.py").write_text('''from flask import Flask, jsonify, request
app = Flask(__name__)
ENTRIES = [] # глобальное хранилище - живёт вне приложения
@app.route("/api/entries", methods=["GET", "POST"])
def entries():
if request.method == "POST":
ENTRIES.append(request.get_json()["text"])
return jsonify(ENTRIES)
''', encoding="utf-8")
Path("test_guestbook.py").write_text('''import sys
sys.path.insert(0, "") # песочнице нужен явный путь к текущей папке
from app_guestbook import app
def test_add_entry():
client = app.test_client()
client.post("/api/entries", json={"text": "Привет!"})
assert len(client.get("/api/entries").get_json()) == 1
def test_list_starts_empty():
client = app.test_client()
assert len(client.get("/api/entries").get_json()) == 0
''', encoding="utf-8")
for name in ("app_guestbook", "test_guestbook"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_guestbook.py", "-q", "--no-header", "--tb=no", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.F [100%] =========================== short test summary info =========================== FAILED test_guestbook.py::test_list_starts_empty - AssertionError: assert 1 == 0 1 failed, 1 passed in 0.18s exit: 1
Второй тест упал, хотя оба по отдельности правильные. Причина в том, как Python работает с модулями: from app_guestbook import app исполняет модуль один раз и кладёт его в кэш импортов. Все тесты процесса получают один и тот же объект app — и один и тот же список ENTRIES, который живёт в модуле, а не в приложении. Первый тест дописал «Привет!» в общий список, и второй увидел чужие данные. Тесты стали зависимыми: поменяй их местами — упадёт уже другой.
board/
app/
__init__.py # create_app(): config, расширения, блюпринты
board_api.py # Blueprint("api") с маршрутами
conftest.py # фикстура app = create_app({"TESTING": True})
test_board.py # тесты - каждому своё приложение
create_app(): приложение как функция
Идея фабрики простая: не создавай приложение на уровне модуля, а научись собирать его функцией. Каждый вызов create_app() возвращает новенький, ни разу не использованный объект Flask — со своими настройками и своими маршрутами. Состояние при этом кладётся не в глобальные переменные, а на само приложение, в app.config:
from flask import Flask, jsonify
def create_app(config=None):
"""Собирает приложение заново при каждом вызове."""
app = Flask(__name__)
app.config.update(config or {})
@app.route("/api/greeting")
def greeting():
name = app.config.get("NAME", "гость")
formal = app.config.get("FORMAL", False)
if formal:
return jsonify({"text": "Здравствуйте, " + name})
return jsonify({"text": "Привет, " + name})
return app
# два приложения из одной фабрики - с разными настройками
casual = create_app()
official = create_app({"NAME": "Анна", "FORMAL": True})
print(casual.test_client().get("/api/greeting").get_json())
print(official.test_client().get("/api/greeting").get_json())
print("Приложения разные:", casual is not official)
{'text': 'Привет, гость'}
{'text': 'Здравствуйте, Анна'}
Приложения разные: TrueРазбор по строчкам. Аргумент config=None принимает словарь настроек; app.config.update(config or {}) применяет его поверх значений по умолчанию — вызвал фабрику без аргументов, получил приложение с дефолтами. Маршруты объявляются внутри фабрики, поэтому каждое собранное приложение получает собственную копию. А проверка casual is not official подтверждает главное: это два разных объекта, изменение одного не видно другому.
Фабрика — не изобретение для тестов, а штатный паттерн Flask: документация называет его application factory и рекомендует для проектов любого размера. В большом проекте фабрика живёт в пакете приложения и собирает всё: конфиг из классов (это было в уроке про конфигурацию), расширения, блюпринты:
from flask import Flask
def create_app(config=None):
app = Flask(__name__)
app.config.update(config or {})
from .db import db
db.init_app(app) # расширение привязывается к приложению
from .blog import bp as blog_bp
from .auth import bp as auth_bp
app.register_blueprint(blog_bp, url_prefix="/blog")
app.register_blueprint(auth_bp, url_prefix="/auth")
return app
Откуда вообще взялась привычка писать app = Flask(__name__) на уровне модуля? Она естественна: объект нужен и декораторам маршрутов, и flask run, и test_client — проще создать один раз сверху. Пока проект маленький, эта строка ничем не мешает. Плата приходит позже, и приходит она сразу в трёх местах: приложение невозможно собрать второй раз с другим конфигом (оно уже построено), состояние, привязанное к нему, живёт весь процесс, а импорт модуля имеет побочный эффект — «просто импортирую модели» и получаешь собранное приложение со всеми его привычками. Фабрика переворачивает отношение: модуль экспортирует не готовый объект, а способ его построить — решение, когда и что строить, остаётся вызывающему.
Блюпринт регистрируется внутри фабрики
Блюпринт из урока про Blueprint идеально ложится на фабрику: маршруты живут в отдельном модуле и не зависят от приложения, а фабрика вешает их на каждый собранный экземпляр. Заодно решим проблему состояния честно: хранилище задач берём из current_app.config — то есть из того приложения, которое обслуживает текущий запрос:
from flask import Blueprint, Flask, current_app, jsonify, request
bp = Blueprint("api", __name__)
@bp.route("/api/tasks", methods=["GET", "POST"])
def tasks():
store = current_app.config["TASKS"]
if request.method == "POST":
store.append({"id": len(store) + 1, "text": request.get_json()["text"]})
return jsonify(store)
def create_app(config=None):
app = Flask(__name__)
app.config.update(config or {})
app.config.setdefault("TASKS", []) # хранилище живёт на приложении
app.register_blueprint(bp) # блюпринт регистрируется внутри
return app
first = create_app()
first.test_client().post("/api/tasks", json={"text": "Задача один"})
second = create_app()
print("Первое приложение:", first.test_client().get("/api/tasks").get_json())
print("Второе приложение:", second.test_client().get("/api/tasks").get_json())
Первое приложение: [{'id': 1, 'text': 'Задача один'}]
Второе приложение: []Вот теперь протечка закрыта принципиально: состояние хранится на приложении, а приложение собирается заново каждой фабрикой. current_app — объект Flask «приложение, которое обрабатывает текущий запрос», поэтому один и тот же код блюпринта честно обслуживает оба экземпляра, доставая данные из своего.
app.register_blueprint(bp)
app.register_blueprint(bp)
Traceback (most recent call last):
...
ValueError: The name 'api' is already registered for this
blueprint. Use 'name=' to provide a unique name.
Фабрика в тестах: свежий экземпляр каждому тесту
Теперь соберём всё вместе с фикстурами из прошлого урока: в conftest.py живёт фикстура app, которая вызывает фабрику, а фикстура client принимает её аргументом. Каждый тест получает собственное приложение — те самые «свежие коробки»:
from pathlib import Path
import sys
Path("app_board.py").write_text('''from flask import Blueprint, Flask, current_app, jsonify, request
bp = Blueprint("api", __name__)
@bp.route("/api/tasks", methods=["GET", "POST"])
def tasks():
store = current_app.config["TASKS"]
if request.method == "POST":
store.append({"id": len(store) + 1, "text": request.get_json()["text"]})
return jsonify(store)
def create_app(config=None):
app = Flask(__name__)
app.config.update(config or {})
app.config.setdefault("TASKS", [])
app.register_blueprint(bp)
return app
''', encoding="utf-8")
Path("conftest.py").write_text('''import sys
sys.path.insert(0, "") # песочнице нужен явный путь к текущей папке
import pytest
from app_board import create_app
@pytest.fixture
def app():
# свежий экземпляр приложения на каждый тест
return create_app({"TESTING": True})
@pytest.fixture
def client(app):
return app.test_client()
''', encoding="utf-8")
Path("test_board.py").write_text('''def test_add_task(client):
client.post("/api/tasks", json={"text": "Полить цветы"})
assert len(client.get("/api/tasks").get_json()) == 1
def test_starts_clean(client):
assert client.get("/api/tasks").get_json() == []
''', encoding="utf-8")
for name in ("app_board", "conftest", "test_board"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_board.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.. [100%] 2 passed in 0.03s exit: 0
Обрати внимание на "TESTING": True в фикстуре app: это стандартный флаг Flask, при котором приложение меняет поведение для тестов — например, отдаёт настоящие исключения вместо аккуратных страниц ошибок. Именно поэтому фабрика с параметром config удобнее глобального app: тестовый и рабочий режимы собираются из одного кода с разными настройками.
@pytest.fixture
def app(tmp_path):
return create_app({
"TESTING": True,
"DATABASE": str(tmp_path / "board.db"),
})
@pytest.fixture
def client(app):
return app.test_client()
| Сценарий | Глобальный app | Фабрика create_app |
|---|---|---|
| Состояние между тестами | течёт через общий модуль | у каждого теста свой экземпляр |
| Разные конфиги | переписывать config руками | словарь аргументом фабрики |
| Блюпринты | регистрируются один раз навсегда | регистрируются при каждой сборке |
| Порядок тестов | влияет на результат | не влияет |
Честности ради: фабрика нужна не каждому проекту. Скрипт на три маршрута, учебное приложение, прототип на вечер — глобальный app читается проще, и заставлять себя писать create_app() ради формы не стоит. Сигналы, что фабрика уже назрела, такие: у проекта появился хотя бы один тест с test_client, конфиг начал переключаться между режимами, или приложение нужно собирать в двух местах — например, рабочее и для сид-скрипта из следующего урока. Всё это случается раньше, чем кажется, поэтому паттерн из этого урока ты, скорее всего, применишь уже в первом своём серьёзном проекте.
Итоги
Трещина закрыта: приложение больше не глобальная переменная, а результат вызова create_app(). Блюпринты регистрируются внутри фабрики, конфиг подаётся аргументом, изменяемое состояние живёт на приложении — и каждый тест получает собственную, стерильную сборку. Набор тестов перестал зависеть от порядка запуска окончательно, на любом уровне.
Приложение собрано и протестировано — осталось обсудить, как его вообще запускают на машине: не app.run(), а flask run, и не только. В следующем уроке познакомимся с командной строкой Flask: переменной FLASK_APP, встроенными командами и своими командами для сидов и обслуживания, которые можно вызывать даже из тестов. Карта курса — самоучитель Flask.
Фабрика приложений — это функция, которая собирает Flask-приложение заново при каждом вызове: свежий конфиг, чистое состояние.
Сначала предскажи ответ в голове — это главный навык программиста.
def create_app():
return {"tasks": []}
a = create_app()
a["tasks"].append("задача")
b = create_app()
print(len(b["tasks"]))
def create_app(config=None):
conf = {"LANG": "ru"}
conf.update(config or {})
return conf
ru = create_app()
en = create_app({"LANG": "en"})
print(ru["LANG"], en["LANG"])
STORE = []
def create_app_bad():
return {"store": STORE}
def create_app_good():
return {"store": []}
first = create_app_bad()
second = create_app_bad()
first["store"].append("x")
print(len(second["store"]), end=" ")
g1 = create_app_good()
g2 = create_app_good()
g1["store"].append("x")
print(len(g2["store"]))
1. Почему тесты гостевой книги влияли друг на друга?
2. Что делает create_app(config=None)?
3. Где должно жить изменяемое состояние при фабрике?
4. Зачем блюпринт регистрируют внутри create_app()?
5. Что даёт фикстура app = create_app({"TESTING": True}) в conftest.py?
6. Что произойдёт при повторном register_blueprint того же блюпринта на одном приложении?
Собери фабрику create_app(prefix) для API приветствий: маршрут /api/greeting возвращает JSON {'text': '<prefix>, мир'}, где префикс берётся из config с ключом GREETING (по умолчанию «Привет»). Собери два приложения: с дефолтами и с GREETING = «Здравствуйте», проверь через test_client, что ответы разные, и напечатай оба текста.
Что такое фабрика приложений во Flask и зачем она нужна?
Это паттерн create_app(): приложение не создаётся на уровне модуля, а собирается функцией при каждом вызове — с поданным конфигом и зарегистрированными блюпринтами. Главное следствие для тестов: каждый тест получает свежий экземпляр приложения, и состояние перестаёт протекать между тестами. Документация Flask рекомендует фабрику для всех проектов, кроме самых маленьких.
Почему тесты проходят по одному, но падают при запуске набора?
Классический признак протечки состояния: тесты делят один объект приложения или глобальную переменную модуля. По отдельности каждый тест застаёт чистые данные, а в наборе — последствия предыдущего. Лечение: перенести состояние на app.config или расширения и собирать приложение фабрикой create_app() в фикстуре, чтобы каждый тест получал собственный экземпляр.
Где регистрировать блюпринты при использовании фабрики?
Внутри create_app(): импортируйте блюпринт и вызовите app.register_blueprint(bp, url_prefix=...) до return. Тогда каждое собранное приложение получит маршруты. Регистрация одного блюпринта дважды на одном приложении даёт ValueError об уже занятом имени — фабрике это не грозит, она каждый раз собирает новое.
Чем create_app() отличается от обычного app = Flask(__name__)?
Глобальный app создаётся один раз при импорте модуля: все, кто его импортировал, делят один объект с одним конфигом и одним состоянием. create_app() возвращает новый экземпляр на каждый вызов: тесты собирают приложение с TESTING=True, рабочий скрипт — с боевым конфигом, и никто никому не мешает.
Понравился урок? Сошлитесь на него
«Фабрика приложений — это функция, которая собирает Flask-приложение заново при каждом вызове: свежий конфиг, чистое состояние.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
Flask · Урок 16
Фикстуры для Flask-тестов: conftest, клиент и временная база
Шестнадцатый урок расширения Flask: каждый тест создаёт клиент сам — пора зафиксировать подготовку в фикстурах, вынести её в conftest.py и раздавать тестам временные базы через tmp_path.
Flask · Урок 12
Конфигурация Flask: config-классы и переменные окружения
Двенадцатый урок курса Flask: настройки живут не в коде, а в конфигурации. Собираем класс Config, читаем SECRET_KEY из переменных окружения и переключаем приложение в тестовый режим одной строкой app.config.update(TESTING=True).
pytest · Урок 15
conftest.py: общие фикстуры для всей папки
Фикстуры перестали быть домашними: conftest.py делает их общими для всей папки — без единого импорта, силами самого pytest.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
pytest · Урок 16
Тестирование классов: методы, состояние и Test-классы
Корзина магазина как подопытная: фикстура раздаёт каждому тесту свежий объект, Test-класс наводит порядок в методах — и ни одно состояние не протекает.
pytest тестирование состоянияpytest группировка тестов
pytest · Урок 12
monkeypatch: подмена функций и окружения в тестах
Функция ходит во внешний мир — за курсом валют, случайностью, переменными окружения — и тест теряет управление. monkeypatch подменяет внешний мир заглушкой на время теста и убирает её сам.
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 задач