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