LocalStack и Testcontainers: локальная S3-эмуляция - ETL-тесты без реального кластера

Полное руководство по тестированию Spark ETL-пайплайнов с LocalStack и Testcontainers: архитектура эфемерных сред, настройка Hadoop S3A коннектора, написание сквозных интеграционных тестов в Pytest, оптимизация CI/CD и troubleshooting типичных ошибок.

storage testing

Почему тестирование S3-взаимодействия - это отдельная задача

Когда Data Engineer пишет Spark-пайплайн, он обычно начинает тестировать его на юнит-уровне: создаёт in-memory DataFrame, передаёт его в функцию трансформации и проверяет результат. Такие тесты быстрые, изолированные и не требуют никакой инфраструктуры. Они проверяют бизнес-логику: дедупликацию, очистку данных, агрегацию.

Но Spark ETL - это не только трансформации. Реальный пайплайн читает данные из S3, пишет результат обратно в S3, создаёт файлы метаданных Delta Lake или Iceberg, использует конкретные форматы файлов (Parquet, ORC), работает с партиционированными директориями. Всё это - поведение файловой системы, а не логика трансформаций.

И именно здесь юнит-тесты перестают работать:

  • S3A Connector конфигурируется отдельно от бизнес-логики. Неправильный endpoint, забытый флаг path.style.access, не тот credentials provider - и пайплайн упадёт только в CI, но не в юнит-тестах.
  • Committer алгоритм влияет на атомарность записи. Staging committer создаёт временные директории определённым образом. Это невозможно проверить в памяти.
  • Schema Enforcement в Delta Lake срабатывает только при реальной записи в файловую систему. df.write.format("delta") в памяти не существует.
  • Путь до файла имеет значение. s3a://bucket/path/to/data vs s3://bucket/path/to/data - это разные схемы, разные коннекторы, разное поведение. Ошибка в URL-схеме не выловится юнит-тестом.

Вывод: интеграционные тесты с реальной файловой системой необходимы. Вопрос только в том, как сделать их быстрыми, дешёвыми и воспроизводимыми.


Антипаттерны тестирования S3: почему они не работают

Прежде чем перейти к правильному решению, разберём распространённые подходы, которые кажутся простыми, но создают проблемы.

Антипаттерн 1: Тесты против живого staging-бакета

Кажется очевидным: есть тестовый S3-бакет - пиши тесты против него. Это работает для одного разработчика, но разваливается при командной работе:

  • Параллельные запуски конфликтуют. Два разработчика запускают один и тот же тест одновременно. Оба пишут в s3://test-bucket/output/. Первый проверяет результат - читает данные второго. Тест нестабильно падает.
  • CI/CD добавляет третий источник. Теперь в test-bucket пишут: разработчик A, разработчик B и CI pipeline. Данные смешиваются, тесты мешают друг другу.
  • Очистка - это тоже код. Нужен setup/teardown для создания и удаления данных. Если тест упал на середине - мусор остаётся в бакете навсегда.
  • Сетевые задержки в CI замедляют тесты. Запрос к реальному S3 из GitLab CI - это задержка 50-200 мс на каждый API-вызов. Тест из 100 S3-операций работает 10-20 секунд только на сетевые ожидания.
  • Credentials нужно хранить в CI. AWS ключи в переменных окружения CI - это security риск, который требует аудита и ротации.

Антипаттерн 2: Мокирование через unittest.mock

# Кажется умным решением - замокировать boto3 и SparkContext
from unittest.mock import patch, MagicMock

@patch("boto3.client")
@patch("pyspark.sql.SparkSession.read")
def test_pipeline(mock_read, mock_boto):
    mock_read.return_value.parquet.return_value = mock_df
    mock_boto.return_value.list_objects_v2.return_value = {...}

    result = run_pipeline("s3a://bucket/input", "s3a://bucket/output")
    assert result.count() == 100

Этот тест не тестирует ничего реального. Проблемы:

  • S3A Connector не задействован вообще. Вы мокируете SparkSession, но не проверяете, правильно ли настроен hadoop.fs.s3a.endpoint. В продакшне Spark упадёт с java.io.IOException: No FileSystem for scheme: s3a.
  • Конфигурация SparkSession не тестируется. Параметры spark.hadoop.* не проходят никакой проверки.
  • Патч нарушает изоляцию. После теста mock остаётся активным. Если следующий тест создаёт реальный клиент - он получит mock. Порядок тестов начинает влиять на результат.
  • Это не интеграционный тест, это документация в виде кода. Вы описываете, как, по вашему мнению, должны работать внешние системы. Но если реальная система работает иначе - вы не узнаете об этом.

Правильное решение: эфемерные среды

Эфемерная среда (ephemeral environment) - это инфраструктура, которая создаётся в начале теста и полностью уничтожается после. Она существует только на время выполнения теста и не разделяется с другими тестами.

Для тестирования S3-взаимодействия Spark-пайплайнов эфемерная среда означает: локальный S3-эмулятор в Docker-контейнере, который поднимается перед тестом и удаляется после.

Схема наглядно показывает разницу: в антипаттерне все три процесса пишут в одно место и мешают друг другу. В правильном подходе каждый процесс получает свой изолированный контейнер с S3-эмулятором, которые никак не пересекаются. Конфликты данных невозможны по определению.


LocalStack: архитектура и возможности

LocalStack - open-source эмулятор AWS-сервисов, который запускается в Docker. Его главная задача - воспроизвести API поведение AWS локально, чтобы код, написанный для работы с AWS, работал с LocalStack без изменений.

Что эмулирует LocalStack

LocalStack реализует десятки AWS-сервисов. Для задач Data Engineering наиболее важны:

Сервис Применение в Data Engineering
S3 Основное хранилище данных: bronze/silver/gold бакеты
STS Security Token Service - получение временных credentials
Glue Метастор для Delta Lake/Iceberg таблиц
Lambda Функции обработки событий (опционально)
SQS/SNS Очереди для event-driven архитектур

Для тестирования Spark S3A-взаимодействия достаточно только S3 - это входит в бесплатный Community план LocalStack.

Как LocalStack эмулирует S3

LocalStack запускает HTTP-сервер, который принимает входящие запросы по протоколу AWS S3 REST API. Внутри он хранит «объекты» в памяти или в локальной файловой системе контейнера. С точки зрения клиента (boto3, S3A Connector) - это настоящий S3 endpoint.

Ключевой момент на схеме: LocalStack - это просто HTTP-сервер. Spark S3A Connector и boto3 отправляют стандартные HTTP-запросы по AWS S3 протоколу. LocalStack принимает их, интерпретирует и возвращает ответы в том же формате, что вернул бы настоящий AWS S3. Никакой магии - только корректная реализация REST API.

Ограничения LocalStack Community Edition

LocalStack полезен, но важно понимать его ограничения:

  • IAM политики не полностью эмулированы. В Community LocalStack все запросы принимаются с любыми credentials (даже с test/test). В реальном AWS работают сложные IAM политики. Ошибки IAM обнаружатся только в staging.
  • Нет S3 Select. Некоторые оптимизации Spark-коннектора используют S3 Select для фильтрации на стороне хранилища. LocalStack Community его не поддерживает.
  • Производительность отличается. LocalStack работает на одном ядре в контейнере. Тест может пройти успешно, но в продакшне пайплайн упрётся в S3 throttling или сетевую задержку.
  • Версионирование объектов работает ограниченно в бесплатной версии.

Эти ограничения не делают LocalStack менее полезным - они просто объясняют, почему LocalStack-тесты дополняют, а не заменяют staging-тестирование.


Testcontainers: управление Docker из Python

Testcontainers - библиотека, которая позволяет управлять Docker-контейнерами программно, прямо из Python-кода. Главная идея: контейнер поднимается вместе с тестом и удаляется после его завершения, даже если тест упал с ошибкой.

Почему не просто docker-compose

Docker Compose - стандартный инструмент для локальной разработки. Его можно использовать для поднятия LocalStack перед тестами. Но у этого подхода есть проблемы:

  • Ручное управление жизненным циклом. Разработчик должен помнить запустить docker-compose up перед тестами и docker-compose down после. В CI нужны дополнительные шаги в pipeline конфигурации.
  • Порт фиксирован. Если в docker-compose.yml написано 4566:4566, тест ожидает именно этот порт. Два параллельных CI запуска на одном хосте конфликтуют.
  • Нет ожидания готовности. Docker Compose поднимает контейнер, но не знает, когда сервис внутри него готов принимать запросы. Тест может начаться, пока LocalStack ещё инициализируется.

Testcontainers решает все эти проблемы:

from testcontainers.localstack import LocalStackContainer

# Testcontainers сам:
# 1. Скачивает образ localstack/localstack:latest (если нет)
# 2. Запускает контейнер
# 3. Маппирует случайный свободный порт хоста → 4566 контейнера
# 4. Ждёт, пока LocalStack полностью инициализируется (healthcheck)
# 5. Возвращает управление тесту
with LocalStackContainer(image="localstack/localstack:3.4") as localstack:
    endpoint = localstack.get_url()
    # endpoint = "http://localhost:54321" (случайный порт!)

    # ... тест ...

# После выхода из контекста контейнер автоматически останавливается и удаляется

Архитектура Testcontainers

Схема показывает поток взаимодействия: pytest вызывает Testcontainers API → Testcontainers общается с Docker Daemon через Unix socket → Docker поднимает контейнер с LocalStack → Testcontainers ожидает готовности через healthcheck → pytest получает адрес и порт для подключения. Всё управление жизненным циклом контейнера находится внутри pytest-сессии.


Настройка зависимостей и окружения

Необходимые пакеты

# Установка через pip
pip install \
    pyspark==3.5.1 \
    boto3==1.34.0 \
    testcontainers[localstack]==4.7.0 \
    pytest==8.1.0 \
    pytest-timeout==2.3.0

# Или через requirements-test.txt (рекомендуется)
# requirements-test.txt
pyspark==3.5.1
boto3>=1.34.0
testcontainers[localstack]>=4.7.0
pytest>=8.1.0
pytest-timeout>=2.3.0

Объяснение зависимостей:

  • testcontainers[localstack] - дополнительный пакет включает LocalStackContainer класс и удобные методы получения S3 endpoint URL.
  • pytest-timeout - устанавливает максимальное время выполнения теста. Необходим, потому что тесты с Docker могут зависнуть при проблемах с сетью или Docker Daemon.
  • boto3 - нужен не только для прямой работы с S3, но и для создания бакетов перед тестом (Spark не умеет создавать бакеты).

Hadoop JARы для S3A

PySpark не включает в себя Hadoop AWS коннектор по умолчанию. Его нужно добавить явно. Существует несколько способов:

Способ 1: через Spark packages (рекомендуется для разработки)

spark = SparkSession.builder \
    .config("spark.jars.packages",
            "org.apache.hadoop:hadoop-aws:3.3.4,"
            "com.amazonaws:aws-java-sdk-bundle:1.12.367") \
    .getOrCreate()

При первом запуске Spark скачает JAR-файлы из Maven Central. При последующих - использует кеш ~/.ivy2/cache/. В CI нужно кешировать этот каталог.

Способ 2: через переменную среды (рекомендуется для CI)

# Dockerfile или CI конфигурация
ENV SPARK_HOME=/opt/spark
ENV HADOOP_CONF_DIR=/etc/hadoop/conf

# Копируем JARы в директорию Spark
COPY hadoop-aws-3.3.4.jar $SPARK_HOME/jars/
COPY aws-java-sdk-bundle-1.12.367.jar $SPARK_HOME/jars/

Способ 3: через PYSPARK_SUBMIT_ARGS

import os
os.environ["PYSPARK_SUBMIT_ARGS"] = (
    "--packages org.apache.hadoop:hadoop-aws:3.3.4,"
    "com.amazonaws:aws-java-sdk-bundle:1.12.367 "
    "pyspark-shell"
)

Важно: версии hadoop-aws и aws-java-sdk-bundle должны быть совместимы между собой и с версией Hadoop, с которой собрана ваша PySpark. Для PySpark 3.5.x используйте hadoop-aws:3.3.4.


Path-Style vs Virtual-Hosted-Style: почему это важно для LocalStack

Это один из самых частых источников ошибок при первом знакомстве с LocalStack и Spark.

Virtual-Hosted-Style (стандарт AWS)

https://{bucket-name}.s3.amazonaws.com/{object-key}

Пример: https://my-bucket.s3.amazonaws.com/data/file.parquet

В этом формате имя бакета является поддоменом. AWS DNS инфраструктура автоматически резолвит my-bucket.s3.amazonaws.com в правильный S3 endpoint. Это работает потому, что AWS управляет DNS для домена amazonaws.com.

Path-Style (устаревший, но необходимый для эмуляторов)

https://s3.amazonaws.com/{bucket-name}/{object-key}

Пример: https://s3.amazonaws.com/my-bucket/data/file.parquet

В этом формате имя бакета является частью пути, а не поддоменом. Это старый формат AWS, который они официально «устаревают» с 2020 года. Но для локальных эмуляторов он единственно возможный.

Почему LocalStack требует Path-Style

LocalStack запускается на localhost:4566. Когда Spark пытается сделать запрос с Virtual-Hosted-Style:

http://my-bucket.localhost:4566/data/file.parquet

DNS не может разрезолвить my-bucket.localhost - это не домен. Запрос упадёт с ошибкой соединения ещё до того, как LocalStack его увидит.

С Path-Style:

http://localhost:4566/my-bucket/data/file.parquet

Запрос идёт напрямую на localhost:4566, LocalStack его получает и парсит имя бакета из пути. Это работает.

Схема ставит точку в вопросе: Virtual-Hosted работает с AWS потому что AWS контролирует DNS и умеет резолвить поддомены бакетов. С LocalStack так не работает физически, поэтому path.style.access=true обязателен.


Конфигурация SparkSession для LocalStack

Это центральная часть урока. Правильная конфигурация SparkSession определяет, будет ли работать S3A Connector с LocalStack вообще.

from pyspark.sql import SparkSession
from testcontainers.localstack import LocalStackContainer


def create_spark_for_localstack(endpoint_url: str) -> SparkSession:
    """
    Создаёт SparkSession с конфигурацией S3A Connector для LocalStack.

    endpoint_url: URL LocalStack контейнера, например "http://localhost:54321"
    Порт динамически выбирается Testcontainers - не хардкодьте его.
    """
    return (
        SparkSession.builder
        .master("local[2]")
        .appName("integration-tests")

        # ── Hadoop AWS JARы ──────────────────────────────────────────
        # Указываем пакеты, которые Spark скачает из Maven.
        # hadoop-aws: реализует FileSystem API для протокола s3a://
        # aws-java-sdk-bundle: клиент AWS API (используется hadoop-aws внутри)
        .config(
            "spark.jars.packages",
            "org.apache.hadoop:hadoop-aws:3.3.4,"
            "com.amazonaws:aws-java-sdk-bundle:1.12.367"
        )

        # ── S3A Endpoint ─────────────────────────────────────────────
        # Перенаправляем все s3a:// запросы на LocalStack вместо реального AWS.
        # Без этого параметра Spark будет пытаться соединиться с
        # https://s3.amazonaws.com и упадёт с NoSuchBucketException или
        # AWSException: Unable to execute HTTP request.
        .config("spark.hadoop.fs.s3a.endpoint", endpoint_url)

        # ── Path Style Access ────────────────────────────────────────
        # true: использовать http://host/bucket/key (работает с LocalStack)
        # false: использовать http://bucket.host/key (Virtual-Hosted, только для AWS)
        # Подробно разобрано в предыдущем разделе.
        .config("spark.hadoop.fs.s3a.path.style.access", "true")

        # ── Credentials Provider ─────────────────────────────────────
        # LocalStack принимает любые credentials, но Spark должен их предоставить.
        # SimpleAWSCredentialsProvider: берёт ключи из конфигурации напрямую
        # (в отличие от InstanceProfileCredentialsProvider, который ищет IMDSv2).
        # Без этого Spark может попытаться обратиться к EC2 Instance Metadata
        # Service и зависнуть на 30 секунд ожидания ответа от несуществующего URL.
        .config(
            "spark.hadoop.fs.s3a.aws.credentials.provider",
            "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider"
        )

        # ── Фиктивные credentials ────────────────────────────────────
        # LocalStack игнорирует конкретные значения, но они должны быть
        # непустыми - S3A Connector валидирует их формат перед отправкой.
        # Конвенция: используйте "test"/"test" - это стандарт для LocalStack.
        .config("spark.hadoop.fs.s3a.access.key", "test")
        .config("spark.hadoop.fs.s3a.secret.key", "test")

        # ── SSL ──────────────────────────────────────────────────────
        # LocalStack работает по HTTP (не HTTPS). Отключаем SSL проверку,
        # чтобы S3A Connector не пытался валидировать TLS сертификат.
        .config("spark.hadoop.fs.s3a.connection.ssl.enabled", "false")

        # ── S3A Committer ────────────────────────────────────────────
        # Для тестов используем простой FileOutputCommitter v1.
        # В продакшне с MinIO или S3 нужен magic committer или staging committer
        # (разобрано в уроке 3 этого модуля), но для локальных тестов
        # v1 достаточен и не требует дополнительной настройки.
        .config("spark.hadoop.mapreduce.fileoutputcommitter.algorithm.version", "2")

        # ── Delta Lake (опционально) ─────────────────────────────────
        # Если тестируете Delta Lake пайплайны, добавьте эти параметры.
        # Они позволяют Delta использовать S3A для хранения логов транзакций.
        # .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
        # .config("spark.sql.catalog.spark_catalog",
        #         "org.apache.spark.sql.delta.catalog.DeltaCatalog")

        # ── Производительность ───────────────────────────────────────
        # Для тестов уменьшаем overhead от параллелизма.
        # В продакшне эти параметры не трогайте.
        .config("spark.sql.shuffle.partitions", "4")
        .config("spark.default.parallelism", "4")

        .getOrCreate()
    )

Каждый параметр в этой конфигурации имеет конкретную причину. Пропуск любого из них приводит к ошибке, которую сложно диагностировать, потому что сообщения об ошибках S3A Connector зачастую не очевидны. Рассмотрим самые типичные:

Пропущенный параметр Ошибка Почему
fs.s3a.endpoint AmazonS3Exception: 403 Forbidden Spark идёт на реальный AWS, ключи test/test не работают
path.style.access UnknownHostException: my-bucket.localhost DNS не резолвит поддомен бакета
credentials.provider Зависание на 30 сек Spark ищет IMDSv2 сервис EC2, которого нет
ssl.enabled=false SSLHandshakeException LocalStack работает на HTTP, не HTTPS

Архитектура тестового окружения: pytest fixtures

В pytest принято разделять код инфраструктуры (что и как поднимать) и код тестов (что тестировать). Это достигается через fixtures - функции, которые pytest вызывает автоматически перед каждым тестом.

Scope фикстур и стоимость запуска

Поднятие LocalStack занимает 5-15 секунд. Создание SparkSession - ещё 10-20 секунд. Если делать это для каждого теста, тесты будут работать очень медленно. Решение - использовать правильный scope у фикстур:

Scope Когда создаётся Когда удаляется Подходит для
function (по умолчанию) Перед каждым тестом После каждого теста Данных, которые должны быть изолированы
module Перед первым тестом модуля После последнего теста модуля Дорогих ресурсов в одном файле
session Один раз для всей сессии После всех тестов LocalStack, SparkSession

Схема иллюстрирует стратегию: дорогие ресурсы (LocalStack, SparkSession) создаются один раз на всю сессию. Бакеты создаются один раз на модуль. Тестовые данные - свежие для каждого теста. Это оптимальный баланс между изоляцией и скоростью.

conftest.py: главный файл конфигурации тестов

# tests/conftest.py

import pytest
import boto3
from botocore.config import Config
from testcontainers.localstack import LocalStackContainer
from pyspark.sql import SparkSession


# ─── Инфраструктурные фикстуры ────────────────────────────────────────────────

@pytest.fixture(scope="session")
def localstack_container():
    """
    Запускает LocalStack контейнер один раз на всю тестовую сессию.

    scope="session" означает: один контейнер для всех тестов.
    Это экономит ~10 секунд на каждый тест - время старта LocalStack.

    with LocalStackContainer(...) as container:
        Testcontainers автоматически:
        1. Запускает docker pull localstack/localstack:3.4 (если нет образа)
        2. Создаёт контейнер с случайным портом вместо 4566
        3. Ждёт, пока healthcheck вернёт 200 OK
        4. Yield - передаёт контейнер в тесты
        5. После всех тестов: docker stop + docker rm

    image="localstack/localstack:3.4" - pinned версия. Никогда не используйте
    "latest" в тестах - в следующем релизе LocalStack может сломать совместимость.
    """
    with LocalStackContainer(image="localstack/localstack:3.4") as container:
        yield container


@pytest.fixture(scope="session")
def localstack_endpoint(localstack_container):
    """
    Возвращает URL endpoint LocalStack.

    LocalStackContainer.get_url() возвращает "http://localhost:{random_port}".
    Порт выбирается Testcontainers автоматически из свободных портов хоста.
    Это позволяет запускать несколько тестовых сессий одновременно —
    у каждой будет свой порт.
    """
    url = localstack_container.get_url()
    print(f"\nLocalStack endpoint: {url}")
    return url


@pytest.fixture(scope="session")
def s3_client(localstack_endpoint):
    """
    Создаёт boto3 S3 client, подключённый к LocalStack.

    Этот клиент используется для:
    1. Создания тестовых бакетов
    2. Загрузки входных тестовых данных
    3. Проверки результатов ETL (чтение метаданных, листинг файлов)

    Для boto3 также нужны специальные параметры для работы с LocalStack.
    endpoint_url: направляем запросы в LocalStack, а не в AWS.
    aws_access_key_id / secret_access_key: любые непустые значения.
    region_name: LocalStack принимает любой регион. "us-east-1" - конвенция.

    Config(signature_version="s3v4"): принудительно использовать Signature V4
    (SigV4) для подписи запросов. LocalStack поддерживает SigV4.
    Без этого параметра boto3 иногда использует Signature V2 (устаревший),
    что может вызвать ошибки аутентификации.
    """
    client = boto3.client(
        "s3",
        endpoint_url=localstack_endpoint,
        aws_access_key_id="test",
        aws_secret_access_key="test",
        region_name="us-east-1",
        config=Config(signature_version="s3v4")
    )
    return client


@pytest.fixture(scope="session")
def spark(localstack_endpoint):
    """
    Создаёт SparkSession с конфигурацией S3A для LocalStack.

    scope="session": SparkSession создаётся один раз на всю тестовую сессию.
    Создание SparkSession - дорогая операция (запуск JVM, загрузка JARов).
    При scope="function" каждый тест тратит 15-20 секунд только на старт Spark.

    Важный момент: SparkSession - синглтон в рамках JVM.
    Если сессия уже создана с одним набором конфигов, второй вызов
    SparkSession.builder.getOrCreate() вернёт ту же сессию с теми же конфигами.
    Поэтому session-scope фикстура - правильное решение.
    """
    spark_session = (
        SparkSession.builder
        .master("local[2]")
        .appName("pyspark-integration-tests")
        .config(
            "spark.jars.packages",
            "org.apache.hadoop:hadoop-aws:3.3.4,"
            "com.amazonaws:aws-java-sdk-bundle:1.12.367"
        )
        .config("spark.hadoop.fs.s3a.endpoint", localstack_endpoint)
        .config("spark.hadoop.fs.s3a.path.style.access", "true")
        .config(
            "spark.hadoop.fs.s3a.aws.credentials.provider",
            "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider"
        )
        .config("spark.hadoop.fs.s3a.access.key", "test")
        .config("spark.hadoop.fs.s3a.secret.key", "test")
        .config("spark.hadoop.fs.s3a.connection.ssl.enabled", "false")
        .config("spark.sql.shuffle.partitions", "4")
        .config("spark.default.parallelism", "4")
        # Отключаем попытки обновить spark в тестах
        .config("spark.ui.enabled", "false")
        .getOrCreate()
    )

    # Выставляем уровень логирования: WARN скрывает INFO-сообщения Spark,
    # которые загромождают вывод тестов. ERROR будет ещё тише.
    spark_session.sparkContext.setLogLevel("WARN")

    yield spark_session

    # После завершения всех тестов - останавливаем SparkSession.
    # Без этого JVM процесс может остаться активным после pytest.
    spark_session.stop()


# ─── Фикстуры данных ─────────────────────────────────────────────────────────

@pytest.fixture(scope="module")
def test_buckets(s3_client):
    """
    Создаёт тестовые бакеты перед началом тестов в модуле.

    scope="module": бакеты создаются один раз для всех тестов в одном .py файле.
    Бакеты не удаляются между тестами - только тестовые данные внутри них.

    Почему LocalStack не требует LocationConstraint для us-east-1?
    В реальном AWS при создании бакета в us-east-1 нельзя передавать
    CreateBucketConfiguration - это вызовет ошибку. Но для us-east-1 это
    правило особое, и LocalStack его воспроизводит корректно.
    """
    bucket_names = ["bronze", "silver", "gold"]

    for bucket in bucket_names:
        try:
            # LocalStack не жалуется на повторное создание - он идемпотентен.
            # В реальном S3 повторное создание своего бакета в том же регионе
            # вернёт BucketAlreadyOwnedByYou (не ошибка) или успех.
            s3_client.create_bucket(Bucket=bucket)
            print(f"Создан бакет: {bucket}")
        except s3_client.exceptions.BucketAlreadyExists:
            print(f"Бакет уже существует: {bucket}")

    yield bucket_names

    # Teardown: очищаем содержимое бакетов (не сами бакеты)
    # Это позволяет следующим тестовым модулям создавать бакеты заново.
    for bucket in bucket_names:
        try:
            paginator = s3_client.get_paginator("list_objects_v2")
            for page in paginator.paginate(Bucket=bucket):
                objects = page.get("Contents", [])
                if objects:
                    s3_client.delete_objects(
                        Bucket=bucket,
                        Delete={"Objects": [{"Key": o["Key"]} for o in objects]}
                    )
        except Exception as e:
            print(f"Ошибка при очистке {bucket}: {e}")

ETL-функции для тестирования

Прежде чем писать тесты, нужно иметь что тестировать. Создадим реалистичный набор ETL-функций, имитирующих стадии Medallion Architecture.

# src/pipeline/bronze_ingestion.py

from pyspark.sql import SparkSession, DataFrame
from pyspark.sql import functions as F
from pyspark.sql.types import (
    StructType, StructField, StringType,
    LongType, TimestampType, IntegerType, DoubleType
)
import json


# Схема входящих событий от источника (JSON файлы в bronze)
EVENT_SCHEMA = StructType([
    StructField("event_id",   StringType(),    nullable=False),
    StructField("user_id",    LongType(),      nullable=False),
    StructField("event_type", StringType(),    nullable=True),
    StructField("timestamp",  StringType(),    nullable=True),
    StructField("session_id", StringType(),    nullable=True),
    StructField("amount",     DoubleType(),    nullable=True),
    StructField("currency",   StringType(),    nullable=True),
    StructField("country",    StringType(),    nullable=True),
])


def ingest_json_to_bronze(
    spark: SparkSession,
    source_path: str,
    bronze_path: str,
    date_partition: str
) -> int:
    """
    Bronze ingestion: читает сырые JSON файлы и записывает в Parquet.

    Это первая стадия Medallion Architecture. Здесь мы:
    1. Читаем JSON с явной схемой (не inferSchema - он медленнее и ненадёжен
       для больших файлов, так как требует второго прохода по данным)
    2. Добавляем технические поля (ingestion timestamp, partition)
    3. Записываем в Parquet с партиционированием по дате
    4. Возвращаем количество записанных строк для метрик

    source_path: s3a://bronze/incoming/2024/01/15/
    bronze_path: s3a://bronze/events/
    date_partition: "2024-01-15" (используется для партиционирования)

    Возвращает: количество записанных строк.
    """
    # Читаем JSON с явной схемой. multiLine=False: каждая строка - один JSON-объект
    # (JSON Lines формат, также известный как NDJSON или jsonl)
    raw_df = spark.read \
        .option("multiLine", "false") \
        .schema(EVENT_SCHEMA) \
        .json(source_path)

    # Добавляем технические поля.
    # ingested_at: timestamp момента ingestion - полезен для отладки и SLA-мониторинга.
    # date_partition: дата партиционирования - используется в partitionBy ниже.
    # _source_path: откуда пришли данные - помогает в troubleshooting.
    enriched_df = raw_df.withColumns({
        "ingested_at":     F.current_timestamp(),
        "date_partition":  F.lit(date_partition),
        "_source_path":    F.lit(source_path),
    })

    # Считаем строки ДО записи - после write() df становится неактивным планом.
    row_count = enriched_df.count()

    # Записываем в Parquet, партиционированный по date_partition.
    # mode("append"): добавляем к существующим данным.
    # partitionBy: создаёт директорию date_partition=2024-01-15/ внутри bronze_path.
    # Это позволяет Spark читать только нужную дату через predicate pushdown.
    enriched_df.write \
        .mode("append") \
        .partitionBy("date_partition") \
        .parquet(bronze_path)

    return row_count


def deduplicate_to_silver(
    spark: SparkSession,
    bronze_path: str,
    silver_path: str,
    date_partition: str
) -> int:
    """
    Silver transformation: дедупликация и очистка данных.

    Это вторая стадия Medallion Architecture. Здесь мы:
    1. Читаем только нужную дату из Bronze (partition pruning)
    2. Удаляем дубликаты по event_id (идемпотентность пайплайна)
    3. Фильтруем записи с null в обязательных полях
    4. Нормализуем event_type (lowercase, trim)
    5. Записываем в Silver

    Дедупликация по event_id критически важна: источники данных часто
    отправляют повторные события (retry при сбоях сети, at-least-once delivery).
    В Silver каждый event_id должен встречаться ровно один раз.
    """
    # Читаем только нужную партицию - Spark использует partition pruning.
    # Физически читаются только файлы в date_partition=2024-01-15/.
    # Это в разы быстрее чем читать весь bronze_path с filter().
    bronze_df = spark.read \
        .parquet(bronze_path) \
        .filter(F.col("date_partition") == date_partition)

    # Дедупликация: оставляем одну запись для каждого event_id.
    # dropDuplicates(["event_id"]): недетерминированный выбор среди дублей.
    # Если нужен детерминированный (например, последний по timestamp),
    # используйте Window с row_number() - медленнее, но предсказуемо.
    deduped_df = bronze_df.dropDuplicates(["event_id"])

    # Фильтрация: отбрасываем записи без обязательных полей.
    # event_id и user_id - первичные ключи, без них запись бессмысленна.
    # event_type - нужен для бизнес-логики downstream агрегаций.
    clean_df = deduped_df.filter(
        F.col("event_id").isNotNull()
        & F.col("user_id").isNotNull()
        & F.col("event_type").isNotNull()
    )

    # Нормализация: стандартизируем event_type.
    # lower() + trim(): "  Purchase  " → "purchase"
    # Без нормализации одно событие может иметь разные написания:
    # "Purchase", "PURCHASE", " purchase" - это разные значения для GROUP BY.
    normalized_df = clean_df.withColumn(
        "event_type",
        F.lower(F.trim(F.col("event_type")))
    )

    row_count = normalized_df.count()

    normalized_df.write \
        .mode("overwrite") \
        .partitionBy("date_partition") \
        .parquet(silver_path)

    return row_count


def aggregate_to_gold(
    spark: SparkSession,
    silver_path: str,
    gold_path: str,
    date_partition: str
) -> DataFrame:
    """
    Gold aggregation: бизнес-метрики по дням и странам.

    Это третья стадия Medallion Architecture. Здесь мы считаем
    бизнес-агрегаты, которые напрямую используются BI-инструментами.

    Агрегаты:
    - Количество уникальных пользователей по стране и типу события
    - Суммарная сумма транзакций (для event_type="purchase")
    - Среднее время сессии (если доступно)

    В отличие от Bronze и Silver, Gold обычно не партиционируется —
    таблицы небольшие и читаются целиком аналитическими инструментами.
    """
    silver_df = spark.read \
        .parquet(silver_path) \
        .filter(F.col("date_partition") == date_partition)

    # Агрегация по стране и типу события.
    # countDistinct("user_id"): уникальные пользователи (не просто count строк).
    # sum("amount"): суммарный объём транзакций. coalesce(0): null → 0.
    # Важно: sum(null) = null в Spark SQL, поэтому нужен coalesce.
    gold_df = silver_df.groupBy("country", "event_type", "date_partition") \
        .agg(
            F.count("*").alias("event_count"),
            F.countDistinct("user_id").alias("unique_users"),
            F.coalesce(F.sum("amount"), F.lit(0.0)).alias("total_amount"),
            F.countDistinct("session_id").alias("unique_sessions"),
        ) \
        .orderBy("country", "event_type")

    gold_df.write \
        .mode("overwrite") \
        .parquet(gold_path)

    return gold_df

Сквозной интеграционный тест: пишем шаг за шагом

Теперь напишем полный интеграционный тест, который проверяет весь пайплайн от загрузки JSON до Gold-агрегатов.

# tests/test_pipeline_integration.py

import json
import io
import pytest
import boto3
from pyspark.sql import functions as F
from pyspark.sql.types import StructType, StructField, LongType, StringType, DoubleType

# Импортируем тестируемые функции
from src.pipeline.bronze_ingestion import (
    ingest_json_to_bronze,
    deduplicate_to_silver,
    aggregate_to_gold,
)


# ─── Тестовые данные ─────────────────────────────────────────────────────────

# Тестовый набор событий: реалистичные данные с известными характеристиками.
# Мы точно знаем: 3 уникальных event_id, 1 дублирующийся (event_4 = event_2),
# 1 с null event_type, 2 purchase события общей суммой 350.0.
# Это позволяет писать точные assert'ы.
TEST_EVENTS = [
    {
        "event_id":   "evt-001",
        "user_id":    1001,
        "event_type": "page_view",
        "timestamp":  "2024-01-15T10:00:00Z",
        "session_id": "sess-a",
        "amount":     None,
        "currency":   None,
        "country":    "RU"
    },
    {
        "event_id":   "evt-002",
        "user_id":    1002,
        "event_type": "Purchase",           # намеренно с заглавной - тест нормализации
        "timestamp":  "2024-01-15T10:05:00Z",
        "session_id": "sess-b",
        "amount":     150.0,
        "currency":   "RUB",
        "country":    "RU"
    },
    {
        "event_id":   "evt-003",
        "user_id":    1003,
        "event_type": "purchase",
        "timestamp":  "2024-01-15T10:10:00Z",
        "session_id": "sess-c",
        "amount":     200.0,
        "currency":   "RUB",
        "country":    "DE"
    },
    {
        # Дубликат evt-002: тот же event_id, другие данные
        # После дедупликации должна остаться только одна из двух записей
        "event_id":   "evt-002",
        "user_id":    1002,
        "event_type": "purchase",
        "timestamp":  "2024-01-15T10:05:01Z",   # на секунду позже
        "session_id": "sess-b",
        "amount":     150.0,
        "currency":   "RUB",
        "country":    "RU"
    },
    {
        # Запись без event_type: должна быть отфильтрована в Silver
        "event_id":   "evt-004",
        "user_id":    1004,
        "event_type": None,
        "timestamp":  "2024-01-15T10:15:00Z",
        "session_id": "sess-d",
        "amount":     None,
        "currency":   None,
        "country":    "US"
    },
]


@pytest.fixture(scope="function")
def upload_test_data(s3_client, test_buckets):
    """
    Загружает тестовые события в bronze бакет перед каждым тестом.

    scope="function": каждый тест получает свежие данные.
    Это важно: если предыдущий тест оставил мусор в бакете,
    следующий тест не должен его видеть.

    Данные загружаются в формате JSON Lines (NDJSON):
    каждая строка - отдельный JSON-объект.
    Spark читает такие файлы через spark.read.json() с multiLine=False.

    fixture возвращает путь к загруженным данным,
    чтобы тест мог передать его в ETL функцию.
    """
    date = "2024-01-15"
    input_key = f"incoming/{date}/events.jsonl"

    # Формируем JSON Lines: каждая запись на отдельной строке
    jsonl_content = "\n".join(json.dumps(event, ensure_ascii=False) for event in TEST_EVENTS)
    jsonl_bytes = jsonl_content.encode("utf-8")

    # Загружаем файл в LocalStack S3
    s3_client.put_object(
        Bucket="bronze",
        Key=input_key,
        Body=jsonl_bytes,
        ContentType="application/x-ndjson"
    )
    print(f"\nТестовые данные загружены: s3://bronze/{input_key} ({len(TEST_EVENTS)} событий)")

    yield {
        "source_path": f"s3a://bronze/incoming/{date}/",
        "date": date,
        "event_count": len(TEST_EVENTS)
    }

    # Teardown: удаляем загруженный файл после теста
    s3_client.delete_object(Bucket="bronze", Key=input_key)
    print(f"Тестовые данные удалены: {input_key}")


# ─── Тесты ───────────────────────────────────────────────────────────────────

class TestBronzeIngestion:
    """
    Тесты первой стадии: чтение JSON → запись Parquet в Bronze.
    """

    def test_ingest_writes_correct_row_count(self, spark, s3_client, upload_test_data):
        """
        Проверяем, что Bronze ingestion записывает все строки из источника,
        включая дубликаты и записи с null - на Bronze-стадии мы не фильтруем ничего.

        Ожидание: 5 строк (все исходные события, без фильтрации)
        """
        source_path = upload_test_data["source_path"]
        date = upload_test_data["date"]
        bronze_path = "s3a://bronze/events/"

        row_count = ingest_json_to_bronze(spark, source_path, bronze_path, date)

        # Все 5 исходных событий должны быть записаны
        assert row_count == 5, (
            f"Bronze должен содержать все исходные строки без фильтрации. "
            f"Ожидалось: 5, получено: {row_count}"
        )

    def test_ingest_creates_partition_directory(self, spark, s3_client, upload_test_data):
        """
        Проверяем, что partitionBy("date_partition") создал корректную директорию.

        Spark при записи с partitionBy создаёт структуру:
        bronze/events/date_partition=2024-01-15/*.parquet

        Мы проверяем через boto3 (не через Spark), что нужные файлы появились.
        Это важно: проверка через boto3 убеждает нас, что файлы реально записаны
        в S3, а не просто существуют в памяти Spark.
        """
        source_path = upload_test_data["source_path"]
        date = upload_test_data["date"]
        bronze_path = "s3a://bronze/events/"

        ingest_json_to_bronze(spark, source_path, bronze_path, date)

        # Проверяем через boto3: листинг файлов в партиционированной директории
        expected_prefix = f"events/date_partition={date}/"
        response = s3_client.list_objects_v2(
            Bucket="bronze",
            Prefix=expected_prefix
        )

        parquet_files = [
            obj for obj in response.get("Contents", [])
            if obj["Key"].endswith(".parquet")
        ]

        assert len(parquet_files) > 0, (
            f"В директории s3://bronze/{expected_prefix} должны быть Parquet файлы. "
            f"Найдено: {len(parquet_files)} файлов."
        )

    def test_ingest_adds_technical_fields(self, spark, upload_test_data):
        """
        Проверяем, что Bronze добавляет технические поля:
        ingested_at, date_partition, _source_path.

        Эти поля важны для операционного мониторинга: по ingested_at можно
        отследить SLA (когда данные появились в Bronze), по _source_path —
        откуда пришли данные при troubleshooting.
        """
        source_path = upload_test_data["source_path"]
        date = upload_test_data["date"]
        bronze_path = "s3a://bronze/events/"

        ingest_json_to_bronze(spark, source_path, bronze_path, date)

        # Читаем записанные данные и проверяем схему
        result_df = spark.read.parquet(bronze_path).filter(
            F.col("date_partition") == date
        )

        column_names = set(result_df.columns)
        required_technical_fields = {"ingested_at", "date_partition", "_source_path"}

        missing_fields = required_technical_fields - column_names
        assert not missing_fields, (
            f"В Bronze отсутствуют технические поля: {missing_fields}. "
            f"Все колонки: {column_names}"
        )

        # Проверяем, что date_partition заполнен правильно
        partition_values = {
            row["date_partition"]
            for row in result_df.select("date_partition").distinct().collect()
        }
        assert partition_values == {date}, (
            f"date_partition должен содержать только '{date}'. "
            f"Найдено: {partition_values}"
        )


class TestSilverDeduplication:
    """
    Тесты второй стадии: дедупликация и очистка данных в Silver.
    """

    @pytest.fixture(autouse=True)
    def setup_bronze_data(self, spark, s3_client, upload_test_data):
        """
        autouse=True: эта фикстура запускается перед каждым тестом в классе.
        Создаём Bronze данные как предусловие для Silver тестов.
        """
        source_path = upload_test_data["source_path"]
        date = upload_test_data["date"]
        ingest_json_to_bronze(spark, source_path, "s3a://bronze/events/", date)
        self.date = date

    def test_deduplication_removes_duplicate_event_ids(self, spark):
        """
        Ключевой тест дедупликации.

        В тестовых данных: 5 событий, 1 дубликат (evt-002 встречается дважды).
        После дедупликации: 4 уникальных event_id.

        При этом запись с null event_type (evt-004) ещё не отфильтрована —
        Silver фильтрует по event_type, но не по event_id.
        Итого после дедупликации: 4 строки.
        """
        deduplicate_to_silver(
            spark,
            "s3a://bronze/events/",
            "s3a://silver/events/",
            self.date
        )

        silver_df = spark.read.parquet("s3a://silver/events/").filter(
            F.col("date_partition") == self.date
        )

        # Считаем уникальные event_id - должны все быть уникальными
        total_rows = silver_df.count()
        unique_event_ids = silver_df.select("event_id").distinct().count()

        assert total_rows == unique_event_ids, (
            f"После дедупликации не должно быть дубликатов. "
            f"Всего строк: {total_rows}, уникальных event_id: {unique_event_ids}"
        )

    def test_silver_filters_null_event_type(self, spark):
        """
        Проверяем, что записи с null event_type не попадают в Silver.

        В тестовых данных: evt-004 имеет event_type=null.
        Silver должен её отфильтровать.

        Это важно для downstream процессов: Gold агрегирует по event_type,
        и null-строки создали бы артефакт "неизвестный тип события".
        """
        deduplicate_to_silver(
            spark,
            "s3a://bronze/events/",
            "s3a://silver/events/",
            self.date
        )

        silver_df = spark.read.parquet("s3a://silver/events/").filter(
            F.col("date_partition") == self.date
        )

        null_event_type_count = silver_df.filter(
            F.col("event_type").isNull()
        ).count()

        assert null_event_type_count == 0, (
            f"Silver не должен содержать строки с null event_type. "
            f"Найдено: {null_event_type_count} строк с null event_type."
        )

    def test_silver_normalizes_event_type_to_lowercase(self, spark):
        """
        Проверяем нормализацию event_type.

        В тестовых данных: "Purchase" (с заглавной P) и "purchase" (строчная).
        После нормализации оба должны стать "purchase".

        Без нормализации GROUP BY посчитал бы их как разные события,
        что привело бы к дублированию в Gold агрегатах.
        """
        deduplicate_to_silver(
            spark,
            "s3a://bronze/events/",
            "s3a://silver/events/",
            self.date
        )

        silver_df = spark.read.parquet("s3a://silver/events/").filter(
            F.col("date_partition") == self.date
        )

        # Проверяем, что event_type содержит только строчные буквы
        # upper_case_events: события, где event_type содержит хотя бы одну заглавную букву
        upper_case_events = silver_df.filter(
            F.col("event_type") != F.lower(F.col("event_type"))
        ).count()

        assert upper_case_events == 0, (
            f"Silver должен нормализовать event_type к lowercase. "
            f"Найдено событий с заглавными буквами: {upper_case_events}"
        )

    def test_silver_row_count_after_all_transformations(self, spark):
        """
        Сквозная проверка итогового количества строк в Silver.

        Тестовые данные: 5 событий
        - 1 дубликат (evt-002 второй раз) → отброшен дедупликацией
        - 1 запись с null event_type (evt-004) → отброшена фильтрацией
        Итого в Silver: 5 - 1 - 1 = 3 записи.

        Проверка точного количества строк - это smoke test, который
        сразу покажет если что-то пошло не так в трансформациях.
        """
        row_count = deduplicate_to_silver(
            spark,
            "s3a://bronze/events/",
            "s3a://silver/events/",
            self.date
        )

        assert row_count == 3, (
            f"После дедупликации и фильтрации должно остаться 3 строки "
            f"(5 исходных - 1 дубликат - 1 null event_type). "
            f"Получено: {row_count}"
        )


class TestGoldAggregation:
    """
    Тесты третьей стадии: бизнес-агрегаты в Gold.
    """

    @pytest.fixture(autouse=True)
    def setup_silver_data(self, spark, s3_client, upload_test_data):
        """
        Создаём Silver данные как предусловие для Gold тестов.
        """
        date = upload_test_data["date"]
        source_path = upload_test_data["source_path"]
        ingest_json_to_bronze(spark, source_path, "s3a://bronze/events/", date)
        deduplicate_to_silver(spark, "s3a://bronze/events/", "s3a://silver/events/", date)
        self.date = date

    def test_gold_aggregates_purchase_amounts_correctly(self, spark):
        """
        Проверяем корректность агрегации сумм покупок по стране.

        В тестовых данных (после Silver обработки):
        - RU + purchase: evt-002 с amount=150.0 (дубликат отброшен)
        - DE + purchase: evt-003 с amount=200.0

        Gold должен вернуть:
        - RU, purchase: total_amount=150.0
        - DE, purchase: total_amount=200.0
        """
        aggregate_to_gold(
            spark,
            "s3a://silver/events/",
            "s3a://gold/daily_stats/",
            self.date
        )

        gold_df = spark.read.parquet("s3a://gold/daily_stats/")

        # Собираем результаты в словарь для удобной проверки
        # Ключ: (country, event_type), Значение: total_amount
        results = {
            (row["country"], row["event_type"]): row["total_amount"]
            for row in gold_df.collect()
        }

        # Проверяем суммы по стране
        ru_purchase_amount = results.get(("RU", "purchase"), 0.0)
        de_purchase_amount = results.get(("DE", "purchase"), 0.0)

        assert abs(ru_purchase_amount - 150.0) < 0.01, (
            f"Total amount для RU/purchase должен быть 150.0. "
            f"Получено: {ru_purchase_amount}"
        )

        assert abs(de_purchase_amount - 200.0) < 0.01, (
            f"Total amount для DE/purchase должен быть 200.0. "
            f"Получено: {de_purchase_amount}"
        )

    def test_gold_unique_users_count(self, spark):
        """
        Проверяем подсчёт уникальных пользователей.

        В тестовых данных (Silver):
        - RU: user_id 1001 (page_view) и 1002 (purchase) = 2 уникальных пользователя
        - DE: user_id 1003 (purchase) = 1 уникальный пользователь
        """
        aggregate_to_gold(
            spark,
            "s3a://silver/events/",
            "s3a://gold/daily_stats/",
            self.date
        )

        gold_df = spark.read.parquet("s3a://gold/daily_stats/")

        # Суммарное количество уникальных пользователей по всем строкам
        # (сумма countDistinct по каждому country+event_type)
        total_unique_users_per_segment = gold_df.agg(
            F.sum("unique_users").alias("total")
        ).collect()[0]["total"]

        # В наших данных: RU/page_view=1, RU/purchase=1, DE/purchase=1
        # Итого 3 пользователя в трёх сегментах
        assert total_unique_users_per_segment == 3, (
            f"Суммарное количество unique_users по всем сегментам должно быть 3. "
            f"Получено: {total_unique_users_per_segment}"
        )

    def test_gold_schema(self, spark):
        """
        Проверяем схему Gold таблицы.

        Схема Gold - это контракт с BI-инструментами и аналитиками.
        Если колонка переименована или удалена, дашборды сломаются.
        Этот тест гарантирует, что схема не изменится незаметно.
        """
        aggregate_to_gold(
            spark,
            "s3a://silver/events/",
            "s3a://gold/daily_stats/",
            self.date
        )

        gold_df = spark.read.parquet("s3a://gold/daily_stats/")

        expected_columns = {
            "country", "event_type", "date_partition",
            "event_count", "unique_users", "total_amount", "unique_sessions"
        }

        actual_columns = set(gold_df.columns)
        missing = expected_columns - actual_columns
        unexpected = actual_columns - expected_columns

        assert not missing, f"В Gold отсутствуют обязательные колонки: {missing}"
        assert not unexpected, f"В Gold появились неожиданные колонки: {unexpected}"

Запуск тестов и интерпретация результатов

Базовый запуск

# Запуск всех интеграционных тестов
pytest tests/test_pipeline_integration.py -v

# Запуск только тестов Bronze стадии
pytest tests/test_pipeline_integration.py::TestBronzeIngestion -v

# Запуск конкретного теста
pytest tests/test_pipeline_integration.py::TestSilverDeduplication::test_deduplication_removes_duplicate_event_ids -v

# С выводом print() и логов LocalStack
pytest tests/test_pipeline_integration.py -v -s

# С таймаутом 120 секунд на тест (через pytest-timeout)
pytest tests/test_pipeline_integration.py --timeout=120

Понимание вывода

================================ test session starts ================================
platform linux -- Python 3.11.4, pytest-8.1.0

collected 8 items

LocalStack endpoint: http://localhost:54321           ← Testcontainers выбрал порт
Тестовые данные загружены: s3://bronze/incoming/...

tests/test_pipeline_integration.py::TestBronzeIngestion::test_ingest_writes_correct_row_count PASSED [12%]
tests/test_pipeline_integration.py::TestBronzeIngestion::test_ingest_creates_partition_directory PASSED [25%]
tests/test_pipeline_integration.py::TestBronzeIngestion::test_ingest_adds_technical_fields PASSED [37%]
tests/test_pipeline_integration.py::TestSilverDeduplication::test_deduplication_removes_duplicate_event_ids PASSED [50%]
tests/test_pipeline_integration.py::TestSilverDeduplication::test_silver_filters_null_event_type PASSED [62%]
tests/test_pipeline_integration.py::TestSilverDeduplication::test_silver_normalizes_event_type_to_lowercase PASSED [75%]
tests/test_pipeline_integration.py::TestGoldAggregation::test_gold_aggregates_purchase_amounts_correctly PASSED [87%]
tests/test_pipeline_integration.py::TestGoldAggregation::test_gold_schema PASSED [100%]

========================= 8 passed in 47.23s ==========================

47 секунд на 8 тестов - это нормально. Из них: ~10 сек старт LocalStack + ~15 сек первый старт SparkSession. Сами тесты выполняются по 2-3 секунды каждый.


Troubleshooting: типичные ошибки и их решения

Ошибка 1: SignatureDoesNotMatch

botocore.exceptions.ClientError: An error occurred (SignatureDoesNotMatch)
when calling the PutObject operation: The request signature we calculated
does not match the signature you provided. Check your key and signing method.

Причина: boto3 использует Signature V2 вместо V4. LocalStack некоторых версий требует V4.

Решение:

from botocore.config import Config

client = boto3.client(
    "s3",
    endpoint_url=endpoint_url,
    config=Config(signature_version="s3v4")  # ← принудительно V4
)

Ошибка 2: java.io.IOException: No FileSystem for scheme "s3a"

py4j.protocol.Py4JJavaError: An error occurred while calling o25.parquet.
: java.io.IOException: No FileSystem for scheme "s3a"

Причина: Hadoop AWS JAR не добавлен в classpath Spark.

Решение: Убедитесь, что в конфигурации SparkSession указаны пакеты:

.config(
    "spark.jars.packages",
    "org.apache.hadoop:hadoop-aws:3.3.4,"
    "com.amazonaws:aws-java-sdk-bundle:1.12.367"
)

Или проверьте, что JARы физически есть в директории $SPARK_HOME/jars/.

Ошибка 3: Connection refused / UnknownHostException

Caused by: java.net.ConnectException: Connection refused (Connection refused)
или
Caused by: java.net.UnknownHostException: bronze.localhost

Причина А: Connection refused - LocalStack ещё не запустился или использован неправильный порт.

Причина Б: UnknownHostException: bronze.localhost - включён Virtual-Hosted style вместо Path Style.

Решение для А:

# Убедитесь что используете порт из Testcontainers, не хардкодите
endpoint = localstack_container.get_url()  # правильно
endpoint = "http://localhost:4566"           # неправильно - порт может быть другим

Решение для Б:

.config("spark.hadoop.fs.s3a.path.style.access", "true")  # обязательно!

Ошибка 4: Зависание при инициализации Credentials

# Тест зависает на несколько минут без вывода
# Потом падает с:
com.amazonaws.SdkClientException: Unable to load AWS credentials from any provider

Причина: Spark пытается найти credentials через InstanceProfileCredentialsProvider, который запрашивает EC2 Instance Metadata Service (IMDS) по адресу 169.254.169.254. Этот адрес недоступен в не-AWS окружении, и провайдер ждёт таймаута.

Решение: Явно указать SimpleAWSCredentialsProvider:

.config(
    "spark.hadoop.fs.s3a.aws.credentials.provider",
    "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider"
)
.config("spark.hadoop.fs.s3a.access.key", "test")
.config("spark.hadoop.fs.s3a.secret.key", "test")

Ошибка 5: SSL-ошибка при подключении к LocalStack

javax.net.ssl.SSLException: Unrecognized SSL message, plaintext connection?

Причина: LocalStack по умолчанию работает на HTTP, но S3A Connector пытается установить HTTPS соединение.

Решение:

.config("spark.hadoop.fs.s3a.connection.ssl.enabled", "false")

Оптимизация CI/CD: ускорение сборки

Для GitHub Actions или GitLab CI ключевые оптимизации - это кеширование Docker-образов и Maven-зависимостей.

GitHub Actions: полная конфигурация

# .github/workflows/integration-tests.yml
name: Integration Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  integration-tests:
    runs-on: ubuntu-22.04
    timeout-minutes: 20    # максимальное время всего job

    steps:
      # ── Checkout ────────────────────────────────────────────────────────────
      - name: Checkout code
        uses: actions/checkout@v4

      # ── Кеш для Maven/Ivy зависимостей Spark ──────────────────────────────
      # Spark при первом запуске скачивает hadoop-aws и aws-java-sdk через Maven.
      # На GitHub Actions это занимает 2-3 минуты.
      # Кеш кешируем по хешу файла зависимостей - обновляется только при изменении.
      - name: Cache Spark/Maven dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.ivy2/cache
            ~/.m2/repository
          key: spark-deps-${{ hashFiles('requirements-test.txt') }}-hadoop3.3.4
          restore-keys: |
            spark-deps-${{ hashFiles('requirements-test.txt') }}-
            spark-deps-

      # ── Кеш для Docker образов LocalStack ───────────────────────────────────
      # Docker pull localstack/localstack:3.4 занимает ~30 секунд.
      # GitHub Actions кеш помогает ускорить это до 5 секунд.
      - name: Cache Docker images
        uses: ScribeMD/docker-cache@0.5.0
        with:
          key: docker-localstack-3.4-${{ runner.os }}

      # ── Python окружение ─────────────────────────────────────────────────────
      - name: Set up Python 3.11
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Install dependencies
        run: pip install -r requirements-test.txt

      # ── Java для Spark ───────────────────────────────────────────────────────
      - name: Set up Java 17
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"
          cache: maven

      # ── Запуск тестов ────────────────────────────────────────────────────────
      - name: Run integration tests
        run: |
          pytest tests/test_pipeline_integration.py \
            -v \
            --timeout=120 \
            --tb=short \
            -p no:warnings \
            --junit-xml=test-results.xml
        env:
          # Testcontainers нужен доступ к Docker socket
          DOCKER_HOST: unix:///var/run/docker.sock
          # Отключаем Ryuk (cleanup контейнер) - он требует привилегированного режима
          # на некоторых CI системах. Testcontainers всё равно очищает контейнеры.
          TESTCONTAINERS_RYUK_DISABLED: "true"

      # ── Публикация результатов ────────────────────────────────────────────────
      - name: Publish test results
        uses: mikepenz/action-junit-report@v4
        if: always()     # запускаем даже если тесты упали
        with:
          report_paths: test-results.xml

GitLab CI: конфигурация

# .gitlab-ci.yml
integration-tests:
  image: python:3.11-bullseye
  services:
    # GitLab CI поднимает Docker-in-Docker сервис, который нужен Testcontainers
    - docker:24-dind

  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
    DOCKER_TLS_VERIFY: "1"
    DOCKER_CERT_PATH: "/certs/client"
    TESTCONTAINERS_HOST_OVERRIDE: "docker"   # адрес DinD хоста для Testcontainers

  cache:
    key: "${CI_COMMIT_REF_SLUG}-spark-deps"
    paths:
      - .ivy2/cache/
      - .m2/repository/

  before_script:
    - apt-get update && apt-get install -y default-jdk-headless
    - pip install -r requirements-test.txt

  script:
    - pytest tests/test_pipeline_integration.py
        --timeout=120
        -v
        --junit-xml=test-results.xml

  artifacts:
    when: always
    reports:
      junit: test-results.xml

  timeout: 20 minutes

Паттерн reuse=True для локальной разработки

При локальной разработке можно включить переиспользование контейнеров между запусками pytest. Это ускоряет повторные запуски с ~30 секунд до ~3 секунд.

# Переиспользование контейнера через переменную окружения
import os

@pytest.fixture(scope="session")
def localstack_container():
    """
    В режиме TESTCONTAINERS_REUSE_ENABLE=true контейнер не удаляется после теста.
    При следующем запуске pytest Testcontainers находит уже запущенный контейнер
    и переиспользует его вместо создания нового.

    Включить: export TESTCONTAINERS_REUSE_ENABLE=true
    Отключить: export TESTCONTAINERS_REUSE_ENABLE=false (по умолчанию)

    ВАЖНО: никогда не включайте reuse в CI. Там контейнеры должны создаваться
    заново при каждом запуске для чистоты окружения.
    """
    reuse = os.getenv("TESTCONTAINERS_REUSE_ENABLE", "false").lower() == "true"

    with LocalStackContainer(
        image="localstack/localstack:3.4",
        reuse=reuse
    ) as container:
        yield container

Тестирование Delta Lake пайплайнов с LocalStack

Delta Lake хранит данные в S3 (Parquet файлы) и там же ведёт лог транзакций (_delta_log/). LocalStack полностью поддерживает эту структуру - нужна только правильная конфигурация.

# tests/test_delta_pipeline.py

import pytest
from pyspark.sql import SparkSession, functions as F
from delta import DeltaTable, configure_spark_with_delta_pip


@pytest.fixture(scope="session")
def spark_delta(localstack_endpoint):
    """
    SparkSession с поддержкой Delta Lake и LocalStack S3.

    Delta Lake требует дополнительных JAR и конфигурации.
    configure_spark_with_delta_pip - удобная функция из пакета delta-spark,
    которая автоматически добавляет нужные зависимости.
    """
    builder = (
        SparkSession.builder
        .master("local[2]")
        .appName("delta-integration-tests")
        # Delta Lake расширения
        .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
        .config(
            "spark.sql.catalog.spark_catalog",
            "org.apache.spark.sql.delta.catalog.DeltaCatalog"
        )
        # S3A для LocalStack
        .config("spark.hadoop.fs.s3a.endpoint", localstack_endpoint)
        .config("spark.hadoop.fs.s3a.path.style.access", "true")
        .config(
            "spark.hadoop.fs.s3a.aws.credentials.provider",
            "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider"
        )
        .config("spark.hadoop.fs.s3a.access.key", "test")
        .config("spark.hadoop.fs.s3a.secret.key", "test")
        .config("spark.hadoop.fs.s3a.connection.ssl.enabled", "false")
        .config("spark.sql.shuffle.partitions", "4")
    )

    spark = configure_spark_with_delta_pip(builder).getOrCreate()
    spark.sparkContext.setLogLevel("WARN")
    yield spark
    spark.stop()


class TestDeltaLakeWithLocalStack:
    """
    Тесты Delta Lake операций против LocalStack S3.

    Эти тесты проверяют, что Delta Lake корректно:
    1. Создаёт таблицы и _delta_log/ в S3
    2. Поддерживает ACID транзакции (MERGE, UPDATE)
    3. Сохраняет историю версий
    4. Работает с Schema Enforcement
    """

    def test_delta_write_and_read(self, spark_delta, test_buckets):
        """
        Базовый тест: запись и чтение Delta Lake таблицы через LocalStack S3.

        Delta при записи создаёт:
        - s3a://silver/delta-events/part-*.parquet - данные
        - s3a://silver/delta-events/_delta_log/00000000000000000000.json - лог транзакций

        Если LocalStack S3 работает корректно, оба типа файлов должны
        создаться и быть доступны для чтения.
        """
        data = [
            (1, "evt-001", "purchase", 100.0),
            (2, "evt-002", "page_view", None),
            (3, "evt-003", "purchase", 200.0),
        ]
        schema = ["user_id", "event_id", "event_type", "amount"]
        df = spark_delta.createDataFrame(data, schema)

        delta_path = "s3a://silver/delta-events/"

        # Записываем как Delta таблицу
        df.write.format("delta").mode("overwrite").save(delta_path)

        # Читаем обратно и проверяем
        result_df = spark_delta.read.format("delta").load(delta_path)
        assert result_df.count() == 3, "Delta таблица должна содержать 3 строки"

    def test_delta_merge_upsert(self, spark_delta, test_buckets):
        """
        Тест MERGE (UPSERT) операции.

        MERGE - одна из ключевых возможностей Delta Lake для CDC-пайплайнов.
        Синхронизирует целевую таблицу с источником: обновляет существующие
        строки, вставляет новые. При этом операция атомарна: либо применились
        все изменения, либо ни одно.

        Это комплексный тест, потому что MERGE использует несколько S3-операций:
        1. Чтение существующих файлов
        2. Запись новых Parquet файлов
        3. Обновление _delta_log с информацией о транзакции
        """
        # Создаём начальные данные
        initial_data = [
            (1, "evt-001", "purchase", 100.0, "RU"),
            (2, "evt-002", "page_view", None, "RU"),
        ]
        schema = ["user_id", "event_id", "event_type", "amount", "country"]
        initial_df = spark_delta.createDataFrame(initial_data, schema)

        delta_path = "s3a://silver/delta-merge-test/"
        initial_df.write.format("delta").mode("overwrite").save(delta_path)

        # Данные обновления: изменяем evt-001 и добавляем evt-003
        updates_data = [
            (1, "evt-001", "purchase", 150.0, "RU"),   # обновлённый amount
            (3, "evt-003", "login",    None,  "DE"),    # новая запись
        ]
        updates_df = spark_delta.createDataFrame(updates_data, schema)

        # Выполняем MERGE
        target_table = DeltaTable.forPath(spark_delta, delta_path)
        target_table.alias("target").merge(
            updates_df.alias("source"),
            "target.event_id = source.event_id"
        ).whenMatchedUpdateAll() \
         .whenNotMatchedInsertAll() \
         .execute()

        # Проверяем результат
        result_df = spark_delta.read.format("delta").load(delta_path)
        results = {
            row["event_id"]: row["amount"]
            for row in result_df.collect()
        }

        # evt-001 должен иметь обновлённый amount
        assert results["evt-001"] == 150.0, (
            f"MERGE должен обновить amount для evt-001 до 150.0. "
            f"Текущий: {results['evt-001']}"
        )

        # evt-003 должен быть добавлен
        assert "evt-003" in results, "MERGE должен вставить новую запись evt-003"

        # Всего должно быть 3 записи (2 исходных + 1 новая)
        assert len(results) == 3, f"После MERGE должно быть 3 записи. Найдено: {len(results)}"

    def test_delta_time_travel(self, spark_delta, test_buckets):
        """
        Тест Time Travel: чтение исторических версий таблицы.

        Delta Lake сохраняет историю транзакций в _delta_log/.
        Time Travel позволяет читать данные на момент конкретной версии или timestamp.
        Это мощная возможность для аудита данных и восстановления от ошибок.

        Для работы Time Travel все версии Parquet файлов должны оставаться в S3.
        LocalStack корректно их хранит, поэтому Time Travel работает так же,
        как в продакшне с настоящим S3.
        """
        delta_path = "s3a://gold/delta-time-travel-test/"

        # Версия 0: первоначальная запись
        v0_data = spark_delta.createDataFrame(
            [(1, "alice", 100), (2, "bob", 200)],
            ["id", "name", "score"]
        )
        v0_data.write.format("delta").mode("overwrite").save(delta_path)

        # Версия 1: добавляем записи
        v1_data = spark_delta.createDataFrame(
            [(3, "charlie", 300)],
            ["id", "name", "score"]
        )
        v1_data.write.format("delta").mode("append").save(delta_path)

        # Читаем текущую версию (v1)
        current = spark_delta.read.format("delta").load(delta_path)
        assert current.count() == 3, "Текущая версия должна содержать 3 записи"

        # Читаем версию 0 через Time Travel
        v0 = spark_delta.read.format("delta") \
            .option("versionAsOf", 0) \
            .load(delta_path)
        assert v0.count() == 2, "Версия 0 должна содержать 2 записи (до append)"

Полный жизненный цикл теста: диаграмма последовательности

Эта диаграмма показывает полный жизненный цикл: от запуска Testcontainers до удаления контейнера. Каждый шаг - реальный сетевой вызов или системный вызов Docker API. В отличие от мокирования, здесь нет ничего «виртуального» - это настоящие HTTP-запросы к настоящему (пусть и локальному) S3-совместимому сервису.


Стратегия тестирования: три уровня пирамиды

LocalStack-тесты - это только один уровень стратегии тестирования. Правильный подход комбинирует три уровня с разным соотношением:

Уровень Что тестирует Когда запускать Инструменты
Unit Чистые функции трансформаций, UDF, бизнес-правила Каждый коммит pytest + spark.createDataFrame()
Integration (LocalStack) S3-взаимодействие, форматы файлов, схемы, committers Каждый PR pytest + LocalStack + Testcontainers
End-to-End Производительность, IAM политики, сетевое поведение Перед мержем в main Real S3 / MinIO staging

Правило выбора: если баг можно поймать юнит-тестом - не нужен интеграционный. Если баг можно поймать интеграционным - не нужен e2e. Это экономит время CI и деньги на облачные ресурсы.


Чек-лист: готовность тестов к запуску в air-gapped окружении

Некоторые CI-среды не имеют доступа к интернету (air-gapped). Это требует предварительной подготовки:

  • [ ] Docker образ LocalStack закеширован в приватном Container Registry (Harbor, GitLab Registry). Команда docker pull localstack/localstack:3.4 должна работать без доступа к Docker Hub.
  • [ ] Hadoop AWS JARы (hadoop-aws-3.3.4.jar, aws-java-sdk-bundle-1.12.367.jar) сохранены в приватный Nexus/Artifactory. Spark должен получать их оттуда, а не из Maven Central.
  • [ ] Конфигурация Maven mirror указывает на приватный репозиторий:
    .config("spark.jars.ivy", "/tmp/.ivy2")  # локальный кеш
    .config("spark.jars.repositories", "https://nexus.internal/maven2")
    
  • [ ] Python зависимости установлены из приватного PyPI-зеркала (Nexus, Artifactory). В pip.conf или переменной PIP_INDEX_URL.
  • [ ] Testcontainers настроен на использование приватного Docker Registry:
    export TESTCONTAINERS_RYUK_DISABLED=true  # Ryuk тоже качает образ!
    export DOCKER_AUTH_CONFIG='{"auths": {"registry.internal": {"auth": "..."}}}' 
    
  • [ ] Нет внешних HTTP-вызовов в коде тестов. Проверьте, что тестируемый код не делает запросы к внешним API (weather, currency rates, etc.) - в air-gapped они упадут.
  • [ ] JVM флаги для offline режима:
    .config("spark.driver.extraJavaOptions", "-Djava.net.useSystemProxies=false")
    

Итоги: что даёт этот подход

LocalStack + Testcontainers + pytest решает реальную инженерную проблему: как тестировать S3-взаимодействие Spark без зависимости от внешней инфраструктуры.

Что вы получаете:

  • Изоляция. Каждый разработчик и каждый CI-запуск работают в своём контейнере. Данные не смешиваются.
  • Воспроизводимость. Тест с localstack:3.4 даёт одинаковый результат на ноутбуке, в CI и на MacOS коллеги.
  • Скорость. 8 интеграционных тестов за ~47 секунд. Юнит-тесты трансформаций - ещё быстрее.
  • Уверенность. Тест проверяет реальный S3A Connector, реальный Parquet writer, реальную структуру директорий - а не то, как вы замокировали эти вещи.
  • Дешевизна. Ноль затрат на API-запросы к AWS, ноль рисков потерять данные в staging-бакете.

Что вы не получаете от LocalStack:

  • Полную эмуляцию IAM-политик - это staging или integration-среда.
  • Реалистичные тесты производительности - для этого нужен настоящий кластер.
  • Проверку поведения при сетевых ошибках - это chaos engineering.

Три уровня тестирования вместе дают полное покрытие: unit-тесты для бизнес-логики, LocalStack-интеграционные тесты для S3-взаимодействия, staging e2e для финальной проверки перед продакшном.