在前面的開發過程中,我們完整建立了 Clean Architecture 的三層架構,包含 UseCase、Repository 和 Notifier。今天,我們要為這個架構建立完整的測試保護網:透過 Unit Test 來驗證業務邏輯和狀態管理是否正確運作。
這是 確保程式碼品質與架構穩定性 的關鍵步驟,讓我們能在未來安心地進行功能擴充和重構。
測試不只是關於「測試」,它更是一種「品質保證」的作法。在開發過程中常會有個問題:「我們應該測試什麼?」從實際經驗來看:對於不想讓它出錯,或者希望足夠有信心它不會引入不良行為的所有東西,都建議寫測試。
要理解自動化測試的重要性,先定義測試的內涵很重要。在 Flutter 開發中,最基本的測試應包含:
在開發過程中,執行測試讓我們能夠以可預測和受控的方式驗證 App 的個別組件。這種系統化的測試作法能及早發現問題,並有助於實踐「左移 (shifting left)」概念——在開發過程早期投入時間發現和解決問題,能大幅降低後期修復 Bug 的成本和複雜性。
在開發經驗中,發現為 Clean Architecture 寫測試有幾個重要好處:
1. 驗證架構設計:
Clean Architecture 設計是否真的有效?UseCase 和 Notifier 的邏輯是否正確?透過測試,我們可以驗證架構的每一層都能正常運作。就像 Notifier,正因為它依賴的是抽象的 UseCase,我們才能在測試中輕鬆「替換」掉它的依賴來進行隔離測試。
2. 測試即文件: 一個好的測試案例,本身就是一份非常實用的「使用說明書」。它清晰地描述了在何種情況下,呼叫哪個方法,會得到何種結果。
3. 提供重構的信心: 當程式碼被完善的測試所保護時,我們就能充滿信心地進行重構或添加新功能,因為只要測試依然通過,就代表沒有破壞原有的邏輯。
4. 程式碼覆蓋率的真正目的:
在開發經驗中發現,比起追求某個目標百分比(如 80% 或 90%),更建議有足夠的測試來確保 App 的可靠性,並為團隊提供信心。測試應該要有目的,並為 App 帶來價值和品質。
5. 測試作為設計改進的催化劑:
在開發過程中常會遇到這種情況:寫了一個函數,卻在測試時才發現它比預期更複雜,與其他系統組件不太相容,或者在測試過程中想到了更好的解決方案。這些經驗都顯示了測試在改進和演進軟體設計中的作用。
測試金字塔提供了一個框架,根據測試的範圍和操作層次對不同類型的測試進行分類。在 Flutter 中,測試金字塔通常由三個主要層次構成:
重要原則:在測試金字塔中走得越高,信心水準越高,但執行速度變慢的可能性也越高。因此,需要策略性地結合不同層次的測試,並根據 App 的具體需求和複雜性進行調整。
為了寫出清晰、易讀的測試,我們採用了一個通用的好作法,就是 AAA 模式。這是一種編寫測試的結構化方法,讓測試程式碼清晰易懂且有組織性。
Arrange (準備): 設定測試的特定條件。這通常包括建立對象、設定模擬 (mocks) 或設置任何必要的狀態。
Act (執行): 執行我們想要測試的動作。這可能是呼叫一個方法,或在一個 Widget 中觸發使用者互動。
Assert (驗證): 檢查動作的結果。這涉及驗證狀態或輸出是否符合我們的預期。
test('should add two numbers correctly', () {
// Arrange
final calculator = Calculator(); // 設置測試所需的對象
// Act
final result = calculator.add(2, 3); // 執行要測試的動作
// Assert
expect(result, 5); // 檢查結果是否符合預期
});
在 Flutter 中編寫清晰完整的測試,建議所撰寫的測試能夠容易理解、獨立運作且不受外部因素影響:
expect
斷言建議在測試失敗時提供清晰的訊息1. 確認測試套件
我們的專案已經設定好了完整的測試環境。在 pubspec.yaml
的 dev_dependencies
中包含:
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.5.4
mockito: ^5.4.6
riverpod_test: ^0.1.9
這些工具讓我們能建立「替身」物件和測試 Riverpod 狀態管理:
2. 查看我們已實作的測試檔案
我們專案中已經建立了完整的測試架構。讓我們看看實際的 UseCase 測試實作:
void main() {
group('GetActivitiesUseCase Tests', () {
late MockActivityRepository mockRepository;
late GetActivitiesUseCase useCase;
setUpAll(() => provideDummyValues());
setUp(() {
mockRepository = MockActivityRepository();
useCase = GetActivitiesUseCase(repository: mockRepository);
});
test('should get activities when operation succeeds', () async {
// Arrange
final testActivities = TestHelpers.createMediumActivityList();
MockHelpers.setupRepositorySuccess(mockRepository, testActivities);
// Act
final result = await useCase.getAllActivities();
// Assert
TestHelpers.expectSuccessResult(result, testActivities);
MockHelpers.verifyRepositoryCall(mockRepository);
});
test('should return failure when repository fails', () async {
// Arrange
MockHelpers.setupRepositoryFailure(mockRepository, 'Network error');
// Act
final result = await useCase.getAllActivities();
// Assert
TestHelpers.expectFailureResult(result, 'Network error');
});
});
}
這個測試展示了 AAA 模式在我們專案中的實際應用。
setUpAll vs setUp 的區別與動機
在這個測試範例中,我們同時使用了 setUpAll
與 setUp
,這是優良的測試實踐:
setUpAll
: 用於執行整個測試群組 (group) 只需進行一次的昂貴或通用設置。在範例中,provideDummyValues()
就是絕佳例子,因為 Dummy Value 的註冊是全域性的,執行一次即可。
setUp
: 用於確保每個測試案例 (test
) 都在獨立、乾淨的環境下執行。在範例中,為每個測試重新建立 MockActivityRepository
和 GetActivitiesUseCase
實例,是為了防止前一個測試的狀態或互動(例如 verify
的呼叫次數)污染下一個測試,這對於保證測試的獨立性至關重要。
3. 測試 Notifier 的狀態管理
我們專案中已經實作了 CreateActivityNotifier
的完整測試。讓我們看看實際的 Riverpod 狀態管理測試:
void main() {
group('CreateActivityNotifier', () {
late ProviderContainer container;
setUp(() => container = ProviderContainer());
tearDown(() => container.dispose());
test('should have correct initial state', () {
final state = container.read(createActivityNotifierProvider);
expect(state.currentStep, ActivityCreationStep.theme);
expect(state.selectedCategory, ActivityCategory.growth);
expect(state.isLoading, isFalse);
});
test('should navigate between steps correctly', () {
final notifier = container.read(createActivityNotifierProvider.notifier);
// Test next step
notifier.nextStep();
expect(container.read(createActivityNotifierProvider).currentStep,
ActivityCreationStep.details);
// Test previous step
notifier.previousStep();
expect(container.read(createActivityNotifierProvider).currentStep,
ActivityCreationStep.theme);
});
});
}
這展現了使用 ProviderContainer
測試 Riverpod 狀態的正確方式。
在我們的專案中,我們使用 mockito 套件來建立測試替身。
測試替身 (Test Double) 的類型
在專業的測試領域中,測試替身有更細緻的分類:
Stub (樁): 主要用於提供測試中所需的「罐頭」回應。when(...).thenAnswer(...)
就是在設定一個 Stub,它只負責回傳預設的資料。
Mock (模擬物件): 不僅能提供回應,更重要的是會記錄與它的互動,並在事後進行驗證。verify(...)
的使用,才真正體現了 Mock 的價值。
Fake (偽物件): 一個輕量級、可運作但又不適合上線的實作。例如,用記憶體內的 List 實作的 ActivityMockDataSource
就是一個 Fake。
Mockito 主要用於建立 Stubs 和 Mocks,讓我們能同時控制依賴的行為並驗證互動是否符合預期。
以下是實際應用的範例:
// 生成 Mock 類別的註解
@GenerateMocks([ActivityRepository])
void main() {}
// 提供 dummy values 給 Mockito
void provideDummyValues() {
provideDummy<Result<List<Activity>>>(const Success(<Activity>[]));
provideDummy<Result<Activity>>(Success(Activity(id: 'dummy', title: 'Test')));
}
// 基本的 Mockito 使用
test('should handle repository success', () async {
// Arrange: 設定 Stub 行為
when(mockRepository.getActivities())
.thenAnswer((_) async => Success(testActivities));
// Act: 執行被測方法
final result = await useCase.getAllActivities();
// Assert: 驗證結果與互動
expect(result.isSuccess, true);
verify(mockRepository.getActivities()).called(1);
});
這個範例展示了在我們專案中的實際作法:
@GenerateMocks
註解自動產生 Mock 類別provideDummyValues()
為複雜類型提供預設值when().thenAnswer()
設定 Mock 的回應verify().called()
確認方法被正確呼叫進階的 Mockito 驗證能力
Mockito 提供了強大的驗證能力,除了基本的 verify(...).called(1)
外,還包含:
verifyNever
: 驗證方法從未被呼叫verifyNever(mockRepository.getActivities());
verify(mockRepository.createActivity(argThat(equals(expectedActivity)))).called(1);
4. 測試輔助工具的使用
我們專案中建立了兩個重要的測試輔助工具。這些 Helper 的設計遵循了 DRY (Don't Repeat Yourself) 原則,目的是:
MockHelpers.setupRepositorySuccess(...)
比 when(...).thenAnswer(...)
更具可讀性TestHelpers - 建立測試資料:
class TestHelpers {
static Activity createTestActivity({String? id, String? title}) => Activity(
id: id ?? 'test-id',
title: title ?? 'Test Activity',
category: ActivityCategory.growth,
founder: createTestFounder(),
);
static List<Activity> createMediumActivityList() =>
List.generate(5, (i) => createTestActivity(id: 'activity-$i'));
static void expectSuccessResult<T>(Result<T> result, T expectedValue) {
expect(result.isSuccess, isTrue);
expect(result.value, equals(expectedValue));
}
static void expectFailureResult<T>(Result<T> result, String? errorMessage) {
expect(result.isFailure, isTrue);
if (errorMessage != null) {
expect((result as Failure<T>).error.message, contains(errorMessage));
}
}
}
MockHelpers - 設定 Mock 物件:
class MockHelpers {
static void setupRepositorySuccess(MockActivityRepository repo, List<Activity> activities) {
when(repo.getActivities()).thenAnswer((_) async => Success(activities));
}
static void setupRepositoryFailure(MockActivityRepository repo, String errorMessage) {
when(repo.getActivities()).thenAnswer((_) async => Failure(AppException.unknown(errorMessage)));
}
static void verifyRepositoryCall(MockActivityRepository repo) {
verify(repo.getActivities()).called(1);
}
}
5. 執行測試
我們專案已設定好完整的測試指令:
# 首次執行測試前,產生 Mock 類別
flutter packages pub run build_runner build
# 執行所有測試
flutter test
# 執行特定測試檔案
flutter test test/features/activity/domain/usecases/get_activities_usecase_test.dart
# 產生測試覆蓋率報告
flutter test --coverage
# 如果修改了 Mock 註解,重新產生 Mock 類別
flutter packages pub run build_runner build --delete-conflicting-outputs
在單元測試中,我們應該專注於測試行為而非內部實作:
❌ 不好的範例:測試內部狀態
test('should increment internal counter', () {
final useCase = GetActivitiesUseCase(repository: mockRepository);
// 不好:測試內部私有變數或方法
expect(useCase._internalCallCount, 0);
useCase._incrementCallCount();
expect(useCase._internalCallCount, 1);
});
✅ 好的範例:測試公開行為
test('should get activities when repository succeeds', () async {
// Arrange
MockHelpers.setupRepositorySuccess(mockRepository, testActivities);
// Act
final result = await useCase.getAllActivities();
// Assert - 測試公開的行為和結果
TestHelpers.expectSuccessResult(result, testActivities);
MockHelpers.verifyRepositoryCall(mockRepository);
});
為什麼好的範例更強韌?
使用 setUp()
和 tearDown()
來管理測試的共同設置:
void main() {
late ProviderContainer container;
setUp(() => container = ProviderContainer());
tearDown(() => container.dispose());
test('test case', () {
// 使用 container
});
}
在我們的 Clean Architecture 中,測試變得特別容易和有意義:
1. 領域層的純粹性
2. 依賴注入的威力
3. 狀態管理的可預測性
我們的 Crew Up! 專案已經建立了全面的測試覆蓋,並完成了測試工具的現代化改進:
🎯 領域層測試
GetActivitiesUseCase
、CreateActivityUseCase
、CreateActivityFromDataUseCase
、IncrementViewCountUseCase
Activity
實體的建立和驗證ActivityValidationService
、ActivityErrorHandler
、ActivityInputSuggestionsService
、ActivityAutoSaveService
🔧 資料層測試
ActivityRepositoryImpl
完整的 CRUD 操作測試ActivityFirebaseDataSource
、ActivityLocalDataSource
、ActivityMockDataSource
ActivityRepositoryFactory
🎨 表現層測試
CreateActivityNotifier
狀態管理,包含進階邊界測試💼 共用元件測試
DatePickerProvider
🛠️ 測試工具改進
測試覆蓋率報告產生:
# 產生覆蓋率報告
flutter test --coverage
# 查看覆蓋率檔案(會產生 coverage/lcov.info)
今天,我們深入了解了專案中已建立的完整測試保護網。透過 Unit Test 和 AAA 模式,我們為 Clean Architecture 建立了堅實的品質保證基礎。
🏗️ 測試架構的完整性
🔧 專案實際成果
TestHelpers
、TestConstants
和 MockHelpers
mockito
的註解驅動測試策略💡 Clean Architecture 的測試優勢
有了這個堅實的測試基礎,我們能安心地進行功能擴充和重構。每個新功能的加入都能透過測試確保不會破壞既有邏輯。
明天,我們將探討 Day 12: Widget Test 與 Integration Test 實戰,深入學習如何測試 UI 組件的互動行為、使用者操作流程,以及多個組件協同工作的整合測試,進一步完善我們的測試保護網。
期待與您在 Day 12 相見!