---
title: 测试策略：用反例验证纯函数、数据库与 API 边界
description: 核对 ChatCrystal 的 Node 测试入口，用离线样本练习依赖隔离、SQL 回滚和 Fastify inject，并明确这些测试没有覆盖什么。
image: "https://zengliangyi.com/og/blog/testing-strategy.png"
---

测试价值来自能识别具体回归，而不是绿灯数量。对话知识库跨越文件解析、SQL、向量索引、HTTP 和模型调用，应该按失败边界安排样本。本文核对 ChatCrystal 的真实测试入口，再用一个独立教学文件练习纯函数、依赖隔离、回滚和路由检查。

## 当前测试入口有四条路径

[根脚本](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/package.json)的 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](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/embedding.test.ts)检查结构化文本字段和损坏 JSON，也检查 Vectra 暂存与已提交状态。不能只写“不抛异常”：应断言哪些内容留下、哪些没有进入索引文本。分段策略的 500 字符是软阈值，长单段仍可能超过它，见[嵌入服务设计](/blog/embedding-service-design/)。

[transaction.test.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/db/transaction.test.ts)分别验证提交、回滚和嵌套 savepoint。事务函数名本身不能证明错误时不会留下半条数据。路由测试则用 [Fastify inject](https://fastify.dev/docs/latest/Guides/Testing/)注册路由并观察响应，无需监听端口，但这不覆盖真实网络、反向代理或 Electron 生命周期。

服务端的一些函数接受可替换依赖，便于绕过模型与真实索引；项目也有直接替换 console.log 或 globalThis.fetch 后在 finally 恢复的测试，例如 [registry.test.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/registry.test.ts)与[客户端 API 测试](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/client/test/api.test.ts)。所以“全部依赖都通过参数注入、从不替换全局”不是当前事实。隔离和恢复比给某种 mock 风格贴上绝对标签更重要。

## 编写一个完整的离线教学测试

在新目录安装固定依赖，保存下面的 lesson.test.mjs，用 Node 24+ 运行。四条测试使用**教学函数、教学表和教学路由，不是 ChatCrystal 的实际服务**。没有真实模型、个人数据库、文件导入或监听端口。



```sh
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
```





```js
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  \n  second  '  ),   '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。实际[导入服务测试](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/import.test.ts)先设置临时目录再动态导入模块；[tags 路由测试](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/routes/tags.test.ts)清理连接与临时目录。直接使用默认路径再 DELETE 表会越过测试隔离边界。

构造样本应显式写出 ID、时间和角色，避免依赖运行顺序。注册表、环境变量、全局 fetch 和计时器的修改都需要恢复；测试文件隔离与文件内部共享状态也不是同一件事。WASM 文件路径还要适应依赖安装布局，项目的 [sql.test-helper.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/test-utils/sql.test-helper.ts)使用 require.resolve，而不是假设固定 node_modules 相对路径。

## CI 绿灯的范围写清楚

发布工作流在构建前运行 pnpm test，但通过已有测试只能说明已覆盖样本没有失败，不能保证没有未知回归，也不能替代打包后的启动与交互验收。模型返回质量需要另设可复查样本，不能只把“mock 成功”写成模型效果良好。

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

分享：

##  相关文章

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

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵，检查工具标记、思考文本、顺序与快照覆盖范围。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/ai-conversation-parser-boundaries)    [##  AI SDK Provider 抽象：能力表与协议边界

核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出，用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#TypeScript](/blog/ai-sdk-provider-abstraction)    [##  SourceAdapter 内部链路：身份、内容哈希与重新导入

对照 ChatCrystal 的注册表、导入器和远端载荷，验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/source-adapter-internals)

##  从文章到代码

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

[访问我的 GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "测试策略：用反例验证纯函数、数据库与 API 边界",
  "description": "核对 ChatCrystal 的 Node 测试入口，用离线样本练习依赖隔离、SQL 回滚和 Fastify inject，并明确这些测试没有覆盖什么。",
  "url": "https://zengliangyi.com/blog/testing-strategy/",
  "image": "https://zengliangyi.com/images/blog/testing-strategy.svg",
  "datePublished": "2026-10-06T00:00:00.000Z",
  "dateModified": "2026-10-06T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/testing-strategy/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "测试策略：用反例验证纯函数、数据库与 API 边界",
      "item": "https://zengliangyi.com/blog/testing-strategy/"
    }
  ]
}
```
