Modern QA2026Тестирование событийно-ориентированных систем
Join

Course08 Infrastructure as Code Testing

Cutting-edge · Chapter 08

Тестирование событийно-ориентированных систем

Updated Jul 2026

Фундаментальная проблема

Событийно-ориентированные архитектуры (SQS, EventBridge, Kafka, SNS) известны своей сложностью в тестировании, потому что в них нет синхронного цикла запрос-ответ, против которого можно делать утверждения. Когда вы публикуете событие, вы не можете сразу проверить результат. Потребитель может обработать его за миллисекунды, секунды или минуты. Он может молча завершиться с ошибкой. Он может обработать событие дважды.

Эта асинхронная природа требует принципиально другого подхода к тестированию: вместо «вызвать и проверить» вы используете «опубликовать, подождать и верифицировать состояние».

Тестирование правил EventBridge

EventBridge — это бессерверная шина событий AWS. Тестирование EventBridge означает верификацию того, что правила соответствуют правильным паттернам событий и маршрутизируют к правильным целям.

Проверка сопоставления паттернов

# tests/integration/test_eventbridge.py
import boto3
import json
import pytest

def test_eventbridge_rule_matching():
    """Verify that EventBridge rules match expected event patterns."""
    client = boto3.client("events", endpoint_url="http://localhost:4566")  # LocalStack

    # Put a test event
    response = client.put_events(
        Entries=[{
            "Source": "myapp.orders",
            "DetailType": "OrderCreated",
            "Detail": json.dumps({
                "orderId": "ORD-TEST",
                "amount": 150.00,
                "region": "us-east-1"
            }),
            "EventBusName": "orders-bus"
        }]
    )

    assert response["FailedEntryCount"] == 0

    # Verify the rule exists and has correct pattern
    rule = client.describe_rule(
        Name="high-value-orders",
        EventBusName="orders-bus"
    )
    pattern = json.loads(rule["EventPattern"])
    assert pattern["source"] == ["myapp.orders"]
    assert pattern["detail"]["amount"] == [{"numeric": [">=", 100]}]

def test_event_pattern_does_not_match_low_value_orders():
    """Verify that low-value orders are NOT matched by the high-value rule."""
    # This is a negative test -- equally important
    pattern = {
        "source": ["myapp.orders"],
        "detail": {
            "amount": [{"numeric": [">=", 100]}]
        }
    }

    low_value_event = {
        "source": "myapp.orders",
        "detail": {
            "orderId": "ORD-SMALL",
            "amount": 25.00,
        }
    }

    # AWS provides a test-event-pattern API
    client = boto3.client("events", endpoint_url="http://localhost:4566")
    result = client.test_event_pattern(
        EventPattern=json.dumps(pattern),
        Event=json.dumps(low_value_event)
    )
    assert result["Result"] is False

Сквозное тестирование потока событий

def test_order_event_triggers_notification(localstack_clients):
    """Test the full event flow: OrderCreated -> EventBridge -> SNS -> Email."""
    events = localstack_clients["events"]
    sns = localstack_clients["sns"]
    sqs = localstack_clients["sqs"]

    # Create an SQS queue subscribed to the SNS topic
    # (SQS acts as a test observer for the notification)
    queue = sqs.create_queue(QueueName="test-notifications")
    queue_url = queue["QueueUrl"]
    queue_arn = sqs.get_queue_attributes(
        QueueUrl=queue_url,
        AttributeNames=["QueueArn"]
    )["Attributes"]["QueueArn"]

    # Subscribe the SQS queue to the notification topic
    sns.subscribe(
        TopicArn="arn:aws:sns:us-east-1:000000000000:order-notifications",
        Protocol="sqs",
        Endpoint=queue_arn,
    )

    # Publish the event
    events.put_events(Entries=[{
        "Source": "myapp.orders",
        "DetailType": "OrderCreated",
        "Detail": json.dumps({
            "orderId": "ORD-789",
            "amount": 250.00,
            "customerEmail": "test@example.com"
        }),
        "EventBusName": "orders-bus"
    }])

    # Wait and verify the notification arrived
    import time
    time.sleep(5)

    messages = sqs.receive_message(QueueUrl=queue_url, WaitTimeSeconds=10)
    assert "Messages" in messages
    notification = json.loads(messages["Messages"][0]["Body"])
    assert "ORD-789" in notification["Message"]

Паттерны тестирования SQS

Тестирование очереди мёртвых писем

Один из наиболее критичных, но часто не тестируемых паттернов — это очередь мёртвых писем (DLQ). Когда сообщение не может быть обработано несколько раз, оно должно попасть в DLQ, а не исчезнуть тихо.

# tests/integration/test_sqs_processing.py
import boto3
import pytest
import json
from testcontainers.localstack import LocalStackContainer

@pytest.fixture(scope="module")
def localstack():
    with LocalStackContainer("localstack/localstack:3.0") as ls:
        yield ls

@pytest.fixture
def sqs_client(localstack):
    return boto3.client(
        "sqs",
        endpoint_url=localstack.get_url(),
        region_name="us-east-1",
        aws_access_key_id="test",
        aws_secret_access_key="test",
    )

def test_dead_letter_queue_receives_failed_messages(sqs_client):
    """Messages that fail processing N times must land in the DLQ."""
    # Create DLQ
    dlq = sqs_client.create_queue(QueueName="orders-dlq")
    dlq_arn = sqs_client.get_queue_attributes(
        QueueUrl=dlq["QueueUrl"],
        AttributeNames=["QueueArn"]
    )["Attributes"]["QueueArn"]

    # Create main queue with DLQ redrive policy
    main_queue = sqs_client.create_queue(
        QueueName="orders",
        Attributes={
            "RedrivePolicy": json.dumps({
                "deadLetterTargetArn": dlq_arn,
                "maxReceiveCount": "2"
            })
        }
    )

    # Send a message that will fail processing
    sqs_client.send_message(
        QueueUrl=main_queue["QueueUrl"],
        MessageBody='{"orderId": "INVALID"}'
    )

    # Simulate 2 failed receive attempts (receive but don't delete)
    for _ in range(2):
        msgs = sqs_client.receive_message(QueueUrl=main_queue["QueueUrl"])
        # Not deleting = simulating processing failure

    # Verify message lands in DLQ
    import time
    time.sleep(2)
    dlq_msgs = sqs_client.receive_message(QueueUrl=dlq["QueueUrl"])
    assert len(dlq_msgs.get("Messages", [])) == 1
    failed_body = json.loads(dlq_msgs["Messages"][0]["Body"])
    assert failed_body["orderId"] == "INVALID"

Порядок сообщений и дедупликация (очереди FIFO)

def test_fifo_queue_preserves_message_order(sqs_client):
    """FIFO queues must deliver messages in the order they were sent."""
    queue = sqs_client.create_queue(
        QueueName="orders.fifo",
        Attributes={
            "FifoQueue": "true",
            "ContentBasedDeduplication": "true"
        }
    )

    # Send messages in a specific order
    for i in range(5):
        sqs_client.send_message(
            QueueUrl=queue["QueueUrl"],
            MessageBody=json.dumps({"sequence": i}),
            MessageGroupId="order-group-1",
        )

    # Receive and verify order
    received_sequences = []
    for _ in range(5):
        msgs = sqs_client.receive_message(QueueUrl=queue["QueueUrl"])
        if "Messages" in msgs:
            body = json.loads(msgs["Messages"][0]["Body"])
            received_sequences.append(body["sequence"])
            sqs_client.delete_message(
                QueueUrl=queue["QueueUrl"],
                ReceiptHandle=msgs["Messages"][0]["ReceiptHandle"]
            )

    assert received_sequences == [0, 1, 2, 3, 4]

def test_fifo_deduplication(sqs_client):
    """FIFO queues must deduplicate messages with the same dedup ID."""
    queue = sqs_client.create_queue(
        QueueName="dedup-test.fifo",
        Attributes={"FifoQueue": "true"}
    )

    # Send the same message twice with the same dedup ID
    for _ in range(2):
        sqs_client.send_message(
            QueueUrl=queue["QueueUrl"],
            MessageBody="duplicate message",
            MessageGroupId="group-1",
            MessageDeduplicationId="same-dedup-id",
        )

    # Should only receive one message
    import time
    time.sleep(1)
    msgs = sqs_client.receive_message(
        QueueUrl=queue["QueueUrl"],
        MaxNumberOfMessages=10,
    )
    assert len(msgs.get("Messages", [])) == 1

Тестирование асинхронных паттернов

Паттерн опроса

При тестировании асинхронных систем используйте опрос с таймаутом вместо time.sleep():

import time

def wait_for_condition(check_fn, timeout=30, interval=1):
    """Poll a condition until it's true or timeout."""
    deadline = time.time() + timeout
    last_error = None
    while time.time() < deadline:
        try:
            result = check_fn()
            if result:
                return result
        except Exception as e:
            last_error = e
        time.sleep(interval)
    raise TimeoutError(
        f"Condition not met within {timeout}s. Last error: {last_error}"
    )

# Usage:
def test_async_processing():
    publish_event({"orderId": "ORD-123"})

    # Poll DynamoDB until the order appears
    def check_order_processed():
        item = dynamodb.get_item(TableName="orders", Key={"orderId": {"S": "ORD-123"}})
        return item.get("Item", {}).get("status", {}).get("S") == "processed"

    wait_for_condition(check_order_processed, timeout=30)

Паттерн наблюдателя

Для сложных потоков событий подключите тестового наблюдателя, который перехватывает события для последующей проверки:

class EventObserver:
    """Captures events for testing by subscribing to the event stream."""

    def __init__(self, sqs_client, queue_name="test-observer"):
        self.sqs = sqs_client
        self.queue = sqs_client.create_queue(QueueName=queue_name)
        self.queue_url = self.queue["QueueUrl"]
        self.captured = []

    def drain(self, timeout=10):
        """Collect all messages received within the timeout period."""
        deadline = time.time() + timeout
        while time.time() < deadline:
            msgs = self.sqs.receive_message(
                QueueUrl=self.queue_url,
                WaitTimeSeconds=min(5, int(deadline - time.time())),
                MaxNumberOfMessages=10,
            )
            for msg in msgs.get("Messages", []):
                self.captured.append(json.loads(msg["Body"]))
                self.sqs.delete_message(
                    QueueUrl=self.queue_url,
                    ReceiptHandle=msg["ReceiptHandle"],
                )
        return self.captured

    def assert_received(self, event_type, count=1):
        """Assert that a specific event type was received."""
        matching = [e for e in self.captured if e.get("eventType") == event_type]
        assert len(matching) == count, \
            f"Expected {count} '{event_type}' events, found {len(matching)}"

Эти паттерны работают в любых событийных системах — SQS, EventBridge, Kafka, RabbitMQ. Конкретные API-вызовы различаются, но стратегия тестирования остаётся прежней: опубликовать, наблюдать и верифицировать состояние.