Перейти до змісту

Для фронтенд-тестів (Vitest + RTL, 80 тестів) див. testing-frontend.md.

Testing (backend)

Статус: Phase 1 аудиту реалізовано — 58 pytest-тестів покривають ядро (UniversalViewSet, permissions, serializers, posting, reports, eTTN, plugin-exclusion). Усі тести зелені на eswf.settings.development (SQLite + --nomigrations).

📖 Навіщо це і як воно влаштоване

Backend виконує дуже багато важливих речей автоматично: фільтрує дані за тенантом, генерує серіалізатори, робить проводки, рахує звіти. Одна випадкова зміна — і тенант перестає ізолюватись, або проводки стають не ідемпотентними. Тести — це «страхова сітка», що фіксує поведінку, яку ми не хочемо втратити.

У проєкті використано комбінацію:

  • pytest — базовий тестовий раннер (зручніший і коротший за unittest).
  • pytest-django — інтеграція з Django (фікстура db, APIClient, управління транзакціями, DJANGO_SETTINGS_MODULE).
  • pytest-cov — звіт про покриття (запускається опційно).
  • DRF APIClient — імітує HTTP-запити до view-сетів без мережі.

Запуск іде через віртуальне оточення backend/venv/, щоб не забруднювати глобальний Python.


🗂️ Структура

backend/
├── pytest.ini                      # конфіг pytest
├── conftest.py                     # fixtures (root) — доступні всім тестам
├── core/tests/
│   ├── __init__.py
│   ├── test_universal_viewset.py   # 12 тестів: whitelist sort/filter, пагінація, tenant, auth
│   ├── test_permissions.py         #  7 тестів: IsTenantUser, IsTenantAdmin
│   └── test_plugin_exclusion.py    #  8 тестів: _PLUGIN_URLS, active-plugins endpoint
├── essentials/tests/
│   ├── __init__.py
│   ├── test_serializers.py         #  5 тестів: auto_list_serializer_factory, TenantFilterMixin
│   ├── test_posting.py             #  7 тестів: post/unpost IncomingPayment, payment_block, idempotency
│   └── test_reports.py             # 11 тестів: P&L, Balance Sheet, Cash Flow, Payment Calendar
└── fleet/tests/
    ├── __init__.py
    └── test_ettn.py                #  8 тестів: Ed25519 sign/verify, canonical JSON, tamper-detection

Правило розміщення: тести лежать у папці tests/ поруч з кодом застосунку, який вони перевіряють, а не в єдиному глобальному каталозі. Це дає змогу автовідкривати їх через pytest без додаткової конфігурації.


⚙️ Конфігурація

backend/pytest.ini

[pytest]
DJANGO_SETTINGS_MODULE = eswf.settings.development
python_files = tests.py test_*.py *_tests.py
addopts = -q --reuse-db --tb=short --nomigrations
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
filterwarnings =
    ignore::DeprecationWarning
    ignore::PendingDeprecationWarning

Розшифровка для новачка:

Опція Що робить Чому саме так
DJANGO_SETTINGS_MODULE Вказує pytest-django, з якими налаштуваннями стартувати Django. development використовує SQLite, DEBUG=True — найшвидший спосіб для тестів.
python_files Маска файлів, які pytest вважає тестовими. Стандарт pytest-django + дозволяє *_tests.py для модульних підкаталогів.
-q «quiet» — менше шуму в консолі. Для CI/локального прогону — однієї крапки на тест достатньо.
--reuse-db Не перестворювати тестову БД між запусками. Значно пришвидшує повторні запуски (інакше — 30+ с лише на міграціях).
--tb=short Короткий traceback у разі падіння. Менше скролити, більше сигналу.
--nomigrations Створити схему БД напряму з моделей, оминувши міграції. Обходить баг у essentials/migrations/0024_* (RunPython seed падає, коли Tenant не існує на момент міграції). Чистіше й швидше для тестів.
markers: slow Кастомний маркер для повільних тестів. Запускати швидкий набір локально, повний — у CI: pytest -m "not slow".
filterwarnings Приглушити шумні warnings від сторонніх пакетів. Ми хочемо бачити лише наші problem-сигнали.

backend/conftest.py — фікстури рівня проєкту

Fixture — це функція, помічена @pytest.fixture, яку pytest передає як аргумент у тест. Це спосіб підготувати дані або клієнт без копіпасту в кожному тесті.

@pytest.fixture
def tenant(db):
    from core.models import Tenant
    return Tenant.objects.create(name='Acme LLC', code='acme')

Що тут важливо:

  • db — вбудована фікстура pytest-django: активує доступ до БД і відкриває транзакцію, яка rollback-ається по закінченні тесту. Тобто кожен тест стартує з чистою БД.
  • scope='function' (default) — фікстура виконується заново для кожного тесту. Це дорожче, але виключає «перетікання» стану між тестами.

Наявні fixtures:

Fixture Повертає Використання
tenant Tenant(code='acme') Головний тенант у тестах
other_tenant Tenant(code='globex') Для перевірки ізоляції між тенантами
user User(username='alice', tenant=tenant) Звичайний юзер тенанта acme
other_user User(username='bob', tenant=other_tenant) Юзер конкурента — для cross-tenant тестів
admin_user User(is_staff=True, is_superuser=True, is_tenant_admin=True) Адмін тенанта
api_client APIClient() Без автентифікації (для 401/403 тестів)
auth_client APIClient з force_authenticate(user) Автентифікований як alice
other_auth_client аналогічно, але як bob Для перевірки, що bob не бачить дані alice
admin_client автентифікований як admin_user Для permission-тестів на tenant-admin endpoints
currency_uah Currency(code='UAH') Валюта, прив'язана до tenant
organization Organization(code='ORG1') Організація в tenant
chart_accounts dict з ChartOfAccounts (5310, 5120, 6240, 7000, 7060) PCG-ноди для тестів проводок/звітів
posting_group factory (функція, що повертає PostingGroup) Збалансована двозапис Dt/Ct для тестів регістрів

▶️ Як запускати

Перший раз:

cd c:/eswf/backend
# Віртуальне оточення (якщо ще немає)
python -m venv venv
venv/Scripts/activate            # Windows (у Git Bash)
# або
source venv/Scripts/activate     # Windows Bash
pip install -r requirements.txt
pip install pytest pytest-django pytest-cov

Регулярний запуск:

# Увесь набір
venv/Scripts/python.exe -m pytest

# Один файл
venv/Scripts/python.exe -m pytest core/tests/test_universal_viewset.py

# Один клас
venv/Scripts/python.exe -m pytest core/tests/test_universal_viewset.py::TestTenantIsolation

# Один метод
venv/Scripts/python.exe -m pytest core/tests/test_universal_viewset.py::TestTenantIsolation::test_list_returns_only_own_tenant_rows

# Стоп на першому падінні
venv/Scripts/python.exe -m pytest -x

# Повний traceback
venv/Scripts/python.exe -m pytest --tb=long

# З покриттям
venv/Scripts/python.exe -m pytest --cov=core --cov=essentials --cov=fleet --cov-report=term-missing

💡 Чому venv/Scripts/python.exe -m pytest, а не просто pytest? Це гарантує, що викличеться Python і pakети з віртуального оточення, навіть якщо у вашому PATH є інший pytest. Особливо важливо на Windows, де оточення часто плутаються.


✅ Що покривається зараз (58 тестів)

core/tests/test_universal_viewset.py — 12 тестів

Пінить головний UniversalViewSet, через який ходять усі CRUD-ендпоінти з EntityRegistry.

Клас Що перевіряємо
TestTenantIsolation list повертає лише рядки свого тенанта; retrieve чужого рядка → 404; create автоматично підставляє тенант.
TestAuthRouting Анонімний запит → 401; невідомий entity_code → 404; kebab-case (expense-items) резолвиться у camelCase.
TestPaginationSafety ?pageSize=99999 обмежено до PAGE_SIZE_MAX=500; невалідний pageSize=fooPAGE_SIZE_DEFAULT=25.
TestSortFilterWhitelist Сортування за невідомим полем ігнорується (не 500); фільтр поза whitelist ігнорується.
TestAutoCode MasterDataModel з порожнім code автогенерує код через get_next_masterdata_code.

core/tests/test_permissions.py — 7 тестів

Перевіряє IsTenantUser та IsTenantAdmin із core/permissions.py:

  • Анонімний → denied.
  • Автентифікований без тенанта → denied.
  • Автентифікований з тенантом → allowed (для IsTenantUser).
  • is_tenant_admin=True або is_superuser=True → allowed (для IsTenantAdmin).

core/tests/test_plugin_exclusion.py — 8 тестів

Регресійна сітка для plugin-exclusion flow (деталі — plugin-instruction.md):

  • _PLUGIN_URLS містить logistic як еталон + усі відомі плагіни.
  • ESWF_AVAILABLE_PLUGINS у settings дзеркалить _PLUGIN_URLS.
  • Маршрут /api/v1/logistic/ існує тільки коли apps.is_installed('logistic') → True.
  • Ендпоінт /api/v1/shop/licenses/active-plugins/: 401 без авторизації, list[str] у відповіді, порожні plugin_key фільтруються.

essentials/tests/test_serializers.py — 5 тестів

Головні інваріанти з core/serializers.py:

  • auto_list_serializer_factory додає <fk>_name поля (read-only) для всіх FK.
  • Системні поля (tenant, tenant_id, created_by_id, updated_by_id) позначаються read-only в auto_serializer_factory.
  • TenantFilterMixin.list повертає лише рядки request.tenant.
  • Важливо: list-action завжди використовує auto_list_serializer_factory — кастомний серіалізатор треба перевизначати в get_serializer_class, інакше воно нічого не зробить.
  • tenant_id у payload під час create ігнорується (попередження атаки "impersonation").

essentials/tests/test_posting.py — 7 тестів

Головні гарантії essentials/services/posting.py:

  • Post IncomingPayment → створюється запис у CashJournal.
  • Повторний post ідемпотентний: .exclude(manual_edit=True).delete() → пересоздає авторядки, лишає ручні.
  • Unpost зберігає рядки з manual_edit=True.
  • post_outgoing_payment викидає ValidationError, коли PurchaseInvoice.payment_block=True (запобігає оплаті заблокованих рахунків).
  • Розблокований invoice → оплата проходить.
  • post_invoice_planned без due_date не створює запис у PlannedPaymentJournal.
  • posting_group factory — перевірка, що сума Dt = сума Ct (балансова властивість).

essentials/tests/test_reports.py — 11 тестів

Smoke-тести 4 канонічних звітів (essentials/views/reports.py):

Report 400 коли 200 коли
P&L (/profit-loss/) date_from/date_to відсутні валідний період
Balance Sheet (/balance-sheet/) as_of відсутній або as_of=not-a-date валідний as_of=2026-04-30
Cash Flow (/cash-flow/) дати відсутні або некоректні валідний період + CashJournal seed
Payment Calendar (/payment-calendar/) дати відсутні валідний період

Плюс cross-tenant leak guard: seed 9999 у чужому тенанті → не з'являється в body відповіді.

fleet/tests/test_ettn.py — 8 тестів

Крипто-примітиви fleet/ettn_service.py (Ed25519, SHA-256):

  • generate_test_keypair() → PEM + 16-символьний hex-fingerprint.
  • Дві згенеровані пари ключів — різні.
  • canonical_json порядково-незалежний (вкладені dict теж).
  • document_hash стабільний для еквівалентних dict.
  • sign_document + verify_signature round-trip.
  • Мутація payload → verify повертає False.
  • Підпис ключем A не проходить перевірку ключем B.
  • Сміття на вході (невалідний base64, коротка довжина) → False без raise.

🧪 Як писати новий тест (шаблон)

Чистий функціональний тест без БД

"""module docstring — навіщо цей файл і що перевіряємо."""
from __future__ import annotations

import pytest


def test_canonical_json_is_order_independent():
    from fleet.ettn_service import canonical_json

    a = {'a': 1, 'b': 2}
    b = {'b': 2, 'a': 1}
    assert canonical_json(a) == canonical_json(b)

Жодних fixtures — pytest автозбирає функції з префіксом test_.

Тест з БД + тенантом

import pytest


@pytest.mark.django_db
def test_price_type_defaults_to_sell(tenant):
    from essentials.models import PriceType

    pt = PriceType.objects.create(tenant=tenant, code='SELL', name='Sell')
    assert pt.kind == 'sell'
  • @pytest.mark.django_dbобов'язково для тестів, що торкаються БД. pytest-django активує транзакційну ізоляцію.
  • tenant — взятий з conftest.py. pytest сам бачить, що фікстура потрібна, і передає її.

HTTP-тест API

@pytest.mark.django_db
class TestMyEndpoint:
    URL = '/api/v1/essentials/data/price-type/'

    def test_unauthenticated_is_rejected(self, api_client):
        resp = api_client.get(self.URL)
        assert resp.status_code in (401, 403)

    def test_authenticated_returns_empty_list(self, auth_client):
        resp = auth_client.get(self.URL)
        assert resp.status_code == 200
        assert resp.json()['results'] == []
  • api_client — сирий APIClient без авторизації.
  • auth_client — уже автентифікований як alice з тенанта acme.
  • resp.json() — розпарсений body. results — стандартний ключ DRF-пагінації.

Тест проводок з factory

@pytest.mark.django_db
def test_my_posting_function(tenant, organization, currency_uah, chart_accounts, posting_group):
    from datetime import date
    group = posting_group(
        date=date(2026, 4, 15),
        debit_account=chart_accounts['5310'],
        credit_account=chart_accounts['7000'],
        amount='1000.00',
    )
    assert group.amount == Decimal('1000.00')
    assert group.entries.count() == 2

⚠️ Gotchas (граблі, які ми вже розгребли)

1. TenantMiddleware та DRF force_authenticate

Симптом: тест з auth_client.post(...) падає з NOT NULL constraint failed: tenant_id.

Причина: Django middleware виконується до DRF-аутентифікації. На момент роботи TenantMiddleware користувач ще AnonymousUser, тому request.tenant = None. DRF потім виставляє request.user, але middleware уже відпрацював.

Рішення: request.tenant тепер SimpleLazyObject — резолвиться на першому зверненні у view, коли DRF уже автентифікував користувача (backend/core/middleware/tenant.py).

Це також виправляє cross-tenant витік у продакшені для JWT-клієнтів — бо в продакшені та сама проблема: JWT обробляється у DRF initial(), тобто після middleware.

2. auto_list_serializer_factory перекриває кастомний серіалізатор

Симптом: Ваш кастомний ItemSerializer з додатковими полями працює на detail-запиті, але list повертає не ті поля.

Причина: UniversalViewSet.get_serializer_class() для action == 'list' за замовчуванням використовує auto_list_serializer_factory, який не знає про ваш кастом.

Рішення: Перевизначте get_serializer_class у своєму viewset, або зареєструйте serializer в EntityRegistry.register(...) — тоді UniversalViewSet використає його і для list (див. core/views/universal.py:103-113).

3. --nomigrations обходить broken migration 0024

Симптом: venv/Scripts/python.exe -m pytest падає з NOT NULL constraint failed: essentials_taxrate.tenant_id ще до першого тесту.

Причина: essentials/migrations/0024_* має RunPython seed TaxRate, який припускає існування тенантів. У свіжій тестовій БД їх немає.

Рішення: --nomigrations у pytest.ini — pytest-django створює схему прямо з моделей, пропускаючи RunPython. Для продакшен-дев-бд використовуємо звичайне migrate.

4. Пам'ятайте про from __future__ import annotations

На початку кожного тестового файлу. Це дає відкладене обчислення анотацій, уникає import cycles для Model.objects.create(...: type hint).

5. Import моделей всередині функції, а не на верхньому рівні

# ❌ Верхній рівень — Django ще не bootstrapped
from core.models import User

def test_something():
    ...

# ✅ Усередині функції — ліниво, після ініціалізації pytest-django
def test_something():
    from core.models import User
    ...

Це дає перевагу: якщо модель зміниться, тест не зламається під час import-часу, а лише коли ви справді її викликаєте.


📊 Звіт про покриття

venv/Scripts/python.exe -m pytest --cov=core --cov=essentials --cov=fleet --cov-report=html
# HTML-звіт у htmlcov/index.html — відкрити в браузері

Або швидкий термінальний звіт:

venv/Scripts/python.exe -m pytest --cov=core --cov=essentials --cov=fleet --cov-report=term-missing

Формат term-missing: для кожного файлу показано номери рядків, що не покриті. Це найшвидший спосіб знайти «дірки».


🎯 Принципи, яких ми дотримуємось

  1. Один тест — одне твердження. Не змішуйте 5 assert'ів на різні поведінки в один тест. Краще 5 коротких методів з красномовними назвами.
  2. Тест має впасти, якщо функцію зламати. Перевіряйте результат, а не виклик: assert journal.balance == Decimal('100'), а не mock_posting.assert_called().
  3. Імена говорять самі за себе. test_blocked_purchase_invoice_prevents_posting читається як речення — не треба docstring'а.
  4. Не мокайте те, що можете побудувати. У нас @pytest.mark.django_db + fixtures дає реальну SQLite — це швидко і надійно. Моки варто використовувати тільки для зовнішніх сервісів (OpenRouter, Wialon, SMTP).
  5. Cross-tenant — обов'язковий параноїдальний клас для будь-якої нової view. Створіть рядок у чужому тенанті й перевірте, що він не спливає.
  6. Snapshot імпортів у тілі тесту — щоб баг у import не ламав зібрання всього модуля.

🚧 Roadmap

Наступні кроки аудиту (з audit-2026-04-21.md §10):

  • Phase 2: розширити покриття до ~150 тестів (CRM, Fleet waybill, shop, mobile sync).
  • CI: GitHub Actions workflow, що запускає pytest + npx vite build на кожному PR.
  • Coverage gate: pytest --cov-fail-under=60 у CI (зараз 0, ціль — 60%, потім 80%).
  • Property-based tests: для проводок (Hypothesis) — автогенерувати випадкові комбінації Dt/Ct і перевіряти інваріанти балансу.
  • Slow-маркер: перенести повільні тести (звіти з великими seed'ами) у @pytest.mark.slow, швидкий локальний прогін — pytest -m "not slow".

🔗 Пов'язане

  • backend.md — архітектура backend, де ці тести живуть
  • plugin-instruction.md — plugin-exclusion flow, частину якого покриває test_plugin_exclusion.py
  • audit.md — аудит, що дав поштовх до Phase 1 тестування
  • conftest.py — реальні фікстури
  • pytest.ini — реальна конфігурація