Testing Pipelines

Interlace’s safety net is layered: preview before you run, assert on what you built, rehearse in a sandbox, and unit-test Python logic as plain functions.

Preview: plan Never Runs Anything

interlace plan compiles the project, fingerprints every model, and classifies every change — without touching the warehouse:

interlace plan
interlace plan --json        # for CI

A plan with breaking changes makes interlace apply exit non-zero unless --force is passed, so the classification itself is an automated review gate. See schema evolution for what counts as breaking.

Assert: Checks Gate Every Promotion

Quality checks are the assertion layer — the full catalogue of ten built-in types lives in that guide. They run against the freshly built tables during every apply/run, and a failing error-severity check blocks promotion — production views never move onto bad data:

/* interlace:
  checks:
    - not_null: order_id
    - unique: order_id
    - relationships: {column: customer_id, to: customers, field: id}
    - expression: {expression: "amount >= 0"}
*/

Every result is recorded in the state store, so checks double as an ops surface:

interlace checks run --env dev      # re-run against promoted tables, no rebuild; exits 1 on error-severity failure
interlace checks list --model orders --limit 20   # recent recorded results, newest first

checks run uses each snapshot’s recorded engine and skips terminal (table/file) models and declared-but-not-promoted models. The same data is at GET /checks, POST /checks/run, and the UI’s Checks view.

Custom assertions are Python functions — return True/0/an empty table to pass, or a failure count / non-empty table of offending rows to fail:

from interlace import check

@check(model="event_totals")
def totals_are_positive(rel):
    t = rel.table()
    return all(v > 0 for v in t.column("total_amount").to_pylist())

Rehearse: Sandbox Environments

An environment is a full-fidelity staging area in the same warehouse:

interlace apply --env ci-1234    # build + check the whole graph, isolated views
interlace checks run --env ci-1234
interlace env drop ci-1234

Unchanged models are reused, not rebuilt, so a sandbox apply after a small edit only pays for what changed and its (non-pruned) downstream.

Unit-Test Python Models

@model returns your function unchanged, so model logic is directly importable and testable — feed it Arrow data through a minimal handle stub:

import pyarrow as pa
from models.user_ltv import user_ltv

class FakeHandle:
    def __init__(self, table: pa.Table):
        self._t = table
    def table(self) -> pa.Table:
        return self._t
    def reader(self) -> pa.RecordBatchReader:
        return pa.RecordBatchReader.from_batches(self._t.schema, self._t.to_batches())

def test_user_ltv():
    users = pa.table({"user_id": [1, 2], "spend": [100.0, 40.0], "events": [3, 1]})
    out = pa.Table.from_batches(list(user_ltv(FakeHandle(users))))
    assert out.column("ltv").to_pylist() == [13.0, 5.0]

Reserved parameters are just arguments in a test: pass cursor=None to exercise the first-run path, or a FakeHandle as this.

A CI Recipe

interlace plan --json > plan.json          # fail the job on unexpected breaking changes
interlace apply --env "ci-${BUILD_ID}"     # build + contract validation + checks
interlace checks run --env "ci-${BUILD_ID}" --json
interlace env drop "ci-${BUILD_ID}"

Every command exits non-zero on failure, and --json output is stable for scripting. Merged to main? The deploy job is simply interlace apply.

Next Steps