【Bug已解决】Stale `xfail` markers in `langchain-core` tests: `test_convert_to_openai_function_nested_v2…
【Bug已解决】Stalexfailmarkers inlangchain-coretests:test_convert_to_openai_function_nested_v2andtest_sync_in_sync_lambdaspass but are marked expected-to-fail 解决方案
一、现象长什么样
在langchain-core的测试套件里,有两个测试用例被打了@pytest.mark.xfail(预期失败),但实际上它们早就修好了、现在会通过:
test_convert_to_openai_function_nested_v2test_sync_in_sync_lambdas
它们的表现如下:
- 在 CI 里跑
pytest,这两个用例不是绿色的 PASS,也不是红色的 FAIL,而是被标记为XPASS(unexpectedly passing,意外通过); - 由于 pytest 默认把
xfail_strict=False,XPASS 会被当成"通过"处理,于是问题被悄悄掩盖——测试报告是全绿的,没人发现这俩标记已经过时; - 一旦哪天有人把
xfail_strict=True打开(这是很多项目为了"xfail 不能偷偷变绿"而推荐的做法),这两个用例会立刻变红,CI 直接挂掉,而且报错信息对排查者极不友好:"expected to fail but passed"; - 更糟的是,因为标了
xfail,新来的贡献者会误以为"这功能本来就是坏的",从而绕开、甚至停止修复相关逻辑,导致技术债被长期保留。
一句话:xfail标记标错了对象,把已经通过的测试当成了已知失败,形成"僵尸标记"。
二、背景
xfail(expected failure)是 pytest 提供的一个机制,用来标记"我知道它会失败,先别让它红"。它在两种场景下是合理的:
- 上游依赖有 bug,暂时绕不过,先
xfail等上游修; - 一个尚不支持的特性,先
xfail锁住,避免回归时无声无息。
但xfail是一把双刃剑:它本质上是在说"这个测试现在不该绿"。一旦底层代码被修复、功能被实现,标记却没同步摘掉,测试就会从 XFAIL(符合预期)变成 XPASS(不符合预期)。XPASS 在严格模式下直接失败,在非严格模式下则只是静默通过——两种都违背了xfail的初衷。
test_convert_to_openai_function_nested_v2是验证嵌套结构(nested v2)转 OpenAI function 的工具;test_sync_in_sync_lambdas是验证在同步 lambda 里调用同步方法不会死锁/报错。这两个能力在langchain-core的某个版本之后就已经正常工作了,但当时为了赶发布临时打的xfail没被清理,于是长期残留。
三、根因
根因是测试维护纪律缺失,而不是业务逻辑问题:
- 当初这两个测试因为某种临时原因(可能是依赖版本、可能是实现未完成)被标
xfail; - 后续 PR 修复了底层实现,让测试能过了,却只改了源码、没回头清理测试标记;
- CI 的
xfail_strict没打开,XPASS 被当成 PASS 吞掉,没有警报,于是标记一直"活着"; - 没有定期跑
pytest --strict-markers或xfail_strict=True的守护,导致僵尸标记无法被发现。
# 残留的错误标记(示意) @pytest.mark.xfail( reason="nested v2 conversion not supported yet", strict=False, ) def test_convert_to_openai_function_nested_v2(): ...# 实际上功能早已支持,标记应被删除 def test_convert_to_openai_function_nested_v2(): ...四、最小可运行复现
下面用一个最小例子演示"僵尸 xfail"如何从静默 XPASS 变成严格模式下的红色失败:
import pytest def _add(a: int, b: int) -> int: # 假设这个功能后来已经被正确实现 return a + b @pytest.mark.xfail(reason="add 还没实现", strict=False) def test_add_passes_but_marked_xfail(): # 功能其实已经好了,但标记没摘 -> XPASS(被当 PASS 吞掉) assert _add(1, 2) == 3 @pytest.mark.xfail(reason="add 还没实现", strict=True) def test_add_strict_xfail(): # 打开 strict 后,XPASS 直接变红 assert _add(1, 2) == 3运行:
$ pytest -q # 默认 strict=False:test_add_passes_but_marked_xfail 显示 XPASS,整体 PASSED # 若 pytest.ini 设 xfail_strict=true:test_add_strict_xfail 直接 FAILED (XPASS)这正是langchain-core里那两个用例的真实处境:平时静默 XPASS,一旦严格化就爆红。
五、解决方案(第一层:最小直接修复)
最小修复就是删掉过时的xfail标记,让测试回归正常的 PASS/FAIL 语义:
# libs/core/tests/unit_tests/test_to_openai.py def test_convert_to_openai_function_nested_v2(): result = _convert_to_openai_function_nested(sample_nested_v2) assert result["parameters"]["type"] == "object" assert "nested" in result["parameters"]["properties"] # libs/core/tests/unit_tests/test_runnables.py def test_sync_in_sync_lambdas(): chain = RunnableLambda(lambda x: x + 1) assert chain.invoke(1) == 2如果某个测试确实还想保留"软失败"的弹性(比如依赖外部服务偶尔抖动),应改用@pytest.mark.flaky或显式的try/except + pytest.skip,而不是用xfail表达"应该会过"。
六、解决方案(第二层:结构化改进)
为防止以后再出现僵尸xfail,应当把"标记清理"纳入流程约束,并提供一个可复用的审查工具。核心思路:在 CI 里强制xfail_strict=True,并提供一个扫描脚本,找出所有 XPASS 的用例清单。
from dataclasses import dataclass from pathlib import Path import re from typing import List @dataclass(frozen=True) class LangChainXfailMarkerPolicy: """xfail 标记审查策略:扫描测试文件,列出所有 xfail 装饰器。 配合 xfail_strict=True 使用,可让"僵尸 xfail"在 CI 直接变红, 从而逼迫贡献者及时清理。 """ root: Path def list_xfail_markers(self) -> List[str]: pattern = re.compile(r"@pytest\.mark\.xfail") hits: List[str] = [] for path in self.root.rglob("test_*.py"): for lineno, line in enumerate(path.read_text().splitlines(), 1): if pattern.search(line): hits.append(f"{path}:{lineno}: {line.strip()}") return hits def audit(self) -> str: markers = self.list_xfail_markers() if not markers: return "OK: 未发现任何 xfail 标记。" return "需要人工复核以下 xfail 标记是否仍合理:\n" + "\n".join(markers) def main() -> None: policy = LangChainXfailMarkerPolicy(root=Path("libs/core/tests")) print(policy.audit()) if __name__ == "__main__": main()同时,在pyproject.toml/pytest.ini里加上:
[pytest] xfail_strict = true这样,任何"意外通过"的xfail都会立即让 CI 失败,迫使开发者要么删除标记、要么真的让它失败,杜绝静默残留。
七、解决方案(第三层:断言 / CI 守护)
用一条 CI 规则把"禁止僵尸 xfail"锁死:
# .github/workflows/tests.yml (节选) - name: Run tests with strict xfail run: pytest -q --xfail-strict并提供回归测试,验证清理后的用例能正常 PASS:
import pytest from langchain_core.utils.function_calling import convert_to_openai_function def test_convert_to_openai_function_nested_v2(): # 确认嵌套 v2 结构能正确转换,且不再被 xfail 屏蔽 spec = { "name": "f", "parameters": { "type": "object", "properties": { "outer": { "type": "object", "properties": {"inner": {"type": "string"}}, } }, }, } converted = convert_to_openai_function(spec) assert converted["parameters"]["properties"]["outer"]["properties"]["inner"]["type"] == "string" def test_sync_in_sync_lambdas(): from langchain_core.runnables import RunnableLambda chain = RunnableLambda(lambda x: x * 2) assert chain.invoke(21) == 42跑pytest --xfail-strict时,这两个用例应当干净地显示PASSED,不再有XPASS出现。
八、排查清单
- 在
langchain-core测试目录里grep -rn "xfail"找出所有标记。 - 跑
pytest --xfail-strict,看是否有用例报XPASS(strict 下为 FAILED)。 - 对每个 XPASS 用例,判断功能是否已实现:已实现则删标记,未实现则保留并补全 reason。
- 在
pytest.ini里打开xfail_strict = true,让僵尸标记今后无法静默存活。 - 用上面的
LangChainXfailMarkerPolicy脚本定期扫描,作为 PR 检查的一部分。 - 新加
xfail时,必须在 reason 里写清"为什么失败、预计何时摘掉"。
九、小结
这两个xfail标记早已过时——底层功能修复后,测试会意外通过(XPASS),却因为xfail_strict=False被静默吞掉,形成僵尸标记,既掩盖了 CI 真实状态,也让贡献者误判功能状态。最小修复是删掉过时标记;更稳妥的做法是在pytest.ini打开xfail_strict=True,并配合LangChainXfailMarkerPolicy这样的扫描脚本,把"xfail 不能偷偷变绿"写进 CI 守护,从根本上杜绝同类问题复发。
