测试价值来自能识别具体回归,而不是绿灯数量。对话知识库跨越文件解析、SQL、向量索引、HTTP 和模型调用,应该按失败边界安排样本。本文核对 ChatCrystal 的真实测试入口,再用一个独立教学文件练习纯函数、依赖隔离、回滚和路由检查。
当前测试入口有四条路径
根脚本的 pnpm test 顺序运行 server、client、electron 和 audit-policy;不是只执行一个 src/**/*.test.ts glob。服务端通过 tsx 执行 node:test,客户端与 Electron 也有自己的 test 目录;审计策略测试使用 Node 命令并关闭文件隔离。
| 路径 | 当前命令或机制 | 能观察的部分 |
|---|---|---|
| server | tsx --test src/**/*.test.ts |
服务、数据库、解析和路由契约 |
| client | tsx --test client/test/*.test.ts |
API 封装、静态源码、语言与界面约定等 |
| electron | tsx --test electron/test/*.test.ts |
桌面状态、导航、更新与接线等 |
| audit-policy | node --test --test-isolation=none |
审计结果的判断策略 |
node:test 与 assert 是内置模块,但整个测试链还有 tsx、sql.js、Fastify 等依赖,因此不能称为“全套零依赖”。客户端源码断言也不等于真实浏览器交互测试。运行环境要求见项目的 Node 24 引擎设置。
测试金字塔可以帮助思考成本,但没有证据支持本项目必须让单元测试占某个固定百分比。优先覆盖数据破坏、状态转换和外部依赖失败,再根据回归记录补充跨边界检查。
从实际测试中找需要保留的反例
embedding.test.ts检查结构化文本字段和损坏 JSON,也检查 Vectra 暂存与已提交状态。不能只写“不抛异常”:应断言哪些内容留下、哪些没有进入索引文本。分段策略的 500 字符是软阈值,长单段仍可能超过它,见嵌入服务设计。
transaction.test.ts分别验证提交、回滚和嵌套 savepoint。事务函数名本身不能证明错误时不会留下半条数据。路由测试则用 Fastify inject注册路由并观察响应,无需监听端口,但这不覆盖真实网络、反向代理或 Electron 生命周期。
服务端的一些函数接受可替换依赖,便于绕过模型与真实索引;项目也有直接替换 console.log 或 globalThis.fetch 后在 finally 恢复的测试,例如 registry.test.ts与客户端 API 测试。所以“全部依赖都通过参数注入、从不替换全局”不是当前事实。隔离和恢复比给某种 mock 风格贴上绝对标签更重要。
编写一个完整的离线教学测试
在新目录安装固定依赖,保存下面的 lesson.test.mjs,用 Node 24+ 运行。四条测试使用教学函数、教学表和教学路由,不是 ChatCrystal 的实际服务。没有真实模型、个人数据库、文件导入或监听端口。
npm init -y
npm install --ignore-scripts --no-audit --no-fund sql.js@1.14.2 fastify@5.12.3
node --test lesson.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
import Fastify from 'fastify';
import initSqlJs from 'sql.js';
const normalize = (text) => text.trim().replace(/\s+/g, ' ');
async function summarizeIfAccepted(accepted, summarize) {
if (!accepted) return null;
return summarize();
}
test('normalization preserves content and handles whitespace', () => {
assert.equal(normalize(' first\nsecond '), 'first second');
assert.equal(normalize(''), '');
});
test('rejected input never reaches the model dependency', async () => {
const result = await summarizeIfAccepted(false, async () => {
throw new Error('unexpected model call');
});
assert.equal(result, null);
assert.equal(await summarizeIfAccepted(true, async () => 'fixture'), 'fixture');
});
test('a failed SQL transaction leaves no inserted row', async () => {
const SQL = await initSqlJs();
const db = new SQL.Database();
try {
db.run('CREATE TABLE items (value TEXT)');
assert.throws(() => {
db.run('BEGIN');
try {
db.run("INSERT INTO items VALUES ('temporary')");
throw new Error('fixture failure');
} catch (error) {
db.run('ROLLBACK');
throw error;
}
}, /fixture failure/);
assert.equal(db.exec('SELECT COUNT(*) FROM items')[0].values[0][0], 0);
} finally {
db.close();
}
});
test('inject checks one route without listening on a port', async () => {
const app = Fastify();
app.get('/lesson', async () => ({ success: true, data: ['fixture'] }));
try {
const response = await app.inject({ method: 'GET', url: '/lesson' });
assert.equal(response.statusCode, 200);
assert.deepEqual(response.json().data, ['fixture']);
assert.equal((await app.inject({ method: 'GET', url: '/missing' })).statusCode, 404);
} finally {
await app.close();
}
});
这个文件应得到四条通过。删除 ROLLBACK 后,失败事务会留下 temporary,零行断言必须变红;恢复后再通过。也可以让拒绝分支调用 summarize,检查意外依赖调用真的使测试失败。通过真实失败路径,才能确认断言检查的是目标行为。
这段最小 SQL 测试没有实现 ChatCrystal 的嵌套事务,也不验证磁盘持久化。要验证实际 withTransaction,应使用项目已有测试及额外故障样本。向量、文件和 SQL 跨存储一致性也不能由这个单库回滚结果推导。
环境与样本需要隔离,结束需要清理
一些模块在导入时读取 DATA_DIR。实际导入服务测试先设置临时目录再动态导入模块;tags 路由测试清理连接与临时目录。直接使用默认路径再 DELETE 表会越过测试隔离边界。
构造样本应显式写出 ID、时间和角色,避免依赖运行顺序。注册表、环境变量、全局 fetch 和计时器的修改都需要恢复;测试文件隔离与文件内部共享状态也不是同一件事。WASM 文件路径还要适应依赖安装布局,项目的 sql.test-helper.ts使用 require.resolve,而不是假设固定 node_modules 相对路径。
CI 绿灯的范围写清楚
发布工作流在构建前运行 pnpm test,但通过已有测试只能说明已覆盖样本没有失败,不能保证没有未知回归,也不能替代打包后的启动与交互验收。模型返回质量需要另设可复查样本,不能只把“mock 成功”写成模型效果良好。
本文只运行了独立教学四条测试及其反例,没有执行整个应用测试套件或产生测试耗时基准。对实际改动,应按受影响边界运行项目检查,并记录尚未覆盖的网络、文件、模型和桌面路径。