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

本文面向:想为全栈 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 的测试命令很简单:

bash 复制代码
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。原因:

  1. 零依赖:不需要在 devDependencies 里加任何测试框架
  2. 原生 ESM 支持 :ChatCrystal 全量使用 ESM("type": "module"),node:test 天然兼容
  3. tsx 集成tsx --test 直接跑 TypeScript 测试文件,不需要编译步骤
  4. 够用test()describe()before()after() 都有,断言 API 和 Jest 的 expect 类似

测试文件命名约定:和被测文件同目录,加 .test.ts 后缀。比如 embedding.ts 的测试就是 embedding.test.ts


单元测试:纯函数怎么测

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

sanitizeContent:正则清洗

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

ts 复制代码
// 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();
}

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

ts 复制代码
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 字符:

ts 复制代码
// 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;
}

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

ts 复制代码
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),需要提取出人类可读的项目名:

ts 复制代码
// 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 标记"和"找不到标记"两种路径:

ts 复制代码
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 的纯文本。它是一个纯函数,接受结构化输入,返回拼接后的字符串。测试它需要验证各个字段是否被正确包含:

ts 复制代码
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 经常是坏的,函数必须不能抛异常:

ts 复制代码
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() 就能拿到一个干净的数据库:

ts 复制代码
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 里定位文件的标准做法。

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

ts 复制代码
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 的事务封装。测试它需要验证两件事:成功时数据被提交,失败时数据被回滚:

ts 复制代码
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), []);
});

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

ts 复制代码
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 服务器的情况下测试路由。这对集成测试非常友好:

ts 复制代码
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 响应对象,有 statusCodejson()payload 等属性。不需要绑端口、不需要发真实 HTTP 请求,测试跑得飞快。

import 服务测试:完整流程

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

ts 复制代码
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 注入:

ts 复制代码
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)();
  // ...
}

测试时传入假的依赖:

ts 复制代码
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);
});

这种模式的好处:

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

triggerSummarize 的依赖注入

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

ts 复制代码
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 之前设置临时目录:

ts 复制代码
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 所有表:

ts 复制代码
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 用工厂函数构造测试数据:

ts 复制代码
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 工作流在构建和发布前都会跑测试:

yaml 复制代码
# .github/workflows/release.yml
- name: Test
  run: npm test

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

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

bash 复制代码
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 在这里

写好测试的三个原则

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

总结

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

  1. 用 node:test + node:assert,零依赖,原生 ESM,够用就好
  2. 依赖注入替代 mock 库,函数参数传入假依赖,类型安全且可维护
  3. sql.js 内存数据库做集成测试,干净隔离,不需要磁盘文件

测试不是负担,是安全感。每次改完代码跑一遍 npm test,绿了就放心提交。红了?说明你刚改的东西有 regression,修完再提交。这就是测试的价值。


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

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

相关推荐
捏塔3 小时前
完美自动生成单元测试SKILL
单元测试·log4j
JieE2128 小时前
Bun + TypeScript:下一代 JavaScript 全栈开发的正确打开方式
typescript·全栈·bun
GuWenyue9 小时前
告别JS类型坑!Ts为什么在ai时代逐渐成为"第一"语言
前端·算法·typescript
kisshyshy9 小时前
告别 Node 噩梦?用 Bun + TypeScript 像写诗一样调用大模型
前端·typescript
悟空瞎说9 小时前
吃透 TypeScript 6.0 五大实用新特性,顺带前瞻 TS7.0,附全代码示例
typescript
sugar__salt11 小时前
Bun 新一代 JavaScript/TypeScript 运行时:从入门到实战
开发语言·javascript·typescript
暗冰ཏོ11 小时前
软件测试完整学习指南:从入门到自动化、性能与安全测试实战
软件测试·功能测试·单元测试·集成测试·压力测试·测试·安全性测试
dundundunsis13 小时前
Codex安装教程
typescript
樱花的浪漫14 小时前
Typescript、Zod基础
前端·javascript·人工智能·语言模型·自然语言处理·typescript