Data Pipeline CI/CD Testing: Automate Quality Gates

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 Monitoring

Let'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 fixed

Python 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 dependencies

Stage 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.0

dbt 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=cautious

Spark/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 > 0

dbt 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_ROLE

Stage 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-regression

Stage 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 issues

dbt 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.

Read more

Start now free