Hi~ 我是 Eric!
Day 4 我看懂 Posts 的資料流,Day 5 我看懂 Settings 如何把使用者偏好存起來,並影響整個 MaterialApp。
今天進入 Day 6。
這一天的主題是測試。
以前我很容易把測試想成一件很遙遠的事:等功能寫完、專案變大、準備上線之前,再來補測試。
但這個 Flutter Learning Lab 的定位不太一樣。
它是學習型範例專案,所以測試不是為了追求覆蓋率數字,也不是為了讓報表看起來很漂亮。
它真正的目的比較像是:
保護每個學習範例,讓我敢做最小修改。
當我新增欄位、調整排序、修改 UI 結構時,測試可以提醒我:你有沒有把原本要教的東西改壞?
Day 6 的目標:看懂測試分層
今天對照的專案內容主要是:
docs/testing_strategy.mdtest/features/posts/test/features/profile_form/test/features/settings/integration_test/app_test.dart
Day 6 我想看懂四種測試:
- repository test:測資料層邏輯
- ViewModel test:測 UI state 如何變化
- widget test:測畫面與互動
- 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:ProviderContainer 和 ProviderScope(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。
我會先寫測試計畫:
- Arrange:建立
FakePostRepository - fake data 放幾筆不包含關鍵字的文章
- 用
ProviderScope(overrides)注入 fake repository - pump
MaterialApp(home: PostListView()) - Act:在
posts_search_field輸入不存在的關鍵字 - pump 超過 debounce 時間
- 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 / ViewModelProviderScope(overrides)適合 widget test 注入 fake dependency- snapshot-style UI 結構檢查可以保護主要畫面骨架
- coverage 的重點不是數字,而是確認學習重點有被測到
這一天讓我對 Flutter Learning Lab 更有信心。
因為我知道自己接下來如果要改 Posts、Settings 或 Profile Form,不是只能靠手動點畫面確認。
我可以先看測試,再小步修改,再用測試確認教學範例還站得住。
下一篇,我會進入 Day 7:用 CI、品質檢查表、打包發布概念,回頭檢查這個 AI 生成的 Flutter 學習專案,離可交付品質還差哪些東西。