昨天先用假資料跑流程,今天重點放在撰寫單元測試,確保資料能正確取得,也能捕捉各種錯誤。程式不只是能跑,還能透過測試驗證不同情境,未來換 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 相關設定集中在 ApiConfig 中,包括 Base URL 和各個 Endpoint。這樣做的好處是:
class ApiConfig {
// API 伺服器 Base URL
static const String baseUrl = 'https://trip-api-server';
// 統一管理各個 Endpoint
static const String getAITrip = '/generateTrip';
}
這個 ApiTripRepository
實作了 TripRepository
介面,負責與後端 API 互動並取得 Trip 資料。
設計要點:
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');
}
}
}
這裡我們會用 http_mock_adapter
搭配 Dio,模擬 API 回應,讓測試完全脫離真實網路環境。
在 pubspec.yaml
中加入:
dev_dependencies:
flutter_test:
sdk: flutter
http_mock_adapter: ^0.6.1
安裝後可以:
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>()),
);
});
});
});
}
測試重點:
不用太複雜,重點是呼叫 → 解析 → 回傳/錯誤處理。
# 執行所有測試
flutter test
# 執行單一檔案
flutter test test/repositories/api_trip_repository_test.dart
結果會顯示每個 test()
是否通過,以及錯誤訊息。
請求 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 了!