testing/runner/docs/snapshot-testing.md
This guide covers how to use snapshot testing in the test-runner to capture and validate SQL query execution plans.
Snapshot tests capture the output of EXPLAIN QUERY PLAN and EXPLAIN (bytecode) for SQL queries. They help detect:
Unlike regular tests that compare query results, snapshot tests validate that the way a query executes remains consistent.
Note: Snapshot tests currently only run on the Rust backend. Other backends (CLI, JS) skip snapshot tests automatically. This is because EXPLAIN output format can differ between backends.
@database :memory:
setup schema {
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
CREATE INDEX idx_users_name ON users(name);
}
@setup schema
snapshot my-query-plan {
SELECT * FROM users WHERE id = 1;
}
# First run creates .snap.new files for review
make -C test-runner run-cli
# Or directly:
cargo run --bin test-runner -- run tests/my-test.sqltest
# Review and accept all pending snapshots
cargo run --bin test-runner -- run tests/ --snapshot-mode=always
git add tests/snapshots/
git commit -m "Add query plan snapshots"
The --snapshot-mode flag controls how snapshots are updated:
| Mode | Description | Use Case |
|---|---|---|
auto | no in CI, new locally (default) | Normal development |
new | Write .snap.new files for review | Manual review before accepting |
always | Write directly to .snap files | Accept all changes |
no | Read-only, no files written | CI validation |
auto (default)
Automatically detects the environment:
nonewCI detection checks for environment variables: CI, GITHUB_ACTIONS, TRAVIS, CIRCLECI, GITLAB_CI, BUILDKITE, JENKINS_URL, TF_BUILD.
new
Creates .snap.new files alongside existing snapshots. This allows you to review changes before accepting them:
tests/snapshots/
my-test__query-plan.snap # existing
my-test__query-plan.snap.new # new/changed
Review the diff manually, then run with --snapshot-mode=always to accept.
always
Directly updates .snap files without creating .snap.new intermediates. Use this when you've reviewed the changes and want to accept them.
no
Read-only mode. No snapshot files are written. Tests will fail if:
This is the mode used in CI to ensure all snapshots are committed.
Snapshot files use YAML frontmatter followed by the captured output:
---
source: my-test.sqltest
expression: SELECT * FROM users WHERE id = 1;
info:
statement_type: SELECT
tables:
- users
setup_blocks:
- schema
database: ':memory:'
---
QUERY PLAN
`--SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
BYTECODE
addr opcode p1 p2 p3 p4 p5 comment
0 Init 0 8 0 0 Start at 8
1 OpenRead 0 2 0 k(3,B,B,B) 0 table=users, root=2, iDb=0
...
| Field | Description |
|---|---|
source | Test file name |
expression | The SQL query |
info.statement_type | Auto-detected: SELECT, INSERT, UPDATE, DELETE, etc. |
info.tables | Auto-extracted table names from the query |
info.setup_blocks | Setup blocks applied before the snapshot |
info.database | Database type used |
Snapshot files are stored in a snapshots/ directory adjacent to the test file:
tests/
queries.sqltest
aggregates.sqltest
snapshots/
queries__select-by-id.snap
queries__select-by-name.snap
aggregates__count-all.snap
Naming convention: {test-file-stem}__{snapshot-name}.snap
# Default mode (auto)
cargo run --bin test-runner -- run tests/
# Accept all snapshot changes
cargo run --bin test-runner -- run tests/ --snapshot-mode=always
# Review mode (create .snap.new files)
cargo run --bin test-runner -- run tests/ --snapshot-mode=new
# Read-only mode (CI)
cargo run --bin test-runner -- run tests/ --snapshot-mode=no
# Filter specific snapshots
cargo run --bin test-runner -- run tests/ --snapshot-filter="query-plan*"
cargo run --bin test-runner -- check tests/
This command:
.snap.new files# Run all tests including snapshots
make -C test-runner run-cli
# Run examples (includes snapshot examples)
make -C test-runner run-examples
# Check syntax and pending snapshots
make -C test-runner check
# .github/workflows/test.yml
- name: Run SQL tests
run: |
cargo run --bin test-runner -- run tests/ --snapshot-mode=no
- name: Check for pending snapshots
run: |
cargo run --bin test-runner -- check tests/
The check command will fail if any .snap.new files exist, ensuring all snapshot changes are committed.
.snap.new files)diff tests/snapshots/*.snap tests/snapshots/*.snap.new--snapshot-mode=always.snap files@database :memory:
snapshot query-plan {
SELECT * FROM users;
}
@database :memory:
setup schema {
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
}
setup data {
INSERT INTO users VALUES (1, 'Alice');
}
@setup schema
@setup data
snapshot query-plan-with-data {
SELECT * FROM users WHERE id = 1;
}
# Unconditional skip
@skip "query plan not stable yet"
snapshot unstable-plan {
SELECT * FROM complex_view;
}
# Conditional skip (MVCC mode)
@skip-if mvcc "different plan in MVCC mode"
snapshot standard-plan {
SELECT * FROM users;
}
Since snapshot tests only run on the Rust backend, the @backend decorator is typically not needed for snapshots. However, you can still use it to explicitly require the Rust backend:
# Explicitly require Rust backend (optional, since it's the only backend that runs snapshots)
@backend rust
snapshot turso-query-plan {
SELECT * FROM users WHERE id = 1;
}
# Snapshot requires trigger support
@requires trigger "query plan involves triggers"
@setup schema-with-triggers
snapshot trigger-query-plan {
INSERT INTO audit_log SELECT * FROM events;
}
Snapshots support all test decorators:
| Decorator | Description |
|---|---|
@setup <name> | Apply a setup block before the snapshot |
@skip "reason" | Skip this snapshot unconditionally |
@skip-if <cond> "reason" | Skip conditionally (e.g., mvcc, sqlite) |
@backend <name> | Only run on specified backend (snapshots only run on rust) |
@requires <cap> "reason" | Only run if backend supports capability |
File-level directives (@skip-file, @skip-file-if, @requires-file) also apply to snapshots.
Snapshots capture two sections:
The output of EXPLAIN QUERY PLAN, formatted as a tree:
QUERY PLAN
`--SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
For complex queries with subqueries or joins:
QUERY PLAN
|--SCAN users
`--SEARCH orders USING INDEX idx_orders_user (user_id=?)
The output of EXPLAIN, formatted as an aligned table:
BYTECODE
addr opcode p1 p2 p3 p4 p5 comment
0 Init 0 8 0 0 Start at 8
1 OpenRead 0 2 0 k(3,B,B,B) 0 table=users, root=2, iDb=0
2 SeekRowid 0 4 7 0 if (r[4]!=cursor 0...) goto 7
While the workflow is similar to cargo-insta, test-runner uses a custom snapshot implementation:
| Feature | test-runner | cargo-insta |
|---|---|---|
| File format | YAML frontmatter + content | YAML frontmatter + content |
| Review tool | Manual diff / --snapshot-mode | cargo insta review |
| CI mode | --snapshot-mode=no | --check |
| Accept all | --snapshot-mode=always | cargo insta accept |
| Metadata | SQL-specific (tables, statement type) | Generic |
.snap.new files--snapshot-mode=always.snap filesThe check command found .snap.new files. Either:
--snapshot-mode=alwaysrm tests/snapshots/*.snap.newQuery plans can vary based on:
Ensure your setup blocks create consistent schema and data.
Make sure you're using --snapshot-mode=always or --snapshot-mode=new. The default auto mode acts as no in CI environments.
Use descriptive snapshot names - query-plan-user-by-id is better than test1
Group related snapshots - Keep snapshots for similar functionality in the same test file
Include necessary setup - Snapshots need indexes and data to produce meaningful plans
Review before accepting - Don't blindly accept snapshot changes; understand why the plan changed
Commit snapshots with code changes - When changing query logic, update snapshots in the same commit
Use skip for unstable plans - If a plan varies between environments, use @skip until stabilized