load-tester Все курсы

✻ Урок 4.7 · Тема 4: Python для тестировщика

pytest: первые тесты

⏱ 3.5 ч

Зачем это нужно

До распродажи месяц, и ты каждый день правишь скрипт с замерами. После каждой правки хочется спросить: «Ничего не сломалось?» Я с такими вопросами давно и скажу по опыту: проверка «запустил, глянул на экран» работает один раз. На двадцатый раз ты пропустишь ошибку.

Автоматический тест - маленькая программа, которая сама делает проверку и отвечает «прошёл» или «упал». Тесты запускает pytest: библиотека, то есть набор готового кода, как requests в уроке 4.5. Саму проверку в нём пишут словом assert («утверждаю»).

Со мной был случай. Я «слегка поправил» функцию, которая считает перцентиль p95: число, не больше которого 95 замеров из 100 (урок 4.2). Неделю я отправлял команде отчёты с неверными цифрами и заметил это случайно. С автоматическим тестом я узнал бы об ошибке через секунду.

Тестировщику нагрузки тесты нужны для двух дел. Первое: проверять свои инструменты, как в моей истории. Второе: перед нагрузкой убедиться, что «Магазин» отвечает как надо: вход работает, корзина без токена даёт 401, несуществующий товар даёт 404. Нагрузка на сломанный сервис бессмысленна: ты измеришь, как быстро он отвечает ошибками.

Шаг проекта: ты ставишь pytest в окружение ~/perf-lab/.venv, пишешь модуль shoptools.py (перцентиль и доля ошибок), тесты к нему и первые тесты API «Магазина», коммитишь всё в perf-lab. Эти файлы работают в связке с measure.py из урока 8.1, а полноценные автотесты API ты разовьёшь в уроке 6.2.

Что нужно знать

  • Функции, return, исключения и try/except: урок 4.3.
  • Списки и словари, JSON-ответы API: урок 4.1, урок 4.4.
  • requests, Session, коды ответов, токен: урок 4.5. Тесты API из этого урока используют те же приёмы.
  • Окружение ~/perf-lab/.venv включено, стенд «Магазин» запущен (урок 2.1).
  • Перцентили (p50, p95, p99): урок 8.1. Если ты ещё не там, достаточно знать формулу из этого урока: перцентиль это значение, не больше которого p% измерений.
  • Классы нужны только для понимания декораторов (@pytest.fixture, @pytest.mark.parametrize): урок 4.6.

Картина целиком

Возьми контролёра на фабрике. У него есть список проверок: «размер такой-то», «цвет такой-то». Он идёт по списку, ставит галочки и в конце докладывает: «из 18 проверок 17 прошли, одна провалена, вот какая и почему». Одна проверка из списка - это тест, контролёр - это pytest, доклад - отчёт. Аналогия ломается в одном месте: на фабрике изделие уже готово, а в Python для проверки изделие часто нужно сначала подготовить. Такая подготовка (и уборка после неё) называется фикстурой: например, «новый покупатель, который уже вошёл в магазин».

flowchart TD
    A["pytest<br/>ищет файлы test_*.py"] --> B["Собирает функции<br/>test_*"]
    B --> C["Готовит фикстуры<br/>(сессия, покупатель)"]
    C --> D["Запускает тест:<br/>assert за assert"]
    D --> E["Отчёт:<br/>passed, failed"]

Что здесь видно: тесты не надо перечислять, pytest находит их по именам. Тебе остаётся написать сами проверки.

Теория

assert: проверка одной строкой

Как сказать программе «вот что должно быть верно» и заставить её закричать, если это не так? Для этого в Python есть слово assert. Если выражение после него истинно, программа идёт дальше. Если ложно, возникает исключение AssertionError (про исключения урок 4.3), и pytest отмечает тест как упавший.

Это галочка в чек-листе: условие либо выполнено, либо нет. Аналогия ломается тем, что на первой невыполненной галочке тест останавливается: остальные assert в нём уже не сработают.

Записывается просто: assert выражение. Особенность pytest в том, что при падении он показывает значения всех частей выражения, и писать сообщения вроде «ожидали 200, получили 201» не нужно.

def test_status_is_ok():
    status = 201
    assert status == 200
>       assert status == 200
E       assert 201 == 200

Стрелка > указывает на проверку, которая не сошлась. Строка с E (от «Error») показывает значения: слева то, что получили (201), справа то, что ждали (200). Для вызова функции в левой части pytest добавит строку where: откуда взялось значение.

Осторожно: assert (False, "ошибка") в скобках всегда проходит, потому что непустой кортеж считается истинным. Своё сообщение пишут без скобок: assert x == 5, "ожидали 5".

Главное: assert говорит «это должно быть верно», а pytest при падении сам показывает, что получили и что ждали.

Один assert мы написали. А как pytest узнаёт, какие функции вообще надо запускать?

Как pytest находит и запускает тесты

Перечислять тесты вручную скучно, поэтому pytest ищет их по именам. Ты запускаешь pytest в каталоге, и он обходит все вложенные папки и берёт файлы test_*.py (подойдёт и *_test.py). В них он ищет функции с именем test_... (и методы классов Test...) и выполняет каждую отдельно. Падение одного теста остальные не останавливает.

Тест проходит, если функция закончилась без исключения, и падает, если возникло исключение (чаще всего AssertionError). return и print для результата не нужны.

Вот самый маленький тест. В файле shoptools.py лежит функция percentile(values, p), полный код будет в практике:

from shoptools import percentile


def test_percentile_median_of_odd_list():
    assert percentile([30, 10, 20], 50) == 20

Строка from shoptools import percentile подключает функцию из соседнего файла, как import в уроке 4.3. Список [30, 10, 20] нарочно перемешан. После сортировки получится [10, 20, 30], середина - это 20. Функция без сортировки вернула бы 10, и тест упал бы. Хороший тест проверяет не «функция запускается», а конкретный результат, который ты знаешь заранее.

Прикинь сам: ты написал функцию check_error_rate() в файле test_tools.py, внутри assert. Запустил pytest. Что будет?

Файл pytest найдёт, а функцию пропустит: её имя не начинается с test_. Итог: no tests ran («ни один тест не запущен»). Переименуй в test_error_rate, и всё заработает.

Осторожно: функция без assert всегда зелёная и бесполезна. Поэтому сначала увидь тест красным: сломай проверку на секунду.

Главное: pytest сам находит файлы test_*.py и функции test_*, а тест проходит, если внутри не возникло исключение.

Запускать умеем. Что именно проверять в функциях, чтобы ловить настоящие ошибки?

Тестируем функции: обычные, крайние и ошибочные случаи

Вернёмся к моей истории. Ошибка в percentile сдвинула p95 на одну позицию, и все отчёты поплыли. Мелкие функции - сердце твоих отчётов, а тесты на них пишутся быстрее всего. Я проверяю три вида случаев.

Первый - обычный: данные, ответ на которых ты знаешь заранее (например, выборка из урока 8.1). Второй - крайний: пустой список, один элемент, p0 или p100. Ошибки живут именно на краях, а не посередине. Третий - ошибочный: данные, на которых функция обязана сломаться.

Для третьего нужен pytest.raises. Блок with (урок 4.4) значит «внутри обязана случиться ошибка ValueError» (про неё урок 4.3).

import pytest

from shoptools import percentile


def test_percentile_of_empty_list_fails():
    with pytest.raises(ValueError):
        percentile([], 95)

Если ошибки не случилось, тест упадёт с сообщением DID NOT RAISE. Текст ошибки тоже можно проверить: pytest.raises(ValueError, match="хотя бы одно"). Зачем так строго? Пустая выборка в нагрузочном скрипте означает «ни один запрос не прошёл», а не «задержка нулевая». Тихий ноль выглядел бы как отличный результат. Функция должна сказать о проблеме громко.

Ещё одна проверка: функция не должна портить свои аргументы. Наш перцентиль сортирует копию (sorted(values)). Сортировка самого списка (values.sort()) незаметно поменяла бы порядок замеров у вызывающего. Тест страхует от этого:

def test_percentile_does_not_change_input():
    data = [3, 1, 2]
    percentile(data, 50)
    assert data == [3, 1, 2]

Прикинь сам: функция error_rate(codes) считает долю кодов 400 и выше, а 0 считается обрывом соединения. Какие тесты ты бы написал?

Обычный: [200, 200, 200, 500] даёт 0.25, одна ошибка из четырёх. С обрывом: [200, 404, 0, 201] даёт 0.5, потому что и 404, и 0 считаются ошибками. Крайний: [200, 201, 204] даёт 0. Ошибочный: пустой список [] должен вызывать ValueError, а не деление на ноль.

Главное: для каждой функции проверяй обычный, крайний и ошибочный случаи, а ошибку проверяй через pytest.raises.

Для перцентиля нас интересуют сразу четыре значения: p50, p90, p95 и p99. Неужели писать четыре почти одинаковых теста?

Параметризация: один тест, много данных

Нет, четыре копии писать не надо. Как у контролёра: действия те же, различаются изделия. Ты описываешь таблицу «входные данные - ожидаемый результат» и один тест, который пробегает по ней. Это называется параметризацией.

Над тестом ставят декоратор @pytest.mark.parametrize (напоминание из урока 4.6: декоратор принимает функцию и возвращает функцию, здесь он «размножает» тест). Первый аргумент - строка с именами параметров через запятую. Второй - список наборов значений. pytest запускает тест отдельно для каждого набора, и в отчёте это отдельные строки.

import pytest

from shoptools import percentile

# Те же 20 измерений, что в уроке 8.1 (мс).
SAMPLE = [12, 13, 13, 14, 14, 15, 15, 15, 16, 16, 17, 17, 18, 19, 20, 22, 25, 31, 48, 410]


@pytest.mark.parametrize("p, expected", [(50, 16), (90, 31), (95, 48), (99, 410)])
def test_percentile_of_sample(p, expected):
    assert percentile(SAMPLE, p) == expected

Имена в "p, expected" совпадают с аргументами теста, наборов четыре. Проверим ожидания вручную. Для p95 умножаем долю на число замеров: 0,95 × 20 = 19. Берём девятнадцатое по счёту значение в отсортированном списке: это 48. Для p99: 0,99 × 20 = 19,8, округляем вверх до 20 (в коде math.ceil), двадцатое значение - 410. Это метод ближайшего ранга, как в уроке 8.1.

flowchart TD
    A["Один тест<br/>test_percentile_of_sample"] --> B["p=50, expected=16"]
    A --> C["p=90, expected=31"]
    A --> D["p=95, expected=48"]
    A --> E["p=99, expected=410"]

Что здесь видно: из одной функции получилось четыре независимых теста. В отчёте с ключом -v («verbose», подробно) у каждого своя строка с набором в скобках: test_percentile_of_sample[95-48] PASSED. Если что-то упадёт, сразу видно, на каких данных.

Прикинь сам: сколько тестов запустит pytest для @pytest.mark.parametrize("product_id", [1, 42, 10000])?

Три, по одному на значение. В отчёте они будут называться test_product_exists[1], test_product_exists[42] и test_product_exists[10000].

Осторожно: цикл for внутри одного теста останавливается на первом падении, и непонятно, на каком наборе оно случилось. А ожидание, посчитанное той же функцией, всегда зелёное и бесполезное: считай его вручную, как мы сделали выше.

Главное: параметризация превращает таблицу данных в отдельные тесты, и по отчёту видно, какой набор упал.

С чистыми функциями разобрались. А для тестов API нужен залогиненный покупатель. Откуда его брать?

Фикстуры: подготовка и уборка

Тесту API нужно то, что не относится к проверке: адрес стенда, открытая сессия, залогиненный покупатель с токеном. Вход, написанный в каждом тесте, повторится десять раз. А общий покупатель на всех заставит тесты мешать друг другу: один положит товар в корзину, другой увидит чужую корзину.

Помогает фикстура (fixture, «приспособление»): функция, которая готовит нужное тесту и, если надо, убирает за ним. Как в операционной: хирургу готовят инструменты и стерилизуют после, а он думает об операции. Аналогия ломается тем, что в Python подготовка занимает миллисекунды.

Фикстура - обычная функция с декоратором @pytest.fixture. Чтобы тест её получил, достаточно назвать её среди аргументов теста: pytest сам подставит результат. Если в фикстуре есть yield («отдай»), код до него - подготовка, значение уходит тесту, а код после него - уборка. Она выполнится, даже если тест упал.

Ещё фикстура решает, как часто готовиться, и за это отвечает scope. По умолчанию подготовка идёт для каждого теста, а scope="session" значит один раз на весь запуск: так делают для адреса стенда и общей Session.

Нажми «Шаг ▶» и проследи за подготовкой и уборкой:

Что здесь видно: подготовка идёт до теста, yield передаёт сессию тесту, уборка выполняется после. Второй тест получает нового покупателя и пустую корзину.

Общие фикстуры живут в файле conftest.py рядом с тестами. pytest читает его сам, импортировать не нужно. Вот он для «Магазина»:

import os
import uuid

import pytest
import requests


@pytest.fixture(scope="session")
def base_url():
    """Адрес стенда; можно переопределить переменной окружения BASE_URL."""
    return os.getenv("BASE_URL", "http://localhost:8000")


@pytest.fixture(scope="session")
def shop():
    """Одна Session на весь запуск: соединение переиспользуется."""
    with requests.Session() as session:
        yield session


@pytest.fixture
def buyer(shop, base_url):
    """Свежий покупатель: регистрируется, входит и отдаёт Session с токеном."""
    credentials = {"email": f"pytest-{uuid.uuid4().hex[:12]}@shop.lab", "password": "password"}
    assert shop.post(f"{base_url}/api/register", json=credentials, timeout=30).status_code == 201
    response = shop.post(f"{base_url}/api/login", json=credentials, timeout=30)
    assert response.status_code == 200
    with requests.Session() as session:
        session.headers["Authorization"] = "Bearer " + response.json()["token"]
        yield session

base_url читает переменную окружения (урок 1.1) и без неё берёт адрес по умолчанию. Так те же тесты можно направить на другой стенд: BASE_URL=http://localhost:8000 pytest. shop - общая Session на весь запуск, yield внутри with закроет её в конце. buyer для каждого теста заводит нового пользователя. uuid.uuid4().hex[:12] - случайная строка из 12 символов, поэтому почта каждый раз новая, например pytest-3f9a1c2b7d10@shop.lab. Фикстура получает shop и base_url, назвав их в параметрах. Токен кладётся в заголовки отдельной сессии, как в уроке 4.5. Метки тестов (smoke, slow) и уборку на реальном API разберёт урок 6.2: сейчас они не нужны.

Прикинь сам: зачем buyer регистрирует нового пользователя на каждый тест, а не берёт user0001?

У user0001 корзина общая для всех тестов и всех запусков: товар, добавленный одним тестом, останется для следующего. Новый пользователь даёт пустую корзину и независимые тесты.

Осторожно: фикстуру не вызывают как функцию (buyer()), pytest подставляет её сам. А scope="session" даёт один общий объект, поэтому всё, что тест меняет (корзину, пользователя), готовь с областью по умолчанию. Если упадёт assert внутри фикстуры, pytest сообщит об ошибке подготовки, а не о падении теста: чем они различаются, скажу дальше.

Главное: фикстура готовит и убирает, тест получает её по имени аргумента, а область scope решает, как часто её готовить.

Тесты написаны, один упал. Как прочитать отчёт и не утонуть в красном тексте?

Как читать отчёт о падении

Упавший тест - не «плохо», а сообщение, ради которого тесты и пишут. Новичок читает отчёт с первой строки и тонет в вызовах чужих библиотек. Я читаю его по трём местам.

Внизу итоговая строка, например 1 failed, 17 passed in 0.31s. Над ней блок short test summary info, по строке на каждый упавший тест. Выше блок FAILURES: у каждого упавшего теста там заголовок с именем, код со строкой-стрелкой >, строки E (что ждали и что получили) и файл с номером строки.

Статусов пять. Тест может пройти (passed) или упасть (failed): не сошёлся assert либо возникло исключение в теле. Если упала фикстура до начала теста, это error. Ещё есть skipped (пропущен нарочно, например на чужой системе) и xfailed (упал, как и ожидалось: известный дефект). Главное различие: failed говорит «тест упал», error говорит «до теста не добрались».

Переключай сценарии и наводи курсор на подсвеченные строки:

Что здесь видно: в первых двух сценариях отчёт короткий, и все ответы лежат в строках со стрелкой и E. В третьем отчёт длинный: исключение случилось внутри библиотеки requests, и pytest показал весь путь вызова. Ответ всё равно внизу: тип исключения и текст.

Отчёт помогают сократить три ключа. --tb=short оставляет одну строку на файл, -x останавливается на первом упавшем тесте, -q убирает лишнее.

Прикинь сам: тест test_product_missing показал E assert 200 == 404. Сервис ответил 404 или 200?

Слева то, что получили, справа то, что ждали. Сервис вернул 200: товар с таким номером существует. Тест ждал 404, значит, надо проверить, какой id в нём записан. Допустимы номера 1–10000, нужен заведомо несуществующий, например 99999999.

Осторожно: упавший тест не значит «тест плохой». Сломаться мог сервис, ожидание, данные или сам тест, и надо решить, кто прав. Не подгоняй тест под результат, пока не проверил, правилен ли он. Тест ждал 410 для p95, а получил 48: по расчёту выше прав результат, ошибся тест.

Главное: читай отчёт по трём местам (строка >, строки E, итог), а перед правкой реши, кто неправ: сервис, ожидание или тест.

Тот самый перцентиль, из-за которого я неделю присылал неверные отчёты, теперь страхуют четыре строки с ожидаемыми числами. Пора повторить это руками.

Практика

1. Поставь pytest и зафиксируй версию

cd ~/perf-lab
source .venv/bin/activate
pip install pytest
pip freeze > requirements.txt
printf '.pytest_cache/\n' >> .gitignore
pytest --version
pytest 9.1.1

Как читать вывод: версия 9.x подойдёт; у тебя может быть новее. pip freeze > requirements.txt перезаписал список зависимостей: теперь в нём pytest и всё, что ему нужно (iniconfig, packaging, pluggy, pygments), плюс requests из прошлого урока. Строка в .gitignore исключает папку .pytest_cache, куда pytest складывает служебные файлы. Если окружение не включено, будет ошибка externally-managed-environment, как в уроке 4.5.

2. Модуль с функциями

Создай ~/perf-lab/04-python/shoptools.py. Это отдельный модуль со своими функциями: не подставляй сюда perflib.py из урока 4.3. Там error_rate возвращает проценты и на пустом списке отдаёт 0.0, а здесь error_rate возвращает долю (0,25 вместо 25) и на пустом списке бросает ValueError. Тесты ниже написаны под эту версию, поэтому бери её целиком:

"""Мелкие функции для обработки результатов нагрузки."""
import math


def percentile(values, p):
    """Перцентиль методом ближайшего ранга: значение, не больше которого p% выборки."""
    if not values:
        raise ValueError("нужно хотя бы одно измерение")
    ordered = sorted(values)
    rank = math.ceil(p / 100 * len(ordered))
    return ordered[max(rank, 1) - 1]


def error_rate(codes):
    """Доля неудачных ответов: код 400 и выше, а 0 означает обрыв соединения."""
    if not codes:
        raise ValueError("нужен хотя бы один код ответа")
    bad = sum(1 for code in codes if code == 0 or code >= 400)
    return bad / len(codes)

Разбор: sorted(values) возвращает новый отсортированный список и не трогает исходный. math.ceil округляет вверх: для 20 измерений и p95 получим ceil(19.0) = 19. max(rank, 1) - 1 превращает номер по счёту (с единицы) в индекс (с нуля) и защищает от нулевого ранга при p=0. sum(1 for code in codes if ...) считает, сколько кодов подходят под условие (это генераторное выражение: как списковое включение, только без создания списка). Логика та же, что в уроке 8.1: ошибка это код 0 (обрыв) или 400 и выше.

3. Первые тесты на функции

Создай ~/perf-lab/04-python/test_shoptools.py:

import pytest

from shoptools import error_rate, percentile

# Те же 20 измерений, что в уроке 8.1.
SAMPLE = [12, 13, 13, 14, 14, 15, 15, 15, 16, 16, 17, 17, 18, 19, 20, 22, 25, 31, 48, 410]


def test_percentile_median_of_odd_list():
    assert percentile([30, 10, 20], 50) == 20


@pytest.mark.parametrize("p, expected", [(50, 16), (90, 31), (95, 48), (99, 410)])
def test_percentile_of_sample(p, expected):
    assert percentile(SAMPLE, p) == expected


def test_percentile_does_not_change_input():
    data = [3, 1, 2]
    percentile(data, 50)
    assert data == [3, 1, 2]


def test_percentile_of_empty_list_fails():
    with pytest.raises(ValueError):
        percentile([], 95)


def test_error_rate():
    assert error_rate([200, 200, 200, 500]) == 0.25
    assert error_rate([200, 404, 0, 201]) == 0.5


def test_error_rate_without_errors():
    assert error_rate([200, 201, 204]) == 0
cd ~/perf-lab/04-python
pytest -v test_shoptools.py
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0 -- /home/student/perf-lab/.venv/bin/python
cachedir: .pytest_cache
rootdir: /home/student/perf-lab/04-python
collecting ... collected 9 items

test_shoptools.py::test_percentile_median_of_odd_list PASSED             [ 11%]
test_shoptools.py::test_percentile_of_sample[50-16] PASSED               [ 22%]
test_shoptools.py::test_percentile_of_sample[90-31] PASSED               [ 33%]
test_shoptools.py::test_percentile_of_sample[95-48] PASSED               [ 44%]
test_shoptools.py::test_percentile_of_sample[99-410] PASSED              [ 55%]
test_shoptools.py::test_percentile_does_not_change_input PASSED          [ 66%]
test_shoptools.py::test_percentile_of_empty_list_fails PASSED            [ 77%]
test_shoptools.py::test_error_rate PASSED                                [ 88%]
test_shoptools.py::test_error_rate_without_errors PASSED                 [100%]

============================== 9 passed in 0.01s ===============================

Как читать вывод: collected 9 items значит «нашёл девять тестов» (параметризованный тест дал четыре). Каждая строка это один тест, PASSED значит «прошёл», а в скобках процент выполнения. В квадратных скобках после имени ([95-48]) идентификатор набора: значения параметров по порядку. Последняя строка это итог: 9 passed. Если у тебя иное число, проверь, что файл сохранён и имена функций начинаются с test_.

Типичные ошибки:

  • ModuleNotFoundError: No module named 'shoptools': ты запустил pytest не из каталога ~/perf-lab/04-python, или файл называется иначе.
  • no tests ran: имя файла или функции не начинается с test_.
  • ModuleNotFoundError: No module named 'pytest': окружение не включено (which pytest должен показывать путь внутри .venv).

4. Тесты API «Магазина»

Создай рядом conftest.py (код из теории выше) и test_shop_api.py:

import pytest


def test_readyz(shop, base_url):
    response = shop.get(f"{base_url}/readyz", timeout=10)
    assert response.status_code == 200
    assert response.json() == {"status": "ready"}


def test_catalog_page_size(shop, base_url):
    response = shop.get(f"{base_url}/api/products", params={"page": 2, "size": 5}, timeout=10)
    assert response.status_code == 200
    body = response.json()
    assert body["page"] == 2
    assert len(body["items"]) == 5
    assert body["total"] == 10000


@pytest.mark.parametrize("product_id", [1, 42, 10000])
def test_product_exists(shop, base_url, product_id):
    response = shop.get(f"{base_url}/api/products/{product_id}", timeout=10)
    assert response.status_code == 200
    assert response.json()["id"] == product_id


def test_product_missing(shop, base_url):
    response = shop.get(f"{base_url}/api/products/99999999", timeout=10)
    assert response.status_code == 404
    assert response.json() == {"detail": "product not found"}


def test_login_wrong_password(shop, base_url):
    body = {"email": "user0001@shop.lab", "password": "wrong"}
    assert shop.post(f"{base_url}/api/login", json=body, timeout=30).status_code == 401


def test_cart_needs_token(shop, base_url):
    assert shop.get(f"{base_url}/api/cart", timeout=10).status_code == 401


def test_add_to_cart(buyer, base_url):
    response = buyer.post(f"{base_url}/api/cart/items", json={"product_id": 1, "qty": 2}, timeout=10)
    assert response.status_code == 201
    item = response.json()["items"][0]
    assert item["product_id"] == 1
    assert item["qty"] == 2

Что проверяет каждый тест: test_readyz сервис готов; test_catalog_page_size номер и размер страницы и общее число товаров; test_product_exists три товара из разных частей каталога; test_product_missing несуществующий товар даёт 404 и сообщение; test_login_wrong_password и test_cart_needs_token защита (401); test_add_to_cart корзина покупателя. Это набор «дымовых» проверок: не детальных, а ровно столько, чтобы убедиться, что нагружать есть что.

Запусти все тесты каталога:

pytest -v
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0 -- /home/student/perf-lab/.venv/bin/python
cachedir: .pytest_cache
rootdir: /home/student/perf-lab/04-python
collecting ... collected 18 items

test_shop_api.py::test_readyz PASSED                                     [  5%]
test_shop_api.py::test_catalog_page_size PASSED                          [ 11%]
test_shop_api.py::test_product_exists[1] PASSED                          [ 16%]
test_shop_api.py::test_product_exists[42] PASSED                         [ 22%]
test_shop_api.py::test_product_exists[10000] PASSED                      [ 27%]
test_shop_api.py::test_product_missing PASSED                            [ 33%]
test_shop_api.py::test_login_wrong_password PASSED                       [ 38%]
test_shop_api.py::test_cart_needs_token PASSED                           [ 44%]
test_shop_api.py::test_add_to_cart PASSED                                [ 50%]
test_shoptools.py::test_percentile_median_of_odd_list PASSED             [ 55%]
test_shoptools.py::test_percentile_of_sample[50-16] PASSED               [ 61%]
test_shoptools.py::test_percentile_of_sample[90-31] PASSED               [ 66%]
test_shoptools.py::test_percentile_of_sample[95-48] PASSED               [ 72%]
test_shoptools.py::test_percentile_of_sample[99-410] PASSED              [ 77%]
test_shoptools.py::test_percentile_does_not_change_input PASSED          [ 83%]
test_shoptools.py::test_percentile_of_empty_list_fails PASSED            [ 88%]
test_shoptools.py::test_error_rate PASSED                                [ 94%]
test_shoptools.py::test_error_rate_without_errors PASSED                 [100%]

============================== 18 passed in 0.62s ===============================

Как читать вывод: 18 тестов, файлы идут по алфавиту. Два теста (test_login_wrong_password, test_add_to_cart) заметно медленнее остальных: они проходят через вход с bcrypt (около четверти секунды). Время итога у тебя будет иным (обычно 1–2 секунды). Если упал test_product_missing, проверь, что стенд не перезапускали с другим набором данных.

Теперь потренируйся управлять запуском:

pytest -q
pytest -k percentile -v
pytest test_shop_api.py::test_readyz -v
pytest -x --tb=short

Разбор ключей: -q короткий вывод (точки вместо строк: каждая точка это прошедший тест). -k percentile запускает только тесты, в имени которых есть слово «percentile» (-k от «keyword», ключевое слово). Путь с :: запускает один конкретный тест: это ровно то, что pytest печатает в строке сводки FAILED ..., поэтому её можно копировать. -x --tb=short останавливается на первом падении и печатает короткий отчёт.

Типичные ошибки:

  • ConnectionError ... Connection refused во всех API-тестах: стенд не запущен. Тесты функций при этом проходят: они не ходят в сеть.
  • fixture 'buyer' not found: файл называется не conftest.py или лежит не рядом с тестами.
  • assert 422 == 201 в фикстуре buyer: пароль короче 8 символов. «Магазин» требует не меньше восьми.

5. Сломай тест намеренно

Убедись, что тест действительно ловит ошибку. Измени в shoptools.py строку rank = math.ceil(p / 100 * len(ordered)) на rank = int(p / 100 * len(ordered)) (округление вниз вместо вверх) и запусти:

pytest test_shoptools.py -q
F...F....                                                                [100%]
=================================== FAILURES ===================================
____________________ test_percentile_median_of_odd_list ____________________
...
E       assert 10 == 20
...
___________________ test_percentile_of_sample[99-410] ___________________
...
E       assert 48 == 410
=========================== short test summary info ============================
FAILED test_shoptools.py::test_percentile_median_of_odd_list - assert 10 == 20
FAILED test_shoptools.py::test_percentile_of_sample[99-410] - assert 48 == 410
2 failed, 7 passed in 0.03s

Как читать вывод: упали два теста из девяти, остальные семь зелёные. Первая F это тест с тремя числами: int(0,5 × 3) даёт 1 вместо 2, индекс сдвигается на единицу, и вместо 20 функция возвращает 10. Вторая F это p99: int(0,99 × 20) = int(19,8) = 19 вместо 20, и вернулось 48, а не 410. Тесты p50, p90 и p95 остались зелёными: там 0,5 × 20, 0,9 × 20 и 0,95 × 20 дают целые 10, 18 и 19, и округление вверх или вниз ничего не меняет. Тест не обязан ловить всё сразу: он ловит то, что ты в него заложил.

Верни ceil, запусти ещё раз: все зелёные. Привычка: убедись, что тест хоть раз был красным по нужной причине, иначе неизвестно, проверяет ли он что-либо.

6. Закоммить результат

cd ~/perf-lab
git add .gitignore requirements.txt 04-python/shoptools.py 04-python/test_shoptools.py 04-python/conftest.py 04-python/test_shop_api.py
git commit -m "4.7: pytest, тесты функций и первые тесты API Магазина"
git push

Как читать вывод: git сообщит число изменённых файлов. Проверь git status: рабочее дерево чистое, и папки .pytest_cache в списке нет.

Тест упал, и ты не понимаешь почему? Прочитай строки assert и E в отчёте сам, потом покажи нейросети весь отчёт. Её версию проверь повторным запуском одного теста.

Сломай и почини

Поломка 1: тест падает из-за неверного ожидания. В test_shop_api.py замени в test_add_to_cart проверку assert response.status_code == 201 на == 200 и запусти pytest -x --tb=short:

>       assert response.status_code == 200
E       assert 201 == 200
E        +  where 201 = <Response [201]>.status_code

test_shop_api.py:43: AssertionError
FAILED test_shop_api.py::test_add_to_cart - assert 201 == 200

Задача. Определи, что сломано: сервис или тест. Что сделать?

Разбор

Левая часть (201) это ответ сервиса, правая (200) то, что ждал тест. Но создание ресурса в REST-API по уроку 2.2 это 201, значит, сервис прав, а ожидание ошибочно. Верни == 201. Общий приём: перед тем как «чинить» падение, определи, кто прав: сервис, спецификация или тест.

Поломка 2: падает «чужой» тест. Верни 201, останови стенд (cd ~/learning/load-tester/project/shop && docker compose stop shop) и запусти pytest -x --tb=short. Потом верни стенд (docker compose start shop, подожди, пока ответит readyz).

E   requests.exceptions.ConnectionError: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /readyz (Caused by NewConnectionError(... Connection refused))
FAILED test_shop_api.py::test_readyz - requests.exceptions.ConnectionError: ...

Задача. Почему тест не показал сообщение вида «assert … == …»? Куда смотреть в таком отчёте?

Разбор

До assert дело не дошло: исключение случилось в самом вызове shop.get(...), и pytest показал его так, как есть. Строка E называет тип (ConnectionError), адрес и порт (localhost:8000) и причину (Connection refused: на порту никто не слушает). Это не ошибка теста, а недоступность стенда. Проверь curl -s localhost:8000/readyz и подними стенд. На Ubuntu в тексте будет [Errno 111], на macOS [Errno 61]: это один и тот же отказ в соединении.

Поломка 3: ошибка в ожидании параметризованного теста. Измени в test_shoptools.py ожидание (95, 48) на (95, 410) и запусти pytest -q. Упадёт один набор: test_percentile_of_sample[95-410], assert 48 == 410. Выбери решение: поправить функцию или тест.

Разбор

Для 20 измерений p95 по методу ближайшего ранга это значение с рангом ceil(0,95 × 20) = 19, то есть 48 (двадцатое значение 410 это p99). Значит, функция верна, а ожидание ошибочно: верни (95, 48). А вот если бы ты изменил формулу в функции и тест остался зелёным, это был бы сигнал, что тестов недостаточно: надо добавить случай, который эту разницу ловит.

ИИ в помощь

Нейросеть быстро набрасывает тесты, но смысл проверки должен понимать ты: тест, который ничего не проверяет, хуже его отсутствия. Общие правила на странице ИИ-помощник.

Задача: набросать тесты для функции.

pytest 9, Python 3.12. Функция:
<вставь функцию percentile>
Напиши тесты: обычный случай, граничные значения (пустой список, один элемент, p=0 и p=100) и проверку исключения через pytest.raises. Используй parametrize. Объясни, что проверяет каждый тест.

Проверь ответ: запусти pytest -v и намеренно сломай функцию: тесты обязаны упасть. Типичная ошибка: тест повторяет ту же формулу, что и функция, и всегда проходит, а ожидаемые числа не посчитаны вручную.

Задача: прочитать отчёт о падении теста.

pytest показал:
<вставь вывод падения целиком>
Объясни каждую часть отчёта (какая строка, что ожидалось и что получено). Это failed или error? Что исправлять: тест или код?

Проверь ответ: воспроизведи падение одним тестом: pytest test_x.py::test_имя -v. Типичная ошибка: «исправить» тест под неверный результат вместо того, чтобы исправить код.

Словарик урока

Термин Простыми словами
Тест (test) Функция test_..., которая проверяет одно утверждение и либо проходит, либо падает
pytest Библиотека, которая находит тесты по именам, запускает их и печатает отчёт
assert Утверждение: если ложно, возникает AssertionError, тест падает
AssertionError Исключение, которое вызывает ложный assert
pytest.raises Проверка, что код внутри блока вызывает указанное исключение
Фикстура (fixture) Функция, которая готовит (и убирает) то, что нужно тесту; подставляется по имени аргумента
yield в фикстуре Отдаёт значение тесту; код после него это уборка
scope Как часто готовить фикстуру: для каждого теста или один раз на запуск (session)
conftest.py Файл с общими фикстурами; pytest находит его сам
Параметризация Один тест, запущенный на нескольких наборах данных
passed, failed, error Прошёл; не сошлась проверка; упала подготовка до теста
Строка > и строки E В отчёте: проверка, которая не сошлась; ожидали и получили
-x, -q, -k, --tb=short Остановиться на первом падении; короткий вывод; фильтр по имени; сокращённый путь ошибки
Дымовой тест (smoke) Короткая проверка «живо ли и работает ли главное»

Вопросы с собеседований

Раздел для повторения: ответь вслух, потом открой ответ. Вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.

1. [junior] [часто] Как pytest находит тесты?

Ответ

По соглашению об именах: файлы test_*.py или *_test.py и в них функции test_* (и методы Test* классов). Запуск pytest без аргументов обходит текущий каталог рекурсивно. Файл conftest.py pytest подхватывает сам для общих фикстур.

Что хотят услышать: имена файлов и функций, рекурсивный обход, conftest.py.

Красный флаг: считает, что тесты надо перечислять вручную.

2. [junior] [часто] Чем тест, который упал (failed), отличается от теста с ошибкой (error)?

Ответ

Failed значит, что тест дошёл до проверки и она не сошлась (AssertionError) либо исключение возникло в теле теста. Error значит, что упала подготовка (исключение в фикстуре до начала теста) или уборка после теста. По первому смотрят, что не так в сервисе или в ожиданиях, по второму что не так с подготовкой, а при ошибке подготовки сам тест не выполнялся.

Что хотят услышать: разница по месту падения, пример с фикстурой.

Красный флаг: считает их одним и тем же.

3. [junior] [часто] Что такое фикстура и зачем она нужна?

Ответ

Функция с @pytest.fixture, которая готовит данные или объекты для теста (сессию, залогиненного пользователя, адрес стенда) и по желанию убирает за ним (код после yield). Тест получает её результат, просто назвав фикстуру аргументом. Это убирает повторяющийся код и делает тесты независимыми: каждому свой чистый покупатель.

Что хотят услышать: подготовка и уборка, подстановка по имени, область scope.

Красный флаг: вызывает фикстуру руками как обычную функцию.

4. [middle] Как проверить, что функция правильно бросает исключение?

Ответ

Через with pytest.raises(ValueError): и вызов внутри блока. Тест проходит, если исключение указанного типа возникло. Если не возникло, будет DID NOT RAISE. Можно дополнительно проверить текст: pytest.raises(ValueError, match="хотя бы одно").

Что хотят услышать: pytest.raises и проверка типа (и при желании текста).

Красный флаг: оборачивает в try/except и пишет pass.

5. [middle] Что такое параметризация и когда она лучше цикла for внутри теста?

Ответ

@pytest.mark.parametrize запускает один тест на нескольких наборах данных, и каждый набор это отдельный тест в отчёте. Цикл for внутри теста останавливается на первом падении: остальные наборы не проверяются, а какой набор упал, видно не всегда. Параметризация даёт независимые случаи, понятные идентификаторы и повторный запуск одного набора.

Что хотят услышать: отдельные тесты, понятный отчёт, пример.

Красный флаг: копирует тест четыре раза с разными числами.

6. [junior] Как прочитать отчёт о падении теста?

Ответ

Сначала итоговая строка (сколько упало), затем блок упавшего теста: строка со стрелкой > это проверка, которая не сошлась; строки E показывают, что получили и что ожидали, where откуда взялось значение; последняя строка блока это файл и номер строки. Для длинных отчётов смотреть на последнюю строку и на E, внутренние файлы библиотек пропускать. Помогают --tb=short и -x.

Что хотят услышать: стрелка, E, итог, ключи для сокращения.

Красный флаг: читает все сто строк и теряется.

7. [middle] Тест упал. Что вы сделаете в первую очередь?

Ответ

Прочитаю отчёт и определю, кто неправ: сервис, ожидание в тесте, данные или окружение (стенд не запущен). Воспроизведу отдельным запуском (pytest путь::имя -x). Поправлю то, что действительно сломано, а не «подгоню» ожидание под фактический результат без проверки.

Что хотят услышать: анализ причины, повторный запуск одного теста, осторожность с правкой ожиданий.

Красный флаг: «просто поменяю ожидаемое значение на то, что пришло».

8. [middle] Почему тесты должны быть независимы друг от друга?

Ответ

Порядок запуска может меняться, тесты запускают по одному (-k), часть может быть пропущена или запущена параллельно. Если тест зависит от результатов предыдущего (например, от товара в корзине), он падает «случайно», и причину ищут не там. Независимость достигается фикстурами: каждому тесту своё чистое окружение.

Что хотят услышать: фикстура на каждый тест, нет общего состояния.

Красный флаг: тесты, которые работают только в определённом порядке.

9. [middle] Зачем в тесте функции перцентиля проверять, что исходный список не изменился?

Ответ

Функция, которая сортирует переданный список на месте, незаметно ломает данные вызывающего: порядок измерений по времени теряется, и следующая функция, считающая что-то по порядку, даёт неверные числа. Тест на неизменность аргумента ловит такую «побочную» ошибку, которую по результату самой функции не заметить.

Что хотят услышать: побочные эффекты, пример с sorted и sort.

Красный флаг: считает, что достаточно проверить возвращаемое значение.

10. [на скорость] Как запустить один конкретный тест?

Ответ

pytest файл.py::имя_теста, например pytest test_shop_api.py::test_readyz -v. Либо фильтр по имени: pytest -k readyz.

Что хотят услышать: путь с :: или -k.

Красный флаг: запускает всё и ищет глазами.

11. [на скорость] Что делают ключи -x и -q?

Ответ

-x останавливает запуск на первом упавшем тесте. -q делает вывод коротким: точки вместо имён и итоговая строка.

Что хотят услышать: оба назначения.

Красный флаг: путает -x с «пропустить».

12. [на скорость] Что значит строка E assert 201 == 200?

Ответ

Проверка assert получили == ожидали не сошлась: слева получено 201, справа ожидалось 200.

Что хотят услышать: слева факт, справа ожидание.

Красный флаг: не знает, что E значит «Error».

Проверено на версиях

Ubuntu 24.04 и 26.04, Python 3.12.3, pytest 9.1, requests 2.34.2, стенд «Магазин» из project/shop. Октябрь 2026. Номера версий в твоём requirements.txt могут быть новее.

Итог урока: ты умеешь

  • Поставить pytest в окружение и зафиксировать версию в requirements.txt.
  • Написать тест-функцию с assert и понимать, по каким правилам pytest её находит.
  • Проверять функции на типичных, граничных и ошибочных данных, в том числе через pytest.raises.
  • Использовать @pytest.mark.parametrize для нескольких наборов данных.
  • Написать фикстуру с подготовкой и уборкой и вынести общие в conftest.py.
  • Написать первые тесты API «Магазина» через requests.
  • Прочитать отчёт о падении: строка со стрелкой, строки E, итог; отличить failed от error.
  • Запускать нужные тесты ключами -k, -x, -q, --tb=short и путём с ::.
  • Попросить нейросеть набросать тесты и проверить их: сломанный код должен их ронять.

Дальше: тема 5. Docker и контейнеры. Тесты API разовьются в уроке 6.2, а shoptools.py пригодится для обработки результатов нагрузки.

Проверь себя

Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.

Тест работает с включённым JavaScript.

тема 4 урок 4.7 3.5 ч курс 0/0 ← → уроки