iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0
Mobile Development

《30 天 Flutter:跨平台 AI 行程規劃 App》系列 第 15

Day 15 - 可以和 AI 說話了!從假資料到真實情境完整演練

  • 分享至 

  • xImage
  •  

昨天先用假資料跑流程,今天重點放在撰寫單元測試,確保資料能正確取得,也能捕捉各種錯誤。程式不只是能跑,還能透過測試驗證不同情境,未來換 API 或調整資料結構時,也能快速確認沒問題。


檔案結構

建議在專案中建立一個 test 資料夾,把測試檔案依功能拆開:

lib/
 ├─ config/
 │   └─ api_config.dart
 ├─ repositories/
 │   └─ trip_repository.dart
test/
 ├─ repositories/
 │   └─ api_trip_repository_test.dart # 放使用 `http_mock_adapter` 模擬 API 回應的測試
 └─ ...其他測試

這樣可以清楚知道每個測試檔案測試什麼,也方便 CI/CD 自動化執行。

串接 API

為了讓 API 設定統一管理、方便維護,我將 API 相關設定集中在 ApiConfig 中,包括 Base URL 和各個 Endpoint。這樣做的好處是:

  • 任何 API 位置或路徑改動,只需要修改一處即可。
  • Repository 層只需引用設定,不會硬編碼 URL,程式更乾淨、可維護。
  • 減少重複程式碼,也方便單元測試時模擬不同 API 回應。

API 設定

class ApiConfig {
  // API 伺服器 Base URL
  static const String baseUrl = 'https://trip-api-server';

  // 統一管理各個 Endpoint
  static const String getAITrip = '/generateTrip';
}

Repository 串接

這個 ApiTripRepository 實作了 TripRepository 介面,負責與後端 API 互動並取得 Trip 資料。
設計要點:

  • Dio 實例注入:方便在測試時傳入 mock 的 Dio。
  • Base URL 統一設定:減少重複設定,容易管理。
  • 錯誤處理:包含非 200 狀態碼及網路/其他例外。
class ApiTripRepository implements TripRepository {
  final Dio dio;

  ApiTripRepository({required this.dio}) {
    // 設定 Base URL,統一管理 API 位置
    dio.options.baseUrl = ApiConfig.baseUrl;
  }

  @override
  Future<Trip> getAITrip({
    required List<String> locations,
    required String startDate,
    required int days,
    required String language,
  }) async {
    try {
      final response = await dio.post(
        ApiConfig.getAITrip, // 從 ApiConfig 取得 endpoint
        data: {
          'locations': locations,
          'startDate': startDate,
          'days': days,
          'language': language,
        },
      );

      if (response.statusCode == 200) {
        return Trip.fromJson(response.data);
      } else {
        throw Exception('Failed to load trip from API');
      }
    } catch (e) {
      // Handle Dio errors or other exceptions
      throw Exception('Failed to load trip: $e');
    }
  }
}

API 測試

這裡我們會用 http_mock_adapter 搭配 Dio,模擬 API 回應,讓測試完全脫離真實網路環境。

安裝與設定

pubspec.yaml 中加入:

dev_dependencies:
  flutter_test:
    sdk: flutter
  http_mock_adapter: ^0.6.1

安裝後可以:

  • 模擬成功、404、500、timeout 等各種情境
  • 測試完全獨立於真實網路
  • 輕量易整合,CI/CD 測試穩定

測試程式碼

void main() {
  late Dio dio;
  late DioAdapter dioAdapter;
  late ApiTripRepository repository;

  setUp(() {
    dio = Dio();
    dio.options.baseUrl = ApiConfig.baseUrl;
    dioAdapter = DioAdapter(dio: dio);
    repository = ApiTripRepository(dio: dio);
  });

  group('getAITrip', () {
    group('成功情境', () {
      test('應成功回傳 Trip', () async {
        dioAdapter.onPost(
          '/query',
          (server) => server.reply(200, {
            "title": "台北一日遊",
            "activities": [
              {
                "id": "act1",
                "type": "sightseeing",
                "location": "故宮",
                "startTime": "2025-09-10T09:00:00.000Z",
                "endTime": "2025-09-10T11:00:00.000Z",
                "isConfirmed": false,
                "childActivities": [
                  {"id": "child1", "name": "看翠玉白菜"},
                ],
              },
            ],
          }),
          data: {
            "locations": ["東京"],
            "startDate": "2025-09-10",
            "days": 2,
            "language": "zh",
          },
        );

        final trip = await repository.getAITrip(
          locations: ['東京'],
          startDate: '2025-09-10',
          days: 2,
          language: 'zh',
        );

        expect(trip.title, "台北一日遊");
        expect(trip.activities.length, 1);
        expect(trip.activities.first.location, "故宮");
        expect(trip.activities.first.childActivities.first.name, "看翠玉白菜");
      });
    });

    group('失敗情境', () {
      test('伺服器回傳 500 應丟 Exception', () async {
        dioAdapter.onPost(
          ApiConfig.getAITrip,
          (request) => request.reply(500, {'error': 'Internal Server Error'}),
        );

        expect(
          () async => await repository.getAITrip(
            locations: ['東京'],
            startDate: '2025-09-10',
            days: 2,
            language: 'zh',
          ),
          throwsA(isA<Exception>()),
        );
      });

      test('網路錯誤應丟 Exception', () async {
        dioAdapter.onPost(
          ApiConfig.getAITrip,
          (server) => server.throws(
            0,
            DioException(
              requestOptions: RequestOptions(path: ApiConfig.getAITrip),
              type: DioExceptionType.connectionError,
            ),
          ),
        );

        expect(
          () async => await repository.getAITrip(
            locations: ['東京'],
            startDate: '2025-09-10',
            days: 2,
            language: 'zh',
          ),
          throwsA(isA<Exception>()),
        );
      });
    });
  });
}

測試重點:

  1. 成功取得資料:確保呼叫 API 回傳的物件正確。
  2. 錯誤處理:模擬 400/500、超時等,確認丟出合適 Exception。
  3. Request 正確性(可選):驗證送出的 body 是否符合規格。

不用太複雜,重點是呼叫 → 解析 → 回傳/錯誤處理。


執行測試

# 執行所有測試
flutter test

# 執行單一檔案
flutter test test/repositories/api_trip_repository_test.dart

結果會顯示每個 test() 是否通過,以及錯誤訊息。


Gemini Code Assist Prompt

請求 Gemini Code Assist 幫生成測試程式碼:

請幫我生成 Flutter 單元測試程式碼,需求如下:

1. 測試範圍:
- Repository:
  - ApiTripRepository:使用 http_mock_adapter 模擬 API 回應
    - 測試成功回應
    - 模擬 API 500 或網路錯誤,檢查是否拋出 Exception

2. 測試結構:
test/
 └─ repositories/api_trip_repository_test.dart

3. 其他要求:
- 使用 flutter_test 套件
- 包含完整 import
- 提供成功與錯誤情境
- 測試程式碼可直接執行 flutter test
- 保留 enum、Model 與 Repository 的相容性

今天接完 API 了!


上一篇
Day 14 - 從 Prompt 到程式碼:API 串接前的測試與準備
下一篇
Day 16 - 選擇 Flutter 本地儲存:為何我擁抱 Drift
系列文
《30 天 Flutter:跨平台 AI 行程規劃 App》19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言