Skip to content

Why SQL Unit Testing Is Hard and How rawsql-ts Solves It

Traditional Testing Patterns and Their Pain Points

Unit testing SQL logic is notoriously painful. A unit test should verify inputs and outputs — yet SQL queries depend on database state, not explicit inputs. Developers have tried several approaches to make SQL testable. Let’s examine them in order, from the most realistic to the most isolated.


1. Development Database Tests

This is the most common approach — running tests directly against a local or shared development database.

AspectDescriptionTrade-off
ExecutionRuns against an actual development or test databaseRequires setup, teardown, and isolation
VerificationTests query results under full schema fidelityDependent on mutable external state
MaintenanceMirrors production environmentSlow and fragile in CI environments

In short: these tests resemble integration tests more than true unit tests.


2. Query Builder & Mocked SQL Tests

To avoid the cost of a real database, many ORM or query-builder tests rely on mocking — verifying that a specific SQL string was generated.

AspectDescriptionTrade-off
VerificationChecks whether a given SQL string was generatedSensitive to harmless refactors and formatting changes
ScopeFocuses on SQL syntax rather than behaviorMisses logic-level correctness
MaintenanceFast and isolatedCan break on minor library or alias changes

Mock-based tests validate implementation details, not actual behavior. It’s like testing the transpiled code instead of running the function.


3. Repository Layer Mocks

This pattern mocks the repository layer — the class that encapsulates SQL access — in order to isolate upper layers (such as services or controllers) from database dependencies. It’s not designed to validate SQL logic itself, but rather to isolate upper layers from database dependencies. In other words, verifying SQL correctness is simply outside the purpose of this pattern.

AspectDescriptionTrade-off
PurposeIsolates service and controller logic from DB dependenciesNot intended for SQL correctness verification
UsageMocks repository results to test upper-layer reactionsMisleading if mistaken for SQL testing
ValueEnables fast and deterministic service testsProvides no insight into query behavior

Put simply: repository mocks are tools for layer isolation, rather than for SQL testing.


4. rawsql-ts: The Middle Ground

rawsql-ts bridges these three worlds.

AspectDescriptionTrade-off
ExecutionRuns real SQL logic on an in-memory engineSlightly more setup than pure mocks
VerificationValidates behavior and semantics instead of syntaxDoesn’t emulate full production engine features
MaintenanceLightweight, schema-free, and CI-friendlyFocused on logical correctness rather than performance

It executes SQL directly in-memory, verifying the behavior of SQL code without tying tests to formatting or infrastructure. This balance makes it ideal for true SQL unit testing — realistic, portable, and CI-friendly.


5. Summary

ApproachValidatesSpeedMaintenanceRealism
Development DBActual resultsSlowHigh✅ Full
Query Builder / MockSQL stringFastLow❌ Low
Repository Layer MockApplication logicFastLow❌ None
rawsql-tsQuery behaviorFastLow✅ High

In practice, many teams start with development DB tests, then adopt builder mocks for speed — but both miss the sweet spot. rawsql-ts exists to deliver that missing middle ground: behavior-level SQL testing without the database baggage.


The RawSQL Approach

rawsql-ts flips this paradigm.

Instead of seeding a physical database, it rewrites intercepted SELECT statements into fixture-backed CTEs (Common Table Expressions).
Tests define fixture rows inline, effectively turning database state into explicit input.

sql
WITH "users" AS (
  SELECT 1 AS id, 'Alice' AS name, 'admin' AS role
)
SELECT * FROM users WHERE role = 'admin';

This makes each test a pure function: given certain rows (input), you get deterministic results (output).
No file I/O, no schema migration, no cleanup required.


Conceptually, It's Just Dependency Injection for Databases

The same principle as DI in code applies here:

  • Traditional tests: depend on a real DB connection (hard dependency)
  • rawsql-ts: injects a "fixture-backed driver" (soft dependency)

This lets repositories stay unmodified while transparently swapping in a different driver for testing.


Scope & Limitations

Why Only SELECT Is Supported

rawsql-ts focuses exclusively on read queries (SELECT statements).
There are three main reasons:

  1. Single-table CUD operations are trivial.
    Most INSERT, UPDATE, and DELETE queries are thin wrappers around ORM or driver APIs, so their correctness depends more on those layers than on SQL itself. Testing them in isolation adds little value.

  2. The real logic hides in R (Read) queries.
    SELECT statements encode joins, filters, computed columns, and aggregation rules - the exact areas that need deterministic verification.

  3. Modification queries stem from prior selections. Any INSERT, UPDATE, or DELETE can be represented as a selection that decides which rows and which values change. Validating the read logic often implies correctness in downstream write logic.

This equivalence is not absolute - triggers, side effects, or constraints can diverge, but those concerns are implementation details, not logical ones. Focusing on SELECT keeps the validation surface tight while maximizing correctness leverage.

What rawsql-ts Deliberately Avoids

rawsql-ts targets logical correctness, not physical performance characteristics.

Out of scope:

  • Index usage
  • Execution plans
  • Locking / concurrency
  • Vendor-specific optimizer behavior

These aspects belong to integration / performance testing against a real database, where the physical layer can be observed directly.


AI Era: Why This Approach Matters Now

As SQL testing becomes more automated and data-driven, the testing landscape itself is evolving. AI-generated SQL now outpaces manual review cycles, so teams need deterministic, fixture-driven tests that treat data as explicit inputs and emit diagnostics that LLMs and humans can act on.

Theoretically, this testing style has always been possible:
you could intercept SQL and rewrite it with WITH ... VALUES manually.
So why is it becoming relevant only now?

The short answer: AI.

AI and the Need for Reliable Unit Tests

As AI-generated code grows in volume and complexity,
human review alone cannot guarantee correctness.
Unit tests serve as the safety net - ensuring that generated logic behaves as expected.

Expanding the domain where unit tests are possible has become strategically important.
For SQL-heavy systems, rawsql-ts opens a previously untestable area to that safety net.

Why It Wasn't Done Before

The obstacle was data preparation.
Creating realistic test data for SQL queries has always been cumbersome -
seeding tables, maintaining schemas, synchronizing fixtures.
Most developers accepted the friction and skipped SQL-level unit tests altogether.

Now, with structured fixtures and schema registries, plus AI-assisted code generation,
this barrier is gone.
AI can easily produce fixture objects - they're just structured function inputs.

The remaining challenge is SQL comprehension:
LLMs can misinterpret complex SQL or generate inaccurate mock data.
That's where rawsql-ts steps in: by providing deterministic parsing,
reliable fixture validation, and actionable diagnostics.

Example diagnostic output:

Fixture for table "users" was not provided.

Diagnostics:
  - Strategy: error
  - Table: users
  - SQL snippet: SELECT * FROM users
  - Required columns (schema registry):
      - id (INTEGER)
      - name (TEXT)
      - role (TEXT)
  - Suggested fixture template:
      {
        tableName: 'users',
        schema: {
          columns: {
            id: 'INTEGER',
            name: 'TEXT',
            role: 'TEXT'
          }
        },
        rows: [
          { id: /* INTEGER */, name: /* TEXT */, role: /* TEXT */ }
        ],
      }

Next steps:
  1. Declare a fixture for the table with the columns listed above.
  2. Provide at least one row so rewritten SELECT statements shadow the physical table.
  3. Pass fixtures via SelectRewriterOptions.fixtures or rewrite context overrides.

Such diagnostics make the system self-descriptive,
allowing AI or developers to iterate rapidly without deep SQL expertise.


Comparison: Before vs After

AspectTraditional Dev DB Testsrawsql-ts Fixture Tests
Environment setupRequires schema + seedInline fixtures
SpeedSeconds to minutesMilliseconds
IsolationShared DB, risk of pollutionFully isolated per test
MaintenanceHeavyLight
AccuracyCan drift from productionEnsured via schema registry
Query validationManualAutomatic (AST + CTE rewrite)

In Summary

Each testing approach has value - mock-based tests remain ideal for upper layers,
while rawsql-ts targets repository-level correctness with minimal friction.

Traditional SQL tests validate how the query was issued.
rawsql-ts validates what the query means.

By elevating fixtures to explicit inputs and treating queries as pure functions,
rawsql-ts brings SQL unit testing back to its true goal - verifying logic, not the infrastructure plumbing.

Learn More

Next Steps

  • Apply the testing model in the SQLite Testkit HowTo.
  • Explore the workspace demos under packages/drivers/sqlite-testkit/demo to see the fixture driver in practice.

Released under the MIT License.