文件性質:這是一份技術實作計畫,用於評估在 Java 既有專案中導入 AI 自主測試的可行性。文中的架構設計基於現有工具的能力推演,尚未經過大規模生產驗證。建議先執行 PoC 驗證核心假設後,再決定是否全面導入。
目錄
- 專案目標與範圍
- 核心概念:從自動化到自主化
- 技術選型與版本
- 專案設定
- 核心組件實作
- 三層驗證迴圈:狀態機設計
- Prompt Engineering
- 整合層實作
- 評估框架與指標
- 導入計畫與里程碑
- 風險評估與緩解策略
- 成本估算
- 決策檢查點
一、專案目標與範圍
1.1 要解決的問題
在大型 Java 既有專案中,我們面臨以下測試困境:
| 問題 | 現狀 | 影響 |
|---|---|---|
| 測試覆蓋率不足 | 單元測試 60%,E2E 測試僅覆蓋 Happy Path | 生產環境頻繁出現邊界條件 Bug |
| 測試腳本脆弱 | 前端 DOM 變更導致 30% 的 Selenium 測試失效 | 每次 UI 改版需 2-3 天修復測試 |
| 診斷效率低 | 測試失敗後平均需 2 小時定位根因 | 開發者時間大量消耗在 Debug |
| Flaky Tests | 約 15% 的測試間歇性失敗 | CI/CD 信心不足,經常手動重跑 |
1.2 專案目標
Phase 1 目標(PoC,8 週):
- 建立 AI 診斷輔助系統,自動分析測試失敗的根因
- 目標:診斷準確率 > 80%,平均診斷時間 < 30 秒
Phase 2 目標(MVP,12 週):
- 實現視覺定位能力,減少因 DOM 變更導致的測試失效
- 目標:測試腳本存活率從 70% 提升到 95%
Phase 3 目標(Production,16 週):
- 實現自主探索測試,發現人工未覆蓋的邊界場景
- 目標:新發現 Bug 數量 > 10 個/月
1.3 不在範圍內
- 效能測試(Load Testing)
- 安全性滲透測試
- 行動裝置 App 測試(僅限 Web)
- 取代現有的單元測試與整合測試
二、核心概念:從自動化到自主化
2.1 傳統自動化 vs AI 自主化
傳統自動化(命令式):
開發者定義 → 點擊 #login-btn → 等待 2s → 斷言 URL 包含 /dashboard
問題:
- 路徑固定,無法處理意外情況
- 元素定位脆弱,DOM 變更即失效
- 失敗時只有 Exception,無診斷能力
AI 自主化(宣告式):
開發者定義 → 目標:以 VIP 身份完成購物,使用折價券,驗證金額
AI 行為:
- 自行規劃達成目標的路徑
- 遇到障礙時嘗試替代方案
- 失敗時自動調查根因
2.2 三層驗證迴圈概念
┌─────────────────────────────────────────────────────────────┐
│ 探索迴圈 (Exploration) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 診斷迴圈 (Diagnosis) │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ 穩定性迴圈 (Stability) │ │ │
│ │ │ │ │ │
│ │ │ 執行單一測試步驟 │ │ │
│ │ │ ↓ │ │ │
│ │ │ 失敗 → 是環境問題?→ 重試 N 次 │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ ↓ │ │
│ │ 仍然失敗 → 收集證據 → 分析根因 → 生成報告 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ 完成當前路徑 → 尋找下一個探索目標 → 發現新的測試場景 │
│ │
└─────────────────────────────────────────────────────────────┘
三、技術選型與版本
3.1 核心技術棧
| 組件 | 技術選擇 | 版本 | 選擇理由 |
|---|---|---|---|
| LLM 編排 | LangChain4j | 0.35.0 | Java 原生、Spring Boot 整合佳、Tool Calling 支援完整 |
| LLM 模型 | GPT-4o | 2024-08-06 | 視覺能力強、推理穩定、Function Calling 支援最佳 |
| 備用模型 | GPT-4o-mini | 2024-07-18 | 成本較低,用於簡單判斷 |
| 瀏覽器自動化 | Playwright | 1.48.0 | 比 Selenium 更穩定、支援多瀏覽器、Java 官方支援 |
| 測試容器 | Testcontainers | 1.20.3 | 資料庫隔離、環境一致性 |
| 日誌收集 | Micrometer + OTLP | 1.13.0 | 與 Spring Boot 整合、支援 TraceId 串聯 |
3.2 相依性相容性矩陣
Spring Boot 3.3.x
├── Java 21 (required)
├── LangChain4j 0.35.0
│ └── langchain4j-open-ai 0.35.0
│ └── langchain4j-spring-boot-starter 0.35.0
├── Playwright 1.48.0
│ └── playwright-java 1.48.0
└── Testcontainers 1.20.3
└── postgresql 1.20.3
四、專案設定
4.1 Maven pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>ai-qa-agent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<langchain4j.version>0.35.0</langchain4j.version>
<playwright.version>1.48.0</playwright.version>
<testcontainers.version>1.20.3</testcontainers.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- LangChain4j -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<!-- Playwright -->
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>${playwright.version}</version>
</dependency>
<!-- Testcontainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
</dependency>
<!-- Observability -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<!-- Utilities -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- Playwright 需要先安裝瀏覽器 -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>install-playwright-browsers</id>
<phase>generate-resources</phase>
<goals>
<goal>java</goal>
</goals>
<configuration>
<mainClass>com.microsoft.playwright.CLI</mainClass>
<arguments>
<argument>install</argument>
<argument>chromium</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
4.2 application.yml
spring:
application:
name: ai-qa-agent
langchain4j:
open-ai:
chat-model:
api-key: ${OPENAI_API_KEY}
model-name: gpt-4o
temperature: 0.1 # 測試場景需要穩定輸出
timeout: PT60S # 視覺分析可能較慢
max-retries: 3
log-requests: true
log-responses: true
# AI QA Agent 配置
ai-qa:
browser:
headless: true
viewport-width: 1280
viewport-height: 720
timeout-ms: 30000
loops:
stability:
max-retries: 3
retry-delay-ms: 1000
flakiness-threshold: 0.8 # 80% 以上成功視為 flaky
diagnosis:
collect-screenshot: true
collect-console-logs: true
collect-network-logs: true
max-log-lines: 500
exploration:
max-depth: 10
max-actions-per-page: 20
cost:
budget-per-test-usd: 0.50
budget-per-day-usd: 100.00
reporting:
output-dir: ./test-reports
screenshot-format: png
# 目標系統配置
target:
base-url: ${TARGET_BASE_URL:http://localhost:8080}
api-base-url: ${TARGET_API_URL:http://localhost:8080/api}
# Actuator(用於診斷迴圈收集後端 Log)
management:
endpoints:
web:
exposure:
include: health,info,loggers,trace
tracing:
sampling:
probability: 1.0
4.3 專案結構
ai-qa-agent/
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/com/example/aiqaagent/
│ │ │ ├── AiQaAgentApplication.java
│ │ │ ├── config/
│ │ │ │ ├── OpenAiConfig.java
│ │ │ │ ├── PlaywrightConfig.java
│ │ │ │ └── AgentConfig.java
│ │ │ ├── agent/
│ │ │ │ ├── AutonomousTester.java # AI Agent 介面
│ │ │ │ ├── TestExecutionContext.java # 執行上下文
│ │ │ │ └── TestReport.java # 測試報告
│ │ │ ├── tools/
│ │ │ │ ├── BrowserTools.java # 瀏覽器操作
│ │ │ │ ├── VisionTools.java # 視覺定位
│ │ │ │ ├── DiagnosticTools.java # 診斷工具
│ │ │ │ └── DataTools.java # 測試資料
│ │ │ ├── loops/
│ │ │ │ ├── LoopStateMachine.java # 狀態機
│ │ │ │ ├── StabilityLoop.java # 穩定性迴圈
│ │ │ │ ├── DiagnosisLoop.java # 診斷迴圈
│ │ │ │ └── ExplorationLoop.java # 探索迴圈
│ │ │ ├── model/
│ │ │ │ ├── TestGoal.java
│ │ │ │ ├── TestStep.java
│ │ │ │ ├── TestEvidence.java
│ │ │ │ └── DiagnosisResult.java
│ │ │ └── service/
│ │ │ ├── TestOrchestratorService.java
│ │ │ ├── EvidenceCollectorService.java
│ │ │ └── ReportGeneratorService.java
│ │ └── resources/
│ │ ├── application.yml
│ │ └── prompts/
│ │ ├── system-prompt.txt
│ │ ├── vision-locate-element.txt
│ │ ├── diagnosis-analyze.txt
│ │ └── exploration-next-action.txt
│ └── test/
│ └── java/com/example/aiqaagent/
│ ├── integration/
│ └── unit/
└── test-reports/
五、核心組件實作
5.1 OpenAI 配置
package com.example.aiqaagent.config;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
@Configuration
public class OpenAiConfig {
@Value("${langchain4j.open-ai.chat-model.api-key}")
private String apiKey;
/**
* 主要模型:GPT-4o,用於複雜推理和視覺分析
*/
@Bean
public ChatLanguageModel primaryChatModel() {
return OpenAiChatModel.builder()
.apiKey(apiKey)
.modelName("gpt-4o")
.temperature(0.1) // 測試場景需要穩定輸出
.timeout(Duration.ofSeconds(60)) // 視覺分析可能較慢
.maxRetries(3)
.logRequests(true)
.logResponses(true)
.build();
}
/**
* 輕量模型:GPT-4o-mini,用於簡單判斷以節省成本
*/
@Bean
public ChatLanguageModel lightweightChatModel() {
return OpenAiChatModel.builder()
.apiKey(apiKey)
.modelName("gpt-4o-mini")
.temperature(0.1)
.timeout(Duration.ofSeconds(30))
.maxRetries(3)
.build();
}
}
5.2 Playwright 配置與生命週期管理
package com.example.aiqaagent.config;
import com.microsoft.playwright.*;
import jakarta.annotation.PreDestroy;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
@Slf4j
@Configuration
public class PlaywrightConfig {
@Value("${ai-qa.browser.headless:true}")
private boolean headless;
@Value("${ai-qa.browser.viewport-width:1280}")
private int viewportWidth;
@Value("${ai-qa.browser.viewport-height:720}")
private int viewportHeight;
@Value("${ai-qa.browser.timeout-ms:30000}")
private int timeoutMs;
private Playwright playwright;
private Browser browser;
@Getter
private volatile BrowserContext currentContext;
@Getter
private volatile Page currentPage;
/**
* 初始化瀏覽器(延遲初始化,避免不必要的資源消耗)
*/
public synchronized void initialize() {
if (playwright == null) {
log.info("Initializing Playwright...");
playwright = Playwright.create();
browser = playwright.chromium().launch(
new BrowserType.LaunchOptions()
.setHeadless(headless)
.setTimeout(timeoutMs)
);
log.info("Playwright initialized successfully");
}
}
/**
* 為每個測試建立新的 Context 和 Page(隔離 Cookie、LocalStorage)
*/
public Page createNewPage() {
initialize();
// 關閉舊的 Context
if (currentContext != null) {
currentContext.close();
}
currentContext = browser.newContext(
new Browser.NewContextOptions()
.setViewportSize(viewportWidth, viewportHeight)
.setRecordVideoDir(java.nio.file.Paths.get("test-reports/videos"))
);
// 啟用 Console 和 Network 日誌收集
currentPage = currentContext.newPage();
currentPage.onConsoleMessage(msg ->
log.debug("[Browser Console] {}: {}", msg.type(), msg.text())
);
currentPage.onPageError(error ->
log.error("[Browser Error] {}", error)
);
return currentPage;
}
/**
* 截取當前頁面截圖(Base64 編碼)
*/
public String captureScreenshotBase64() {
if (currentPage == null) {
throw new IllegalStateException("No active page");
}
byte[] screenshot = currentPage.screenshot(
new Page.ScreenshotOptions().setFullPage(false)
);
return java.util.Base64.getEncoder().encodeToString(screenshot);
}
/**
* 收集瀏覽器 Console 日誌
*/
public java.util.List<String> getConsoleLogs() {
// 需要在 Page 建立時註冊 listener 收集
// 這裡返回累積的日誌
return consoleLogBuffer;
}
private final java.util.List<String> consoleLogBuffer =
java.util.Collections.synchronizedList(new java.util.ArrayList<>());
@PreDestroy
public void cleanup() {
log.info("Cleaning up Playwright resources...");
if (currentContext != null) {
currentContext.close();
}
if (browser != null) {
browser.close();
}
if (playwright != null) {
playwright.close();
}
}
}
5.3 瀏覽器操作 Tools
package com.example.aiqaagent.tools;
import com.example.aiqaagent.config.PlaywrightConfig;
import com.microsoft.playwright.*;
import dev.langchain4j.agent.tool.Tool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Component
@RequiredArgsConstructor
public class BrowserTools {
private final PlaywrightConfig playwrightConfig;
@Tool("開啟指定的 URL。回傳頁面標題。")
public String navigateTo(String url) {
log.info("Navigating to: {}", url);
Page page = playwrightConfig.getCurrentPage();
Response response = page.navigate(url,
new Page.NavigateOptions().setTimeout(30000)
);
if (response != null && !response.ok()) {
return String.format("頁面載入失敗,HTTP 狀態碼: %d", response.status());
}
// 等待頁面穩定
page.waitForLoadState(LoadState.NETWORKIDLE);
return String.format("成功載入頁面,標題: %s", page.title());
}
@Tool("點擊包含指定文字的按鈕或連結。文字需完全匹配或部分匹配。")
public String clickByText(String text) {
log.info("Clicking element with text: {}", text);
Page page = playwrightConfig.getCurrentPage();
try {
Locator locator = page.getByText(text,
new Page.GetByTextOptions().setExact(false)
);
// 確保元素可見且可點擊
locator.first().waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(5000)
);
locator.first().click();
// 等待可能的頁面變化
page.waitForTimeout(500);
return String.format("成功點擊包含 '%s' 的元素", text);
} catch (TimeoutError e) {
return String.format("找不到包含 '%s' 的可點擊元素", text);
}
}
@Tool("使用 CSS Selector 點擊元素")
public String clickBySelector(String selector) {
log.info("Clicking element with selector: {}", selector);
Page page = playwrightConfig.getCurrentPage();
try {
page.click(selector, new Page.ClickOptions().setTimeout(5000));
page.waitForTimeout(500);
return String.format("成功點擊 selector: %s", selector);
} catch (TimeoutError e) {
return String.format("找不到 selector: %s", selector);
}
}
@Tool("在指定的輸入框中填入文字。selector 是 CSS 選擇器或 placeholder 文字。")
public String fillInput(String selectorOrPlaceholder, String value) {
log.info("Filling input {} with value: {}", selectorOrPlaceholder, value);
Page page = playwrightConfig.getCurrentPage();
try {
// 先嘗試 CSS selector
Locator locator = page.locator(selectorOrPlaceholder);
if (locator.count() == 0) {
// 嘗試用 placeholder 找
locator = page.getByPlaceholder(selectorOrPlaceholder);
}
locator.first().fill(value);
return String.format("成功在 '%s' 輸入 '%s'", selectorOrPlaceholder, value);
} catch (Exception e) {
return String.format("無法填入輸入框: %s", e.getMessage());
}
}
@Tool("取得當前頁面上所有可見的可互動元素(按鈕、連結、輸入框)")
public String getInteractiveElements() {
Page page = playwrightConfig.getCurrentPage();
List<String> elements = new java.util.ArrayList<>();
// 收集按鈕
for (Locator btn : page.locator("button:visible").all()) {
String text = btn.textContent();
if (text != null && !text.trim().isEmpty()) {
elements.add(String.format("[按鈕] %s", text.trim()));
}
}
// 收集連結
for (Locator link : page.locator("a:visible").all()) {
String text = link.textContent();
String href = link.getAttribute("href");
if (text != null && !text.trim().isEmpty()) {
elements.add(String.format("[連結] %s -> %s", text.trim(), href));
}
}
// 收集輸入框
for (Locator input : page.locator("input:visible, textarea:visible").all()) {
String placeholder = input.getAttribute("placeholder");
String name = input.getAttribute("name");
String type = input.getAttribute("type");
elements.add(String.format("[輸入框] name=%s, type=%s, placeholder=%s",
name, type, placeholder));
}
if (elements.isEmpty()) {
return "當前頁面沒有找到可互動元素";
}
return "當前頁面的可互動元素:\n" +
elements.stream()
.limit(30) // 限制數量避免 token 爆炸
.collect(Collectors.joining("\n"));
}
@Tool("取得當前頁面的文字內容摘要")
public String getPageTextContent() {
Page page = playwrightConfig.getCurrentPage();
String fullText = page.locator("body").textContent();
// 限制長度
if (fullText.length() > 2000) {
fullText = fullText.substring(0, 2000) + "...(內容已截斷)";
}
return String.format("頁面標題: %s\n\n頁面內容:\n%s",
page.title(), fullText);
}
@Tool("取得當前頁面的 URL")
public String getCurrentUrl() {
return playwrightConfig.getCurrentPage().url();
}
@Tool("等待指定的毫秒數")
public String waitFor(int milliseconds) {
playwrightConfig.getCurrentPage().waitForTimeout(milliseconds);
return String.format("已等待 %d 毫秒", milliseconds);
}
@Tool("按下鍵盤按鍵,例如 Enter, Tab, Escape")
public String pressKey(String key) {
playwrightConfig.getCurrentPage().keyboard().press(key);
return String.format("已按下 %s 鍵", key);
}
@Tool("捲動頁面。direction 可以是 'up', 'down', 'top', 'bottom'")
public String scroll(String direction) {
Page page = playwrightConfig.getCurrentPage();
switch (direction.toLowerCase()) {
case "down":
page.mouse().wheel(0, 500);
break;
case "up":
page.mouse().wheel(0, -500);
break;
case "top":
page.evaluate("window.scrollTo(0, 0)");
break;
case "bottom":
page.evaluate("window.scrollTo(0, document.body.scrollHeight)");
break;
default:
return "未知的捲動方向: " + direction;
}
return String.format("已向 %s 捲動", direction);
}
}
5.4 視覺定位 Tools
package com.example.aiqaagent.tools;
import com.example.aiqaagent.config.PlaywrightConfig;
import com.microsoft.playwright.Page;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.data.message.*;
import dev.langchain4j.model.chat.ChatLanguageModel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Slf4j
@Component
@RequiredArgsConstructor
public class VisionTools {
private final PlaywrightConfig playwrightConfig;
@Qualifier("primaryChatModel")
private final ChatLanguageModel visionModel;
private static final String VISION_LOCATE_PROMPT = """
你是一個視覺分析助手,專門協助定位網頁元素。
任務:在這張網頁截圖中,找到符合以下描述的元素:
%s
請回傳該元素的中心點座標,格式為:
COORDINATES: x=數字, y=數字
如果找不到符合描述的元素,請回傳:
NOT_FOUND: 原因說明
注意事項:
- 座標是相對於圖片左上角的像素位置
- 圖片尺寸為 1280x720
- 只需要回傳座標,不需要其他解釋
"""";
@Tool("使用視覺 AI 在當前頁面截圖中定位元素。描述應該是視覺特徵,如「藍色的登入按鈕」或「頁面右上角的購物車圖示」")
public String locateElementByVision(String visualDescription) {
log.info("Vision locating element: {}", visualDescription);
String screenshotBase64 = playwrightConfig.captureScreenshotBase64();
// 建構帶有圖片的訊息
ImageContent imageContent = ImageContent.from(
screenshotBase64, "image/png"
);
TextContent textContent = TextContent.from(
String.format(VISION_LOCATE_PROMPT, visualDescription)
);
UserMessage userMessage = UserMessage.from(imageContent, textContent);
// 呼叫 GPT-4o Vision
String response = visionModel.generate(userMessage).content().text();
log.debug("Vision response: {}", response);
return response;
}
@Tool("使用視覺定位並點擊元素。描述應該是視覺特徵。")
public String clickByVision(String visualDescription) {
String locationResult = locateElementByVision(visualDescription);
if (locationResult.contains("NOT_FOUND")) {
return locationResult;
}
// 解析座標
Pattern pattern = Pattern.compile("x=(\\d+),\\s*y=(\\d+)");
Matcher matcher = pattern.matcher(locationResult);
if (!matcher.find()) {
return "無法解析座標: " + locationResult;
}
int x = Integer.parseInt(matcher.group(1));
int y = Integer.parseInt(matcher.group(2));
log.info("Clicking at coordinates: ({}, {})", x, y);
Page page = playwrightConfig.getCurrentPage();
page.mouse().click(x, y);
page.waitForTimeout(500);
return String.format("已透過視覺定位點擊座標 (%d, %d)", x, y);
}
@Tool("分析當前頁面截圖,描述頁面上看到的內容和佈局")
public String analyzePageVisually() {
String screenshotBase64 = playwrightConfig.captureScreenshotBase64();
ImageContent imageContent = ImageContent.from(
screenshotBase64, "image/png"
);
TextContent textContent = TextContent.from("""
請分析這張網頁截圖,描述:
1. 這是什麼類型的頁面(登入頁、商品列表、結帳頁等)
2. 主要的 UI 元素有哪些
3. 頁面的狀態(是否有錯誤訊息、載入中、正常顯示)
4. 下一步可能的操作
用繁體中文回答,簡潔扼要。
""");
UserMessage userMessage = UserMessage.from(imageContent, textContent);
return visionModel.generate(userMessage).content().text();
}
@Tool("比較兩張截圖,檢查是否有預期的變化")
public String compareScreenshots(String expectedChange) {
// 先截第一張
String before = playwrightConfig.captureScreenshotBase64();
// 等待變化
playwrightConfig.getCurrentPage().waitForTimeout(1000);
// 再截第二張
String after = playwrightConfig.captureScreenshotBase64();
ImageContent beforeImage = ImageContent.from(before, "image/png");
ImageContent afterImage = ImageContent.from(after, "image/png");
TextContent textContent = TextContent.from(String.format("""
這是兩張連續的網頁截圖(前後順序)。
預期的變化是:%s
請分析:
1. 兩張圖片之間有什麼變化?
2. 預期的變化是否發生了?
3. 有沒有意外的變化(錯誤訊息、版面錯亂等)?
回答格式:
CHANGE_DETECTED: 是/否
EXPECTED_CHANGE: 是/否
DESCRIPTION: 變化描述
""", expectedChange));
UserMessage userMessage = UserMessage.from(beforeImage, afterImage, textContent);
return visionModel.generate(userMessage).content().text();
}
}
5.5 診斷 Tools
package com.example.aiqaagent.tools;
import com.example.aiqaagent.config.PlaywrightConfig;
import com.example.aiqaagent.model.TestEvidence;
import dev.langchain4j.agent.tool.Tool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.Instant;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class DiagnosticTools {
private final PlaywrightConfig playwrightConfig;
private final RestTemplate restTemplate = new RestTemplate();
@Value("${target.api-base-url}")
private String apiBaseUrl;
@Tool("收集當前的所有診斷資訊:截圖、Console Log、Network Log")
public String collectAllEvidence() {
StringBuilder evidence = new StringBuilder();
// 1. 截圖
evidence.append("=== 截圖 ===\n");
evidence.append("[截圖已保存,Base64 長度: ")
.append(playwrightConfig.captureScreenshotBase64().length())
.append("]\n\n");
// 2. Console Logs
evidence.append("=== 瀏覽器 Console 日誌 ===\n");
List<String> consoleLogs = playwrightConfig.getConsoleLogs();
if (consoleLogs.isEmpty()) {
evidence.append("無 Console 日誌\n");
} else {
consoleLogs.stream()
.limit(50)
.forEach(log -> evidence.append(log).append("\n"));
}
evidence.append("\n");
// 3. 當前 URL 和頁面狀態
evidence.append("=== 頁面狀態 ===\n");
evidence.append("URL: ").append(playwrightConfig.getCurrentPage().url()).append("\n");
evidence.append("Title: ").append(playwrightConfig.getCurrentPage().title()).append("\n");
return evidence.toString();
}
@Tool("查詢後端 API 的健康狀態")
public String checkBackendHealth() {
try {
String healthUrl = apiBaseUrl + "/actuator/health";
String response = restTemplate.getForObject(healthUrl, String.class);
return "後端健康狀態: " + response;
} catch (Exception e) {
return "無法連接後端: " + e.getMessage();
}
}
@Tool("根據 TraceId 查詢後端日誌(需要後端支援)")
public String queryBackendLogs(String traceId) {
try {
// 這裡假設後端有暴露日誌查詢 API
// 實際實作需要根據你的日誌系統調整(ELK、Splunk、CloudWatch 等)
String logsUrl = apiBaseUrl + "/actuator/trace?traceId=" + traceId;
String response = restTemplate.getForObject(logsUrl, String.class);
return "後端日誌:\n" + response;
} catch (Exception e) {
return "無法查詢後端日誌: " + e.getMessage();
}
}
@Tool("檢查頁面上是否有錯誤訊息")
public String checkForErrorMessages() {
var page = playwrightConfig.getCurrentPage();
// 常見的錯誤元素 selectors
String[] errorSelectors = {
".error", ".alert-danger", ".alert-error",
"[class*='error']", "[class*='Error']",
".notification-error", ".toast-error",
"[role='alert']"
};
StringBuilder errors = new StringBuilder();
for (String selector : errorSelectors) {
var elements = page.locator(selector).all();
for (var element : elements) {
if (element.isVisible()) {
String text = element.textContent();
if (text != null && !text.trim().isEmpty()) {
errors.append("- ").append(text.trim()).append("\n");
}
}
}
}
if (errors.length() == 0) {
return "頁面上沒有發現明顯的錯誤訊息";
}
return "發現以下錯誤訊息:\n" + errors;
}
@Tool("取得最近的 Network 請求(最後 10 個)")
public String getRecentNetworkRequests() {
// Playwright 需要在頁面建立時就設定 Network 監聽
// 這裡提供一個簡化版本
return "Network 請求監控需要在測試開始時啟用。請使用 collectAllEvidence() 取得完整資訊。";
}
@Tool("生成診斷報告摘要")
public String generateDiagnosisSummary(String symptom, String evidence) {
return String.format(
"=== 診斷報告 ===\n\n時間: %s\n症狀: %s\n\n收集的證據:\n%s\n\n待分析...\n",
Instant.now(), symptom, evidence);
}
}
5.6 測試資料 Tools
package com.example.aiqaagent.tools;
import dev.langchain4j.agent.tool.Tool;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.testcontainers.containers.PostgreSQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Slf4j
@Component
public class DataTools {
private PostgreSQLContainer<?> postgresContainer;
private final Map<String, Object> testDataCache = new HashMap<>();
@Tool("建立一個隔離的測試資料庫環境")
public String createIsolatedDatabase() {
if (postgresContainer != null && postgresContainer.isRunning()) {
return "測試資料庫已在運行中: " + postgresContainer.getJdbcUrl();
}
postgresContainer = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
postgresContainer.start();
String jdbcUrl = postgresContainer.getJdbcUrl();
log.info("Test database started: {}", jdbcUrl);
return String.format(
"測試資料庫已建立:\nJDBC URL: %s\nUsername: test\nPassword: test\n",
jdbcUrl);
}
@Tool("在測試資料庫中執行 SQL")
public String executeSql(String sql) {
if (postgresContainer == null || !postgresContainer.isRunning()) {
return "錯誤: 請先呼叫 createIsolatedDatabase() 建立測試資料庫";
}
try (Connection conn = DriverManager.getConnection(
postgresContainer.getJdbcUrl(),
postgresContainer.getUsername(),
postgresContainer.getPassword());
Statement stmt = conn.createStatement()) {
boolean hasResultSet = stmt.execute(sql);
if (hasResultSet) {
var rs = stmt.getResultSet();
StringBuilder result = new StringBuilder();
var meta = rs.getMetaData();
int cols = meta.getColumnCount();
// Header
for (int i = 1; i <= cols; i++) {
result.append(meta.getColumnName(i)).append("\t");
}
result.append("\n");
// Data
while (rs.next()) {
for (int i = 1; i <= cols; i++) {
result.append(rs.getString(i)).append("\t");
}
result.append("\n");
}
return result.toString();
} else {
return "SQL 執行成功,影響 " + stmt.getUpdateCount() + " 行";
}
} catch (Exception e) {
return "SQL 執行失敗: " + e.getMessage();
}
}
@Tool("生成測試用的隨機資料")
public String generateTestData(String dataType) {
String result;
switch (dataType.toLowerCase()) {
case "email":
result = "test_" + UUID.randomUUID().toString().substring(0, 8) + "@example.com";
break;
case "username":
result = "user_" + UUID.randomUUID().toString().substring(0, 8);
break;
case "password":
result = "Test@" + UUID.randomUUID().toString().substring(0, 8);
break;
case "phone":
result = "09" + String.format("%08d", (int)(Math.random() * 100000000));
break;
case "credit_card":
result = "4111111111111111"; // 測試用卡號
break;
default:
result = UUID.randomUUID().toString();
}
// 快取以便後續參照
testDataCache.put(dataType, result);
return String.format("生成的 %s: %s", dataType, result);
}
@Tool("取得之前生成的測試資料")
public String getGeneratedData(String dataType) {
Object data = testDataCache.get(dataType.toLowerCase());
if (data == null) {
return "找不到 " + dataType + " 的測試資料,請先呼叫 generateTestData()";
}
return data.toString();
}
@Tool("清理測試環境")
public String cleanup() {
testDataCache.clear();
if (postgresContainer != null && postgresContainer.isRunning()) {
postgresContainer.stop();
postgresContainer = null;
return "測試資料庫已關閉,測試資料已清除";
}
return "測試資料已清除";
}
}
六、三層驗證迴圈:狀態機設計
6.1 狀態定義
package com.example.aiqaagent.loops;
/**
* 測試執行狀態
*/
public enum TestState {
// 初始狀態
IDLE,
// 探索迴圈狀態
EXPLORING,
PLANNING_NEXT_ACTION,
// 執行狀態
EXECUTING_ACTION,
WAITING_FOR_RESULT,
// 穩定性迴圈狀態
RETRYING,
CHECKING_FLAKINESS,
// 診斷迴圈狀態
COLLECTING_EVIDENCE,
ANALYZING_FAILURE,
GENERATING_REPORT,
// 終態
ACTION_SUCCEEDED,
ACTION_FAILED_DIAGNOSED,
ACTION_FLAKY,
TEST_COMPLETED,
TEST_ABORTED
}
6.2 狀態機實作
package com.example.aiqaagent.loops;
import com.example.aiqaagent.model.*;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class LoopStateMachine {
private final StabilityLoop stabilityLoop;
private final DiagnosisLoop diagnosisLoop;
private final ExplorationLoop explorationLoop;
@Getter
private TestState currentState = TestState.IDLE;
@Getter
private final List<TestStep> executedSteps = new ArrayList<>();
@Getter
private final List<DiagnosisResult> diagnosisResults = new ArrayList<>();
private int totalActionsExecuted = 0;
private int failedActions = 0;
private int flakyActions = 0;
/**
* 執行單一測試動作,經過三層迴圈處理
*/
public ActionResult executeAction(TestAction action, TestExecutionContext context) {
log.info("Executing action: {} in state: {}", action.getDescription(), currentState);
Instant startTime = Instant.now();
totalActionsExecuted++;
// 第一層:嘗試執行
transitionTo(TestState.EXECUTING_ACTION);
ActionResult result = attemptAction(action, context);
if (result.isSuccess()) {
transitionTo(TestState.ACTION_SUCCEEDED);
recordStep(action, result, Duration.between(startTime, Instant.now()));
return result;
}
// 第二層:穩定性迴圈 - 判斷是否為環境問題
transitionTo(TestState.RETRYING);
StabilityResult stabilityResult = stabilityLoop.checkAndRetry(action, context);
if (stabilityResult.isEventuallySucceeded()) {
if (stabilityResult.isFlaky()) {
transitionTo(TestState.ACTION_FLAKY);
flakyActions++;
log.warn("Action is flaky: {} (success rate: {}%)",
action.getDescription(), stabilityResult.getSuccessRate() * 100);
} else {
transitionTo(TestState.ACTION_SUCCEEDED);
}
recordStep(action, stabilityResult.getFinalResult(),
Duration.between(startTime, Instant.now()));
return stabilityResult.getFinalResult();
}
// 第三層:診斷迴圈 - 分析根因
transitionTo(TestState.COLLECTING_EVIDENCE);
DiagnosisResult diagnosis = diagnosisLoop.diagnose(action, result, context);
diagnosisResults.add(diagnosis);
transitionTo(TestState.ACTION_FAILED_DIAGNOSED);
failedActions++;
ActionResult failedResult = ActionResult.failure(
result.getErrorMessage(),
diagnosis
);
recordStep(action, failedResult, Duration.between(startTime, Instant.now()));
return failedResult;
}
/**
* 執行探索性測試
*/
public ExplorationResult exploreAndTest(TestGoal goal, TestExecutionContext context) {
log.info("Starting exploration for goal: {}", goal.getDescription());
transitionTo(TestState.EXPLORING);
List<TestAction> discoveredActions = new ArrayList<>();
int maxIterations = context.getConfig().getExplorationMaxDepth();
for (int i = 0; i < maxIterations; i++) {
transitionTo(TestState.PLANNING_NEXT_ACTION);
// 讓 AI 決定下一步
TestAction nextAction = explorationLoop.planNextAction(goal, executedSteps, context);
if (nextAction == null || nextAction.isTerminal()) {
log.info("Exploration completed: no more actions to take");
break;
}
discoveredActions.add(nextAction);
// 執行動作(經過穩定性和診斷迴圈)
ActionResult result = executeAction(nextAction, context);
// 如果是關鍵失敗,可能需要中斷探索
if (result.isCriticalFailure() && !context.getConfig().isContinueOnCriticalFailure()) {
log.warn("Critical failure detected, stopping exploration");
transitionTo(TestState.TEST_ABORTED);
break;
}
// 檢查是否達成目標
if (explorationLoop.isGoalAchieved(goal, executedSteps)) {
log.info("Goal achieved!");
break;
}
}
transitionTo(TestState.TEST_COMPLETED);
return ExplorationResult.builder()
.goal(goal)
.discoveredActions(discoveredActions)
.executedSteps(new ArrayList<>(executedSteps))
.diagnosisResults(new ArrayList<>(diagnosisResults))
.statistics(buildStatistics())
.build();
}
private ActionResult attemptAction(TestAction action, TestExecutionContext context) {
try {
// 實際執行動作(透過 AI Agent)
return context.getAgent().executeAction(action);
} catch (Exception e) {
log.error("Action execution failed", e);
return ActionResult.failure(e.getMessage(), null);
}
}
private void transitionTo(TestState newState) {
log.debug("State transition: {} -> {}", currentState, newState);
currentState = newState;
}
private void recordStep(TestAction action, ActionResult result, Duration duration) {
executedSteps.add(TestStep.builder()
.action(action)
.result(result)
.duration(duration)
.timestamp(Instant.now())
.build());
}
private TestStatistics buildStatistics() {
return TestStatistics.builder()
.totalActions(totalActionsExecuted)
.successfulActions(totalActionsExecuted - failedActions)
.failedActions(failedActions)
.flakyActions(flakyActions)
.diagnosisCount(diagnosisResults.size())
.build();
}
public void reset() {
currentState = TestState.IDLE;
executedSteps.clear();
diagnosisResults.clear();
totalActionsExecuted = 0;
failedActions = 0;
flakyActions = 0;
}
}
6.3 穩定性迴圈
package com.example.aiqaagent.loops;
import com.example.aiqaagent.model.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class StabilityLoop {
@Value("${ai-qa.loops.stability.max-retries:3}")
private int maxRetries;
@Value("${ai-qa.loops.stability.retry-delay-ms:1000}")
private long retryDelayMs;
@Value("${ai-qa.loops.stability.flakiness-threshold:0.8}")
private double flakinessThreshold;
// 看起來像環境問題的錯誤模式
private static final List<String> TRANSIENT_ERROR_PATTERNS = List.of(
"timeout",
"timed out",
"connection reset",
"connection refused",
"network",
"ECONNRESET",
"ETIMEDOUT",
"socket hang up",
"503",
"502",
"504"
);
public StabilityResult checkAndRetry(TestAction action, TestExecutionContext context) {
log.info("Entering stability loop for action: {}", action.getDescription());
List<ActionResult> attempts = new ArrayList<>();
int successCount = 0;
ActionResult lastResult = null;
for (int attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
log.info("Retry attempt {} of {}", attempt, maxRetries);
sleep(retryDelayMs);
}
lastResult = context.getAgent().executeAction(action);
attempts.add(lastResult);
if (lastResult.isSuccess()) {
successCount++;
// 如果第一次就成功,直接返回
if (attempt == 0) {
return StabilityResult.success(lastResult);
}
} else {
// 檢查是否為暫時性錯誤
if (!isTransientError(lastResult.getErrorMessage())) {
log.info("Error doesn't look transient, skipping further retries");
break;
}
}
}
// 計算成功率
double successRate = (double) successCount / attempts.size();
if (successCount > 0) {
// 至少有一次成功
boolean isFlaky = successRate < 1.0 && successRate >= flakinessThreshold;
if (isFlaky) {
log.warn("Action is flaky: success rate = {}%", successRate * 100);
}
return StabilityResult.builder()
.eventuallySucceeded(true)
.flaky(isFlaky)
.successRate(successRate)
.attempts(attempts)
.finalResult(findSuccessfulResult(attempts))
.build();
}
// 全部失敗
return StabilityResult.builder()
.eventuallySucceeded(false)
.flaky(false)
.successRate(0)
.attempts(attempts)
.finalResult(lastResult)
.build();
}
private boolean isTransientError(String errorMessage) {
if (errorMessage == null) return false;
String lowerError = errorMessage.toLowerCase();
return TRANSIENT_ERROR_PATTERNS.stream()
.anyMatch(lowerError::contains);
}
private ActionResult findSuccessfulResult(List<ActionResult> attempts) {
return attempts.stream()
.filter(ActionResult::isSuccess)
.findFirst()
.orElse(attempts.get(attempts.size() - 1));
}
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
6.4 診斷迴圈
package com.example.aiqaagent.loops;
import com.example.aiqaagent.model.*;
import com.example.aiqaagent.tools.DiagnosticTools;
import dev.langchain4j.model.chat.ChatLanguageModel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import java.time.Instant;
@Slf4j
@Component
@RequiredArgsConstructor
public class DiagnosisLoop {
private final DiagnosticTools diagnosticTools;
@Qualifier("primaryChatModel")
private final ChatLanguageModel analysisModel;
private static final String DIAGNOSIS_PROMPT = """
你是一位資深的 QA 工程師,正在分析一個測試失敗案例。
## 失敗的動作
%s
## 錯誤訊息
%s
## 收集到的證據
%s
## 分析任務
請分析這個失敗的根本原因,並提供:
1. **根因分類**(選擇一個):
- FRONTEND_BUG: 前端 JavaScript 錯誤、渲染問題
- BACKEND_BUG: API 錯誤、資料庫問題、業務邏輯錯誤
- ENVIRONMENT: 網路問題、服務不可用、資源不足
- TEST_SCRIPT: 測試腳本本身的問題(定位錯誤、時序問題)
- DATA_ISSUE: 測試資料問題
- UNKNOWN: 無法判斷
2. **根因描述**:用一句話描述問題的本質
3. **技術細節**:相關的錯誤訊息、堆疊追蹤、狀態碼等
4. **建議修復方向**:開發者應該從哪裡開始調查
5. **信心程度**:HIGH / MEDIUM / LOW
請用以下 JSON 格式回答:
```json
{
"rootCauseCategory": "FRONTEND_BUG",
"rootCauseDescription": "...",
"technicalDetails": "...",
"suggestedFix": "...",
"confidence": "HIGH"
}
```
""";
public DiagnosisResult diagnose(TestAction action, ActionResult failedResult,
TestExecutionContext context) {
log.info("Starting diagnosis for failed action: {}", action.getDescription());
Instant startTime = Instant.now();
// 1. 收集證據
String evidence = collectEvidence(context);
// 2. 檢查頁面錯誤
String pageErrors = diagnosticTools.checkForErrorMessages();
// 3. 組合所有證據
String fullEvidence = evidence + "\n\n頁面錯誤檢查:\n" + pageErrors;
// 4. 呼叫 AI 分析
String prompt = String.format(DIAGNOSIS_PROMPT,
action.getDescription(),
failedResult.getErrorMessage(),
fullEvidence
);
String analysisResponse = analysisModel.generate(prompt);
log.debug("Diagnosis response: {}", analysisResponse);
// 5. 解析回應
DiagnosisResult result = parseAnalysisResponse(analysisResponse, action, failedResult);
result.setDiagnosisTime(java.time.Duration.between(startTime, Instant.now()));
result.setEvidence(fullEvidence);
return result;
}
private String collectEvidence(TestExecutionContext context) {
StringBuilder evidence = new StringBuilder();
try {
evidence.append(diagnosticTools.collectAllEvidence());
} catch (Exception e) {
log.warn("Failed to collect some evidence: {}", e.getMessage());
evidence.append("部分證據收集失敗: ").append(e.getMessage());
}
// 嘗試收集後端健康狀態
try {
evidence.append("\n\n").append(diagnosticTools.checkBackendHealth());
} catch (Exception e) {
evidence.append("\n\n後端健康檢查失敗: ").append(e.getMessage());
}
return evidence.toString();
}
private DiagnosisResult parseAnalysisResponse(String response, TestAction action,
ActionResult failedResult) {
// 嘗試解析 JSON
try {
// 提取 JSON 部分
int jsonStart = response.indexOf("{");
int jsonEnd = response.lastIndexOf("}") + 1;
if (jsonStart >= 0 && jsonEnd > jsonStart) {
String json = response.substring(jsonStart, jsonEnd);
// 使用 Jackson 解析
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
var node = mapper.readTree(json);
return DiagnosisResult.builder()
.action(action)
.originalError(failedResult.getErrorMessage())
.rootCauseCategory(RootCauseCategory.valueOf(
node.get("rootCauseCategory").asText()))
.rootCauseDescription(node.get("rootCauseDescription").asText())
.technicalDetails(node.get("technicalDetails").asText())
.suggestedFix(node.get("suggestedFix").asText())
.confidence(Confidence.valueOf(node.get("confidence").asText()))
.rawAnalysis(response)
.build();
}
} catch (Exception e) {
log.warn("Failed to parse diagnosis response as JSON: {}", e.getMessage());
}
// 解析失敗,返回原始回應
return DiagnosisResult.builder()
.action(action)
.originalError(failedResult.getErrorMessage())
.rootCauseCategory(RootCauseCategory.UNKNOWN)
.rootCauseDescription("無法解析 AI 分析結果")
.technicalDetails(response)
.suggestedFix("請人工檢視原始分析內容")
.confidence(Confidence.LOW)
.rawAnalysis(response)
.build();
}
}
6.5 探索迴圈
package com.example.aiqaagent.loops;
import com.example.aiqaagent.model.*;
import dev.langchain4j.model.chat.ChatLanguageModel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Component
@RequiredArgsConstructor
public class ExplorationLoop {
@Qualifier("primaryChatModel")
private final ChatLanguageModel planningModel;
private static final String PLANNING_PROMPT = """
你是一位資深的 QA 工程師,正在進行探索性測試。
## 測試目標
%s
## 已執行的步驟
%s
## 當前頁面狀態
%s
## 可用的互動元素
%s
## 任務
請決定下一步應該執行什麼動作。考慮:
1. 是否已達成目標?
2. 有沒有值得探索的邊界條件?
3. 有沒有潛在的風險路徑值得測試?
回答格式(JSON):
```json
{
"shouldContinue": true,
"nextAction": {
"type": "CLICK | FILL | NAVIGATE | ASSERT | WAIT",
"target": "元素描述或 selector",
"value": "如果是 FILL,填入的值",
"description": "這個動作的目的"
},
"reasoning": "選擇這個動作的原因",
"goalProgress": "目前進度評估 (0-100)"
}
```
如果認為測試應該結束,設置 shouldContinue 為 false。
""";
private static final String GOAL_CHECK_PROMPT = """
## 測試目標
%s
## 已執行的步驟和結果
%s
## 問題
這個測試目標是否已經達成?
回答格式:
ACHIEVED: 是/否
REASON: 判斷理由
""";
public TestAction planNextAction(TestGoal goal, List<TestStep> executedSteps,
TestExecutionContext context) {
log.info("Planning next action for goal: {}", goal.getDescription());
// 取得當前頁面資訊
String pageState = context.getAgent().getPageState();
String interactiveElements = context.getAgent().getInteractiveElements();
String stepsHistory = formatStepsHistory(executedSteps);
String prompt = String.format(PLANNING_PROMPT,
goal.getDescription(),
stepsHistory,
pageState,
interactiveElements
);
String response = planningModel.generate(prompt);
return parseActionFromResponse(response);
}
public boolean isGoalAchieved(TestGoal goal, List<TestStep> executedSteps) {
String stepsHistory = formatStepsHistory(executedSteps);
String prompt = String.format(GOAL_CHECK_PROMPT,
goal.getDescription(),
stepsHistory
);
String response = planningModel.generate(prompt);
return response.toUpperCase().contains("ACHIEVED: 是") ||
response.toUpperCase().contains("ACHIEVED: YES");
}
private String formatStepsHistory(List<TestStep> steps) {
if (steps.isEmpty()) {
return "(尚無執行步驟)";
}
return steps.stream()
.map(step -> String.format("- %s: %s",
step.getAction().getDescription(),
step.getResult().isSuccess() ? "成功" : "失敗 - " + step.getResult().getErrorMessage()))
.collect(Collectors.joining("\n"));
}
private TestAction parseActionFromResponse(String response) {
try {
int jsonStart = response.indexOf("{");
int jsonEnd = response.lastIndexOf("}") + 1;
if (jsonStart >= 0 && jsonEnd > jsonStart) {
String json = response.substring(jsonStart, jsonEnd);
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
var node = mapper.readTree(json);
boolean shouldContinue = node.get("shouldContinue").asBoolean();
if (!shouldContinue) {
return TestAction.terminal();
}
var actionNode = node.get("nextAction");
return TestAction.builder()
.type(ActionType.valueOf(actionNode.get("type").asText()))
.target(actionNode.get("target").asText())
.value(actionNode.has("value") ? actionNode.get("value").asText() : null)
.description(actionNode.get("description").asText())
.reasoning(node.get("reasoning").asText())
.build();
}
} catch (Exception e) {
log.error("Failed to parse action from response: {}", e.getMessage());
}
return TestAction.terminal();
}
}
七、Prompt Engineering
7.1 System Prompt(存於 resources/prompts/system-prompt.txt)
# AI QA Agent System Prompt
你是一位資深的 QA 自動化工程師,具備以下能力:
## 核心能力
1. **測試規劃**:根據業務目標規劃測試路徑
2. **操作執行**:透過工具操作瀏覽器
3. **問題診斷**:當測試失敗時分析根因
4. **邊界探索**:主動發現潛在的 Bug 和風險
## 行為準則
### 執行測試時
- 每一步操作後都要驗證結果
- 如果操作失敗,先嘗試替代方案再報告失敗
- 遇到意外的彈窗、錯誤訊息時要記錄並處理
- 定期截圖作為證據
### 診斷問題時
- 先收集所有可用的證據(截圖、Console Log、Network Log)
- 區分前端問題、後端問題、環境問題
- 提供具體的技術細節,不要只說「出錯了」
- 給出可執行的修復建議
### 探索測試時
- 優先測試核心業務流程
- 嘗試邊界條件(空值、極大值、特殊字符)
- 測試異常流程(未登入訪問、權限不足)
- 注意頁面間的狀態一致性
## 工具使用指南
### 元素定位優先順序
1. 先嘗試用文字內容定位(clickByText)
2. 如果失敗,嘗試 CSS Selector(clickBySelector)
3. 最後才使用視覺定位(clickByVision)—— 這個最貴但最可靠
### 等待策略
- 頁面跳轉後等待 networkidle
- 動態內容載入等待特定元素出現
- 不要使用固定時間等待(除非必要)
### 成本意識
- 視覺分析(Vision)成本較高,謹慎使用
- 簡單判斷用 GPT-4o-mini
- 複雜推理才用 GPT-4o
## 回報格式
所有回報請使用繁體中文,技術術語可保留英文。
7.2 視覺定位 Prompt(resources/prompts/vision-locate-element.txt)
你是一個視覺分析助手,專門協助定位網頁元素。
## 任務
在這張網頁截圖中,找到符合以下描述的元素:
{element_description}
## 輸出格式
請回傳該元素的中心點座標,格式必須是:
COORDINATES: x=數字, y=數字
如果找不到符合描述的元素,請回傳:
NOT_FOUND: 原因說明
## 注意事項
- 座標是相對於圖片左上角的像素位置
- 圖片尺寸為 1280x720
- 優先找視覺上最符合描述的元素
- 如果有多個符合的元素,選擇最明顯/最主要的那個
- 只需要回傳座標,不需要其他解釋
## 常見元素識別技巧
- 按鈕通常是矩形、有明顯邊框或背景色
- 連結通常是藍色或有底線的文字
- 輸入框通常是白色矩形、有邊框
- 圖示通常在按鈕內部或獨立存在
7.3 診斷分析 Prompt(resources/prompts/diagnosis-analyze.txt)
你是一位資深的 QA 工程師,正在分析一個測試失敗案例。
## 失敗的動作
{action_description}
## 錯誤訊息
{error_message}
## 收集到的證據
{evidence}
## 分析任務
請分析這個失敗的根本原因,並提供:
1. **根因分類**(選擇一個):
- FRONTEND_BUG: 前端 JavaScript 錯誤、渲染問題
- BACKEND_BUG: API 錯誤、資料庫問題、業務邏輯錯誤
- ENVIRONMENT: 網路問題、服務不可用、資源不足
- TEST_SCRIPT: 測試腳本本身的問題(定位錯誤、時序問題)
- DATA_ISSUE: 測試資料問題
- UNKNOWN: 無法判斷
2. **根因描述**:用一句話描述問題的本質
3. **技術細節**:相關的錯誤訊息、堆疊追蹤、狀態碼等
4. **建議修復方向**:開發者應該從哪裡開始調查
5. **信心程度**:HIGH / MEDIUM / LOW
請用以下 JSON 格式回答:
```json
{
"rootCauseCategory": "FRONTEND_BUG",
"rootCauseDescription": "...",
"technicalDetails": "...",
"suggestedFix": "...",
"confidence": "HIGH"
}
### 7.4 探索下一步行動 Prompt(resources/prompts/exploration-next-action.txt)
```text
你是一位資深的 QA 工程師,正在進行探索性測試。
## 測試目標
{test_goal_description}
## 已執行的步驟
{executed_steps_history}
## 當前頁面狀態分析
{current_page_state}
## 可用的互動元素
{interactive_elements_list}
## 任務
請決定下一步應該執行什麼動作,以達成測試目標。考慮:
1. 是否已達成測試目標?
2. 有沒有值得探索的邊界條件?
3. 有沒有潛在的風險路徑值得測試?
4. 如何避免重複已失敗的步驟?
如果認為測試應該結束,請將 "shouldContinue" 設定為 false。
## 可用的動作類型 (type)
- CLICK: 點擊元素
- FILL: 填入文字
- NAVIGATE: 導航到指定 URL
- ASSERT: 斷言某個條件成立
- WAIT: 等待一段時間 (盡量少用)
- SCROLL: 捲動頁面
- PRESS_KEY: 按下鍵盤按鍵
## 輸出格式(JSON)
```json
{
"shouldContinue": true,
"nextAction": {
"type": "CLICK",
"target": "結帳按鈕文字或 CSS Selector",
"value": "如果是 FILL 動作,請填入的值",
"description": "這個動作的具體目的和期待效果"
},
"reasoning": "選擇這個動作的原因,以及為何它有助於達成測試目標或發現 Bug",
"goalProgress": "目前達成目標的進度評估 (0-100)"
}
---
## 八、整合層實作
### 8.1 AutonomousTester 介面
```java
package com.example.aiqaagent.agent;
import com.example.aiqaagent.model.*;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
/**
* AI QA Agent 的核心介面,定義了其行為。
* 由 LangChain4j 的 AiServices 動態實作。
*/
public interface AutonomousTester {
@SystemMessage("你是一位資深的 QA 自動化工程師,具備廣泛的軟體測試知識。" +
"你的任務是根據使用者提供的測試目標,自主規劃、執行、診斷並驗證測試流程。" +
"你擁有操作瀏覽器、執行視覺分析、查詢後端日誌和管理測試數據的能力。" +
"請始終保持專業、細心,並像一位人類資深 QA 一樣思考。" +
"請使用繁體中文進行溝通。")
void initialize(); // 用於在對話開始時設定 System Message
@UserMessage("測試目標: {{goal.description}}")
TestReport performTest(@MemoryId String testId, TestGoal goal);
// 內部方法,用於 LangChain4j 的工具調用
@Tool("執行瀏覽器操作或數據處理等原子動作")
ActionResult executeAction(TestAction action);
// 取得當前頁面文字內容
@Tool("取得當前頁面文字內容")
String getPageState();
// 取得當前頁面可互動元素
@Tool("取得當前頁面可互動元素")
String getInteractiveElements();
}
8.2 TestOrchestratorService(測試編排器)
這是整合所有迴圈和 Agent 的核心服務。
package com.example.aiqaagent.service;
import com.example.aiqaagent.agent.AutonomousTester;
import com.example.aiqaagent.config.PlaywrightConfig;
import com.example.aiqaagent.loops.LoopStateMachine;
import com.example.aiqaagent.model.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class TestOrchestratorService {
private final AutonomousTester autonomousTester;
private final PlaywrightConfig playwrightConfig;
private final LoopStateMachine loopStateMachine;
public TestReport runAutonomousTest(TestGoal goal) {
String testId = UUID.randomUUID().toString();
log.info("Starting autonomous test for goal: {} with ID: {}", goal.getDescription(), testId);
Instant startTime = Instant.now();
playwrightConfig.initialize(); // 確保 Playwright 已初始化
playwrightConfig.createNewPage(); // 為每次測試建立新的瀏覽器 Page
loopStateMachine.reset(); // 重置狀態機
try {
// LangChain4j 會自動處理 Tool Calling,將 goal 傳遞給 AI
// AI 會自行呼叫 executeAction, getPageState 等方法
autonomousTester.initialize(); // 確保 System Message 已設定
TestReport finalReport = autonomousTester.performTest(testId, goal);
finalReport.setTestId(testId);
finalReport.setStartTime(startTime);
finalReport.setEndTime(Instant.now());
finalReport.setTotalDuration(java.time.Duration.between(startTime, Instant.now()));
finalReport.setExecutedSteps(loopStateMachine.getExecutedSteps());
finalReport.setDiagnosisResults(loopStateMachine.getDiagnosisResults());
finalReport.setStatistics(loopStateMachine.getStatistics());
log.info("Autonomous test completed for goal: {}. Status: {}",
goal.getDescription(), finalReport.getStatus());
return finalReport;
} catch (Exception e) {
log.error("Autonomous test aborted due to unexpected error", e);
return TestReport.builder()
.testId(testId)
.goal(goal)
.status(TestStatus.ABORTED)
.summary("測試意外終止: " + e.getMessage())
.startTime(startTime)
.endTime(Instant.now())
.totalDuration(java.time.Duration.between(startTime, Instant.now()))
.build();
} finally {
playwrightConfig.cleanup(); // 清理 Playwright 資源
loopStateMachine.reset(); // 重置狀態機
}
}
}
九、評估框架與指標
9.1 定義成功標準
| 指標 | Phase 1 (PoC) | Phase 2 (MVP) | Phase 3 (Production) |
|---|---|---|---|
| 診斷準確率 | > 80% | > 90% | > 95% |
| 診斷時間 | < 30 秒 | < 10 秒 | < 5 秒 |
| 腳本存活率 | N/A | > 95% | > 99% |
| 新發現 Bug 數 | N/A | N/A | > 10 個/月 |
| 測試覆蓋率 | N/A | N/A | > 80% (基於探索) |
| Token 成本 | 可接受 | 可接受 | 預算內 |
9.2 評估方法
- 人工標註 (Human Labeling):每次 AI 診斷或發現 Bug 後,由資深 QA 人工驗證結果的準確性。
- A/B 測試:將測試案例分成兩組,一組使用 AI Agent 執行,一組使用傳統自動化執行,比較 Bug 發現率和維護成本。
- 覆蓋率工具:使用 JaCoCo 等工具追蹤後端程式碼執行覆蓋率,並結合 Playwright 的
page.coverage()追蹤前端 JS/CSS 覆蓋率。 - 成本監控:定期監控 OpenAI API 的 Token 使用量和費用,確保在預算內。
十、導入計畫與里程碑
10.1 Phase 1 (PoC – 8 週): 診斷輔助系統
- 週 1-2:環境建置與基礎架構設定 (LangChain4j, Playwright, Spring Boot)。
- 週 3-4:完成 BrowserTools, DiagnosticTools 的實作,整合 GPT-4o 進行日誌分析。
- 週 5-6:開發診斷迴圈核心邏輯
DiagnosisLoop,並與現有 CI 系統整合,監聽失敗 Build。 - 週 7-8:針對真實失敗案例進行 PoC 驗證,收集人工標註數據,計算診斷準確率。
10.2 Phase 2 (MVP – 12 週): 視覺與自癒測試
- 週 1-3:完成 VisionTools 的實作,整合 GPT-4o Vision 進行元素定位與截圖分析。
- 週 4-6:開發「自癒機制」:當
clickByText或clickBySelector失敗時,自動觸發locateElementByVision重新定位。 - 週 7-9:在一個獨立的測試套件中,針對部分高頻變化的 UI 功能,進行自癒測試的驗證,評估腳本存活率。
- 週 10-12:開發 Co-pilot 介面(CLI 或簡易 Web UI),讓 QA 可以透過自然語言發送測試指令。
10.3 Phase 3 (Production – 16 週): 全自主探索與驗收
- 週 1-4:完成 ExplorationLoop 的實作,整合 Testcontainers 實現測試資料隔離與環境管理。
- 週 5-8:結合所有迴圈,開發 TestOrchestratorService,實現完整 Autonomous Tester 的功能。
- 週 9-12:選擇一個核心業務模組(例如商品詳情頁到購物車流程),進行全自主探索測試。
- 週 13-16:將 Autonomous Tester 整合到夜間 Build 流程,並配置自動報告生成與告警機制。
十一、風險評估與緩解策略
| 風險 | 評估 | 緩解策略 |
|---|---|---|
| 高昂的 Token 成本 | 高 | 1. 優先使用 GPT-4o-mini 或本地化 LLM 處理簡單任務 2. 優化 Prompt,減少 Token 使用 3. 實施嚴格的成本監控和預算控制 4. 僅在核心驗證和複雜診斷時使用 GPT-4o |
| AI 幻覺/誤判 | 中 | 1. 提高 System Prompt 的清晰度與專業性 2. 引入人工覆審機制,利用 RLHF 提高準確度 3. 設置信心閾值,低信心的判斷需人工介入 |
| 測試結果不穩定 (Flakiness) | 中 | 1. 穩定性迴圈本身就是緩解措施 2. 優化 Playwright 截圖與 Vision 分析的同步機制 3. 提供完整的測試證據(錄影、截圖、Log),方便人工判斷 |
| 敏感數據洩露 | 中 | 1. 嚴禁將生產環境敏感數據直接送入 LLM API 2. 測試數據應做去識別化處理 3. 考慮使用企業內部部署的 LLM 解決方案(如 Azure OpenAI Service) |
| 導入時間過長 | 中 | 1. 採取 PoC, MVP, Production 的漸進式導入 2. 每個階段設定明確的驗收標準 3. 重視開發者與 QA 的早期參與和回饋 |
十二、成本估算
12.1 OpenAI API 成本
假設:
- GPT-4o Vision 分析一張截圖(包含 Prompt 和輸出)約 $0.05
- GPT-4o 複雜推理(診斷、規劃)一次約 $0.03
- GPT-4o-mini 簡單判斷一次約 $0.001
| 場景 | 預計執行次數/天 | 單位成本 | 日成本 |
|---|---|---|---|
| 診斷迴圈 | 50 次 | $0.03 | $1.50 |
| 探索迴圈 | 200 次 | $0.03 | $6.00 |
| 視覺定位 | 1000 次 | $0.05 | $50.00 |
| GPT-4o-mini | 5000 次 | $0.001 | $5.00 |
| 總計 | $62.50 |
每月預估成本: $62.50 * 22 個工作天 = $1375 USD
12.2 人力成本 ( PoC 階段 8 週 )
| 角色 | 人月 | 薪資/月 | 總計 |
|---|---|---|---|
| 資深 Java 開發 (AI 專長) | 2 | $5000 | $10000 |
| 資深 QA (需求定義) | 0.5 | $3500 | $1750 |
| 總計 | $11750 USD |
12.3 硬體成本
PoC 階段可使用現有開發機器。生產環境需額外考慮 Docker Host 或 K8s 節點資源。
十三、決策檢查點
- 8 週後:PoC 結束時,診斷準確率是否達到 80%?AI 診斷時間是否 < 30 秒?如果未達標,是否需要調整策略或停止?
- 20 週後:MVP 結束時,腳本存活率是否達到 95%?AI Co-pilot 是否能顯著提升 QA 效率?每月 Token 成本是否在預算內?
- 36 週後:Production 階段,新發現 Bug 數量是否顯著增加?測試覆蓋率是否達到 80%?是否真的能釋放 QA 的人力,使其從執行轉向策略?
只有在這些檢查點達成預期目標後,才能繼續投入資源,逐步將 AI 自主測試擴展到更多業務模組。