Cherry Studio 数据库测试指南:基于 setupTestDatabase 与生产迁移的 SQLite 主进程测试
【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
本文是一份面向 Cherry Studio 主进程 SQLite 数据层的实战测试指南,系统讲解统一测试装置(test harness)setupTestDatabase()的用法、生命周期机制、适用边界与常见反模式。读完本文,你将掌握如何为 Service、Handler、Seeder 与迁移代码编写真实读写 SQLite 的 Vitest 用例,理解它如何通过生产application.get('DbService').getDb()路径注入真实数据库,并学会规避 better-sqlite3 ABI、FTS5 触发器与并发等隐藏陷阱。
TL;DR:三行接入真实数据库
任何读写 SQLite 的服务(Service)、处理器(Handler)、种子数据器(Seeder)或迁移(Migration),都应使用来自@test-helpers/db的setupTestDatabase()。它会在 Vitest 生命周期内挂接一个真实、隔离、基于文件的 SQLite 数据库,并通过生产的application.get('DbService').getDb()路径暴露它。你不需要mock@application,不需要手写任何CREATE TABLESQL,也不需要再使用vi.mock('node:fs', importOriginal)这种逃生通道。
import { setupTestDatabase } from '@test-helpers/db' import { messageService } from '@data/services/MessageService' import { messageTable } from '@data/db/schemas/message' import { eq } from 'drizzle-orm' describe('MessageService', () => { const dbh = setupTestDatabase() it('persists a message', async () => { const msg = await messageService.create({ topicId: 't1', role: 'user', ... }) const [row] = dbh.db .select() .from(messageTable) .where(eq(messageTable.id, msg.id)) expect(row).toMatchObject({ role: 'user' }) }) })装置入口定义在 tests/helpers/db/testDatabase.ts,并经由 tests/helpers/db/index.ts 统一导出setupTestDatabase、TestDatabaseHandle、TestDatabaseOptions以及消息树辅助函数。
Harness 到底做了什么:六步初始化
在测试文件中的第一个用例执行前,harness 的beforeAll钩子会完成如下初始化(对应 testDatabase.ts 的实现):
- 创建唯一临时目录:通过
mkdtempSync(join(tmpdir(), 'cs-test-db-'))在os.tmpdir()下生成形如cs-test-db-xxxxxx的独立目录,保证并发与多文件之间互不干扰。 - 打开文件型 SQLite 并暴露原生连接:在
<tmp>/test.db位置用 better-sqlite3 打开数据库,同时构造 Drizzle 实例(drizzle({ client: sqlite, casing: 'snake_case' }))作为dbh.db,并把原生连接作为dbh.sqlite提供给需要绕过 Drizzle 直接执行 SQL/PRAGMA 的测试。 - 执行生产迁移:通过与
DbService.onInit完全相同的applyMigrations()函数运行 migrations/sqlite-drizzle/ 下的生产迁移文件,并执行项目中 Drizzle 无法管理的CUSTOM_SQL_STATEMENTS(FTS5 虚拟表、触发器)。迁移函数定义在 src/main/data/db/applyMigrations.ts,它是"实时库(DbService.onInit)、测试库(harness)、备份恢复管线(detached work.sqlite 前向迁移)"三方共享的唯一定点;其内部会临时关闭外键约束以规避 drizzle-orm migrator 在单事务内PRAGMA foreign_keys=OFF失效的问题,并在迁移后执行PRAGMA foreign_key_check检查悬挂引用,最后再执行CUSTOM_SQL_STATEMENTS。 - 设置持久 PRAGMA:单次设置
foreign_keys = ON与synchronous = NORMAL。better-sqlite3 在整个数据库生命周期内保持单一连接,因此这里设置的 PRAGMA 会持续生效,无需在每次测试前重放。 - 注入全局 DbService mock:调用
MockMainDbServiceUtils.setDb(db)与setIsReady(true),将真实数据库挂到全局 mock 的DbService单例上。任何调用application.get('DbService').getDb()的生产代码都会透明地命中测试库。 - 健全性断言:校验
PRAGMA integrity_check返回'ok'且foreign_keys为1,否则抛错使初始化失败("fail loudly"),而不是让后续测试在损坏状态下静默运行。
在每个测试前(beforeEach),harness 会调用truncateAll(db, sqlite)清空所有用户表数据,同时保留表结构与__drizzle_migrations迁移日志。FTS5 影子表(shadow table)的清理则依靠基表的AFTER DELETE触发器级联完成。truncateAll的实现细节在 tests/helpers/db/internal/truncate.ts:它先PRAGMA foreign_keys = OFF,从sqlite_master中筛选出非sqlite_%、非__drizzle%、非%_fts、非%_fts_%前缀的用户表逐一DELETE,在一个事务中顺带清空sqlite_sequence(若存在 AUTOINCREMENT 列),最后在finally中恢复外键约束。
整个文件跑完后(afterAll),关闭客户端连接、删除临时目录(best-effort,失败由操作系统回收 tmpdir),并调用MockMainDbServiceUtils.resetMocks()复位所有 mock 状态。
一个值得注意的细节:harness 通过模块级计数器activeHarnessCount检测嵌套调用,一旦在同一个 describe 树中重复调用会直接抛错——两个调用会互相覆盖MockMainDbServiceUtils.setDb(),导致外层作用域在内层afterAll之后指向过期的数据库。
句柄对象:db 与 sqlite 双通道
setupTestDatabase()返回的TestDatabaseHandle提供两个只读属性(见 testDatabase.ts):
db: DbType—— Drizzle 数据库实例,与生产DbService.getDb()返回类型一致,是绝大多数断言的入口;sqlite: Database.Database—— 同一个库上的原生 better-sqlite3 连接,是"逃生通道",用于执行 Drizzle 难以表达的原生 SQL/PRAGMA,例如sqlite.prepare(...).all()、sqlite.pragma('foreign_key_check')。
两个属性都是惰性 getter:如果在beforeAll之前访问会抛出明确错误,提示应在describe内调用、在it/beforeEach中访问。
何时使用、何时不要用
应当使用 harness 的场景
- 触及 SQLite 的 Service 测试(
MessageService、AssistantService等)。 - 真实数据库至关重要的 Handler 集成测试,例如
temporaryChats.integration.test.ts。 - Seeder 测试。
- 任何需要验证外键级联、FTS5、
RETURNING语义或事务行为的用例——恰恰是这些场景下 Drizzle 链式 mock 会失真。
不要使用 harness 的场景
- 纯逻辑测试:mapper、transformer、Zod schema、分页辅助函数等,不涉及数据库。
- 仅验证路由/接线形状的 Handler 测试:这类测试合法地 mock 下游 service,因为断言目标是调用形状而非数据库状态。
src/main/data/migration/v2/migrators/__tests__/*下的 migrator 测试:其 mock 上下文经过刻意建模,用于验证 migrator 的编排逻辑(阶段顺序、幂等性、源回退),真实数据库不会在 mock 已覆盖的断言上增加新价值。- 编排层 Service 测试(
KnowledgeService、McpService等 mock 了下游数据服务):它们验证的是协调逻辑,而非持久化。
选项:seeders
TestDatabaseOptions目前只有一个可选字段:
export interface TestDatabaseOptions { seeders?: ISeeder[] }seeders在 schema 初始化完成后立即执行,适用于少数依赖种子数据的 Service 测试(如ProviderRegistryService、preset 感知的流程)。示例:
import { PresetProviderSeeder } from '@data/db/seeding/seeders/presetProviderSeeder' setupTestDatabase({ seeders: [new PresetProviderSeeder()] })实现上,harness 通过new SeedRunner(db).runAll(options.seeders)执行种子数据(见 testDatabase.ts),与生产的种子执行路径保持一致。
迁移食谱:从旧式手写 setup 到统一 harness
移除遗留的vi.mock('@application', ...)覆盖
v2 重构前,常见的写法是手搓一个模块级realDb变量并 mock@application,再在beforeEach中手工createClient({ url: 'file::memory:' })+initializeTables。现在应替换为 harness:
- let realDb: DbType | null = null - - vi.mock('@application', () => ({ - application: { - get: vi.fn(() => ({ - getDb: vi.fn(() => realDb) - })) - } - })) - - const { MessageService } = await import('../MessageService') - - describe('MessageService', () => { - beforeEach(async () => { - const client = createClient({ url: 'file::memory:' }) - realDb = drizzle({ client, casing: 'snake_case' }) - await initializeTables(realDb) - }) - afterEach(() => { realDb = null }) - }) + import { setupTestDatabase } from '@test-helpers/db' + import { messageService } from '@data/services/MessageService' + + describe('MessageService', () => { + const dbh = setupTestDatabase() + // no manual setup — dbh.db is ready in every it() + })用状态断言替换 mock 链断言
旧风格测试经常构造一长串 mock 链去断言"某方法以某参数被调用";这既脆弱又看不见数据库真实反应。新风格改为直接执行服务再查询数据库:
- const values = vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([row]) }) - mockInsert.mockReturnValue({ values }) - - await service.create(dto) - - expect(values).toHaveBeenCalledWith({ - name: 'New Base', - embeddingModelId: 'embed-model', - ... - }) + const created = await service.create(dto) + + expect(created.name).toBe('New Base') + const [row] = dbh.db.select().from(knowledgeBaseTable) + expect(row.name).toBe('New Base') + expect(row.embeddingModelId).toBe('embed-model')新形式更强:它能捕获 mock 完全看不见的数据库侧约束改写——snake_case 列名映射、NOT NULL 默认值、CHECK 约束拒绝等。只要生产 schema 演进,这些约束就真实作用于测试数据。
反模式清单
使用 harness 时必须避免以下五种写法:
- 不要 mock
@application来覆盖DbService:全局 setup 已通过mockApplicationFactory()mock 了@application,harness 通过MockMainDbServiceUtils.setDb()注入真实库。测试局部的覆盖会破坏这条注入链路。 - 不要手写
CREATE TABLESQL:harness 运行的是真实迁移。手写 schema 会在生产 schema 演进时静默漂移;真实迁移则会在漂移时响亮地失败。 - 不要在 harness 作用域内使用
describe.concurrent/test.concurrent:MockMainDbServiceUtils.setDb()是每个测试文件级别的模块单例,并发兄弟测试会在该单例与beforeEach截断周期上竞争。 - 不要嵌套调用
setupTestDatabase():harness 会对嵌套调用抛出明确错误。把单个调用放在最外层需要数据库的 describe 顶部,或将嵌套 describe 拆成兄弟 describe。 - 不要重新添加
vi.mock('node:fs', importOriginal):全局 tests/main.setup.ts 已让node:fs、node:os、node:path保持真实实现(os.homedir()仍被 stub 为/mock/home)。如果确实需要 stub 特定 fs 方法(如固定fs.existsSync返回值),用vi.spyOn(fs, 'existsSync'),或在测试文件内声明局部vi.mock('node:fs', ...)并借助@test-helpers/mocks/nodeFsMock的createNodeFsMock辅助函数。
Gotchas:三个最容易踩的坑
better-sqlite3 原生模块 ABI
better-sqlite3 是原生模块,且不是 N-API——这意味着它是 ABI 相关的,必须为加载它的运行时单独编译。一个原生.node只有一个构建槽/一个 ABI,而应用(Electron)与测试(系统 Node)需要的 ABI 不同。
仓库的策略(对应 package.json 的 scripts):
- 测试运行在Node ABI:
pnpm install产出的就是 Node ABI,Vitest(运行在系统 Node 下)需要它。test:main通过pretest:main钩子先执行pnpm rebuild:node,test通过pretest钩子执行同样的命令,确保套件运行前一定是 Node ABI。 - Electron 应用入口脚本(
dev、dev:watch、debug、start)与打包需要Electron ABI:每个入口脚本都会前置执行pnpm rebuild:electron(electron-rebuild --force --only better-sqlite3,配合--force)。 - 因此,在应用模式与数据库测试之间切换时,
pretest/pretest:main与应用入口脚本会自动翻转 ABI。
如果你在pnpm dev之后立即使用交互式运行器(pnpm test:watch、pnpm test:coverage、裸vitest或 IDE 的 Vitest),请先手动pnpm rebuild:node(或完整跑一次pnpm test:main)切回 Node ABI——这些命令并非全部带有pre*钩子。CI 在系统 Node 下安装与测试,同样使用 Node ABI。
FTS5 与 NULL 内容
searchable_text由AFTER INSERT触发器从消息的data.parts(含文本的 part)填充;没有文本 part 的消息会得到空字符串searchable_text(触发器用COALESCE(…, '')包裹group_concat)。FTS5 的AFTER DELETE触发器随后用该值删除索引。这在截断场景下是安全的(truncate 可通过),但你的 FTS 断言必须考虑"空文本"这种可能性。
Truncate 而非 Drop
beforeEach截断用户表,不会drop 或重建表。需要物理 drop 表的测试(例如损坏回滚的回归测试)会破坏该文件中其后所有测试的 harness 状态——这类场景应隔离在专属测试文件中,避免共享 harness。
底层 Mock 系统速览
更完整的 mock 目录说明见 tests/mocks/README.md。harness 依赖的三个关键件:
@test-mocks/main/application——mockApplicationFactory()在 tests/main.setup.ts 中全局接入,提供类型安全的application.get()服务访问。@test-mocks/main/DbService—— 全局 mock 的MockMainDbServiceUtils(定义于 tests/mocks/main/DbService.ts)正是 harness 用来把生产查找路由到真实库的载体。该文件同时提供链式查询构建器 mock(.run()/.all()/.get()终端同步形态与 better-sqlite3 drizzle 方言对齐)、withWriteTx写事务 mock(挂接真实连接时委托给.transaction(),否则退化为普通 db stub)以及快照/checkpoint 相关 no-op spies。@test-helpers/mocks/nodeFsMock—— 需要在本地 stubnode:fs的测试的工厂函数(全局 setup 已不再 mock fs)。
配套工具:消息树辅助与 harness 自测
messageTree.ts 提供两个针对"虚拟根(virtual-root)"消息模型的辅助函数,专门用于编写对话型数据测试:
rootRow(topicId):生成话题的虚拟根哨兵行(parentId = null、role = 'root'、空data),使用确定性的vroot-<topicId>id 便于断言,镜像生产MessageService.createRootMessageTx的行为;withRoot(topicId, messages):把parentId === null的首条消息重新挂到虚拟根之下,让测试可以用自然的"第一轮对话"写法构造嵌套消息树。
harness 自身的正确性由 tests/helpers/db/tests/testDatabase.test.ts 覆盖,它依次验证:初始化后的foreign_keys = 1与integrity_check = 'ok'、topic 表初始为空、__drizzle_migrations日志保留、FTS5 虚拟表由CUSTOM_SQL_STATEMENTS创建、测试间数据隔离(前一个测试插入的行在下一个测试中被截断)、事务提交与事务后外键仍开启、AFTER INSERT触发器写入message_fts、truncateAll经AFTER DELETE触发器级联清空 FTS、无文本消息时 truncate 不抛错,以及application.get('DbService').getDb()与dbh.db指向同一实例。这套自测本身即是使用 harness 的完整范例,遇到不确定的写法时可以直接对照阅读。
小结
setupTestDatabase()把"真实迁移 + 真实 SQLite + 全局服务路由"三件事压缩进一行调用,让数据库相关测试从"mock 链脆弱断言"升级为"真实状态断言"。使用时记住四条主线:继承生产迁移(不手写 schema)、通过MockMainDbServiceUtils.setDb()路由生产代码(不局部 mock@application)、每个文件一个 harness(不嵌套、不并发)、跑测试前确认 Node ABI(pnpm rebuild:node)。遵循这些约定,你写出的测试将同时具备真实性与可维护性。
【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考