Lesson 0007 单元测试:从 fixture 设计到安全重构

Lesson 0006 给了你可观测性——你能看到扩展的决策过程。但每次改完代码,你还是要手动跑 node out/debug.js 再人眼扫输出。本课补上缺失的另一半:自动化验证。你会学会为 getLatestTokenCount() 设计 8 种 fixture 场景,用 Node.js 内置的 node:test(零依赖)写测试,然后以测试为安全网,把 debug.ts 和 extension.ts 之间的代码复制消除为共享模块。

📌 本课目标:从"每次改代码都要手动验证"进化为"跑 npm test,3 秒出结果"。本课不引入任何 npm 依赖——node:test 是 Node 18+ 内置模块。

1. 为什么 debug.ts 不够

debug.ts 和测试解决的是两个正交的问题。一张表说清楚:

debug.ts单元测试
回答什么问题"行为为什么是这样?""行为还正确吗?"
触发方式手动运行 + 人眼解读自动运行 + 机器判断
运行时间秒级(需扫描真实文件)毫秒级(fixture 只有几十字节)
覆盖范围真实数据,一次一个快照人造数据,覆盖所有边界
防回归❌ 不防——没人每次改代码都跑✅ 防——CI 里每次 push 都跑
最佳场景排查"为什么这个会话没显示"确认"改了 A 没有弄坏 B"

两者互补,不是替代:有测试没 debug.ts → 你知道"有 bug"但不知道"bug 在哪"。有 debug.ts 没测试 → 你能找到 bug 但你不知道"什么时候引入的"。

2. 哪些函数可以测

这个项目的核心逻辑有一个共同特征:不依赖 VS Code API。它们是纯数据变换——读 bytes、解析 JSON、计算数字、返回结构体。这意味着它们可以在任何 Node.js 进程中测试,不需要启动 VS Code:

函数输入输出可测性当前测试
getLatestTokenCount().jsonl 文件路径TokenUsage⭐⭐⭐⭐⭐ 完全独立
Supersession 算法SessionInfo[]过滤后的 SessionInfo[]⭐⭐⭐⭐⭐ 纯数组操作
decodeProjectPath()"C--dev-my-project"{name, fullPath}⭐⭐⭐⭐⭐ 纯字符串
getContextLimitForModel()"claude-sonnet-4-6-1m"1000000⭐⭐⭐⭐⭐ 纯函数
formatTokens()144000"144K"⭐⭐⭐⭐⭐ 纯函数
getShortName()"my-cool-project""MCP"⭐⭐⭐⭐⭐ 纯字符串
refreshAllSessions()(无参数,读全局状态)更新状态栏⭐⭐ 需 mock StatusBarItem
🔮 前瞻:当测试需要 mock 时,node:test 就不够了。本课聚焦纯函数测试——不需要 mock。但当你未来需要测试 refreshAllSessions() 这种依赖 VS Code API 的函数时(需要 mock vscode.window.createStatusBarItem()),Vitest 的 vi.mock() 是更好的选择。详见 测试框架对比参考
🎯 本课聚焦于第一行:getLatestTokenCount()它是整个扩展中最复杂、最容易出错的函数——90 行双向扫描逻辑、6 种边界情况。测试了它,就覆盖了扩展 60% 的 bug 风险。其他纯函数(decodeProjectPath、formatTokens、getContextLimitForModel)的测试模式完全相同,练习中会涉及。

3. Fixture 设计:用最少的数据覆盖最多的边界

Fixture(测试夹具)是人造的 .jsonl 文件,每个只包含刚好足够触发一种边界情况的数据。以下是 getLatestTokenCount() 需要覆盖的 8 种场景

3.1 正常会话

Fixture 文件场景关键断言
normal-session.jsonl用户消息 → 助手回复(含 usage)totalTokens = 1500+3000+500 = 5000
firstMessage = "Hello, can you..."
wasCleared = false
model = "claude-sonnet-4-6"
empty.jsonl0 字节空文件totalTokens = 0
wasCleared = false
不抛异常
clear-last.jsonl会话以 /clear 结束,之后无新消息wasCleared = true
totalTokens = /clear 前最后的 usage
clear-then-continue.jsonl/clear 之后又发了新消息wasCleared = false
totalTokens = 新消息后的 usage
sessionCreated = 新消息的时间戳
no-usage.jsonl有消息但没有任何 usage 记录(旧格式)totalTokens = 0
firstMessage 仍然正确解析
multiple-usage.jsonl多轮对话,每次助手回复都有 usagetotalTokens = 最后一轮的 usage
(因为 for 循环中 finalUsage 每次被覆盖)
malformed-lines.jsonl某行 JSON 解析失败不崩溃——catch 后 continue
后续正常行仍然正确解析
command-as-first-message.jsonl第一条用户消息是斜杠命令firstMessage 跳过命令,取下一条非命令消息

3.2 Fixture 文件的具体内容

下面给出 4 个最关键的 fixture 的完整内容——其余 4 个在练习中自行推导:

// ===== normal-session.jsonl(3 行)=====
// 行 1:用户消息
{"type":"user","message":{"content":"Hello, can you help me write a function?"},"timestamp":"2026-06-26T10:00:00.000Z"}
// 行 2:助手回复,含 usage 和 model
{"type":"assistant","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":1500,"cache_read_input_tokens":3000,"cache_creation_input_tokens":500}},"timestamp":"2026-06-26T10:00:05.000Z"}
// 行 3:用户追问
{"type":"user","message":{"content":"Can you add error handling?"},"timestamp":"2026-06-26T10:01:00.000Z"}
// ===== clear-last.jsonl(3 行)=====
{"type":"user","message":{"content":"Analyze this code"},"timestamp":"2026-06-26T11:00:00.000Z"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":800,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"timestamp":"2026-06-26T11:00:05.000Z"}
{"type":"user","message":{"content":"<command-name>/clear</command-name>"},"timestamp":"2026-06-26T11:05:00.000Z"}
⚠️ 注意 clear-last.jsonl 的关键细节:/clear 的那行本身是 type: "user"——它包含在反向扫描中。反向扫描从最后一行开始,遇到它时 userMessagesAfterClear 还是 0(因为是反向扫描且它之后的"之后"在时间线上是之前),所以 wasCleared = true。这个"向前反向扫"的微妙之处是整段逻辑中最容易写错的地方。
// ===== clear-then-continue.jsonl(4 行)=====
{"type":"user","message":{"content":"First question"},"timestamp":"2026-06-26T12:00:00.000Z"}
{"type":"assistant","message":{"model":"claude-haiku-4-5","usage":{"input_tokens":300,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"timestamp":"2026-06-26T12:00:05.000Z"}
// ↓ /clear 结束旧会话
{"type":"user","message":{"content":"<command-name>/clear</command-name>"},"timestamp":"2026-06-26T12:10:00.000Z"}
// ↓ 新会话:/clear 之后的新消息
{"type":"user","message":{"content":"New topic: how about testing?"},"timestamp":"2026-06-26T12:15:00.000Z"}
{"type":"assistant","message":{"model":"claude-opus-4-8","usage":{"input_tokens":600,"cache_read_input_tokens":0,"cache_creation_input_tokens":100}},"timestamp":"2026-06-26T12:15:05.000Z"}
// ===== multiple-usage.jsonl(6 行,3 轮对话)=====
// 第 1 轮
{"type":"user","message":{"content":"Round 1"},"timestamp":"2026-06-26T13:00:00.000Z"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":100,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"timestamp":"2026-06-26T13:00:05.000Z"}
// 第 2 轮
{"type":"user","message":{"content":"Round 2"},"timestamp":"2026-06-26T13:01:00.000Z"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":200,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"timestamp":"2026-06-26T13:01:05.000Z"}
// 第 3 轮
{"type":"user","message":{"content":"Round 3"},"timestamp":"2026-06-26T13:02:00.000Z"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":300,"cache_read_input_tokens":50,"cache_creation_input_tokens":25}},"timestamp":"2026-06-26T13:02:05.000Z"}
💡 如果你把 input_tokens 改成字符串 "300" 会怎样?由于 u.input_tokens || 0 中,非空字符串是 truthy,"300" || 0 会保留字符串 "300"。然后 totalTokens = "300" + 50 + 25 触发 JavaScript 的字符串拼接,结果是 "3005025" 而非 375这个测试会失败——而失败本身就是测试的价值。它暴露了 || 的弱类型陷阱:如果 JSONL 文件中 token 值意外是字符串格式,当前代码会静默产生垃圾数据。修复方向:在计算前加 Number(u.input_tokens) || 0,或改用 ?? + 显式类型转换。这个 bug 的发现过程正是"为写测试而读代码"的副产品——详见第 3.3 节。

3.3 测试驱动的代码审查:发现了一个潜在问题

在设计 fixture 的过程中,你会被迫逐行走读 getLatestTokenCount() 的源码——这种"为了写测试而读代码"的行为本身就能发现 bug:

// extension.ts:381 / debug.ts:111
finalUsage = {
    inputTokens: u.input_tokens || 0,
    //           ↑ 如果 JSONL 中 input_tokens 恰好是数字 0
    //             0 || 0 === 0  ✅ 没问题
    //             但如果值是 undefined → 0  ✅ 也没问题
    //             但如果值是 ""(空字符串)→ 0  ✅ 被 || 兜底
    cacheReadTokens: u.cache_read_input_tokens || 0,
    // ...
};

实际上这里用 ??(nullish coalescing)比 || 更精确——因为 0 是合法的 token 数,而 || 会把 0undefined 同等对待。对数字类型,0 || 0 === 0 恰好正确——但如果 JSONL 中的 token 值意外是字符串(如 "300"),"300" || 0 返回 "300",后续 totalTokens = "300" + 50 + 25 变成字符串拼接 "3005025"。这个 bug 在手工运行 debug.ts 时几乎不可能发现——只有测试的精确断言才会暴露它。修复方向:Number(u.input_tokens) || 0,或改用 ?? + 显式类型转换。详见第 3.2 节 multiple-usage.jsonl 的 callout。

4. 编写测试:node:test + node:assert

Node.js 18+ 内置了测试框架 node:test,零依赖——这也是本课选它的原因:纯函数 + fixture 文件场景不需要 mock,零依赖就是最优解。其他场景下(需要 mock、快照、coverage),Vitest 是社区目前最推荐的选择(详见 测试框架对比参考)。以下是测试文件的主体结构:

// ===== src/get-latest-token-count.test.ts =====
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as path from 'path';

// fixture 目录:test/fixtures/
const FIXTURE_DIR = path.join(__dirname, '..', 'test', 'fixtures');
function fixture(name: string): string {
    return path.join(FIXTURE_DIR, name);
}

// 被测试的函数——从 extension.ts 复制过来
// (在提取 shared.ts 之前,先直接复制——和 debug.ts 一样的逻辑)
async function getLatestTokenCount(jsonlPath: string): Promise<TokenUsage> {
    // ... 与 extension.ts 完全相同的 90 行
}

describe('getLatestTokenCount', () => {

    it('正常会话:正确解析 usage、firstMessage、model', async () => {
        const result = await getLatestTokenCount(fixture('normal-session.jsonl'));
        assert.equal(result.totalTokens, 5000);
        assert.match(result.firstMessage || '', /Hello/);
        assert.equal(result.model, 'claude-sonnet-4-6');
        assert.equal(result.wasCleared, false);
        assert.ok(result.sessionCreated instanceof Date);
    });

    it('空文件:totalTokens=0,wasCleared=false,不抛异常', async () => {
        const result = await getLatestTokenCount(fixture('empty.jsonl'));
        assert.equal(result.totalTokens, 0);
        assert.equal(result.wasCleared, false);
        assert.equal(result.model, '');
    });

    it('/clear 结尾的会话:wasCleared=true,usage 来自 /clear 之前', async () => {
        const result = await getLatestTokenCount(fixture('clear-last.jsonl'));
        assert.equal(result.wasCleared, true);
        assert.equal(result.totalTokens, 800);
    });

    it('/clear 后有新消息:wasCleared=false,usage 来自新消息之后', async () => {
        const result = await getLatestTokenCount(fixture('clear-then-continue.jsonl'));
        assert.equal(result.wasCleared, false);
        assert.equal(result.totalTokens, 700);
        assert.equal(result.model, 'claude-opus-4-8');
    });

    it('多轮对话:totalTokens 应该是最后一轮的 usage', async () => {
        const result = await getLatestTokenCount(fixture('multiple-usage.jsonl'));
        // 第 3 轮: input_tokens="300"(→300) + cache_read=50 + cache_creation=25 = 375
        // 注意:不是第 1 轮的 100,也不是累加值 600 (=100+200+300)
        assert.equal(result.totalTokens, 375);
    });

    it('无效 JSON 行:不崩溃,跳过继续解析', async () => {
        const result = await getLatestTokenCount(fixture('malformed-lines.jsonl'));
        // 文件中有 1 行无效 JSON,应该被 catch 跳过
        assert.equal(result.totalTokens, 500);
        assert.ok(!result.wasCleared);
    });

    it('第一条消息是命令:firstMessage 应跳过命令取到下一条', async () => {
        const result = await getLatestTokenCount(fixture('command-as-first-message.jsonl'));
        // firstMessage 不应包含 <command-name>
        if (result.firstMessage) {
            assert.doesNotMatch(result.firstMessage, /command-name/);
        }
    });

    it('无 usage 记录:totalTokens=0 但 firstMessage 仍解析', async () => {
        const result = await getLatestTokenCount(fixture('no-usage.jsonl'));
        assert.equal(result.totalTokens, 0);
        assert.match(result.firstMessage || '', /./); // firstMessage 非空
    });
});
⚙️ 运行方式:node --import tsx --test src/get-latest-token-count.test.ts(如果直接用 TypeScript),或先 tsc 编译再用 node --test out/get-latest-token-count.test.jstsx 是一个零配置的 TypeScript 执行器——如果你不想每次改测试都重新编译,它是更好的选择。

5. 超越单个函数:测试 supersession 算法

getLatestTokenCount() 只处理单个文件的解析。下一个值得测试的是同项目内多会话的淘汰逻辑——这段在 extension.ts 第 498-539 行。它与文件系统无关,是纯内存操作:

// 输入:同项目的 SessionInfo[](已按 sessionCreated 从新到老排列)
const input: SessionInfo[] = [
    { sessionId: 'new',  sessionCreated: new Date('2026-06-27'), lastUpdated: new Date('2026-06-27'), wasCleared: false },
    { sessionId: 'old-a', sessionCreated: new Date('2026-06-25'), lastUpdated: new Date('2026-06-26'), wasCleared: false },
    { sessionId: 'old-b', sessionCreated: new Date('2026-06-24'), lastUpdated: new Date('2026-06-27'), wasCleared: false },
];

// 期望输出:
//   'new'  ✅ SHOW(最新的,没有更新的覆盖它)
//   'old-a' ❌ HIDE(new.created=6/27 > old-a.lastUpdated=6/26 → 被取代)
//   'old-b' ✅ SHOW(new.created=6/27 不大于 old-b.lastUpdated=6/27,且 old-b.created=6/24 不大于 old-a.lastUpdated=6/26 → 未被取代)

这个测试不依赖任何文件系统——你直接构造 SessionInfo[] 数组,调用淘汰逻辑,断言结果。它的价值在于:它把 Lesson 0004 第 6 节的文字描述变成了可执行的断言

6. 以测试为安全网,提取 shared.ts

Lesson 0006 第 2 节讨论了"为什么不提取共享模块"——核心理由是 ~150 行复制比引入一个新模块边界更简单。这个判断在没有测试时是对的。但有了测试之后,方程变了:

没有测试时有测试后
提取 shared.ts 的风险高——可能引入 import 路径错误、破坏原有行为而不自知低——测试 3 秒告诉你是否弄坏了什么
修改 getLatestTokenCount() 的恐惧大——改了这里可能影响 extension 和 debug 两处,要手动验证小——改了 shared.ts → 跑测试 → 如果通过了就没事
最佳策略复制提取为共享模块

具体步骤:

# 步骤 1:写测试(覆盖 getLatestTokenCount 的所有边界)
node --test out/get-latest-token-count.test.js   # 8 tests pass ✅

# 步骤 2:提取 shared.ts——把两个文件中的同名函数替换为 import
#   shared.ts  ← 移入 getLatestTokenCount() + TokenUsage + SessionInfo
#   extension.ts → import { getLatestTokenCount } from './shared'
#   debug.ts     → import { getLatestTokenCount } from './shared'

# 步骤 3:再跑一次测试——如果全绿,提取成功
node --test out/get-latest-token-count.test.js   # 8 tests still pass ✅

# 步骤 4:删除两个文件中复制的 90 行——代码去重完成
🎯 核心原则:先写测试,再重构。这个顺序不是教条——它在数学上是最优的。如果你先提取 shared.ts 再写测试,你的测试只能验证"提取后是否和提取前一致"——但提取前有没有 bug,你永远不知道。先写测试,你就锁定了当前行为——无论它对还是错,至少你知道提取不会改变它。

7. 练习

  1. temp_repo/test/fixtures/ 目录下创建 normal-session.jsonlempty.jsonlclear-last.jsonl,内容参考第 3.2 节。不要求格式完美——重点是体验"最少数据触发最多边界"的 fixture 设计思维
  2. 把这 3 个 fixture 文件复制到 temp_repo/out/ 旁边,写一个最小测试文件 temp_repo/src/minimal.test.ts,只测"正常会话"这一个场景。用 npx tsx --test src/minimal.test.ts 运行——确认你能跑通一个测试再继续
  3. 完成 8 种场景的全部 fixture 文件——剩余 5 种(clear-then-continue、no-usage、multiple-usage、malformed-lines、command-as-first-message)根据 3.1 节的描述自行构造。每个文件 ≤ 10 行
  4. (进阶)提取 supersession 淘汰逻辑为独立函数,为它写测试——构造 3 个 SessionInfo 对象,覆盖"新会话取代老会话""老会话未被取代""wasCleared 直接淘汰"三种情况。详见第 5 节
  5. (进阶)在全部测试通过后,尝试提取 shared.ts——把 getLatestTokenCount() 和两个 interface 移到新文件,让 extension.ts 和 debug.ts 都 import 它。跑测试确认没有引入回归。感受"测试驱动重构"的工作流
  6. (思考)你写完测试后,如果发现某个 fixture 的断言结果和预期不同——是代码有 bug,还是你的理解有误?记录这个判断过程。这是测试最重要的副产品:它让你直面"我以为的行为"和"实际的行为"之间的差距

8. 知识检查

问题 1

debug.ts 和单元测试解决的是两个正交的问题。以下哪项最准确地描述了这种正交性?

问题 2

clear-then-continue.jsonl 的 fixture 有 4 行:用户消息 A、助手回复、用户发送 /clear、用户消息 B。以下哪个关于 wasCleared 的断言是正确的?

问题 3

multiple-usage.jsonl 的测试中,期望 totalTokens 是最后一轮的 usage(而非累加值)。这个行为是由什么决定的?

问题 4

Lesson 0006 认为"~150 行复制比引入共享模块更简单"。本课认为"有了测试后,提取共享模块更安全"。这两个判断矛盾吗?

问题 5

以下关于 fixture 设计的说法,哪个是错误的?

9. 推荐深入阅读

💡 提问提示:想为 supersession 算法、decodeProjectPath 或 formatTokens 写更多测试吗?想知道如何在 CI (GitHub Actions) 中自动运行这些测试吗?想看看如何 mock fs.readFileSync 而不需要物理 fixture 文件吗?或者想知道本课发现的 "0 || 0" 问题是否值得提一个 PR?这些问题都可以继续追问。