gendesign/tradein-mvp/backend/tests/support/README.md
bot-backend 57963e3a06 test(tradein): reusable legacy/scraper_kit parity harness (#2304)
Stage 0 of the scraper_kit migration epic (#2277): shared test tool for
issues #2305-#2310, which each need to prove their kit-path importer
produces the same output as the legacy path on the same input.

- tests/support/parity.py: assert_parity()/compare_outputs() normalize
  dataclass/pydantic outputs to dict/list/scalar before comparing, since
  legacy vs kit dataclasses (e.g. DetailEnrichment) are different classes
  and dataclass __eq__ always returns False across classes even when all
  field values match. Supports ignore_fields (drop non-deterministic
  fields like latency_ms/fetched_at) and numeric tolerance (math.isclose)
  for float fields, with an assertion listing every differing field
  (path + legacy value + kit value) on mismatch.
- tests/support/test_parity.py: unit tests for the harness itself
  (identical outputs pass, differing outputs raise with informative
  diff, tolerance/ignore_fields options, cross-class dataclass parity).
- tests/scrapers/test_avito_detail_kit_parity.py: end-to-end smoke proof
  against real code — app.services.scrapers.avito_detail.parse_detail_html
  (legacy, reached via admin.py's scrape_avito_detail debug endpoint
  through fetch_detail) vs scraper_kit.providers.avito.detail's copy, on
  a fixed HTML fixture.
- tests/support/README.md: usage note for #2305-#2310 migration PRs.

Found while implementing: tests/test_scraper_kit_*_parity.py (9 files,
~3400 lines) already do ad-hoc `dataclasses.asdict(old) == asdict(new)`
parity checks for the already-migrated SERP scraper modules (avito/cian/
domclick/yandex/base/scheduler/pipeline) — this harness generalizes that
repeated pattern for the remaining 12 non-scraper importers, adding
ignore_fields/tolerance which those ad-hoc checks don't have.
2026-07-03 23:45:58 +03:00

80 lines
4.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# tests/support/ — общие test-инструменты (не сами тесты)
## parity.py — legacy → scraper_kit parity harness (issue #2304)
Инструмент для issues #2305-#2310 (миграция 12 неймигрированных importers
`app/services/scrapers/*``scraper_kit` эквиваленты, см. audit
`Scraper_Kit_Legacy_Dependency_Audit_0703` в vault). Каждая такая миграция
должна доказать, что kit-путь даёт ТОТ ЖЕ результат, что и legacy-путь на
одном и том же входе — для этого используйте `assert_parity`.
### Быстрый старт
```python
from app.services.scrapers.cian_valuation import evaluate_via_cian as legacy_fn
from scraper_kit.providers.cian.valuation import evaluate_via_cian as kit_fn
from tests.support.parity import assert_parity
def test_cian_valuation_parity() -> None:
assert_parity(
legacy_fn=legacy_fn,
kit_fn=kit_fn,
fixtures=[
(fixture_html_1, "https://cian.ru/flat/1"),
(fixture_html_2, "https://cian.ru/flat/2"),
],
ignore_fields={"latency_ms", "fetched_at"}, # недетерминированные поля
tolerance=1e-6, # допуск для float-полей (напр. рассчитанные оценки)
)
```
### Как формировать `fixtures`
Каждый элемент списка — один тестовый вход:
- `tuple`/`list` → распаковывается как позиционные аргументы: `legacy_fn(*fixture)`;
- любое другое значение (str, dict, ...) → передаётся как единственный
позиционный аргумент: `legacy_fn(fixture)`.
Начните с 1-2 hardcoded HTML-фикстур/dict'ов (см.
`tests/scrapers/test_avito_detail_kit_parity.py` — референсный пример).
DB-фикстуры НЕ нужны для чистых parse/compute-функций — используйте их
только если сама legacy/kit-функция реально требует Session.
### Почему нельзя просто `==`
legacy- и kit-версии одного и того же dataclass (напр. `DetailEnrichment`,
`CianValuationResult`) — это РАЗНЫЕ Python-классы (живут в разных модулях),
даже если поля идентичны. Дефолтный `dataclass.__eq__` сначала проверяет
`other.__class__ is self.__class__` — для двух разных классов это всегда
`False`, ДАЖЕ когда все значения полей совпадают. `assert_parity` / `compare_outputs`
нормализуют оба вывода в dict/list/scalar (через `dataclasses.fields()` /
`.model_dump()` рекурсивно) и сравнивают СТРУКТУРНО, по именам полей — эта
проблема класс-идентичности не мешает.
### ignore_fields vs tolerance
- `ignore_fields={"latency_ms", "fetched_at", ...}` — поле целиком исключается
из сравнения на ЛЮБОМ уровне вложенности. Используйте для полей, у которых
даже приблизительное совпадение не гарантировано (timestamps, request-id).
- `tolerance=1e-6` — числовой (`int`/`float`, НЕ `bool`) допуск через
`math.isclose(rel_tol=tolerance, abs_tol=tolerance)`. Используйте для
float-полей, где legacy/kit могут давать чуть разное значение из-за
порядка операций с плавающей точкой (не для timestamps/datetime — там
используйте `ignore_fields`).
### При мисматче
`assert_parity` кидает `ParityMismatchError` (подкласс `AssertionError`) со
списком ВСЕХ различающихся полей: путь до поля + значение legacy + значение
kit. Не просто "not equal" — сразу видно, что чинить.
### Не входит в scope harness'а
- Он НЕ загружает DB-фикстуры сам — если legacy/kit функция требует
`Session`, передавайте mock/session в fixture-tuple как обычно.
Live network/DB в parity-тестах избегайте — они должны быть
detereministic offline unit-тестами.
- Он НЕ мигрирует сами importers — это делает каждый sub-issue #2305-#2310
отдельно (тесты для конкретной пары legacy/kit функций пишет тот sub-issue).