Step 11 — 테스트
학습 목표
TestWorkflowEnvironment 로 Temporal 서버 없이 워크플로우를 실행하고 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-testing 이 temporal-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());
결과
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-testing | temporal-test-server 를 포함. 인메모리 Temporal, Docker 불필요 |
TestWorkflowEnvironment | newInstance() → newWorker() → start() → close(). 진짜 히스토리가 쌓인다 |
TestWorkflowExtension | JUnit 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.
TestWorkflowEnvironment 를 수동으로 구성하고 정상 주문 테스트를 완성하기
Workflow.sleep(Duration.ofDays(30)) 워크플로우를 2초 미만에 통과시키기 (시간 스킵)
- 액티비티 mock 등록이
IllegalArgumentException 으로 실패하는 코드를 고치기
- 결제가 2회 실패 후 성공하는 시나리오를 만들고 호출 횟수를 검증하기
- 비동기 시작 → 시그널 → 쿼리 → 결과 회수의 4단계 테스트 작성하기
- Saga 보상이 역순인지
InOrder 로 검증하기
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() 을 부르지 마세요.
TimeSkippingTests 는 System.currentTimeMillis() 로 벽시계 경과를 재서 assertThat(wallClock).isLessThan(2000) 으로 단언합니다. 즉 "빨리 끝났다"가 주석이 아니라 테스트 조건입니다. 시간 스킵이 꺼지면 이 테스트가 실패합니다.
ReplayTests 는 src/test/resources/histories/ 에 .json 파일이 있어야 돌아갑니다. 파일이 없으면 @ParameterizedTest 가 0건으로 끝나며 조용히 통과하므로, 클래스 안에 histories() 가 비어 있지 않다는 가드 테스트를 함께 넣어 두었습니다.
- 파일 맨 아래
SelfContainedFixtures 에 ReviewReminderWorkflow / 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... 를 눈으로 본 다음 고치는 문제입니다. 고치기 전에 한 번 돌려 보세요.
- 문제 2 는
assertThat(wallClock).isLessThan(2000) 이 이미 적혀 있습니다. 시간 스킵을 이해하지 못하고 Thread.sleep 으로 접근하면 절대 통과할 수 없게 설계했습니다.
- 문제 5 는
WorkflowClient.start(...) 까지만 주어져 있고, 그 뒤 시그널·쿼리·결과 회수를 채워야 합니다. 결과 회수에서 WorkflowStub.fromTyped 를 떠올리는 것이 이 문제의 핵심입니다.
- 문제 6 은
verify(payment).refund(...) / verify(inventory).release(...) 만 적힌 상태로 시작합니다. 이대로도 통과합니다. 이걸 InOrder 로 바꿔 순서까지 검증하게 만드는 것이 문제입니다. "통과하는 테스트를 더 엄격하게 만드는" 연습입니다.
- 문제 7 은
histories/ 디렉터리에 파일이 없으면 의미가 없으므로, 문제지 상단 주석에 ./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() 헬퍼로 재발을 막는 방법까지 씁니다.
- 정답 4 는
verify(payment, times(3)) 이 왜 3인지(재시도 2회 + 성공 1회)를 히스토리 이벤트 순서로 풀어 쓰고, 이 테스트가 0.5초에 끝나는 이유가 재시도 백오프에도 시간 스킵이 적용되기 때문임을 덧붙입니다.
- 정답 6 의 주석이 가장 깁니다.
verify 만 쓴 버전과 InOrder 버전을 나란히 두고, 보상 순서를 뒤집은 구현에서 전자는 통과하고 후자는 VerificationInOrderFailure 로 실패하는 실제 출력을 붙여 두었습니다.
- 정답 7 은
@MethodSource 가 Stream<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);
}
}