test(tradein): reusable legacy/scraper_kit parity harness (#2304) #2313

Merged
lekss361 merged 2 commits from feat/tradein-parity-test-harness into main 2026-07-03 20:52:26 +00:00
5 changed files with 536 additions and 0 deletions

View file

@ -0,0 +1,129 @@
"""Smoke-proof для parity-harness'а (tests/support/parity.py) — issue #2304.
Доказывает, что harness реально ловит эквивалентность legacy scraper_kit на
РЕАЛЬНОЙ паре функций, а не на синтетике: `parse_detail_html` parsing-ядро,
которое `app/api/v1/admin.py::scrape_avito_detail` (debug-роут ручного запуска
парсера) вызывает косвенно через `fetch_detail()` (fetch_detail = HTTP GET +
`parse_detail_html(html, url)`; сам parse чистая функция, без сети/БД, что и
позволяет сравнить её offline на фиксированной HTML-фикстуре).
`app/services/scrapers/avito_detail.py::parse_detail_html` (legacy) и
`scraper_kit/providers/avito/detail.py::parse_detail_html` (kit) на момент
написания теста ИДЕНТИЧНЫ по телу функции (см. audit
`Scraper_Kit_Legacy_Dependency_Audit_0703` в vault) различаются только
import-пути модулей, из которых берутся зависимости (`app.services.scrapers.*`
vs `scraper_kit.*`) и Strangler-инжекция `ScraperConfig` в соседних функциях
(`fetch_detail`, `_build_detail_session`), которую сам `parse_detail_html` не
использует. Это НЕ миграция admin.py (issue #2305+) — только доказательство
инструмента.
HTML-фикстура тот же minimal-marker набор, что и в
`tests/test_avito_detail_parse.py::test_minimal_parse` (независимая копия
литерала, чтобы этот тест не зависел от другого test-модуля).
"""
# ruff: noqa: E501 # HTML-фикстура содержит длинные строки разметки
from __future__ import annotations
import dataclasses
import os
import pytest
# app.services.scrapers.avito_detail импортирует app.core.config.settings=Settings(),
# которому нужен DATABASE_URL. Офлайн-парсинг БД не трогает — фиктивный DSN достаточен
# (mirror tests/test_scraper_kit_avito_golden_parity.py — тест должен жить и без
# CI-уровневого DATABASE_URL из ci-tradein.yml/deploy-tradein.yml).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.providers.avito.detail import (
parse_detail_html as kit_parse_detail_html,
)
from app.services.scrapers.avito_detail import (
parse_detail_html as legacy_parse_detail_html,
)
from tests.support.parity import ParityMismatchError, assert_parity
_DETAIL_HTML = """
<html><body>
<div data-marker="item-view/item-id"> 7986882804 · 18 мая в 13:40 · 1899 просмотров (+ 12 сегодня)</div>
<span itemprop="price" content="11990000">11 990 000 </span>
<div data-marker="item-map-wrapper" data-map-lat="56.797713" data-map-lon="60.609135" data-location-id="654070"></div>
<div data-marker="item-view/item-params">
<ul>
<li>Количество комнат: 3</li>
<li>Общая площадь: 75 м²</li>
<li>Площадь кухни: 15 м²</li>
<li>Этаж: 20 из 26</li>
<li>Балкон или лоджия: лоджия</li>
<li>Ремонт: евро</li>
<li>Способ продажи: свободная</li>
<li>Условия продажи: возможна ипотека</li>
</ul>
<ul>
<li>Тип дома: монолитный</li>
<li>Этажей в доме: 26</li>
</ul>
</div>
<a data-marker="nd-jk-details-button" href="/catalog/houses/ekaterinburg/ul_postovskogo_17a/3171365">Дом</a>
<div data-marker="domoteka-entry-block">
<p data-marker="TeaserData.item">1 собственник</p>
<p data-marker="TeaserData.item">Последняя смена собственника 5 мая 2020</p>
<p data-marker="TeaserData.item">Не найдены ограничения и обременения</p>
<p data-marker="TeaserData.item">Совпадают площадь, адрес и этаж</p>
</div>
<div data-marker="item-view/item-description">Отличная квартира в новом ЖК. Чкаловская 11-15 мин пешком.</div>
</body></html>
"""
_SOURCE_URL = "https://www.avito.ru/ekaterinburg/kvartiry/test_7986882804"
def test_legacy_and_kit_parse_detail_html_are_class_distinct() -> None:
"""Sanity-check ДО harness'а: подтверждает, зачем harness вообще нужен.
legacy DetailEnrichment и kit DetailEnrichment РАЗНЫЕ классы (разные
модули) обычный `==` даёт False, хотя данные идентичны. Именно это
harness должен обходить через структурное сравнение (dataclasses.fields).
"""
legacy_result = legacy_parse_detail_html(_DETAIL_HTML, _SOURCE_URL)
kit_result = kit_parse_detail_html(_DETAIL_HTML, _SOURCE_URL)
assert type(legacy_result) is not type(kit_result)
assert legacy_result != kit_result # dataclass __eq__ проверяет class identity первым
assert dataclasses.asdict(legacy_result) == dataclasses.asdict(kit_result)
def test_parity_harness_confirms_avito_detail_parse_equivalence() -> None:
"""Реальное proof-of-concept использование harness'а (issues #2305-#2310 template)."""
assert_parity(
legacy_fn=legacy_parse_detail_html,
kit_fn=kit_parse_detail_html,
fixtures=[(_DETAIL_HTML, _SOURCE_URL)],
)
def test_parity_harness_catches_real_field_divergence() -> None:
"""Доказывает, что harness реально ЛОВИТ расхождение на этой же реальной
30+-полевой dataclass-форме а не только сообщает "нет разницы" на двух
genuinely-identical инстансах, как предыдущий тест.
Мутируем один field (``price_rub``) в kit-результате через
``dataclasses.replace`` и убеждаемся, что harness падает с
``ParityMismatchError``, называющим именно этот field.
"""
legacy_result = legacy_parse_detail_html(_DETAIL_HTML, _SOURCE_URL)
kit_result = kit_parse_detail_html(_DETAIL_HTML, _SOURCE_URL)
assert kit_result.price_rub is not None # sanity: поле реально распарсилось из фикстуры
mutated_kit_result = dataclasses.replace(kit_result, price_rub=kit_result.price_rub + 1)
with pytest.raises(ParityMismatchError) as exc_info:
assert_parity(
legacy_fn=lambda: legacy_result,
kit_fn=lambda: mutated_kit_result,
fixtures=[()],
)
assert "price_rub" in str(exc_info.value)

View file

@ -0,0 +1,80 @@
# 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).

View file

@ -0,0 +1,187 @@
"""Общий harness для parity-тестов legacy → scraper_kit (issue #2304).
Каждая миграция importer'а legacy `app/services/scrapers/*` на его
`scraper_kit` эквивалент (issues #2305-#2310) должна доказать, что kit-путь
даёт ТОТ ЖЕ результат, что и legacy-путь на одном и том же входе. Это
переиспользуемый инструмент для такого доказательства, НЕ сама миграция.
Usage (см. tests/support/README.md для подробностей)::
from tests.support.parity import assert_parity
def test_parse_detail_html_parity() -> None:
assert_parity(
legacy_fn=legacy_parse_detail_html,
kit_fn=kit_parse_detail_html,
fixtures=[(html, source_url)],
ignore_fields={"latency_ms", "fetched_at"},
tolerance=1e-6,
)
Design note: legacy- и kit-версии одного и того же dataclass (напр.
``DetailEnrichment``) это РАЗНЫЕ классы (разные модули), поэтому обычный
``==`` между их инстансами всегда False (dataclass ``__eq__`` сравнивает
``other.__class__ is self.__class__`` первым делом). Поэтому сравнение
нормализует оба вывода в dict/list/scalar (``_normalize``) и сравнивает
СТРУКТУРНО, по полям а не через identity классов.
"""
from __future__ import annotations
import dataclasses
import math
from collections.abc import Callable, Iterable, Mapping, Sequence
from typing import Any
class ParityMismatchError(AssertionError):
"""legacy_fn(...) и kit_fn(...) дали разный результат на одной фикстуре.
Подкласс AssertionError совместим с `pytest.raises(AssertionError)`
и обычным assert-репортингом pytest.
"""
def _normalize(value: Any) -> Any:
"""Приводит dataclass / pydantic-модель к сравнимому dict/list/scalar.
Рекурсивно вложенные dataclass/pydantic-поля тоже нормализуются.
"""
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return {f.name: _normalize(getattr(value, f.name)) for f in dataclasses.fields(value)}
if hasattr(value, "model_dump") and callable(value.model_dump):
return _normalize(value.model_dump())
if isinstance(value, Mapping):
return {k: _normalize(v) for k, v in value.items()}
if isinstance(value, list | tuple):
return [_normalize(v) for v in value]
return value
def _is_number(value: Any) -> bool:
# bool — подкласс int, но семантически не "число с допуском".
return isinstance(value, int | float) and not isinstance(value, bool)
def _within_tolerance(legacy_val: Any, kit_val: Any, tolerance: float) -> bool:
if not tolerance or not _is_number(legacy_val) or not _is_number(kit_val):
return False
return math.isclose(float(legacy_val), float(kit_val), rel_tol=tolerance, abs_tol=tolerance)
def _diff(
path: str,
legacy_val: Any,
kit_val: Any,
ignore_fields: frozenset[str],
tolerance: float,
diffs: list[str],
) -> None:
legacy_val = _normalize(legacy_val)
kit_val = _normalize(kit_val)
legacy_is_map = isinstance(legacy_val, Mapping)
kit_is_map = isinstance(kit_val, Mapping)
if legacy_is_map or kit_is_map:
if not (legacy_is_map and kit_is_map):
diffs.append(f"{path}: type mismatch — legacy={legacy_val!r}, kit={kit_val!r}")
return
for key in sorted({*legacy_val.keys(), *kit_val.keys()}, key=str):
if key in ignore_fields:
continue
child_path = f"{path}.{key}"
if key not in legacy_val:
diffs.append(f"{child_path}: missing in legacy, kit={kit_val[key]!r}")
elif key not in kit_val:
diffs.append(f"{child_path}: legacy={legacy_val[key]!r}, missing in kit")
else:
_diff(child_path, legacy_val[key], kit_val[key], ignore_fields, tolerance, diffs)
return
legacy_is_seq = isinstance(legacy_val, list | tuple)
kit_is_seq = isinstance(kit_val, list | tuple)
if legacy_is_seq or kit_is_seq:
if not (legacy_is_seq and kit_is_seq):
diffs.append(f"{path}: type mismatch — legacy={legacy_val!r}, kit={kit_val!r}")
return
if len(legacy_val) != len(kit_val):
diffs.append(f"{path}: length differs — legacy={len(legacy_val)}, kit={len(kit_val)}")
return
for i, (lv, kv) in enumerate(zip(legacy_val, kit_val, strict=True)):
_diff(f"{path}[{i}]", lv, kv, ignore_fields, tolerance, diffs)
return
if isinstance(legacy_val, bool) != isinstance(kit_val, bool):
# bool — подкласс int в Python, поэтому `True == 1` и `False == 0` дают True
# при обычном `==` (проверять надо isinstance(x, bool), НЕ isinstance(x, int)).
# Миграция, случайно превратившая `bool | None` поле в сырой 0/1 (или наоборот),
# должна считаться расхождением, даже если числовое значение совпадает.
diffs.append(
f"{path}: type mismatch (bool vs non-bool) — legacy={legacy_val!r}, kit={kit_val!r}"
)
return
if legacy_val == kit_val:
return
if _within_tolerance(legacy_val, kit_val, tolerance):
return
diffs.append(f"{path}: legacy={legacy_val!r} != kit={kit_val!r}")
def compare_outputs(
legacy_output: Any,
kit_output: Any,
*,
ignore_fields: Iterable[str] = (),
tolerance: float = 0.0,
) -> list[str]:
"""Возвращает список текстовых различий между двумя выводами (пусто = совпадают).
ignore_fields имена полей (ключи dict / поля dataclass на ЛЮБОМ уровне
вложенности), которые пропускаются при сравнении для недетерминированных
полей вроде ``latency_ms`` / ``fetched_at`` / ``request_id``.
tolerance абсолютно-и-относительный допуск (``math.isclose``) для
числовых полей (int/float, НЕ bool). Не применяется к datetime/date
такие поля, если недетерминированы, кладите в ignore_fields.
"""
diffs: list[str] = []
_diff("$", legacy_output, kit_output, frozenset(ignore_fields), tolerance, diffs)
return diffs
def assert_parity(
legacy_fn: Callable[..., Any],
kit_fn: Callable[..., Any],
fixtures: Sequence[Any],
*,
ignore_fields: Iterable[str] = (),
tolerance: float = 0.0,
) -> None:
"""Прогоняет legacy_fn и kit_fn на каждой фикстуре, сверяет вывод.
Args:
legacy_fn: функция из `app/services/scrapers/*` (legacy-путь).
kit_fn: соответствующая функция из `scraper_kit` (kit-путь).
fixtures: список входов. Каждый элемент фикстуры:
- tuple/list распаковывается как позиционные аргументы (``*args``);
- любое другое значение (dict, str, ...) передаётся как ЕДИНСТВЕННЫЙ
позиционный аргумент.
ignore_fields: имена полей, игнорируемых при сравнении (см. compare_outputs).
tolerance: числовой допуск для float-полей (см. compare_outputs).
Raises:
ParityMismatchError: если legacy_fn(...) != kit_fn(...) хотя бы на одной
фикстуре. Сообщение перечисляет ВСЕ различающиеся поля (path + old/new).
"""
for i, fixture in enumerate(fixtures):
args = fixture if isinstance(fixture, tuple | list) else (fixture,)
legacy_result = legacy_fn(*args)
kit_result = kit_fn(*args)
diffs = compare_outputs(
legacy_result, kit_result, ignore_fields=ignore_fields, tolerance=tolerance
)
if diffs:
diff_text = "\n".join(f" - {d}" for d in diffs)
raise ParityMismatchError(
f"Parity mismatch on fixture #{i} (input={fixture!r}):\n{diff_text}"
)

View file

@ -0,0 +1,140 @@
"""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_bool_vs_int_type_mismatch_is_not_silently_equal() -> None:
# Python bool — подкласс int: `True == 1` и `False == 0` дают True при обычном
# `==`. Миграция, случайно превратившая bool-поле в сырой 0/1, должна ловиться.
def legacy_fn(x: int) -> dict[str, object]:
return {"mortgage_available": True}
def kit_fn(x: int) -> dict[str, object]:
return {"mortgage_available": 1} # намеренно int вместо bool
with pytest.raises(ParityMismatchError) as exc_info:
assert_parity(legacy_fn, kit_fn, fixtures=[1])
message = str(exc_info.value)
assert "mortgage_available" in message
assert "bool" in message
# Обратный случай (False vs 0) и совпадающие типы (bool vs bool) — симметрично.
assert compare_outputs({"v": False}, {"v": 0}) != []
assert compare_outputs({"v": True}, {"v": True}) == []
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]