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

4.8 KiB
Raw Blame History

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.

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

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).