重點摘要
- Flutter Web 將所有 UI 渲染到 Canvas,Selenium 必須透過 Tab 鍵啟動語意樹(flt-semantics)才能操作元素
- showDialog 的按鈕呼叫 Navigator.pop 時,必須使用 dialog 自己的 BuildContext,否則 async 後 context stale 會導致靜默失敗
- HTTP 部署無法使用相機(非 Secure Context),需實作手動輸入 fallback
- Release build 的 debugPrint 完全靜默,debug 必須靠 SharedPreferences breadcrumb + Selenium 輪詢 localStorage
本文記錄了在 Flutter Web 應用上進行 Selenium E2E 自動化測試時踩過的所有坑。這不是教科書式的教學,而是一次完整的實戰復盤——從「為什麼 Selenium 找不到任何元素」到「為什麼確認 dialog 回傳 null」,每一條都是反覆碰壁後才理解的真相。適合正在做 Flutter Web 自動化測試、或考慮將 Ionic/AngularJS 遷移到 Flutter 的開發者。
一、Flutter Web 語意樹(Semantics Tree)是什麼?為什麼 Selenium 找不到元素?
Flutter Web 語意樹是 Flutter 為了無障礙功能(Accessibility)而產生的一組 DOM overlay 元素。與 React 或 Vue 不同,Flutter Web 預設將所有 UI 渲染到 <canvas> 元素中,DOM 裡沒有任何可見的文字節點。這意味著 Selenium 的常規定位方式(XPath 文字搜尋、CSS text selector)完全失效。
如何啟動語意樹?
語意樹由 flt-semantics 自定義元素組成,但預設是未啟動的。啟動方式只需要一行:
driver.find_element(By.TAG_NAME, "body").send_keys(Keys.TAB)
一次 Tab 鍵足以觸發整個語意樹。之後,所有語意元素都會以 <flt-semantics> 出現在 DOM 中。讀取文字的穩定做法:
text = driver.execute_script("return arguments[0].innerText", element)
語意角色速查表
| Flutter Widget | CSS Selector | 備註 |
|---|---|---|
| ElevatedButton / TextButton | flt-semantics[role='button'] | innerText 包含按鈕文字 |
| BottomNavigationBar Tab | flt-semantics[role='tab'] | innerText 含 “Tab X of Y” |
| SwitchListTile | flt-semantics[role='switch'] | title 文字不在 innerText! |
| FloatingActionButton | flt-semantics[flt-tappable] | tooltip 文字在 innerText |
| DropdownButton | flt-semantics[role='button'] | 同按鈕角色 |
最大的坑:SwitchListTile 的 title 文字(如「開始巡邏」)完全不會出現在語意樹的 innerText 中。你必須用 role='switch' 去定位元素,而不是搜尋 title 文字。這個坑讓我浪費了整整一小時。
ScrollView 內容不在語意樹
AlertDialog 裡的可滾動 ListView 內容(如 RadioListTile 選項列表)全部以 Canvas 渲染,不在 flt-semantics 中。測試時只能驗證「dialog 是否開啟」(確認「關閉」按鈕出現),無法直接驗證列表內容。
二、Flutter Web SPA 狀態污染:為什麼 driver.get() 沒有重置頁面?
Flutter Web SPA 狀態污染是指在同一個 Flutter 應用中,前一個測試留下的 widget 狀態(dialog、表單、loading)會影響下一個測試。這是因為 Flutter Web 使用 hash-based routing,driver.get(url + "/#/settings") 如果 hash 沒有變,瀏覽器不會發出新的 HTTP 請求,Flutter widget tree 完全保持原狀。
# ❌ 錯誤:如果已在 #/settings,不會重置
driver.get("http://host:port/app/#/settings")
# ✅ 正確:先到無 hash URL(觸發真正的 HTTP reload)
driver.get("http://host:port/app") # Step 1: full reload
time.sleep(3)
driver.find_element(By.TAG_NAME, "body").send_keys(Keys.TAB) # Step 2: 啟動語意樹
time.sleep(1)
driver.execute_script("window.location.hash = '/settings'") # Step 3: SPA 導航
time.sleep(2)
這個模式在 session-scoped fixture 中尤其重要。當多個測試共享同一個 driver 時,前一個測試的 dialog 可能還開著,下一個測試找不到預期的元素。
三、BuildContext 跨 async 使用的致命陷阱
BuildContext 跨 async 陷阱是整個 debug 過程中最花時間的一個坑——3 小時才定位到根因。症狀是:確認 dialog 明明點了「是」,但後續邏輯完全沒執行,沒有任何錯誤訊息。
問題程式碼
Future<bool> _confirm(String message) async {
final result = await showDialog<bool>(
context: context, // 外層 widget 的 context
builder: (_) => AlertDialog(
actions: [
TextButton(
// ❌ 用外層 context 呼叫 Navigator.pop
onPressed: () => Navigator.pop(context, true),
child: const Text('是'),
),
],
),
);
return result == true; // null == true → false → 邏輯被跳過!
}
發生了什麼?
showDialog()開啟一個新的 Route(dialog route)- 使用者點擊「是」
Navigator.pop(context, true)中的context是外層 widget 的 BuildContext- 如果外層 widget 在 async 等待期間被 Framework 重建(例如 Provider 狀態更新),該 context 可能 stale
Navigator.pop()找到錯誤的 Navigator 或 pop 了錯誤的 routeshowDialog()的 Future 得到null(而非true)null == true→false→ 確認邏輯認為使用者「取消」了- 所有後續程式碼被跳過,沒有任何錯誤訊息
正確做法
builder: (dialogCtx) => AlertDialog(
actions: [
TextButton(
// ✅ 用 dialog 自己的 context
onPressed: () => Navigator.of(dialogCtx).pop(true),
child: const Text('是'),
),
],
)
通用規則:在 showDialog 的 builder 中,永遠使用 builder 參數提供的 context(命名為 dialogCtx),不要使用外層 widget 的 context。這在 Flutter 官方文檔中有提到「Don’t use BuildContext across async gaps」,但在 dialog builder 內部容易忽略。
四、Release Build 下的靜默失敗:debugPrint 不見了
Flutter release build 的靜默失敗是另一個時間殺手。flutter build web 產出的是 release build,以下行為與 debug build 完全不同:
debugPrint()完全靜默——不會輸出到 browser consoleassert()完全跳過——不會觸發 assertion error- 未被 catch 的 async exception 由 Flutter Framework 處理,通常靜默記錄
Debug Breadcrumb 技巧:寫入 SharedPreferences,Selenium 讀 localStorage
// Dart side — 在 try/catch 每個步驟寫 breadcrumb
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('flutter.dbg', 'step_A: loading points');
// ... 業務邏輯 ...
await prefs.setString('flutter.dbg', 'step_B: saved record');
} catch (e) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('flutter.dbg', 'ERROR: $e');
}
# Selenium side — 輪詢 localStorage
for i in range(20):
time.sleep(0.5)
dbg = driver.execute_script("return localStorage.getItem('flutter.dbg')")
print(f't={i*0.5}s dbg={dbg}')
if dbg and 'ERROR' in dbg: break
這個技巧讓我在 5 分鐘內定位到「_confirm() 回傳 null」這個根因,而之前靠 console.log 完全看不到任何線索。
五、Secure Context 限制:HTTP 部署的相機和 Service Worker 全掛
Secure Context 是瀏覽器對某些敏感 Web API 的安全限制。以下 API 只在 HTTPS 或 localhost 下可用:
navigator.mediaDevices.getUserMedia()(相機 / 麥克風)navigator.serviceWorker(Service Worker / PWA)window.crypto.subtle(Web Crypto API,flutter_secure_storage 依賴此 API)
用 HTTP + IP 位址部署 Flutter Web(如 http://192.168.x.x:8010),嘗試使用 mobile_scanner 掃描 QR code 會得到:
MobileScannerException(unsupported, This browser does not support displaying video from the camera.)
解決方案:Web fallback 自動降級
@override
void initState() {
super.initState();
if (kIsWeb) {
// Web 版直接跳手動輸入,不嘗試開相機
WidgetsBinding.instance.addPostFrameCallback((_) {
_showManualInputDialog();
});
}
}
// 相機版的 errorBuilder 也要處理
errorBuilder: (context, error, child) {
if (error.toString().contains('unsupported')) {
_showManualInputDialog(); // 降級為手動輸入
}
return child ?? const SizedBox.shrink();
}
生產環境建議架設 HTTPS(Let’s Encrypt / Nginx 反向代理),即可解除所有限制。開發環境用 localhost 也自動視為 Secure Context。
六、SharedPreferences 在 Flutter Web 的雙重 JSON 編碼
Flutter Web 的 shared_preferences 套件底層使用 localStorage,但有兩個容易踩坑的特性:
Key 前綴
prefs.setString('my_key', 'value');
// 實際存儲為: localStorage['flutter.my_key']
值的雙重 JSON 編碼
prefs.setString('foo', '[{"id":1}]');
// localStorage 實際內容: '"[{\\"id\\":1}]"'
// 注意外層多了一組引號 + 轉義
# Selenium 讀取時要解兩層
import json
raw = driver.execute_script("return localStorage.getItem('flutter.foo')")
layer1 = json.loads(raw) # 解 SharedPreferences 包裝
parsed = json.loads(layer1) # 解原始 JSON 內容
七、非同步快取的時機問題:為什麼清單永遠是空的?
非同步快取時機問題是一個隱蔽的 bug:App 啟動時以 async 方式從 API 抓取主資料(如巡邏點清單)並快取到 localStorage。但如果用戶在快取完成前就操作,快取為空 → 業務邏輯用空資料建立記錄 → 記錄一旦建立就再也不會重新抓取 → 永遠空白。
解法:On-demand fallback + 恢復修補
Future<List<PatrolPoint>> _loadPatrolPoints() async {
var rawPoints = await db.getCachedPatrolPoints();
if (rawPoints.isEmpty) {
// 快取為空 → 直接打 API 補抓(此 API 不需要 token)
try {
final live = await api.getNavPoints(token);
rawPoints = live.map((item) => {
'point_id': item['id']?.toString() ?? '',
'point_name': item['navpoint_name']?.toString() ?? '',
}).toList();
if (rawPoints.isNotEmpty) await db.cachePatrolPoints(rawPoints);
} catch (e) {
debugPrint('Live fetch failed: $e');
}
}
return rawPoints.map((p) => PatrolPoint(...)).toList();
}
// 恢復進行中任務時也要修補
if (patrol != null && patrol.patrolPoints.isEmpty) {
final fresh = await _loadPatrolPoints();
if (fresh.isNotEmpty) {
patrol = patrol.copyWith(patrolPoints: fresh);
await db.updatePatrolRecord(patrol);
}
}
八、Selenium + Firefox geckodriver 實戰注意事項
Console Log 不可直接取
Firefox geckodriver 不支援 driver.get_log('browser')。要捕捉 console 輸出,需要在頁面注入攔截器:
driver.execute_script("""
window.__logs = [];
var orig = console.log;
console.log = function() {
window.__logs.push(Array.from(arguments).join(' '));
orig.apply(console, arguments);
};
""")
# 操作後讀取
logs = driver.execute_script("return window.__logs")
但在 release build 中,Flutter 的 debugPrint 不呼叫 console.log,所以這招也沒用。前面提到的 SharedPreferences breadcrumb 才是唯一可靠的方式。
click_text() helper 的完整實作
封裝一個通用的「找語意元素並點擊」函式,需要同時搜尋 button、tab、tappable 三種角色:
def click_text(driver, text, timeout=15):
"""Click flt-semantics element whose innerText contains text."""
end = time.time() + timeout
while time.time() < end:
selectors = [
"flt-semantics[role='button']",
"flt-semantics[role='tab']",
"flt-semantics[flt-tappable]",
]
for sel in selectors:
for el in driver.find_elements(By.CSS_SELECTOR, sel):
t = driver.execute_script("return arguments[0].innerText", el) or ""
if text in t:
driver.execute_script("arguments[0].click()", el)
return
time.sleep(0.5)
raise AssertionError(f"Clickable element '{text}' not found")
九、方法論:Flutter Web Debug 的正確排查流程
當 Selenium 測試與 Flutter Web 出現異常時,依照以下順序排查,可以最快定位問題:
- 語意樹是否啟動?按 Tab 後檢查
flt-semantics元素數量。為 0 → 重按 Tab - localStorage 狀態正確嗎?檢查
flutter.*key,確認 token、快取資料是否存在 - HTTP 還是 HTTPS?相機、Service Worker、Web Crypto 都需要 Secure Context
- Dialog 回傳值正確嗎?用 dialog 內部的 context 而非外層 context
- Release 還是 Debug build?debugPrint 在 release 完全靜默
- 寫 debug breadcrumb:try/catch 裡寫 SharedPreferences,Selenium 輪詢 localStorage
- SPA 狀態殘留?用 full HTTP reload(無 hash URL)強制重置 Flutter widget tree
總結:Flutter Web + Selenium 是可行的,但需要正確的心智模型
Flutter Web 的測試難度不在於「找不到元素」,而在於「找到了但行為不符預期」。Canvas 渲染 + 語意覆層的架構與傳統 HTML DOM 測試完全不同,需要建立新的心智模型。最大的兩個坑——BuildContext 跨 async 靜默失敗和 release build debugPrint 消失——可以讓你花上數小時 debug 而沒有任何錯誤訊息。
掌握了這九個教訓後,我們的 Selenium 測試從 0 做到 40/40 全過,涵蓋了登入、導航、簽到/簽退、巡邏流程、設定頁面等完整 E2E 場景。希望這篇記錄能幫到正在走同一條路的開發者。
發佈留言