iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0
Mobile Development

我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅系列 第 11

Day 11 - Unit Test 與 AAA 模式:為 Clean Architecture 建立品質防護網

  • 分享至 

  • xImage
  •  

在前面的開發過程中,我們完整建立了 Clean Architecture 的三層架構,包含 UseCase、Repository 和 Notifier。今天,我們要為這個架構建立完整的測試保護網:透過 Unit Test 來驗證業務邏輯和狀態管理是否正確運作。

這是 確保程式碼品質與架構穩定性 的關鍵步驟,讓我們能在未來安心地進行功能擴充和重構。

為什麼測試很重要:不只是抓 Bug

測試不只是關於「測試」,它更是一種「品質保證」的作法。在開發過程中常會有個問題:「我們應該測試什麼?」從實際經驗來看:對於不想讓它出錯,或者希望足夠有信心它不會引入不良行為的所有東西,都建議寫測試。

測試的定義與「左移」原則

要理解自動化測試的重要性,先定義測試的內涵很重要。在 Flutter 開發中,最基本的測試應包含:

  • 單一行為:使用特定輸入進行測試
  • 可見輸出或行為:由輸入產生
  • 受控執行環境:例如模擬器、無頭測試或隔離的程序

在開發過程中,執行測試讓我們能夠以可預測和受控的方式驗證 App 的個別組件。這種系統化的測試作法能及早發現問題,並有助於實踐「左移 (shifting left)」概念——在開發過程早期投入時間發現和解決問題,能大幅降低後期修復 Bug 的成本和複雜性。

為什麼要為架構寫測試?

在開發經驗中,發現為 Clean Architecture 寫測試有幾個重要好處:

1. 驗證架構設計:

Clean Architecture 設計是否真的有效?UseCase 和 Notifier 的邏輯是否正確?透過測試,我們可以驗證架構的每一層都能正常運作。就像 Notifier,正因為它依賴的是抽象的 UseCase,我們才能在測試中輕鬆「替換」掉它的依賴來進行隔離測試。

2. 測試即文件: 一個好的測試案例,本身就是一份非常實用的「使用說明書」。它清晰地描述了在何種情況下,呼叫哪個方法,會得到何種結果。

3. 提供重構的信心: 當程式碼被完善的測試所保護時,我們就能充滿信心地進行重構或添加新功能,因為只要測試依然通過,就代表沒有破壞原有的邏輯。

4. 程式碼覆蓋率的真正目的:
在開發經驗中發現,比起追求某個目標百分比(如 80% 或 90%),更建議有足夠的測試來確保 App 的可靠性,並為團隊提供信心。測試應該要有目的,並為 App 帶來價值和品質。

5. 測試作為設計改進的催化劑:
在開發過程中常會遇到這種情況:寫了一個函數,卻在測試時才發現它比預期更複雜,與其他系統組件不太相容,或者在測試過程中想到了更好的解決方案。這些經驗都顯示了測試在改進和演進軟體設計中的作用。

Flutter 中的測試金字塔

測試金字塔提供了一個框架,根據測試的範圍和操作層次對不同類型的測試進行分類。在 Flutter 中,測試金字塔通常由三個主要層次構成:

1. 單元測試 (Unit Tests)

  • 專注於應用程式最小的部分,例如單獨的函數、方法或變數
  • 這些測試在隔離環境中進行,通常是純 Dart 測試,不依賴 Flutter
  • 它們特別適用於驗證契約 (contract)業務邏輯 (business logic),為開發者提供快速且有價值的回饋

2. 小部件測試 (Widget Tests)

  • 專注於單個 UI 組件,關鍵在於測試小部件子樹 (widget sub-tree)
  • 這種類型的測試對於獨立評估 UI 組件至關重要
  • 為確保有效性,它們應保持在單個組件範圍內

3. 整合測試 (Integration Tests)

  • 彌合了單元測試和端到端測試之間的差距
  • 它們透過模擬依賴 (mocked dependencies) 來模擬端到端體驗
  • 這些測試對於識別業務邏輯中的任何中斷以及確保 App 的各個組件按預期協同工作特別有價值

4. 端到端測試 (End-to-end, E2E Tests)

  • 全面性的黑盒測試,涉及使用真實後端和硬體的整個 App
  • 這些測試對於確保整個 App 如使用者所體驗的那樣正常運行至關重要

重要原則:在測試金字塔中走得越高,信心水準越高,但執行速度變慢的可能性也越高。因此,需要策略性地結合不同層次的測試,並根據 App 的具體需求和複雜性進行調整。

測試的結構:Arrange-Act-Assert (AAA) 模式

為了寫出清晰、易讀的測試,我們採用了一個通用的好作法,就是 AAA 模式。這是一種編寫測試的結構化方法,讓測試程式碼清晰易懂且有組織性。

  • Arrange (準備): 設定測試的特定條件。這通常包括建立對象、設定模擬 (mocks) 或設置任何必要的狀態。

  • Act (執行): 執行我們想要測試的動作。這可能是呼叫一個方法,或在一個 Widget 中觸發使用者互動

  • Assert (驗證): 檢查動作的結果。這涉及驗證狀態或輸出是否符合我們的預期

AAA 模式範例

test('should add two numbers correctly', () {
  // Arrange
  final calculator = Calculator(); // 設置測試所需的對象

  // Act
  final result = calculator.add(2, 3); // 執行要測試的動作

  // Assert
  expect(result, 5); // 檢查結果是否符合預期
});

編寫清晰完整的測試原則

在 Flutter 中編寫清晰完整的測試,建議所撰寫的測試能夠容易理解、獨立運作且不受外部因素影響:

  • 清晰性 (Clarity):測試名稱建議能明確表達它正在測試什麼行為
  • 完整性 (Completeness):測試建議透過執行被測方法並斷言預期結果來檢查該方法
  • 獨立性 (Independence):測試不依賴任何外部狀態,會創建自己的實例
  • 文件化 (Documentation):測試本身就示範了方法應該如何運作
  • 失敗訊息 (Failure Message)expect 斷言建議在測試失敗時提供清晰的訊息

動手時間:測試我們的 Clean Architecture

1. 確認測試套件

我們的專案已經設定好了完整的測試環境。在 pubspec.yamldev_dependencies 中包含:

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.5.4
  mockito: ^5.4.6
  riverpod_test: ^0.1.9

這些工具讓我們能建立「替身」物件和測試 Riverpod 狀態管理:

  • flutter_test: Flutter 官方測試框架
  • mockito: 經典的 Mock 框架,使用註解產生 Mock 類別
  • riverpod_test: 專門用於測試 Riverpod Provider 的工具
  • build_runner: 用於產生程式碼,mockito 需要它來產生 Mock 類別

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 的區別與動機

在這個測試範例中,我們同時使用了 setUpAllsetUp,這是優良的測試實踐:

  • setUpAll: 用於執行整個測試群組 (group) 只需進行一次的昂貴或通用設置。在範例中,provideDummyValues() 就是絕佳例子,因為 Dummy Value 的註冊是全域性的,執行一次即可。

  • setUp: 用於確保每個測試案例 (test) 都在獨立、乾淨的環境下執行。在範例中,為每個測試重新建立 MockActivityRepositoryGetActivitiesUseCase 實例,是為了防止前一個測試的狀態或互動(例如 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 建立測試替身

在我們的專案中,我們使用 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 類別
  • Dummy Values:使用 provideDummyValues() 為複雜類型提供預設值
  • Stub 行為:使用 when().thenAnswer() 設定 Mock 的回應
  • Mock 驗證:使用 verify().called() 確認方法被正確呼叫
  • AAA 模式:清晰的測試結構

進階的 Mockito 驗證能力

Mockito 提供了強大的驗證能力,除了基本的 verify(...).called(1) 外,還包含:

  • verifyNever: 驗證方法從未被呼叫
verifyNever(mockRepository.getActivities());
  • 參數驗證: 確保方法被正確呼叫
verify(mockRepository.createActivity(argThat(equals(expectedActivity)))).called(1);

4. 測試輔助工具的使用

我們專案中建立了兩個重要的測試輔助工具。這些 Helper 的設計遵循了 DRY (Don't Repeat Yourself) 原則,目的是:

  • 減少樣板程式碼 (Boilerplate Code):將重複的物件建立、Mock Stubbing 和結果驗證邏輯抽離出來
  • 提升測試可讀性:測試案例本身能更專注於描述「意圖」,而不是「實作細節」。例如 MockHelpers.setupRepositorySuccess(...)when(...).thenAnswer(...) 更具可讀性
  • 確保一致性:統一了測試資料的生成和 Mock 的行為設定,避免不同測試產生不一致的狀況

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

Unit Test 最佳實踐

測試行為,而非實作細節

在單元測試中,我們應該專注於測試行為而非內部實作

❌ 不好的範例:測試內部狀態

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);
});

為什麼好的範例更強韌?

  • 耦合度低:測試不依賴內部實作,重構時測試依然有效
  • 真實性高:測試的是實際使用的公開 API
  • 維護性佳:內部實作改變時,測試不需要跟著修改

共享設置與其價值

使用 setUp()tearDown() 來管理測試的共同設置:

void main() {
  late ProviderContainer container;

  setUp(() => container = ProviderContainer());
  tearDown(() => container.dispose());

  test('test case', () {
    // 使用 container
  });
}

Clean Architecture 的測試優勢

在我們的 Clean Architecture 中,測試變得特別容易和有意義:

1. 領域層的純粹性

  • UseCase 只包含業務邏輯,沒有外部依賴
  • 可以輕鬆測試各種業務場景
  • 測試結果穩定,不依賴網路或資料庫

2. 依賴注入的威力

  • 可以輕鬆替換 Repository 實作
  • 可以模擬各種錯誤情況
  • 測試執行速度快

3. 狀態管理的可預測性

  • Notifier 的狀態變化有明確的規律
  • 可以驗證狀態轉換的正確性
  • 可以測試邊界條件

專案中的測試覆蓋範圍

我們的 Crew Up! 專案已經建立了全面的測試覆蓋,並完成了測試工具的現代化改進:

🎯 領域層測試

  • UseCase 測試GetActivitiesUseCaseCreateActivityUseCaseCreateActivityFromDataUseCaseIncrementViewCountUseCase
  • 實體測試Activity 實體的建立和驗證
  • 服務測試ActivityValidationServiceActivityErrorHandlerActivityInputSuggestionsServiceActivityAutoSaveService

🔧 資料層測試

  • Repository 測試ActivityRepositoryImpl 完整的 CRUD 操作測試
  • DataSource 測試ActivityFirebaseDataSourceActivityLocalDataSourceActivityMockDataSource
  • 工廠測試ActivityRepositoryFactory

🎨 表現層測試

  • Notifier 測試CreateActivityNotifier 狀態管理,包含進階邊界測試

💼 共用元件測試

  • Provider 測試DatePickerProvider

🛠️ 測試工具改進

  • TestConstants:統一管理魔術數字,提升維護性
  • TestHelpers:提供強大的結果驗證和資料建立工具
  • MockHelpers:統一的 Mock 物件設定和驗證工具
  • Mockito 註解系統:自動產生型別安全的 Mock 類別
  • 英文化測試描述:符合國際開發標準

測試覆蓋率報告產生:

# 產生覆蓋率報告
flutter test --coverage

# 查看覆蓋率檔案(會產生 coverage/lcov.info)

結語:建立可靠的測試基礎

今天,我們深入了解了專案中已建立的完整測試保護網。透過 Unit Test 和 AAA 模式,我們為 Clean Architecture 建立了堅實的品質保證基礎。

🏗️ 測試架構的完整性

  • UseCase 測試:驗證業務邏輯的正確性和錯誤處理
  • Notifier 測試:驗證 Riverpod 狀態管理的準確性
  • Repository 測試:確保資料存取層的正確性

🔧 專案實際成果

  • 完整的測試輔助工具:TestHelpersTestConstantsMockHelpers
  • 使用 mockito 的註解驅動測試策略
  • 遵循 AAA 模式的清晰測試結構,並採用英文描述提升國際標準

💡 Clean Architecture 的測試優勢

  • 依賴注入:輕鬆替換外部依賴進行隔離測試
  • 層次分離:各層可獨立測試,提高測試效率
  • 業務邏輯純淨:UseCase 無副作用,測試結果穩定可靠

有了這個堅實的測試基礎,我們能安心地進行功能擴充和重構。每個新功能的加入都能透過測試確保不會破壞既有邏輯。

下一步

明天,我們將探討 Day 12: Widget Test 與 Integration Test 實戰,深入學習如何測試 UI 組件的互動行為、使用者操作流程,以及多個組件協同工作的整合測試,進一步完善我們的測試保護網。

期待與您在 Day 12 相見!


📋 相關資源

📝 專案資訊

  • 專案名稱: Crew Up!
  • 開發日誌: Day 11 - Unit Test 與 AAA 模式:為 Clean Architecture 建立品質防護網
  • 文章日期: 2024-12-15
  • 技術棧: Flutter, Dart, Riverpod, Mockito, Clean Architecture, TestConstants, TestHelpers, MockHelpers

上一篇
Day 10 - 錯誤處理與日誌記錄:建立錯誤追蹤機制
系列文
我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言