AWS Glue ETL Testing: Local Development and CI/CD Integration
AWS Glue ETL jobs are PySpark scripts that run in AWS-managed Spark clusters. Testing them properly is challenging: you need Glue-specific libraries (GlueContext, DynamicFrame), access to AWS services, and a Spark runtime. This guide covers the full testing stack: local development with the Glue Docker container, unit testing transformations with PyTest and mock data, and CI/CD integration.
The AWS Glue Testing Stack
| Layer | Tool | When to Use |
|---|---|---|
| Local Glue runtime | AWS Glue Docker image | Development and debugging |
| Unit tests | PyTest + PySpark | Transform logic in isolation |
| Integration tests | LocalStack + pytest | S3, Catalog interactions |
| End-to-end | AWS Glue console | Pre-production validation |
Local Development with Glue Docker
AWS provides an official Docker image with the full Glue runtime:
# Pull Glue 4.0 image (Python 3.10, Spark 3.3)
docker pull amazon/aws-glue-libs:glue_libs_4.0.0_image_01
# Start interactive development environment
docker run -it \
--name glue-dev \
-v $(pwd):/home/glue_user/workspace \
-e AWS_DEFAULT_REGION=us-east-1 \
-e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
-e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
-p 4040:4040 \ # Spark UI
amazon/aws-glue-libs:glue_libs_4.0.0_image_01 \
/home/glue_user/jupyter/jupyter_start.sh # For Jupyter
# Or run pytest directly
docker run --rm \
-v $(pwd):/home/glue_user/workspace \
-e AWS_DEFAULT_REGION=us-east-1 \
-e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
-e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
amazon/aws-glue-libs:glue_libs_4.0.0_image_01 \
python -m pytest /home/glue_user/workspace/tests/Structuring a Testable Glue Job
Separate transform logic from Glue infrastructure:
# transformations.py — pure transformation logic, testable without Glue
import pandas as pd
from typing import Optional
def clean_orders(df: pd.DataFrame) -> pd.DataFrame:
"""Clean and validate order data."""
df = df.copy()
# Remove nulls in required fields
df = df.dropna(subset=['order_id', 'customer_id', 'amount'])
# Filter invalid amounts
df = df[df['amount'] > 0]
# Normalize status
df['status'] = df['status'].str.lower().str.strip()
# Parse dates
df['order_date'] = pd.to_datetime(df['order_date'])
df['year'] = df['order_date'].dt.year
df['month'] = df['order_date'].dt.month
return df
def aggregate_by_customer(df: pd.DataFrame) -> pd.DataFrame:
"""Aggregate orders by customer."""
return df.groupby('customer_id').agg(
total_orders=('order_id', 'count'),
total_revenue=('amount', 'sum'),
first_order_date=('order_date', 'min'),
last_order_date=('order_date', 'max'),
).reset_index()# glue_job.py — the actual Glue script
import sys
from awsglue.transforms import *
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from awsglue.context import GlueContext
from awsglue.job import Job
import pyspark.pandas as ps
# Import pure transformation logic
from transformations import clean_orders, aggregate_by_customer
args = getResolvedOptions(sys.argv, ['JOB_NAME', 'source_path', 'target_path'])
sc = SparkContext()
glueContext = GlueContext(sc)
spark = glueContext.spark_session
job = Job(glueContext)
job.init(args['JOB_NAME'], args)
# Read
source_df = glueContext.create_dynamic_frame.from_options(
connection_type="s3",
connection_options={"paths": [args['source_path']], "recurse": True},
format="parquet"
)
# Convert to pandas-on-Spark for transformations
pandas_df = source_df.toDF().toPandas()
# Apply transformations (pure functions, tested separately)
cleaned = clean_orders(pandas_df)
aggregated = aggregate_by_customer(cleaned)
# Write
output_df = spark.createDataFrame(aggregated)
output_dynamic_frame = DynamicFrame.fromDF(output_df, glueContext, "output")
glueContext.write_dynamic_frame.from_options(
frame=output_dynamic_frame,
connection_type="s3",
connection_options={"path": args['target_path']},
format="parquet"
)
job.commit()Unit Testing Transformation Logic
Since the transformation logic is in plain Python functions, test them with pytest and pandas:
# tests/test_transformations.py
import pytest
import pandas as pd
from datetime import datetime
from transformations import clean_orders, aggregate_by_customer
class TestCleanOrders:
@pytest.fixture
def sample_orders(self):
return pd.DataFrame({
'order_id': ['o1', 'o2', 'o3', 'o4'],
'customer_id': ['c1', 'c1', 'c2', None],
'amount': [100.0, -50.0, 200.0, 150.0],
'status': ['PENDING', ' completed ', 'Cancelled', 'pending'],
'order_date': ['2024-01-15', '2024-01-16', '2024-01-17', '2024-01-18'],
})
def test_removes_null_customer_ids(self, sample_orders):
result = clean_orders(sample_orders)
assert result['customer_id'].isna().sum() == 0
def test_removes_negative_amounts(self, sample_orders):
result = clean_orders(sample_orders)
assert (result['amount'] <= 0).sum() == 0
def test_normalizes_status(self, sample_orders):
result = clean_orders(sample_orders)
assert result['status'].isin(['pending', 'completed', 'cancelled']).all()
def test_parses_order_date(self, sample_orders):
result = clean_orders(sample_orders)
assert result['order_date'].dtype == 'datetime64[ns]'
assert 'year' in result.columns
assert 'month' in result.columns
def test_preserves_valid_records(self, sample_orders):
result = clean_orders(sample_orders)
# o1 (pending), o3 (cancelled) should be kept; o2 (negative), o4 (null) dropped
assert len(result) == 2
assert set(result['order_id']) == {'o1', 'o3'}
class TestAggregateByCustomer:
@pytest.fixture
def cleaned_orders(self):
return pd.DataFrame({
'order_id': ['o1', 'o2', 'o3', 'o4'],
'customer_id': ['c1', 'c1', 'c2', 'c1'],
'amount': [100.0, 200.0, 150.0, 50.0],
'order_date': pd.to_datetime(['2024-01-01', '2024-01-15', '2024-01-10', '2024-01-20']),
})
def test_aggregates_per_customer(self, cleaned_orders):
result = aggregate_by_customer(cleaned_orders)
c1 = result[result['customer_id'] == 'c1'].iloc[0]
assert c1['total_orders'] == 3
assert c1['total_revenue'] == pytest.approx(350.0)
c2 = result[result['customer_id'] == 'c2'].iloc[0]
assert c2['total_orders'] == 1
def test_first_and_last_order_dates(self, cleaned_orders):
result = aggregate_by_customer(cleaned_orders)
c1 = result[result['customer_id'] == 'c1'].iloc[0]
assert c1['first_order_date'] == pd.Timestamp('2024-01-01')
assert c1['last_order_date'] == pd.Timestamp('2024-01-20')Testing with PySpark (in Glue Docker)
For tests that need PySpark (testing DynamicFrame operations, schema validation):
# tests/test_glue_transforms.py
import pytest
# Only run in Glue environment
try:
from pyspark.context import SparkContext
from awsglue.context import GlueContext
HAS_GLUE = True
except ImportError:
HAS_GLUE = False
pytestmark = pytest.mark.skipif(not HAS_GLUE, reason="Requires Glue runtime")
@pytest.fixture(scope="session")
def glue_context():
sc = SparkContext.getOrCreate()
glueContext = GlueContext(sc)
yield glueContext
sc.stop()
def test_dynamic_frame_schema(glue_context, tmp_path):
"""Test that Glue reads data with correct schema."""
spark = glue_context.spark_session
# Create test parquet file
test_data = spark.createDataFrame([
('o1', 'c1', 100.0, 'pending', '2024-01-15'),
('o2', 'c2', 200.0, 'completed', '2024-01-16'),
], ['order_id', 'customer_id', 'amount', 'status', 'order_date'])
test_path = str(tmp_path / 'test-data')
test_data.write.parquet(test_path)
# Read with Glue
dynamic_frame = glue_context.create_dynamic_frame.from_options(
connection_type="s3",
connection_options={"paths": [test_path]},
format="parquet",
transformation_ctx="test_input",
)
schema = dynamic_frame.schema()
field_names = [f.name for f in schema]
assert 'order_id' in field_names
assert 'amount' in field_names
count = dynamic_frame.count()
assert count == 2Integration Testing with LocalStack
LocalStack emulates S3 and Glue APIs locally:
# docker-compose.yml for tests
version: '3'
services:
localstack:
image: localstack/localstack
ports:
- "4566:4566"
environment:
- SERVICES=s3,glue
- DEFAULT_REGION=us-east-1
- AWS_DEFAULT_REGION=us-east-1# tests/test_s3_integration.py
import boto3
import pytest
import pandas as pd
import io
LOCALSTACK_ENDPOINT = "http://localhost:4566"
@pytest.fixture(scope="session")
def s3_client():
return boto3.client(
's3',
endpoint_url=LOCALSTACK_ENDPOINT,
aws_access_key_id='test',
aws_secret_access_key='test',
region_name='us-east-1',
)
@pytest.fixture
def test_bucket(s3_client):
bucket_name = f'test-bucket-{id(s3_client)}'
s3_client.create_bucket(Bucket=bucket_name)
yield bucket_name
# Cleanup
objects = s3_client.list_objects_v2(Bucket=bucket_name).get('Contents', [])
for obj in objects:
s3_client.delete_object(Bucket=bucket_name, Key=obj['Key'])
s3_client.delete_bucket(Bucket=bucket_name)
def test_etl_reads_from_s3(s3_client, test_bucket):
# Upload test data
test_df = pd.DataFrame([
{'order_id': 'o1', 'amount': 100.0, 'status': 'pending'},
])
buffer = io.BytesIO()
test_df.to_parquet(buffer, index=False)
s3_client.put_object(
Bucket=test_bucket,
Key='input/orders.parquet',
Body=buffer.getvalue(),
)
# Run ETL logic
from transformations import clean_orders
# Read from S3
obj = s3_client.get_object(Bucket=test_bucket, Key='input/orders.parquet')
df = pd.read_parquet(io.BytesIO(obj['Body'].read()))
result = clean_orders(df)
# Write output
output_buffer = io.BytesIO()
result.to_parquet(output_buffer, index=False)
s3_client.put_object(
Bucket=test_bucket,
Key='output/cleaned.parquet',
Body=output_buffer.getvalue(),
)
# Verify output in S3
output_obj = s3_client.get_object(Bucket=test_bucket, Key='output/cleaned.parquet')
output_df = pd.read_parquet(io.BytesIO(output_obj['Body'].read()))
assert len(output_df) == 1
assert output_df.iloc[0]['status'] == 'pending'CI/CD Integration
# .github/workflows/glue-tests.yml
name: Glue ETL Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
- run: pip install pandas pytest pytest-cov
- name: Run unit tests (no Glue runtime needed)
run: pytest tests/test_transformations.py -v --cov=transformations
glue-integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start LocalStack
run: |
pip install localstack awscli-local
localstack start -d
sleep 10
- name: Run integration tests
run: |
docker run --rm \
--network host \
-v $(pwd):/home/glue_user/workspace \
-e AWS_DEFAULT_REGION=us-east-1 \
-e AWS_ACCESS_KEY_ID=test \
-e AWS_SECRET_ACCESS_KEY=test \
-e AWS_ENDPOINT_URL=http://localhost:4566 \
amazon/aws-glue-libs:glue_libs_4.0.0_image_01 \
python -m pytest /home/glue_user/workspace/tests/test_s3_integration.py -vThe key insight for Glue testing: separate your transformation logic from Glue infrastructure. Pure pandas/PySpark transformations can be tested without the Glue runtime. Only test actual GlueContext behavior in integration tests that run in the Docker image. This gives you fast unit tests for the business logic and targeted integration tests for the Glue-specific behavior.