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

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

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

Проект: сервис заметок с тестами — фабрика, 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  # проверка изоляции приложений
Четыре файла - компактный, но взрослый проект: код отделён от тестов, подготовка живёт в conftest.py. Дома эти файлы кладут рядом и запускают pytest одной командой.

Блюпринт: маршруты API в отдельном модуле

Начнём с маршрутов. Блюпринт notes объявляет два правила: список заметок с чтением и созданием, и страница одной заметки. Хранилище — список в current_app.config["NOTES"]: состояние живёт на приложении, как постановил урок про фабрику, поэтому код маршрутов не зависит от того, сколько экземпляров приложения соберётся:

notes_api.py — маршруты блюпринта
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
Три развилки статусов из уроков про API: 201 для созданной заметки, 400 для пустого текста, 404 для промаха мимо id. get_json(silent=True) не роняет запрос на кривом теле - валидацию делаем сами.

Вторая половина модуля — фабрика. Она применяет конфиг, подставляет хранилище по умолчанию и вешает блюпринт на свежесобранное приложение:

notes_api.py — фабрика приложения
def create_app(config=None):
    app = Flask(__name__)
    app.config.update(config or {})
    app.config.setdefault("NOTES", [])
    app.register_blueprint(bp)
    return app
Четыре строки, на которых держится весь проект: конфиг аргументом, дефолт через setdefault, регистрация блюпринта, возврат приложения. Каждый вызов - новое приложение с чистым хранилищем.

Дымовой прогон: API отвечает

Прежде чем писать тесты, убедимся, что API вообще живой. Блок ниже записывает модуль целиком, собирает приложение с одной стартовой заметкой и прогоняет четыре запроса через test_client — как это делалось ещё в уроке про REST API. Статусы печатаются первыми числами каждой строки:

Дымовой прогон 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': 'Заметка не найдена'}
Четыре запроса - четыре верных ответа: список со стартовой заметкой, создание со статусом 201 и id 2, чтение созданной, отказ 404 для несуществующего id. API живой - пора фиксировать это тестами.

Две детали в этом выводе достойны внимания. Первая — сортировка ключей: 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 принимает её аргументом. В стартовом хранилище — одна заметка, чтобы у тестов чтения был материал:

conftest.py — фикстуры набора
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()
Каждый вызов фикстуры app зовёт фабрику заново: тесты получают собственные приложения с собственным хранилищем. Данные создаются ВНУТРИ фикстуры - это важно, о нём отдельный pitfall ниже.

Тесты проверяют контракт API целиком: и happy path, и отказы. Шесть тестов — по одному на статус и два на последствия создания:

test_notes_api.py — контракт API
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
Четыре статуса покрыты: 200 на список и на детальную, 201 на создание, 404 мимо id, 400 без текста. Последние два теста проверяют не статус, а эффект: создание видно в списке.

Запускаем весь проект целиком: блок пишет все три файла, сбрасывает кэш импорта и передаёт 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
Семь точек - семь тестов, код выхода 0. Запись уехала в первое приложение, второе осталось пустым - фабрика и фикстуры дали каждому тесту собственный мир.

Ещё одно свойство этого набора, которое легко недооценить, — скорость и портативность. Семь тестов пролетают за доли секунды, потому что нет ни сети, ни сокетов: test_client вызывает функции приложения прямо в процессе, а хранилище умирает вместе с экземпляром. Такой набор не жалко запускать на каждое сохранение файла и на каждый коммит, а код выхода 0 или 1 — это ровно тот язык, на котором разговаривают автоматические сборки: ноль пускает изменения дальше, единица останавливает конвейер до разбора.

Что проект добавил к первым десяти урокам

Приложение готово, и оно собрано не из новых слов, а из словаря, который копился весь курс. Сведём расширение к одной таблице — держи её перед глазами, когда возьмёшься за собственный проект:

УрокиЧто добавилиКлючевая идея
11–12. Ошибки и конфигурацияerrorhandler, abort, config-классы, переменные окруженияприложение отвечает осмысленно и настраивается снаружи
13–15. REST API и тестыjsonify, GET/POST, статусы 201 и 400, pytest и test_clientAPI — это контракт, и его можно проверять автоматически
16. Фикстурыclient из фикстуры, conftest.py, tmp_pathкаждый тест получает свежую подготовку
17. Фабрикаcreate_app(config)свежий экземпляр приложения каждому тесту
18. CLIflask 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)
Проверь себя
0 / 6

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 в итоговой сводке?

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

Добавь к 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. Запусти файл и напечатай код выхода.

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

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

TelegramVK

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

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

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

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

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