Software Engineering for Data ETL · pytest · MagicMock · PostgreSQL · Google Sheets

Building an ETL pipeline is easy.
Building one that survives failures is harder.

An end-to-end fashion product ETL with full unit testing, dependency injection, and isolated mocks using pytest + MagicMock. 50 pages scraped, 3 destinations, 100% branch coverage.

Extract requests · BeautifulSoup · 50 pages Transform pandas · validation · USD→IDR Load CSV · PostgreSQL · Google Sheets Testing pytest · MagicMock · 100% branch coverage
50
Pages scraped
per run
3
Load destinations
CSV · PG · Sheets
3
Independent modules
extract · transform · load
100%
Branch coverage
per module
0
Live dependencies
during tests
01 · Business Problem

Pipelines fail at the edges. The edges you forgot to test.

Most ETL tutorials show the happy path. Real pipelines fail when HTML structure changes mid-scrape, a product has a null price, or the database connection drops after 40 successful writes.

Without Testing
Website
Extract
Broken HTML
Pipeline Crash
No Data
With Testing
Website
Extract
Broken Card
Return None · Skip
Continue
47 Good Pages
PythonBeautifulSouppandas PostgreSQLSQLAlchemyGoogle Sheets API pytestMagicMock
02 · Architecture

Three independently testable layers. One clean pipeline.

Each layer has one responsibility and one owner. Extract never calls transform. Load never calls extract. Each can be swapped, tested, and debugged in isolation.

Extract — fail-safe per card

Each product card is parsed by extract_product(). The function wraps all field access in try/except and returns None on any error. A single broken card never crashes the scrape loop — extract_data() skips None results with continue.

Load — three destinations, one contract

save_csv(), save_postgres(), and save_gsheet() each follow the same contract: check empty → write → catch exceptions. Each is independently callable. The pipeline doesn't fail completely if one destination is unavailable.

03 · Pipeline Flow

Every step in the data path, visualised.

Transform — explicit validation chain

The cleaning chain is ordered to fail fast: filter null titles and null prices first, then check that price strings actually contain digits (rejects "Price Unavailable"), then convert types.

Currency conversion uses an environment variable with a safe default — not a hardcoded constant. Changing the rate never requires a code change.

# Explicit validation order — fast fail
df = df[df["title"].notna()]
df = df[df["title"].str.lower() != "unknown product"]
df = df[df["price"].notna()]
df = df[df["price"].str.contains(r"\d", regex=True)]

RATE = float(os.getenv("EXCHANGE_RATE_USD_TO_IDR", 16000))
df["price"] = (
    df["price"]
    .str.replace(r"[$,]", "", regex=True)
    .astype(float) * RATE
)
04 · Testing Architecture

No live database. No real HTTP. Millisecond-fast deterministic tests.

The test suite uses MagicMock throughout — every external dependency is replaced with a controlled fake. Tests run identically in CI, on a plane, and at 2am when the database is unreachable.

"A test that requires a live database connection is a test that fails at 2am when the database is unreachable."

How MagicMock replaces real systems

Without Mock
Test
Real Website
Real Database
Real Google API
Slow · Unstable
With MagicMock
Test
Fake HTML
Fake DB
Fake Google API
Milliseconds · Deterministic
05 · Coverage Dashboard

Every branch exercised. Every failure mode caught.

test_extract.py
100%
  • Success — all fields present
  • Partial — missing rating/colors/gender
  • Exception — broken HTML → None
  • Empty page — no cards
  • Network error — all pages fail
test_transform.py
100%
  • Mixed input — valid + invalid rows
  • Empty DataFrame — no crash
  • All-invalid — empty result returned
  • Price dtype → float
  • Prefix stripping on size/gender
test_load.py
100%
  • CSV — written correctly
  • PostgreSQL — correct table + replace strategy
  • Google Sheets — clear + update called once
  • Empty DataFrame — each destination skips
06 · Data Validation Flow

Explicit validation chain. No silent drops.

Why this order?

Each filter is ordered from cheapest to most expensive to compute. Null checks run before string operations. String operations run before numeric casting. This prevents type errors from propagating downstream.

Key snippet

# Reject "Price Unavailable" etc.
df = df[df["price"].str.contains(
    r"\d", regex=True
)]

# Type-safe numeric conversion
df["rating"] = (
    df["rating"]
    .str.extract(r"([\d.]+)")[0]
    .astype(float)
)
df["colors"] = (
    df["colors"]
    .str.extract(r"(\d+)")[0]
    .astype(int)
)
07 · Design Decisions

Every choice was deliberate.

Problem Solution Benefit
Broken HTML card crashes pipeline try/except → return None Pipeline keeps running
Hardcoded exchange rate EXCHANGE_RATE_USD_TO_IDR env var Configurable without code change
Google API hard to test Dependency injection on save_gsheet(service=...) Directly mockable without credentials
Monolithic save_all() function Split into save_csv, save_postgres, save_gsheet Each independently testable and deployable
Silent data drops in transform Explicit ordered filter chain with logging Every drop is traceable

Dependency Injection in practice

The injectable service parameter in save_gsheet() wasn't planned — it emerged because the function was hard to test without credentials. The need to test revealed a design problem, and the fix made the code better.

Without DI — untestable
def save_gsheet(df): creds = load_credentials() # ← always runs service = build('sheets', 'v4', credentials=creds) # needs real creds to test
With DI — testable
def save_gsheet(df, service=None): if service is None: creds = load_credentials() service = build(...) # test passes service=MagicMock()
08 · Folder Structure

Mirrors the architecture. Tests live beside the code they test.

etl-fashion-pipeline/ │ ├── utils/ │ ├── extract.py ← scraper + parser │ ├── transform.py ← validation + conversion │ └── load.py ← CSV, PG, Sheets │ ├── tests/ │ ├── test_extract.py ← 6 test cases │ ├── test_transform.py ← 3 test cases │ └── test_load.py ← 4 test cases │ ├── requirements.txt ├── pytest.ini └── main.py ← orchestrator

CI Pipeline

With 100% branch coverage and zero live dependencies in tests, this pipeline is ready for CI. Every commit can trigger the full test suite without environment setup.

GitHub Push
pytest
Coverage 100%
Build ✓

Error handling strategy

LocationStrategy
extract_product()fail-safe → return None
extract_data()skip page → continue loop
transform()filter rows → log drop count
save_*()catch exception → log → continue
09 · Business Impact

Why this architecture matters in production.

🛡

Broken HTML doesn't stop the pipeline

Fail-safe parsing means 47 good pages still get processed even when 3 pages have broken HTML.

Tests finish in milliseconds

Zero live dependencies in tests means CI runs fast, even in environments without database or network access.

🔌

External APIs are isolated

Dependency injection means adding a new load destination (e.g. S3) requires no changes to existing test infrastructure.

🔍

Faster debugging

Each module is independently runnable. A failing load never looks like a failing transform.

🔄

CI/CD ready

The test suite requires no credentials or live services. It can run on any CI platform from day one.

📐

Production-ready architecture

Fail-safe extraction, ordered validation, injectable dependencies, and modular load — patterns used in production data systems at scale.

10 · Lessons Learned

QA isn't a safety net. It's a design discipline.

Writing tests for this pipeline changed how I wrote the pipeline itself. Functions that are hard to test are usually hard to maintain.

Testability shapes design

When you write the test first, you catch structural problems early. Functions with too many dependencies are hard to mock — and that difficulty is the design feedback.

MagicMock for external APIs

The Google Sheets API chain (spreadsheets().values().clear().execute()) looks daunting to mock. Chained MagicMock calls just work — each returns another Mock by default.

Coverage is a floor, not a goal

100% branch coverage doesn't mean the pipeline is correct. It means every code path has been exercised at least once. The actual data quality contracts live in the assertions.

Modular load > monolithic save

Splitting into save_csv, save_postgres, save_gsheet made each independently testable. A monolithic save_all() would have been a test nightmare.

Source Code · Writeup

Full source on GitHub. Complete with test suite.

Extract, transform, load modules plus full pytest test suite with MagicMock. All three modules, all edge cases, 100% branch coverage.