Unit Tests¶
Assertions and contracts check the data that is currently in the warehouse. A unit test checks the SQL itself: you give a model fixed input rows and declare the rows it should produce.
Each test runs on a throwaway in-memory DuckDB database with your project's macros registered. Nothing is read from the warehouse and nothing is written to it, so a unit test cannot pass because of what happens to be built, and it works on a clone that has never run a pipeline.
havn test # run every unit test
havn test --model silver.customers
havn test -v # show the rows that differ
havn check # validation + assertions + contracts + unit tests
File format¶
Unit tests live in tests/unit/*.yml (or .yaml) inside your project.
havn init scaffolds one worked example there.
# tests/unit/customers.yml
model: silver.customers
tests:
- name: counts orders per customer
description: Customers with no orders must still appear, with a zero count.
given:
bronze.customers:
columns: {customer_id: INTEGER, name: VARCHAR}
rows:
- [1, "Ann"] # list form: columns order
- {customer_id: 2, name: "Bo"} # mapping form: by name
bronze.orders:
rows:
- {order_id: 1, customer_id: 1, amount: 5.0}
expect:
rows:
- {customer_id: 1, name: "Ann", order_count: 1}
- {customer_id: 2, name: "Bo", order_count: 0}
ordered: false # the default
| Key | Meaning |
|---|---|
model |
The model under test, schema.name. Can be set per test instead of per file. |
tests[].name |
Free text, unique within the model. |
tests[].description |
Optional. Say why the case matters, not what it does. |
given |
One entry per upstream table. Every upstream must be here. |
given.<table>.columns |
Optional column name to DuckDB type mapping. Recommended. |
given.<table>.rows |
Lists (positional) or mappings (by name). |
expect.rows |
The rows the model must produce. |
expect.columns |
Required only when expect.rows uses the list form. |
expect.ordered |
true compares row N to row N. Default false. |
Quoting parameterized types
In YAML flow style, {amount: DECIMAL(10,2)} splits on the comma. Write
amount: "DECIMAL(10,2)", or use block style.
CSV fixtures¶
Wide tables read better as CSV, the same as in dbt. Use format: csv with a
csv: block, on given entries and on expect alike:
given:
bronze.orders:
columns: {order_id: INTEGER, customer_id: INTEGER, amount: DOUBLE}
format: csv
csv: |
order_id,customer_id,amount
1,1,5.0
2,1,7.5
expect:
format: csv
csv: |
customer_id,total
1,12.5
An empty field is NULL. Fields that look like numbers become numbers; everything else stays a string and is cast to the column's type on insert.
How a test runs¶
- A fresh
:memory:DuckDB connection is opened and yourmacros/are registered on it, so Python macros work exactly as they do in a build. The connection is then locked down:enable_external_accessis turned off and the configuration is locked, so nothing that runs on it afterwards can reach the filesystem or the network. - One
TEMP TABLEis created pergivenentry. - Every reference to a mocked table in the model's SQL is rewritten to point at its mock, preserving aliases and leaving CTE names alone.
- The rewritten query runs into a temp table and is compared to
expect.
Column types¶
Types come from the first of these that is available:
- the
columns:block on the mock, - the live warehouse catalog, when a warehouse exists (the CLI, API and MCP all pass one in),
- the Python types of the fixture values (
1is BIGINT,5.0is DOUBLE,"x"is VARCHAR, a bare date is DATE).
Declaring columns: is worth the lines. It pins the fixture to the shape you
mean, and it keeps the test reproducible on a machine where the warehouse has
not been built.
Comparison¶
Rows are compared by hashing every column as text, the same comparator
havn diff uses. That means DECIMAL versus DOUBLE, or a SUM coming back
wider than you wrote in the fixture, cannot fail a test on their own:
expected rows are cast into the model's own output types before hashing.
Comparison is a multiset by default, so order does not matter but duplicates
do: two identical output rows need two identical expected rows. Set
ordered: true to compare positionally, which is what you want for a model
whose ORDER BY is part of its contract.
A failure reports three things: rows that were expected but not produced, rows that were produced but not expected, and column-set mismatches.
Warehouse-free, on purpose¶
If a model reads an upstream that has no given entry, the test is an
error, not a silent read of the real table:
error no mock for upstream bronze.orders: every upstream must be mocked,
unit tests never read the warehouse
This is the property that makes the result mean something. dbt's local unit tests still need the direct upstream models to exist in the warehouse so the schema can be fetched; havn's do not, because the engine and the warehouse are the same thing and the mocks carry their own types.
And file-free, for the same reason¶
The test connection cannot read or write files either. A model under test
that calls read_csv, read_text, glob or a bare FROM '<path>', or that
tries COPY ... TO, ATTACH, INSTALL or LOAD, fails its test with a
permission error rather than touching the disk. havn test runs on the
server too, where the SQL being tested is a file anyone with write permission
just saved, so the run gets no filesystem at all.
Nothing legitimate needs it: fixture rows are the input a unit test is for.
If a test seems to need a file read, put the rows it would have read into a
given block, in the inline csv: form when there are a lot of columns.
Limitations¶
- Incremental models are tested as a full refresh. The model's query runs
as written;
incremental_filter,unique_keyand the merge strategy are not exercised, because there is no prior state to merge into. Test the transformation logic here and the incremental behaviour withhavn diff. SELECT *models are only as wide as the mock. A mock that declares three columns makes a star model produce three columns, and the test then passes against a narrower fiction. When a warehouse is available, havn warns about this:
warn mock for bronze.customers omits 2 column(s) present in the warehouse
(name, email); a SELECT * model is being tested against a narrower table
The warning only appears for models that actually project a star.
- Unparseable SQL cannot be tested. Dependency extraction can fall back to
regex, but rewriting cannot: a partial rewrite would point some references
at the real warehouse. A model sqlglot cannot parse fails its test with a
clear message instead.
- Sources and seeds are mocked like anything else. There is no special
handling; mock them by name under given.
API¶
# List declared tests (read permission)
curl http://localhost:3000/api/unit-tests
# Run them (execute permission)
curl -X POST http://localhost:3000/api/unit-tests/run \
-H 'Content-Type: application/json' \
-d '{"model": "silver.customers"}'
The web UI has the same thing under Observe > Unit Tests, and AI agents
can call the run_unit_tests MCP tool.
Related Pages¶
- Data Quality -- Assertions, profiling, freshness
- Contracts -- Standalone YAML rules against built data
- Transforms -- Model configuration and directives
- Macros -- Python functions callable from model SQL