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

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

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

Тестирование 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
Разница не в строках, а в том, кто сверяет с ожиданием: глаза — или assert, который молчит при совпадении и роняет тест при расхождении.

Тесты живут в отдельных файлах с префиксом test_ — например test_app.py. pytest сам находит такие файлы, собирает в них функции test_ и запускает все подряд; команду запуска выполняют из папки проекта, поэтому тестовый файл кладут рядом с app.py. Вот тестовый файл для мини-API целиком:

test_app.py — тестовый файл целиком
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
Это содержимое файла, а не скрипт: у тестов нет print — результат их работы виден в отчёте pytest. Запускаем его ниже, прямо в браузере.

Три теста — три привычки, которые стоит перенять. test_hello_status проверяет статус: самая дешёвая и самая частая проверка API. test_hello_json спускается в тело: get_json() возвращает словарь, и assert сравнивает конкретное поле — так тестируют содержимое, а не только код ответа. test_missing_page_is_404 закрепляет поведение из урока 11: чужой адрес обязан давать 404. Внутри каждого теста — свой test_client: запросы выполняются без сети, сервер не запускается.

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

Запуск: 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 — в отчёте остаётся сам факт и краткая сводка:

Падающий тест с --tb=no (запустите)
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:

test_notes.py — тесты цикла API (запустите)
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:

test_api_400.py — тесты плохих входов (запустите)
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
Команды терминала, а не Python — в песочнице их не выполнить. Аргументы те же, что в pytest.main: -q — краткий отчёт, имя файла — что запускать. Без имени файла pytest прогонит все тесты папки.

Последнее — и самое ценное — свойство набора: он запускается целиком одной командой. 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 "красный")
Проверь себя
0 / 6

1. Какую функцию pytest считает тестом?

2. Почему в тестах Flask используют app.test_client()?

3. Что означают три точки в строке отчёта pytest -q?

4. Что напечатает print("exit:", int(rc)), если один из тестов упал?

5. Зачем в падающем демо использовали --tb=no?

6. Почему перед тестом списка мы вызвали notes.clear()?

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

Напишите тестовый файл 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 и напечатайте код выхода.

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

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

TelegramVK

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

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

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

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

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