跳到主要内容
那句手写的桩,成了全套测试唯一的证词

那句手写的桩,成了全套测试唯一的证词

老墨
老墨

· 阅读约 4 分钟

旧稿:

function scriptedReply(specialist: string) {
  return { role: 'assistant', content: '这是个好问题,让我从几个角度说说我们的看法。' }
}

二十个字,客气,像模像样。可它是这条链路上唯一一句证词——这一点我第一眼完全没看出来。

真模型从 provider 那一层切掉,UI 真跑,路由真跑,消息真落库,五个练习整套走完不花一分钱。这个决定我举双手赞成,钱和覆盖率两个死结一次解开。问题不在切,在切完之后:原来由模型即兴吐出来的那句话,现在得我手写。而手写的这一句,继承了原来那句话全部的责任。

V1 就是上面那段。跑起来很顺,我写的断言是"收到一条 assistant 消息"。写的时候还挺踏实。回头看,这条断言能查什么?什么都查不出来。分发逻辑把 VP of Product 的活派给了 guide,桩照样返回,测试照样绿。它测的是"有个字符串出现了",不是"产品做对了"。

V2 我把桩拆开,一个 specialist 一段,各自带点人物。VP 谈定价,guide 谈框架,写的时候挺得意,觉得这才叫 fixture。跑了两天问题就来了:断言开始咬桩的措辞。

新稿:

expect(message.content).toContain('定价')

我把"定价"改成"价格",一条本来该绿的测试红了。桩是内容,内容会跟着课程练习改;断言不该跟着内容一起漂。咬措辞的断言,迟早变成某次"顺手改个文案"引发的排查。

V3 才想明白:桩的正文可以随便写,断言要咬的那个东西得小到只服务断言、不服务可读性。我给它加了一个锚点词,专供 toContain 用:

const ANCHOR = { guide: 'FRAME', vp: 'PRICING' }

expect(message.content).toContain(ANCHOR.vp)

正文改十遍,锚点不动,测试不惊。这里有个我之前没意识到的分工:桩的文本其实是两件事——给人看的部分,和给测试看的部分。混在一起写,两边互相牵制。

评论区 Mudassir 说的那个 fixture drift,落点就在这个位置。桩没漂,路由漂了,桩老老实实返回,测试绿着,产品坏着。他的建议是断言"选了谁",跟我最后收敛到的地方一样:断言得落在身份上,不能落在有没有。断言"有没有"的时候,桩写得再漂亮也是白写。

顺带说评分器。它一次读整段对话,是这套东西里最贵的一调,我干脆让它返回一个写死的 100%。于是断言只剩下"rubric chip 有没有填满"——听着挺虚,但它真抓到过一次回归:分数回来了,那条 bar 没更新。桩给什么,断言就只能看什么。这不是偷懒,是把有限的注意力押在真 UI 上。

还有一层是我做的时候没想清楚、后来才补上的。切在 provider 这层,代价是你的桩的形状被框死了。切在网络层,桩长得像一段 HTTP 响应,随便什么形状都行;切在 provider,桩得长得跟真 provider 的返回值一模一样——role 字段、消息形状、推给事件流的那个对象,一个都不能少。你切在哪一层,决定了你手写的那句话要长成什么样。留着真路由和真落库,换来的就是这一层约束。这笔交易我觉得值。

扯回来,说这条链路上另一处更贵的字。不是桩,是那条拦截线。

旧稿:

if (!script) return { role: 'assistant', content: '' }   // 无害放行

新稿:

if (!script) throw new Error(`unscripted message reached the harness: ${text}`)

旧稿那行的注释是我自己写的,写完还挺满意——"无害放行",多温柔的四个字。问题就在这:它把【我没给它写桩】和【这条消息没问题】写成了同一句话。这两个状态差着十万八千里,一个是我的活没干完,一个是真模型被叫了一次。

后来真出事。一条没匹配上的消息溜到了真 provider,测试全绿,然后每次跑都在花钱。修它的那个 commit title 我到现在还记得:test(course-studio): prevent deterministic tests using providers。标题里没有"花钱"两个字,但那次修的是钱。

所以拦截线被越过的时候必须出声,默认值必须选最吵的那一个。温柔的默认值不是宽容,是把账单设成了静音。测试项目的名字里带 no-credit,也是同一个心思——让人在跑之前就看得见这个 suite 是不是免费,而不是等月底看账单。

定稿大概长这样:

// stubs.ts
export const ANCHOR = { guide: 'FRAME', vp: 'PRICING' }

export function scriptedReply(specialist: string) {
  if (!(specialist in ANCHOR)) {
    throw new Error(`unscripted specialist: ${specialist}`)
  }
  const body = BODIES[specialist] ?? ''
  return { role: 'assistant', content: `[${ANCHOR[specialist]}] ${body}` }
}

比最初那版长了不少,我不打算把它改短。短在这儿没意义,能挡住一次真调用才有意义。

最后坦白一句:桩的正文会过期,这事我没解决。课程练习一改,那段话就得跟着改,BODIES 里迟早会躺着一句谁都不认识的话。我能守住的只有锚点和那条 throw;至于桩读起来还像不像人话,暂时只能靠人眼过一遍。哪天想明白了,再改一版。