Metadata-Version: 2.4
Name: testy-pytest-adapter
Version: 1.0.8
Summary: Pytest plugin for sync Tests with TestY TMS
Author: Igor Safronov
License: MIT
Project-URL: Homepage, https://github.com/TheGreatPepix/testy-pytest-adapter
Project-URL: Repository, https://github.com/TheGreatPepix/testy-pytest-adapter
Keywords: pytest,testy,tms,test-management,allure,reporting
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: Pytest
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Testing
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pytest>=7
Requires-Dist: requests>=2.28
Provides-Extra: dev
Requires-Dist: responses>=0.25; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Dynamic: license-file

# testy-pytest-adapter

Pytest-плагин, который после прогона отправляет результаты автотестов в **TestY TMS**:
в нужный тест-план, рядом с ручными тестами, с историей запусков и связкой «кейс ↔ автотест».

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

Обычно автотесты и TMS живут отдельно. Результаты остаются в Allure или JUnit, каждый новый
прогон перетирает предыдущий, а статусы в TMS приходится обновлять руками.

Этот адаптер решает эту проблему:

- сопоставляет pytest-тест с тест-кейсом в TestY и отправляет результат
  (`passed`, `failed`, `skipped`, `broken`) с комментарием и трейсбеком;
- записывает результаты в конкретный тест-план и сохраняет историю прогонов;
- может создавать недостающие кейсы, наборы и планы из кода по разметке Allure;
- может связать автотест с уже существующим ручным кейсом по id (`@testy.case`);
- добавляет к результату логи и ссылки на CI-джобу.

Это именно мост между автотестами и TMS, а не замена Allure. Allure по-прежнему можно
использовать для подробного разбора прогона.

## Установка

```bash
pip install testy-pytest-adapter
```

Плагин подхватывается pytest автоматически. Пока не передан флаг `--testy`
или не включён `TESTY_ENABLED` / `testy_enabled`, он ничего не делает.

## Быстрый старт

### 1. Установите плагин
```bash
pip install testy-pytest-adapter
```

### 2. Настройте pytest.ini
Добавьте в `pytest.ini` (или в секцию `[tool.pytest.ini_options]` файла `pyproject.toml`):

```ini
[pytest]
; Параметр включающий плагин по умолчанию, любой запуск теста будет
; отправлять результат в TestY при наличии токена в окружении
testy_enabled = true 
testy_url = https://testy.example.ru
testy_project_id = 4
testy_root_name = Autotests

; маппинг статусов на значения вашего проекта TestY
testy_status_passed = Пройден
testy_status_failed = Провален
testy_status_skipped = Пропущен
testy_status_broken = Сломан
testy_status_untested = Untested
```

Токен доступа **не храните в файле** – передавайте его через переменную окружения `TESTY_TOKEN`.

### 3. Создайте структуру в TestY (первый запуск)
Синхронизация создаст дерево наборов, планов и тест‑кейсы с атрибутом `automation_id`.  
Тесты **не выполняются**, только собираются (`--collect-only`).

```bash
export TESTY_TOKEN=<ваш_токен>
pytest --testy --testy-sync --collect-only tests/
```

После синхронизации в TestY появится готовая структура.

### 4. Запускайте тесты с отправкой результатов
Для обычного прогона (в том числе в CI) флаги `--testy-sync` и `--collect-only` **не нужны**.

```bash
pytest --testy tests/
```
Результаты будут записаны в соответствующий тест‑план. При падениях к кейсу автоматически добавятся сообщение об ошибке, трейсбек, вложения и ссылки на CI-джобу.

## Как сопоставляются тест и кейс

Адаптер берёт стабильный идентификатор теста из pytest **nodeid**.
По умолчанию суффикс параметризации `[...]` отрезается.

Пример nodeid:

```text
tests/api/login_test.py::TestLogin::test_login
```

Дальше адаптер ищет кейс, у которого в атрибутах есть `automation_id` с таким же значением:

```text
cases/?case_attributes={"automation_id":"..."}
```

То есть id кейса в коде прописывать не нужно. Достаточно, чтобы у кейса в TestY был атрибут
`automation_id` с nodeid теста.

Заполнить этот атрибут можно вручную или автоматически через `--testy-sync`.
Если автотест нужно связать с уже существующим ручным кейсом, используйте `@testy.case(id)` —
см. раздел [«Связь с ручным кейсом»](#связь-с-ручным-кейсом-testycase).

По умолчанию адаптер находит нужный `Тест` так: резолвит кейс по `automation_id`, а затем
ищет его экземпляр в дереве плана `TESTY_PLAN_ID` (с учётом вложенных планов). Если же задан
`TESTY_TEST_MAP` (его передаёт `testy-gitlab-plugin`), адаптер пишет результат **напрямую**
в перечисленные там `test_id`, без резолва плана. Это позволяет одним прогоном записать
результаты в тесты из разных тест-планов и адресно — в конкретные `Тесты`, а не «угадывая» по
дереву. В этом режиме `TESTY_PLAN_ID`/`testy_root_name` для репортинга не нужны.

### Большой прогон: таргеты по хэндлу вместо инлайна

`TESTY_PYTEST_TARGETS` (выбор тестов для прогона) и `TESTY_TEST_MAP` (адресная запись)
передаются как JSON прямо в переменных пайплайна. На большом плане они **упираются в лимит
размера CI-переменной**.

Чтобы это обойти, runner может отдать адаптеру не сами данные, а **маленький хэндл**:

- `TESTY_RUN_ID` — id прогона; адаптер запросит
  `GET {TESTY_URL}/plugins/gitlab-runner/api/runs/{run_id}/targets/`;
- либо `TESTY_TARGETS_URL` — полный URL эндпоинта (приоритетнее, если route другой).

Адаптер делает один запрос на старте и ожидает ответ вида:

```json
{ "targets": ["tests/api/login_test.py::TestLogin::test_login", "..."],
  "target_map": {"tests/api/login_test.py::TestLogin::test_login": [123, 456]} }
```

и дальше работает как с инлайновыми `TESTY_PYTEST_TARGETS`/`TESTY_TEST_MAP`. Лимит переменной
больше не мешает, и сценарий «тесты из разных тест-планов» тоже работает — список приходит из
эндпоинта. Если заданы и инлайн-переменные, и хэндл — приоритет у инлайна (обратная
совместимость). Если хэндл задан, но запрос не удался, прогон падает с понятной ошибкой
(чтобы случайно не прогнать весь репозиторий вместо нужной выборки).

## Связь с ручным кейсом: `@testy.case`

Если кейс уже написан руками (со своими шагами), автотест можно привязать к нему по id,
не создавая отдельный автокейс:

```python
import pytest
import testy


@testy.case(1234)
def test_login():
    ...


@testy.case(10, 11)          # один автотест закрывает несколько кейсов
def test_profile():
    ...


@pytest.mark.parametrize("role", [
    pytest.param("admin", marks=testy.case(55)),   # у каждого параметра свой кейс
    pytest.param("user", marks=testy.case(56)),
])
def test_access(role):
    ...
```

`testy.case` — обычный маркер pytest (`testy_case`), поэтому работает с классами, `-m` и xdist.

**Что делает sync-шаг** (`--testy-sync`). Для привязанного кейса адаптер ничего не создаёт,
а записывает в его атрибуты:

- `automation_id` — nodeid теста, чтобы кейс можно было запускать из TestY через
  `testy-gitlab-plugin`;
- `automation_manual: "true"` — метку ручного кейса.

Запись идёт, только если что-то поменялось (первая привязка или тест переехал); повторный sync
кейс не трогает. Метки, вложения и шаги кейса при этом сохраняются, в истории кейса появляется
новая версия.

**Что адаптер никогда не делает с ручным кейсом** (с атрибутом `automation_manual`): не меняет
его шаги, имя и сьют — даже если с теста позже снимут декоратор. Чистка истории в
`testy-gitlab-plugin` такие кейсы тоже пропускает.

**Как отправляется результат.** Шаги автотеста и шаги ручного кейса не сопоставляются (в кейсе
может быть 5 шагов, а в автотесте 10). Всем шагам кейса ставится общий статус автотеста,
а в комментарий результата при падении попадают трейсбек и дерево шагов Allure — видно,
где именно сломалось.

Если на один кейс ссылаются несколько автотестов, их результаты сливаются в один: итоговый
статус худший, в комментарии указано, какой тест упал. При запуске из TestY по одному такому
тесту остальные тесты этого кейса подтягиваются в прогон автоматически.

Sync предупреждает в логе, если:

- на кейс ссылаются несколько тестов (в `automation_id` попадает первый по алфавиту nodeid);
- у кейса раньше был другой `automation_id` (тест переехал);
- такой же `automation_id` есть у другого кейса — например, у старого автокейса этого теста;
- кейс не найден или принадлежит другому проекту.

## Конфигурация

Значения берутся в таком порядке:

```text
CLI → переменные окружения → pytest.ini → дефолт
```

Токен намеренно не читается из `pytest.ini`. Его лучше передавать через переменную окружения
или CLI:

```text
--testy-token / TESTY_TOKEN
```

Несекретные настройки удобно хранить в `pytest.ini` или в `[tool.pytest.ini_options]`
в `pyproject.toml`.

```ini
[pytest]
testy_enabled = true
testy_url = https://testy.example.com
testy_project_id = 4
testy_root_name = Autotests

; Если вы используете самоподписанный сертификат на стенде:
testy_insecure = true

; имена статусов в проекте могут быть локализованы:
testy_status_passed = Пройден
testy_status_failed = Провален
testy_status_skipped = Пропущен
testy_status_broken = Сломан
testy_status_untested = Untested
```

Тогда для запуска достаточно передать токен через окружение:

```bash
TESTY_TOKEN=... pytest tests/
```

## Основные опции

| CLI                 | Переменная окружения                      | pytest.ini             | Назначение                                                             |
| ------------------- |-------------------------------------------|------------------------|------------------------------------------------------------------------|
| `--testy`           | `TESTY_ENABLED`                           | `testy_enabled`        | включить отправку в TestY                                              |
| `--testy-url`       | `TESTY_URL`                               | `testy_url`            | базовый адрес TestY                                                    |
| `--testy-token`     | `TESTY_TOKEN`                             | -                      | токен доступа                                                          |
| `--testy-project`   | `TESTY_PROJECT_ID`                        | `testy_project_id`     | id проекта                                                             |
| `--testy-plan`      | `TESTY_PLAN_ID`                           | `testy_plan_id`        | id корневого тест-плана                                                |
| -                   | `TESTY_PYTEST_TARGETS`                    | -                      | JSON-список nodeid/путей: прогнать только эти тесты                    |
| -                   | `TESTY_TEST_MAP`                          | -                      | карта `{automation_id: [id тестов]}` для адресной записи результатов   |
| -                   | `TESTY_RUN_ID`                            | -                      | id прогона у runner-сервиса — адаптер сам подтянет таргеты и `test_map`|
| -                   | `TESTY_TARGETS_URL`                       | -                      | полный URL, откуда забрать `{targets, target_map}` (приоритетнее `TESTY_RUN_ID`)|
| `--testy-root-name` | `TESTY_ROOT_NAME`                         | `testy_root_name`      | имя корня, если план нужно найти или создать по имени                  |
| `--testy-suite-name`| `TESTY_SUITE_NAME`                        | `testy_suite_name`     | имя корневого набора, если оно отличается от `testy_root_name`         |
| `--testy-plan-name` | `TESTY_PLAN_NAME`                         | `testy_plan_name`      | имя корневого плана, если оно отличается от `testy_root_name`          |
| `--testy-suite`     | `TESTY_SUITE_ID`                          | `testy_suite_id`       | корневой набор для автосоздания кейсов                                 |
| `--testy-sync`      | `TESTY_SYNC` или `TESTY_MODE=sync`        | -                      | создать кейсы и структуру перед прогоном                               |
| -                   | `TESTY_KEEP_PARAMS`                       | `testy_keep_params`    | не срезать `[param]`, каждая параметризация будет отдельным кейсом     |
| -                   | `TESTY_OVERRIDE_CASES`                    | `testy_override_cases` | обновлять имя, сьют и шаги существующих автокейсов, по умолчанию `true`|
| -                   | `TESTY_ATTACH`                            | `testy_attach`         | когда загружать вложения: `failure`, `always`, `never`                 |
| -                   | `TESTY_ATTACH_BYTES`                      | `testy_attach_bytes`   | захватывать `allure.attach` из памяти (скриншоты), по умолчанию `true` |
| -                   | `TESTY_AUTH_SCHEME`                       | `testy_auth_scheme`    | схема авторизации: `Token` или `Bearer`                                |
| -                   | `TESTY_INSECURE`                          | `testy_insecure`       | отключить проверку TLS-сертификата                                     |
| -                   | `TESTY_STATUS_PASSED` и остальные статусы | `testy_status_*`       | реальные имена статусов проекта                                        |
| -                   | `TESTY_WORKERS`                           | `testy_workers`        | Количество воркеров клиента для Testy API                              |

## Авторизация и статусы

Для авторизации используется TTL-токен из TestY:

```text
POST /api/token/obtain/
```

Имена статусов в TestY могут отличаться от стандартных. Например, в проекте они могут
называться `Пройден`, `Провален`, `Пропущен`.

Адаптер сначала приводит результат pytest к одному из канонических статусов:

| Итог pytest                                  | Канонический статус |
| -------------------------------------------- | ------------------- |
| passed, xfailed                              | `Passed`            |
| failed, xpassed                              | `Failed`            |
| ошибка в setup / фикстуре                    | `Broken`            |
| skipped                                      | `Skipped`           |

Если в проекте нет статуса для `Broken` (`testy_status_broken` / `TESTY_STATUS_BROKEN`),
ошибка в setup отправляется как `Failed`, а не теряется.

Затем эти статусы можно замапить на реальные имена проекта:

```bash
TESTY_STATUS_PASSED=Пройден
TESTY_STATUS_FAILED=Провален
```

Или задать их в `pytest.ini`:

```ini
testy_status_passed = Пройден
testy_status_failed = Провален
```

## Автосоздание кейсов и структуры

Чтобы не заполнять `automation_id` вручную, можно запустить отдельный sync-шаг.
Он создаст недостающие кейсы и добавит их в план.

Тесты при этом не выполняются, запуск идёт через `--collect-only`:

```bash
pytest --collect-only --testy --testy-sync \
  --testy-root-name=Autotests \
  --testy-url="$TESTY_URL" \
  --testy-token="$TESTY_TOKEN" \
  --testy-project=4
```

Sync-шаг можно включить не только флагом `--testy-sync`, но и переменной окружения —
`TESTY_SYNC=1` или `TESTY_MODE=sync`. Это нужно для запуска из CI без правки команды pytest:
например, `testy-gitlab-plugin` при нажатии «Sync autotests» сам выставляет `TESTY_MODE=sync`.
Джоба синхронизации должна по-прежнему собирать тесты без выполнения (`--collect-only`).

Дерево суитов и тест-планов в TestY строится из разметки Allure:

| Allure в коде                                                                            | В TestY                                              |
| ---------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `@allure.parent_suite("API")` / `@allure.suite("/api/login")` / `@allure.sub_suite(...)` | вложенное дерево `TestSuite` и `TestPlan` под корнем |
| `@allure.title("GET /api/login")`                                                        | имя `TestCase`                                       |

Без Allure тоже работает. Allure не обязателен и даже не должен быть установлен.

Если у теста нет `@allure.title`, имя кейса берётся из имени pytest-теста:

```text
test_login
test_login[case1]
```

Если нет `@allure.suite`, `@allure.parent_suite` или `@allure.sub_suite`, дерево не строится.
Кейсы создаются плоско под корнем, который задан через `--testy-suite` или `--testy-root-name`.

Матчинг при этом всё равно работает по nodeid → `automation_id`.
Allure нужен только для более читаемых имён и структуры.

Sync идемпотентный: повторный запуск находит уже созданные сущности и не плодит дубли.
Лучше запускать его отдельным шагом, без xdist-воркеров, а уже после этого запускать обычный прогон
с отправкой результатов.

### Жизненный цикл кейсов и сьютов (ротация)

> ℹ**Всё описанное ниже происходит только на sync-шаге** (`--testy-sync` / `TESTY_SYNC` /
> `TESTY_MODE=sync`). Обычный прогон с отправкой результатов имена кейсов и дерево
> наборов/планов **не трогает** — он лишь резолвит кейс по `automation_id` и пишет результат.
> Единственное исключение — **шаги Allure**: они синхронизируются и на обычном прогоне (см.
> раздел [«Шаги Allure»](#шаги-allure)).
>
> Практически: поменял `@allure.title` или `@allure.parent_*` — прогони sync-шаг, иначе
> изменения не подтянутся. Поменял только шаги теста — отдельный sync не нужен.

При каждом sync адаптер не только создаёт недостающее, но и подтягивает уже существующие
кейсы/сьюты под актуальное состояние кода. Логика разная, потому что у кейса и сьюта разная
«идентичность».

**Кейс** — идентичность это `automation_id`, равный nodeid теста
(`path/to/module.py::TestClass::test_func`, суффикс `[params]` отрезается). По шагам:

1. Кейса с таким `automation_id` нет → **создаётся новый** под листовым сьютом.
2. Кейс найден и изменился `@allure.title` → **переименование** (полный `PUT`, у кейсов нет PATCH;
   сценарий и текст шагов сохраняются, но шаги пересоздаются с новыми id, а метки и вложения
   кейса сбрасываются — см. ограничения ниже).
3. Кейс найден и изменился только сьют (правка `@allure.parent_suite/suite/sub_suite`) →
   **переезд** через `cases/bulk-update/`: патчится только привязка к сьюту, имя/сценарий/шаги
   не трогаются.
4. Изменилось и имя, и сьют → один `PUT`, который делает и то, и другое.
5. Ничего не изменилось → действий нет.

Всё это управляется настройкой `testy_override_cases` (по умолчанию `true`). При `false`
существующий кейс не трогается — только досоздаются отсутствующие.

> **Идентичность завязана на nodeid.** Если переименовать модуль/класс/функцию или
> перенести тест-файл в другую папку — nodeid меняется, и для адаптера это **новый тест**:
> создастся новый кейс, а старый осиротеет (останется в TestY, но результаты в него больше
> не пойдут, история разорвётся). Чтобы сохранить кейс при таком переезде в коде —
> вручную поправьте у него атрибут `automation_id` в TestY на новый nodeid.

**Сьют и узел тест-плана** — идентичность это стабильный атрибут `automation_id`, который строится
из **пути в nodeid** (файловая структура), а не из текста allure-метки. Дерево суитов и дерево
планов обрабатываются одинаково. По шагам:

1. Узел найден по атрибуту, но текст метки (`parent_suite/suite/...`) изменился →
   **переименование на месте** (`PATCH` только `name`), id сохраняется — старый узел не дублируется.
2. Узла с таким атрибутом нет, но есть узел с совпадающим именем (легаси/созданный вручную) →
   матчинг по имени, атрибут дописывается, чтобы в следующий раз сматчилось по нему.
3. Не найден ни по атрибуту, ни по имени → **создаётся**.

Поскольку при ренейме метки и сьют, и узел плана сохраняют id, кейсу обычно даже не нужно
«переезжать», а тест-инстанс с его результатами остаётся на месте. Bulk-update-переезд кейса
срабатывает только при реальной смене структуры дерева.

> Переименование узла плана идёт строго `PATCH {"name": ...}` — поле `test_cases` адаптер
> в апдейт плана **никогда не кладёт**: TestY трактует его как полную замену набора и удалил бы
> инстансы и результаты кейсов, которых нет в списке.

**Что пока НЕ ротируется (известные ограничения):**

* Старый **пустой** сьют/узел плана при реальной смене структуры дерева не удаляется — остаётся в TestY.
* **Удаление** кейсов/сьютов, исчезнувших из кода, адаптер не делает.
* Когда адаптер переписывает автокейс через `PUT` (переименование или новые шаги Allure),
  TestY сбрасывает у кейса метки и вложения и пересоздаёт шаги. К ручным кейсам из
  `@testy.case` это не относится — их адаптер не переписывает.

## Доказательства падений: вложения и ссылки

Вложения к результату можно добавить двумя способами.

**Через `testy.attach`** — явно указать локальный файл:

```python
import testy

def test_login(page):
    page.screenshot(path="fail.png")
    testy.attach("fail.png")
    ...
```

**Через Allure** — если установлен `allure-pytest`, адаптер сам подхватывает любые
`allure.attach(...)` и `allure.attach.file(...)` и грузит их к результату. Отдельно
вызывать `testy.attach` не нужно — в том числе если скриншот прикрепляется из хука
`pytest_runtest_makereport` (автоскриншот на падении):

```python
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item):
    outcome = yield
    report = outcome.get_result()
    if report.when == "call" and not report.passed:
        allure.attach(page.screenshot(), attachment_type=allure.attachment_type.PNG)
```

Картинки (`image/*`) дополнительно встраиваются прямо в текст комментария результата.

Когда загружать вложения, задаётся через `TESTY_ATTACH`:

```text
failure / always / never
```

По умолчанию используется `failure`, то есть вложения отправляются только на падениях.

Захват `allure.attach()` можно отключить, оставив только файловые вложения:

```text
TESTY_ATTACH_BYTES=false   # или testy_attach_bytes = false в pytest.ini
```

Также адаптер сохраняет в атрибутах результата ссылки из GitLab CI:

```text
CI_PIPELINE_URL
CI_JOB_URL
```

## Шаги Allure

Если установлен `allure-pytest`, адаптер забирает дерево выполненных `allure.step`
и синхронизирует его в кейс как структурные шаги.

Перезапись шагов существующего кейса управляется настройкой:

```text
testy_override_cases
```

По умолчанию значение `true`: кейсы поддерживаются в актуальном состоянии относительно кода.

В отличие от имён и дерева, шаги синхронизируются **на обычном прогоне** с отправкой результатов —
отдельный `--testy-sync` для них не нужен.

Если Allure не установлен, этот механизм просто не используется.

Ручные кейсы, привязанные через `@testy.case`, шагами Allure не перезаписываются: их шагам
ставится общий статус автотеста, а дерево шагов Allure при падении попадает в комментарий.

## Параметризованные тесты

По умолчанию все варианты одного параметризованного теста схлопываются в один кейс.
Суффикс параметризации `[...]` отрезается.

Итоговый статус выбирается как худший из всех вариантов. Например, если один вариант упал,
весь кейс будет отмечен как `failed`. В комментарий добавляется краткая сводка.

Если нужно, чтобы каждый параметр стал отдельным кейсом, включите:

```bash
TESTY_KEEP_PARAMS=1
```

## Пример для GitLab CI

Если у вас уже заданы параметры в `pytest.ini`, то для запуска из Gitlab CI используйте такой конфиг:

```yaml
testy_run:
  stage: test
  script:
    - pytest -v -s tests --testy-token=${TESTY_TOKEN}
```

`TESTY_*` удобно передавать как переменные пайплайна.
`TESTY_TOKEN` лучше хранить как masked CI/CD-переменную репозитория.

## Поведение и гарантии

* Ошибки отправки результатов только логируются.
* Прогон тестов не падает из-за проблем с TestY. Исключение — заданы `TESTY_RUN_ID` /
  `TESTY_TARGETS_URL`, а выборку тестов получить не удалось: тогда pytest останавливается
  с ошибкой, чтобы не прогнать весь репозиторий.
* При запуске через `pytest-xdist` воркеры собирают результаты, а запись в TestY выполняет
  только контроллер в конце прогона.

## Логи

Адаптер пишет в логгер `testy_pytest` (уровень `INFO`): что создал, переименовал, перенёс,
куда отправил результат и какие ошибки получил от API.

По умолчанию этих сообщений **не видно**. Бо́льшая часть из них (создание/переименование/перенос
кейсов и структуры) появляется на этапе синка — внутри сбора тестов, ещё до их запуска, — и pytest
такие логи проглатывает, пока не включён вывод в консоль.

Чтобы видеть логи прямо в терминале, включите live-логи pytest. Разово через CLI:

```bash
pytest --testy --testy-sync --collect-only --log-cli-level=INFO
```

Или постоянно — в `pytest.ini` (или `[tool.pytest.ini_options]` в `pyproject.toml`):

```ini
[pytest]
log_cli = true
log_cli_level = INFO
log_cli_format = %(levelname)s %(name)s: %(message)s
```

Если шум от остальных библиотек мешает, держите общий уровень повыше, а наш логгер опустите
в `conftest.py`:

```python
import logging

logging.getLogger("testy_pytest").setLevel(logging.INFO)
```

```ini
[pytest]
log_cli = true
log_cli_level = WARNING
```

## Лицензия

MIT - см. файл [`LICENSE`](./LICENSE).
