本文面向:想为全栈 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。原因:
- 零依赖:不需要在 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 负责清理:
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 响应对象,有 statusCode、json()、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);
});
这种模式的好处:
- 不需要 mock 库:直接传对象进去
- 类型安全:TypeScript 编译时检查依赖接口
- 测试即文档:看测试就知道函数依赖什么
triggerSummarize 的依赖注入
summarize 服务的 triggerSummarize 也用同样的模式。它接受一个 deps 对象,包含 prepareTranscript、evaluateExperience、summarizeConversation、generateEmbeddings 等回调:
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 在这里
写好测试的三个原则
- 一个测试验证一件事 :不要在一个
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 或私信交流,很乐意解答。