gendesign/tradein-mvp/backend/tests/support/test_parity.py
bot-backend d95c9e6e55
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m34s
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/tests): harden parity harness — bool-type mismatch detection + divergence-catch proof (#2304)
Code-reviewer follow-up on the parity harness (aaaf8179):

- parity.py: plain `==` in the scalar-equality branch let `True == 1` /
  `False == 0` silently pass (Python bool is an int subclass). A future
  migration bug turning a `bool | None` field into a raw 0/1 would slip
  through undetected. Now any type mismatch where exactly one side is a
  bool is reported as a diff, regardless of numeric equality.
- test_parity.py: unit test for the new bool-vs-int branch.
- test_avito_detail_kit_parity.py: the existing smoke test only proved the
  harness reports "no diff" on two genuinely-identical real dataclass
  instances — it never proved the harness catches a real divergence on
  this same 30+-field shape (only the toy fixtures in test_parity.py did).
  Added a test that mutates `price_rub` via dataclasses.replace() on the
  real kit DetailEnrichment and asserts assert_parity raises
  ParityMismatchError naming that field.
2026-07-03 23:45:58 +03:00

140 lines
5.4 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_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]