gendesign/tradein-mvp/backend/tests/support/test_parity.py
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

119 lines
4.5 KiB
Python
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.

"""Unit-тесты для parity-harness'а самого по себе (tests/support/parity.py).
Проверяет три сценария из issue #2304:
- legacy/kit выводы идентичны → assert_parity проходит без исключения;
- выводы различаются → ParityMismatchError с информативным диффом (не просто "not equal");
- tolerance-опция гасит незначащие float-расхождения (напр. latency_ms).
Также покрывает ignore_fields (второй способ игнорировать недетерминированные поля)
и кейс, ради которого harness вообще нужен: dataclass-инстансы РАЗНЫХ классов
(имитация legacy vs kit module) с одинаковыми полями должны сравниваться
структурно, а не через identity классов.
"""
from __future__ import annotations
from dataclasses import dataclass
import pytest
from tests.support.parity import ParityMismatchError, assert_parity, compare_outputs
def test_assert_parity_passes_when_outputs_identical() -> None:
def legacy_fn(x: int) -> dict[str, int]:
return {"value": x * 2}
def kit_fn(x: int) -> dict[str, int]:
return {"value": x * 2}
assert_parity(legacy_fn, kit_fn, fixtures=[1, 2, 3])
def test_assert_parity_raises_with_informative_diff_when_outputs_differ() -> None:
def legacy_fn(x: int) -> dict[str, int]:
return {"value": x, "count": 10}
def kit_fn(x: int) -> dict[str, int]:
return {"value": x, "count": 999} # намеренное расхождение
with pytest.raises(ParityMismatchError) as exc_info:
assert_parity(legacy_fn, kit_fn, fixtures=[1])
message = str(exc_info.value)
# Диагностика должна называть КОНКРЕТНОЕ поле и оба значения, не просто "not equal".
assert "$.count" in message
assert "10" in message
assert "999" in message
# value совпало у обеих функций → не должно попасть в список различий.
assert "$.value" not in message
def test_assert_parity_tolerance_ignores_small_float_drift() -> None:
def legacy_fn(x: int) -> dict[str, float]:
return {"latency_ms": 100.001, "score": 0.5}
def kit_fn(x: int) -> dict[str, float]:
return {"latency_ms": 100.004, "score": 0.5}
# Без tolerance — расхождение 0.003 ловится.
with pytest.raises(ParityMismatchError):
assert_parity(legacy_fn, kit_fn, fixtures=[1])
# С tolerance >= drift — проходит.
assert_parity(legacy_fn, kit_fn, fixtures=[1], tolerance=0.01)
def test_ignore_fields_skips_named_field_entirely() -> None:
def legacy_fn(x: int) -> dict[str, object]:
return {"fetched_at": "2026-01-01T00:00:00Z", "value": x}
def kit_fn(x: int) -> dict[str, object]:
return {"fetched_at": "2026-07-03T12:00:00Z", "value": x} # timestamp всегда разный
with pytest.raises(ParityMismatchError):
assert_parity(legacy_fn, kit_fn, fixtures=[1])
assert_parity(legacy_fn, kit_fn, fixtures=[1], ignore_fields={"fetched_at"})
def test_dataclass_instances_of_different_classes_compared_structurally() -> None:
# Имитация legacy vs kit: одинаковые поля, РАЗНЫЕ классы (разные модули).
@dataclass
class LegacyResult:
item_id: str
price: int
@dataclass
class KitResult:
item_id: str
price: int
legacy = LegacyResult(item_id="42", price=100)
kit = KitResult(item_id="42", price=100)
# Прямое == было бы False (dataclass __eq__ проверяет class identity первым).
assert legacy != kit
# compare_outputs сравнивает по полям, а не по классу → различий нет.
assert compare_outputs(legacy, kit) == []
def test_dataclass_field_mismatch_reported_by_name() -> None:
@dataclass
class LegacyResult:
item_id: str
price: int
@dataclass
class KitResult:
item_id: str
price: int
legacy = LegacyResult(item_id="42", price=100)
kit = KitResult(item_id="42", price=200)
diffs = compare_outputs(legacy, kit)
assert len(diffs) == 1
assert "price" in diffs[0]
assert "100" in diffs[0]
assert "200" in diffs[0]