Flutter Web + Selenium E2E 測試踩坑全紀錄:九大實戰教訓

重點摘要

  • 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 / TextButtonflt-semantics[role='button']innerText 包含按鈕文字
BottomNavigationBar Tabflt-semantics[role='tab']innerText 含 “Tab X of Y”
SwitchListTileflt-semantics[role='switch']title 文字不在 innerText!
FloatingActionButtonflt-semantics[flt-tappable]tooltip 文字在 innerText
DropdownButtonflt-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 → 邏輯被跳過!
}

發生了什麼?

  1. showDialog() 開啟一個新的 Route(dialog route)
  2. 使用者點擊「是」
  3. Navigator.pop(context, true) 中的 context 是外層 widget 的 BuildContext
  4. 如果外層 widget 在 async 等待期間被 Framework 重建(例如 Provider 狀態更新),該 context 可能 stale
  5. Navigator.pop() 找到錯誤的 Navigator 或 pop 了錯誤的 route
  6. showDialog() 的 Future 得到 null(而非 true
  7. null == truefalse → 確認邏輯認為使用者「取消」了
  8. 所有後續程式碼被跳過,沒有任何錯誤訊息

正確做法

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 console
  • assert() 完全跳過——不會觸發 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 出現異常時,依照以下順序排查,可以最快定位問題:

  1. 語意樹是否啟動?按 Tab 後檢查 flt-semantics 元素數量。為 0 → 重按 Tab
  2. localStorage 狀態正確嗎?檢查 flutter.* key,確認 token、快取資料是否存在
  3. HTTP 還是 HTTPS?相機、Service Worker、Web Crypto 都需要 Secure Context
  4. Dialog 回傳值正確嗎?用 dialog 內部的 context 而非外層 context
  5. Release 還是 Debug build?debugPrint 在 release 完全靜默
  6. 寫 debug breadcrumb:try/catch 裡寫 SharedPreferences,Selenium 輪詢 localStorage
  7. 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 場景。希望這篇記錄能幫到正在走同一條路的開發者。

留言

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *