Skip to main content

Factory + trait DSL on top of dbt unit tests.

Project description

slimtest

Factory + trait DSL for dbt™'s unit_tests. Removes column-level repetition from your test YAML so the tests stay readable as the upstream schema grows.

slimtest is not a replacement for dbt test — it's a thin pre-processor that lets you write unit_tests in a far more compact form, then compiles them into the standard YAML dbt already knows how to run.

your factory + trait YAML
        │
        │  $ slimtest compile
        ▼
target/slimtest/<model>.generated.yml   ── standard dbt unit_tests
        │
        │  $ dbt test --select <generated names>     (slimtest unittest does this for you)
        ▼
results (with failures mapped back to your source line)

Quick start

Install

Recommended — install as a tool managed by uv:

uv tool install slimtest

Or as a dev dependency of your dbt project:

uv add --dev slimtest
# or, with pip:
pip install slimtest

(Until the package is published, replace slimtest with the path to a local clone: uv tool install /path/to/slimtest.)

Dependencies

Layer Required
Python 3.11+
Runtime libs pydantic >=2.6,<3, ruamel.yaml >=0.18,<1, typer >=0.12,<1
dbt dbt-core >=1.10 + an adapter — provided by your dbt project, not bundled with slimtest

slimtest shells out to whatever dbt is on PATH. Install the dbt adapter that matches your project (dbt-duckdb, dbt-bigquery, …).

Run Slimtest

Assuming you already have a dbt project:

  1. Tell dbt to read the generated YAML — add target/slimtest to model-paths in dbt_project.yml:

    model-paths: ["models", "target/slimtest"]
    
  2. Add a factory at tests/slimtest_factories/customers.yml:

    factories:
      customers:
        base:
          customer_id: 1
          country: JP
          tier: standard
        traits:
          premium: {tier: premium}
    
  3. Write the test under meta.slimtest in your model.yml:

    models:
      - name: dim_customers
        meta:
          slimtest:
            unit_tests:
              - name: premium_tier_propagates
                given:
                  customers:
                    - {factory: customers, trait: premium, override: {customer_id: 42}}
                expect:
                  - {customer_id: 42, tier: premium}
    
  4. Run it from your dbt project root:

    dbt seed && dbt run    # upstreams must exist before unit tests can introspect their schema
    slimtest unittest
    

Failures look like this — note the source location pointing back to the YAML you wrote:

[slimtest] 0/1 passed; 1 failed, 0 errored, 0 skipped.
  FAIL: premium_tier_propagates  (models/dim_customers.yml:5)
      actual differs from expected:
      @@ ,customer_id,tier
      → ,42         ,standard→premium

For a more complete example with four upstreams and four scenarios, see examples/sample_dbt_project/.

Why

dbt's built-in unit_tests (dbt 1.8+) ask you to spell out every upstream row in full. With wide denormalized tables, or models that join several upstreams, the YAML gets verbose fast — the pain is the column × row repetition inside each test, not the number of tests.

slimtest lets you say "this is what a row of customers normally looks like; in this test, override the tier", instead of typing the same 15 columns into every row.

Tiny taste

# Before: dbt-native unit_tests. One of four upstream blocks shown.
unit_tests:
  - name: test_full_lifecycle
    model: order_statuses
    given:
      - input: ref('order_events')
        rows:
          - {event_id: 1, order_id: 5001, event_type: placed,    occurred_at: '2024-01-01 09:00:00', cancelled_at: null}
          - {event_id: 2, order_id: 5001, event_type: paid,      occurred_at: '2024-01-01 09:30:00', cancelled_at: null}
          - {event_id: 3, order_id: 5001, event_type: shipped,   occurred_at: '2024-01-02 14:00:00', cancelled_at: null}
          - {event_id: 4, order_id: 5001, event_type: delivered, occurred_at: '2024-01-04 11:00:00', cancelled_at: null}
      - input: ref('orders')
        rows:
          - {order_id: 5001, customer_id: 8001, product_id: 3001, total_amount: 12000, currency: JPY, channel: web, placed_at: '2024-01-01 09:00:00', cancelled_at: null}
      - input: ref('customers')
        rows:
          - {customer_id: 8001, country: JP, tier: standard, signup_source: organic}
      - input: ref('products')
        rows:
          - {product_id: 3001, category: apparel, price: 12000}
    expect:
      rows:
        - {status: pending,    valid_from: '2024-01-01 09:00:00', valid_to: '2024-01-01 09:30:00'}
        - {status: processing, valid_from: '2024-01-01 09:30:00', valid_to: '2024-01-02 14:00:00'}
        - {status: shipping,   valid_from: '2024-01-02 14:00:00', valid_to: '2024-01-04 11:00:00'}
        - {status: completed,  valid_from: '2024-01-04 11:00:00', valid_to: null}
# After: slimtest. Same assertion, every row carries only what changes.
models:
  - name: order_statuses
    meta:
      slimtest:
        unit_tests:
          - name: test_full_lifecycle
            given:
              order_events:
                - {factory: order_events, trait: placed,    override: {event_id: 1, occurred_at: '2024-01-01 09:00:00'}}
                - {factory: order_events, trait: paid,      override: {event_id: 2, occurred_at: '2024-01-01 09:30:00'}}
                - {factory: order_events, trait: shipped,   override: {event_id: 3, occurred_at: '2024-01-02 14:00:00'}}
                - {factory: order_events, trait: delivered, override: {event_id: 4, occurred_at: '2024-01-04 11:00:00'}}
              # orders / customers / products are auto-filled from each factory's base row
            expect:
              - {status: pending,    valid_from: '2024-01-01 09:00:00', valid_to: '2024-01-01 09:30:00'}
              - {status: processing, valid_from: '2024-01-01 09:30:00', valid_to: '2024-01-02 14:00:00'}
              - {status: shipping,   valid_from: '2024-01-02 14:00:00', valid_to: '2024-01-04 11:00:00'}
              - {status: completed,  valid_from: '2024-01-04 11:00:00', valid_to: null}

…plus a one-time tests/slimtest_factories/order_events.yml:

factories:
  order_events:
    base:
      event_id: 10000
      order_id: 5001
      occurred_at: '2024-01-01 09:00:00'
      cancelled_at: null
    traits:
      placed:    {event_type: placed}
      paid:      {event_type: paid}
      shipped:   {event_type: shipped}
      delivered: {event_type: delivered}
      cancelled: {event_type: cancelled}
      returned:  {event_type: returned}

The full runnable version of this lives in examples/sample_dbt_project/.

Core concepts

Factory

One factory represents "one row of upstream model X". By convention the factory name matches the upstream name so slimtest can auto-fill missing upstreams (see below). Factories live in tests/slimtest_factories/*.yml.

base and traits

  • base: the "default row" — common defaults shared by every variant.
  • traits: named diffs that overlay base to represent state / variant (premium user, placed event, us customer, …).

In a test, you reference a row as {factory: <name>, trait: <name>, override: {...}}. Layers are deep-merged in this order: base → trait → override.

override

Per-row, per-test diff applied on top of base + trait. This is where test-specific IDs and per-case values go.

Upstream auto-fill

If a test's given: omits an upstream and a factory with the same name exists, slimtest injects one base row of that factory automatically. A happy-path test that only cares about events doesn't have to repeat the boilerplate of orders / customers / products / etc.

Scenario

A reusable bundle of given: rows declared under meta.slimtest.scenarios.<name>. Tests reference it with scenario: <name> and slimtest merges its given: into the test's (test wins per upstream key). Used to factor out "all the supporting rows for one order shape" so tests only carry their differing bits.

meta:
  slimtest:
    scenarios:
      premium_jp_books:
        given:
          orders:    [{factory: orders, override: {order_id: 5002}}]
          customers: [{factory: customers, trait: premium, override: {customer_id: 8002}}]
          products:  [{factory: products, trait: books}]
    unit_tests:
      - name: cancellation_after_payment
        scenario: premium_jp_books     # orders/customers/products provided by the scenario
        given:
          order_events: [...]
        expect: [...]

Parametrize

A table-driven sugar that expands one test into N by substituting $<column> placeholders. Reduces "for each event type, the status maps to X" style tests from N copies to 1 block.

unit_tests:
  - name: event_maps_to_status
    parametrize:
      columns: [event_trait, expected_status]
      cases:
        - [placed,    pending]
        - [paid,      processing]
        - [shipped,   shipping]
        - [delivered, completed]
    given:
      order_events:
        - {factory: order_events, trait: $event_trait, override: {event_id: 1}}
    expect:
      - {status: $expected_status}

Each case becomes a separate dbt unit test named slimtest__<model>__event_maps_to_status__<case_id>. case_id defaults to the first column's value (so the example yields __placed, __paid, __shipped, __delivered); set an explicit id: column to override.

Source map

Failures from dbt test are mapped back to the source model.yml line where you wrote the test, so you don't have to grep through generated YAML.

Test name prefix

Every generated test is named slimtest__<model>__<original_name>, guaranteeing no collision with hand-written unit_tests in the same project.

Coverage

slimtest is a wrapper around dbt's unit_tests feature. It does not replace dbt test; it generates the YAML that dbt test consumes.

Test kind Handled by slimtest covers?
Unit tests (dbt 1.8+ unit_tests:) slimtest
Existing top-level unit_tests: (dbt-native) dbt passthrough (untouched)
Data tests (unique, not_null, etc.) dbt ❌ — run dbt test directly
Singular tests (tests/*.sql) dbt ❌ — run dbt test directly
Snapshot / seed / macro tests (none) ❌ — dbt itself does not support these

Currently supported

  • factory + trait + override with deep-merge semantics
  • given: dict form ({upstream: [rows]}) instead of dbt's list-of-pairs form
  • model: field inferred from the enclosing models[*].name
  • scenario: — shared given: bundle declared under meta.slimtest.scenarios
  • parametrize:$column substitution that expands one test into N
  • ref() vs source() automatic disambiguation via manifest.json
  • upstream auto-fill via same-named factories
  • partial expect: — only list the columns you care about; dbt ignores unmentioned columns
  • source map: failure → models/foo.yml:42
  • --select accepts a model name, the user-written test name, the prefixed test name, test_type:unit, or a comma-separated union
  • works with any dbt adapter (validated on DuckDB; BigQuery and Postgres are mechanically the same)
  • slimtest.yml config file with factories_path, generated_yml_path, auto_fill_upstreams

Not yet supported

  • slimtest validate subcommand (factory / model.yml lint)
  • slimtest factories list
  • factory composition (one row built from multiple factories)
  • factory inheritance (one factory extends another)
  • stale generated-yml detection (no source-hash invariant yet)
  • ephemeral upstreams that require format: sql fixtures
  • watch mode / IDE integration / LSP completion
  • dbt's dbtRunner in-process API (we shell out to dbt, so each unittest pays the dbt cold-start cost)

Commands

slimtest compile  [--project-dir DIR] [--select SEL]
    Expand slimtest extension YAML into target/slimtest/*.generated.yml.
    Does NOT invoke dbt. Useful for debugging the expansion.

slimtest unittest [--project-dir DIR] [--select SEL]
    1. dbt parse        (refresh target/manifest.json)
    2. slimtest compile
    3. dbt test --select <generated test names>
    4. Map failures back to source location.

slimtest --version
slimtest --help

The --select selector accepts:

  • a model name (orders)
  • a user-written test name (premium_tier_propagates)
  • a prefixed name (slimtest__orders__premium_tier_propagates)
  • test_type:unit (no-op; everything slimtest emits is a unit test)
  • comma-separated tokens (orders,customers)

Development

uv sync                    # install runtime + dev deps into .venv
uv run pytest              # run the test suite
uv run ruff check .        # lint
uv run ruff format .       # format
uv run mypy src tests      # type check

Conventions:

  • ruff with line-length 88 (E501 is a hard error)
  • mypy --strict on src/; tests are checked with disallow_untyped_defs off
  • pydantic v2 models for every user-facing schema; runtime data is plain dict[str, Any]
  • dbt is mocked in pytest so the test suite needs no warehouse and no installed adapter

License

MIT. See LICENSE.

slimtest is an independent open-source project and is not affiliated with dbt Labs. dbt is a trademark of dbt Labs, LLC.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

slimtest-0.1.0.tar.gz (97.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

slimtest-0.1.0-py3-none-any.whl (36.3 kB view details)

Uploaded Python 3

File details

Details for the file slimtest-0.1.0.tar.gz.

File metadata

  • Download URL: slimtest-0.1.0.tar.gz
  • Upload date:
  • Size: 97.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for slimtest-0.1.0.tar.gz
Algorithm Hash digest
SHA256 3acbd9f9998c8d909070a587a01c665d5a8b40ce237e6f1ecd292ad364b9cbf5
MD5 4c5b4f303fda3b5a93464e8b8c6f9fae
BLAKE2b-256 7a78c0cc80765f3a0a61503b7682a39d4f0c5189d5d3b836de4c8af9d5021519

See more details on using hashes here.

Provenance

The following attestation bundles were made for slimtest-0.1.0.tar.gz:

Publisher: release.yml on Hisaaki-Kato/slimtest

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file slimtest-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: slimtest-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 36.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for slimtest-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c2df68c11523a1c6b006a8ccdb0169336d651a59199a1e15d019fbd08e44410e
MD5 668a5321857e9eff3733d80c7b0b0e09
BLAKE2b-256 daa34d72df2a854935322235112ace2e72c258fd9a157dc9d3268d170f305883

See more details on using hashes here.

Provenance

The following attestation bundles were made for slimtest-0.1.0-py3-none-any.whl:

Publisher: release.yml on Hisaaki-Kato/slimtest

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page