Data Pipeline CI/CD Testing: Automate Quality Gates
Software engineers have spent decades building CI/CD practices that make deployments safe and reliable. Data engineers are catching up—and the lessons transfer directly.
A data pipeline CI/CD system automatically tests every change before it reaches production, catches regressions before they corrupt your warehouse, and gives teams the confidence to ship changes quickly. This guide shows you how to build one.
Why Data Pipelines Need CI/CD
Data engineering code changes are high-risk:
- A broken SQL transformation silently corrupts downstream tables
- Schema changes can break dependent models and BI reports
- A performance regression in a core transformation can delay daily reports
- Logic errors in aggregations produce wrong business metrics
Without CI/CD, these issues reach production and get discovered by analysts—hours or days later. With CI/CD, automated tests catch them in minutes.
The Data Pipeline CI/CD Architecture
A complete data CI/CD pipeline has these stages:
Code Change → Lint & Static Analysis → Unit Tests → Integration Tests →
Staging Deployment → Data Quality Validation → Performance Tests →
Production Deploy → Post-deploy MonitoringLet's build each stage.
Stage 1: Linting and Static Analysis
Catch syntax errors and style issues before running any tests.
SQL Linting with SQLFluff
# .sqlfluff
[sqlfluff]
dialect = snowflake
templater = dbt
[sqlfluff:rules]
max_line_length = 100
[sqlfluff:rules:L010]
capitalisation_policy = upper
[sqlfluff:rules:L014]
capitalisation_policy = lower# Run in CI
sqlfluff lint models/
sqlfluff fix models/ --dry-run # Show what would be fixedPython Linting for ETL Code
# pyproject.toml
[tool.ruff]
line-length = 100
select = ["E", "W", "F", "I", "N"]
[tool.mypy]
strict = true
python_version = "3.11"dbt Parsing and Compilation
Verify your dbt project compiles before running any tests:
dbt parse # Validates YAML and Jinja without running
dbt compile --select +my_model # Compile specific model and dependenciesStage 2: Unit Tests
Test transformation logic in isolation without touching production databases.
Python Unit Tests for ETL Transformations
# tests/unit/test_revenue_calculation.py
import pytest
import pandas as pd
from etl.transformations import calculate_net_revenue
def test_net_revenue_calculation():
orders = pd.DataFrame({
'gross_revenue': [100.0, 200.0, 150.0],
'discount': [10.0, 0.0, 25.0],
'refund': [0.0, 50.0, 0.0]
})
result = calculate_net_revenue(orders)
expected = [90.0, 150.0, 125.0]
assert list(result['net_revenue']) == expected
def test_net_revenue_handles_nulls():
orders = pd.DataFrame({
'gross_revenue': [100.0, None, 150.0],
'discount': [None, 0.0, 25.0],
'refund': [0.0, 50.0, None]
})
result = calculate_net_revenue(orders)
# Nulls should be treated as 0
assert result['net_revenue'].iloc[0] == 100.0
assert pd.isna(result['net_revenue'].iloc[1]) # Null gross stays null
assert result['net_revenue'].iloc[2] == 125.0
def test_net_revenue_negative_when_refund_exceeds_revenue():
orders = pd.DataFrame({
'gross_revenue': [100.0],
'discount': [0.0],
'refund': [150.0] # Refund exceeds revenue
})
result = calculate_net_revenue(orders)
assert result['net_revenue'].iloc[0] == -50.0dbt Unit Tests (dbt 1.8+)
dbt now has native unit testing support:
# models/marts/fact_orders.yml
unit_tests:
- name: test_net_revenue_calculation
model: fact_orders
given:
- input: ref('stg_orders')
rows:
- {order_id: 1, gross_revenue: 100, discount: 10, refund: 0}
- {order_id: 2, gross_revenue: 200, discount: 0, refund: 50}
expect:
rows:
- {order_id: 1, net_revenue: 90}
- {order_id: 2, net_revenue: 150}dbt test --select fact_orders --indirect-selection=cautiousSpark/PySpark Unit Tests
# tests/unit/test_spark_transformations.py
from pyspark.sql import SparkSession
from chispa.dataframe_comparer import assert_df_equality
from etl.spark_transformations import deduplicate_orders
@pytest.fixture(scope="session")
def spark():
return SparkSession.builder \
.master("local[*]") \
.appName("unit-tests") \
.getOrCreate()
def test_deduplication_keeps_latest_record(spark):
input_df = spark.createDataFrame([
("ord_123", "2024-01-01 10:00:00", "pending"),
("ord_123", "2024-01-01 12:00:00", "shipped"), # Latest
("ord_456", "2024-01-01 09:00:00", "delivered"),
], ["order_id", "updated_at", "status"])
expected_df = spark.createDataFrame([
("ord_123", "2024-01-01 12:00:00", "shipped"),
("ord_456", "2024-01-01 09:00:00", "delivered"),
], ["order_id", "updated_at", "status"])
result_df = deduplicate_orders(input_df)
assert_df_equality(result_df, expected_df, ignore_row_order=True)Stage 3: Integration Tests
Test your pipeline against a real database with representative data.
Setting Up Test Data Infrastructure
Use Testcontainers or cloud-based test environments:
# conftest.py
import pytest
from testcontainers.postgres import PostgresContainer
from etl.database import create_engine
@pytest.fixture(scope="session")
def test_database():
with PostgresContainer("postgres:15") as postgres:
engine = create_engine(postgres.get_connection_url())
# Load test fixtures
load_test_fixtures(engine, "tests/fixtures/")
yield engine
def test_etl_pipeline_integration(test_database):
# Run your actual ETL pipeline against the test database
run_etl_pipeline(source_engine=test_database, target_engine=test_database)
# Validate results
result = test_database.execute(
"SELECT COUNT(*) FROM fact_orders WHERE load_date = CURRENT_DATE"
).scalar()
assert result > 0dbt Integration Tests with a Staging Environment
# CI script for dbt integration tests
export DBT_TARGET=ci
# Run on a separate schema to avoid polluting production
dbt run --target ci --vars '{"schema_prefix": "ci_test_"}'
dbt test --target ci
dbt run-operation clean_up_ci_schema# profiles.yml
my_project:
outputs:
ci:
type: snowflake
account: "{{ env_var('SNOWFLAKE_ACCOUNT') }}"
database: ANALYTICS
schema: "CI_TEST_{{ env_var('CI_BUILD_NUMBER', 'local') }}"
warehouse: CI_WAREHOUSE
role: CI_ROLEStage 4: GitHub Actions CI Pipeline
# .github/workflows/data-pipeline-ci.yml
name: Data Pipeline CI
on:
pull_request:
paths:
- 'models/**'
- 'tests/**'
- 'etl/**'
- 'dbt_project.yml'
- 'packages.yml'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: pip install sqlfluff ruff mypy dbt-core dbt-snowflake
- name: SQL lint
run: sqlfluff lint models/
- name: Python lint
run: ruff check etl/
- name: Type check
run: mypy etl/ --strict
- name: dbt compile
run: |
dbt deps
dbt parse
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: pip install pytest pytest-cov pyspark great_expectations
- name: Run unit tests
run: |
pytest tests/unit/ -v --cov=etl --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: testpassword
options: >-
--health-cmd pg_isready
--health-interval 10s
steps:
- uses: actions/checkout@v4
- name: Run integration tests
run: pytest tests/integration/ -v
env:
TEST_DB_URL: postgresql://postgres:testpassword@localhost:5432/test
dbt-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- name: Set up dbt
run: pip install dbt-snowflake
- name: Run dbt tests
run: |
dbt deps
dbt run --target ci --select state:modified+
dbt test --target ci --select state:modified+
env:
DBT_SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
DBT_SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_CI_PASSWORD }}
CI_BUILD_NUMBER: ${{ github.run_number }}
- name: Cleanup CI schema
if: always()
run: dbt run-operation drop_ci_schema
env:
CI_BUILD_NUMBER: ${{ github.run_number }}Stage 5: Staging Environment Validation
Before merging to main, validate against a staging environment with production-like data:
staging-validation:
runs-on: ubuntu-latest
needs: [lint, unit-tests, dbt-tests]
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
run: |
dbt run --target staging --select state:modified+
dbt test --target staging --select state:modified+
env:
DBT_TARGET: staging
SNOWFLAKE_DATABASE: ANALYTICS_STAGING
- name: Run data quality checks
run: python scripts/run_quality_checks.py --env staging
- name: Performance regression check
run: python scripts/benchmark_queries.py --env staging --fail-on-regressionStage 6: Post-Deploy Monitoring
Deployment isn't the end. Monitor production data quality continuously:
# scripts/post_deploy_checks.py
import sys
from quality_checks import run_all_checks
from notifications import send_slack_alert, create_pagerduty_incident
def run_post_deploy_checks(environment: str):
results = run_all_checks(environment)
critical_failures = [r for r in results if r.severity == "critical" and not r.passed]
warnings = [r for r in results if r.severity == "warning" and not r.passed]
if critical_failures:
message = f"🚨 Critical data quality failures after deploy:\n"
for failure in critical_failures:
message += f"- {failure.check_name}: {failure.details}\n"
send_slack_alert(channel="#data-incidents", message=message)
create_pagerduty_incident(
title=f"Data quality failure: {len(critical_failures)} critical checks failed",
details=message
)
return False
if warnings:
message = f"⚠️ Data quality warnings after deploy:\n"
for warning in warnings:
message += f"- {warning.check_name}: {warning.details}\n"
send_slack_alert(channel="#data-quality", message=message)
return True
if __name__ == "__main__":
success = run_post_deploy_checks(sys.argv[1])
sys.exit(0 if success else 1)Handling Schema Changes Safely
Schema changes in data pipelines are risky. Protect against them:
Automated Schema Comparison
# ci_scripts/check_schema_changes.py
from sqlalchemy import inspect
def compare_schemas(before_engine, after_engine, critical_tables):
issues = []
before_inspector = inspect(before_engine)
after_inspector = inspect(after_engine)
for table in critical_tables:
before_cols = {c['name']: c['type'] for c in before_inspector.get_columns(table)}
after_cols = {c['name']: c['type'] for c in after_inspector.get_columns(table)}
# Check for removed columns (breaking change)
removed = set(before_cols.keys()) - set(after_cols.keys())
if removed:
issues.append(f"BREAKING: Columns removed from {table}: {removed}")
# Check for type changes (potentially breaking)
for col in set(before_cols.keys()) & set(after_cols.keys()):
if str(before_cols[col]) != str(after_cols[col]):
issues.append(
f"WARNING: Type changed in {table}.{col}: "
f"{before_cols[col]} → {after_cols[col]}"
)
return issuesdbt State-Based Testing
Only test models that changed, speeding up CI dramatically:
# Save state from last successful production run
dbt ls --target prod > prod_state.txt
# In CI: only run tests for changed models and their dependents
dbt test --select state:modified+ # Changed + downstream
dbt test --select 1+state:modified # Changed + upstream (for regression)Pipeline Observability
Instrument your pipelines for observability:
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
import time
tracer = trace.get_tracer(__name__)
def run_pipeline_with_observability(pipeline_name: str, run_fn):
with tracer.start_as_current_span(f"pipeline.{pipeline_name}") as span:
start_time = time.time()
try:
result = run_fn()
span.set_attribute("pipeline.success", True)
span.set_attribute("pipeline.rows_processed", result.rows_processed)
return result
except Exception as e:
span.set_attribute("pipeline.success", False)
span.set_attribute("pipeline.error", str(e))
span.record_exception(e)
raise
finally:
duration = time.time() - start_time
span.set_attribute("pipeline.duration_seconds", duration)Data Pipeline CI/CD Checklist
For every pipeline change:
Pre-merge (CI)
- SQL linting passes
- Python linting + type checks pass
- dbt compiles without errors
- Unit tests pass with >80% coverage
- Integration tests pass against test database
- dbt tests pass on CI schema
- No breaking schema changes without migration plan
- Performance benchmarks within acceptable range
Post-merge (CD)
- Staging deployment successful
- Staging data quality checks pass
- Production deployment triggered
- Post-deploy quality checks pass
- Monitoring/alerting confirms pipeline running normally
Conclusion
Data pipelines deserve the same engineering rigor as application code. A bad deployment can silently corrupt your warehouse and poison business decisions for days before anyone notices.
CI/CD for data pipelines catches these issues automatically: SQLFluff catches syntax errors, unit tests catch transformation logic bugs, integration tests catch wiring issues, and post-deploy monitoring catches anything that slips through.
The investment pays for itself the first time an automated test catches a regression before it reaches production. Start small—add linting and basic unit tests to your most critical models today. Build up to full integration testing and staging validation as your team matures.
Data engineering is software engineering. Treat it that way.