想象你走进一家来自世界各地的厨师共享的爱心食堂。任何人想往菜单里加一道新菜,都不能直接端上桌,而是要先走进后厨,经历一场严格的“试菜考核”:单尝每一撮盐、搭着主食试小份菜、最后在正式晚宴上完整走一遍流程。只有当试菜官点头,这道菜才能印上新菜单。LangChain 就是这样一家开源食堂。每一位贡献者都是大厨,而今天我们要聊的“测试编写”,就是那把决定新菜能否进菜单、老菜会不会变味的试菜尺。从单元测试到 Mock LLM,从 CI 回归门禁到生产级测试经验,你会发现:写测试不是写代码的负担,而是给未来上的一份保险。

岗位关联

学会为 LangChain 组件编写测试,是 PR 被合并的敲门砖,更是你从“能跑通 demo”走向“能交付生产级代码”的关键一步。

目录
  1. 可视化演示
  2. 测试分类体系
  3. 单元测试规范
  4. 集成测试设计
  5. Mock LLM 策略
  6. 回归与 CI 门禁
  7. 生产测试经验
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

本节配有交互动效(进入视口后自动加载,约 3KB)。

测试分类体系

社区爱心食堂的大厨们立了条规矩:任何新菜要进菜单,先得试菜。试菜不是等上桌后才让客人评判,而是分三层把关——先在厨房里单独尝每一撮盐、每一勺酱;再把小份菜端给隔壁桌的同事搭着主食试;最后在周末晚宴从备菜到上桌完整走一遍。三层各有各的任务,少了哪层都容易出事。

测试编写也遵循同样的三层结构,业内称之为“测试金字塔”。金字塔底部是大量单元测试,只针对单个函数或类的内部逻辑;中间是适量集成测试,把几个组件拼起来检查配合;顶部是少量端到端测试,模拟真实用户完整走一遍流程。LangChain 的每个新组件(tool、retriever、chain)提交时,维护者都会要求附带对应层次的测试用例。

为了量化“你尝了几口菜”,工程上常用覆盖率公式:跑完测试后,多少比例的可执行代码行被执行过。贡献 PR 时,覆盖率是维护者快速判断测试充分度的直观指标,但它只反映动作的数量,不反映质量。

三个层级像试菜的三道关卡:单颗盐要尝、整桌菜要试、正式宴席要走一遍。覆盖率只告诉你“尝了多少口”,而味道对不对,要靠后面每一层的断言设计来回答。

覆盖率=NcoveredNtotal×100%\text{覆盖率} = \frac{N_{\text{covered}}}{N_{\text{total}}} \times 100\%
覆盖率回答的是“测试执行了多少代码行”,而不是“这些断言是否真正保护了行为”。
  1. 统计分母统计仓库中可执行代码的总行数。
    NtotalN_{\text{total}}
  2. 收集分子运行测试后统计实际执行过的代码行数。
    NcoveredN_{\text{covered}}
  3. 计算比值两者相除并乘以 100%,得到覆盖率百分比。
    覆盖率=NcoveredNtotal×100%\text{覆盖率} = \frac{N_{\text{covered}}}{N_{\text{total}}} \times 100\%

单元测试规范

一道菜里最容易被忽略的调味品是盐。大厨会先单独沾一点盐确认咸淡,而不是等整锅菜下锅才想起来。单元测试就是这种“单独尝盐”的工序:在完全不接触数据库、不调用网络的前提下,把某个函数或类拿出来单独考验。

在 LangChain 中,单元测试的目标是链里的一个独立环节,例如一个 prompt 模板的格式化函数、一个文本分割器、一个工具函数的返回值。测试里不启动真实模型,也不连向量库,只验证“输入经过这个函数后,输出是否符合约定”。

写单元测试有四个习惯:断言表达意图而非照抄实现;用例彼此独立,不在测试间共享状态;命名可读,让人从名字读懂行为;每个用例能够单独运行。核心断言可以看成一次“实际值 vs 期望值”的比较,结果只有通过或失败两种状态。

把每一粒盐单独尝过,整道菜的咸淡才不会失控。同理,把每个小函数验证过,链式逻辑出问题时才能快速定位是哪个环节变味。

assert_eq(a,b)={PASS,a=bFAIL,a≠b\text{assert\_eq}(a, b) =\begin{cases}\text{PASS}, & a = b \\\text{FAIL}, & a \ne b\end{cases}
断言不是简单比较两个数,而是把“这段代码应该做什么”写进测试里的承诺书。
  1. 取出实际值运行被测函数后得到真实输出。
    aa
  2. 写明期望值根据需求列出预期结果。
    bb
  3. 比较并判定两者相等则 PASS,否则 FAIL,测试停止并报错。
    PASS / FAIL=(a=b)\text{PASS / FAIL} = (a = b)

集成测试设计

单尝盐过关了,接下来要把盐、油、菜、火候放在一起炒。集成测试就像为整桌菜做一次彩排:确认两台灶台和一个烤箱能在同一时间各自工作,上菜顺序不会乱,摆盘和主食能对上。

LangChain 的链式逻辑天然是集成场景:一个 Chain 调用 Retriever 查库,再把结果拼进 Prompt,最后交给 LLM。集成测试把这些真实组件拼装起来验证,必要时连接本地数据库或测试容器中的外部服务。因为涉及多个模块,测试失败时不再能断言“某个函数错了”,而要定位组件间的配合问题。

组件越多,连接点越多。n 个组件两两组合产生的连接数量由组合数公式给出。例如链、数据库、外部接口三个组件,就有三个两两交汇的连接点,每一处都值得被测试覆盖。集成测试中常见技巧:用 pytest 的标记把集成用例从默认运行中排除、用临时数据库做前后清理、对异步任务显式等待并注入错误。

整桌菜彩排后,任何一台灶失灵都会被提早发现。集成测试做的是让组件们先见个面,确认它们之间没有锅碗瓢盆互相绊脚,数据能顺畅地从一口锅递到另一口锅。

Nlinks=(n2)=n(n−1)2N_{\text{links}} = \binom{n}{2} = \frac{n(n-1)}{2}
每增加一个组件,连接点不是线性增加,而是组合级增长——这也是集成测试必须“适量设计”的原因。
  1. 确定组件数列出参与协作的组件总数。
    nn
  2. 两两配对从 n 个组件中任选两个进行组合,连接点数量由组合数给出。
    (n2)=n(n−1)2\binom{n}{2} = \frac{n(n-1)}{2}
  3. 设计覆盖对每个关键连接点安排用例,确保数据格式、调用顺序、异常传递都正确。
    NlinksN_{\text{links}}

Mock LLM 策略

真正的大厨做新菜,不会每次都拜托猎人空运一头鹿来试做;先用冰箱里的冷冻鹿肉练出火候手感,等正式晚宴再换真材实料。测试里的 LLM 调用就是那头鹿:真实调用代价高、速度慢、结果还随机,试菜官根本没法稳定判断“这次好吃是因为火候对了,还是因为运气好”。

Mock LLM 是测试编写中最关键的一课。做法是准备一个假模型对象(如 LangChain 的 FakeListLLM 或自定义 BaseLLM 子类),它在测试中不联网,只根据输入返回预先写好的“台词”。这样测试变成完全确定、离线、免费的。

Mock 的响应策略可以看成一个有规则的映射:当 prompt 命中预设关键词集合时返回固定响应,否则走兜底分支。除了返回值,还要断言调用次数和传入的 prompt 内容;比如链里误写了两次 LLM 调用,次数断言会让它立刻现形。但要注意避免过度 Mock:如果把所有外部依赖都换成替身,测试就变成“自己的话和回声对话”,失去对真实世界的免疫力。

冷冻鹿肉能帮你练出火候手感,但永远代替不了真实野味的质感。Mock 保障了逻辑的确定性,还得保留少量真实集成的窗口,才能知道假食材和真食材的区别。

response={rfixed,if prompt∈Kmockrfallback,otherwise\text{response} =\begin{cases}r_{\text{fixed}}, & \text{if prompt} \in K_{\text{mock}} \\r_{\text{fallback}}, & \text{otherwise}\end{cases}
Mock 的关键是“规则可控”:命中预设就返回固定响应,没命中就兜底,这样测试结果永远可复现。
  1. 定义命中集合把需要模拟的 prompt 关键词放入预设集合。
    KmockK_{\text{mock}}
  2. 返回固定响应输入命中集合时,返回预先写好的稳定文本。
    rfixedr_{\text{fixed}}
  3. 处理未命中输入不在集合中时,抛出异常或返回默认兜底文本。
    rfallbackr_{\text{fallback}}

回归与 CI 门禁

菜单改版时,最怕的不是新菜做砸,而是招牌牛排不小心变味。大厨会在每天营业前把经典菜全部重新试吃一遍——这就是回归测试。

LangChain 仓库时刻在变化:每天有大量 PR 提出新组件、改接口、修 bug。任何一个改动都可能让已有 chain 的输出悄悄变化。回归测试就是维护者的“巡店试菜”,把历史用例全部重跑一遍,确保旧功能没有被新改动破坏。

在 CI 自动流水线中,回归测试通常分阶段执行:先跑快速单元测试,再跑集成测试,全部通过才允许合并 PR。合并与否可以看作一个逻辑与条件:所有关卡同时为 PASS 时放行,任一 FAIL 即阻断。为防止全量测试时间过长,会把慢测试加标记移到夜间任务;同时将失败用例作为硬性门禁,杜绝“先合并后修复”的心态。

每天营业前的试菜制度,让十年前的招牌菜到今天还是那个味。CI 回归门禁就是 LangChain 的每日试菜,让开源食堂的菜单越来越丰富,却不会弄丢老顾客的记忆。

MergeAllowed=⋀i=1n(Testi=PASS)\text{MergeAllowed} = \bigwedge_{i=1}^{n} (\text{Test}_i = \text{PASS})
合并门禁的本质是一票否决制:n 个测试关卡中只要有一个失败,PR 就不能合入主干。
  1. 列出关卡把单元、集成、静态检查等测试按顺序编号。
    Test1,Test2,…,Testn\text{Test}_1, \text{Test}_2, \dots, \text{Test}_n
  2. 逐项判定每个关卡的结果必须等于 PASS。
    Testi=PASS\text{Test}_i = \text{PASS}
  3. 逻辑与合并全部关卡同时通过,合并才被允许。
    ⋀i=1n(Testi=PASS)\bigwedge_{i=1}^{n} (\text{Test}_i = \text{PASS})

生产测试经验

星级餐厅的质检体系不是一夜建成的:一开始可能只是随手试吃,后来变成了标准工序表、温度计和留样柜。测试体系也要像后厨制度一样持续演进,而不是写完就再也不看。

在生产环境中,测试本身也是需要维护的代码资产。测试金字塔的配比(约 70% 单元、20% 集成、10% 端到端)帮助团队把成本投入在回报最高的层次;覆盖率数字只是辅助工具,真正的质量来自断言强度和场景设计。

评价测试体系可以引入一个简单的净收益模型:测试带来的价值是捕获的缺陷数,付出的成本包括编写、维护与执行三部分。团队要控制维护成本,比如避免断言内部实现细节、定期删除无效用例、用标记把慢测试分层。PR 审查时,维护者会一并对测试打分:有没有覆盖关键分支、Mock 是否合理、断言是否稳定——测试代码和业务代码一样,必须接受同样严格的审视。

食堂的后厨制度会在一次意外的食物中毒事件后立刻升级;测试体系也在一次次线上事故和回归漏洞中日益森严。从技术原型到大规模生产,靠的就是这套试菜、巡店、留样的制度,把每一次改动的风险挡在菜单之外。

Rnet=Bcaught−(Cwrite+Cmaintain+Crun)R_{\text{net}} = B_{\text{caught}} - (C_{\text{write}} + C_{\text{maintain}} + C_{\text{run}})
好测试是投资,坏测试是负债。当维护成本超过捕获缺陷的收益时,测试就会从护栏变成绊脚石。
  1. 计算捕获收益统计测试累计发现并拦截的线上缺陷数。
    BcaughtB_{\text{caught}}
  2. 统计编写成本新开发时编写测试消耗的人力和时间。
    CwriteC_{\text{write}}
  3. 统计维护成本接口变更、代码重构时同步更新测试的开销。
    CmaintainC_{\text{maintain}}
  4. 统计执行成本每次 CI 运行占用的机器时间与等待时长。
    CrunC_{\text{run}}
  5. 计算净收益用捕获缺陷的价值减去三类成本之和。
    Rnet=Bcaught−(Cwrite+Cmaintain+Crun)R_{\text{net}} = B_{\text{caught}} - (C_{\text{write}} + C_{\text{maintain}} + C_{\text{run}})

AI 应用场景

场景一:自研 RAG 检索链的贡献前测试

阶段:质量保障 / PR 提交前测试

解决的问题:真实 LLM 调用慢、贵、结果随机,开发者在本地很难稳定复现链的输出。传统方式是不断改 prompt 后手工验证,既耗时又无法沉淀为回归资产。

具体案例:某团队开发企业内部知识库问答链,提交 PR 前为每个检索器编写单元测试,并用 FakeListLLM 注入两三条预设回答。CI 在每次 push 后自动运行测试,确保“检索结果 + 提示词模板 + LLM 调用”三步在改动后仍按预期工作。

场景二:开源仓库 PR 的自动化回归流水线

阶段:持续集成 / 贡献合并门禁

解决的问题:LangChain 仓库每天收到大量 PR,任何改动都可能破坏已有 chain 的输出。仅靠维护者人工试菜不现实,必须借助 CI 自动执行全量回归。

具体案例:CI 平台(如 GitHub Actions)配合 pytest,按“单元 → 集成 → 慢测试”分阶段执行。覆盖率低于阈值或任一用例失败时,PR 会被自动标记为“需修改”,阻止合并,保证主干始终可发布。

场景三:切换模型供应商时的兼容性回归

阶段:发布前回归测试

解决的问题:从 OpenAI 切换到开源模型或升级供应商版本时,响应格式、流式行为、超时特征都可能变化,导致解析层崩溃。

具体案例:团队为两家供应商封装统一接口,用录制回放测试保存真实响应快照。升级时先离线对比新响应与快照结构,再用少量 Mock 适配用例做全链路模拟,最后在预发环境用真实 API 冒烟,三层把关完成平滑切换。

Python 演示

python51 行
"""演示为 LangChain 风格的链式逻辑编写单元测试与 LLM Mock。"""  class FakeLLM:    """极简 FakeLLM: 根据 prompt 关键词返回预设内容, 不产生真实网络请求。"""     def __init__(self, responses):        self.responses = responses        self.call_count = 0     def invoke(self, prompt):        self.call_count += 1        for key, reply in self.responses.items():            if key in prompt:                return reply        return "我不知道"  class SimpleChain:    """极简链: 先包装 prompt, 再调用 LLM。"""     def __init__(self, llm):        self.llm = llm     def run(self, question):        prompt = "请回答: " + question        answer = self.llm.invoke(prompt)        return "答案: " + answer  def test_chain_returns_expected_answer():    fake = FakeLLM(responses={"LangChain": "一个开源 AI 框架"})    chain = SimpleChain(fake)    result = chain.run("什么是 LangChain?")    assert "一个开源 AI 框架" in result    assert fake.call_count == 1    print("PASS:", result)  def test_chain_handles_unknown_prompt():    fake = FakeLLM(responses={"LangChain": "一个开源 AI 框架"})    chain = SimpleChain(fake)    result = chain.run("什么是概率论?")    assert "我不知道" in result    print("PASS:", result)  if __name__ == "__main__":    test_chain_returns_expected_answer()    test_chain_handles_unknown_prompt()    print("全部测试通过!")
目的:用几十行代码演示最核心的“替身测试”思想——FakeLLM 充当预设响应的替身,SimpleChain 是被测对象,两个测试用例验证正常命中与兜底分支。
  1. class FakeLLM:用一个本地对象代替真实 LLM 客户端,测试过程中不发任何网络请求。
  2. self.call_count = 0记录模型被调用的次数,用于捕捉链逻辑中的重复调用 bug。
  3. if key in prompt:极简的 Mock 匹配规则:prompt 中包含预设关键词即返回对应响应。
  4. chain = SimpleChain(fake)把替身“注入”被测链,验证的不是模型本身,而是链的组装逻辑。
  5. assert "一个开源 AI 框架" in result用包含断言而非精确相等,容忍外层包装文案的正常变化。
  6. assert fake.call_count == 1断言调用次数,确保链没有重复调 LLM,这是 Mock 测试的经典手法。

常见误区

误区很多初学者把“行覆盖率 100%”当成测试目标,认为只要所有代码行都被执行过,代码就没有 bug 了。

正解覆盖率只回答“哪些行被执行”,不回答“断言是否有效”。一个只写 assert True 的用例可以覆盖所有行,却什么都防不住。就像试菜官把每道菜都闻了一遍,却没有真正尝出味道。覆盖率是辅助雷达,不是质量本身。

为何常见:覆盖率是 CI 面板上最显眼的数字,像 KPI 一样直观,容易让人误把度量当目标。

误区初学者发现 Mock 让测试又快又稳,于是把所有外部依赖都换成替身,连数据库和第三方 API 也一律 Mock 掉,认为这样就不用写集成测试了。

正解Mock 是替身,不是真身。它验证的是“你的代码是否按预期调用了接口”,不验证外部系统是否真的返回你模拟的形状。网络超时、鉴权失败、数据格式漂移这些真实问题,只有少量真实集成测试才能兜住。冷冻鹿肉练火候可以,但正式晚宴前必须试一次真鹿肉。

为何常见:Mock 带来的即时确定感和零成本太有诱惑力,容易让人产生“替身即真身”的错觉。

误区有些测试把断言写到“prompt 字符串必须和某段文本逐字相等”的程度,认为越精确的断言越能抓住问题。

正解过度具体的断言会把测试变成“照相机”:提示词文案一调整就红,哪怕行为完全正确。优秀断言应锁定输出语义和关键副作用,而不是内部实现细节。试菜官要判断“这道菜咸淡适中”,而不是“这勺盐必须是 3.12 克”。

为何常见:抓住具体返回值是最直观的写法,而表达“行为意图”需要更多设计经验。

误区看到集成测试报错,第一反应是自己写的链逻辑有 bug,于是反复调试业务代码,却查不出原因。

正解集成测试失败可能来自测试数据污染、数据库环境差异、用例并发互相干扰,甚至前一个用例没清理干净。正确做法是先确认测试隔离,再定位业务逻辑。稳定复现、用例独立,是让失败真正具有指导价值的前提。

为何常见:新手对红绿信号灯的本能反应是“绿好红坏”,忽略了测试环境本身也是故障源。

面试题

Q1. 单元测试最关注的是?

单选题 · easy

  • A.单个组件内部逻辑← 正确答案
  • B.完整用户操作流程
  • C.跨服务调用全链路
  • D.高并发下性能表现

答案:A。单元测试只针对单个函数或类的内部行为,不依赖数据库和网络。B 属于端到端测试,C 属于集成测试,D 属于性能测试。就像试菜时单独尝盐,而不是整桌菜一起评。

Q2. 测试金字塔中哪一层的用例数量最多?

单选题 · easy

  • A.单元测试用例数最多← 正确答案
  • B.集成测试用例数居中
  • C.端到端测试用例数最少
  • D.回归测试用例数最多

答案:A。金字塔底部是单元测试,数量最多、速度最快、成本最低;越往上数量越少。回归测试是目标场景而非金字塔层级,因此 D 不符合题意。这就像厨房里先大范围单尝食材,再少量整桌试菜。

Q3. Mock LLM 的核心目的是?

单选题 · easy

  • A.让测试结果确定且成本低← 正确答案
  • B.提高大模型真实推理质量
  • C.加速训练数据集扩张
  • D.替代人工标注全部流程

答案:A。Mock LLM 用本地替身返回预设响应,解决真实调用慢、贵、结果随机的问题,使测试可重复、可离线运行。B、C、D 都和训练或推理本身有关,不是测试替身的职责。

Q4. 某仓库总共有 200 行可执行代码,测试实际执行了 160 行,行覆盖率是多少?

单选题 · mid

  • A.75%
  • B.80%← 正确答案
  • C.85%
  • D.90%

答案:B。按公式计算,160 除以 200 等于 0.8,再乘以 100% 得 80%。覆盖率只表示“尝了多少口菜”,不代表断言质量。误选其他项的同学注意分子分母不要写反。

Q5. 下列哪些属于集成测试的关注点?

多选题 · mid

  • A.检查组件间数据传递正确← 正确答案
  • B.检查数据库连接是否可用← 正确答案
  • C.检查单个循环内变量取值
  • D.检查两条链式调用结果衔接← 正确答案

答案:ABD。集成测试聚焦组件协作:数据传递、数据库连接、链式衔接都属于此类。C 是单个函数内部的逻辑细节,属于单元测试范畴。整桌菜的配合由集成测试把关,单颗盐的咸淡由单元测试负责。

Q6. 最接近 LangChain 中 FakeLLM 角色的是?

单选题 · mid

  • A.本地替身返回预设响应← 正确答案
  • B.反向代理转发到多个模型
  • C.分布式训练的任务调度器
  • D.自动补全代码的 IDE 插件

答案:A。FakeLLM 是测试专用的本地替身,不联网、不发真实请求,只按输入返回预设文本。B 是流量转发组件,C 是训练基础设施,D 是开发工具,都和“测试替身”无关。

Q7. 编写高质量测试用例应满足哪些要求?

多选题 · mid

  • A.测试之间彼此独立← 正确答案
  • B.每次执行完毕自动清理← 正确答案
  • C.故意省去失败分支断言
  • D.使用固定种子模拟随机数← 正确答案

答案:ABD。独立用例能避免互相污染,清理现场防止残留状态,固定种子让随机场景可复现。C 恰好相反,失败分支正是最容易藏 bug 的地方,必须显式断言。试菜时既要尝成功口味,也要知道食材坏了会是什么反应。

Q8. 回归测试最核心的价值是?

单选题 · hard

  • A.防止历史功能被无意识破坏← 正确答案
  • B.提升模型在训练集上的准确率
  • C.替代开发者全部代码评审
  • D.保证每次提交只含一个文件改动

答案:A。回归测试通过重跑历史用例,确保新改动没有破坏旧行为。B 是训练目标,C 夸大了测试的作用(它不能替代代码评审),D 是提交规范而非测试价值。这就是菜单改版后招牌牛排也要重新试吃的原因。

Q9. 集成测试容易“变脆”的原因有哪些?

多选题 · hard

  • A.依赖真实外部服务不稳定← 正确答案
  • B.测试顺序共享全局状态← 正确答案
  • C.断言过于依赖响应细节← 正确答案
  • D.集成测试不需要配置 Mock

答案:ABC。真实服务抖动、全局状态耦合、断言盯死细节都会让测试在代码没问题时无故变红。D 是错误选项:集成测试同样需要适度 Mock 来隔离不可控依赖。测试变脆会让团队对红灯麻木,最终失去试菜的意义。

Q10. 某链实现了 prompt 级缓存:同一 prompt 第一次调用真实 LLM,第二次直接复用结果。测试运行两次相同询问后,断言 fake.call_count 应为?

单选题 · extreme

  • A.恰好 1 次← 正确答案
  • B.恰好 2 次
  • C.恰好 3 次
  • D.恰好 4 次

答案:A。由于缓存命中了第二次调用,真实 LLM 只被调用一次。用“恰好 1 次”作为断言,能有效防止后续重构时缓存逻辑被悄悄移除。若断言 2 次,反而会把预期固定成错误行为。这就像同一道菜点两次,厨房只需要做一次。