跳到正文
曾亮懿 ZengLiangYi Blog

测试策略:用反例验证纯函数、数据库与 API 边界

核对 ChatCrystal 的 Node 测试入口,用离线样本练习依赖隔离、SQL 回滚和 Fastify inject,并明确这些测试没有覆盖什么。

曾亮懿

曾亮懿

阅读约 4 分钟
目录 展开 收起

测试价值来自能识别具体回归,而不是绿灯数量。对话知识库跨越文件解析、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 成功”写成模型效果良好。

本文只运行了独立教学四条测试及其反例,没有执行整个应用测试套件或产生测试耗时基准。对实际改动,应按受影响边界运行项目检查,并记录尚未覆盖的网络、文件、模型和桌面路径。

分享:

相关文章

五种 AI 对话解析器:字段保留与损失边界

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵,检查工具标记、思考文本、顺序与快照覆盖范围。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

AI SDK Provider 抽象:能力表与协议边界

核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出,用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #TypeScript

SourceAdapter 内部链路:身份、内容哈希与重新导入

对照 ChatCrystal 的注册表、导入器和远端载荷,验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录,欢迎交流实现细节。