Для фронтенд-тестів (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=foo → PAGE_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_groupfactory — перевірка, що сума 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_signatureround-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 — відкрити в браузері
Або швидкий термінальний звіт:
Формат term-missing: для кожного файлу показано номери рядків, що не покриті. Це найшвидший спосіб знайти «дірки».
🎯 Принципи, яких ми дотримуємось¶
- Один тест — одне твердження. Не змішуйте 5 assert'ів на різні поведінки в один тест. Краще 5 коротких методів з красномовними назвами.
- Тест має впасти, якщо функцію зламати. Перевіряйте результат, а не виклик:
assert journal.balance == Decimal('100'), а неmock_posting.assert_called(). - Імена говорять самі за себе.
test_blocked_purchase_invoice_prevents_postingчитається як речення — не треба docstring'а. - Не мокайте те, що можете побудувати. У нас
@pytest.mark.django_db+ fixtures дає реальну SQLite — це швидко і надійно. Моки варто використовувати тільки для зовнішніх сервісів (OpenRouter, Wialon, SMTP). - Cross-tenant — обов'язковий параноїдальний клас для будь-якої нової view. Створіть рядок у чужому тенанті й перевірте, що він не спливає.
- 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 — реальна конфігурація