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.
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.
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.
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.
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.
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.
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 )
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.
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.
# 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) )
| 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 |
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.
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.
| Location | Strategy |
|---|---|
| extract_product() | fail-safe → return None |
| extract_data() | skip page → continue loop |
| transform() | filter rows → log drop count |
| save_*() | catch exception → log → continue |
Fail-safe parsing means 47 good pages still get processed even when 3 pages have broken HTML.
Zero live dependencies in tests means CI runs fast, even in environments without database or network access.
Dependency injection means adding a new load destination (e.g. S3) requires no changes to existing test infrastructure.
Each module is independently runnable. A failing load never looks like a failing transform.
The test suite requires no credentials or live services. It can run on any CI platform from day one.
Fail-safe extraction, ordered validation, injectable dependencies, and modular load — patterns used in production data systems at scale.
Writing tests for this pipeline changed how I wrote the pipeline itself. Functions that are hard to test are usually hard to maintain.
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.
The Google Sheets API chain (spreadsheets().values().clear().execute()) looks daunting to mock. Chained MagicMock calls just work — each returns another Mock by default.
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.
Splitting into save_csv, save_postgres, save_gsheet made each independently testable. A monolithic save_all() would have been a test nightmare.
Extract, transform, load modules plus full pytest test suite with MagicMock. All three modules, all edge cases, 100% branch coverage.