Тестирование Flask: pytest и test_client
Пятнадцатый урок курса Flask — мост в мир автотестов: проверки API из предыдущих уроков становятся тест-функциями pytest с assert, а app.test_client() работает внутри теста без сервера и сети.
Редакция Питоники
Признаемся честно: уроки 13 и 14 вы проверяли руками. Написали jsonify — запустили блок, посмотрели на статус. Добавили валидацию — снова запустили, снова посмотрели. Один эндпоинт — терпимо. Но API живёт: добавили DELETE, поменяли валидацию, переименовали поле. После каждой правки список проверок «а 201 всё ещё возвращается? а 400 не отвалился?» надо прогонять заново, и каждая проверка — это вы, терминал и надежда, что ничего не забыто.
Ручное тыканье эндпоинтов не масштабируется: тест pytest проверяет статус и JSON за доли секунды и падает с точным указанием причины. Сегодня мы завернём проверки из прошлых уроков в автотесты — и заодно откроем дверь в целый раздел про pytest: всё, что вы здесь увидите, там разобрано фундаментально. Flask в этом союзе участвует одной функцией — test_client, знакомым вам ещё с урока 2.
Тест — это функция с assert
Тест на pytest — обычная функция Python, подчиняющаяся двум правилам: имя начинается с test_ и внутри есть проверки через assert. Никаких классов, декораторов и импортов фреймворка. Сравните проверку из прошлого урока и тест:
# проверка: запустил и посмотрел глазами
client = app.test_client()
print(client.get("/api/hello").status_code) # надеюсь, 200
# тест: запустил и получил вердикт машины
def test_hello_status():
client = app.test_client()
assert client.get("/api/hello").status_code == 200
Тесты живут в отдельных файлах с префиксом test_ — например test_app.py. pytest сам находит такие файлы, собирает в них функции test_ и запускает все подряд; команду запуска выполняют из папки проекта, поэтому тестовый файл кладут рядом с app.py. Вот тестовый файл для мини-API целиком:
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/api/hello")
def hello():
return jsonify(message="привет")
def test_hello_status():
client = app.test_client()
assert client.get("/api/hello").status_code == 200
def test_hello_json():
client = app.test_client()
body = client.get("/api/hello").get_json()
assert body["message"] == "привет"
def test_missing_page_is_404():
client = app.test_client()
assert client.get("/no-such-page").status_code == 404
Три теста — три привычки, которые стоит перенять. test_hello_status проверяет статус: самая дешёвая и самая частая проверка API. test_hello_json спускается в тело: get_json() возвращает словарь, и assert сравнивает конкретное поле — так тестируют содержимое, а не только код ответа. test_missing_page_is_404 закрепляет поведение из урока 11: чужой адрес обязан давать 404. Внутри каждого теста — свой test_client: запросы выполняются без сети, сервер не запускается.
def test_create_note_full():
notes.clear()
client = app.test_client()
r = client.post("/api/notes", json={"text": "заметка"})
assert r.status_code == 201
body = r.get_json()
assert body["text"] == "заметка"
assert isinstance(body["id"], int)
Запуск: pytest.main и отчёт
Дома тесты запускают командой pytest из папки проекта. В песочнице терминала нет, поэтому файл создаёт код, а pytest вызывается программно — pytest.main со списком аргументов, тем же самым, что вы дали бы команде:
from pathlib import Path
Path("test_app.py").write_text("""from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/api/hello")
def hello():
return jsonify(message="привет")
def test_hello_status():
client = app.test_client()
assert client.get("/api/hello").status_code == 200
def test_hello_json():
client = app.test_client()
body = client.get("/api/hello").get_json()
assert body["message"] == "привет"
def test_missing_page_is_404():
client = app.test_client()
assert client.get("/no-such-page").status_code == 404
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_app.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.17s exit: 0
Читаем отчёт построчно. Три точки — три теста, каждая точка значит «прошёл»; в квадратных скобках — прогресс, 100 процентов. Строка 3 passed — сводка: сколько тестов прошло и сколько времени занял прогон; доли секунды на три запроса — вот скорость, о которой ручные проверки могут только мечтать. Наконец, exit: 0 — код возврата прогона: ноль означает зелёный набор. Автоматика (CI, скрипты, редактор) читает именно его, а не текст отчёта.
| Элемент отчёта | Что означает |
|---|---|
. точка | один тест прошёл; F на её месте — упал, E — ошибка запуска |
[100%] | выполнены все найденные тесты набора |
3 passed in 0.17s | сводка: количество и время |
exit: 0 / exit: 1 | код выхода: зелёный набор / есть падения |
Красный тест: что показывает падение
Зелёный отчёт приятен, но учит падение. Уроним тест специально: тест ожидает английское приветствие, а API отвечает по-русски. Ключ --tb=no отключает вывод полного traceback — в отчёте остаётся сам факт и краткая сводка:
from pathlib import Path
Path("test_bad.py").write_text("""from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/api/hello")
def hello():
return jsonify(message="привет")
def test_greeting():
client = app.test_client()
body = client.get("/api/hello").get_json()
assert body["message"] == "hello"
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_bad.py", "-q", "--no-header", "--tb=no", "-p", "no:cacheprovider"])
print("exit:", int(rc))
F [100%] =========================== short test summary info =========================== FAILED test_bad.py::test_greeting - AssertionError: assert 'привет' == 'hello' 1 failed in 0.17s exit: 1
Буква F на месте точки, строка FAILED называет файл, тест и самое ценное — расхождение: assert 'привет' == 'hello'. pytest показывает левую и правую сторону сравнения, и мгновенно видно, что сломалось: API вернул «привет», тест ждал «hello». Код выхода 1 — красный набор. Это и есть главный аргумент автотестов: упавший тест сообщает точнее и быстрее, чем любой ручной прогон, и повторяет это после каждой правки бесплатно.
Отдельно стоит сказать об адресе падения: FAILED test_bad.py::test_greeting читается как путь — файл, затем имя теста через двойное двоеточие. В наборе из пятидесяти проверок это различие между «искать полчаса» и «чинить сразу»: отчёт сам ведёт к строке, где ожидание разошлось с реальностью.
Тесты для API из урока 14
Проверим наш главный приём: тесты должны быть независимыми, поэтому каждый начинается с очистки хранилища. Три теста накрывают три сценария — создание, чтение списка, отсутствующий id:
from pathlib import Path
Path("test_notes.py").write_text("""from flask import Flask, jsonify, request
app = Flask(__name__)
notes = []
@app.route("/api/notes", methods=["GET", "POST"])
def notes_view():
if request.method == "POST":
data = request.get_json()
note = {"id": len(notes) + 1, "text": data["text"]}
notes.append(note)
return jsonify(note), 201
return jsonify(notes)
@app.route("/api/notes/<int:note_id>")
def get_note(note_id):
for n in notes:
if n["id"] == note_id:
return jsonify(n)
return jsonify(error="not found"), 404
def test_create_returns_201():
notes.clear()
client = app.test_client()
r = client.post("/api/notes", json={"text": "первая заметка"})
assert r.status_code == 201
assert r.get_json()["text"] == "первая заметка"
def test_list_after_create():
notes.clear()
client = app.test_client()
client.post("/api/notes", json={"text": "первая заметка"})
assert len(client.get("/api/notes").get_json()) == 1
def test_missing_note_is_404():
client = app.test_client()
assert client.get("/api/notes/999").status_code == 404
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_notes.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.17s exit: 0
Посмотрите, как мало осталось от ритуала из уроков 13–14: каждый ручной сценарий стал функцией, print уступил место assert, а глазами теперь смотрит машина. Заметьте и цену небрежности: если убрать notes.clear() из test_list_after_create, тест упадёт — заметку создаст предыдущий тест. Ошибка не в API, а в зависимом тесте; искать такое глазами — то ещё удовольствие, а pytest назовёт виновника по имени.
Тесты — это ещё и документация, которую невозможно забыть обновить. Не нужно открывать код маршрута, чтобы вспомнить, что вернёт POST с пустым полем: имена test_create_returns_201 и test_missing_note_is_404 говорят сами за себя. Через полгода, вернувшись к проекту, вы запустите набор — и он честно расскажет, что в API работает, а что отвалилось.
И финальный аккорд — валидация из урока 14. Плохие входы — идеальный материал для тестов: сценарии известны заранее, и каждый описывается одной строкой с assert:
from pathlib import Path
Path("test_api_400.py").write_text("""from flask import Flask, jsonify, request
app = Flask(__name__)
@app.route("/api/notes", methods=["POST"])
def create_note():
data = request.get_json(silent=True)
if data is None or not data.get("text", "").strip():
return jsonify(error="поле text обязательно"), 400
return jsonify(text=data["text"].strip()), 201
def test_create_ok():
client = app.test_client()
assert client.post("/api/notes", json={"text": "кофе"}).status_code == 201
def test_empty_text_is_400():
client = app.test_client()
r = client.post("/api/notes", json={"text": " "})
assert r.status_code == 400
assert "error" in r.get_json()
def test_not_json_is_400():
client = app.test_client()
assert client.post("/api/notes", data="нет").status_code == 400
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_api_400.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.16s exit: 0
Дома: терминал вместо pytest.main
На своём компьютере всё то же самое делается двумя командами терминала — файл test_app.py вы создаёте в редакторе, а запускать его вызывает pytest:
pip install pytest
pytest -q test_app.py
... [100%] 3 passed in 0.02s
Последнее — и самое ценное — свойство набора: он запускается целиком одной командой. pytest -q без имени файла находит все test_*.py в проекте и прогоняет их за один вход: три файла уроков 13–15 слились бы в один отчёт с одним кодом выхода. Именно это делает тесты страховкой при рефакторинге: поменяли jsonify на ручную сборку JSON — запустили набор — и через секунды знаете, не сломали ли что-нибудь.
Итоги: проверки работают сами
- Тест — функция test_ с assert внутри; файл тестов — test_*.py, находит и запускает их pytest
- app.test_client() внутри теста выполняет настоящие запросы без сети и сервера
- Отчёт: точка за пройденный тест, F за упавший, сводка «N passed» и код выхода 0 или 1
- Падение читается по строке FAILED: файл, тест, расхождение assert; --tb=no оставляет только суть
- Каждый тест готовит своё состояние — notes.clear() — иначе тесты зависят друг от друга
- Валидация из урока 14 превращается в набор тестов на 201, 400 и 404 — и запускается после каждой правки
Расширение курса пройдено: ваши API умеют отвечать ошибками, их поведение описано тестами, а тесты запускаются одной командой. Дальше начинается курс про фикстуры, фабрику приложений и CLI — но всё это будет опираться на привычку, которую вы только что завели: сначала тест, потом код.
Ручное тыканье эндпоинтов не масштабируется: тест pytest проверяет статус и JSON за доли секунды и падает с точным указанием причины.
Сначала предскажи ответ в голове — это главный навык программиста.
report = "..F."
print(report.count("."), "passed,", report.count("F"), "failed")
rc = 1
print("набор", "зелёный" if rc == 0 else "красный")
1. Какую функцию pytest считает тестом?
2. Почему в тестах Flask используют app.test_client()?
3. Что означают три точки в строке отчёта pytest -q?
4. Что напечатает print("exit:", int(rc)), если один из тестов упал?
5. Зачем в падающем демо использовали --tb=no?
6. Почему перед тестом списка мы вызвали notes.clear()?
Напишите тестовый файл test_books.py для книжного API: маршрут /api/books с POST (создаёт книгу с новым id и отвечает 201) и GET (отдаёт список). Два теста: test_create_returns_201 — POST отвечает 201 и поле title в ответе совпадает; test_list_contains_created — после POST список из GET содержит одну книгу. Перед каждым тестом очищайте books.clear(). Запустите через pytest.main и напечатайте код выхода.
Как протестировать Flask-приложение с помощью pytest?
Создайте файл test_app.py, внутри объявите app и тест-функции с именами на test_: в каждой создайте client = app.test_client() и проверьте ответы через assert — например, assert client.get("/api/hello").status_code == 200. Установите pytest (pip install pytest) и запустите командой pytest из папки проекта: отчёт покажет точки за пройденные тесты и F за упавшие.
Нужен ли запущенный сервер, чтобы тестировать API через test_client?
Нет. app.test_client() выполняет запросы внутри Python, вызывая view-функции через полный механизм запрос-ответ, но без сокетов и сети. Поэтому тесты мгновенны и не занимают портов: сервер нужен только при ручной проверке в браузере или curl.
Что означает строка FAILED test_app.py::test_greeting в отчёте?
Файл и имя упавшего теста, а после дефиса — расхождение: AssertionError: assert 'привет' == 'hello' показывает левую и правую сторону сравнения. Это самый быстрый способ понять, что сломалось; полный traceback при желании включается без --tb=no.
Почему два теста у меня проходят по отдельности, но вместе падает второй?
Классическая зависимость тестов: оба работают с одним app и его глобальными списками, и первый тест оставляет в них данные. Начинайте каждый тест с очистки состояния (notes.clear()), а систематическое решение — фикстуры pytest, которые готовят свежий клиент и чистое хранилище для каждого теста автоматически.
Понравился урок? Сошлитесь на него
«Ручное тыканье эндпоинтов не масштабируется: тест pytest проверяет статус и JSON за доли секунды и падает с точным указанием причины.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
Flask · Урок 13
REST API на Flask: jsonify и методы GET, POST
Тринадцатый урок курса Flask, центральный в расширении: приложение перестаёт отдавать только HTML и начинает говорить JSON. Маршруты с methods, jsonify, статус 201 при создании — и полный цикл POST создал, GET вернул, прямо в браузере.
Flask · Урок 14
Валидация в Flask API: плохие запросы и коды 400
Четырнадцатый урок курса Flask: API из урока 13 падает на первом же кривом запросе. Добавляем валидацию — честные 400 с понятным текстом ошибки, 404 для отсутствующих записей и тесты на плохие входы через test_client.
pytest · Урок 1
Первый тест на pytest: assert, запуск и первый отчёт
Первая тест-функция на обычном assert: pytest сам находит её, запускает и печатает отчёт — точка, 1 passed и код выхода 0. Всё прямо в браузере.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
pytest · Урок 2
Как pytest находит тесты: конвенции имён файлов и функций
pytest не читает мысли: тестом становится только то, что названо по конвенции — test_*.py, test_-функции, классы Test*. И адрес одного теста: файл::тест.
pytest как запускать тестыpytest конвенции имён
pytest · Урок 18
Что тестировать: стратегия, границы и покрытие
Механику 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 задач