
在傳統軟體工程中,單元測試的標準是「輸入 $A$,預期輸出一定是 $B$(Deterministic)」。但當工程師第一次為 AI Agent 撰寫測試時,經常會陷入一個嚴重的誤區:試圖用斷言(Assert)去比對 LLM 產生的自然語言字串是否一字不差。
結果可想而知:
今天我們將建立一套企業級 Agentic AI 測試金字塔:讓確定性的邏輯回歸單元測試,讓 LLM 的不可預測性被鎖在 Mock 與邊界驗證中,並透過嚴格的 Guardrail 測試確保合規防線不可撼動。
@Disabled 跳過測試。
在 Embabel 架構中,我們將測試精確切分為三個層次:


PromptRunner 鏈式 API 與 Mockable 設計Embabel 核心的 Ai 介面與 PromptRunner 設計遵循依賴注入(Dependency Injection)與 Fluent Builder 模式:
ai.withDefaultLlm() 或 ai.withModel("gpt-4o")
runner.withToolObject(domainObject):掛載工具runner.createObject(prompt, TargetClass.class):結構化生成這種高內聚的抽象使我們能使用 Mockito 完全模擬 LLM 行為,0 成本、0 延遲、100% 確定性地在 CI 環境中驗證 Action 的行為。
對於呼叫 LLM 的 Action,測試要斷言的不是「回傳文字」,而是:
TravellerActivity),而沒有洩漏危險的外部 Service?業務防禦規則必須是純 Java 程式碼或獨立 Validator,具備第一級優先權,測試必須驗證:無論 LLM 產出多麼荒謬的折扣,Guardrail 一定能拋出 InvalidOfferException 並阻斷流程。
以下提供包含三個測試維度的完整實作:
package com.antechinus.travel.guardrails;
import com.antechinus.travel.domain.OfferDraft;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 方案審核守衛規則測試
* 驗證確定性業務規則,確保非合規折扣絕不可能通過
*/
class OfferGuardrailTest {
private final OfferGuardrail guardrail = new OfferGuardrail();
@Test
@DisplayName("合規方案:折扣率 15% 且包含理由,應成功通過審核")
void validOfferShouldPass() {
OfferDraft draft = new OfferDraft(1001L, 15, "常旅客忠誠回饋方案");
boolean isApproved = guardrail.validate(draft);
assertThat(isApproved).isTrue();
}
@ParameterizedTest
@ValueSource(ints = {21, 30, 50, 99})
@DisplayName("違規方案:折扣率超過 20% 上限,必須拋出業務異常並攔截")
void excessiveDiscountMustBeBlocked(int discountPercent) {
OfferDraft illegalDraft = new OfferDraft(1001L, discountPercent, "LLM 幻覺生成的超額折扣");
assertThatThrownBy(() -> guardrail.validate(illegalDraft))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("折扣率不可超過 20%");
}
}
package com.antechinus.travel.agent;
import com.antechinus.travel.domain.ActivitySummary;
import com.antechinus.travel.domain.TravellerActivity;
import com.antechinus.travel.domain.Trip;
import com.embabel.agent.api.Ai;
import com.embabel.agent.api.PromptRunner;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* SummarizeAction 邊界測試
* 測試重點:Prompt 內容組裝、工具安全暴露、型別映射
*/
@ExtendWith(MockitoExtension.class)
class SummarizeActionTest {
@Mock
private Ai ai;
@Mock
private PromptRunner promptRunner;
private CustomerCareAgent agent;
@BeforeEach
void setUp() {
agent = new CustomerCareAgent();
when(ai.withDefaultLlm()).thenReturn(promptRunner);
when(promptRunner.withToolObject(any())).thenReturn(promptRunner);
}
@Test
@DisplayName("summarize 必須正確暴露 TravellerActivity 作為 Tool,且 Prompt 必須帶入客戶名稱")
void summarizeShouldExposeToolAndBindPromptCorrectly() {
// 1. 準備測試數據
TravellerActivity activity = new TravellerActivity(
"陳大文",
Instant.now().minusSeconds(86400 * 365),
Instant.now(),
List.of(new Trip("東京五日遊", 45000), new Trip("巴黎自由行", 98000))
);
// 2. 模擬 LLM 結構化輸出
ActivitySummary mockSummary = new ActivitySummary("年度活躍高消費常客", true, true);
when(promptRunner.createObject(anyString(), eq(ActivitySummary.class)))
.thenReturn(mockSummary);
// 3. 執行 Action
ActivitySummary result = agent.summarize(activity, ai);
// 4. 斷言 1:驗證是否正確掛載領域物件作為工具
verify(promptRunner, times(1)).withToolObject(activity);
// 5. 斷言 2:使用 ArgumentCaptor 捕捉並檢查傳入的 Prompt 內容
ArgumentCaptor<String> promptCaptor = ArgumentCaptor.forClass(String.class);
verify(promptRunner).createObject(promptCaptor.capture(), eq(ActivitySummary.class));
String actualPrompt = promptCaptor.getValue();
assertThat(actualPrompt)
.as("Prompt 必須明確包含客戶名稱與分析指示")
.contains("陳大文")
.contains("High spender threshold")
.contains("Frequent traveler criteria");
// 6. 斷言 3:驗證輸出物件正確傳遞
assertThat(result.highSpender()).isTrue();
assertThat(result.frequentTraveler()).isTrue();
}
}
package com.antechinus.travel.integration;
import com.antechinus.travel.domain.CustomerQuery;
import com.antechinus.travel.domain.ReviewedOffer;
import com.embabel.agent.api.EmbabelClient;
import com.embabel.agent.api.ProcessResult;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
/**
* GOAP 流程端到端整合測試
* 驗證從起始狀態 CustomerQuery 能否透過 A* 搜尋成功抵達 ReviewedOffer
*/
@SpringBootTest
@ActiveProfiles("test")
class CustomerCareAgentFlowTest {
@Autowired
private EmbabelClient embabelClient;
@Test
@DisplayName("給定 CustomerQuery,Agent 應完整執行 Fetch -> Summarize -> Draft -> Review 四步驟並達成 Goal")
void shouldAchieveReviewedOfferGoal() {
CustomerQuery input = new CustomerQuery(1001L, "查詢年度優惠與專屬回饋");
// 觸發流程引擎執行
ProcessResult<ReviewedOffer> result = embabelClient.goal(ReviewedOffer.class)
.withInput(input)
.run();
// 驗證流程成功
assertThat(result.isSuccess()).isTrue();
ReviewedOffer offer = result.getOutput();
assertThat(offer).isNotNull();
assertThat(offer.customerId()).isEqualTo(1001L);
assertThat(offer.approvedDiscountPercent()).isLessThanOrEqualTo(20);
assertThat(offer.auditStatus()).isEqualTo("APPROVED");
}
}
Ai 與 PromptRunner;遠端 API 僅保留在夜間排程執行的少數 E2E Smoke Tests 中。contains("優惠"))當作核心業務驗證
| 測試維度 | ❌ 錯誤的做法 (Bad) | ✅ 正確的 Embabel 做法 (Good) |
|---|---|---|
| LLM 輸出驗證 | assertEquals("您好,這是為您...", output.text()) |
驗證 output.highSpender() == true 與 Prompt 欄位注入 |
| 工具安全驗證 | 執行了事,不管內部到底給了模型什麼工具 | 使用 verify(runner).withToolObject(expectedTool) 嚴格斷言 |
| 合規防護 | 寄望於 Prompt 裡的 System Message:「請務必遵守規定」 | 撰寫單元測試驗證 Java Guardrail 的攔截能力 |
| CI/CD 穩定度 | 每次 Push 都連線至 OpenAI,容易超時與漂移 | 100% 本地 Mock 執行,毫秒級完成且結果確定 |
ArgumentCaptor<String> 捕捉並檢查傳入 PromptRunner.createObject 的 Prompt 字串,確認特定業務關鍵字(如 VIP 或客戶 ID)有被正確拼接。