Проект: сервис заметок с тестами — фабрика, blueprint и pytest
Финал расширения: собираем всё выученное в одно приложение — JSON API заметок на фабрике create_app с блюпринтом, дымовой прогон через test_client и полный pytest-набор на 200, 201, 400 и 404.
Редакция Питоники
Десять уроков назад расширение начиналось со страницы ошибок и конфигурации, а сейчас на столе всё для настоящего проекта: фабрика приложений из семнадцатого урока, блюпринты из восьмого, фикстуры и временные базы из шестнадцатого, API-статусы из тринадцатого и четырнадцатого. Соберём из этого финальное приложение — JSON API заметок с полным тестовым набором. Каждый большой блок этого урока запускается целиком: приложение пишется в файлы, pytest гоняет тесты, ты видишь честную сводку.
notes-project/
notes_api.py # блюпринт с маршрутами + фабрика create_app
conftest.py # фикстуры app и client для всех тестов
test_notes_api.py # контракт API: 200, 201, 400, 404
test_notes_isolation.py # проверка изоляции приложений
Блюпринт: маршруты API в отдельном модуле
Начнём с маршрутов. Блюпринт notes объявляет два правила: список заметок с чтением и созданием, и страница одной заметки. Хранилище — список в current_app.config["NOTES"]: состояние живёт на приложении, как постановил урок про фабрику, поэтому код маршрутов не зависит от того, сколько экземпляров приложения соберётся:
from flask import Blueprint, Flask, current_app, jsonify, request
bp = Blueprint("notes", __name__)
@bp.route("/api/notes", methods=["GET", "POST"])
def notes_list():
store = current_app.config["NOTES"]
if request.method == "POST":
text = (request.get_json(silent=True) or {}).get("text", "").strip()
if not text:
return jsonify({"error": "Поле text обязательно"}), 400
note = {"id": max((n["id"] for n in store), default=0) + 1, "text": text}
store.append(note)
return jsonify(note), 201
return jsonify(store)
@bp.route("/api/notes/<int:note_id>")
def note_detail(note_id):
for note in current_app.config["NOTES"]:
if note["id"] == note_id:
return jsonify(note)
return jsonify({"error": "Заметка не найдена"}), 404
Вторая половина модуля — фабрика. Она применяет конфиг, подставляет хранилище по умолчанию и вешает блюпринт на свежесобранное приложение:
def create_app(config=None):
app = Flask(__name__)
app.config.update(config or {})
app.config.setdefault("NOTES", [])
app.register_blueprint(bp)
return app
Дымовой прогон: API отвечает
Прежде чем писать тесты, убедимся, что API вообще живой. Блок ниже записывает модуль целиком, собирает приложение с одной стартовой заметкой и прогоняет четыре запроса через test_client — как это делалось ещё в уроке про REST API. Статусы печатаются первыми числами каждой строки:
from pathlib import Path
import sys
sys.path.insert(0, "") # песочнице нужен явный путь к текущей папке
Path("notes_api.py").write_text('''from flask import Blueprint, Flask, current_app, jsonify, request
bp = Blueprint("notes", __name__)
@bp.route("/api/notes", methods=["GET", "POST"])
def notes_list():
store = current_app.config["NOTES"]
if request.method == "POST":
text = (request.get_json(silent=True) or {}).get("text", "").strip()
if not text:
return jsonify({"error": "Поле text обязательно"}), 400
note = {"id": max((n["id"] for n in store), default=0) + 1, "text": text}
store.append(note)
return jsonify(note), 201
return jsonify(store)
@bp.route("/api/notes/<int:note_id>")
def note_detail(note_id):
for note in current_app.config["NOTES"]:
if note["id"] == note_id:
return jsonify(note)
return jsonify({"error": "Заметка не найдена"}), 404
def create_app(config=None):
app = Flask(__name__)
app.config.update(config or {})
app.config.setdefault("NOTES", [])
app.register_blueprint(bp)
return app
''', encoding="utf-8")
from notes_api import create_app
app = create_app({"NOTES": [{"id": 1, "text": "Первая заметка"}]})
client = app.test_client()
r = client.get("/api/notes")
print(r.status_code, r.get_json())
r = client.post("/api/notes", json={"text": "Купить кофе"})
print(r.status_code, r.get_json())
r = client.get("/api/notes/2")
print(r.status_code, r.get_json())
r = client.get("/api/notes/99")
print(r.status_code, r.get_json())
200 [{'id': 1, 'text': 'Первая заметка'}]
201 {'id': 2, 'text': 'Купить кофе'}
200 {'id': 2, 'text': 'Купить кофе'}
404 {'error': 'Заметка не найдена'}Две детали в этом выводе достойны внимания. Первая — сортировка ключей: jsonify печатает {'id': 2, 'text': ...} по алфавиту, даже если в словаре ключи шли в другом порядке. Flask делает это намеренно: одинаковые данные всегда дают одинаковую строку ответа, а тесты и кэши сравнивают ответы спокойно, без перестановок. Вторая — конвертер <int:note_id>: запрос к /api/notes/abc не попадёт в функцию note_detail вовсе, потому что «abc» не целое число, — Flask сам ответит 404 ещё на этапе маршрутизации, и твоя проверка «not found» остаётся только для честных чисел, которых нет в хранилище.
Обрати внимание на id созданной заметки: max((n["id"] for n in store), default=0) + 1 берёт наибольший существующий и добавляет единицу — на пустом хранилище default=0 даёт первый id. После создания через POST заметка с id 2 действительно читается отдельным маршрутом. Всё работает, но «запустил руками и увидел» — не доказательство: завтра правка маршрута всё сломает, и никто не заметит. Доказательство — тесты.
Тестовый набор: conftest и четыре статуса
Подготовка переезжает в conftest.py — по схеме урока про фикстуры: фикстура app вызывает фабрику с тестовым конфигом, фикстура client принимает её аргументом. В стартовом хранилище — одна заметка, чтобы у тестов чтения был материал:
import sys
sys.path.insert(0, "") # песочнице нужен явный путь к текущей папке
import pytest
from notes_api import create_app
@pytest.fixture
def app():
return create_app({
"TESTING": True,
"NOTES": [{"id": 1, "text": "Первая заметка"}],
})
@pytest.fixture
def client(app):
return app.test_client()
Тесты проверяют контракт API целиком: и happy path, и отказы. Шесть тестов — по одному на статус и два на последствия создания:
def test_list_200(client):
assert client.get("/api/notes").status_code == 200
def test_detail_200(client):
resp = client.get("/api/notes/1")
assert resp.status_code == 200
assert resp.get_json()["text"] == "Первая заметка"
def test_detail_404(client):
assert client.get("/api/notes/99").status_code == 404
def test_create_201(client):
resp = client.post("/api/notes", json={"text": "Новая заметка"})
assert resp.status_code == 201
assert resp.get_json()["id"] == 2
def test_create_400(client):
assert client.post("/api/notes", json={}).status_code == 400
def test_list_after_create(client):
client.post("/api/notes", json={"text": "Ещё одна"})
assert len(client.get("/api/notes").get_json()) == 2
Запускаем весь проект целиком: блок пишет все три файла, сбрасывает кэш импорта и передаёт pytest тестовый файл:
from pathlib import Path
import sys
Path("notes_api.py").write_text('''from flask import Blueprint, Flask, current_app, jsonify, request
bp = Blueprint("notes", __name__)
@bp.route("/api/notes", methods=["GET", "POST"])
def notes_list():
store = current_app.config["NOTES"]
if request.method == "POST":
text = (request.get_json(silent=True) or {}).get("text", "").strip()
if not text:
return jsonify({"error": "Поле text обязательно"}), 400
note = {"id": max((n["id"] for n in store), default=0) + 1, "text": text}
store.append(note)
return jsonify(note), 201
return jsonify(store)
@bp.route("/api/notes/<int:note_id>")
def note_detail(note_id):
for note in current_app.config["NOTES"]:
if note["id"] == note_id:
return jsonify(note)
return jsonify({"error": "Заметка не найдена"}), 404
def create_app(config=None):
app = Flask(__name__)
app.config.update(config or {})
app.config.setdefault("NOTES", [])
app.register_blueprint(bp)
return app
''', encoding="utf-8")
Path("conftest.py").write_text('''import sys
sys.path.insert(0, "") # песочнице нужен явный путь к текущей папке
import pytest
from notes_api import create_app
@pytest.fixture
def app():
return create_app({
"TESTING": True,
"NOTES": [{"id": 1, "text": "Первая заметка"}],
})
@pytest.fixture
def client(app):
return app.test_client()
''', encoding="utf-8")
Path("test_notes_api.py").write_text('''def test_list_200(client):
assert client.get("/api/notes").status_code == 200
def test_detail_200(client):
resp = client.get("/api/notes/1")
assert resp.status_code == 200
assert resp.get_json()["text"] == "Первая заметка"
def test_detail_404(client):
assert client.get("/api/notes/99").status_code == 404
def test_create_201(client):
resp = client.post("/api/notes", json={"text": "Новая заметка"})
assert resp.status_code == 201
assert resp.get_json()["id"] == 2
def test_create_400(client):
assert client.post("/api/notes", json={}).status_code == 400
def test_list_after_create(client):
client.post("/api/notes", json={"text": "Ещё одна"})
assert len(client.get("/api/notes").get_json()) == 2
''', encoding="utf-8")
for name in ("notes_api", "conftest", "test_notes_api"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_notes_api.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
...... [100%] 6 passed in 0.03s exit: 0
У тестового набора есть и вторая, менее очевидная роль: документация. Новый человек в проекте открывает test_notes_api.py и за шесть коротких функций читает весь контракт: какие адреса есть, что они возвращают при успехе и при отказе. Документация в вики устаревает, комментарии врут, а зелёный тест врёт только вместе с приложением — потому что проверяет его при каждом прогоне. Когда через полгода захочешь изменить формат ответа, именно этот файл честно покажет, что придётся обновить.
Итоговый прогон: изоляция и сводка
Финальный аккорд — тест, который проверяет саму изоляцию: два приложения из одной фабрики не должны видеть данные друг друга. Добавим второй тест-файл и прогоним весь набор разом, двумя файлами одной командой:
from pathlib import Path
import sys
Path("notes_api.py").write_text('''from flask import Blueprint, Flask, current_app, jsonify, request
bp = Blueprint("notes", __name__)
@bp.route("/api/notes", methods=["GET", "POST"])
def notes_list():
store = current_app.config["NOTES"]
if request.method == "POST":
text = (request.get_json(silent=True) or {}).get("text", "").strip()
if not text:
return jsonify({"error": "Поле text обязательно"}), 400
note = {"id": max((n["id"] for n in store), default=0) + 1, "text": text}
store.append(note)
return jsonify(note), 201
return jsonify(store)
@bp.route("/api/notes/<int:note_id>")
def note_detail(note_id):
for note in current_app.config["NOTES"]:
if note["id"] == note_id:
return jsonify(note)
return jsonify({"error": "Заметка не найдена"}), 404
def create_app(config=None):
app = Flask(__name__)
app.config.update(config or {})
app.config.setdefault("NOTES", [])
app.register_blueprint(bp)
return app
''', encoding="utf-8")
Path("conftest.py").write_text('''import sys
sys.path.insert(0, "") # песочнице нужен явный путь к текущей папке
import pytest
from notes_api import create_app
@pytest.fixture
def app():
return create_app({
"TESTING": True,
"NOTES": [{"id": 1, "text": "Первая заметка"}],
})
@pytest.fixture
def client(app):
return app.test_client()
''', encoding="utf-8")
Path("test_notes_api.py").write_text('''def test_list_200(client):
assert client.get("/api/notes").status_code == 200
def test_detail_200(client):
resp = client.get("/api/notes/1")
assert resp.status_code == 200
assert resp.get_json()["text"] == "Первая заметка"
def test_detail_404(client):
assert client.get("/api/notes/99").status_code == 404
def test_create_201(client):
resp = client.post("/api/notes", json={"text": "Новая заметка"})
assert resp.status_code == 201
assert resp.get_json()["id"] == 2
def test_create_400(client):
assert client.post("/api/notes", json={}).status_code == 400
def test_list_after_create(client):
client.post("/api/notes", json={"text": "Ещё одна"})
assert len(client.get("/api/notes").get_json()) == 2
''', encoding="utf-8")
Path("test_notes_isolation.py").write_text('''import sys
sys.path.insert(0, "") # песочнице нужен явный путь к текущей папке
from notes_api import create_app
def test_apps_are_independent():
first = create_app().test_client()
second = create_app().test_client()
first.post("/api/notes", json={"text": "Только для первого"})
assert len(first.get("/api/notes").get_json()) == 1
assert second.get("/api/notes").get_json() == []
''', encoding="utf-8")
for name in ("notes_api", "conftest", "test_notes_api", "test_notes_isolation"):
sys.modules.pop(name, None)
import pytest
rc = pytest.main(["test_notes_api.py", "test_notes_isolation.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
....... [100%] 7 passed in 0.04s exit: 0
Ещё одно свойство этого набора, которое легко недооценить, — скорость и портативность. Семь тестов пролетают за доли секунды, потому что нет ни сети, ни сокетов: test_client вызывает функции приложения прямо в процессе, а хранилище умирает вместе с экземпляром. Такой набор не жалко запускать на каждое сохранение файла и на каждый коммит, а код выхода 0 или 1 — это ровно тот язык, на котором разговаривают автоматические сборки: ноль пускает изменения дальше, единица останавливает конвейер до разбора.
Что проект добавил к первым десяти урокам
Приложение готово, и оно собрано не из новых слов, а из словаря, который копился весь курс. Сведём расширение к одной таблице — держи её перед глазами, когда возьмёшься за собственный проект:
| Уроки | Что добавили | Ключевая идея |
|---|---|---|
| 11–12. Ошибки и конфигурация | errorhandler, abort, config-классы, переменные окружения | приложение отвечает осмысленно и настраивается снаружи |
| 13–15. REST API и тесты | jsonify, GET/POST, статусы 201 и 400, pytest и test_client | API — это контракт, и его можно проверять автоматически |
| 16. Фикстуры | client из фикстуры, conftest.py, tmp_path | каждый тест получает свежую подготовку |
| 17. Фабрика | create_app(config) | свежий экземпляр приложения каждому тесту |
| 18. CLI | flask run, @app.cli.command, test_cli_runner | обслуживание проекта — командами, а не скриптами |
| 19. Безопасность | autoescape, CSRF-токен, SECRET_KEY | три замка ставятся почти бесплатно |
| 20. Этот проект | фабрика + блюпринт + pytest-набор | всё вместе в одном приложении из четырёх файлов |
Куда двигаться с проектом дальше, подсказывает сам курс: заменить хранилище-список на SQLite с миграциями, добавить DELETE-маршрут и тест к нему (это упражнение ниже), вынести сид в команду @app.cli.command из восемнадцатого урока и перед деплоем пройтись по чек-листу безопасности. Каждый шаг теперь измеримый: набор из семи тестов подскажет, что сломалось.
Итоги курса
Двадцать уроков назад сайт был строкой return "Привет, мир!". Теперь у тебя есть: маршруты с параметрами, шаблоны Jinja2, формы, база, блюпринты, вход по паролю, REST API с контрактными тестами, фабрика приложений и CLI. Это не коллекция приёмов, а рабочий инструмент: четыре файла этого проекта — масштаб, с которого начинают настоящие сервисы. Дальше только практика: возьми собственную идею, опиши её как список маршрутов, накрой тестами — и Flask уже не твой инструмент для учёбы, а твой инструмент для работы.
Фабрика собирает приложение, блюпринт хранит маршруты, фикстуры раздают клиент — pytest остаётся подтвердить контракт зелёной сводкой.
Сначала предскажи ответ в голове — это главный навык программиста.
def api(method, exists=False, valid=True):
if method == "POST" and not valid:
return 400
if not exists:
return 404
if method == "POST":
return 201
return 200
print(api("GET", exists=True), api("GET", exists=False),
api("POST"), api("POST", valid=False))
store = [{"id": 3, "text": "старая"}]
next_id = max((n["id"] for n in store), default=0) + 1
store.append({"id": next_id, "text": "новая"})
print(store[-1]["id"])
empty = []
print(max((n["id"] for n in empty), default=0) + 1)
def create_app():
return {"notes": []}
results = []
for _ in range(2): # два теста
app = create_app() # фикстура дала свежий экземпляр
app["notes"].append("x")
results.append(len(app["notes"]))
print(results)
1. Почему хранилище заметок берут из current_app.config, а не из глобальной переменной модуля?
2. Какой статус должен вернуть POST /api/notes при создании заметки?
3. Зачем в наборе тесты test_detail_404 и test_create_400?
4. Тест test_apps_are_independent прошёл. Что именно он доказывает?
5. Почему заготовку стартовых заметок создают внутри фикстуры app?
6. Что означает строка 7 passed в итоговой сводке?
Добавь к API заметок удаление: в notes_api.py уже есть маршрут DELETE /api/notes/<id> (успех — пустой ответ со статусом 204, промах — 404). Напиши в test_notes_delete.py два теста: test_delete_204 — удалить заметку с id 1, проверить статус 204 и что список опустел; test_delete_404 — удалить несуществующий id 99 и проверить статус 404. Запусти файл и напечатай код выхода.
Как построить структуру Flask-проекта с API и тестами?
Минимальный взрослый набор из четырёх файлов: модуль API с блюпринтом и фабрикой create_app (notes_api.py), conftest.py с фикстурами app и client, и test-файлы с проверками контракта. Блюпринт держит маршруты, фабрика — сборку и конфиг, фикстуры — подготовку; тесты при таком разделении получают свежее приложение на каждый тест и не зависят от порядка запуска.
Какие статусы должен покрывать тестовый набор для JSON API?
Как минимум четыре: 200 на успешное чтение, 201 на создание через POST, 400 на некорректный ввод и 404 на запрос несуществующего ресурса. Позитивные кейсы подтверждают работу, негативные — правильные отказы; проверять только первые опасно: сломанная валидация останется незамеченной, потому что маршруты продолжат отвечать 200.
Как тестировать POST-запросы во Flask через test_client?
Методом client.post с аргументом json: resp = client.post("/api/notes", json={"text": "Купить кофе"}). Клиент сериализует словарь и ставит нужный заголовок, на сервере тело читается через request.get_json(). В тесте проверяют статус ответа (201) и тело через resp.get_json() — например, что назначился id.
Как запустить все тесты проекта разом?
Передать pytest все файлы одним вызовом: pytest test_notes_api.py test_notes_isolation.py — или запустить pytest без аргументов в папке проекта, тогда он соберёт все test_*.py сам. Сводка вида 7 passed и код выхода 0 — сигнал, что контракт API цел; именно по коду выхода автоматические сборки решают, принимать изменения или нет.
Понравился урок? Сошлитесь на него
«Фабрика собирает приложение, блюпринт хранит маршруты, фикстуры раздают клиент — pytest остаётся подтвердить контракт зелёной сводкой.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
Flask · Урок 17
Фабрика приложений: паттерн create_app
Семнадцатый урок расширения Flask: один глобальный app протекает состоянием между тестами. Паттерн «фабрика приложений» собирает свежий экземпляр с блюпринтом и конфигом на каждый тест.
Flask · Урок 16
Фикстуры для Flask-тестов: conftest, клиент и временная база
Шестнадцатый урок расширения Flask: каждый тест создаёт клиент сам — пора зафиксировать подготовку в фикстурах, вынести её в conftest.py и раздавать тестам временные базы через tmp_path.
pytest · Урок 15
conftest.py: общие фикстуры для всей папки
Фикстуры перестали быть домашними: conftest.py делает их общими для всей папки — без единого импорта, силами самого pytest.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
FastAPI · Урок 10
Проект: REST API сервиса заметок с базой данных
Сквозной проект курса: сервис заметок с тегами, поиском, SQLite и JWT. Структура по файлам, контракты Pydantic, тесты и README — портфолио-проект за пару вечеров.
fastapi проектapi заметок python
pytest · Урок 20
Финальный проект: полный тест-сьют модуля работы с заказами
Выпускной: модуль заказов со скидками, классом Order, JSON и валютой — и полный сьют из шестнадцати тестов, который держит его весь целиком и гоняется одной командой.
pytest финальный проектpytest тест-сьют
Flask · Урок 13
REST API на Flask: jsonify и методы GET, POST
Тринадцатый урок курса Flask, центральный в расширении: приложение перестаёт отдавать только HTML и начинает говорить JSON. Маршруты с methods, jsonify, статус 201 при создании — и полный цикл POST создал, GET вернул, прямо в браузере.
flask rest api jsonifyflask jsonify пример
Проверьте знания по Flask
В челлендже — 20 задач по Flask, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.
Тест по Flask: 20 задач