mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-08 07:41:36 +08:00
New guide at docs/references/testing/database-testing.md describing:
- When to use setupTestDatabase() (and when not to).
- Options, lifecycle behaviour, and PRAGMA handling.
- Migration recipe (before/after diffs) for converting legacy tests.
- Anti-patterns: vi.mock('@application') override, hand-written
CREATE TABLE SQL, describe.concurrent within harness scope,
nested setupTestDatabase() calls, re-adding the
vi.mock('node:fs', importOriginal) escape hatch.
- Gotchas: LibSQL transaction connection recycle + setPragma replay,
pathToFileURL for Windows, FTS5 with NULL content, truncate-vs-drop.
Linked from CLAUDE.md's Testing Guidelines section so future
contributors find the convention.
247 lines
9.1 KiB
Markdown
247 lines
9.1 KiB
Markdown
# Database Testing Guide
|
|
|
|
This guide covers how to write tests that exercise the SQLite data layer in the
|
|
main process. It documents the unified test harness introduced alongside the
|
|
v2 refactor and the idioms that replace the older hand-rolled setups.
|
|
|
|
## TL;DR
|
|
|
|
For any service, handler, seeder, or migration that reads or writes SQLite,
|
|
use `setupTestDatabase()` from `@test-helpers/db`. It wires a real, isolated,
|
|
file-backed SQLite database into Vitest's lifecycle and exposes it through
|
|
the production `application.get('DbService').getDb()` path. You do not need
|
|
to mock `@application`, nor write any `CREATE TABLE` SQL, nor reach for the
|
|
`vi.mock('node:fs', importOriginal)` escape hatch.
|
|
|
|
```typescript
|
|
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] = await dbh.db
|
|
.select()
|
|
.from(messageTable)
|
|
.where(eq(messageTable.id, msg.id))
|
|
expect(row).toMatchObject({ role: 'user' })
|
|
})
|
|
})
|
|
```
|
|
|
|
## What the Harness Does
|
|
|
|
On the first test in a file the harness:
|
|
|
|
1. Creates a unique temporary directory under `os.tmpdir()`.
|
|
2. Opens a LibSQL file-backed database at `file://<tmp>/test.db` using
|
|
`pathToFileURL` (safe on Windows too).
|
|
3. Runs the production migrations (`migrations/sqlite-drizzle/`) and the
|
|
project's `CUSTOM_SQL_STATEMENTS` (FTS5 virtual tables, triggers). The
|
|
resulting schema is byte-for-byte identical to what the real app sees
|
|
after `DbService.onInit`.
|
|
4. Registers durable PRAGMAs (`foreign_keys = ON`, `synchronous = NORMAL`)
|
|
via the patched `@libsql/client` `setPragma()` so they survive the
|
|
transaction-induced connection recycle. See the "Gotchas" section.
|
|
5. Swaps the globally-mocked `DbService` to hand out the real database
|
|
via `MockMainDbServiceUtils.setDb()`. Any production code that calls
|
|
`application.get('DbService').getDb()` now transparently hits the test DB.
|
|
6. Asserts `PRAGMA integrity_check = 'ok'` and `PRAGMA foreign_keys = 1`.
|
|
|
|
Before every test it truncates all user tables (keeping schema and the
|
|
`__drizzle_migrations` journal intact). FTS5 shadow tables clear through
|
|
the base-table `AFTER DELETE` trigger cascade.
|
|
|
|
After the whole file runs it closes the client, removes the tmpdir, and
|
|
resets the mocks.
|
|
|
|
## When to Use the Harness
|
|
|
|
### Do use it for
|
|
|
|
- Service tests that touch SQLite (`MessageService`, `AssistantService`, …).
|
|
- Handler integration tests where the real DB matters (e.g. `temporaryChats.integration.test.ts`).
|
|
- Seeder tests.
|
|
- Anything that exercises FK cascades, FTS5, `RETURNING` semantics, or
|
|
transactions — because those are exactly where Drizzle-chain mocks lie.
|
|
|
|
### Do NOT use it for
|
|
|
|
- Pure logic tests (mappers, transformers, Zod schemas, pagination helpers).
|
|
- Handler tests that only verify wiring/routing — these legitimately mock
|
|
the downstream service because the assertion is about the call shape,
|
|
not the DB state.
|
|
- LibSQL client-level contract tests (`pragmaReplay.test.ts`) — they need
|
|
direct control over the underlying client.
|
|
- Migrator tests under `src/main/data/migration/v2/migrators/__tests__/*` —
|
|
their mock context has been deliberately modelled to verify the
|
|
migrator's orchestration logic (phase ordering, idempotency, source
|
|
fallbacks). A real DB would not add coverage over what the mock
|
|
already asserts.
|
|
- Orchestration-layer service tests that mock their downstream data
|
|
service (`KnowledgeRuntimeService`, `KnowledgeOrchestrationService`,
|
|
`MCPService`) — they test coordination, not persistence.
|
|
|
|
## Options
|
|
|
|
```typescript
|
|
export interface TestDatabaseOptions {
|
|
seeders?: ISeeder[]
|
|
}
|
|
```
|
|
|
|
- `seeders`: run these after schema init. Useful for the small set of
|
|
service tests that depend on seeded data (`ProviderRegistryService`,
|
|
preset-aware flows).
|
|
|
|
```typescript
|
|
setupTestDatabase({ seeders: [presetProviderSeeder] })
|
|
```
|
|
|
|
## Migration Recipes
|
|
|
|
### Removing a legacy `vi.mock('@application', ...)` override
|
|
|
|
```diff
|
|
- 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()
|
|
+ })
|
|
```
|
|
|
|
### Replacing mock-chain assertions with state assertions
|
|
|
|
```diff
|
|
- 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] = await dbh.db.select().from(knowledgeBaseTable)
|
|
+ expect(row.name).toBe('New Base')
|
|
+ expect(row.embeddingModelId).toBe('embed-model')
|
|
```
|
|
|
|
The new form is stronger: it catches DB-side constraint rewrites
|
|
(snake_case column naming, NOT NULL defaults, CHECK rejections) that the
|
|
mock could not see.
|
|
|
|
## Anti-Patterns
|
|
|
|
Avoid all of the following when you are using the harness.
|
|
|
|
### Do NOT mock `@application` to override `DbService`
|
|
|
|
The global setup already mocks `@application` via `mockApplicationFactory()`,
|
|
and the harness wires the real DB through `MockMainDbServiceUtils.setDb()`.
|
|
A test-local override would trample that wiring.
|
|
|
|
### Do NOT hand-write `CREATE TABLE` SQL in tests
|
|
|
|
The harness runs real migrations. Hand-written schemas drift silently when
|
|
the production schema evolves; real migrations fail loudly on drift.
|
|
|
|
### Do NOT use `describe.concurrent` / `test.concurrent` within a harness scope
|
|
|
|
`MockMainDbServiceUtils.setDb()` is a module-level singleton per test file.
|
|
Running sibling tests concurrently would race on that singleton and the
|
|
`beforeEach` truncate cycle.
|
|
|
|
### Do NOT nest `setupTestDatabase()` calls
|
|
|
|
The harness refuses nested setup with a clear error. Place a single call
|
|
at the top of the outermost describe that needs a DB, or split nested
|
|
describes into sibling describes.
|
|
|
|
### Do NOT re-add `vi.mock('node:fs', importOriginal)` in test files
|
|
|
|
The global `tests/main.setup.ts` keeps `node:fs`, `node:os`, and
|
|
`node:path` real now. You don't need to undo a mock that doesn't exist.
|
|
If your test genuinely needs to stub a specific fs method (e.g.
|
|
`fs.existsSync` returning a fixed value), use `vi.spyOn(fs, 'existsSync')`
|
|
or declare a local `vi.mock('node:fs', ...)` with the
|
|
`createNodeFsMock` helper from `@test-helpers/mocks/nodeFsMock`.
|
|
|
|
## Gotchas
|
|
|
|
### LibSQL transaction connection recycle
|
|
|
|
`@libsql/client`'s `transaction()` releases the current connection and
|
|
lazily creates a new one on the next operation. Without the project's
|
|
patched `setPragma()` replay mechanism, per-connection PRAGMAs (like
|
|
`foreign_keys = ON`) would silently revert after every transaction.
|
|
|
|
The harness correctly uses `setPragma()` to register durable settings
|
|
(replayed on every reconnect), but uses one-shot `client.execute()` for
|
|
transient settings (like temporarily toggling FK off during truncate) —
|
|
otherwise the replay array would grow linearly with truncate cycles.
|
|
|
|
### `file:` URL on Windows
|
|
|
|
A naive `'file:' + path.join(tmpdir(), 'test.db')` produces an illegal URL
|
|
on Windows (`file:C:\path\to\db`). The harness uses
|
|
`pathToFileURL(dbPath).href` which yields `file:///C:/path/to/db`.
|
|
|
|
### FTS5 and NULL content
|
|
|
|
`searchable_text` is populated by the `AFTER INSERT` trigger from the
|
|
message's `data.blocks`; messages without any `main_text` block end up
|
|
with NULL `searchable_text`. The FTS5 `AFTER DELETE` trigger then deletes
|
|
using the NULL value. This is safe — truncate passes — but your FTS
|
|
assertions must account for the possibility.
|
|
|
|
### Truncate vs drop
|
|
|
|
`beforeEach` truncates user tables; it does not drop or recreate them.
|
|
Tests that need to physically drop a table (e.g. rollback-on-corruption
|
|
regression tests) will corrupt the harness for every subsequent test in
|
|
the file. Keep those scenarios confined to their own dedicated file and
|
|
avoid sharing the harness.
|
|
|
|
## The Mock System
|
|
|
|
See [`tests/__mocks__/README.md`](../../../tests/__mocks__/README.md) for
|
|
the broader mock catalogue. Key pieces the harness relies on:
|
|
|
|
- `@test-mocks/main/application` — `mockApplicationFactory()` is wired
|
|
globally in `tests/main.setup.ts`.
|
|
- `@test-mocks/main/DbService` — the global mock's `MockMainDbServiceUtils`
|
|
is what the harness mutates to route production lookups to the real DB.
|
|
- `@test-helpers/mocks/nodeFsMock` — factory for tests that need to stub
|
|
`node:fs` locally (the global setup no longer does this).
|