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.
80 lines
4.8 KiB
Markdown
80 lines
4.8 KiB
Markdown
# 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).
|