Modern QA2026AI-Powered Drift Detection — tiles
Log inJoin
78 / 89 · 04 API & Contract Testing with AI · Schema Drift Detection← prev⊞ allnext →☰ Read as one page

12.2AI-Powered Drift Detection

from dataclasses import dataclass

@dataclass
class Drift:
    type: str           # UNDOCUMENTED_FIELD, MISSING_FIELD, TYPE_MISMATCH, etc.
    field: str
    severity: str       # HIGH, MEDIUM, LOW
    message: str

class SchemaDriftDetector:
    """Detect differences between documented API schema and actual behavior."""

    def __init__(self, openapi_spec: dict, base_url: str):
        self.spec = openapi_spec
        self.base_url = base_url

    def detect_response_drift(self, endpoint: str, method: str) -> list[Drift]:
        """Compare actual API response against documented schema."""
        # Get documented schema
        documented = self.spec["paths"][endpoint][method]["responses"]["200"]
        documented_schema = documented["content"]["application/json"]["schema"]

        # Get actual response
        response = httpx.request(method.upper(), f"{self.base_url}{endpoint}")
        actual_body = response.json()

        drifts = []

        # Check for undocumented fields
        actual_fields = set(self._flatten_keys(actual_body))
        documented_fields = set(self._flatten_keys(documented_schema.get("properties", {})))

        undocumented = actual_fields - documented_fields
        for field in undocumented:
            drifts.append(Drift(
                type="UNDOCUMENTED_FIELD",
                field=field,
                severity="LOW",
                message=f"Field '{field}' present in response but not in schema"
            ))

        # Check for missing documented fields
        missing = documented_fields - actual_fields
        for field in missing:
            required = field in documented_schema.get("required", [])
            drifts.append(Drift(
                type="MISSING_FIELD",
                field=field,
                severity="HIGH" if required else "LOW",
                message=f"Field '{field}' documented but missing from response"
            ))

        # Check for type mismatches
        for field in actual_fields & documented_fields:
            actual_type = type(actual_body.get(field)).__name__
            doc_type = documented_schema["properties"].get(field, {}).get("type")
            if doc_type and not self._types_match(actual_type, doc_type):
                drifts.append(Drift(
                    type="TYPE_MISMATCH",
                    field=field,
                    severity="HIGH",
                    message=f"Field '{field}': documented as '{doc_type}', "
                            f"actual is '{actual_type}'"
                ))

        return drifts

    def _flatten_keys(self, obj, prefix="") -> list[str]:
        """Flatten a nested dict/schema into dot-notation keys."""
        keys = []
        if isinstance(obj, dict):
            for key in obj:
                full_key = f"{prefix}.{key}" if prefix else key
                keys.append(full_key)
        return keys

    def _types_match(self, python_type: str, openapi_type: str) -> bool:
        """Check if a Python type matches an OpenAPI type."""
        mapping = {
            "str": "string",
            "int": "integer",
            "float": "number",
            "bool": "boolean",
            "list": "array",
            "dict": "object",
            "NoneType": "null",
        }
        return mapping.get(python_type) == openapi_type