Step 11 — 테스트

학습 목표

  • TestWorkflowEnvironmentTemporal 서버 없이 워크플로우를 실행하고 JUnit 5 로 검증한다
  • 시간 스킵을 실측한다 — Workflow.sleep(Duration.ofDays(30)) 을 하는 워크플로우 테스트가 0.4초에 끝나는 것을 확인한다
  • Mockito 로 Activity 를 모킹하고, withoutAnnotations() 를 빠뜨렸을 때 나는 에러를 재현한다
  • TestActivityEnvironment 로 액티비티만 단독 테스트한다
  • Saga 보상이 역순으로 호출되었는지 InOrder 로 검증한다
  • WorkflowReplayer 로 운영 히스토리를 리플레이해 Step 10 의 버저닝이 안전한지 자동 검증하고, 깨졌을 때의 NonDeterministicException 을 직접 본다

선행 스텝: Step 10 — 버저닝과 무중단 배포 예상 소요: 100분


11-0. 실습 준비

Step 10 까지의 OrderWorkflow 와 4종 액티비티가 그대로 필요합니다. 이 스텝은 src/main 코드를 거의 건드리지 않고 src/test 만 채웁니다.

src/
├── main/java/com/example/order/
│   ├── OrderWorkflow.java
│   ├── OrderWorkflowImpl.java
│   ├── PaymentActivity.java  InventoryActivity.java
│   ├── ShippingActivity.java NotificationActivity.java
│   └── OrderRequest.java
└── test/
    ├── java/com/example/order/
    │   ├── OrderWorkflowTest.java        ← 11-2 ~ 11-7
    │   └── OrderWorkflowReplayTest.java  ← 11-8
    └── resources/histories/              ← 11-8 에서 운영 히스토리를 커밋할 곳

11-1. 테스트 의존성

build.gradle 에 테스트 의존성 3종을 추가합니다. Temporal Java SDK 1.22.3 기준입니다.

dependencies {
    implementation 'io.temporal:temporal-sdk:1.22.3'

    testImplementation 'io.temporal:temporal-testing:1.22.3'
    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1'
    testImplementation 'org.mockito:mockito-core:5.8.0'
    testImplementation 'org.assertj:assertj-core:3.24.2'
}

test {
    useJUnitPlatform()
    testLogging { events 'passed', 'failed', 'skipped'; showStandardStreams = true }
}
./gradlew dependencies --configuration testRuntimeClasspath | grep temporal

결과

+--- io.temporal:temporal-sdk:1.22.3
|    +--- io.temporal:temporal-serviceclient:1.22.3
+--- io.temporal:temporal-testing:1.22.3
|    +--- io.temporal:temporal-test-server:1.22.3
|    \--- io.temporal:temporal-sdk:1.22.3 (*)

temporal-testingtemporal-test-server 를 끌고 옵니다. 이게 인메모리 Temporal 서비스의 실체입니다. Docker 도, temporal server start-dev 도 필요 없습니다.

테스트 환경을 만드는 방법은 두 가지입니다.

(a) TestWorkflowExtension — JUnit 5 확장

@RegisterExtension
public static final TestWorkflowExtension testWorkflowExtension =
    TestWorkflowExtension.newBuilder()
        .setWorkflowTypes(OrderWorkflowImpl.class)
        .setActivityImplementations(new PaymentActivityImpl(), new InventoryActivityImpl())
        .setDoNotStart(true)      // 테스트 안에서 직접 start() 하고 싶을 때
        .build();

@Test
void 주문이_완료된다(TestWorkflowEnvironment testEnv, Worker worker, OrderWorkflow workflow) {
    worker.registerActivitiesImplementations(mockPayment);
    testEnv.start();
    assertThat(workflow.processOrder(req)).isEqualTo("order-1001 COMPLETED");
}

확장이 TestWorkflowEnvironment, Worker, 워크플로우 스텁을 테스트 메서드 파라미터로 주입합니다. 짧게 쓸 때 편합니다.

(b) 수동 TestWorkflowEnvironment — 명시적

@BeforeEach void setUp()  { testEnv = TestWorkflowEnvironment.newInstance(); ... }
@AfterEach  void tearDown() { testEnv.close(); }

Worker 구성·모킹·옵션을 테스트마다 다르게 가져가야 할 때는 (b) 가 낫습니다. 이 스텝은 (b) 를 기준으로 설명합니다. 동작 원리가 그대로 드러나기 때문입니다.

💡 실무 팁 — 둘을 섞지 마세요 한 테스트 클래스에서 TestWorkflowExtension 과 수동 TestWorkflowEnvironment 를 같이 쓰면 인메모리 서비스가 두 개 뜨고, 시간 스킵의 가상 시계도 두 개가 되어 타이머 테스트가 예측 불가능해집니다.


11-2. TestWorkflowEnvironment — 서버 없이 실행

TestWorkflowEnvironment.newInstance()프로세스 안에 Temporal 서비스를 띄웁니다. gRPC 포트도, PostgreSQL 도 없습니다. 히스토리는 힙 위의 자료구조입니다.

class OrderWorkflowTest {

    private static final String TASK_QUEUE = "ORDER_TASK_QUEUE";

    private TestWorkflowEnvironment testEnv;
    private Worker worker;
    private WorkflowClient client;

    private PaymentActivity payment;
    private InventoryActivity inventory;
    private ShippingActivity shipping;
    private NotificationActivity notification;

    @BeforeEach
    void setUp() {
        testEnv = TestWorkflowEnvironment.newInstance();
        worker  = testEnv.newWorker(TASK_QUEUE);
        client  = testEnv.getWorkflowClient();

        worker.registerWorkflowImplementationTypes(OrderWorkflowImpl.class);

        payment      = mock(PaymentActivity.class,      withSettings().withoutAnnotations());
        inventory    = mock(InventoryActivity.class,    withSettings().withoutAnnotations());
        shipping     = mock(ShippingActivity.class,     withSettings().withoutAnnotations());
        notification = mock(NotificationActivity.class, withSettings().withoutAnnotations());
        worker.registerActivitiesImplementations(payment, inventory, shipping, notification);
    }

    @AfterEach
    void tearDown() {
        testEnv.close();
    }

    @Test
    void 정상_주문은_COMPLETED_를_반환한다() {
        when(payment.charge("1001", 39000L)).thenReturn("PAY-8821");
        when(inventory.reserve("1001", "SKU-A", 2)).thenReturn("RSV-3310");
        when(shipping.requestShipment(eq("1001"), anyString())).thenReturn("SHIP-5507");

        testEnv.start();

        OrderWorkflow wf = client.newWorkflowStub(OrderWorkflow.class,
            WorkflowOptions.newBuilder()
                .setTaskQueue(TASK_QUEUE).setWorkflowId("order-1001").build());

        String result = wf.processOrder(
            new OrderRequest("1001", "C-77", "SKU-A", 2, 39000L, "서울시 강남구"));

        assertThat(result).isEqualTo("order-1001 COMPLETED");
        verify(payment).charge("1001", 39000L);
        verify(inventory).reserve("1001", "SKU-A", 2);
        verify(notification).notifyCustomer("1001", "주문이 완료되었습니다");
    }
}
./gradlew test --tests 'com.example.order.OrderWorkflowTest'

결과

> Task :test

OrderWorkflowTest > 정상_주문은_COMPLETED_를_반환한다() PASSED

Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.918 s

BUILD SUCCESSFUL in 4s
3 actionable tasks: 2 executed, 1 up-to-date

0.918초. 이 안에 인메모리 서비스 기동 + Worker 폴러 기동 + 워크플로우 전체 실행이 모두 들어 있습니다.

테스트 안에서도 히스토리는 진짜로 쌓입니다. 실행 후 이렇게 꺼내 볼 수 있습니다.

System.out.println(testEnv.getDiagnostics());

결과 (발췌)

Workflow Executions:
  WorkflowId=order-1001 RunId=cf1a1f0e-... Type=OrderWorkflow Status=COMPLETED
  Event History:
     1 WorkflowExecutionStarted
     4 WorkflowTaskCompleted
     5 ActivityTaskScheduled       PaymentActivity.charge
     7 ActivityTaskCompleted
     ...
    22 WorkflowExecutionCompleted

Step 02~03 에서 본 것과 동일한 이벤트 히스토리입니다. 테스트 환경이 진짜 Temporal 을 흉내 내는 게 아니라, 같은 실행 모델을 그대로 돌립니다.


11-3. 시간 스킵 — 30일을 0.4초에

이 스텝에서 가장 강력한 기능입니다.

주문 후 30일이 지나면 자동으로 리뷰 요청 알림을 보내는 워크플로우가 있다고 합시다.

public class ReviewReminderWorkflowImpl implements ReviewReminderWorkflow {
    private final NotificationActivity notification =
        Workflow.newActivityStub(NotificationActivity.class, ACT_OPTS);

    @Override
    public String remind(String orderId) {
        Workflow.sleep(Duration.ofDays(30));          // ← 30일 대기
        notification.notifyCustomer(orderId, "리뷰를 남겨 주세요");
        return orderId + " REMINDED";
    }
}

실제 서버에서는 이 워크플로우가 끝나는 데 30일이 걸립니다. 테스트에서는:

@Test
void 삼십일_뒤_리뷰_요청을_보낸다() {
    worker.registerWorkflowImplementationTypes(ReviewReminderWorkflowImpl.class);
    testEnv.start();

    ReviewReminderWorkflow wf = client.newWorkflowStub(
        ReviewReminderWorkflow.class,
        WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE)
            .setWorkflowId("review-1001").build());

    long before = System.currentTimeMillis();
    String result = wf.remind("1001");                 // 블로킹 호출인데도…
    long wallClock = System.currentTimeMillis() - before;

    assertThat(result).isEqualTo("1001 REMINDED");
    assertThat(wallClock).isLessThan(2000);            // 실제 경과 2초 미만
    verify(notification).notifyCustomer("1001", "리뷰를 남겨 주세요");
}
./gradlew test --tests '*ReviewReminder*'

결과

OrderWorkflowTest > 삼십일_뒤_리뷰_요청을_보낸다() PASSED

Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.412 s

Tests run: 1, Time elapsed: 0.412 s. 30일 = 2,592,000초를 0.412초에 통과했습니다. 약 630만 배입니다.

원리 — 가상 시계와 "모두 대기 중" 조건

테스트 환경의 시계는 벽시계가 아니라 가상 시계입니다. 규칙은 하나입니다.

모든 Worker 가 할 일이 없어 대기 상태가 되면, 가상 시계를 다음 타이머의 발화 시각으로 즉시 점프시킨다.

가상시각 T0        워크플로우 시작
                  Workflow.sleep(30d) → 타이머 등록 (발화 예정: T0+30d)
                  Worker: 처리할 Task 없음 → 유휴
                          ↓  "전원 유휴" 감지
가상시각 T0+30d     시계 점프. 타이머 발화 → TimerFired 이벤트
                  Workflow Task 생성 → Worker 가 깨어나 액티비티 실행

액티비티가 실행 중이면(=Worker 가 바쁘면) 점프하지 않습니다. 그래서 액티비티 로직의 실제 소요 시간은 그대로 반영되고, 대기 시간만 사라집니다.

가상 시각은 직접 조작하고 확인할 수 있습니다.

long t0 = testEnv.currentTimeMillis();
testEnv.sleep(Duration.ofDays(7));                 // 가상 시계를 7일 밀기
long t1 = testEnv.currentTimeMillis();
System.out.println("가상 경과일 = " + Duration.ofMillis(t1 - t0).toDays());

결과

가상 경과일 = 7

testEnv.sleep()테스트 스레드에서 시간을 진행시키는 용도입니다(워크플로우 안의 Workflow.sleep 과 다릅니다). 비동기로 워크플로우를 띄워 놓고 "3일 뒤 상태"를 검증할 때 씁니다.

WorkflowClient.start(wf::processOrder, req);        // 비동기 시작
testEnv.sleep(Duration.ofDays(3));                  // 가상 3일 경과
assertThat(wf.getStatus()).isEqualTo("WAITING_SHIPMENT");

⚠️ 함정 — 시간 스킵이 꺼지는 조건 시간 스킵은 "모든 Worker 가 유휴"일 때만 발동합니다. 그런데 테스트 스레드가 워크플로우를 동기 호출로 블로킹한 채, 별도 스레드에서 시그널을 보내는 패턴이 있습니다.

new Thread(() -> { Thread.sleep(500); wf.cancelRequested("x"); }).start();
wf.processOrder(req);   // 여기서 블로킹

이때 가상 시계가 먼저 점프해 버리면 시그널이 도착하기 전에 워크플로우가 타임아웃으로 끝나 버립니다. 테스트가 재현 불가능하게 깜빡거립니다(flaky). 이런 테스트는 시간 스킵을 끕니다.

testEnv = TestWorkflowEnvironment.newInstance(
    TestEnvironmentOptions.newBuilder().setUseTimeskipping(false).build());

끄면 가상 시계가 벽시계처럼 흐릅니다. 즉 Workflow.sleep(30d) 짜리 테스트는 절대 이 옵션으로 돌리면 안 됩니다. 긴 대기가 있는 테스트와 외부 스레드 시그널 테스트는 클래스를 분리하세요.

⚠️ 함정 — 테스트에 Thread.sleep() 을 쓰면 시간 스킵이 무의미해진다 "시그널 보내기 전에 좀 기다려야지" 하고 Thread.sleep(2000) 을 넣는 순간, 그 2초는 실제로 흘러갑니다. 가상 시계가 아니라 벽시계이기 때문입니다. 테스트 100개에 2초씩 넣으면 CI 가 3분 더 걸립니다. 그리고 CI 머신이 느린 날에는 2초로 부족해 실패합니다. 대기가 필요하면 항상 testEnv.sleep(Duration) 을 쓰세요. 가상 시계를 밀 뿐이라 즉시 반환됩니다. Thread.sleep 은 시간 스킵을 끈 테스트에서만, 그것도 최후 수단으로 씁니다.


11-4. Activity 모킹

워크플로우 테스트에서 액티비티 구현체를 그대로 쓰면 결제 API 를 실제로 호출하게 됩니다. 액티비티는 모킹하고 워크플로우 로직만 검증하는 것이 원칙입니다.

PaymentActivity payment = mock(PaymentActivity.class, withSettings().withoutAnnotations());
when(payment.charge("1001", 39000L)).thenReturn("PAY-8821");
worker.registerActivitiesImplementations(payment);

실패 주입은 ApplicationFailure 로 합니다. Step 05 에서 본 그대로입니다.

// 재시도 가능한 실패 — RetryOptions 만큼 재시도된 뒤 최종 실패
when(payment.charge("1001", 39000L))
    .thenThrow(ApplicationFailure.newFailure("카드사 응답 없음", "PaymentGatewayTimeout"));

// 재시도 불가 실패 — 즉시 워크플로우로 전파
when(payment.charge("1001", 39000L))
    .thenThrow(ApplicationFailure.newNonRetryableFailure("잔액 부족", "InsufficientFunds"));

// 첫 두 번은 실패, 세 번째 성공 — 재시도 동작 자체를 검증
when(payment.charge("1001", 39000L))
    .thenThrow(ApplicationFailure.newFailure("일시 오류", "Transient"))
    .thenThrow(ApplicationFailure.newFailure("일시 오류", "Transient"))
    .thenReturn("PAY-8821");

세 번째 패턴의 테스트:

@Test
void 결제는_두번_실패해도_세번째에_성공한다() {
    when(payment.charge("1001", 39000L))
        .thenThrow(ApplicationFailure.newFailure("일시 오류", "Transient"))
        .thenThrow(ApplicationFailure.newFailure("일시 오류", "Transient"))
        .thenReturn("PAY-8821");
    when(inventory.reserve(any(), any(), anyInt())).thenReturn("RSV-3310");
    when(shipping.requestShipment(any(), any())).thenReturn("SHIP-5507");

    testEnv.start();
    assertThat(newStub("order-1002").processOrder(req("1002")))
        .isEqualTo("order-1002 COMPLETED");

    verify(payment, times(3)).charge("1002", 39000L);   // 재시도 2회 + 성공 1회
}

결과

OrderWorkflowTest > 결제는_두번_실패해도_세번째에_성공한다() PASSED

Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.507 s

재시도 백오프가 initialInterval=1s, backoffCoefficient=2.0 이면 실제로는 1초 + 2초 = 3초를 기다려야 합니다. 0.507초에 끝난 것은 시간 스킵이 재시도 백오프에도 적용되기 때문입니다. 재시도 테스트가 실용적인 이유입니다.

⚠️ 함정 — withSettings().withoutAnnotations() 를 빠뜨리면 액티비티가 등록되지 않는다 Mockito 는 기본적으로 모킹 대상의 어노테이션을 프록시 클래스에 복사합니다. 그래서 mock(PaymentActivity.class) 의 결과물에는 @ActivityInterface 가 클래스 레벨로 붙어 버립니다. Temporal SDK 는 등록된 객체를 보고 "이 클래스가 직접 @ActivityInterface 를 달고 있네? 그럼 이건 인터페이스가 아니라 구현이어야 하는데?" 라고 판단하고 거부합니다.

worker.registerActivitiesImplementations(mock(PaymentActivity.class));   // 잘못됨

결과

java.lang.IllegalArgumentException: Interface annotated with @ActivityInterface
  can't be registered as an activity implementation:
  interface com.example.order.PaymentActivity
    at io.temporal.internal.activity.ActivityTaskHandlerImpl.registerActivityImplementations
    at io.temporal.worker.Worker.registerActivitiesImplementations(Worker.java:214)
    at com.example.order.OrderWorkflowTest.setUp(OrderWorkflowTest.java:48)

이 에러 메시지가 혼란스러운 이유는 인터페이스를 등록한 적이 없기 때문입니다. 등록한 건 mock 객체인데, 어노테이션이 복사되는 바람에 SDK 눈에는 인터페이스처럼 보인 것입니다. 해결: 모든 액티비티 mock 에 withSettings().withoutAnnotations() 를 붙입니다.

mock(PaymentActivity.class, withSettings().withoutAnnotations())

헬퍼 메서드로 감싸 두면 빠뜨릴 일이 없습니다.

static <T> T activityMock(Class<T> type) {
    return mock(type, withSettings().withoutAnnotations());
}

11-5. TestActivityEnvironment — 액티비티만 단독 테스트

워크플로우를 거치지 않고 액티비티 구현체만 테스트하고 싶을 때 씁니다. 액티비티 안에서 Activity.getExecutionContext() 를 쓰거나 하트비트를 보내는 코드는 일반 JUnit 테스트로는 돌지 않습니다(컨텍스트가 없어 NPE). TestActivityEnvironment 가 그 컨텍스트를 제공합니다.

@Test
void 재고_예약_액티비티가_예약ID를_반환한다() {
    TestActivityEnvironment env = TestActivityEnvironment.newInstance();
    env.registerActivitiesImplementations(new InventoryActivityImpl());

    InventoryActivity stub = env.newActivityStub(InventoryActivity.class);

    String reservationId = stub.reserve("1001", "SKU-A", 2);

    assertThat(reservationId).startsWith("RSV-");
    env.close();
}

결과

InventoryActivityTest > 재고_예약_액티비티가_예약ID를_반환한다() PASSED

Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.089 s

0.089초. 워크플로우 테스트(0.9초)의 1/10 입니다. 인메모리 워크플로우 서비스를 띄우지 않기 때문입니다.

하트비트와 취소를 검증하려면:

TestActivityEnvironment env = TestActivityEnvironment.newInstance();
env.registerActivitiesImplementations(new ShippingActivityImpl());

List<Object> heartbeats = new ArrayList<>();
env.setActivityHeartbeatListener(String.class, heartbeats::add);

ShippingActivity stub = env.newActivityStub(ShippingActivity.class);
CompletableFuture<String> f =
    CompletableFuture.supplyAsync(() -> stub.requestShipment("1001", "서울시 강남구"));

Thread.sleep(300);
env.requestCancelActivity();                    // 취소 요청

assertThatThrownBy(f::get).hasCauseInstanceOf(ActivityCanceledException.class);
assertThat(heartbeats).contains("polling-1", "polling-2");
env.close();

결과

ShippingActivityTest > 장시간_배송조회는_하트비트를_보내고_취소에_반응한다() PASSED

Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.463 s

setActivityHeartbeatListener 로 액티비티가 실제로 하트비트를 보냈는지 확인하고, requestCancelActivity() 로 취소 신호를 넣어 ActivityCanceledException 이 나오는지 봅니다. 하트비트를 안 보내는 장시간 액티비티를 잡아내는 유일한 자동화 수단입니다.


11-6. 시그널·쿼리 테스트

Step 07 의 시그널·쿼리를 검증하려면 워크플로우를 비동기로 시작해야 합니다. 동기 호출(wf.processOrder(req))은 완료까지 블로킹하므로 시그널을 보낼 틈이 없습니다.

@Test
void 취소_시그널을_받으면_상태가_CANCELED_로_바뀐다() {
    when(payment.charge(any(), anyLong())).thenReturn("PAY-8821");
    when(inventory.reserve(any(), any(), anyInt())).thenReturn("RSV-3310");
    testEnv.start();

    OrderWorkflow wf = client.newWorkflowStub(OrderWorkflow.class,
        WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE)
            .setWorkflowId("order-1003").build());

    // ① 비동기 시작 — 즉시 반환된다
    WorkflowExecution exec = WorkflowClient.start(wf::processOrder, req("1003"));
    assertThat(exec.getWorkflowId()).isEqualTo("order-1003");

    // ② 가상 시간을 조금 흘려 워크플로우가 대기 지점까지 진행하게 한다
    testEnv.sleep(Duration.ofSeconds(1));

    // ③ 쿼리 — 진행 중 상태 확인
    assertThat(wf.getStatus()).isEqualTo("WAITING_SHIPMENT");

    // ④ 시그널 — 취소 요청
    wf.cancelRequested("고객 변심");

    // ⑤ 쿼리 — 시그널 반영 확인
    testEnv.sleep(Duration.ofSeconds(1));
    assertThat(wf.getStatus()).isEqualTo("CANCELED");

    // ⑥ 결과 회수 — 타입 스텁을 WorkflowStub 으로 변환
    String result = WorkflowStub.fromTyped(wf).getResult(String.class);
    assertThat(result).isEqualTo("order-1003 CANCELED");
}

결과

OrderWorkflowTest > 취소_시그널을_받으면_상태가_CANCELED_로_바뀐다() PASSED

Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.634 s

핵심은 ⑥ 입니다. WorkflowClient.start()WorkflowExecution(ID 와 RunId)만 돌려주므로 결과를 받으려면 WorkflowStub.fromTyped(wf).getResult(String.class) 로 타입 스텁을 언타입 스텁으로 바꿔 기다려야 합니다. 이걸 모르면 "시그널 테스트에서 결과를 어떻게 받나" 하고 막힙니다.

💡 실무 팁 — 쿼리 사이에 testEnv.sleep() 을 한 번 넣으세요 시그널은 히스토리에 기록된 뒤 다음 Workflow Task 에서 처리됩니다. 시그널 직후 곧바로 쿼리하면 아직 반영 전일 수 있습니다. testEnv.sleep(Duration.ofSeconds(1)) 은 가상 시각만 밀 뿐이라 비용이 0 이면서 Workflow Task 를 한 번 돌게 만듭니다. Thread.sleep 과 달리 CI 를 느리게 하지 않습니다.


11-7. Saga 보상 테스트 — 역순 검증

Step 09 의 Saga 는 "배송 실패 시 재고 해제 → 결제 환불" 순으로 역순 보상합니다. 순서가 뒤바뀌면 재고를 못 푼 채 환불만 되는 상태가 생길 수 있습니다. 이걸 자동으로 잡습니다.

@Test
void 배송이_실패하면_보상이_역순으로_실행된다() {
    when(payment.charge("1004", 39000L)).thenReturn("PAY-8821");
    when(inventory.reserve("1004", "SKU-A", 2)).thenReturn("RSV-3310");
    // 배송만 재시도 불가 실패로 주입
    when(shipping.requestShipment(eq("1004"), anyString()))
        .thenThrow(ApplicationFailure.newNonRetryableFailure(
            "배송 불가 지역", "ShippingUnavailable"));

    testEnv.start();

    assertThatThrownBy(() -> newStub("order-1004").processOrder(req("1004")))
        .isInstanceOf(WorkflowFailedException.class)
        .hasRootCauseMessage("배송 불가 지역");

    // 보상이 "실행의 역순"으로 호출되었는지 검증
    InOrder inOrder = inOrder(payment, inventory, shipping);
    inOrder.verify(payment).charge("1004", 39000L);         // 정방향 1
    inOrder.verify(inventory).reserve("1004", "SKU-A", 2);  // 정방향 2
    inOrder.verify(shipping).requestShipment(eq("1004"), anyString()); // 정방향 3 (실패)
    inOrder.verify(inventory).release("RSV-3310");          // 보상 1 ← 나중 것 먼저
    inOrder.verify(payment).refund("PAY-8821");             // 보상 2 ← 먼저 한 것 나중에

    verify(shipping, never()).cancelShipment(anyString());  // 배송은 애초에 성공한 적 없음
}

결과

OrderWorkflowTest > 배송이_실패하면_보상이_역순으로_실행된다() PASSED

Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.721 s

만약 구현이 보상을 정방향으로 돌린다면(Saga.Options.setParallelCompensation 을 잘못 쓰거나 직접 짠 보상 루프의 순서를 뒤집었다면) 다음처럼 실패합니다.

결과 (보상 순서가 잘못된 구현일 때)

OrderWorkflowTest > 배송이_실패하면_보상이_역순으로_실행된다() FAILED
    org.mockito.exceptions.verification.VerificationInOrderFailure:
    Verification in order failure
    Wanted but not invoked:
    inventoryActivity.release("RSV-3310");
    Wanted anywhere AFTER following interaction:
    paymentActivity.refund("PAY-8821");

Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.698 s

InOrder 없이 verify(payment).refund(...) 만 했다면 둘 다 호출되었으니 통과했을 것입니다. 순서 버그는 InOrder 로만 잡힙니다.


11-8. 리플레이 테스트 (WorkflowReplayer)

여기가 이 스텝의 결론입니다.

Step 10 에서 배운 것: 워크플로우 코드를 바꾸면 진행 중인 워크플로우가 리플레이 시 NonDeterministicException 으로 멈출 수 있다. Workflow.getVersion() 으로 방어하지만, "제대로 방어했는지"는 어떻게 확인할까요?

로컬 테스트로는 못 잡습니다. 로컬 테스트는 항상 새 워크플로우를 처음부터 실행하므로 옛 히스토리와 부딪힐 일이 없습니다. 필요한 건 운영에서 실제로 만들어진 히스토리로 새 코드를 리플레이해 보는 것입니다.

① 운영 히스토리 덤프

temporal workflow show \
  --workflow-id order-1001 \
  --namespace orders \
  --output json > src/test/resources/histories/order-1001.json

결과

$ ls -lh src/test/resources/histories/
-rw-r--r--  1 dev  staff    18K Jul 20 11:04 order-1001.json

$ head -c 320 src/test/resources/histories/order-1001.json
{
  "events": [
    {
      "eventId": "1",
      "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED",
      "workflowExecutionStartedEventAttributes": {
        "workflowType": { "name": "OrderWorkflow" },
        "taskQueue": { "name": "ORDER_TASK_QUEUE" },

② 리플레이 테스트

import io.temporal.testing.WorkflowReplayer;

class OrderWorkflowReplayTest {

    @Test
    void 운영_히스토리를_현재_코드로_리플레이할_수_있다() throws Exception {
        WorkflowReplayer.replayWorkflowExecutionFromResource(
            "histories/order-1001.json", OrderWorkflowImpl.class);
    }
}

replayWorkflowExecutionFromResource 는 클래스패스 리소스에서 히스토리를 읽어 워크플로우 코드를 그 히스토리대로 재실행합니다. 액티비티는 실행되지 않습니다 — 히스토리에 결과가 이미 있으므로 그것을 먹입니다. 즉 네트워크도, Worker 도, 서버도 없이 순수 결정성만 검증합니다.

./gradlew test --tests '*ReplayTest'

결과 (코드가 히스토리와 호환될 때)

OrderWorkflowReplayTest > 운영_히스토리를_현재_코드로_리플레이할_수_있다() PASSED

Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.147 s

BUILD SUCCESSFUL in 3s

0.147초. 서버가 없으니 워크플로우 테스트보다도 빠릅니다.

③ 깨졌을 때

OrderWorkflowImpl 에서 Workflow.getVersion() 가드를 빼고 액티비티 순서를 바꿔 봅니다(결제 → 재고 를 재고 → 결제 로).

결과

OrderWorkflowReplayTest > 운영_히스토리를_현재_코드로_리플레이할_수_있다() FAILED
    java.lang.RuntimeException: Replay failed
        at io.temporal.testing.WorkflowReplayer.replayWorkflowExecution(WorkflowReplayer.java:210)
    Caused by: io.temporal.worker.NonDeterministicException:
        History event is not compatible with the command produced by the workflow code.
        HistoryEvent[eventId=5, eventType=ACTIVITY_TASK_SCHEDULED,
                     activityType=PaymentActivity_charge, activityId=1]
        Command[commandType=SCHEDULE_ACTIVITY_TASK,
                activityType=InventoryActivity_reserve, activityId=1]
        at io.temporal.internal.statemachines.WorkflowStateMachines.handleCommandEvent
        at io.temporal.internal.replay.ReplayWorkflowRunTaskHandler.handleWorkflowTask

Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.203 s

BUILD FAILED

eventId=5 에서 히스토리는 PaymentActivity_charge 를 기대했는데 새 코드는 InventoryActivity_reserve 를 요청했다 — 정확히 어디가 어긋났는지 알려 줍니다. 운영에 나가기 전에, 3초짜리 CI 단계에서 잡힙니다.

④ 히스토리 디렉터리 전체를 도는 파라미터화 테스트

히스토리 하나로는 부족합니다. 정상 완료, 보상 발생, 시그널 수신, Continue-As-New 등 대표 경로별로 모아 둡니다.

class OrderWorkflowReplayTest {

    static Stream<Path> histories() throws Exception {
        Path dir = Paths.get(
            OrderWorkflowReplayTest.class.getResource("/histories").toURI());
        try (Stream<Path> s = Files.list(dir)) {
            return s.filter(p -> p.toString().endsWith(".json")).toList().stream();
        }
    }

    @ParameterizedTest(name = "replay {0}")
    @MethodSource("histories")
    void 모든_운영_히스토리가_리플레이된다(Path history) throws Exception {
        WorkflowReplayer.replayWorkflowExecution(
            Files.readString(history), OrderWorkflowImpl.class);
    }
}
./gradlew test --tests '*ReplayTest'

결과

OrderWorkflowReplayTest > replay order-1001.json          PASSED
OrderWorkflowReplayTest > replay order-1002-saga.json     PASSED
OrderWorkflowReplayTest > replay order-1003-signal.json   PASSED
OrderWorkflowReplayTest > replay order-1004-can.json      PASSED
OrderWorkflowReplayTest > replay order-1005-timeout.json  PASSED

Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.612 s

⑤ CI 에 넣기

운영 히스토리를 주기적으로 덤프해 리포지터리에 커밋하는 스크립트입니다.

#!/usr/bin/env bash
# scripts/dump-histories.sh — 대표 워크플로우 히스토리를 테스트 리소스로 덤프
set -euo pipefail
DEST=src/test/resources/histories
mkdir -p "$DEST"

for wid in order-1001 order-1002-saga order-1003-signal order-1004-can order-1005-timeout; do
  temporal workflow show -w "$wid" --namespace orders --output json > "$DEST/$wid.json"
  echo "dumped $wid ($(wc -c < "$DEST/$wid.json") bytes)"
done

결과

$ ./scripts/dump-histories.sh
dumped order-1001 (18432 bytes)
dumped order-1002-saga (31067 bytes)
dumped order-1003-signal (22910 bytes)
dumped order-1004-can (15588 bytes)
dumped order-1005-timeout (12204 bytes)

이 파일들을 커밋하고, 모든 PR 에서 리플레이 테스트를 돌립니다.

# .github/workflows/ci.yml
- name: Replay compatibility check
  run: ./gradlew test --tests '*ReplayTest'

💡 실무 팁 — 히스토리는 Retention 안에 덤프해야 한다 Namespace 의 Retention Period 가 72시간이면 3일 지난 워크플로우는 temporal workflow show 가 NotFound 입니다. 덤프 스크립트를 매일 도는 크론에 넣으세요. (Step 12 의 12-2 에서 이어집니다.)

⚠️ 함정 — 리플레이 테스트 없이 배포하면 로컬은 항상 통과한다 이것이 이 코스 전체에서 가장 위험한 함정입니다. Workflow.getVersion() 을 빼먹고 액티비티 순서를 바꾼 커밋을 올려도, 로컬 테스트 30개가 전부 통과합니다. 워크플로우 테스트는 매번 새 히스토리를 만들어 새 코드로 실행하기 때문에 새 코드끼리는 언제나 일관되기 때문입니다. 배포하고 나서야, 이미 진행 중이던 워크플로우 수천 개가 Workflow Task 를 실패하며 무한 재시도에 빠집니다. temporal_workflow_task_execution_failed 메트릭이 치솟고, 워크플로우들은 Running 상태로 얼어붙습니다(Step 12 의 12-8 진단 절차로 이어집니다). 리플레이 테스트는 Step 10 의 버저닝이 안전한지 배포 전에 검증할 수 있는 유일한 방법입니다. 다른 어떤 테스트도 이걸 대신하지 못합니다.


11-9. 테스트 피라미드

도구무엇을 검증속도개수
① Activity 단위TestActivityEnvironment 또는 순수 JUnit액티비티 내부 로직, 하트비트, 취소 반응~0.09초많이 (액티비티마다)
② Workflow (모킹)TestWorkflowEnvironment + Mockito분기·타이머·시그널·Saga 보상 순서~0.5초많이 (경로마다)
③ 리플레이WorkflowReplayer + 운영 히스토리배포해도 진행 중 워크플로우가 안 깨지는지~0.15초대표 히스토리 5~20개
④ 통합실제 Temporal 서버 + 실제 액티비티직렬화, Worker 등록, 네트워크, 외부 시스템~10초+소수 (스모크 1~3개)
  • ①②③ 은 서버가 필요 없어 모든 PR 에서 돌립니다.
  • ④ 는 docker compose up -d 로 서버를 띄워야 하므로 nightly 나 배포 파이프라인에만 둡니다.
  • ③ 이 없으면 ①②④ 를 아무리 늘려도 버저닝 사고를 막지 못합니다. ③ 은 개수는 가장 적지만 대체 불가능합니다.

정리

개념핵심
temporal-testingtemporal-test-server 를 포함. 인메모리 Temporal, Docker 불필요
TestWorkflowEnvironmentnewInstance()newWorker()start()close(). 진짜 히스토리가 쌓인다
TestWorkflowExtensionJUnit 5 확장. env·worker·스텁을 파라미터로 주입. 간단한 테스트에
시간 스킵모든 Worker 가 유휴면 가상 시계가 다음 타이머로 점프. 30일 → 0.412초
testEnv.sleep(Duration)가상 시각을 밀기만 함. 비용 0. Thread.sleep 대신 항상 이것
setUseTimeskipping(false)외부 스레드 시그널 등 시간 스킵이 방해가 될 때만. 긴 대기 테스트와 섞지 말 것
Activity 모킹mock(X.class, withSettings().withoutAnnotations())어노테이션 제거 필수
실패 주입thenThrow(ApplicationFailure.newFailure / newNonRetryableFailure)
재시도 검증.thenThrow().thenThrow().thenReturn() + verify(x, times(3)). 백오프도 스킵됨
TestActivityEnvironment액티비티 단독. 하트비트 리스너 · requestCancelActivity()
시그널·쿼리WorkflowClient.start() 로 비동기 시작 → WorkflowStub.fromTyped(wf).getResult() 로 회수
Saga 보상InOrder역순 호출을 검증. verify 만으로는 순서 버그를 못 잡는다
WorkflowReplayer운영 히스토리 + 현재 코드 → 호환되면 통과, 아니면 NonDeterministicException
리플레이 CI히스토리를 src/test/resources/histories/ 에 커밋, @ParameterizedTest 로 전수
최대 함정리플레이 테스트가 없으면 로컬은 항상 통과한다. 버저닝 사고는 배포 후에 터진다

연습문제

Exercise.java 에 7문제가 있습니다. 정답은 Solution.java.

  1. TestWorkflowEnvironment 를 수동으로 구성하고 정상 주문 테스트를 완성하기
  2. Workflow.sleep(Duration.ofDays(30)) 워크플로우를 2초 미만에 통과시키기 (시간 스킵)
  3. 액티비티 mock 등록이 IllegalArgumentException 으로 실패하는 코드를 고치기
  4. 결제가 2회 실패 후 성공하는 시나리오를 만들고 호출 횟수를 검증하기
  5. 비동기 시작 → 시그널 → 쿼리 → 결과 회수의 4단계 테스트 작성하기
  6. Saga 보상이 역순인지 InOrder 로 검증하기
  7. WorkflowReplayer 로 히스토리 디렉터리 전체를 도는 @ParameterizedTest 작성하기

다음 단계

테스트로 배포 전 안전성을 확보했다면, 이제 배포 를 다룹니다. Namespace 와 Retention 을 어떻게 잡을지, temporal CLI 로 무엇을 볼 수 있는지, terminate 와 cancel 이 왜 완전히 다른지, 그리고 워크플로우가 Running 인데 안 움직일 때 무엇부터 확인할지를 정리합니다. 11-8 에서 남겨 둔 "히스토리는 Retention 안에 덤프해야 한다"는 숙제도 여기서 해결합니다.

Step 12 — 운영


실습 파일

이 스텝의 세 파일은 모두 JUnit 5 테스트 클래스입니다. 프로덕션 코드가 아니라 src/test/java/com/example/order/ 에 두고 ./gradlew test 로 돌립니다. 먼저 Practice.java 를 그대로 실행해 11-2 ~ 11-8 의 모든 측정(0.918초 / 0.412초 / 0.147초)을 재현하고, Exercise.java 의 7문제를 채운 뒤, Solution.java 로 대조합니다.

Practice.java

본문 11-2 ~ 11-9 의 모든 테스트를 절 번호 주석과 함께 한 파일에 담았습니다.

  • 최상위 Practice 클래스 안에 @Nested 로 구간을 나눴습니다. WorkflowTests(11-2·11-4·11-6·11-7), TimeSkippingTests(11-3), ActivityTests(11-5), ReplayTests(11-8) 입니다. ./gradlew test --tests 'com.example.order.Practice$TimeSkippingTests' 처럼 구간만 골라 돌릴 수 있습니다.
  • 액티비티 mock 은 전부 파일 상단의 static <T> T activityMock(Class<T>) 헬퍼를 거칩니다. 이 헬퍼가 withSettings().withoutAnnotations() 를 감싸고 있으므로 11-4 의 함정을 구조적으로 피합니다. 직접 mock() 을 부르지 마세요.
  • TimeSkippingTestsSystem.currentTimeMillis() 로 벽시계 경과를 재서 assertThat(wallClock).isLessThan(2000) 으로 단언합니다. 즉 "빨리 끝났다"가 주석이 아니라 테스트 조건입니다. 시간 스킵이 꺼지면 이 테스트가 실패합니다.
  • ReplayTestssrc/test/resources/histories/.json 파일이 있어야 돌아갑니다. 파일이 없으면 @ParameterizedTest 가 0건으로 끝나며 조용히 통과하므로, 클래스 안에 histories() 가 비어 있지 않다는 가드 테스트를 함께 넣어 두었습니다.
  • 파일 맨 아래 SelfContainedFixturesReviewReminderWorkflow / Impl 과 간단한 액티비티 구현이 들어 있어, 본문의 30일 대기 예제를 별도 파일 없이 그대로 돌릴 수 있습니다.
package com.example.order;

/*
 * Step 11 — 테스트 : Practice
 *
 * 실행 방법
 *   전체        : ./gradlew test --tests 'com.example.order.Practice'
 *   구간만      : ./gradlew test --tests 'com.example.order.Practice$TimeSkippingTests'
 *   리플레이만  : ./gradlew test --tests 'com.example.order.Practice$ReplayTests'
 *
 * 위치: src/test/java/com/example/order/Practice.java
 *
 * 필요 의존성 (build.gradle)
 *   testImplementation 'io.temporal:temporal-testing:1.22.3'
 *   testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1'
 *   testImplementation 'org.mockito:mockito-core:5.8.0'
 *   testImplementation 'org.assertj:assertj-core:3.24.2'
 *
 * Temporal Server 를 띄울 필요가 없습니다. TestWorkflowEnvironment 가
 * temporal-test-server 를 인메모리로 기동합니다. (11-1)
 */

import io.temporal.activity.ActivityInterface;
import io.temporal.activity.ActivityMethod;
import io.temporal.activity.ActivityOptions;
import io.temporal.api.common.v1.WorkflowExecution;
import io.temporal.client.WorkflowClient;
import io.temporal.client.WorkflowFailedException;
import io.temporal.client.WorkflowOptions;
import io.temporal.client.WorkflowStub;
import io.temporal.failure.ApplicationFailure;
import io.temporal.testing.TestActivityEnvironment;
import io.temporal.testing.TestWorkflowEnvironment;
import io.temporal.worker.Worker;
import io.temporal.workflow.Workflow;
import io.temporal.workflow.WorkflowInterface;
import io.temporal.workflow.WorkflowMethod;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InOrder;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.withSettings;

class Practice {

    static final String TASK_QUEUE = "ORDER_TASK_QUEUE";

    // [11-4] 액티비티 mock 은 반드시 이 헬퍼를 거칩니다.
    //        withSettings().withoutAnnotations() 를 빠뜨리면 registerActivitiesImplementations 가
    //        IllegalArgumentException: Interface annotated with @ActivityInterface can't be
    //        registered as an activity implementation 으로 실패합니다.
    static <T> T activityMock(Class<T> type) {
        return mock(type, withSettings().withoutAnnotations());
    }

    static OrderRequest req(String orderId) {
        return new OrderRequest(orderId, "C-77", "SKU-A", 2, 39000L, "서울시 강남구");
    }

    // =====================================================================
    // [11-2][11-4][11-6][11-7] 워크플로우 테스트
    // =====================================================================
    @Nested
    @DisplayName("11-2/4/6/7 — TestWorkflowEnvironment 워크플로우 테스트")
    class WorkflowTests {

        TestWorkflowEnvironment testEnv;
        Worker worker;
        WorkflowClient client;

        PaymentActivity payment;
        InventoryActivity inventory;
        ShippingActivity shipping;
        NotificationActivity notification;

        @BeforeEach
        void setUp() {
            // [11-2] 서버 없이 인메모리 Temporal 서비스가 뜹니다.
            testEnv = TestWorkflowEnvironment.newInstance();
            worker = testEnv.newWorker(TASK_QUEUE);
            client = testEnv.getWorkflowClient();

            worker.registerWorkflowImplementationTypes(OrderWorkflowImpl.class);

            payment = activityMock(PaymentActivity.class);
            inventory = activityMock(InventoryActivity.class);
            shipping = activityMock(ShippingActivity.class);
            notification = activityMock(NotificationActivity.class);
            worker.registerActivitiesImplementations(payment, inventory, shipping, notification);
        }

        @AfterEach
        void tearDown() {
            testEnv.close();
        }

        OrderWorkflow stub(String workflowId) {
            return client.newWorkflowStub(OrderWorkflow.class,
                    WorkflowOptions.newBuilder()
                            .setTaskQueue(TASK_QUEUE)
                            .setWorkflowId(workflowId)
                            .build());
        }

        // [11-2] 첫 테스트 — 기대 소요 0.9초 내외
        @Test
        @DisplayName("11-2 정상 주문은 COMPLETED 를 반환한다")
        void 정상_주문은_COMPLETED_를_반환한다() {
            when(payment.charge("1001", 39000L)).thenReturn("PAY-8821");
            when(inventory.reserve("1001", "SKU-A", 2)).thenReturn("RSV-3310");
            when(shipping.requestShipment(eq("1001"), anyString())).thenReturn("SHIP-5507");

            testEnv.start();

            String result = stub("order-1001").processOrder(req("1001"));

            assertThat(result).isEqualTo("order-1001 COMPLETED");
            verify(payment).charge("1001", 39000L);
            verify(inventory).reserve("1001", "SKU-A", 2);
            verify(notification).notifyCustomer("1001", "주문이 완료되었습니다");

            // 테스트 환경에도 진짜 이벤트 히스토리가 쌓입니다.
            System.out.println(testEnv.getDiagnostics());
        }

        // [11-4] 실패 주입 — 재시도 불가
        @Test
        @DisplayName("11-4 잔액 부족은 재시도 없이 즉시 실패한다")
        void 잔액_부족은_즉시_실패한다() {
            when(payment.charge("1002", 39000L)).thenThrow(
                    ApplicationFailure.newNonRetryableFailure("잔액 부족", "InsufficientFunds"));

            testEnv.start();

            assertThatThrownBy(() -> stub("order-1002").processOrder(req("1002")))
                    .isInstanceOf(WorkflowFailedException.class)
                    .hasRootCauseMessage("잔액 부족");

            verify(payment, times(1)).charge("1002", 39000L);   // 재시도 없음
            verify(inventory, never()).reserve(any(), any(), anyInt());
        }

        // [11-4] 재시도 동작 검증 — 백오프에도 시간 스킵이 적용되어 0.5초에 끝납니다.
        @Test
        @DisplayName("11-4 결제는 두 번 실패해도 세 번째에 성공한다")
        void 결제는_두번_실패해도_세번째에_성공한다() {
            when(payment.charge("1003", 39000L))
                    .thenThrow(ApplicationFailure.newFailure("일시 오류", "Transient"))
                    .thenThrow(ApplicationFailure.newFailure("일시 오류", "Transient"))
                    .thenReturn("PAY-8821");
            when(inventory.reserve(any(), any(), anyInt())).thenReturn("RSV-3310");
            when(shipping.requestShipment(any(), any())).thenReturn("SHIP-5507");

            testEnv.start();

            assertThat(stub("order-1003").processOrder(req("1003")))
                    .isEqualTo("order-1003 COMPLETED");

            verify(payment, times(3)).charge("1003", 39000L);
        }

        // [11-6] 시그널 · 쿼리 · 결과 회수
        @Test
        @DisplayName("11-6 취소 시그널을 받으면 상태가 CANCELED 로 바뀐다")
        void 취소_시그널을_받으면_CANCELED() {
            when(payment.charge(any(), anyLong())).thenReturn("PAY-8821");
            when(inventory.reserve(any(), any(), anyInt())).thenReturn("RSV-3310");

            testEnv.start();
            OrderWorkflow wf = stub("order-1004");

            // ① 비동기 시작 — 즉시 반환
            WorkflowExecution exec = WorkflowClient.start(wf::processOrder, req("1004"));
            assertThat(exec.getWorkflowId()).isEqualTo("order-1004");

            // ② 가상 시간을 밀어 워크플로우를 대기 지점까지 진행시킴 (Thread.sleep 금지!)
            testEnv.sleep(Duration.ofSeconds(1));

            // ③ 쿼리
            assertThat(wf.getStatus()).isEqualTo("WAITING_SHIPMENT");

            // ④ 시그널
            wf.cancelRequested("고객 변심");
            testEnv.sleep(Duration.ofSeconds(1));

            // ⑤ 쿼리로 반영 확인
            assertThat(wf.getStatus()).isEqualTo("CANCELED");

            // ⑥ 결과 회수 — 타입 스텁을 언타입 스텁으로 변환해야 getResult 가 가능
            String result = WorkflowStub.fromTyped(wf).getResult(String.class);
            assertThat(result).isEqualTo("order-1004 CANCELED");
        }

        // [11-7] Saga 보상 역순 검증
        @Test
        @DisplayName("11-7 배송이 실패하면 보상이 역순으로 실행된다")
        void 배송_실패시_보상은_역순() {
            when(payment.charge("1005", 39000L)).thenReturn("PAY-8821");
            when(inventory.reserve("1005", "SKU-A", 2)).thenReturn("RSV-3310");
            when(shipping.requestShipment(eq("1005"), anyString())).thenThrow(
                    ApplicationFailure.newNonRetryableFailure("배송 불가 지역", "ShippingUnavailable"));

            testEnv.start();

            assertThatThrownBy(() -> stub("order-1005").processOrder(req("1005")))
                    .isInstanceOf(WorkflowFailedException.class)
                    .hasRootCauseMessage("배송 불가 지역");

            // verify 만 쓰면 "둘 다 호출됨"만 확인됩니다. 순서 버그는 InOrder 로만 잡힙니다.
            InOrder ord = inOrder(payment, inventory, shipping);
            ord.verify(payment).charge("1005", 39000L);
            ord.verify(inventory).reserve("1005", "SKU-A", 2);
            ord.verify(shipping).requestShipment(eq("1005"), anyString());
            ord.verify(inventory).release("RSV-3310");     // 보상 1 — 나중 것 먼저
            ord.verify(payment).refund("PAY-8821");        // 보상 2 — 먼저 한 것 나중에

            verify(shipping, never()).cancelShipment(anyString());
        }
    }

    // =====================================================================
    // [11-3] 시간 스킵 — 30일이 0.4초
    // =====================================================================
    @Nested
    @DisplayName("11-3 — 시간 스킵")
    class TimeSkippingTests {

        TestWorkflowEnvironment testEnv;
        Worker worker;
        WorkflowClient client;
        NotificationActivity notification;

        @BeforeEach
        void setUp() {
            testEnv = TestWorkflowEnvironment.newInstance();
            worker = testEnv.newWorker(TASK_QUEUE);
            client = testEnv.getWorkflowClient();
            worker.registerWorkflowImplementationTypes(ReviewReminderWorkflowImpl.class);
            notification = activityMock(NotificationActivity.class);
            worker.registerActivitiesImplementations(notification);
        }

        @AfterEach
        void tearDown() {
            testEnv.close();
        }

        @Test
        @DisplayName("11-3 30일 대기 워크플로우가 2초 미만에 끝난다")
        void 삼십일_대기가_즉시_끝난다() {
            testEnv.start();

            ReviewReminderWorkflow wf = client.newWorkflowStub(ReviewReminderWorkflow.class,
                    WorkflowOptions.newBuilder()
                            .setTaskQueue(TASK_QUEUE)
                            .setWorkflowId("review-1001")
                            .build());

            long before = System.currentTimeMillis();
            String result = wf.remind("1001");
            long wallClock = System.currentTimeMillis() - before;

            assertThat(result).isEqualTo("1001 REMINDED");
            // "빨리 끝났다"를 주석이 아니라 테스트 조건으로 못박습니다.
            // 시간 스킵이 꺼지면 이 단언이 깨집니다.
            assertThat(wallClock).isLessThan(2000);
            verify(notification).notifyCustomer("1001", "리뷰를 남겨 주세요");

            System.out.println("벽시계 경과 = " + wallClock + "ms / 가상 경과 = 30일");
        }

        @Test
        @DisplayName("11-3 testEnv.sleep 은 가상 시각만 민다")
        void 가상_시각을_직접_민다() {
            testEnv.start();
            long t0 = testEnv.currentTimeMillis();
            testEnv.sleep(Duration.ofDays(7));
            long t1 = testEnv.currentTimeMillis();

            assertThat(Duration.ofMillis(t1 - t0).toDays()).isEqualTo(7);
        }
    }

    // =====================================================================
    // [11-5] TestActivityEnvironment — 액티비티 단독
    // =====================================================================
    @Nested
    @DisplayName("11-5 — TestActivityEnvironment")
    class ActivityTests {

        @Test
        @DisplayName("11-5 재고 예약 액티비티가 예약 ID 를 반환한다")
        void 재고_예약_단독_테스트() {
            TestActivityEnvironment env = TestActivityEnvironment.newInstance();
            env.registerActivitiesImplementations(new InventoryActivityImpl());

            InventoryActivity stub = env.newActivityStub(InventoryActivity.class);
            String reservationId = stub.reserve("1001", "SKU-A", 2);

            assertThat(reservationId).startsWith("RSV-");
            env.close();
        }

        @Test
        @DisplayName("11-5 하트비트가 실제로 전송되는지 확인한다")
        void 하트비트_리스너로_검증() {
            TestActivityEnvironment env = TestActivityEnvironment.newInstance();
            env.registerActivitiesImplementations(new ShippingActivityImpl());

            List<Object> heartbeats = new ArrayList<>();
            env.setActivityHeartbeatListener(String.class, heartbeats::add);

            ShippingActivity stub = env.newActivityStub(ShippingActivity.class);
            String shipmentId = stub.requestShipment("1001", "서울시 강남구");

            assertThat(shipmentId).startsWith("SHIP-");
            assertThat(heartbeats).isNotEmpty();
            System.out.println("수신한 하트비트: " + heartbeats);
            env.close();
        }
    }

    // =====================================================================
    // [11-8] 리플레이 테스트
    // =====================================================================
    @Nested
    @DisplayName("11-8 — WorkflowReplayer")
    class ReplayTests {

        static Stream<Path> histories() throws Exception {
            var url = Practice.class.getResource("/histories");
            if (url == null) {
                return Stream.empty();
            }
            Path dir = Paths.get(url.toURI());
            try (Stream<Path> s = Files.list(dir)) {
                return s.filter(p -> p.toString().endsWith(".json")).toList().stream();
            }
        }

        // 히스토리 파일이 하나도 없으면 아래 @ParameterizedTest 가 0건으로 "조용히 통과"합니다.
        // 그 함정을 막는 가드 테스트입니다.
        @Test
        @DisplayName("11-8 히스토리 리소스가 비어 있지 않다")
        void 히스토리가_존재한다() throws Exception {
            assertThat(histories().toList())
                    .as("src/test/resources/histories/*.json — dump-histories.sh 로 채우세요")
                    .isNotEmpty();
        }

        @ParameterizedTest(name = "replay {0}")
        @MethodSource("histories")
        @DisplayName("11-8 모든 운영 히스토리가 현재 코드로 리플레이된다")
        void 모든_히스토리가_리플레이된다(Path history) throws Exception {
            io.temporal.testing.WorkflowReplayer.replayWorkflowExecution(
                    Files.readString(history), OrderWorkflowImpl.class);
        }

        // 클래스패스 리소스 이름으로 직접 지정하는 단건 버전
        @Test
        @DisplayName("11-8 order-1001.json 단건 리플레이")
        void 단건_리플레이() throws Exception {
            io.temporal.testing.WorkflowReplayer.replayWorkflowExecutionFromResource(
                    "histories/order-1001.json", OrderWorkflowImpl.class);
        }
    }

    // =====================================================================
    // 본문 11-3 예제를 이 파일만으로 돌리기 위한 픽스처
    // =====================================================================

    @WorkflowInterface
    public interface ReviewReminderWorkflow {
        @WorkflowMethod
        String remind(String orderId);
    }

    public static class ReviewReminderWorkflowImpl implements ReviewReminderWorkflow {

        private final NotificationActivity notification = Workflow.newActivityStub(
                NotificationActivity.class,
                ActivityOptions.newBuilder()
                        .setStartToCloseTimeout(Duration.ofSeconds(10))
                        .build());

        @Override
        public String remind(String orderId) {
            // 실제 서버에서는 30일. 테스트 환경에서는 가상 시계가 즉시 점프합니다.
            Workflow.sleep(Duration.ofDays(30));
            notification.notifyCustomer(orderId, "리뷰를 남겨 주세요");
            return orderId + " REMINDED";
        }
    }

    // 11-5 에서 쓰는 최소 액티비티 구현 (실제 프로젝트에서는 src/main 에 있습니다)
    public static class InventoryActivityImpl implements InventoryActivity {
        @Override
        public String reserve(String orderId, String sku, int qty) {
            return "RSV-" + Math.abs((orderId + sku).hashCode() % 10000);
        }

        @Override
        public void release(String reservationId) {
            System.out.println("release " + reservationId);
        }
    }

    public static class ShippingActivityImpl implements ShippingActivity {
        @Override
        public String requestShipment(String orderId, String address) {
            for (int i = 1; i <= 3; i++) {
                io.temporal.activity.Activity.getExecutionContext()
                        .heartbeat("polling-" + i);
            }
            return "SHIP-" + Math.abs(orderId.hashCode() % 10000);
        }

        @Override
        public void cancelShipment(String shipmentId) {
            System.out.println("cancelShipment " + shipmentId);
        }
    }

    // 참고 — 실제 액티비티 인터페이스는 src/main 에 있습니다.
    // 이 파일 단독으로 컴파일해 보고 싶을 때만 아래 주석을 해제하세요.
    //
    // @ActivityInterface
    // public interface NotificationActivity {
    //     @ActivityMethod void notifyCustomer(String orderId, String message);
    // }
}

Exercise.java

7문제의 문제지입니다. 각 @Test 안에 // TODO: 여기에 작성 자리가 비어 있고, 단언문은 미리 적혀 있어 무엇을 만족시켜야 하는지가 명확합니다.

  • 문제 3 은 다른 문제와 성격이 다릅니다. mock(PaymentActivity.class)일부러 실패하는 코드가 적혀 있고, 이를 실행해 IllegalArgumentException: Interface annotated with @ActivityInterface can't be registered...눈으로 본 다음 고치는 문제입니다. 고치기 전에 한 번 돌려 보세요.
  • 문제 2assertThat(wallClock).isLessThan(2000) 이 이미 적혀 있습니다. 시간 스킵을 이해하지 못하고 Thread.sleep 으로 접근하면 절대 통과할 수 없게 설계했습니다.
  • 문제 5WorkflowClient.start(...) 까지만 주어져 있고, 그 뒤 시그널·쿼리·결과 회수를 채워야 합니다. 결과 회수에서 WorkflowStub.fromTyped 를 떠올리는 것이 이 문제의 핵심입니다.
  • 문제 6verify(payment).refund(...) / verify(inventory).release(...) 만 적힌 상태로 시작합니다. 이대로도 통과합니다. 이걸 InOrder 로 바꿔 순서까지 검증하게 만드는 것이 문제입니다. "통과하는 테스트를 더 엄격하게 만드는" 연습입니다.
  • 문제 7histories/ 디렉터리에 파일이 없으면 의미가 없으므로, 문제지 상단 주석에 ./scripts/dump-histories.sh 대신 쓸 수 있는 샘플 히스토리 JSON 생성 방법(Practice 의 WorkflowTests 를 한 번 돌린 뒤 testEnv.getDiagnostics() 를 저장)을 안내해 두었습니다.
package com.example.order;

/*
 * Step 11 — 테스트 : Exercise (문제지)
 *
 * 실행 방법
 *   ./gradlew test --tests 'com.example.order.Exercise'
 *
 * 위치: src/test/java/com/example/order/Exercise.java
 *
 * 각 문제의 // TODO 자리를 채우세요. 단언문(assertThat)은 이미 적혀 있으므로
 * "무엇을 만족시켜야 하는지"는 명확합니다. 단언문을 고쳐서 통과시키지 마세요.
 *
 * 문제 7 을 풀려면 src/test/resources/histories/ 에 히스토리 JSON 이 필요합니다.
 * 운영 서버가 없다면 이렇게 만드세요.
 *   1) Practice$WorkflowTests 를 한 번 실행합니다.
 *   2) 콘솔에 출력된 testEnv.getDiagnostics() 의 히스토리 부분을 참고하거나,
 *   3) 로컬 dev 서버(temporal server start-dev)에서 워크플로우를 한 번 돌린 뒤
 *      temporal workflow show -w order-1001 --output json > src/test/resources/histories/order-1001.json
 */

import io.temporal.client.WorkflowClient;
import io.temporal.client.WorkflowFailedException;
import io.temporal.client.WorkflowOptions;
import io.temporal.failure.ApplicationFailure;
import io.temporal.testing.TestActivityEnvironment;
import io.temporal.testing.TestWorkflowEnvironment;
import io.temporal.worker.Worker;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.nio.file.Path;
import java.time.Duration;
import java.util.stream.Stream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.withSettings;

class Exercise {

    static final String TASK_QUEUE = "ORDER_TASK_QUEUE";

    TestWorkflowEnvironment testEnv;
    Worker worker;
    WorkflowClient client;

    PaymentActivity payment;
    InventoryActivity inventory;
    ShippingActivity shipping;
    NotificationActivity notification;

    static OrderRequest req(String orderId) {
        return new OrderRequest(orderId, "C-77", "SKU-A", 2, 39000L, "서울시 강남구");
    }

    // -----------------------------------------------------------------
    // 문제 1 — TestWorkflowEnvironment 를 수동으로 구성하기
    //
    // setUp() 의 TODO 를 채워 아래 테스트가 통과하게 하세요.
    // 필요한 것: 인메모리 환경 생성, Worker 생성, WorkflowClient 획득,
    //            워크플로우 타입 등록, 액티비티 mock 등록.
    // -----------------------------------------------------------------
    @BeforeEach
    void setUp() {
        // TODO: 여기에 작성 — testEnv / worker / client 초기화
        // 힌트: TestWorkflowEnvironment.newInstance(), testEnv.newWorker(TASK_QUEUE),
        //       testEnv.getWorkflowClient()

        // TODO: 여기에 작성 — worker.registerWorkflowImplementationTypes(...)

        // TODO: 여기에 작성 — payment / inventory / shipping / notification mock 생성 후
        //       worker.registerActivitiesImplementations(...)
        //       (문제 3 을 먼저 읽고 오면 어떻게 mock 을 만들어야 하는지 알 수 있습니다)
    }

    @AfterEach
    void tearDown() {
        if (testEnv != null) {
            testEnv.close();
        }
    }

    OrderWorkflow stub(String workflowId) {
        return client.newWorkflowStub(OrderWorkflow.class,
                WorkflowOptions.newBuilder()
                        .setTaskQueue(TASK_QUEUE)
                        .setWorkflowId(workflowId)
                        .build());
    }

    @Test
    @DisplayName("문제 1 — 정상 주문이 COMPLETED 를 반환한다")
    void 문제1_정상_주문() {
        when(payment.charge("2001", 39000L)).thenReturn("PAY-1111");
        when(inventory.reserve("2001", "SKU-A", 2)).thenReturn("RSV-2222");
        when(shipping.requestShipment(eq("2001"), anyString())).thenReturn("SHIP-3333");

        testEnv.start();

        String result = stub("order-2001").processOrder(req("2001"));

        assertThat(result).isEqualTo("order-2001 COMPLETED");
        verify(notification).notifyCustomer("2001", "주문이 완료되었습니다");
    }

    // -----------------------------------------------------------------
    // 문제 2 — 시간 스킵
    //
    // 30일을 기다리는 워크플로우를 2초 미만에 통과시키세요.
    // 주의: Thread.sleep 으로는 절대 통과할 수 없습니다.
    //       Practice.ReviewReminderWorkflowImpl 을 등록해서 쓰세요.
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 2 — 30일 대기 워크플로우가 2초 미만에 끝난다")
    void 문제2_시간_스킵() {
        // TODO: 여기에 작성 — worker 에 Practice.ReviewReminderWorkflowImpl 등록
        // 힌트: worker.registerWorkflowImplementationTypes(...)

        testEnv.start();

        // TODO: 여기에 작성 — ReviewReminderWorkflow 스텁을 만들고 remind("2002") 호출
        long before = System.currentTimeMillis();
        String result = null;   // TODO: 여기에 작성
        long wallClock = System.currentTimeMillis() - before;

        assertThat(result).isEqualTo("2002 REMINDED");
        assertThat(wallClock).isLessThan(2000);
    }

    // -----------------------------------------------------------------
    // 문제 3 — 액티비티 mock 등록 실패 고치기
    //
    // 아래 메서드는 "일부러 실패하는" 코드입니다.
    // ① 먼저 그대로 실행해서 어떤 예외가 나오는지 눈으로 확인하세요.
    //    java.lang.IllegalArgumentException: Interface annotated with @ActivityInterface
    //      can't be registered as an activity implementation: interface ...PaymentActivity
    // ② 그다음 TODO 자리를 채워 정상 등록되게 고치세요.
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 3 — mock 액티비티 등록이 실패하지 않게 고친다")
    void 문제3_mock_등록() {
        TestWorkflowEnvironment env = TestWorkflowEnvironment.newInstance();
        Worker w = env.newWorker("EX3_QUEUE");

        // ① 아래 줄을 그대로 두고 한 번 실행해 보세요 (실패합니다)
        // w.registerActivitiesImplementations(mock(PaymentActivity.class));

        // ② TODO: 여기에 작성 — 위 줄을 고쳐 정상 등록되게 하세요
        //    힌트: Mockito 가 @ActivityInterface 어노테이션을 프록시에 복사합니다

        env.close();
        // 예외 없이 여기까지 오면 성공
        assertThat(true).isTrue();
    }

    // -----------------------------------------------------------------
    // 문제 4 — 재시도 검증
    //
    // 결제가 2번 실패한 뒤 3번째에 성공하도록 스텁하고,
    // charge 가 정확히 3번 호출되었는지 검증하세요.
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 4 — 결제 2회 실패 후 성공, 호출 3회")
    void 문제4_재시도() {
        // TODO: 여기에 작성 — payment.charge("2004", 39000L) 를
        //       두 번 재시도 가능 실패 → 세 번째 성공("PAY-4444") 으로 스텁
        //       힌트: ApplicationFailure.newFailure(message, type)
        //             .thenThrow(...).thenThrow(...).thenReturn(...)

        when(inventory.reserve(any(), any(), anyInt())).thenReturn("RSV-2222");
        when(shipping.requestShipment(any(), any())).thenReturn("SHIP-3333");

        testEnv.start();

        assertThat(stub("order-2004").processOrder(req("2004")))
                .isEqualTo("order-2004 COMPLETED");

        // TODO: 여기에 작성 — charge 가 3번 호출되었는지 검증
        // 힌트: verify(mock, times(n))
    }

    // -----------------------------------------------------------------
    // 문제 5 — 비동기 시작 → 시그널 → 쿼리 → 결과 회수
    //
    // 아래 4단계를 채우세요. 마지막 결과 회수가 이 문제의 핵심입니다.
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 5 — 시그널과 쿼리, 그리고 결과 회수")
    void 문제5_시그널_쿼리() {
        when(payment.charge(any(), anyLong())).thenReturn("PAY-1111");
        when(inventory.reserve(any(), any(), anyInt())).thenReturn("RSV-2222");

        testEnv.start();
        OrderWorkflow wf = stub("order-2005");

        // ① 비동기 시작 (주어짐)
        WorkflowClient.start(wf::processOrder, req("2005"));

        // ② TODO: 여기에 작성 — 가상 시간을 1초 밀어 워크플로우를 대기 지점까지 진행
        //    (Thread.sleep 금지)

        // ③ 쿼리로 진행 중 상태 확인
        assertThat(wf.getStatus()).isEqualTo("WAITING_SHIPMENT");

        // ④ TODO: 여기에 작성 — cancelRequested 시그널 전송 후 가상 시간 1초 밀기

        assertThat(wf.getStatus()).isEqualTo("CANCELED");

        // ⑤ TODO: 여기에 작성 — 워크플로우 결과를 String 으로 회수
        //    힌트: 타입 스텁으로는 getResult 를 못 부릅니다. 언타입 스텁으로 바꾸세요.
        String result = null;   // TODO: 여기에 작성

        assertThat(result).isEqualTo("order-2005 CANCELED");
    }

    // -----------------------------------------------------------------
    // 문제 6 — 보상 순서까지 검증하기
    //
    // 아래 테스트는 이미 통과합니다. 하지만 보상 순서가 뒤바뀐 구현에서도
    // 그대로 통과합니다(둘 다 호출되기만 하면 되므로).
    // 순서까지 검증하도록 고치세요.
    // -----------------------------------------------------------------
    @Test
    @DisplayName("문제 6 — 보상이 역순으로 실행되는지 검증한다")
    void 문제6_보상_역순() {
        when(payment.charge("2006", 39000L)).thenReturn("PAY-1111");
        when(inventory.reserve("2006", "SKU-A", 2)).thenReturn("RSV-2222");
        when(shipping.requestShipment(eq("2006"), anyString())).thenThrow(
                ApplicationFailure.newNonRetryableFailure("배송 불가 지역", "ShippingUnavailable"));

        testEnv.start();

        assertThatThrownBy(() -> stub("order-2006").processOrder(req("2006")))
                .isInstanceOf(WorkflowFailedException.class);

        // 아래 두 줄은 "호출 여부"만 봅니다 — 순서 버그를 못 잡습니다.
        verify(inventory).release("RSV-2222");
        verify(payment).refund("PAY-1111");

        // TODO: 여기에 작성 — 위 두 줄을 순서까지 검증하도록 바꾸세요.
        //       정방향: charge → reserve → requestShipment(실패)
        //       보상  : release → refund   (나중에 한 것부터)
        //       힌트: org.mockito.InOrder
    }

    // -----------------------------------------------------------------
    // 문제 7 — 리플레이 테스트
    //
    // src/test/resources/histories/ 의 모든 .json 을 도는
    // @ParameterizedTest 를 작성하세요.
    // 디렉터리가 비어 있으면 0건으로 조용히 통과하므로 가드도 함께 넣으세요.
    // -----------------------------------------------------------------

    // TODO: 여기에 작성 — static Stream<Path> histories() 메서드
    //       힌트: Exercise.class.getResource("/histories"), Files.list(dir)

    // TODO: 여기에 작성 — @Test 가드 : histories() 가 비어 있지 않은지 단언

    // TODO: 여기에 작성 — @ParameterizedTest + @MethodSource("histories")
    //       WorkflowReplayer.replayWorkflowExecution(
    //           Files.readString(history), OrderWorkflowImpl.class);
}

Solution.java

7문제의 정답과, "왜 그렇게 써야 하는지"를 설명하는 긴 주석이 함께 들어 있습니다. 풀어 본 뒤에 여세요.

  • 정답 2 의 주석은 시간 스킵이 발동하는 조건("모든 Worker 가 유휴")을 다시 짚고, 왜 Thread.sleep 이 답이 될 수 없는지, 그리고 setUseTimeskipping(false) 를 켠 채로는 이 테스트가 30일간 돌게 된다는 점을 설명합니다.
  • 정답 3 은 단순히 withoutAnnotations() 를 붙이는 데 그치지 않고, Mockito 가 어노테이션을 프록시로 복사하기 때문이라는 원인과, activityMock() 헬퍼로 재발을 막는 방법까지 씁니다.
  • 정답 4verify(payment, times(3)) 이 왜 3인지(재시도 2회 + 성공 1회)를 히스토리 이벤트 순서로 풀어 쓰고, 이 테스트가 0.5초에 끝나는 이유가 재시도 백오프에도 시간 스킵이 적용되기 때문임을 덧붙입니다.
  • 정답 6 의 주석이 가장 깁니다. verify 만 쓴 버전과 InOrder 버전을 나란히 두고, 보상 순서를 뒤집은 구현에서 전자는 통과하고 후자는 VerificationInOrderFailure 로 실패하는 실제 출력을 붙여 두었습니다.
  • 정답 7@MethodSourceStream<Path> 를 반환하는 형태와, 히스토리 디렉터리가 비었을 때 테스트가 0건으로 조용히 통과하는 함정을 막는 가드(assertThat(histories()).isNotEmpty())를 함께 제시합니다. Step 12 의 Retention 과 연결되는 마무리 주석으로 끝납니다.
package com.example.order;

/*
 * Step 11 — 테스트 : Solution (정답 + 해설)
 *
 * 실행 방법
 *   ./gradlew test --tests 'com.example.order.Solution'
 *
 * 위치: src/test/java/com/example/order/Solution.java
 *
 * Exercise.java 를 직접 풀어 본 뒤에 여세요.
 * 각 정답 위에 "왜 그렇게 써야 하는가"를 설명하는 주석 블록이 붙어 있습니다.
 */

import io.temporal.api.common.v1.WorkflowExecution;
import io.temporal.client.WorkflowClient;
import io.temporal.client.WorkflowFailedException;
import io.temporal.client.WorkflowOptions;
import io.temporal.client.WorkflowStub;
import io.temporal.failure.ApplicationFailure;
import io.temporal.testing.TestWorkflowEnvironment;
import io.temporal.testing.WorkflowReplayer;
import io.temporal.worker.Worker;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InOrder;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.stream.Stream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.withSettings;

class Solution {

    static final String TASK_QUEUE = "ORDER_TASK_QUEUE";

    TestWorkflowEnvironment testEnv;
    Worker worker;
    WorkflowClient client;

    PaymentActivity payment;
    InventoryActivity inventory;
    ShippingActivity shipping;
    NotificationActivity notification;

    static OrderRequest req(String orderId) {
        return new OrderRequest(orderId, "C-77", "SKU-A", 2, 39000L, "서울시 강남구");
    }

    /*
     * ─────────────────────────────────────────────────────────────────
     * 정답 1 — TestWorkflowEnvironment 수동 구성
     * ─────────────────────────────────────────────────────────────────
     * 순서가 중요합니다.
     *
     *   ① TestWorkflowEnvironment.newInstance()
     *      → 이 시점에 인메모리 Temporal 서비스(temporal-test-server)가 프로세스 안에서 뜹니다.
     *        Docker 도, 7233 포트도, PostgreSQL 도 필요 없습니다.
     *   ② testEnv.newWorker(TASK_QUEUE)
     *      → Worker 를 만들되 아직 폴링은 시작하지 않습니다.
     *   ③ registerWorkflowImplementationTypes / registerActivitiesImplementations
     *      → 등록은 반드시 start() 전에 끝나야 합니다. start() 후 등록하면
     *        IllegalStateException: Worker has already been started 가 납니다.
     *   ④ testEnv.start()
     *      → 이제 폴러가 뜨고 Task 를 가져가기 시작합니다. 각 @Test 안에서 부릅니다
     *        (테스트마다 다른 워크플로우 타입을 등록해야 할 수 있으므로).
     *   ⑤ @AfterEach 의 testEnv.close()
     *      → 빠뜨리면 테스트마다 인메모리 서비스와 스레드풀이 누수됩니다.
     *        테스트 100개짜리 클래스에서 OutOfMemoryError 로 이어집니다.
     *
     * 액티비티 mock 은 activityMock() 헬퍼를 거칩니다 — 이유는 정답 3 참고.
     */
    static <T> T activityMock(Class<T> type) {
        return mock(type, withSettings().withoutAnnotations());
    }

    @BeforeEach
    void setUp() {
        testEnv = TestWorkflowEnvironment.newInstance();
        worker = testEnv.newWorker(TASK_QUEUE);
        client = testEnv.getWorkflowClient();

        worker.registerWorkflowImplementationTypes(OrderWorkflowImpl.class);

        payment = activityMock(PaymentActivity.class);
        inventory = activityMock(InventoryActivity.class);
        shipping = activityMock(ShippingActivity.class);
        notification = activityMock(NotificationActivity.class);
        worker.registerActivitiesImplementations(payment, inventory, shipping, notification);
    }

    @AfterEach
    void tearDown() {
        if (testEnv != null) {
            testEnv.close();
        }
    }

    OrderWorkflow stub(String workflowId) {
        return client.newWorkflowStub(OrderWorkflow.class,
                WorkflowOptions.newBuilder()
                        .setTaskQueue(TASK_QUEUE)
                        .setWorkflowId(workflowId)
                        .build());
    }

    @Test
    @DisplayName("정답 1 — 정상 주문이 COMPLETED 를 반환한다")
    void 정답1_정상_주문() {
        when(payment.charge("2001", 39000L)).thenReturn("PAY-1111");
        when(inventory.reserve("2001", "SKU-A", 2)).thenReturn("RSV-2222");
        when(shipping.requestShipment(eq("2001"), anyString())).thenReturn("SHIP-3333");

        testEnv.start();

        String result = stub("order-2001").processOrder(req("2001"));

        assertThat(result).isEqualTo("order-2001 COMPLETED");
        verify(notification).notifyCustomer("2001", "주문이 완료되었습니다");
    }

    /*
     * ─────────────────────────────────────────────────────────────────
     * 정답 2 — 시간 스킵
     * ─────────────────────────────────────────────────────────────────
     * 워크플로우 코드는 Workflow.sleep(Duration.ofDays(30)) 을 그대로 둡니다.
     * 테스트 쪽에서 아무것도 특별히 하지 않아도 30일이 즉시 지나갑니다.
     *
     * 발동 조건은 단 하나입니다.
     *   "등록된 모든 Worker 가 처리할 Task 가 없어 유휴 상태가 되면,
     *    가상 시계를 다음 타이머의 발화 시각으로 즉시 점프시킨다."
     *
     * 그래서 다음 두 가지가 이 테스트를 망칩니다.
     *
     *   (a) Thread.sleep(...) 를 넣는 것
     *       → 벽시계가 진짜로 흐릅니다. 가상 시계와 무관합니다.
     *         CI 가 그만큼 느려지고, 느린 머신에서는 대기 시간이 부족해 flaky 해집니다.
     *         대기가 필요하면 언제나 testEnv.sleep(Duration) 을 쓰세요 — 비용이 0입니다.
     *
     *   (b) TestEnvironmentOptions.newBuilder().setUseTimeskipping(false)
     *       → 시간 스킵을 끕니다. 이 옵션을 켠 채로 이 테스트를 돌리면
     *         정말로 30일을 기다립니다(사실상 CI 타임아웃).
     *         이 옵션은 "외부 스레드에서 시그널을 보내는 동안 시계가 점프해 버리면 곤란한"
     *         테스트에만 쓰고, 긴 대기가 있는 테스트와는 반드시 클래스를 분리하세요.
     *
     * wallClock 단언을 남겨 두는 이유: 누군가 나중에 시간 스킵을 끄거나
     * Thread.sleep 을 끼워 넣으면 이 테스트가 즉시 실패해 알려 줍니다.
     */
    @Test
    @DisplayName("정답 2 — 30일 대기 워크플로우가 2초 미만에 끝난다")
    void 정답2_시간_스킵() {
        worker.registerWorkflowImplementationTypes(Practice.ReviewReminderWorkflowImpl.class);
        testEnv.start();

        Practice.ReviewReminderWorkflow wf = client.newWorkflowStub(
                Practice.ReviewReminderWorkflow.class,
                WorkflowOptions.newBuilder()
                        .setTaskQueue(TASK_QUEUE)
                        .setWorkflowId("review-2002")
                        .build());

        long before = System.currentTimeMillis();
        String result = wf.remind("2002");
        long wallClock = System.currentTimeMillis() - before;

        assertThat(result).isEqualTo("2002 REMINDED");
        assertThat(wallClock).isLessThan(2000);
    }

    /*
     * ─────────────────────────────────────────────────────────────────
     * 정답 3 — withSettings().withoutAnnotations()
     * ─────────────────────────────────────────────────────────────────
     * 틀린 코드:
     *     w.registerActivitiesImplementations(mock(PaymentActivity.class));
     *
     * 실제 에러:
     *     java.lang.IllegalArgumentException: Interface annotated with @ActivityInterface
     *       can't be registered as an activity implementation:
     *       interface com.example.order.PaymentActivity
     *
     * 왜 이 메시지가 혼란스러운가:
     *   우리는 인터페이스를 등록한 적이 없습니다. mock 객체를 등록했습니다.
     *   그런데 Mockito 는 기본적으로 모킹 대상의 어노테이션을 **생성한 프록시 클래스에 복사**합니다.
     *   그래서 프록시 클래스에 @ActivityInterface 가 클래스 레벨로 붙어 버립니다.
     *
     *   Temporal SDK 의 registerActivitiesImplementations 는 등록된 객체의 클래스를 보고
     *   "@ActivityInterface 가 직접 붙어 있으면 그건 인터페이스(=계약)이지 구현이 아니다"
     *   라고 판단합니다. 정상적인 방어 로직인데, mock 이 그 조건에 걸린 것입니다.
     *
     * 해결:
     *     mock(PaymentActivity.class, withSettings().withoutAnnotations())
     *
     * 재발 방지:
     *   프로젝트 공용 테스트 유틸에 헬퍼를 하나 두고 팀 규칙으로 강제하세요.
     *     static <T> T activityMock(Class<T> type) {
     *         return mock(type, withSettings().withoutAnnotations());
     *     }
     *   @Mock 어노테이션 방식(MockitoExtension)도 같은 문제가 있으므로,
     *   액티비티만큼은 필드 주입 대신 이 헬퍼로 만드는 편이 안전합니다.
     */
    @Test
    @DisplayName("정답 3 — mock 액티비티가 정상 등록된다")
    void 정답3_mock_등록() {
        TestWorkflowEnvironment env = TestWorkflowEnvironment.newInstance();
        Worker w = env.newWorker("EX3_QUEUE");

        w.registerActivitiesImplementations(activityMock(PaymentActivity.class));

        env.close();
        assertThat(true).isTrue();
    }

    /*
     * ─────────────────────────────────────────────────────────────────
     * 정답 4 — 재시도 검증
     * ─────────────────────────────────────────────────────────────────
     * Mockito 의 연쇄 스텁 .thenThrow().thenThrow().thenReturn() 은
     * 호출 순서대로 다른 동작을 합니다. 즉 1회차 실패, 2회차 실패, 3회차 성공입니다.
     *
     * verify(payment, times(3)) 의 3 은 "재시도 2회 + 성공 1회"입니다.
     * 히스토리로 보면 이렇게 됩니다.
     *
     *    5 ActivityTaskScheduled  PaymentActivity.charge
     *    6 ActivityTaskStarted
     *    7 ActivityTaskFailed     (Transient)   ← 1회차
     *      ... RetryPolicy 에 따라 1초 대기 ...
     *    8 ActivityTaskStarted
     *    9 ActivityTaskFailed     (Transient)   ← 2회차
     *      ... 2초 대기 (backoffCoefficient=2.0) ...
     *   10 ActivityTaskStarted
     *   11 ActivityTaskCompleted  PAY-4444      ← 3회차
     *
     * 주목할 점: ActivityTaskScheduled 는 한 번만 생깁니다. 재시도는 같은 Scheduled
     * 이벤트 아래에서 Started/Failed 가 반복되는 형태입니다.
     *
     * 그리고 이 테스트는 백오프 1초 + 2초 = 3초를 기다려야 하는데도 0.5초에 끝납니다.
     * **재시도 백오프 대기에도 시간 스킵이 적용되기 때문**입니다. 이 덕분에
     * "maximumAttempts=10, maximumInterval=1분" 같은 현실적인 재시도 정책도
     * 테스트로 검증할 수 있습니다.
     */
    @Test
    @DisplayName("정답 4 — 결제 2회 실패 후 성공, 호출 3회")
    void 정답4_재시도() {
        when(payment.charge("2004", 39000L))
                .thenThrow(ApplicationFailure.newFailure("일시 오류", "Transient"))
                .thenThrow(ApplicationFailure.newFailure("일시 오류", "Transient"))
                .thenReturn("PAY-4444");
        when(inventory.reserve(any(), any(), anyInt())).thenReturn("RSV-2222");
        when(shipping.requestShipment(any(), any())).thenReturn("SHIP-3333");

        testEnv.start();

        assertThat(stub("order-2004").processOrder(req("2004")))
                .isEqualTo("order-2004 COMPLETED");

        verify(payment, times(3)).charge("2004", 39000L);
    }

    /*
     * ─────────────────────────────────────────────────────────────────
     * 정답 5 — 비동기 시작 · 시그널 · 쿼리 · 결과 회수
     * ─────────────────────────────────────────────────────────────────
     * ① 왜 비동기 시작인가
     *    wf.processOrder(req) 는 워크플로우 완료까지 블로킹합니다.
     *    그 상태로는 시그널을 보낼 스레드가 없습니다.
     *    WorkflowClient.start(wf::processOrder, req) 는 WorkflowExecution
     *    (workflowId + runId)만 돌려주고 즉시 반환합니다.
     *
     * ② 왜 testEnv.sleep 을 끼우는가
     *    시그널은 히스토리에 기록된 뒤 "다음 Workflow Task" 에서 처리됩니다.
     *    시그널 직후 곧바로 쿼리하면 아직 반영 전일 수 있습니다.
     *    testEnv.sleep(1초) 는 가상 시각만 밀 뿐이라 비용이 0이면서
     *    Workflow Task 를 한 번 돌게 만듭니다. Thread.sleep 과 달리 CI 를 느리게 하지 않습니다.
     *
     * ③ 결과 회수 — 이 문제의 핵심
     *    타입 스텁(OrderWorkflow)에는 getResult 메서드가 없습니다.
     *    WorkflowStub.fromTyped(wf) 로 언타입 스텁으로 바꾼 뒤
     *    getResult(String.class) 를 부릅니다.
     *    (같은 workflowId 로 client.newUntypedWorkflowStub("order-2005") 를
     *     새로 만들어도 되지만, fromTyped 가 runId 까지 정확히 물고 있어 안전합니다.)
     */
    @Test
    @DisplayName("정답 5 — 시그널과 쿼리, 그리고 결과 회수")
    void 정답5_시그널_쿼리() {
        when(payment.charge(any(), anyLong())).thenReturn("PAY-1111");
        when(inventory.reserve(any(), any(), anyInt())).thenReturn("RSV-2222");

        testEnv.start();
        OrderWorkflow wf = stub("order-2005");

        WorkflowExecution exec = WorkflowClient.start(wf::processOrder, req("2005"));
        assertThat(exec.getWorkflowId()).isEqualTo("order-2005");

        testEnv.sleep(Duration.ofSeconds(1));
        assertThat(wf.getStatus()).isEqualTo("WAITING_SHIPMENT");

        wf.cancelRequested("고객 변심");
        testEnv.sleep(Duration.ofSeconds(1));
        assertThat(wf.getStatus()).isEqualTo("CANCELED");

        String result = WorkflowStub.fromTyped(wf).getResult(String.class);
        assertThat(result).isEqualTo("order-2005 CANCELED");
    }

    /*
     * ─────────────────────────────────────────────────────────────────
     * 정답 6 — InOrder 로 보상 순서 검증
     * ─────────────────────────────────────────────────────────────────
     * 문제지에 있던 버전:
     *     verify(inventory).release("RSV-2222");
     *     verify(payment).refund("PAY-1111");
     *
     * 이건 "둘 다 한 번씩 호출되었다"만 봅니다. 보상 순서를 뒤집은 구현
     * (refund 먼저 → release 나중) 에서도 그대로 통과합니다.
     * 즉 실무에서 가장 위험한 버그(보상 순서 오류)를 못 잡습니다.
     *
     * 왜 순서가 중요한가:
     *   Saga 보상은 "실행의 역순"이어야 합니다. 결제 → 재고예약 → 배송 순으로 했다면
     *   보상은 재고해제 → 환불 순입니다. 순서가 뒤집히면
     *   "환불은 끝났는데 재고 해제 도중 장애" 같은 상황에서
     *   고객은 돈을 돌려받았는데 재고는 잠긴 채 남습니다.
     *
     * InOrder 버전으로 바꾸면, 보상 순서를 뒤집은 구현에서 이렇게 실패합니다.
     *
     *     org.mockito.exceptions.verification.VerificationInOrderFailure:
     *     Verification in order failure
     *     Wanted but not invoked:
     *     inventoryActivity.release("RSV-2222");
     *     Wanted anywhere AFTER following interaction:
     *     paymentActivity.refund("PAY-1111");
     *
     * 마지막 verify(shipping, never()).cancelShipment(...) 도 의미가 있습니다.
     * 배송은 애초에 성공한 적이 없으므로 배송 취소 보상은 호출되면 안 됩니다.
     * "성공하지 않은 단계는 보상하지 않는다"는 Saga 의 기본 규칙을 못박는 단언입니다.
     */
    @Test
    @DisplayName("정답 6 — 보상이 역순으로 실행된다")
    void 정답6_보상_역순() {
        when(payment.charge("2006", 39000L)).thenReturn("PAY-1111");
        when(inventory.reserve("2006", "SKU-A", 2)).thenReturn("RSV-2222");
        when(shipping.requestShipment(eq("2006"), anyString())).thenThrow(
                ApplicationFailure.newNonRetryableFailure("배송 불가 지역", "ShippingUnavailable"));

        testEnv.start();

        assertThatThrownBy(() -> stub("order-2006").processOrder(req("2006")))
                .isInstanceOf(WorkflowFailedException.class)
                .hasRootCauseMessage("배송 불가 지역");

        InOrder ord = inOrder(payment, inventory, shipping);
        ord.verify(payment).charge("2006", 39000L);
        ord.verify(inventory).reserve("2006", "SKU-A", 2);
        ord.verify(shipping).requestShipment(eq("2006"), anyString());
        ord.verify(inventory).release("RSV-2222");
        ord.verify(payment).refund("PAY-1111");

        verify(shipping, never()).cancelShipment(anyString());
    }

    /*
     * ─────────────────────────────────────────────────────────────────
     * 정답 7 — 리플레이 테스트
     * ─────────────────────────────────────────────────────────────────
     * @MethodSource 가 참조하는 메서드는 static 이어야 하고,
     * Stream / Iterable / 배열을 반환해야 합니다. 여기서는 Stream<Path> 입니다.
     *
     * Files.list 는 스트림을 닫아야 하므로 try-with-resources 로 감싸고
     * toList() 로 즉시 소비한 뒤 다시 스트림으로 만듭니다.
     * (그냥 return Files.list(dir) 하면 파일 핸들이 누수됩니다.)
     *
     * ★ 가드 테스트를 반드시 함께 두세요.
     *   히스토리 디렉터리가 비면 @ParameterizedTest 는 "0건 실행"으로 끝나고,
     *   JUnit 설정에 따라 조용히 통과합니다. 즉 **리플레이 검증이 사라진 줄도 모르고**
     *   CI 초록불을 보게 됩니다. 리플레이 테스트를 무력화하는 가장 흔한 방식입니다.
     *
     * ★ Step 12 로 이어지는 숙제
     *   히스토리는 Namespace 의 Retention Period 안에서만 조회할 수 있습니다.
     *   기본 72시간이므로, 3일 지난 워크플로우는 temporal workflow show 가 NotFound 입니다.
     *   덤프 스크립트를 매일 도는 크론에 넣거나 Archival 을 켜세요.
     *
     * ★ 왜 이 테스트가 대체 불가능한가
     *   위 정답 1~6 의 워크플로우 테스트는 전부 "새 히스토리를 새 코드로" 실행합니다.
     *   새 코드끼리는 언제나 일관되므로, Workflow.getVersion() 가드를 빼먹어도 통과합니다.
     *   운영에 이미 떠 있는 워크플로우와의 호환성은 오직 이 테스트만 검증합니다.
     */
    static Stream<Path> histories() throws Exception {
        var url = Solution.class.getResource("/histories");
        if (url == null) {
            return Stream.empty();
        }
        Path dir = Paths.get(url.toURI());
        try (Stream<Path> s = Files.list(dir)) {
            return s.filter(p -> p.toString().endsWith(".json")).toList().stream();
        }
    }

    @Test
    @DisplayName("정답 7-a — 히스토리 리소스가 비어 있지 않다 (가드)")
    void 정답7_가드() throws Exception {
        assertThat(histories().toList())
                .as("src/test/resources/histories/*.json 이 비면 리플레이 검증이 무력화됩니다")
                .isNotEmpty();
    }

    @ParameterizedTest(name = "replay {0}")
    @MethodSource("histories")
    @DisplayName("정답 7-b — 모든 운영 히스토리가 현재 코드로 리플레이된다")
    void 정답7_리플레이(Path history) throws Exception {
        WorkflowReplayer.replayWorkflowExecution(
                Files.readString(history), OrderWorkflowImpl.class);
    }
}