Hi~ 我是 Eric!

Day 4 我看懂 Posts 的資料流,Day 5 我看懂 Settings 如何把使用者偏好存起來,並影響整個 MaterialApp

今天進入 Day 6。

這一天的主題是測試。

以前我很容易把測試想成一件很遙遠的事:等功能寫完、專案變大、準備上線之前,再來補測試。

但這個 Flutter Learning Lab 的定位不太一樣。

它是學習型範例專案,所以測試不是為了追求覆蓋率數字,也不是為了讓報表看起來很漂亮。

它真正的目的比較像是:

保護每個學習範例,讓我敢做最小修改。

當我新增欄位、調整排序、修改 UI 結構時,測試可以提醒我:你有沒有把原本要教的東西改壞?


Day 6 的目標:看懂測試分層

今天對照的專案內容主要是:

  • docs/testing_strategy.md
  • test/features/posts/
  • test/features/profile_form/
  • test/features/settings/
  • integration_test/app_test.dart

Day 6 我想看懂四種測試:

  1. repository test:測資料層邏輯
  2. ViewModel test:測 UI state 如何變化
  3. widget test:測畫面與互動
  4. integration test:測 app 能不能用接近真實流程啟動

這一天最重要的關鍵字是:

fake repository
ProviderContainer
ProviderScope(overrides: ...)

這幾個東西讓測試可以不依賴真實 API、不依賴真實 SharedPreferences,也不需要每次都跑完整 app。


測試不是為了 100% coverage

docs/testing_strategy.md 一開始就講得很清楚:

測試不是為了追求覆蓋率數字,而是保護教學重點不被破壞。

這句話對我很重要。

因為初學測試時,很容易掉進兩個極端:

  • 完全不寫測試,覺得先能跑就好
  • 一開始就追求 100% coverage,結果不知道每個測試到底保護了什麼

這個專案的測試比較務實。

它把每個 feature 的學習重點保護起來:

  • Posts:搜尋、篩選、分頁、載入更多、錯誤 UI
  • Settings:讀取偏好、切換主題、保存設定
  • Profile Form:欄位驗證、送出中、成功、錯誤顯示

換句話說,測試不是另一套額外工作。

測試是在回答:

這個範例想教的行為,現在還在嗎?

Repository test:先不要碰 UI

我先看 Posts 的 repository test:

test/features/posts/data/post_repository_test.dart

這裡有一個 FakePostApiService

class FakePostApiService extends PostApiService {
  FakePostApiService(this.posts);

  final List<Post> posts;

  @override
  Future<List<Post>> getPosts() async {
    return posts;
  }
}

Repository test 的重點不是打網路,而是驗證 RemotePostRepository 的行為。

例如:

  • fetchPosts 會回傳 API service 給的資料
  • fetchPostPage 可以依 title 搜尋
  • fetchPostPage 可以依 body 搜尋
  • fetchPostPage 可以分頁並回傳 hasMore

這層測試不需要 widget,也不需要按按鈕。

它只要確認資料邏輯是對的。

我用白話記:

Repository test 測的是資料入口。
不要讓它變成 UI test,也不要讓它真的打網路。

ViewModel test:測 state transition

接著看:

test/features/posts/presentation/post_list_view_model_test.dart

這裡使用 ProviderContainer

final container = ProviderContainer(
  overrides: [
    postRepositoryProvider.overrideWithValue(
      FakePostRepository(expectedPosts),
    ),
  ],
);
addTearDown(container.dispose);

這是我 Day 6 最想弄懂的地方。

ProviderContainer 可以在測試裡建立一個 Riverpod 容器,然後用 override 把真實 repository 換成 fake repository。

這樣 ViewModel 仍然照平常方式讀 provider:

ref.read(postRepositoryProvider)

但測試環境裡,它拿到的是 fake。

這讓 ViewModel test 可以專心測 state transition:

  • 初始載入後 state 有資料
  • deletePost() 會移除指定文章
  • updateSearchTerm() 會 debounce 後重新查詢
  • changeFilter() 會立刻重新載入第一頁
  • loadMore() 會 append 下一頁資料
  • retryInitialLoad() 會處理重試流程

這層測試的重點是:

使用者事件進來後,ViewModel 會把 state 變成什麼?

它還不需要真的畫 UI。


Widget test:測使用者看得到的畫面

再看 Posts 的 widget test:

test/features/posts/presentation/post_list_view_test.dart

這裡就會真的 pump widget:

await tester.pumpWidget(
  ProviderScope(
    overrides: [
      postRepositoryProvider.overrideWithValue(
        FakePostRepository(const [
          Post(id: 1, title: 'Feature first', body: 'Body text'),
        ]),
      ),
    ],
    child: const MaterialApp(home: PostListView()),
  ),
);

這段和 ViewModel test 很像,但用途不同。

ViewModel test 用 ProviderContainer,不需要畫面。

Widget test 用 ProviderScope 包住實際 Widget,讓畫面裡的 provider 也可以拿到 fake repository。

這樣測試就能確認:

  • 畫面有顯示 fake repository 提供的文章
  • app bar、refresh button、search field、filter chips 都存在
  • 點刪除按鈕後,文章會從畫面消失
  • repository 失敗時,畫面會出現 retry UI
  • 搜尋輸入後,debounce 會更新 query
  • 點 filter chip 後,query filter 會改變
  • 點載入更多後,下一頁資料會 append 到畫面

我覺得這層測試最像在模擬使用者。

但它仍然不是完整 app test,因為它只包 PostListView,而不是跑整個 app。


Snapshot-style UI 結構檢查

這個專案目前沒有做 golden test,而是用比較輕量的 snapshot-style UI 結構檢查。

例如 Posts widget test 會固定幾個重要 UI:

expect(find.text('文章列表 (Riverpod + Dio)'), findsOneWidget);
expect(find.byIcon(Icons.refresh), findsOneWidget);
expect(find.byKey(const Key('posts_search_field')), findsOneWidget);
expect(find.text('全部'), findsOneWidget);
expect(find.text('標題'), findsOneWidget);
expect(find.text('內文'), findsOneWidget);
expect(find.byType(ListTile), findsNWidgets(2));

這不是在比對截圖像素。

它是在保護畫面的主要骨架。

這對教學專案很適合,因為 golden test 會受到字型、平台、截圖環境影響;但結構檢查比較穩定,也比較容易理解。

我先把它理解成:

不要保證畫面每一個像素完全一樣。
先保證這個範例的主要 UI 結構沒有被改壞。

Settings test:SharedPreferences 也可以被隔離

Day 5 的 Settings feature 也有測試。

例如 settings_view_test.dart 裡有 FakeSettingsRepository

class FakeSettingsRepository implements SettingsRepository {
  FakeSettingsRepository(this.preferences);

  UserPreferences preferences;
  AppThemeMode? savedThemeMode;

  @override
  Future<UserPreferences> loadPreferences() async {
    return preferences;
  }

  @override
  Future<void> saveThemeMode(AppThemeMode themeMode) async {
    savedThemeMode = themeMode;
    preferences = preferences.copyWith(themeMode: themeMode);
  }
}

這讓 widget test 可以驗證:

  • 三個主題選項有顯示
  • 目前偏好會正確呈現
  • 點擊深色模式後,repository 收到 AppThemeMode.dark

重點是:widget test 不需要真的碰 SharedPreferences。

這跟 Posts 不打真實 API 是同一個原則。

測試要把外部依賴換成可控制的 fake,才能穩定驗證自己的邏輯。


Profile Form test:表單要測欄位錯誤和送出錯誤

Profile Form 的測試也很有代表性。

它至少拆成三種:

  • validator test:測 ProfileFormValidators
  • ViewModel test:測 submit success / error state
  • widget test:測空表單錯誤、loading state、error display

這讓我重新理解 Day 3 的表單。

表單不是只測「按了送出有沒有成功」。

它要分清楚:

欄位錯誤:validator 顯示在欄位附近
送出錯誤:ViewModel error state 顯示在頁面 error display
送出中:按鈕不可重複送出,畫面顯示 loading state

如果只做手動測試,很容易漏掉 error state。

但 widget test 可以刻意注入會失敗的 repository,把錯誤狀態穩定重現出來。


Integration test:確認 app shell 能啟動

最後看:

integration_test/app_test.dart

它做的是最小煙霧測試:

await tester.pumpWidget(const ProviderScope(child: MyApp()));

expect(find.text('Flutter 學習路徑'), findsOneWidget);
expect(find.text('基礎 UI'), findsOneWidget);
expect(find.text('狀態與資料'), findsOneWidget);
expect(find.text('Form 表單驗證'), findsOneWidget);
expect(find.text('SharedPreferences 偏好設定'), findsOneWidget);

這個測試不是要測完所有流程。

它只是確認整個 app shell 可以啟動,而且首頁主要入口存在。

我把 integration test 先理解成:

不要一開始就拿它測所有細節。
先用它確認 app 能不能像真實 app 一樣跑起來。

細節留給 repository、ViewModel、widget test 會比較清楚。


我怎麼用 Codex 追 Day 6?

Day 6 我跟 Codex 的對話方式,是請它幫我分類測試責任。

我先問:

請閱讀 docs/testing_strategy.md,
再對照 test/features/posts/。
幫我整理 repository test、ViewModel test、widget test 分別在保護什麼。

接著問 fake:

為什麼 Posts widget test 要用 FakePostRepository?
如果 widget test 直接打真實 API,會有哪些問題?

再問 Riverpod override:

請比較 ProviderContainer 和 ProviderScope(overrides)。
為什麼 ViewModel test 用 ProviderContainer,
而 widget test 用 ProviderScope 包住畫面?

最後我會請 Codex 幫我做測試設計:

如果我要替 Posts 補一個「搜尋沒有結果」的空狀態測試,
請先列出測試要安排什麼 fake data、觸發什麼使用者動作、最後 expect 什麼 UI。
不要直接寫程式碼。

這種問法對我很有幫助。

因為它不是直接叫 AI 幫我生測試,而是先訓練我用 Arrange、Act、Assert 的方式思考。


Day 6 常見卡點:測試分層靠問答釐清

測試最容易讓我卡住的地方,是不知道「這個行為該測哪一層」。

所以我把問題拆成幾個 Q&A。

Q1:Repository test 和 ViewModel test 差在哪?

Repository test 和 ViewModel test 都不用畫 UI,
那它們到底差在哪?

Codex 幫我分成一句話:

Repository test 測資料規則。
ViewModel test 測 UI state 如何變化。

例如 Posts 的 filter / pagination 是 repository 行為;deletePost()、debounce、load more 後 state 怎麼變,則是 ViewModel 行為。

Q2:為什麼 widget test 不要打真 API?

Widget test 如果真的打 API,
不是更接近真實使用情境嗎?

Codex 提醒我,widget test 的目標不是驗證 API。

它要驗證畫面:

給定某種資料,畫面是否正確顯示?
使用者點擊後,畫面是否正確變化?
錯誤狀態是否有被呈現?

真 API 會讓測試變慢、不穩、不可控。

Q3:ProviderContainerProviderScope(overrides) 怎麼分?

ViewModel test 用 ProviderContainer,
widget test 用 ProviderScope(overrides),
我應該怎麼記?

我最後用這個方式記:

不用畫 UI,只測 provider / ViewModel:ProviderContainer。
要 pump Widget,讓畫面吃到 fake dependency:ProviderScope(overrides)。

這個記法很適合初學者,至少先不會亂用。

Q4:coverage 越高就越好嗎?

CI 會產生 coverage artifact,
那是不是代表 coverage 越高越好?

Codex 的回答和文件一致:

coverage 是提醒,不是目的。

對這個學習專案來說,比起追數字,更重要的是每個 feature 的教學重點有沒有被測到。


今日最小練習:設計一個 empty state widget test

Day 6 的最小練習是替 Posts 設計一個測試案例。

情境:

使用者搜尋一個不存在的關鍵字,
列表沒有符合條件的文章,
畫面應該顯示 empty state。

我會先寫測試計畫:

  1. Arrange:建立 FakePostRepository
  2. fake data 放幾筆不包含關鍵字的文章
  3. ProviderScope(overrides) 注入 fake repository
  4. pump MaterialApp(home: PostListView())
  5. Act:在 posts_search_field 輸入不存在的關鍵字
  6. pump 超過 debounce 時間
  7. Assert:確認畫面顯示「沒有符合條件的文章」

這個練習很小,但它會串起三件事:

  • fake repository
  • user interaction
  • UI expectation

這就是 widget test 的核心。


Day 6 結論:測試讓我敢改學習範例

Day 6 我學到的不是「Flutter test API 怎麼背」。

我真正學到的是測試分層。

我現在可以說出:

  • repository test 測資料層行為,不碰 UI
  • ViewModel test 測 state transition,不需要 pump widget
  • widget test 測畫面與使用者互動
  • integration test 先做 app shell 的最小煙霧測試
  • fake repository 可以讓測試不依賴真實 API 或本地儲存
  • ProviderContainer 適合測 provider / ViewModel
  • ProviderScope(overrides) 適合 widget test 注入 fake dependency
  • snapshot-style UI 結構檢查可以保護主要畫面骨架
  • coverage 的重點不是數字,而是確認學習重點有被測到

這一天讓我對 Flutter Learning Lab 更有信心。

因為我知道自己接下來如果要改 Posts、Settings 或 Profile Form,不是只能靠手動點畫面確認。

我可以先看測試,再小步修改,再用測試確認教學範例還站得住。

下一篇,我會進入 Day 7:用 CI、品質檢查表、打包發布概念,回頭檢查這個 AI 生成的 Flutter 學習專案,離可交付品質還差哪些東西。