Design data-quality tests that catch real bugs — uniqueness / not-null / referential integrity / freshness / row-count drift / value-range / cross-source reconciliation. dbt-test mechanics for each, severity tiers (error vs warn), the runbook-entry-per-failing-test discipline, and when to escalate to Great Expectations or Monte Carlo / Bigeye. Reach for this skill when launching a new pipeline OR after a "the numbers were wrong" incident. Used by `etl-pipeline-engineer` (primary).
Scanned 9/23/2026
npx -y skills add mcorbett51090/RavenClaude --skill data-quality-tests --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Data Quality Tests?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/mcorbett51090-data-quality-tests)More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.
---
name: data-quality-tests
description: Design data-quality tests that catch real bugs — uniqueness / not-null / referential integrity / freshness / row-count drift / value-range / cross-source reconciliation. dbt-test mechanics for each, severity tiers (error vs warn), the runbook-entry-per-failing-test discipline, and when to escalate to Great Expectations or Monte Carlo / Bigeye. Reach for this skill when launching a new pipeline OR after a "the numbers were wrong" incident. Used by `etl-pipeline-engineer` (primary).
---
# Skill: data-quality-tests
> **Invoked by:** `etl-pipeline-engineer` (primary — owns pipeline correctness). Also consulted by `dashboard-builder` when a widget shows nonsense and the root cause is upstream, and by `database-setup-guide` when establishing source-freshness contracts.
>
> **When to invoke:** new pipeline launch (greenfield); post-incident — "the dashboard numbers were wrong"; pipeline inheritance — testing what the previous engagement built; mart redesign where downstream dashboards exist.
>
> **Output:** test suite committed to the dbt project (or DQ-tool config), severity tiers documented, alert wiring + runbook entries per failing test, escalation criteria documented.
## The discipline (the floor, not the ceiling)
**Every failing test maps to a runbook entry with triage steps.** Tests without owners and runbook entries become noise the team learns to ignore. Two-line discipline:
1. **No test without an owner** — the runbook entry names the human or team that triages on failure
2. **No test without a runbook entry** — the alert tells someone what to *do*, not just that something broke
If you can't write the runbook entry, the test doesn't belong in the suite. Drop it or escalate the data-modeling problem.
## Test taxonomy
| Tier | Category | Examples |
|---|---|---|
| **Column** | not-null | `customer_id` must always be populated |
| | unique | surrogate keys |
| | accepted-values | `status IN ('active', 'inactive', 'archived')` |
| | range | `amount BETWEEN 0 AND 1000000` |
| | regex | email format, phone format |
| **Table** | row-count drift | this fact-table grows 1k-10k rows/day |
| | freshness | source loaded within last 24h |
| | composite uniqueness | `(tenant_id, order_id)` unique even if `order_id` alone isn't |
| | volume floor / ceiling | this table has ≥ 1k rows and ≤ 10M |
| **Cross-table** | referential integrity | every `order.customer_id` exists in `customers` |
| | reconciliation across sources | Stripe charges total = revenue GL account ± 0.1% |
| | conservation laws | sum(debits) = sum(credits) per period |
## dbt mechanics per category
### Generic tests (column level — declared in `_models.yml`)
```yaml
models:
- name: stg_quickbooks__invoices
columns:
- name: invoice_id
tests:
- unique
- not_null
- name: status
tests:
- accepted_values:
values: ['draft', 'sent', 'paid', 'void']
severity: error
- name: amount
tests:
- dbt_expectations.expect_column_values_to_be_between:
min_value: 0
max_value: 1000000
severity: warn
- name: customer_id
tests:
- relationships:
to: ref('stg_quickbooks__customers')
field: customer_id
severity: error
```
### Table-level generic tests
```yaml
models:
- name: fct_revenue_daily
tests:
- dbt_utils.unique_combination_of_columns:
combination_of_columns: [tenant_id, revenue_date, revenue_stream]
- dbt_expectations.expect_table_row_count_to_be_between:
min_value: 1000
max_value: 10000000
severity: warn
```
### Freshness (source-level)
```yaml
sources:
- name: quickbooks_raw
loaded_at_field: _airbyte_extracted_at
freshness:
warn_after: { count: 6, period: hour }
error_after: { count: 24, period: hour }
tables:
- name: invoices
freshness:
warn_after: { count: 2, period: hour }
error_after: { count: 6, period: hour }
```
Run separately from `dbt build`: `dbt source freshness`. Schedule every 15-30 min in CI. Freshness failure = ELT broken, not transform broken; separate alert channel.
### Singular tests (table-shape assertions that don't fit generic mold)
```sql
-- tests/assert_no_future_dated_invoices.sql
-- An invoice dated > today is almost always a typo or a timezone bug.
SELECT invoice_id, invoice_date
FROM {{ ref('stg_quickbooks__invoices') }}
WHERE invoice_date > current_date + interval '1 day'
{{ config(severity='warn') }}
```
Any row returned = test fails. Put project-specific business rules here.
### Row-count drift (the unsung hero)
The pattern that catches the most production issues. **A fact table that's growing should grow by a predictable band per day.**
```sql
-- tests/assert_revenue_row_count_in_band.sql
-- fct_revenue_daily should add 800-12000 rows/day (tenant_id × day × revenue_stream).
-- An outside-band day means: missing tenant data, duplicate load, or pipeline silently dropped rows.
with yesterday as (
select count(*) as n from {{ ref('fct_revenue_daily') }}
where revenue_date = current_date - 1
),
expected as (select 800 as floor, 12000 as ceiling)
select n, floor, ceiling
from yesterday, expected
where n < floor or n > ceiling
{{ config(severity='warn') }}
```
For more sophisticated drift detection (deviation from rolling average), `dbt_expectations.expect_row_count_to_be_close_to` or escalate to Great Expectations / Monte Carlo.
### Cross-source reconciliation (the killer)
If two sources should agree, *test that they do*. Catches: silent ELT drops, deduplication bugs, timezone bugs, transform errors.
```sql
-- tests/assert_stripe_revenue_matches_gl.sql
-- Daily Stripe captured charges (after refunds) should equal the GL revenue account ± 0.1%.
with stripe_daily as (
select date_trunc('day', captured_at) as day, sum(amount_net) as stripe_total
from {{ ref('stg_stripe__charges') }}
where status = 'succeeded'
group by 1
),
gl_daily as (
select date_trunc('day', posted_at) as day, sum(amount) as gl_total
from {{ ref('stg_quickbooks__gl_entries') }}
where account = 'Revenue'
group by 1
)
select s.day, s.stripe_total, g.gl_total, abs(s.stripe_total - g.gl_total) as variance
from stripe_daily s
join gl_daily g on s.day = g.day
where abs(s.stripe_total - g.gl_total) > (greatest(s.stripe_total, 1) * 0.001)
and s.day >= current_date - interval '7 days'
{{ config(severity='error') }}
```
This is the test that proves "the numbers are right." Write at least one per material data source pair.
## Severity tiers
| Severity | Meaning | Behavior |
|---|---|---|
| `error` (dbt default) | Test failure halts the build / blocks downstream models | Pipeline does NOT proceed; on-call paged |
| `warn` | Test failure logs but pipeline continues | Slack channel notified; reviewed next business day |
| `error` with `--warn-error` flag | Promote warns to errors in production | Production-strict mode |
| `error` with `where: ...` | Test only fires on a subset (e.g., recent data) | Backfill-friendly; doesn't fight historical bad data |
**Tier-assignment rule of thumb:**
- **`error`** — anything that, if wrong, breaks a downstream dashboard's correctness or compliance posture
- **`warn`** — anything where you want visibility but the pipeline / dashboard can survive the failure (e.g., row count slightly outside band)
- **Configure thresholds** instead of binary pass/fail wherever the test allows it (`expect_column_values_to_be_between` with `row_condition`, `min_value`, `max_value`)
### Severity calibration anti-pattern
Setting every test to `error` so nothing slips → the team learns to bypass the build with `--no-fail-on-test` → tests become decorative. Calibrate carefully. **`warn` is not weakness; it's appropriate for tests where the right human response is "review tomorrow," not "wake me up."**
## Alerting + runbook integration
Every failing test = a Slack / PagerDuty alert that links to a runbook entry. Test names are the runbook key.
### Runbook entry template
```markdown
# Runbook: assert_stripe_revenue_matches_gl
**Owner:** etl-pipeline-engineer (primary), finance ops (downstream consumer)
**Severity:** error
**SLA:** triage within 1 business hour; resolve or escalate within 1 business day
## What this test checks
Daily Stripe captured-charge total vs. QBO GL Revenue account, ± 0.1% tolerance, last 7 days.
## Common causes when this fails
1. **Stripe refund landed but QBO refund hasn't posted yet** → wait 1 business day; will resolve at next QBO sync
2. **Timezone bug** — Stripe in UTC, QBO in tenant local time → check `date_trunc` arguments
3. **QBO connector missed an entry** → check `dbt source freshness` for `quickbooks_raw.gl_entries`
4. **Refund category mismapped in QBO** → check GL chart-of-accounts mapping table
## Triage steps
1. `dbt test --select assert_stripe_revenue_matches_gl --vars '{date: yyyy-mm-dd}'` for the failing date
2. Query the discrepancy: variance per day, which side is high
3. Check #2 above (timezone) by re-running with explicit tz cast
4. If variance > $500: escalate to finance ops within 1 business hour
## Recent incidents
- 2026-03-14: QBO sync delay during Intuit maintenance window; auto-resolved
- 2026-02-02: Refund mapping bug; fix in PR #142
```
Store under `docs/runbooks/<test_name>.md` in the dbt project. CI checks that every test has a corresponding runbook on PR.
## When to escalate beyond dbt tests
dbt tests cover the floor. Escalate when you hit any of these:
| Trigger | Escalate to |
|---|---|
| Volume > 10M rows/day, drift detection needs more than min/max bands | **Great Expectations** (Python, OSS) — richer expectation suite, profiling, data docs |
| Multi-warehouse, multi-pipeline org-wide observability | **Monte Carlo** (sales-quoted; retrieved 2026-05-21) — column-level lineage, ML-based anomaly detection |
| Same as above with a lower price point | **Bigeye** (sales-quoted) or **Sifflet** (sales-quoted) — newer entrants, similar shape |
| Regulated data (HIPAA, PCI-DSS, SOX) — need audit trail + signed test runs | **Soda Cloud** or **Anomalo** — compliance-oriented features |
| Real-time / streaming data | **dbt tests don't cover streaming.** Look at Confluent Schema Registry + ksqlDB tests, or stream-native DQ tools (Acceldata, Bigeye streaming) |
**Decision rule:** dbt tests are the floor; reach for a DQ tool when (a) the volume of tests becomes ungovernable, (b) regulatory / audit pressure requires evidence beyond CI logs, or (c) the engagement is large enough that the DQ tool's annual cost is < the cost of a single missed-data incident.
## Test selection — don't test everything
The wrong move: write a `not_null` + `unique` on every column of every model. The right move: think about **what could go wrong** and test that.
### Test selection checklist (per model)
- [ ] **Identifying columns** — `unique` + `not_null` (always)
- [ ] **Foreign keys** — `relationships` test (always)
- [ ] **Enums** — `accepted_values` (always)
- [ ] **Numeric columns with known bounds** — range tests (often)
- [ ] **Date columns** — sanity test (no future-dated invoices, no pre-launch-dated rows)
- [ ] **Tenant-scoped tables** — `tenant_id not_null` + composite uniqueness with `(tenant_id, ...)` (always for multi-tenant)
- [ ] **Aggregated marts** — row-count drift + at least one reconciliation against a source
- [ ] **Cross-source consistency** — at least one reconciliation test per material source pair (Stripe ↔ QBO, HubSpot ↔ Salesforce, etc.)
## Anti-patterns this skill flags
- **Tests without owners** — every test should have a runbook entry naming who triages
- **Tests that fail silently** — `warn` severity with no alert wiring = nobody knows
- **`error` on tests where the right response is "check tomorrow"** — desensitizes the team to alerts
- **`not_null` + `unique` on every column** — noise that buries the meaningful tests
- **No reconciliation tests** — uniqueness and not-null don't catch silent drops; only cross-source reconciliation does
- **No row-count drift band** — pipeline silently dropping rows is the most common production failure mode
- **`dbt run` in CI instead of `dbt build`** — `run` skips tests; you ship untested marts (see [`../dbt-project-scaffolding/SKILL.md`](../dbt-project-scaffolding/SKILL.md))
- **Singular tests that don't have a `where` clause limiting to recent data** — historical bad data fails every build forever
- **Freshness tests bundled with `dbt build`** — they should fire on a separate schedule (every 15-30 min) so ELT failures alert independently of transform failures
- **Severity calibrated all-`error` then bypassed with `--no-fail-fast`** — defeats the purpose; calibrate carefully instead
- **DQ tool purchased before dbt tests are exhausted** — paying for sophistication you can't justify; start with the floor
## Hygiene checklist before shipping a pipeline
- [ ] Every staging model has `unique` + `not_null` on its identifying column
- [ ] Every FK has a `relationships` test
- [ ] Every enum column has `accepted_values`
- [ ] Multi-tenant tables: `tenant_id not_null` + composite uniqueness
- [ ] At least one row-count drift band per fact mart
- [ ] At least one cross-source reconciliation per material source pair
- [ ] `dbt source freshness` scheduled separately (every 15-30 min)
- [ ] Severity calibrated — not every test is `error`
- [ ] Every test has a runbook entry under `docs/runbooks/<test_name>.md`
- [ ] Alerts wired to a Slack channel (warn) + PagerDuty (error)
- [ ] CI fails the PR if a new test lacks a runbook entry
## See also
- Skill: [`../dbt-project-scaffolding/SKILL.md`](../dbt-project-scaffolding/SKILL.md) — the layer that hosts these tests
- Skill: [`../dashboard-performance-tuning/SKILL.md`](../dashboard-performance-tuning/SKILL.md) — row-count drift tests catch silent pre-agg refresh failures
- Skill: [`../multi-tenant-migration/SKILL.md`](../multi-tenant-migration/SKILL.md) — `tenant_id not_null` + composite uniqueness as migration gates
- Skill: [`../connector-configuration/SKILL.md`](../connector-configuration/SKILL.md) — source-side gotchas that DQ tests catch downstream
- Knowledge: [`../../knowledge/quickbooks-online-integration.md`](../../knowledge/quickbooks-online-integration.md) — QBO-specific reconciliation gotchas
- Knowledge: [`../../knowledge/stripe-integration.md`](../../knowledge/stripe-integration.md) — Stripe-specific reconciliation patterns
- dbt-expectations: [github.com/calogica/dbt-expectations](https://github.com/calogica/dbt-expectations) — Great Expectations port for dbt
- Great Expectations: [docs.greatexpectations.io](https://docs.greatexpectations.io) (current as of 2026-05-21)
- Monte Carlo pricing: sales-quoted; retrieved 2026-05-21
- Bigeye / Sifflet / Soda: sales-quoted; retrieved 2026-05-21
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!