1fac387e75
tests/ 不进 wheel,所以那套被 CLAUDE.md §0 称作「任何新适配器的准入标准」的用例,第一个 下游根本拿不到。**接法同时换掉**:pytest 的 conftest 只沿被收集文件的目录链查找,装在 site-packages 里的测试模块看不见下游的 conftest,原来那个「在自己的 conftest 里覆盖同名 fixture」的接法在发布之后走不通。改成继承契约基类,下游的子类定义在自己的目录链上。 **搬的过程中发现这套准入标准从来没被执行过。** test_model_client.py 有四条用例调用 records.model_call(...),而工厂里根本没有这个方法——它没炸是因为那个 fixture 默认 skip。 五个接缝里只有存储那套被真跑过(15 条跳过里有 15 条是这四套)。 所以这个提交的另一半是让它真的跑起来。存储接两个实现(一份契约同时验多个实现,正是换接法 换来的);动作执行接注册表分发器,外加一个有真实等待点的替身,否则那条取消用例的断言半边 永远走不到;模型调用接网关适配器,落在 integration,它连的是真网关;决策解释与事件出口各 接一个测试替身——替身住在 tests/ 里不进 wheel,下游拿不到,所以不违反「库不带默认实现」, 判据是下游拿不拿得到。 **一并清掉两类坏用例。** 五条函数体只有 docstring、一个断言都没有却报 PASSED 的假绿——一个 准入标准里出现假绿比出现跳过糟得多,下游看到全绿会以为验过了。以及一条端口从没承诺过的 长度断言(len(history_text) <= len(reply.content)):压测的 AppWorld 场景为了迁就它,刻意 不补被复刻的实现真的会补的三个反引号,注释里写着「补一个字符就违约」。七条「这一层验不了」 统一成无条件 skip,理由字符串写全「承诺是什么/为什么验不了/你该在哪儿自己验」。 **发一个 pytest11 entry point,只为换回断言重写。** 契约模块不在下游的 python_files 里, 默认不被重写,于是一条契约失败时下游看到的是光秃秃的 AssertionError。不做的话没有任何东西 会报错,纯静默退化。实测过:editable 安装下 entry point 注册了但重写不生效(RECORD 里没有 包文件),要装真 wheel 才验得出来。
82 lines
4.6 KiB
Python
82 lines
4.6 KiB
Python
"""五个接缝的契约套件,随包发布,给下游当准入标准用。
|
||
|
||
每个接缝一个基类。这套用例不针对任何具体实现,写的是「不管你怎么实现,都必须满足这些
|
||
行为」——所以它自己不造实现,只声明「你得提供什么」。写完自己的存储或适配器之后,在自己的
|
||
测试文件里继承对应的基类、覆盖那几个必需 fixture,跑一遍全绿就算合格。
|
||
|
||
# tests/test_my_store.py
|
||
import pytest
|
||
|
||
from polyloop.testing import RunStoreContract
|
||
|
||
from myproject.storage import MyPostgresStore
|
||
|
||
|
||
class TestMyPostgresStore(RunStoreContract):
|
||
@pytest.fixture
|
||
def store(self, pg_pool):
|
||
return MyPostgresStore(pg_pool)
|
||
|
||
子类的类名要以 `Test` 开头,pytest 才收集它。基类自己叫 `...Contract` 正是为了不被收集:
|
||
pytest 按 `Test` 前缀匹配测试类,一个叫 `RunStoreContract` 的基类不会被当成测试类跑一遍,
|
||
于是它那些 `raise NotImplementedError` 的默认 fixture 也就不会失败。
|
||
|
||
**装它**:`pip install "polyloop[testing]"`。
|
||
|
||
**必需的 fixture 在基类里都有一个默认实现,函数体是 `raise NotImplementedError`**,报错消息
|
||
写着该覆盖什么、该返回什么。忘了覆盖时看到的是这条消息,而不是 pytest 那句
|
||
`fixture 'store' not found` 加一整屏 available fixtures 列表——后者指向的是 site-packages 里
|
||
的库文件,第一反应会是库坏了。
|
||
|
||
**样本输入由实现方提供**,见 `DecisionParserContract.reply_samples`、
|
||
`ActionExecutorContract.action_samples` 与 `ModelClientContract.failing_call` 的 docstring。
|
||
套件不认识任何一家的动作语言,也不知道一家实现要怎样才会失败:拿一家的代码围栏去喂另一家的
|
||
JSON 解析器,后者正确地返回「无效决策」,而写死输入的套件会把这个正确行为判成失败。
|
||
|
||
**实现之间的能力差异走运行期跳过,不走失败。** 取消那两条就是这么处理的——一个在单个事件
|
||
循环 tick 之内就返回的实现根本没有机会吞掉取消,那条契约对它无从谈起,跳过的理由字符串会
|
||
说清楚这一点。看到这种跳过不必去改自己的实现。
|
||
|
||
**async 用例的事件循环归下游管。** 套件里的用例照常写成 `async def`,套件不做任何事件循环
|
||
安排。所以要么把 `asyncio_mode` 设成 `"auto"`(`pyproject.toml` 的
|
||
`[tool.pytest.ini_options]` 里),要么自己给子类打上对应的标记。没配对的失败是响亮的:
|
||
pytest 会明说 async def 函数不被原生支持,那条信息直接指向解法。
|
||
|
||
库不替下游安排循环,是因为下游的 fixture 很可能是异步的——一个数据库存储实现的连接池就是。
|
||
那个 fixture 在下游的循环里创建,库自己开的循环是另一个,跨循环使用 asyncio 对象会炸,而且
|
||
炸得很难查。
|
||
|
||
**`PYTEST_DISABLE_PLUGIN_AUTOLOAD=1` 的环境要多写一行。** 本包声明了一个 pytest 插件入口,
|
||
它唯一的作用是让 pytest 重写这些模块里的 `assert`,失败时打印出等号两边的实际值。那个环境
|
||
变量一设上,入口就不加载了,契约失败会退化成光秃秃的 `AssertionError`。这时在自己的
|
||
`conftest.py` 顶上补一行:
|
||
|
||
pytest.register_assert_rewrite("polyloop.testing")
|
||
|
||
**套件里一个自定义 marker 都不用。** 开了 `--strict-markers` 而没注册那个 marker 的话,炸掉
|
||
的是整个测试文件的收集,不是一条失败,而报错指向的同样是库的文件。要给这些用例分组就在自己
|
||
的子类文件上打标记,那个文件和那个 marker 都在你自己的仓库里。
|
||
|
||
**这里的每个名字都是公共承诺**:基类名、基类上的 fixture 名、每一条用例的方法名。下游的子类
|
||
按它们写,改名的代价和改公共类型的字段一样。用例方法名尤其要紧——下游要豁免某一条时按名字
|
||
重绑它,改名之后那个豁免会悄悄失效,跑出来照样全绿。
|
||
|
||
设计与取舍见 `research-wiki/design/0014-contract-suite-distribution.md`。
|
||
"""
|
||
|
||
from polyloop.testing._action_executor import ActionExecutorContract
|
||
from polyloop.testing._decision_parser import DecisionParserContract
|
||
from polyloop.testing._event_sink import EventSinkContract
|
||
from polyloop.testing._model_client import ModelClientContract
|
||
from polyloop.testing._records import RecordFactory
|
||
from polyloop.testing._run_store import RunStoreContract
|
||
|
||
__all__ = [
|
||
"ActionExecutorContract",
|
||
"DecisionParserContract",
|
||
"EventSinkContract",
|
||
"ModelClientContract",
|
||
"RecordFactory",
|
||
"RunStoreContract",
|
||
]
|