惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

MongoDB | Blog
MongoDB | Blog
J
Java Code Geeks
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
D
DataBreaches.Net
腾讯CDC
GbyAI
GbyAI
I
InfoQ
博客园 - Franky
G
Google Developers Blog
Last Week in AI
Last Week in AI
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
V
Visual Studio Blog
Vercel News
Vercel News
博客园_首页
MyScale Blog
MyScale Blog
Martin Fowler
Martin Fowler
N
Netflix TechBlog - Medium
V
V2EX
T
The Blog of Author Tim Ferriss
M
MIT News - Artificial intelligence
雷峰网
雷峰网
H
Hackread – Cybersecurity News, Data Breaches, AI and More
大猫的无限游戏
大猫的无限游戏
The GitHub Blog
The GitHub Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Testing Sagas with Real Failure Scenarios
Pedro Santos · 2026-05-05 · via DEV Community

In the previous post, I walked through the compensation logic in each service. The code looks clean on paper. But sagas have a lot of moving parts, and bugs tend to hide in the transitions between services, not inside a single service.

This post covers how I test the saga system: unit tests for each service, orchestrator routing tests, and the edge cases that caught me off guard.

Testing the Orchestrator Routing

The orchestrator's state transition table is the most critical piece. If it routes to the wrong topic, the entire saga breaks. I test every (source, status) combination:

@Test
void shouldReturnNextTopicGivenValidSourceAndSuccessStatus() {
    setEvent(PAYMENT_SERVICE.toString(), SUCCESS);

    TopicsEnum topic = sagaExecutionController.getNextTopic(event);

    assertEquals(INVENTORY_SUCCESS, topic);
}

@Test
void shouldReturnFailTopicGivenValidSourceAndFailStatus() {
    setEvent(PAYMENT_SERVICE.toString(), FAIL);

    TopicsEnum topic = sagaExecutionController.getNextTopic(event);

    assertEquals(PRODUCT_VALIDATION_FAIL, topic);
}

@Test
void shouldReturnRollbackTopic() {
    setEvent(PRODUCT_VALIDATION_SERVICE.toString(), ROLLBACK);

    TopicsEnum topic = sagaExecutionController.getNextTopic(event);

    assertEquals(PRODUCT_VALIDATION_FAIL, topic);
}

Enter fullscreen mode Exit fullscreen mode

These tests are fast and deterministic. No Kafka, no databases. Just the lookup logic. If someone adds a new service to the saga and forgets to update the table, the test for that (source, status) pair will fail with "Topic not found!"

Edge Cases in Routing

Two cases that caught me early on:

@Test
void shouldThrowValidationExceptionWhenSourceIsNull() {
    setEvent(null, SUCCESS);

    ValidationException ex = assertThrows(ValidationException.class, () -> {
        sagaExecutionController.getNextTopic(event);
    });

    assertEquals("Source and status must be informed.", ex.getMessage());
}

@Test
void shouldThrowValidationExceptionWhenTopicNotFound() {
    setEvent(PAYMENT_SERVICE.toString(), TIMEOUT);

    ValidationException ex = assertThrows(ValidationException.class, () -> {
        sagaExecutionController.getNextTopic(event);
    });

    assertEquals("Topic not found!", ex.getMessage());
}

Enter fullscreen mode Exit fullscreen mode

The TIMEOUT status exists in the enum but has no mapping in the saga table. Without this test, a timeout event would silently disappear. The exception makes it visible immediately.

Testing the OrchestrationService

The orchestration layer adds history entries and publishes to Kafka. I mock the producer and verify the correct topic:

@Test
void shouldStartSagaSuccessfully() {
    when(sagaExecutionController.getNextTopic(event))
        .thenReturn(TopicsEnum.PRODUCT_VALIDATION_SUCCESS);

    orchestrationService.startSaga(event);

    verify(producer).sendEvent(eq("product-validation-success"), eq("{json}"));
    assertEquals("ORCHESTRATOR", event.getSource());
    assertEquals(SUCCESS, event.getStatus());
    assertTrue(event.getEventHistory().stream()
        .anyMatch(h -> h.getMessage().contains("Saga started")));
}

@Test
void shouldFinishSagaWithFailure() {
    orchestrationService.finishSagaFail(event);

    verify(producer).sendEvent(eq("notify-ending"), eq("{json}"));
    assertEquals(FAIL, event.getStatus());
    assertTrue(event.getEventHistory().stream()
        .anyMatch(h -> h.getMessage().contains("with errors")));
}

Enter fullscreen mode Exit fullscreen mode

The history assertion is important. It verifies that each step leaves a trace. If a saga fails and the history is empty, debugging becomes guesswork.

Testing Payment: The Happy and Sad Paths

The payment-service has the most complex logic. It validates amounts, checks fraud scores, simulates gateway responses, and handles refunds. Here's how I test the main scenarios:

Payment Success

@Test
void shouldRealizePaymentSuccessfully_givenValidOrderAndAmount() {
    givenNoExistingPayment();
    givenPaymentFound();
    givenJsonSerialization();

    paymentService.realizePayment(event);

    assertEquals(SUCCESS, event.getStatus());
    assertEquals("PAYMENT_SERVICE", event.getSource());
    assertEquals(20.0, event.getOrder().getTotalAmount());
    assertHistoryContains("Payment realized successfully");
    verify(producer).sendEvent("{json}");
}

Enter fullscreen mode Exit fullscreen mode

Amount Below Minimum

@Test
void shouldRollback_givenAmountIsLessThanMinimum() {
    event = buildEvent(0.0, 1);     // unit value = 0.0
    payment = buildPayment(0.0, 1);
    givenNoExistingPayment();
    givenPaymentFound();
    givenJsonSerialization();

    paymentService.realizePayment(event);

    assertEquals(ROLLBACK, event.getStatus());
    assertHistoryContains("minimal amount");
}

Enter fullscreen mode Exit fullscreen mode

Duplicate Transaction

@Test
void shouldRollback_givenTransactionAlreadyExists() {
    when(paymentRepository.existsByOrderIdAndTransactionId(any(), any()))
        .thenReturn(true);
    givenJsonSerialization();

    paymentService.realizePayment(event);

    assertEquals(ROLLBACK, event.getStatus());
    assertHistoryContains("transactionId");
}

Enter fullscreen mode Exit fullscreen mode

Refund (Compensation)

@Test
void shouldRealizeRefund_whenPaymentExists() {
    when(paymentRepository.findByOrderIdAndTransactionId(any(), any()))
        .thenReturn(Optional.of(payment));
    givenJsonSerialization();

    paymentService.realizeRefund(event);

    assertEquals(FAIL, event.getStatus());
    assertEquals(PaymentStatus.REFUND, payment.getStatus());
    assertHistoryContains("Rollback executed for payment");
    verify(paymentRepository).save(payment);
}

Enter fullscreen mode Exit fullscreen mode

Refund Failure (Compensation of the Compensation)

This is the tricky one. What if the refund itself fails? The payment-service still publishes FAIL so the saga can continue rolling back. It just logs that the refund didn't execute:

@Test
void shouldHandleRefundFailureGracefully_whenPaymentNotFound() {
    when(paymentRepository.findByOrderIdAndTransactionId(any(), any()))
        .thenThrow(new RuntimeException("DB error"));
    givenJsonSerialization();

    paymentService.realizeRefund(event);

    assertEquals(FAIL, event.getStatus());
    assertHistoryContains("Rollback not executed for payment");
    verify(producer).sendEvent("{json}");
}

Enter fullscreen mode Exit fullscreen mode

The saga doesn't get stuck. The refund failure is recorded in the history for manual intervention later.

Testing Inventory Rollback

The inventory tests follow the same pattern. The interesting case is restoring stock to its previous value:

@Test
void shouldRollbackInventorySuccessfully() {
    OrderInventory orderInventory = OrderInventory.builder()
        .inventory(inventory)
        .oldQuantity(10)
        .newQuantity(5)
        .orderId("order-1")
        .transactionId("tx-123")
        .build();

    when(orderInventoryRepository.findByOrderIdAndTransactionId("order-1", "tx-123"))
        .thenReturn(List.of(orderInventory));

    inventoryService.rollbackInventory(event);

    assertEquals(FAIL, event.getStatus());
    assertEquals(10, inventory.getAvailable());  // restored to old value
    assertHistoryContains("Rollback executed for inventory");
}

Enter fullscreen mode Exit fullscreen mode

The oldQuantity was 10, the forward action reduced it to 5, and the rollback restores it to 10. Without the OrderInventory record that saves both values, this rollback would be impossible.

A Helper That Saves Time

I use the same assertion helper across all service tests:

private void assertHistoryContains(String expectedMessage) {
    assertTrue(event.getEventHistory().stream()
        .anyMatch(h -> h.getMessage().toLowerCase()
            .contains(expectedMessage.toLowerCase())),
        "Expected message not found in history: " + expectedMessage);
}

Enter fullscreen mode Exit fullscreen mode

This checks that the service added the right message to the event history. Every test verifies both the status AND the history. The status controls the saga flow. The history tells you why.

What I'd Do Differently

Looking back, there are a few things I'd add:

Integration tests with embedded Kafka. The unit tests mock the producer, so they don't catch serialization bugs or topic misconfiguration. An embedded Kafka setup would let me publish a real event and verify the full chain.

Testcontainers for the databases. The unit tests mock the repositories. A Testcontainers setup with real PostgreSQL and MongoDB would catch schema issues and migration bugs.

Chaos testing. Kill a service mid-saga and verify recovery. Introduce network delays between services. These are the scenarios that break sagas in production, and they're hard to test with mocks alone.

These are in the roadmap. For now, the unit tests cover the routing logic and compensation flows well enough to catch regressions.

Wrapping Up

The saga orchestrator pattern works because each piece is testable in isolation. The state transition table is a pure function. Each service's forward and compensation logic can be tested with mocked dependencies. The event history gives you a built-in audit trail.

The full test suite runs in seconds because nothing touches real infrastructure. That's the payoff of keeping the orchestrator stateless and the services decoupled.

The repo (with all tests): github.com/pedrop3/saga-orchestration