欢迎光临
我们一直在努力

测试策略:单元测试 + 集成测试怎么写

本文面向:想为全栈 TypeScript 项目建立测试体系的开发者,尤其关注无框架(node:test)方案的同学。 预计阅读时间:12 分钟 最终效果:掌握测试金字塔分层、纯函数单元测试、sql.js 内存数据库与 Fastify inject 集成测试,以及依赖注入式 Mock 与 CI 自动化。

写代码不写测试,就像走钢丝不挂安全绳——早晚要出事。ChatCrystal 作为一个涉及文件解析、数据库操作、向量索引、LLM 调用的全栈项目,测试是保证每次发版不翻车的核心手段。

本文以 ChatCrystal 的真实测试代码为例,从测试金字塔讲起,手把手带你写纯函数单元测试、数据库集成测试、API 路由测试,再到 CI 自动化。所有代码都可以在 github.com/ZengLiangYi/ChatCrystal 里找到。


测试金字塔

测试金字塔是一个经典模型,从底到顶分三层:

/ E2E \\ 少量,慢,贵
/───────────\\
/ Integration \\ 适量,中速
/───────────────\\
/ Unit Tests \\ 大量,快,便宜
/───────────────────\\

  • 单元测试:测单个函数,不依赖外部系统。跑得快、写得快、维护成本低。应该占测试总量的 70% 以上。
  • 集成测试:测多个模块协作,涉及数据库、文件系统、HTTP 请求。跑得慢一些,但能发现单元测试覆盖不到的接口问题。
  • 端到端测试:模拟真实用户操作。ChatCrystal 目前以单元测试和集成测试为主,E2E 留给 Electron 打包后的手动验证。

ChatCrystal 的测试命令很简单:

npm test # 等价于 tsx –test src/**/*.test.ts

用的是 Node.js 内置的 node:test 模块,不需要装 Jest 或 Vitest。零依赖,开箱即用。


选型:为什么用 node:test

ChatCrystal 没有选 Jest 也没有选 Vitest,而是用 Node.js 22 自带的 node:test + node:assert/strict。原因:

  • 零依赖:不需要在 devDependencies 里加任何测试框架
  • 原生 ESM 支持:ChatCrystal 全量使用 ESM("type": "module"),node:test 天然兼容
  • tsx 集成:tsx –test 直接跑 TypeScript 测试文件,不需要编译步骤
  • 够用:test()、describe()、before()、after() 都有,断言 API 和 Jest 的 expect 类似
  • 测试文件命名约定:和被测文件同目录,加 .test.ts 后缀。比如 embedding.ts 的测试就是 embedding.test.ts。


    单元测试:纯函数怎么测

    纯函数是最好测的——给输入、查输出,不依赖外部状态。ChatCrystal 里有大量纯函数非常适合单元测试。

    sanitizeContent:正则清洗

    Claude Code 的 JSONL 对话里混着各种 XML 标签噪音,sanitizeContent 负责清理:

    // server/src/parser/adapters/claude-code.ts
    function sanitizeContent(text: string): string {
    let result = text;
    result = result.replace(/<system-reminder>[\\s\\S]*?<\\/system-reminder>/g, '');
    result = result.replace(/<command-name>[^<]*<\\/command-name>/g, '');
    result = result.replace(/<command-message>[^<]*<\\/command-message>/g, '');
    result = result.replace(/<command-args>[^<]*<\\/command-args>/g, '');
    result = result.replace(/<local-command-stdout>[^<]*<\\/local-command-stdout>/g, '');
    result = result.replace(/<local-command-caveat>[\\s\\S]*?<\\/local-command-caveat>/g, '');
    return result.trim();
    }

    测试思路:构造各种带噪音的字符串,验证清洗后的结果。需要覆盖的场景:

    import test from 'node:test';
    import assert from 'node:assert/strict';

    test('sanitizeContent strips system-reminder tags', () => {
    const input = 'Hello <system-reminder>Do not share this</system-reminder> world';
    assert.equal(sanitizeContent(input), 'Hello world');
    });

    test('sanitizeContent strips multi-line system-reminder', () => {
    const input = 'Before\\n<system-reminder>\\nLine 1\\nLine 2\\n</system-reminder>\\nAfter';
    assert.equal(sanitizeContent(input).trim(), 'Before\\n\\nAfter');
    });

    test('sanitizeContent strips command tags', () => {
    const input = '<command-name>/help</command-name><command-message>show help</command-message>';
    assert.equal(sanitizeContent(input), '');
    });

    test('sanitizeContent returns clean text unchanged', () => {
    assert.equal(sanitizeContent('No tags here'), 'No tags here');
    });

    关键点:每条测试只验证一个场景,测试名清楚描述预期行为。正则函数特别适合这种"输入-输出"对照测试,因为没有副作用。

    chunkText:文本分块

    Embedding 服务需要把长文本切成小块。chunkText 按段落边界切分,每块不超过 500 字符:

    // server/src/services/embedding.ts
    const CHUNK_SIZE = 500;

    function chunkText(text: string): string[] {
    if (text.length <= CHUNK_SIZE) return [text];

    const chunks: string[] = [];
    const paragraphs = text.split(/\\n\\n+/);
    let current = '';

    for (const para of paragraphs) {
    if (current.length + para.length + 2 > CHUNK_SIZE && current.length > 0) {
    chunks.push(current.trim());
    current = para;
    } else {
    current += (current ? '\\n\\n' : '') + para;
    }
    }
    if (current.trim()) chunks.push(current.trim());

    return chunks;
    }

    测试策略——覆盖三类边界:

    test('chunkText returns single chunk for short text', () => {
    const chunks = chunkText('Short text');
    assert.equal(chunks.length, 1);
    assert.equal(chunks[0], 'Short text');
    });

    test('chunkText splits on paragraph boundaries', () => {
    const para1 = 'A'.repeat(300);
    const para2 = 'B'.repeat(300);
    const chunks = chunkText(`${para1}\\n\\n${para2}`);

    assert.equal(chunks.length, 2);
    assert.ok(chunks[0].startsWith('AAA'));
    assert.ok(chunks[1].startsWith('BBB'));
    });

    test('chunkText merges small paragraphs until chunk is full', () => {
    const paras = Array.from({ length: 20 }, (_, i) => `Paragraph ${i}`);
    const chunks = chunkText(paras.join('\\n\\n'));

    // Each paragraph is short, so multiple paragraphs fit in one chunk
    assert.ok(chunks.length < 20);
    });

    extractProjectName:路径解析

    Claude Code 的项目目录名是编码过的(比如 c–Users-Rayner-Project-ChatCrystal),需要提取出人类可读的项目名:

    // server/src/parser/adapters/claude-code.ts
    function extractProjectName(dirName: string): string {
    const parts = dirName.split('-').filter(Boolean);

    const projectIdx = parts.findIndex(
    (p) => p.toLowerCase() === 'project' || p.toLowerCase() === 'projects',
    );
    if (projectIdx >= 0 && projectIdx < parts.length – 1) {
    return parts.slice(projectIdx + 1).join('-');
    }

    if (parts.length >= 2) {
    return parts.slice(–2).join('-');
    }
    return dirName;
    }

    测试要覆盖"找到 Project 标记"和"找不到标记"两种路径:

    test('extractProjectName extracts name after Project marker', () => {
    assert.equal(
    extractProjectName('c–Users-Rayner-Project-ChatCrystal'),
    'ChatCrystal',
    );
    });

    test('extractProjectName extracts name after Projects marker', () => {
    assert.equal(
    extractProjectName('home-user-projects-my-app'),
    'my-app',
    );
    });

    test('extractProjectName falls back to last two segments', () => {
    assert.equal(
    extractProjectName('some-random-dir-name'),
    'dir-name',
    );
    });

    test('extractProjectName returns original for single segment', () => {
    assert.equal(extractProjectName('single'), 'single');
    });

    buildNoteEmbeddingText:结构化文本拼接

    这个函数把笔记的各个字段(标题、摘要、结论、代码片段、标签)拼成一段用于 Embedding 的纯文本。它是一个纯函数,接受结构化输入,返回拼接后的字符串。测试它需要验证各个字段是否被正确包含:

    test('buildNoteEmbeddingText includes structured agent writeback memory signals', () => {
    const text = buildNoteEmbeddingText({
    title: 'Server readiness race causes ECONNREFUSED',
    summary: 'Requests must wait for server readiness before client calls.',
    keyConclusionsJson: JSON.stringify([
    'Await readiness before issuing HTTP requests.',
    ]),
    codeSnippetsJson: '[]',
    tagsText: 'readiness testing',
    sourceType: 'agent-writeback',
    rawPayloadJson: JSON.stringify({
    root_cause: 'Client calls raced server startup.',
    resolution: 'Block request setup until server readiness resolves.',
    }),
    errorSignaturesJson: '[]',
    filesTouchedJson: '[]',
    });

    assert.match(text, /Server readiness race causes ECONNREFUSED/);
    assert.match(text, /Root cause: Client calls raced server startup/);
    assert.match(text, /Resolution: Block request setup/);
    });

    还有一个重要场景——防御性输入。LLM 返回的 JSON 经常是坏的,函数必须不能抛异常:

    test('buildNoteEmbeddingText ignores malformed JSON defensively', () => {
    assert.doesNotThrow(() => {
    buildNoteEmbeddingText({
    title: 'Malformed payload',
    summary: 'Still works.',
    keyConclusionsJson: '{not-json',
    codeSnippetsJson: 'also-not-json',
    tagsText: null,
    sourceType: 'agent-writeback',
    rawPayloadJson: '{"root_cause"',
    errorSignaturesJson: '[broken',
    filesTouchedJson: '{broken',
    });
    });
    });


    集成测试:数据库和 API 怎么测

    纯函数好测,但 ChatCrystal 的核心逻辑离不开数据库和 HTTP 接口。集成测试要解决两个问题:怎么隔离数据库、怎么模拟 HTTP 请求。

    测试数据库:sql.js 内存实例

    ChatCrystal 用 sql.js(WASM SQLite),天然支持内存数据库。测试里不需要磁盘文件,直接 new SQL.Database() 就能拿到一个干净的数据库:

    import { fileURLToPath } from 'node:url';
    import initSqlJs from 'sql.js';
    import { SCHEMA_SQL } from '../db/schema.js';

    async function createSqlDatabase() {
    const SQL = await initSqlJs({
    locateFile: (file) =>
    fileURLToPath(
    new URL(`../../../node_modules/sql.js/dist/${file}`, import.meta.url),
    ),
    });
    const db = new SQL.Database();
    db.exec(SCHEMA_SQL);
    return db;
    }

    关键细节:locateFile 回调。sql.js 需要加载 .wasm 文件,在测试环境里需要手动指定路径。import.meta.url 配合 new URL() 是 ESM 里定位文件的标准做法。

    拿到内存数据库后,往里插测试数据:

    function insertConversation(db: Database, id: string) {
    db.run(
    `INSERT INTO conversations (
    id, source, project_dir, project_name, cwd, git_branch, message_count,
    first_message_at, last_message_at, file_path, file_size, file_mtime, status
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `
    ,
    [id, 'codex', 'C:/repo', 'repo', 'C:/repo', 'main', 2,
    '2026-04-29T00:00:00Z', '2026-04-29T00:01:00Z',
    `C:/repo/${id}.jsonl`, 1, '2026-04-29T00:00:00Z', 'imported'],
    );
    }

    事务测试:commit 和 rollback

    withTransaction 是 ChatCrystal 的事务封装。测试它需要验证两件事:成功时数据被提交,失败时数据被回滚:

    test('withTransaction commits all changes on success', async () => {
    const db = await createDatabase();

    withTransaction(db, () => {
    db.run('INSERT INTO items (value) VALUES (?)', ['first']);
    db.run('INSERT INTO items (value) VALUES (?)', ['second']);
    });

    assert.deepEqual(getValues(db), ['first', 'second']);
    });

    test('withTransaction rolls back all changes on error', async () => {
    const db = await createDatabase();

    assert.throws(() => {
    withTransaction(db, () => {
    db.run('INSERT INTO items (value) VALUES (?)', ['first']);
    throw new Error('boom');
    });
    }, /boom/);

    assert.deepEqual(getValues(db), []);
    });

    嵌套事务也要测——内层回滚不应该影响外层已提交的数据:

    test('nested rollback preserves outer transaction work', async () => {
    const db = await createDatabase();

    withTransaction(db, () => {
    db.run('INSERT INTO items (value) VALUES (?)', ['outer-before']);

    assert.throws(() => {
    withTransaction(db, () => {
    db.run('INSERT INTO items (value) VALUES (?)', ['inner-fail']);
    throw new Error('inner boom');
    });
    }, /inner boom/);

    db.run('INSERT INTO items (value) VALUES (?)', ['outer-after']);
    });

    assert.deepEqual(getValues(db), ['outer-before', 'outer-after']);
    });

    API 路由测试:Fastify inject

    Fastify 自带 inject() 方法,可以在不启动真实 HTTP 服务器的情况下测试路由。这对集成测试非常友好:

    import Fastify from 'fastify';

    test('GET /api/tags returns only tags that are used by notes', async () => {
    const db = await dbService.initDatabase();
    resetDatabase();

    // 准备测试数据
    db.exec(`
    INSERT INTO conversations (id, source, project_dir, project_name,
    first_message_at, last_message_at, file_path)
    VALUES ('conversation-1', 'codex', 'C:/repo', 'repo',
    '2026-04-29', '2026-04-29', 'a.jsonl');

    INSERT INTO notes (id, conversation_id, title, summary)
    VALUES (1, 'conversation-1', 'Useful note', 'This note should keep its tag.');

    INSERT INTO tags (id, name) VALUES (1, 'used'), (2, 'orphan');
    INSERT INTO note_tags (note_id, tag_id) VALUES (1, 1);
    `);

    // 注册路由,发请求
    const app = Fastify();
    await app.register(noteRoutes);

    try {
    const response = await app.inject({ method: 'GET', url: '/api/tags' });

    assert.equal(response.statusCode, 200);
    assert.deepEqual(response.json().data, [
    { id: 1, name: 'used', count: 1 },
    ]);
    } finally {
    await app.close();
    dbService.closeDatabase();
    }
    });

    app.inject() 返回一个模拟的 HTTP 响应对象,有 statusCode、json()、payload 等属性。不需要绑端口、不需要发真实 HTTP 请求,测试跑得飞快。

    import 服务测试:完整流程

    import 服务是 ChatCrystal 的核心流程之一——扫描数据源、去重、插入数据库。测试它需要模拟整个适配器链路:

    test('importAll resets changed conversations and clears stale notes', async () => {
    const { db, importAll, registerAdapter, appConfig } = await loadRuntime();
    resetDatabase(db);

    // 插入一条"旧"对话
    insertExistingConversation(db, {
    id: 'conv-1',
    source: 'test-source',
    status: 'summarized',
    });
    insertExistingMessage(db, 'conv-1', 'old message');
    insertExistingNote(db, 2, 'conv-1');

    // 注册一个模拟适配器,返回"新"数据
    registerAdapter(
    testAdapter('test-source', [conversationMeta('conv-1', 'test-source', 30, '2026-04-29T00:03:00Z')],
    new Map([['conv-1', parsedConversation('conv-1', 'test-source', ['new message'])]])
    ),
    );
    appConfig.enabledSources = ['test-source'];

    const progress = await importAll();

    assert.equal(progress.imported, 1);

    // 验证旧笔记被清理
    const notes = db.exec('SELECT COUNT(*) FROM notes WHERE conversation_id = ?', ['conv-1']);
    assert.equal(Number(notes[0].values[0][0]), 0);
    });

    这里的关键技巧是 testAdapter——一个工厂函数,返回符合 SourceAdapter 接口的模拟对象。不需要真实文件系统,所有数据都在内存里。


    Mock 策略:依赖注入而非 monkey-patch

    ChatCrystal 不用 Jest 的 jest.mock(),也不用 sinon。所有外部依赖都通过函数参数注入。这是测试可维护性的关键。

    semanticSearch 的依赖注入

    semanticSearch 函数需要调用 Embedding 模型、查询向量索引、读数据库。这些依赖全部通过第四个参数 deps 注入:

    export async function semanticSearch(
    query: string,
    topK = 10,
    expandRelations = false,
    deps: SemanticSearchDeps = {},
    ): Promise<DirectSearchHit[]> {
    const loadIndex = deps.getIndex ?? getIndex;
    const index = await loadIndex();
    const embedding = await (deps.embedQuery ?? embedSearchQuery)(query);
    const db = (deps.getDb ?? getDatabase)();
    // …
    }

    测试时传入假的依赖:

    test('semanticSearch overfetches when stale hits fill topK', async () => {
    const results = await semanticSearch('q', 1, false, {
    getIndex: async () => mockIndex,
    embedQuery: async () => [1, 0, 0],
    cleanupPreflight: async () => {},
    getDb: () => mockDb,
    });

    assert.equal(results.length, 1);
    assert.equal(results[0].noteId, 42);
    });

    这种模式的好处:

  • 不需要 mock 库:直接传对象进去
  • 类型安全:TypeScript 编译时检查依赖接口
  • 测试即文档:看测试就知道函数依赖什么
  • triggerSummarize 的依赖注入

    summarize 服务的 triggerSummarize 也用同样的模式。它接受一个 deps 对象,包含 prepareTranscript、evaluateExperience、summarizeConversation、generateEmbeddings 等回调:

    test('triggerSummarize marks rejected conversations as filtered', async () => {
    const db = await createSqlDatabase();
    insertConversation(db, 'conv-low');
    insertMessage(db, 'conv-low', 'm1', 'user', 'TypeScript interface 是什么?', 1);
    insertMessage(db, 'conv-low', 'm2', 'assistant', 'interface 描述对象形状。', 2);

    const result = await triggerSummarize('conv-low', {
    db: db as never,
    save: () => undefined,
    prepareTranscript: () => 'short informational transcript',
    evaluateExperience: async () => ({
    decision: 'reject',
    score: 0,
    confidence: 0.9,
    reasons: ['low-signal'],
    // …
    }),
    summarizeConversation: async () => {
    throw new Error('summarize should not run'); // 被拒绝的对话不该走到这
    },
    generateEmbeddings: async () => 1,
    discoverRelations: async () => undefined,
    });

    assert.equal(result, null);
    const conv = db.exec('SELECT status FROM conversations WHERE id = ?', ['conv-low']);
    assert.equal(conv[0].values[0][0], 'filtered');
    });

    注意 summarizeConversation 里故意抛异常——如果被拒绝的对话还调用了 LLM 摘要,说明逻辑有 bug,测试就会失败。

    环境变量隔离

    有些模块在 import 时就读取环境变量(比如 DATA_DIR)。测试文件在 import 之前设置临时目录:

    import { mkdtempSync } from 'node:fs';
    import { tmpdir } from 'node:os';
    import { join } from 'node:path';

    // 必须在 import 被测模块之前设置
    process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'chatcrystal-test-'));

    用 mkdtempSync 创建临时目录,测试结束后清理。每个测试文件的临时目录互不干扰。


    测试数据管理

    数据库重置

    集成测试之间需要干净的数据库状态。ChatCrystal 的做法是手动 DELETE 所有表:

    function resetDatabase(db: Database) {
    db.exec(`
    PRAGMA foreign_keys = ON;
    DELETE FROM experience_reviews;
    DELETE FROM note_tags;
    DELETE FROM embeddings;
    DELETE FROM note_relations;
    DELETE FROM notes;
    DELETE FROM messages;
    DELETE FROM conversations;
    DELETE FROM tags;
    DELETE FROM import_log;
    DELETE FROM vector_cleanup_tasks;
    `
    );
    }

    注意删除顺序——先删子表、再删父表,配合 PRAGMA foreign_keys = ON 确保外键约束不报错。

    测试数据工厂

    为了避免每个测试都写一大段 INSERT,ChatCrystal 用工厂函数构造测试数据:

    function conversationMeta(id: string, source: string, fileSize: number, fileMtime: string) {
    return { id, source, filePath: `C:/fixtures/${id}.jsonl`, fileSize, fileMtime, projectDir: 'C:/repo' };
    }

    function parsedConversation(id: string, source: string, messages: string[]) {
    const parsedMessages = messages.map((content, index) => ({
    id: `${id}-message-${index}`,
    type: index % 2 === 0 ? 'user' : 'assistant',
    content,
    // …
    }));
    return { id, source, messages: parsedMessages, /* … */ };
    }

    工厂函数让测试代码聚焦于"测什么"而不是"怎么准备数据"。


    CI 集成

    ChatCrystal 的 GitHub Actions 工作流在构建和发布前都会跑测试:

    # .github/workflows/release.yml
    – name: Test
    run: npm test

    这一行出现在 npm 发布和 Electron 打包两个 job 里。测试不过,不发版。

    本地开发时,npm test 跑全部测试:

    npm test
    # 等价于
    tsx –test src/**/*.test.ts

    tsx –test 会自动发现所有 *.test.ts 文件,按文件并行执行。Node.js 的 test runner 输出清晰的 pass/fail 报告,包括执行时间和失败的断言详情。


    实战建议

    测什么、不测什么

    优先级测什么原因
    高 纯函数(sanitize、chunk、extract) 改动频繁,回归风险大
    高 数据库操作(事务、CRUD) 数据损坏代价最高
    高 解析器(JSONL、SQLite) 输入格式千变万化
    中 API 路由 保证接口契约不被破坏
    低 LLM 调用 依赖外部服务,用 mock 覆盖逻辑即可
    低 UI 组件 ChatCrystal 目前没有前端测试

    测试文件结构

    server/src/
    services/
    embedding.ts
    embedding.test.ts ← 纯函数 + 依赖注入测试
    summarize.ts
    summarize.test.ts ← 依赖注入测试
    import.ts
    import.test.ts ← 完整流程集成测试
    db/
    transaction.ts
    transaction.test.ts ← 事务 commit/rollback
    index.test.ts ← 数据库初始化
    routes/
    tags.test.ts ← Fastify inject 测试
    notes-delete.test.ts
    parser/
    adapters/
    claude-code.ts ← sanitizeContent、extractProjectName 在这里

    写好测试的三个原则

  • 一个测试验证一件事:不要在一个 test() 里塞十个断言验证十个不同的行为
  • 测试名是文档:'withTransaction rolls back all changes on error' 比 'test transaction' 有用一百倍
  • 测试要能独立运行:不依赖执行顺序、不依赖外部服务、不共享可变状态

  • 总结

    ChatCrystal 的测试策略可以归纳为三个要点:

  • 用 node:test + node:assert,零依赖,原生 ESM,够用就好
  • 依赖注入替代 mock 库,函数参数传入假依赖,类型安全且可维护
  • sql.js 内存数据库做集成测试,干净隔离,不需要磁盘文件
  • 测试不是负担,是安全感。每次改完代码跑一遍 npm test,绿了就放心提交。红了?说明你刚改的东西有 regression,修完再提交。这就是测试的价值。


    项目地址:github.com/ZengLiangYi/ChatCrystal

    如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。

    赞(0)
    未经允许不得转载:171主机测评 » 测试策略:单元测试 + 集成测试怎么写
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址