Testing PySpark: pyspark.testing.assertDataFrameEqual и Unit-тесты
Тестирование PySpark-пайплайнов: архитектура тестируемого кода, assertDataFrameEqual с параметрами checkRowOrder/rtol/atol, pytest fixtures, schema drift защита, integration vs unit тесты, CI/CD настройка
Введение: почему data engineers пишут тесты последними¶
В классическом software engineering тестирование - это стандарт. В data engineering - до недавнего времени - экзотика. Типичное оправдание: «У нас данные - как протестировать терабайты?» или «Spark - это не код, это конфиги».
Эти оправдания дорого обходятся командам. Silent data corruption - самая опасная ошибка в data pipelines: Spark Job завершается успешно, данные записаны, дашборды обновились - но бизнес-метрики вычислены неверно. Обнаружение происходит через неделю, когда аналитик находит расхождение в отчёте. К этому времени данные расползлись по десяткам downstream-потребителей.
В этом уроке мы разберём, как тестировать PySpark-код правильно: быстро, дёшево и в CI/CD, без терабайт данных и реального кластера.
Часть 1. Зачем тестировать Big Data код¶
1.1. Реальные причины ошибок в Spark-пайплайнах¶
Большинство production-инцидентов в data pipelines вызваны не «случайными» сбоями, а логическими ошибками в трансформациях, которые проявляются только на определённых комбинациях данных.
Типичные причины:
- NULL handling:
col_a + col_bвозвращает NULL если хотя бы один операнд NULL. Разработчик забыл проcoalesce. - Join duplication: LEFT JOIN с таблицей, где ключи не уникальны, размножает строки. Тест на маленьких данных не выявляет (там ключи уникальны), на 500M строках - взрыв размера.
- Schema drift: источник добавил колонку или изменил тип. Пайплайн не упал, но начал читать данные некорректно.
- Timezone issues:
to_timestampбез явного указания timezone интерпретирует время по локальному времени JVM. На dev-машине и кластере разные timezone. - Window function ordering: без явного
orderByв Window спецификации порядок строк нестабилен. Агрегация работает «случайно правильно» на маленьком датасете. - Floating point precision: сумма
DoubleTypeне ассоциативна.a + b + c ≠ c + b + aна больших числах.
Все эти ошибки воспроизводимы на маленьких тестовых датасетах из 3–10 строк.
1.2. Пирамида тестирования в Data Engineering¶
Пирамида тестирования адаптируется к Data Engineering следующим образом:
Ключевой принцип: Unit-тесты должны составлять 80%+ от всего тест-сьюта. Они запускаются за секунды, не требуют кластера и ловят большинство логических ошибок.
1.3. «Тестирование на проде» - настоящая цена¶
Без тестов команда использует production как testing environment:
| Метрика | С Unit-тестами | Без Unit-тестов |
|---|---|---|
| Время обнаружения бага | Секунды (в CI) | Дни–недели |
| Стоимость исправления | Исправить функцию | Найти баг, чинить downstream, пересчитать историю |
| Уверенность при рефакторинге | Высокая | Никогда не рефакторим - слишком страшно |
| Доверие к данным | Высокое | Постоянные сомнения у аналитиков |
Часть 2. Архитектура тестируемого PySpark-кода¶
2.1. Антипаттерн: монолитный ETL-скрипт¶
Главная причина, по которой люди «не могут» тестировать PySpark-код - неправильная архитектура. Монолитный скрипт непригоден для тестирования:
# АНТИПАТТЕРН: нетестируемый монолитный ETL
# Весь код - процедурная последовательность, IO и трансформации перемешаны
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
# SparkSession создаётся прямо в скрипте - невозможно подменить в тесте
spark = SparkSession.builder.appName("etl").getOrCreate()
# IO и бизнес-логика перемешаны - нельзя тестировать логику без чтения данных
df = spark.read.parquet("s3://bronze/orders/")
# Сложная трансформация прямо в main-скрипте - никакого переиспользования
df_clean = df \
.filter(F.col("order_id").isNotNull()) \
.withColumn("total", F.col("quantity") * F.col("unit_price")) \
.withColumn("total", F.when(F.col("discount") > 0,
F.col("total") * (1 - F.col("discount")))
.otherwise(F.col("total")))
df_enriched = df_clean \
.join(spark.read.parquet("s3://silver/customers/"), # ещё один IO внутри логики!
on="customer_id", how="left") \
.dropDuplicates(["order_id"])
df_enriched.write.mode("overwrite").parquet("s3://silver/orders/")
spark.stop()
# КАК ТЕСТИРОВАТЬ ЭТО? Нужны s3://bronze/, s3://silver/, реальный Spark...
2.2. Правильная архитектура: Pure Transformation Functions¶
Решение - разделить IO и логику. Трансформации выносятся в чистые функции (Pure Functions), принимающие DataFrame и возвращающие DataFrame. IO остаётся в main-функции и не тестируется unit-тестами.
2.3. Пример тестируемого кода¶
"""
orders_pipeline.py
Правильная архитектура: IO отделён от логики.
Трансформационные функции принимают DataFrame и возвращают DataFrame.
"""
from pyspark.sql import DataFrame, functions as F
from pyspark.sql.types import StructType, StructField, StringType, DoubleType, IntegerType
# ── Transformation Layer (чистые функции - легко тестировать) ─────────────────
def clean_orders(df: DataFrame) -> DataFrame:
"""
Очищает сырые заказы:
- Убирает строки с NULL order_id (невалидные записи)
- Приводит типы к стандарту
- Убирает leading/trailing spaces в строковых полях
Чистая функция: только DataFrame → DataFrame, нет IO, нет side effects.
Именно такие функции легко unit-тестировать.
"""
return (
df
.filter(F.col("order_id").isNotNull())
.filter(F.col("quantity") > 0)
.withColumn("order_id", F.trim(F.col("order_id")))
.withColumn("quantity", F.col("quantity").cast(IntegerType()))
.withColumn("unit_price", F.col("unit_price").cast(DoubleType()))
.withColumn("discount", F.coalesce(F.col("discount"), F.lit(0.0)))
)
def calculate_order_total(df: DataFrame) -> DataFrame:
"""
Вычисляет итоговую сумму заказа с учётом скидки.
Формула: total = quantity * unit_price * (1 - discount)
Скидка: от 0.0 (0%) до 1.0 (100%).
Граничный случай: discount > 1.0 → total становится отрицательным.
Это баг, который unit-тест должен поймать.
"""
return df.withColumn(
"order_total",
F.round(
F.col("quantity") * F.col("unit_price") * (1 - F.col("discount")),
2
)
)
def enrich_with_customer(orders: DataFrame, customers: DataFrame) -> DataFrame:
"""
Обогащает заказы данными клиента (имя, сегмент).
LEFT JOIN: заказы без клиента остаются в результате с NULL в customer_name.
ВАЖНО: деревянная ловушка - если customers содержит дубли customer_id,
JOIN умножит строки заказов. Это должен поймать unit-тест.
"""
customers_deduped = customers.dropDuplicates(["customer_id"])
return orders.join(
customers_deduped.select("customer_id", "customer_name", "segment"),
on="customer_id",
how="left",
)
def deduplicate_orders(df: DataFrame) -> DataFrame:
"""
Дедупликация по order_id: при дублях оставляем самую свежую запись.
Используем Window + row_number вместо dropDuplicates,
чтобы контролировать, какую из дублирующихся строк оставить.
"""
from pyspark.sql.window import Window
window_spec = Window.partitionBy("order_id").orderBy(
F.col("updated_at").desc_nulls_last()
)
return (
df
.withColumn("_rn", F.row_number().over(window_spec))
.filter(F.col("_rn") == 1)
.drop("_rn")
)
# ── IO Layer (тестируем только integration-тестами) ───────────────────────────
def read_bronze_orders(spark, date: str) -> DataFrame:
"""Читает сырые заказы из Bronze-слоя."""
return spark.read.parquet(f"s3://bronze/orders/date={date}/")
def write_silver_orders(df: DataFrame, target: str, date: str) -> None:
"""Записывает обработанные заказы в Silver-слой."""
(
df.write
.format("iceberg")
.mode("overwrite")
.option("replaceWhere", f"order_date = '{date}'")
.saveAsTable(target)
)
# ── Main (оркестрирует IO + transforms) ──────────────────────────────────────
def run_pipeline(spark, date: str, target: str = "catalog.silver.orders") -> None:
"""Основной пайплайн. IO + оркестрация трансформаций."""
customers = spark.read.parquet("s3://silver/customers/")
orders_raw = read_bronze_orders(spark, date)
orders_clean = clean_orders(orders_raw)
orders_total = calculate_order_total(orders_clean)
orders_enriched = enrich_with_customer(orders_total, customers)
orders_final = deduplicate_orders(orders_enriched)
write_silver_orders(orders_final, target, date)
Часть 3. Настройка тестового окружения¶
3.1. pytest + SparkSession: паттерн fixtures¶
Ключевой вопрос: как создать SparkSession для тестов эффективно - чтобы она не пересоздавалась на каждый тест и правильно закрывалась.
Ответ - pytest fixture с scope="session": SparkSession создаётся один раз на весь тест-сьют, используется всеми тестами, останавливается после последнего.
# tests/conftest.py
# Этот файл автоматически подгружается pytest для всех тестов в директории.
# Содержит разделяемые fixtures.
import pytest
from pyspark.sql import SparkSession
@pytest.fixture(scope="session")
def spark():
"""
Разделяемая SparkSession для всего тест-сьюта.
scope="session": создаётся ОДИН РАЗ для всего тест-запуска.
Это критично для скорости: создание SparkSession занимает ~5-10 секунд.
Конфигурация минимальная: local[2] для использования 2 потоков,
малый объём памяти (тесты работают с маленькими датасетами).
"""
spark_session = (
SparkSession.builder
.master("local[2]") # 2 потока - достаточно для тестов
.appName("pyspark-unit-tests")
.config("spark.driver.memory", "2g") # Минимум памяти (тестовые данные маленькие)
.config("spark.sql.shuffle.partitions", "4") # Меньше партиций → быстрее для малых данных
.config("spark.sql.adaptive.enabled", "false") # Отключаем AQE: тесты должны быть детерминированными
.config("spark.ui.showConsoleProgress", "false") # Меньше шума в логах тестов
.config("spark.ui.enabled", "false") # Не нужен Spark UI в тестах
# Отключаем логи Spark: в тестах важен вывод pytest, не Spark INFO
.config("spark.sql.execution.arrow.pyspark.enabled", "false")
.getOrCreate()
)
# Снижаем уровень логов до WARN: меньше шума
spark_session.sparkContext.setLogLevel("WARN")
yield spark_session # Передаём сессию тестам
# Teardown: вызывается автоматически после последнего теста
spark_session.stop()
@pytest.fixture(scope="function")
def sample_orders(spark):
"""
Минимальный тестовый датасет заказов.
scope="function": создаётся заново для каждого теста.
Это гарантирует изоляцию: один тест не может испортить данные для другого.
Правило хорошего теста: 3-10 строк с явными, читаемыми значениями.
Каждая строка должна тестировать что-то конкретное.
"""
from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType
schema = StructType([
StructField("order_id", StringType(), nullable=True),
StructField("customer_id", StringType(), nullable=False),
StructField("quantity", IntegerType(), nullable=True),
StructField("unit_price", DoubleType(), nullable=True),
StructField("discount", DoubleType(), nullable=True),
StructField("updated_at", StringType(), nullable=True),
])
data = [
("ORD-001", "CUST-01", 2, 10.0, 0.1, "2024-01-15 10:00:00"), # Нормальный заказ
("ORD-002", "CUST-02", 1, 50.0, 0.0, "2024-01-15 11:00:00"), # Без скидки
(None, "CUST-03", 3, 20.0, 0.0, "2024-01-15 12:00:00"), # NULL order_id → должен быть отфильтрован
("ORD-003", "CUST-01", 0, 15.0, 0.0, "2024-01-15 13:00:00"), # qty=0 → должен быть отфильтрован
("ORD-001", "CUST-01", 2, 10.0, 0.1, "2024-01-15 14:00:00"), # Дубль ORD-001 (более свежий)
]
return spark.createDataFrame(data, schema=schema)
@pytest.fixture(scope="function")
def sample_customers(spark):
"""Тестовые данные клиентов для join-тестов."""
from pyspark.sql.types import StructType, StructField, StringType
schema = StructType([
StructField("customer_id", StringType(), nullable=False),
StructField("customer_name", StringType(), nullable=True),
StructField("segment", StringType(), nullable=True),
])
data = [
("CUST-01", "Alice Smith", "Premium"),
("CUST-02", "Bob Johnson", "Standard"),
# CUST-03 отсутствует - тест на LEFT JOIN behaviour
]
return spark.createDataFrame(data, schema=schema)
3.2. Структура тест-проекта¶
project/
├── src/
│ └── orders_pipeline.py ← Код с чистыми transform-функциями
├── tests/
│ ├── conftest.py ← SparkSession fixture и shared данные
│ ├── unit/
│ │ ├── test_clean_orders.py ← Unit-тесты для clean_orders()
│ │ ├── test_calculate_total.py
│ │ ├── test_enrich.py
│ │ └── test_dedup.py
│ ├── integration/
│ │ └── test_pipeline_e2e.py ← Integration-тесты (с реальным хранилищем)
│ └── schema/
│ └── test_schema_contracts.py ← Тесты схемы данных
├── pyproject.toml
└── pytest.ini
# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v # Verbose: показывать имена тестов
--tb=short # Короткий traceback при падении
--no-header # Без лишнего заголовка
filterwarnings =
ignore::DeprecationWarning
ignore::UserWarning:pyspark
Часть 4. pyspark.testing.assertDataFrameEqual: современный подход¶
4.1. Почему наивное сравнение не работает¶
До Spark 3.4 разработчики сравнивали DataFrames через collect() или .toPandas():
# АНТИПАТТЕРН: наивное сравнение через collect()
def test_clean_bad():
result = clean_orders(sample_df)
expected = [("ORD-001", "CUST-01", ...), ...]
# Проблема 1: порядок строк в Spark НЕ ГАРАНТИРОВАН
# Distributed execution → разные партиции → разный порядок при collect()
# Тест будет мигать (flaky): иногда проходит, иногда нет
assert result.collect() == expected # Nondeterministic!
# Проблема 2: схема не проверяется
# IntegerType vs LongType → collect() вернёт одинаковые Python int
# Но в Spark это разные типы, и это важно для downstream
# Проблема 3: float precision
# 18.0 == 18.000000001 → False из-за floating point
# Тест упадёт на корректных данных
4.2. assertDataFrameEqual: правильное сравнение¶
pyspark.testing.assertDataFrameEqual (появился в Spark 3.4, значительно улучшен в 3.5) решает все эти проблемы:
from pyspark.testing import assertDataFrameEqual
from pyspark.testing.utils import assertSchemaEqual
from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType
def test_assert_dataframe_equal_anatomy(spark):
"""
Демонстрация ключевых параметров assertDataFrameEqual.
"""
# Сравниваемые DataFrames
result = spark.createDataFrame(
[("ORD-002", "CUST-02", 1, 50.0, 0.0, 50.0),
("ORD-001", "CUST-01", 2, 10.0, 0.1, 18.0)],
schema="order_id STRING, customer_id STRING, quantity INT, "
"unit_price DOUBLE, discount DOUBLE, order_total DOUBLE"
)
expected = spark.createDataFrame(
[("ORD-001", "CUST-01", 2, 10.0, 0.1, 18.0),
("ORD-002", "CUST-02", 1, 50.0, 0.0, 50.0)],
schema="order_id STRING, customer_id STRING, quantity INT, "
"unit_price DOUBLE, discount DOUBLE, order_total DOUBLE"
)
assertDataFrameEqual(
actual=result,
expected=expected,
# checkRowOrder=False - КРИТИЧНО для Spark:
# Distributed execution не гарантирует порядок строк.
# С False: строки сортируются перед сравнением → детерминированный результат.
# С True (default): тест будет мигать (flaky) на разных кластерах.
checkRowOrder=False,
# checkSchema=True (default) - сравнивать схему:
# Типы данных (INT vs LONG), nullable флаги, имена колонок.
# Важно! IntegerType vs LongType - это разные типы в Spark,
# хотя Python-int одинаковый.
checkSchema=True,
# rtol=0.0 (default) - относительная погрешность для float сравнения.
# rtol=1e-5 означает: |actual - expected| / |expected| <= 1e-5
# Используйте для финансовых расчётов: rtol=1e-10
rtol=0.0,
# atol=0.0 (default) - абсолютная погрешность.
# atol=0.01 означает: |actual - expected| <= 0.01
# Используйте когда expected ≈ 0 (rtol не работает при expected=0)
atol=0.0,
)
# Если DataFrames совпадают → тест проходит без исключений
# Если нет → AssertionError с понятным diff-сообщением
4.3. Параметры: детальный разбор¶
checkRowOrder - самый важный параметр:
# Демонстрация проблемы без checkRowOrder=False
def test_row_order_problem(spark):
"""
Показывает, почему checkRowOrder=True (default) опасен.
"""
# Spark может вернуть строки в ЛЮБОМ порядке после shuffle
df = spark.range(5).orderBy(F.rand()) # Случайный порядок
# Этот тест будет мигать:
# Иногда порядок совпадёт, иногда нет
try:
assertDataFrameEqual(df, spark.range(5), checkRowOrder=True)
except AssertionError:
print("Порядок строк не совпал - это ОЖИДАЕМО для Spark!")
# Этот тест стабилен:
# Строки сортируются перед сравнением
assertDataFrameEqual(df, spark.range(5), checkRowOrder=False) # Всегда проходит
rtol и atol для floating point:
def test_float_precision(spark):
"""
Демонстрация управления точностью float сравнений.
Проблема: 2 * 10.0 * (1 - 0.1) = 18.0 в теории,
но 18.000000000000004 на практике из-за IEEE 754.
"""
result = spark.createDataFrame(
[("ORD-001", 18.000000000000004)],
schema="order_id STRING, order_total DOUBLE"
)
expected = spark.createDataFrame(
[("ORD-001", 18.0)],
schema="order_id STRING, order_total DOUBLE"
)
# БЕЗ atol: тест упадёт (18.0 != 18.000000000000004)
# assertDataFrameEqual(result, expected) # AssertionError!
# С atol=1e-6: допускаем абсолютное отклонение до 0.000001
assertDataFrameEqual(result, expected, checkRowOrder=False, atol=1e-6) # Проходит
# С rtol=1e-6: допускаем относительное отклонение до 0.0001%
assertDataFrameEqual(result, expected, checkRowOrder=False, rtol=1e-6) # Проходит
checkSchema=False когда нужна гибкость:
def test_schema_relaxed(spark):
"""
checkSchema=False: игнорируем типы данных, проверяем только значения.
Полезно когда источник присылает INT, а мы ожидаем LONG,
но значения одинаковые. Или когда nullable-флаги разные.
"""
result = spark.createDataFrame(
[("ORD-001", 2)], # INT в результате
schema="order_id STRING, quantity INT"
)
expected = spark.createDataFrame(
[("ORD-001", 2)], # LONG в expected (spark.createDataFrame default для int)
schema="order_id STRING, quantity LONG"
)
# С checkSchema=True: AssertionError (INT != LONG)
# С checkSchema=False: проходит (значения одинаковые)
assertDataFrameEqual(result, expected, checkRowOrder=False, checkSchema=False)
4.4. assertSchemaEqual: проверка только схемы¶
Для проверки совместимости схем без проверки данных:
from pyspark.testing.utils import assertSchemaEqual
from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType
def test_output_schema(spark):
"""
Проверяем, что функция возвращает DataFrame с правильной схемой,
независимо от значений данных.
Это особенно важно после изменений: добавили колонку? Изменили тип?
Тест немедленно это поймает.
"""
sample_df = spark.createDataFrame(
[("ORD-001", "CUST-01", 2, 10.0, 0.1, None)],
schema="order_id STRING, customer_id STRING, quantity INT, "
"unit_price DOUBLE, discount DOUBLE, updated_at STRING"
)
result = calculate_order_total(sample_df)
expected_schema = StructType([
StructField("order_id", StringType(), True),
StructField("customer_id", StringType(), False),
StructField("quantity", IntegerType(), True),
StructField("unit_price", DoubleType(), True),
StructField("discount", DoubleType(), True),
StructField("updated_at", StringType(), True),
StructField("order_total", DoubleType(), True), # Новая колонка
])
assertSchemaEqual(
actual=result.schema,
expected=expected_schema,
# checkNullable=True (default): проверяет nullable флаги
# checkNullable=False: игнорирует nullable, проверяет только имена и типы
checkNullable=False, # Часто нужно False: nullable зависит от операции
)
Часть 5. Тестирование трансформаций: реальные сценарии¶
5.1. Тестирование фильтрации и NULL handling¶
# tests/unit/test_clean_orders.py
import pytest
from pyspark.testing import assertDataFrameEqual
from src.orders_pipeline import clean_orders
class TestCleanOrders:
"""
Тесты для функции clean_orders().
Каждый тест проверяет ОДИН конкретный аспект поведения.
Имена тестов описывают сценарий: test_{condition}_{expected_behaviour}.
"""
def test_removes_null_order_id(self, spark, sample_orders):
"""
Строки с NULL order_id должны быть удалены.
Это нарушение Data Contract: order_id - обязательное поле.
"""
result = clean_orders(sample_orders)
null_count = result.filter("order_id IS NULL").count()
assert null_count == 0, "NULL order_id rows should be removed"
def test_removes_zero_quantity(self, spark, sample_orders):
"""
Строки с quantity=0 должны быть удалены.
Заказ с нулевым количеством - это ошибка источника.
"""
result = clean_orders(sample_orders)
zero_qty_count = result.filter("quantity = 0").count()
assert zero_qty_count == 0, "Zero quantity orders should be removed"
def test_fills_null_discount_with_zero(self, spark, spark_session=None):
"""
NULL в поле discount должен заменяться на 0.0 (нет скидки).
Это бизнес-правило: отсутствие скидки != NULL.
"""
spark_session = spark
df = spark_session.createDataFrame(
[("ORD-001", "CUST-01", 2, 10.0, None, "2024-01-15")],
schema="order_id STRING, customer_id STRING, quantity INT, "
"unit_price DOUBLE, discount DOUBLE, updated_at STRING"
)
result = clean_orders(df)
discount_val = result.select("discount").collect()[0]["discount"]
assert discount_val == 0.0, "NULL discount should be filled with 0.0"
def test_trims_whitespace_in_order_id(self, spark):
"""
Пробелы в order_id должны быть удалены.
Источник иногда присылает ' ORD-001 ' вместо 'ORD-001'.
"""
df = spark.createDataFrame(
[(" ORD-001 ", "CUST-01", 1, 10.0, 0.0, "2024-01-15")],
schema="order_id STRING, customer_id STRING, quantity INT, "
"unit_price DOUBLE, discount DOUBLE, updated_at STRING"
)
result = clean_orders(df)
order_id = result.select("order_id").collect()[0]["order_id"]
assert order_id == "ORD-001", f"Expected 'ORD-001', got '{order_id}'"
def test_returns_correct_rows_from_mixed_input(self, spark, sample_orders):
"""
Из смешанного ввода (валидные + невалидные строки) должны
вернуться только валидные.
sample_orders содержит:
- 2 валидных строки (ORD-001 × 2 дубля, ORD-002)
- 1 строку с NULL order_id → отфильтровать
- 1 строку с quantity=0 → отфильтровать
Ожидаем 3 строки: ORD-001 (два дубля остаются!), ORD-002
"""
result = clean_orders(sample_orders)
assert result.count() == 3, f"Expected 3 rows, got {result.count()}"
5.2. Тестирование бизнес-логики вычислений¶
# tests/unit/test_calculate_total.py
from pyspark.testing import assertDataFrameEqual
from src.orders_pipeline import calculate_order_total
def test_calculate_total_with_discount(spark):
"""
Проверяем формулу: total = qty * price * (1 - discount)
Тест с конкретными числами, которые легко проверить вручную.
"""
from pyspark.sql import Row
input_df = spark.createDataFrame([
Row(order_id="ORD-001", quantity=2, unit_price=10.0, discount=0.1),
Row(order_id="ORD-002", quantity=1, unit_price=50.0, discount=0.0),
Row(order_id="ORD-003", quantity=3, unit_price=20.0, discount=0.5),
])
result = calculate_order_total(input_df)
expected = spark.createDataFrame([
Row(order_id="ORD-001", quantity=2, unit_price=10.0, discount=0.1, order_total=18.0),
Row(order_id="ORD-002", quantity=1, unit_price=50.0, discount=0.0, order_total=50.0),
Row(order_id="ORD-003", quantity=3, unit_price=20.0, discount=0.5, order_total=30.0),
])
assertDataFrameEqual(
result.select("order_id", "order_total"),
expected.select("order_id", "order_total"),
checkRowOrder=False,
atol=1e-6, # Допускаем floating point погрешность
)
def test_calculate_total_full_discount(spark):
"""
Граничный случай: скидка 100% (discount=1.0) → total = 0.
"""
input_df = spark.createDataFrame(
[("ORD-001", 5, 100.0, 1.0)],
schema="order_id STRING, quantity INT, unit_price DOUBLE, discount DOUBLE"
)
result = calculate_order_total(input_df)
total = result.select("order_total").collect()[0]["order_total"]
assert total == 0.0, f"100% discount should give 0 total, got {total}"
def test_calculate_total_preserves_other_columns(spark):
"""
calculate_order_total добавляет колонку, но не должна менять другие.
Проверяем через assertDataFrameEqual с checkSchema=True.
"""
input_df = spark.createDataFrame(
[("ORD-001", "CUST-01", 2, 10.0, 0.0)],
schema="order_id STRING, customer_id STRING, quantity INT, unit_price DOUBLE, discount DOUBLE"
)
result = calculate_order_total(input_df)
# Все исходные колонки должны присутствовать без изменений
assert "customer_id" in result.columns
assert "quantity" in result.columns
# Новая колонка добавлена
assert "order_total" in result.columns
5.3. Тестирование JOIN: ловушки дублирования¶
# tests/unit/test_enrich.py
from pyspark.testing import assertDataFrameEqual
from src.orders_pipeline import enrich_with_customer
def test_enrich_matches_known_customer(spark):
"""
Основной happy path: клиент есть в customers → поля заполнены.
"""
orders = spark.createDataFrame(
[("ORD-001", "CUST-01", 18.0)],
schema="order_id STRING, customer_id STRING, order_total DOUBLE"
)
customers = spark.createDataFrame(
[("CUST-01", "Alice Smith", "Premium")],
schema="customer_id STRING, customer_name STRING, segment STRING"
)
result = enrich_with_customer(orders, customers)
row = result.collect()[0]
assert row["customer_name"] == "Alice Smith"
assert row["segment"] == "Premium"
def test_enrich_preserves_order_without_customer(spark):
"""
LEFT JOIN: заказ без совпадающего клиента остаётся в результате.
customer_name и segment → NULL (это ожидаемое поведение).
ВАЖНО: это критичный тест! Если бы использовали INNER JOIN —
заказы без клиентов потерялись бы незаметно.
"""
orders = spark.createDataFrame(
[("ORD-999", "UNKNOWN-CUST", 50.0)],
schema="order_id STRING, customer_id STRING, order_total DOUBLE"
)
customers = spark.createDataFrame(
[("CUST-01", "Alice Smith", "Premium")],
schema="customer_id STRING, customer_name STRING, segment STRING"
)
result = enrich_with_customer(orders, customers)
assert result.count() == 1, "Order without customer should remain"
row = result.collect()[0]
assert row["customer_name"] is None, "customer_name should be NULL for unknown customer"
def test_enrich_deduplicates_customers_before_join(spark):
"""
ЛОВУШКА: если customers содержит дубли customer_id,
LEFT JOIN размножит строки заказов.
Функция enrich_with_customer ДОЛЖНА дедуплицировать customers
перед JOIN. Этот тест проверяет, что дедупликация работает.
Это реальный production-баг, который встречается часто:
'customers' пришёл без dedup → в Gold появились дубли заказов.
"""
orders = spark.createDataFrame(
[("ORD-001", "CUST-01", 18.0)],
schema="order_id STRING, customer_id STRING, order_total DOUBLE"
)
customers_with_dupes = spark.createDataFrame(
[("CUST-01", "Alice Smith", "Premium"),
("CUST-01", "Alice Smith", "Premium")], # Дубль!
schema="customer_id STRING, customer_name STRING, segment STRING"
)
result = enrich_with_customer(orders, customers_with_dupes)
# БЕЗ dedup в функции: count() = 2 (JOIN умножил строку заказа на 2)
# С dedup: count() = 1
assert result.count() == 1, \
"Duplicate customers should not multiply orders. " \
"Make sure enrich_with_customer deduplicates customers before JOIN."
5.4. Тестирование Window Functions и дедупликации¶
# tests/unit/test_dedup.py
from pyspark.testing import assertDataFrameEqual
from src.orders_pipeline import deduplicate_orders
def test_dedup_removes_duplicates(spark):
"""
Из двух строк с одинаковым order_id должна остаться одна.
"""
df = spark.createDataFrame(
[("ORD-001", "2024-01-15 10:00:00", 18.0),
("ORD-001", "2024-01-15 14:00:00", 18.0)], # Дубль
schema="order_id STRING, updated_at STRING, order_total DOUBLE"
)
result = deduplicate_orders(df)
assert result.count() == 1, "Duplicate order_id should result in 1 row"
def test_dedup_keeps_latest_by_updated_at(spark):
"""
При дублях должна остаться строка с максимальным updated_at.
Это бизнес-правило: более свежая версия заказа перекрывает старую.
"""
df = spark.createDataFrame(
[("ORD-001", "2024-01-15 10:00:00", 15.0), # Старая версия
("ORD-001", "2024-01-15 14:00:00", 18.0)], # Новая версия (скидка изменилась)
schema="order_id STRING, updated_at STRING, order_total DOUBLE"
)
result = deduplicate_orders(df)
row = result.collect()[0]
assert row["updated_at"] == "2024-01-15 14:00:00", \
"Should keep the most recent version (max updated_at)"
assert row["order_total"] == 18.0, \
"order_total should match the most recent version"
def test_dedup_handles_null_updated_at(spark):
"""
Граничный случай: updated_at = NULL.
Строки с NULL updated_at должны идти ПОСЛЕ строк с реальным timestamp.
В deduplicate_orders используем orderBy(...desc_nulls_last()),
что означает: NULL → последние в Window → удаляются при row_number > 1.
"""
df = spark.createDataFrame(
[("ORD-001", None, 15.0), # NULL updated_at
("ORD-001", "2024-01-15 10:00:00", 18.0)], # Реальный timestamp
schema="order_id STRING, updated_at STRING, order_total DOUBLE"
)
result = deduplicate_orders(df)
assert result.count() == 1
row = result.collect()[0]
# Должна остаться строка с реальным timestamp, не NULL
assert row["updated_at"] == "2024-01-15 10:00:00"
def test_dedup_preserves_unique_orders(spark):
"""
Строки с разными order_id не должны удаляться - они уникальны.
"""
df = spark.createDataFrame(
[("ORD-001", "2024-01-15 10:00:00", 18.0),
("ORD-002", "2024-01-15 11:00:00", 50.0),
("ORD-003", "2024-01-15 12:00:00", 30.0)],
schema="order_id STRING, updated_at STRING, order_total DOUBLE"
)
result = deduplicate_orders(df)
assert result.count() == 3, "Unique orders should all be preserved"
Часть 6. Тестирование схемы: защита от Schema Drift¶
6.1. Почему схема - это data contract¶
Schema drift - одна из главных причин production-инцидентов в lakehouse:
- Источник переименовал
user_idвuserId→ пайплайн читает NULL везде - Источник изменил
amountсDECIMAL(18,2)наSTRING→ агрегации сломались - Источник добавил колонку → downstream ждёт старую схему → ошибка
Unit-тесты схемы ловят эти проблемы до деплоя.
6.2. Тесты схемы: практика¶
# tests/schema/test_schema_contracts.py
import pytest
from pyspark.testing.utils import assertSchemaEqual
from pyspark.sql.types import (
StructType, StructField, StringType, IntegerType,
DoubleType, TimestampType, DateType, BooleanType
)
from src.orders_pipeline import clean_orders, calculate_order_total, enrich_with_customer
# ── Ожидаемые схемы (Data Contracts) ─────────────────────────────────────────
# Контракт на Bronze Orders схему (от источника)
BRONZE_ORDERS_SCHEMA = StructType([
StructField("order_id", StringType(), nullable=True),
StructField("customer_id", StringType(), nullable=False),
StructField("quantity", IntegerType(), nullable=True),
StructField("unit_price", DoubleType(), nullable=True),
StructField("discount", DoubleType(), nullable=True),
StructField("updated_at", StringType(), nullable=True),
])
# Контракт на Silver Orders схему (наш output)
SILVER_ORDERS_SCHEMA = StructType([
StructField("order_id", StringType(), nullable=True),
StructField("customer_id", StringType(), nullable=False),
StructField("quantity", IntegerType(), nullable=True),
StructField("unit_price", DoubleType(), nullable=True),
StructField("discount", DoubleType(), nullable=True),
StructField("updated_at", StringType(), nullable=True),
StructField("order_total", DoubleType(), nullable=True),
StructField("customer_name", StringType(), nullable=True),
StructField("segment", StringType(), nullable=True),
])
def test_clean_orders_output_schema(spark):
"""
clean_orders() не должна изменять схему (только фильтрует строки).
Тест защищает от случайного добавления/удаления колонок при рефакторинге.
"""
input_df = spark.createDataFrame(
[("ORD-001", "CUST-01", 2, 10.0, 0.1, "2024-01-15")],
schema=BRONZE_ORDERS_SCHEMA
)
result = clean_orders(input_df)
assertSchemaEqual(result.schema, BRONZE_ORDERS_SCHEMA, checkNullable=False)
def test_calculate_order_total_adds_column(spark):
"""
calculate_order_total() должна добавить order_total DOUBLE.
Если тип изменится (напр. Decimal вместо Double) - тест упадёт.
"""
input_df = spark.createDataFrame(
[("ORD-001", "CUST-01", 2, 10.0, 0.1, "2024-01-15")],
schema=BRONZE_ORDERS_SCHEMA
)
result = calculate_order_total(input_df)
assert "order_total" in result.columns, "order_total column must be present"
order_total_field = result.schema["order_total"]
assert isinstance(order_total_field.dataType, DoubleType), \
f"order_total must be DOUBLE, got {order_total_field.dataType}"
def test_schema_fail_on_missing_column(spark):
"""
Тест специально ПРОВАЛИВАЕТСЯ если отсутствует обязательная колонка.
Имитирует ситуацию schema drift:
источник прислал данные без customer_id (переименовал в 'cid').
"""
broken_schema_df = spark.createDataFrame(
[("ORD-001", "CUST-01", 2, 10.0)],
schema="order_id STRING, cid STRING, quantity INT, unit_price DOUBLE"
# Нет 'customer_id', нет 'discount', нет 'updated_at'
)
# Пытаемся применить функцию к DataFrame с неправильной схемой
with pytest.raises(Exception):
# clean_orders ожидает 'discount' колонку для coalesce
# На неправильной схеме должна упасть с AnalysisException
clean_orders(broken_schema_df).collect() # .collect() триггерит вычисление
def test_full_pipeline_output_schema(spark):
"""
End-to-end схема-тест: после всей цепочки трансформаций
схема должна точно соответствовать SILVER_ORDERS_SCHEMA.
"""
orders = spark.createDataFrame(
[("ORD-001", "CUST-01", 2, 10.0, 0.1, "2024-01-15")],
schema=BRONZE_ORDERS_SCHEMA
)
customers = spark.createDataFrame(
[("CUST-01", "Alice", "Premium")],
schema="customer_id STRING, customer_name STRING, segment STRING"
)
result = enrich_with_customer(calculate_order_total(clean_orders(orders)), customers)
assertSchemaEqual(result.schema, SILVER_ORDERS_SCHEMA, checkNullable=False)
Часть 7. pytest.raises: тестирование ожидаемых исключений¶
Fail-Fast логика - часть бизнес-правил пайплайна. Если входные данные невалидны, функция должна падать с понятной ошибкой, а не молча продолжать работу:
# tests/unit/test_validation.py
import pytest
from pyspark.sql.utils import AnalysisException
from src.orders_pipeline import calculate_order_total
def test_fails_on_missing_required_column(spark):
"""
Если входной DataFrame не содержит обязательную колонку 'quantity',
функция должна упасть с понятной ошибкой - не с NullPointerException.
pytest.raises(): тест ПРОХОДИТ если внутри блока произошло указанное исключение.
Тест ПАДАЕТ если исключения не было (функция не упала - баг!).
"""
df_missing_column = spark.createDataFrame(
[("ORD-001", 10.0)],
schema="order_id STRING, unit_price DOUBLE"
# Нет quantity, discount - обязательных для расчёта
)
with pytest.raises(AnalysisException) as exc_info:
calculate_order_total(df_missing_column).collect()
# Дополнительно: проверяем, что сообщение об ошибке понятное
assert "quantity" in str(exc_info.value).lower() or \
"UNRESOLVED_COLUMN" in str(exc_info.value), \
"Error message should mention the missing column"
def test_custom_validation_error(spark):
"""
Пример кастомной валидации с понятным сообщением об ошибке.
"""
from src.orders_pipeline import validate_and_clean
df_empty = spark.createDataFrame([], schema="order_id STRING, quantity INT, unit_price DOUBLE, discount DOUBLE")
with pytest.raises(ValueError, match="DataFrame is empty"):
validate_and_clean(df_empty) # Должна падать с ValueError, а не молча возвращать пустой DF
Часть 8. Integration Testing: IO и end-to-end¶
8.1. Различие unit и integration тестов¶
8.2. Integration тест с временной директорией¶
# tests/integration/test_pipeline_e2e.py
import pytest
import tempfile
import os
from pathlib import Path
from pyspark.testing import assertDataFrameEqual
@pytest.fixture(scope="function")
def tmp_dir():
"""Временная директория для тестовых Parquet/Delta файлов."""
with tempfile.TemporaryDirectory() as tmpdir:
yield tmpdir
def test_write_and_read_parquet_roundtrip(spark, tmp_dir):
"""
Integration-тест: запись в Parquet и чтение обратно.
Проверяет корректность сериализации схемы.
Это простейший integration-тест - никакого реального S3/HDFS,
только локальная файловая система.
"""
original = spark.createDataFrame(
[("ORD-001", 18.0), ("ORD-002", 50.0)],
schema="order_id STRING, order_total DOUBLE"
)
output_path = os.path.join(tmp_dir, "orders_output")
original.write.parquet(output_path)
read_back = spark.read.parquet(output_path)
assertDataFrameEqual(original, read_back, checkRowOrder=False)
def test_idempotent_partition_overwrite(spark, tmp_dir):
"""
Integration-тест: идемпотентность OVERWRITE.
Запустим пайплайн дважды → результат должен быть одинаковым (нет дублей).
"""
output_path = os.path.join(tmp_dir, "silver_orders")
orders = spark.createDataFrame(
[("ORD-001", "2024-01-15", 18.0),
("ORD-002", "2024-01-15", 50.0)],
schema="order_id STRING, order_date STRING, order_total DOUBLE"
)
# Первый запуск
orders.write.partitionBy("order_date").mode("overwrite").parquet(output_path)
# Второй запуск (повторный - должен быть идемпотентным)
orders.write.partitionBy("order_date").mode("overwrite").parquet(output_path)
result = spark.read.parquet(output_path)
assert result.count() == 2, \
"Idempotent overwrite should not create duplicates. " \
f"Expected 2 rows, got {result.count()}"
8.3. Contract tests между слоями Medallion¶
def test_bronze_to_silver_contract(spark, tmp_dir):
"""
Contract test: Silver читает Bronze и ожидает определённую схему.
Если Bronze изменит схему → этот тест упадёт → инженер узнает до prod.
"""
# Симулируем Bronze данные (как они приходят от источника)
bronze_path = os.path.join(tmp_dir, "bronze")
bronze_data = spark.createDataFrame(
[("ORD-001", "CUST-01", 2, 10.0, 0.1, "2024-01-15 10:00:00"),
("ORD-002", "CUST-02", 1, 50.0, 0.0, "2024-01-15 11:00:00")],
schema="order_id STRING, customer_id STRING, quantity INT, "
"unit_price DOUBLE, discount DOUBLE, updated_at STRING"
)
bronze_data.write.parquet(bronze_path)
# Silver пайплайн читает Bronze
bronze_read = spark.read.parquet(bronze_path)
# Применяем Silver трансформации
from src.orders_pipeline import clean_orders, calculate_order_total
silver_result = calculate_order_total(clean_orders(bronze_read))
# Контракт: Silver должен содержать order_total
assert "order_total" in silver_result.columns
assert silver_result.filter("order_total IS NULL").count() == 0, \
"All Silver rows should have non-NULL order_total"
Часть 9. CI/CD: запуск тестов автоматически¶
9.1. GitHub Actions конфигурация¶
# .github/workflows/test-pyspark.yml
# Запускается при каждом Push/PR в main
name: PySpark Unit Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Set up Java 11
# PySpark требует Java! Без этого шага - ошибка JAVA_HOME
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "11"
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Run unit tests
env:
# Spark логи: только WARN/ERROR в CI - без INFO спама
SPARK_LOCAL_IP: "127.0.0.1"
PYSPARK_PYTHON: python3
run: |
pytest tests/unit/ \
-v \
--tb=short \
--junitxml=test-results/unit-results.xml \
--cov=src \
--cov-report=xml:coverage.xml \
--cov-report=term-missing
- name: Run schema tests
run: |
pytest tests/schema/ -v --tb=short
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: test-results/
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: coverage.xml
9.2. Ускорение тестов: типичные проблемы и решения¶
# Проблема 1: SparkSession создаётся на каждый тест (медленно)
# Решение: scope="session" в conftest.py (уже показано выше)
# Проблема 2: Слишком много партиций (shuffle overhead на маленьких данных)
# В conftest.py уже есть:
# .config("spark.sql.shuffle.partitions", "4") # Вместо default 200
# Проблема 3: Тесты зависят от порядка выполнения (shared state)
# Решение: scope="function" для fixture данных (уже показано)
# Проблема 4: Тяжёлые integration-тесты запускаются в CI на каждый commit
# Решение: маркировка тестов
# pytest.ini (добавить):
# markers =
# integration: Integration tests (slow, require storage)
# unit: Unit tests (fast, no IO)
# Пример маркировки:
import pytest
@pytest.mark.unit
def test_clean_orders_fast(spark):
pass
@pytest.mark.integration
@pytest.mark.slow
def test_pipeline_with_real_storage(spark):
pass
# Запуск только быстрых unit-тестов:
# pytest tests/ -m "unit and not slow"
# Запуск только integration тестов (например, на PR в main):
# pytest tests/ -m "integration"
Часть 10. End-to-End Production Кейс: test suite для Orders ETL¶
10.1. Намеренный баг: как тесты его ловят¶
Продемонстрируем на практике, как тесты ловят реальный баг:
"""
СЦЕНАРИЙ: разработчик сделал "небольшой рефакторинг" формулы расчёта скидки.
Было: total = qty * price * (1 - discount)
Стало: total = qty * price - discount ← БАГ! discount теперь трактуется как сумма, а не процент
В production: отчёты о выручке показывают на 10% больше для Premium-клиентов.
Бизнес паникует. Разбор занимает 2 дня.
С unit-тестами: CI падает немедленно.
"""
# Версия с багом
def calculate_order_total_BUGGY(df: DataFrame) -> DataFrame:
return df.withColumn(
"order_total",
F.round(
F.col("quantity") * F.col("unit_price") - F.col("discount"), # БАГИ!
2
)
)
# Тест немедленно покажет ошибку:
def test_catches_formula_bug(spark):
"""Этот тест УПАДЁТ на buggy версии."""
df = spark.createDataFrame(
[("ORD-001", 2, 10.0, 0.1)],
schema="order_id STRING, quantity INT, unit_price DOUBLE, discount DOUBLE"
)
# Правильный расчёт: 2 * 10 * (1 - 0.1) = 18.0
# Багованный расчёт: 2 * 10 - 0.1 = 19.9
result = calculate_order_total_BUGGY(df)
total = result.select("order_total").collect()[0]["order_total"]
assert abs(total - 18.0) < 0.01, \
f"order_total should be 18.0 (qty*price*(1-discount)), got {total}. " \
f"Check the discount formula: it should be multiplicative (1-discount), " \
f"not subtractive (-discount)."
# AssertionError: order_total should be 18.0, got 19.9.
# Check the discount formula: it should be multiplicative...
# → CI падает → PR не мержится → production спасён
10.2. Полный test suite: структура и запуск¶
"""
Структура полного test suite для Orders ETL Pipeline.
Файлы:
tests/conftest.py - SparkSession, shared fixtures
tests/unit/test_clean_orders.py - 5 тестов, покрытие 95%
tests/unit/test_calculate_total.py - 4 теста, все граничные случаи
tests/unit/test_enrich.py - 4 теста, включая join ловушки
tests/unit/test_dedup.py - 4 теста, включая NULL в sort
tests/schema/test_contracts.py - 5 schema-тестов
tests/integration/test_e2e.py - 3 integration-теста
Итого: 25 тестов
Время выполнения: ~15-20 секунд (SparkSession ~5с + тесты ~10-15с)
"""
# Запуск всех тестов:
# pytest tests/ -v
# Только unit-тесты (быстро, для локальной разработки):
# pytest tests/unit/ tests/schema/ -v
# С coverage отчётом:
# pytest tests/unit/ --cov=src --cov-report=html
# Конкретный тест для отладки:
# pytest tests/unit/test_enrich.py::test_enrich_deduplicates_customers_before_join -v -s
10.3. Чек-лист: готов ли PySpark-код к тестированию¶
"""
Чек-лист перед написанием тестов.
"""
TESTING_READINESS_CHECKLIST = {
"pure_functions": {
"check": "Трансформации вынесены в отдельные функции DataFrame → DataFrame",
"verify": "Есть ли функции, которые можно вызвать без SparkSession.read/write?",
},
"io_separated": {
"check": "IO (read/write) отделён от трансформационной логики",
"verify": "Можно ли тестировать clean/transform/enrich без файлов на диске?",
},
"no_globals": {
"check": "SparkSession не создаётся на уровне модуля",
"verify": "Нет spark = SparkSession... вне функций?",
},
"deterministic": {
"check": "Трансформации детерминированы (нет current_timestamp(), rand() в ключах)",
"verify": "Один и тот же input всегда даёт один и тот же output?",
},
"explicit_schema": {
"check": "Схемы колонок явные (не infer-schema при чтении)",
"verify": "Используется StructType или явная строка схемы в createDataFrame?",
},
"error_messages": {
"check": "Ошибки содержат понятное описание причины",
"verify": "AssertionError с 'Expected X, got Y' лучше чем просто assert X == Y",
},
"edge_cases_covered": {
"check": "Тесты покрывают NULL, empty DataFrame, дубли",
"verify": "Есть ли тест с NULL в ключевых колонках?",
},
"assertDataFrameEqual": {
"check": "Используется assertDataFrameEqual вместо collect()==",
"verify": "checkRowOrder=False везде?",
},
}
Итоги урока¶
Тестирование PySpark - не опциональная практика, а инженерная дисциплина, определяющая зрелость data platform.
Ключевые выводы:
-
Архитектура важнее инструментов: тестируемый код - это код с чистыми функциями (DataFrame → DataFrame), изолированным IO и детерминированными трансформациями. Без правильной архитектуры никакой pytest не поможет.
-
assertDataFrameEqual- стандарт с Spark 3.4: забудьте про.collect() == .... ИспользуйтеcheckRowOrder=False(distributed = нет гарантий порядка),atol/rtolдля float,checkSchema=Trueдля schema-тестов. -
Малые датасеты - сила, не слабость: 5–10 строк в тесте лучше чем 1000. Каждая строка тестирует конкретный сценарий. Тест должен быть понятен без запуска.
-
Schema tests - дешёвая страховка от drift:
assertSchemaEqualна каждую трансформацию ловит переименования колонок, изменения типов и nullable-нарушения до деплоя. -
JOIN тесты - обязательны: дублирование строк из-за некорректного JOIN - самый частый тихий баг в Spark ETL. Тест с дублями в Customers - must-have.
-
CI/CD = gate before production: тесты не запускаемые автоматически на каждый PR существуют только на бумаге. GitHub Actions с
setup-java(Spark требует JVM) + кэшированием pip - настраивается за 30 минут.