How to test query code without a warehouse¶
Run your queries against an in-memory DuckDB semantic view instead of a live
warehouse. DuckDB executes the SQL your query builder generates, so tests see
real aggregation and filtering, and your application code calls
Model.query().execute() exactly as it does in production.
This page covers the testing fixture. To connect an application to a DuckDB database as a backend, see How to connect to DuckDB. For how engines and the registry work, see How to connect an engine to your warehouse.
Install the DuckDB extra¶
uv add "semolina[duckdb]"
# or
pip install "semolina[duckdb]"
Set up an in-memory engine fixture¶
Build a DuckDB engine backed by ":memory:", then create the table, semantic
view, and seed rows on each new connection. DuckDB isolates in-memory databases
per physical connection, so the setup runs on a connect event rather than
once up front. The engine owns its pool; reach it as engine._pool to attach
the seed listener and to close it in teardown:
import pytest
from adbc_poolhouse import DuckDBConfig, close_pool
from sqlalchemy import event
from semolina import (
Dimension,
Metric,
SemanticView,
register,
unregister,
create_engine,
)
class Sales(SemanticView, view="sales_view"):
revenue = Metric()
country = Dimension()
def _seed(dbapi_conn, _record):
cur = dbapi_conn.cursor()
cur.execute("INSTALL semantic_views FROM community")
cur.execute("LOAD semantic_views")
cur.execute(
"CREATE TABLE sales_data (id INTEGER, revenue INTEGER, country VARCHAR)"
)
cur.execute(
"INSERT INTO sales_data VALUES (1, 1000, 'US'), (2, 2000, 'CA'), (3, 500, 'US')"
)
cur.execute("""
CREATE OR REPLACE SEMANTIC VIEW sales_view AS
TABLES (s AS sales_data PRIMARY KEY (id))
DIMENSIONS (s.country AS country)
METRICS (s.revenue AS SUM(s.revenue))
""")
cur.close()
dbapi_conn.commit()
@pytest.fixture
def sales_engine():
engine = create_engine(
DuckDBConfig(database=":memory:", pool_size=1)
)
event.listen(engine._pool, "connect", _seed)
register("default", engine)
yield
unregister("default")
close_pool(engine._pool)
The commit() after CREATE SEMANTIC VIEW matters: ADBC connections open
with autocommit=False, and the semantic_views extension resolves the
view on a separate read connection that only sees committed state. See
How to connect to DuckDB for more on the extension.
Write a test¶
Query your model the same way your application does. DuckDB aggregates the
metric, so US returns 1500 (1000 + 500):
def test_revenue_by_country(sales_engine):
cursor = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.execute()
)
rows = {
row.country: row.revenue
for row in cursor.fetchall_rows()
}
cursor.close()
assert rows == {"US": 1500, "CA": 2000}
Because the SQL actually runs, .where() filters return only matching rows:
def test_filtered_query(sales_engine):
cursor = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.where(Sales.country == "US")
.execute()
)
rows = cursor.fetchall_rows()
cursor.close()
assert len(rows) == 1
assert rows[0].country == "US"
assert rows[0].revenue == 1500
Inspect generated SQL¶
Use .to_sql() to check the SQL a query produces without executing it. It
defaults to the Snowflake dialect (AGG(), double-quoted identifiers folded
to upper case):
def test_sql_generation():
sql = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.where(Sales.country == "US")
.to_sql()
)
assert 'AGG("REVENUE")' in sql
assert '"COUNTRY"' in sql
Pass a dialect to preview another backend, for example
.to_sql(dialect="databricks"). Use .to_sql() for structural assertions
on the generated SQL, and the DuckDB fixture above for behavior.
Record your warehouse with pytest-adbc-replay¶
DuckDB runs the SQL, but it is not your warehouse: its results can differ from Snowflake or Databricks in numeric type and precision, and in how your semantic view resolves. When you want tests to match what your warehouse actually returns, record the real responses once with pytest-adbc-replay and replay them from disk on every later run.
The plugin wraps the ADBC connection your engine’s pool hands out. A credentialed run captures each query and its Arrow result into a cassette; after that, tests read the cassette and reach no warehouse.
Install it as a dev dependency:
uv add --dev pytest-adbc-replay
Point adbc_auto_patch at the driver module your engine connects through, and
set adbc_dialect so recorded SQL is matched correctly on replay:
[tool.pytest.ini_options]
adbc_auto_patch = ["adbc_driver_snowflake.dbapi"]
adbc_dialect = ["adbc_driver_snowflake.dbapi: snowflake"]
[tool.pytest.ini_options]
adbc_auto_patch = ["adbc_driver_manager.dbapi"]
adbc_dialect = ["adbc_driver_manager.dbapi: databricks"]
adbc-poolhouse connects to Databricks through the ADBC driver manager, so
the module to patch is adbc_driver_manager.dbapi rather than a
Databricks-specific one.
Register your real engine, then mark the test with adbc_cassette so the
plugin records or replays its connections:
import pytest
@pytest.mark.adbc_cassette
def test_revenue_by_country(sales_engine):
cursor = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.execute()
)
rows = {
row.country: row.revenue
for row in cursor.fetchall_rows()
}
cursor.close()
assert rows == {"US": 1500, "CA": 2000}
Record once against the real warehouse, then replay with no credentials:
# Record: reads warehouse credentials from your environment
pytest --adbc-record=once
# Replay (the default): reads cassettes, reaches no warehouse
pytest
Commit the cassette files next to your tests. They are matched by normalised SQL, so they only need re-recording when the query your code generates changes.
Clean up between tests¶
Call unregister() in teardown so a registration does not leak
into the next test. The fixtures above do this on the far side of their
yield.
See also¶
How to connect to DuckDB – connect to a DuckDB database and the
semantic_viewsextensionHow to choose and configure a backend – register real engines for Snowflake and Databricks
How to build queries – the full query API
pytest-adbc-replay – record and replay ADBC responses