Data Warehouse Testing Guide: Strategies for Reliable Analytics
Data warehouses are where your organization's most important decisions get made. Board reports, investor metrics, operational dashboards—they all pull from your warehouse. When the data is wrong, so are the decisions.
Data warehouse testing is the discipline that ensures your analytical data is accurate, complete, and reliable. It's more complex than testing an application because you're dealing with massive scale, complex dimensional models, and data that arrives from dozens of upstream sources.
What Makes Data Warehouse Testing Unique
Testing a data warehouse differs from application testing in several key ways:
Scale: Warehouses process billions of rows. You can't test every record—you need statistical sampling and aggregate validation.
Dimensional modeling: Star schemas, snowflake schemas, slowly changing dimensions (SCDs), and fact/dimension relationships add complexity that requires specific test patterns.
Historical data: Unlike OLTP systems, warehouses accumulate years of data. Schema changes must be backward-compatible and historical data must remain valid.
Query performance: Analytical queries can scan terabytes. Performance regression testing matters as much as correctness.
Multiple source systems: Warehouse data comes from many upstream systems. Each source is a potential failure point.
Core Testing Domains
1. Schema and Structure Testing
Before testing data, verify your schema is correct:
Column presence and types
-- Verify expected columns exist with correct types
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'fact_orders'
ORDER BY ordinal_position;Partition and clustering validation Modern warehouses like BigQuery and Snowflake use partitioning for performance. Validate partitions are created correctly:
-- BigQuery: verify partitions exist for recent dates
SELECT partition_id, row_count, last_modified_time
FROM `project.dataset.INFORMATION_SCHEMA.PARTITIONS`
WHERE table_name = 'fact_orders'
AND partition_id >= FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
ORDER BY partition_id;Index and constraint validation Check that primary key constraints and unique indexes are in place and enforced.
2. Dimensional Model Testing
Dimensional models have specific integrity requirements:
Fact table foreign key validation Every foreign key in a fact table must resolve to a record in the corresponding dimension:
-- Find orphaned records in fact_sales
SELECT COUNT(*) AS orphaned_orders
FROM fact_orders fo
LEFT JOIN dim_customer dc ON fo.customer_key = dc.customer_key
WHERE dc.customer_key IS NULL;Slowly Changing Dimension (SCD) testing SCD Type 2 dimensions maintain history. Test that the history is maintained correctly:
-- Verify no gaps in SCD2 date ranges
SELECT customer_key,
MAX(effective_end_date) AS latest_end,
MIN(effective_start_date) AS earliest_start
FROM dim_customer
GROUP BY customer_key
HAVING MAX(effective_end_date) < '9999-12-31' -- No current record
AND COUNT(*) > 1;Conformed dimension consistency If the same dimension (like dim_date) is used across multiple fact tables, ensure it's consistent:
-- date_key in fact_orders should exist in dim_date
SELECT COUNT(*)
FROM fact_orders fo
LEFT JOIN dim_date dd ON fo.date_key = dd.date_key
WHERE dd.date_key IS NULL;3. Data Quality Testing
Completeness checks Required fields should never be null:
-- Check null rates for critical columns
SELECT
SUM(CASE WHEN customer_key IS NULL THEN 1 ELSE 0 END) AS null_customer_key,
SUM(CASE WHEN order_date IS NULL THEN 1 ELSE 0 END) AS null_order_date,
SUM(CASE WHEN revenue IS NULL THEN 1 ELSE 0 END) AS null_revenue,
COUNT(*) AS total_rows
FROM fact_orders
WHERE load_date = CURRENT_DATE;Value range validation Business metrics have natural bounds:
-- Revenue should be positive (or within reasonable refund range)
SELECT COUNT(*) AS out_of_range_revenue
FROM fact_orders
WHERE revenue < -10000 OR revenue > 1000000;
-- Order dates shouldn't be in the future
SELECT COUNT(*) AS future_orders
FROM fact_orders
WHERE order_date > CURRENT_DATE;Referential integrity at scale For large warehouses, full integrity checks are expensive. Use sampling or partition-scoped checks:
-- Check only today's partition
SELECT COUNT(DISTINCT fo.customer_key) AS orphaned_customers
FROM fact_orders fo
LEFT JOIN dim_customer dc ON fo.customer_key = dc.customer_key
WHERE fo.load_date = CURRENT_DATE
AND dc.customer_key IS NULL;4. Aggregation and Metric Validation
The most critical tests validate that your key business metrics calculate correctly:
Revenue reconciliation
-- Total revenue should match source system
SELECT
(SELECT SUM(amount) FROM source_system.payments
WHERE payment_date = CURRENT_DATE - 1) AS source_revenue,
(SELECT SUM(revenue) FROM fact_orders
WHERE order_date = CURRENT_DATE - 1) AS warehouse_revenue;Count reconciliation
-- Order count should match between source and warehouse
WITH source_counts AS (
SELECT DATE(created_at) AS order_date, COUNT(*) AS cnt
FROM source_system.orders
WHERE created_at >= CURRENT_DATE - 7
GROUP BY 1
),
warehouse_counts AS (
SELECT order_date, COUNT(*) AS cnt
FROM fact_orders
WHERE order_date >= CURRENT_DATE - 7
GROUP BY 1
)
SELECT
s.order_date,
s.cnt AS source_count,
w.cnt AS warehouse_count,
s.cnt - w.cnt AS discrepancy
FROM source_counts s
LEFT JOIN warehouse_counts w ON s.order_date = w.order_date
WHERE s.cnt != w.cnt;Derived metric validation If your warehouse computes metrics like average order value or conversion rate, validate them independently:
-- Verify average order value matches manual calculation
SELECT
SUM(revenue) / COUNT(DISTINCT order_id) AS calculated_aov,
AVG(average_order_value) AS stored_aov
FROM fact_daily_metrics
WHERE report_date = CURRENT_DATE - 1;Testing Warehouse Performance
Performance regression is a real concern in data warehouses. A query that runs in 5 seconds becomes unusable at 5 minutes.
Query Performance Benchmarking
Maintain a suite of representative queries and track their performance:
import time
import statistics
def benchmark_query(warehouse_client, query: str, runs: int = 5):
times = []
for _ in range(runs):
start = time.time()
warehouse_client.execute(query)
times.append(time.time() - start)
return {
"mean": statistics.mean(times),
"median": statistics.median(times),
"p95": sorted(times)[int(runs * 0.95)]
}
# Track performance over time
BENCHMARK_QUERIES = {
"daily_revenue_summary": """
SELECT DATE(order_date), SUM(revenue)
FROM fact_orders
WHERE order_date >= CURRENT_DATE - 30
GROUP BY 1
""",
"customer_lifetime_value": """
SELECT customer_key, SUM(revenue)
FROM fact_orders
GROUP BY 1
ORDER BY 2 DESC
LIMIT 1000
"""
}
results = {}
for query_name, query in BENCHMARK_QUERIES.items():
results[query_name] = benchmark_query(client, query)
# Alert if p95 exceeds threshold
for query_name, metrics in results.items():
if metrics['p95'] > THRESHOLDS[query_name]:
send_alert(f"Performance regression: {query_name} p95={metrics['p95']:.2f}s")Warehouse-Specific Optimization Tests
Different warehouses have different performance characteristics:
Snowflake: Test that your Time Travel queries don't cause storage bloat. Validate clustering keys are being used.
BigQuery: Verify partitioning is working—check bytes_processed in query stats to confirm partition pruning is happening.
Redshift: Check distribution keys and sort keys are appropriate for your query patterns. ANALYZE COMPRESSION output should be reviewed after large loads.
Testing Data Freshness
Stale data is a quality issue. Define freshness SLAs and test them:
-- Data freshness check: last record should be within 26 hours
SELECT
MAX(created_at) AS latest_record,
DATEDIFF('hour', MAX(created_at), CURRENT_TIMESTAMP) AS hours_old,
CASE
WHEN DATEDIFF('hour', MAX(created_at), CURRENT_TIMESTAMP) > 26
THEN 'STALE'
ELSE 'FRESH'
END AS freshness_status
FROM fact_orders;This can be run as an automated health check—HelpMeTest's monitoring can execute this query on a schedule and alert you when data goes stale.
Testing Warehouse Migrations
Data warehouse migrations (upgrading versions, switching vendors, or major schema changes) require extra rigor:
Pre/post migration comparison
def compare_warehouse_states(old_client, new_client, tables: list):
for table in tables:
old_count = old_client.execute(f"SELECT COUNT(*) FROM {table}")[0][0]
new_count = new_client.execute(f"SELECT COUNT(*) FROM {table}")[0][0]
assert old_count == new_count, \
f"Row count mismatch in {table}: {old_count} vs {new_count}"
# Sample-based comparison
old_sample = old_client.execute(
f"SELECT * FROM {table} ORDER BY RANDOM() LIMIT 1000"
)
new_sample = new_client.execute(
f"SELECT * FROM {table} WHERE pk IN ({format_ids(old_sample)})"
)
compare_datasets(old_sample, new_sample, table)Query compatibility testing Existing BI tool queries should return the same results after migration:
CRITICAL_QUERIES = load_critical_queries() # From your BI tools
for query in CRITICAL_QUERIES:
old_result = old_client.execute(query)
new_result = new_client.execute(query)
assert_results_equal(old_result, new_result, tolerance=0.001)Automated Testing with dbt
dbt has become the standard transformation layer for most modern data warehouses. Its testing capabilities are mature:
# models/marts/fact_orders.yml
version: 2
models:
- name: fact_orders
description: "Core sales fact table"
tests:
- dbt_utils.equal_rowcount:
compare_model: ref('stg_orders')
columns:
- name: order_id
tests:
- not_null
- unique
- name: customer_id
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
- name: revenue
tests:
- not_null
- dbt_utils.accepted_range:
min_value: 0
max_value: 1000000
- name: order_date
tests:
- not_null
- dbt_utils.not_future_valueFor complex business rules, write custom singular tests:
-- tests/fact_orders_revenue_reconciliation.sql
-- This test fails if warehouse revenue doesn't match source system
SELECT
source_day,
ABS(source_revenue - warehouse_revenue) / NULLIF(source_revenue, 0) AS discrepancy_pct
FROM (
SELECT
s.order_date AS source_day,
s.total_revenue AS source_revenue,
w.total_revenue AS warehouse_revenue
FROM {{ ref('stg_daily_revenue') }} s
JOIN {{ ref('fact_daily_revenue') }} w ON s.order_date = w.order_date
WHERE s.order_date >= CURRENT_DATE - 7
)
WHERE discrepancy_pct > 0.001 -- >0.1% discrepancy fails the testContinuous Monitoring Strategy
Testing at deployment time isn't enough. Warehouse data changes continuously. You need ongoing monitoring:
Automated daily validation jobs Schedule data quality checks to run after each pipeline load:
- Row count reconciliation
- Null rate checks
- Value range validation
- Freshness validation
- Aggregation reconciliation
Alerting thresholds Not every anomaly is a crisis. Set tiered alerting:
- Critical: Revenue reconciliation fails >1%, data more than 12 hours stale
- Warning: Null rate increase >5%, row count anomaly >10%
- Info: Partition sizes growing unusually fast
Data Warehouse Testing Checklist
For every schema change or major load:
- Schema validation (columns, types, constraints)
- Row count reconciliation (source vs. warehouse)
- Foreign key integrity (fact tables → dimension tables)
- Null rate within acceptable bounds
- Value ranges within expected limits
- Key business metrics match source system
- Performance benchmarks within SLA
- Freshness validation passing
- SCD2 history correctly maintained (if applicable)
- Backward compatibility verified (existing queries still work)
Conclusion
Data warehouse testing isn't optional when your business depends on the numbers in your dashboards. The cost of incorrect data—wrong decisions, missed opportunities, lost trust in analytics—far exceeds the cost of building proper test coverage.
Start with reconciliation: if source system totals match warehouse totals, you've covered the biggest risk. Add structural validation and business rule tests as your pipeline matures. Implement continuous monitoring to catch issues introduced by upstream system changes.
A trusted data warehouse is one where every team knows the numbers are right—and can prove it.