返回博客

SDK 测试策略:从单元测试到契约测试的全面覆盖

coomia-dip SDK 的测试策略涵盖五个层次:单元测试(逻辑正确性)、集成测试(gRPC 通信)、契约测试(API 兼容性)、端到端测试(真实场景)和性能测试(基准回归)。测试基础设施包括 Mock gRPC 服务器、Schema 固件生成器、快照测试和 CI 流水线集成。本文从测试金字塔、各层测试实现、测试基础设施到 CI/CD 集成,完整解析 SDK 的质量保障体系。

Coomia发布于 2025年10月1日7 分钟阅读
分享本文Twitter / X

系列:S6 平台工程 · 第 17 篇 | 难度:高级 | 阅读时间:18 分钟

SDK 测试策略:从单元测试到契约测试的全面覆盖

#TL;DR

coomia-dip SDK 的测试策略涵盖五个层次:单元测试(逻辑正确性)、集成测试(gRPC 通信)、契约测试(API 兼容性)、端到端测试(真实场景)和性能测试(基准回归)。测试基础设施包括 Mock gRPC 服务器、Schema 固件生成器、快照测试和 CI 流水线集成。本文从测试金字塔、各层测试实现、测试基础设施到 CI/CD 集成,完整解析 SDK 的质量保障体系。

#1. 测试金字塔

Code
           ╱╲
          ╱  ╲         E2E 测试 (5%)
         ╱────╲        真实平台, 完整流程
        ╱      ╲
       ╱────────╲      性能测试 (5%)
      ╱          ╲     基准回归, SLA 验证
     ╱────────────╲
    ╱              ╲   契约测试 (15%)
   ╱────────────────╲  API 兼容性, Protobuf 契约
  ╱                  ╲
 ╱────────────────────╲ 集成测试 (25%)
╱                      ╲ gRPC 通信, 序列化, 拦截器
╱────────────────────────╲
╱                          ╲ 单元测试 (50%)
╱────────────────────────────╲ 模型, 过滤器, 查询构建器

#2. 单元测试

#2.1 模型测试

Python
class TestOntologyModels:
    """Ontology 模型单元测试"""

    def test_object_creation(self):
        employee = Employee(
            id="emp-001", name="张三",
            department="Engineering", salary=50000,
            hire_date=date(2024, 1, 15),
        )
        assert employee.name == "张三"
        assert employee.salary == 50000

    def test_object_validation(self):
        with pytest.raises(ValidationError) as exc_info:
            Employee(
                id="emp-001", name="张三",
                department="Engineering",
                salary="not_a_number",  # 类型错误
                hire_date=date(2024, 1, 15),
            )
        assert "salary" in str(exc_info.value)

    def test_object_serialization(self):
        employee = Employee(
            id="emp-001", name="张三",
            department="Engineering", salary=50000,
            hire_date=date(2024, 1, 15),
        )
        data = employee.to_dict()
        restored = Employee.from_dict(data)
        assert restored == employee

    def test_object_equality(self):
        e1 = Employee(id="emp-001", name="张三", department="Eng", salary=50000, hire_date=date(2024, 1, 1))
        e2 = Employee(id="emp-001", name="张三", department="Eng", salary=50000, hire_date=date(2024, 1, 1))
        assert e1 == e2

#2.2 查询构建器测试

Python
class TestQueryBuilder:
    def test_simple_filter(self):
        query = (
            QueryBuilder("Employee")
            .where(Filter("department", "eq", "Engineering"))
            .build()
        )
        assert query.filters[0].field == "department"
        assert query.filters[0].operator == "eq"
        assert query.filters[0].value == "Engineering"

    def test_compound_filter(self):
        query = (
            QueryBuilder("Employee")
            .where(Filter("department", "eq", "Engineering"))
            .where(Filter("salary", "gt", 50000))
            .build()
        )
        assert len(query.filters) == 2

    def test_select_fields(self):
        query = (
            QueryBuilder("Employee")
            .select("name", "salary")
            .build()
        )
        assert query.selected_fields == ["name", "salary"]

    def test_ordering(self):
        query = (
            QueryBuilder("Employee")
            .order_by("salary", "desc")
            .build()
        )
        assert query.order_by[0].field == "salary"
        assert query.order_by[0].direction == "desc"

    def test_pagination(self):
        query = (
            QueryBuilder("Employee")
            .limit(50)
            .offset(100)
            .build()
        )
        assert query.page_size == 50
        assert query.offset == 100

#2.3 错误映射测试

Python
class TestErrorMapping:
    def test_not_found_mapping(self):
        grpc_error = make_grpc_error(grpc.StatusCode.NOT_FOUND, "Object not found")
        sdk_error = GrpcErrorMapper().map(grpc_error)
        assert isinstance(sdk_error, ObjectNotFoundError)

    def test_permission_denied_mapping(self):
        grpc_error = make_grpc_error(grpc.StatusCode.PERMISSION_DENIED, "Access denied")
        sdk_error = GrpcErrorMapper().map(grpc_error)
        assert isinstance(sdk_error, AuthorizationError)

    def test_unknown_error_mapping(self):
        grpc_error = make_grpc_error(grpc.StatusCode.INTERNAL, "Server error")
        sdk_error = GrpcErrorMapper().map(grpc_error)
        assert isinstance(sdk_error, OntoSDKError)

#3. 集成测试

#3.1 Mock gRPC 服务器

Python
class MockOntologyServer:
    """Mock gRPC 服务器用于集成测试"""

    def __init__(self):
        self._objects: dict[str, dict[str, dict]] = {}
        self._server: grpc.aio.Server | None = None

    async def start(self, port: int = 0) -> int:
        self._server = grpc.aio.server()
        add_OntologyServiceServicer_to_server(
            MockOntologyServicer(self._objects), self._server,
        )
        actual_port = self._server.add_insecure_port(f"[::]:{port}")
        await self._server.start()
        return actual_port

    async def stop(self):
        await self._server.stop(grace=5)

    def seed_data(self, object_type: str, objects: list[dict]):
        self._objects[object_type] = {obj["id"]: obj for obj in objects}


class MockOntologyServicer(OntologyServiceServicer):
    def __init__(self, objects: dict):
        self._objects = objects

    async def GetObject(self, request, context):
        obj_type = request.object_type
        obj_id = request.object_id

        if obj_type not in self._objects or obj_id not in self._objects[obj_type]:
            context.set_code(grpc.StatusCode.NOT_FOUND)
            context.set_details(f"{obj_type} with id '{obj_id}' not found")
            return GetObjectResponse()

        return GetObjectResponse(
            object=self._to_proto(self._objects[obj_type][obj_id]),
        )

#3.2 集成测试用例

Python
class TestGrpcIntegration:
    @pytest.fixture
    async def mock_server(self):
        server = MockOntologyServer()
        server.seed_data("Employee", [
            {"id": "emp-001", "name": "张三", "department": "Engineering", "salary": 50000},
            {"id": "emp-002", "name": "李四", "department": "Marketing", "salary": 45000},
        ])
        port = await server.start()
        yield f"localhost:{port}"
        await server.stop()

    @pytest.mark.asyncio
    async def test_get_object(self, mock_server):
        async with AsyncOntoPlatform.connect(mock_server) as client:
            employee = await client.objects.get("Employee", "emp-001")
            assert employee.name == "张三"

    @pytest.mark.asyncio
    async def test_not_found(self, mock_server):
        async with AsyncOntoPlatform.connect(mock_server) as client:
            with pytest.raises(ObjectNotFoundError):
                await client.objects.get("Employee", "nonexistent")

    @pytest.mark.asyncio
    async def test_retry_on_unavailable(self, mock_server_with_failures):
        async with AsyncOntoPlatform.connect(
            mock_server_with_failures, max_retries=3,
        ) as client:
            employee = await client.objects.get("Employee", "emp-001")
            assert employee is not None

#4. 契约测试

#4.1 Protobuf 契约验证

Python
class TestProtobufContract:
    """Protobuf 契约测试 - 确保 SDK 与服务端兼容"""

    def test_request_message_fields(self):
        """验证请求消息包含所有必需字段"""
        request = GetObjectRequest(
            object_type="Employee",
            object_id="emp-001",
        )
        assert request.HasField("object_type") or request.object_type
        assert request.HasField("object_id") or request.object_id

    def test_response_message_backward_compatible(self):
        """验证响应消息向后兼容"""
        # 模拟旧版本响应(缺少新字段)
        old_response_data = {
            "object": {"id": "emp-001", "properties": {"name": "张三"}},
        }
        response = GetObjectResponse()
        Parse(json.dumps(old_response_data), response)
        assert response.object.id == "emp-001"

    def test_enum_values_stable(self):
        """验证枚举值稳定不变"""
        assert FilterOperator.Value("EQUALS") == 1
        assert FilterOperator.Value("GREATER_THAN") == 2
        assert FilterOperator.Value("LESS_THAN") == 3

#4.2 API 兼容性测试

Python
class TestAPICompatibility:
    def test_sdk_supports_server_v1(self):
        """验证 SDK 兼容服务端 v1 API"""
        negotiator = VersionNegotiator(sdk_version="1.2.0")
        result = negotiator.negotiate(server_version="1.0.0")
        assert result.compatible

    def test_sdk_warns_on_deprecated_features(self):
        """验证 SDK 对废弃功能发出警告"""
        negotiator = VersionNegotiator(sdk_version="2.0.0")
        result = negotiator.negotiate(server_version="1.5.0")
        assert result.compatible
        assert len(result.warnings) > 0

#5. 端到端测试

Python
class TestEndToEnd:
    """端到端测试 - 对真实平台执行"""

    @pytest.fixture
    def platform_client(self):
        url = os.environ.get("ONTO_PLATFORM_URL", "http://localhost:8080")
        token = os.environ.get("ONTO_PLATFORM_TOKEN")
        if not token:
            pytest.skip("No platform token configured")
        return SyncOntoPlatform.connect(url, token=token)

    def test_full_crud_lifecycle(self, platform_client):
        client = platform_client

        # Create
        employee = client.objects.create("Employee", {
            "name": "E2E Test User",
            "department": "Testing",
            "salary": 50000,
            "hire_date": "2024-01-01",
        })
        assert employee.id is not None

        # Read
        fetched = client.objects.get("Employee", employee.id)
        assert fetched.name == "E2E Test User"

        # Update
        updated = client.objects.update("Employee", employee.id, {"salary": 55000})
        assert updated.salary == 55000

        # Delete
        client.objects.delete("Employee", employee.id)
        with pytest.raises(ObjectNotFoundError):
            client.objects.get("Employee", employee.id)

    def test_query_with_filters(self, platform_client):
        results = platform_client.objects.list(
            "Employee",
            filters=[Filter("department", "eq", "Engineering")],
            page_size=10,
        )
        assert all(e.department == "Engineering" for e in results.items)

#6. 性能测试

Python
class TestPerformance:
    @pytest.mark.benchmark
    def test_serialization_performance(self, benchmark):
        employee = Employee(
            id="emp-001", name="张三",
            department="Engineering", salary=50000,
            hire_date=date(2024, 1, 15),
        )
        result = benchmark(employee.to_protobuf)
        assert result is not None

    @pytest.mark.benchmark
    def test_deserialization_performance(self, benchmark):
        proto = make_employee_proto()
        result = benchmark(Employee.from_protobuf, proto)
        assert result.name == "张三"

    @pytest.mark.benchmark
    @pytest.mark.asyncio
    async def test_concurrent_throughput(self, benchmark, mock_server):
        async with AsyncOntoPlatform.connect(mock_server) as client:
            async def workload():
                tasks = [
                    client.objects.get("Employee", f"emp-{i:03d}")
                    for i in range(100)
                ]
                await asyncio.gather(*tasks)

            benchmark(lambda: asyncio.get_event_loop().run_until_complete(workload()))

    @pytest.mark.asyncio
    async def test_latency_sla(self, mock_server):
        async with AsyncOntoPlatform.connect(mock_server) as client:
            latencies = []
            for _ in range(100):
                start = time.monotonic()
                await client.objects.get("Employee", "emp-001")
                latencies.append(time.monotonic() - start)

            p99 = sorted(latencies)[98]
            assert p99 < 0.1  # P99 < 100ms

#7. 测试基础设施

#7.1 固件生成器

Python
class SchemaFixtureGenerator:
    """Schema 固件生成器"""

    @staticmethod
    def generate_employee_schema() -> ObjectTypeSchema:
        return ObjectTypeSchema(
            api_name="Employee",
            properties=[
                PropertySchema(name="name", data_type="string", required=True),
                PropertySchema(name="department", data_type="string", required=True),
                PropertySchema(name="salary", data_type="integer", required=True),
                PropertySchema(name="hire_date", data_type="date", required=True),
                PropertySchema(name="email", data_type="string", required=False),
            ],
        )

    @staticmethod
    def generate_employee_data(count: int = 10) -> list[dict]:
        return [
            {
                "id": f"emp-{i:03d}",
                "name": f"Employee {i}",
                "department": random.choice(["Engineering", "Marketing", "Sales"]),
                "salary": random.randint(40000, 120000),
                "hire_date": (date(2020, 1, 1) + timedelta(days=random.randint(0, 1500))).isoformat(),
            }
            for i in range(count)
        ]

#7.2 CI 集成

YAML
# .gitlab-ci.yml
sdk-test:
  stage: test
  script:
    - pip install -e ".[dev]"
    - pytest tests/unit/ -v --cov=ontology_sdk --cov-report=xml
    - pytest tests/integration/ -v --cov-append
    - pytest tests/contract/ -v --cov-append
  coverage: '/TOTAL.*\s+(\d+%)/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

sdk-e2e:
  stage: e2e
  when: manual
  script:
    - pytest tests/e2e/ -v
  variables:
    ONTO_PLATFORM_URL: $STAGING_URL
    ONTO_PLATFORM_TOKEN: $STAGING_TOKEN

sdk-performance:
  stage: performance
  script:
    - pytest tests/performance/ --benchmark-json=benchmark.json
    - python scripts/check_performance_regression.py benchmark.json
  artifacts:
    paths:
      - benchmark.json

#8. 总结

coomia-dip SDK 的测试策略通过五层测试覆盖,确保 SDK 的质量和可靠性:

  1. 单元测试 (50%):模型验证、查询构建器、错误映射
  2. 集成测试 (25%):Mock gRPC 服务器验证通信正确性
  3. 契约测试 (15%):Protobuf 契约和 API 兼容性
  4. 性能测试 (5%):序列化/反序列化基准、吞吐量和延迟 SLA
  5. 端到端测试 (5%):对真实平台的完整 CRUD 生命周期

下一篇将探讨 coomia-dip 的 Docker Compose 部署方案。