經過前四天的準備工作(PRD、User Story、AC、UI/UX),今天我們終於要開始寫 code 了!但不是直接寫功能,而是用 TDD(Test-Driven Development) 的方式:先寫測試,再寫實作。
很多人(包括以前的我)覺得 TDD 很麻煩:「為什麼要先寫測試?直接寫功能不是更快嗎?」
但經歷過幾次「改A壞B」的痛苦後,我才理解 TDD 的價值:
傳統開發:功能 → 測試 → 發現bug → 修bug → 又壞了別的地方
TDD 開發:測試 → 功能 → 通過測試 → 有保護網可以放心重構
TDD 的核心是 Red-Green-Refactor 循環:
🔴 Red(紅燈):寫一個失敗的測試
↓
🟢 Green(綠燈):寫最少的code讓測試通過
↓
♻️ Refactor(重構):優化code但保持測試通過
↓
重複循環
# 建立 Flutter 專案
flutter create mood_stamp
cd mood_stamp
# 安裝相依套件
flutter pub add provider hive hive_flutter
flutter pub add table_calendar intl
flutter pub add http
# 開發相依(測試用)
flutter pub add --dev flutter_test
flutter pub add --dev mockito build_runner
按照昨天的設計,建立清晰的資料夾結構:
mood_stamp/
├── lib/
│ ├── models/ # 資料模型
│ ├── repositories/ # 資料存取層
│ ├── services/ # 業務邏輯層
│ ├── widgets/ # UI 組件
│ ├── screens/ # 頁面
│ ├── theme/ # Design System
│ └── main.dart
├── test/ # 測試檔案
│ ├── models/
│ ├── repositories/
│ └── services/
└── docs/ # 文件
├── [PRD.md](http://PRD.md)
├── [user-stories.md](http://user-stories.md)
└── [ui-design.md](http://ui-design.md)
根據 Day 3 的 User Story,我們需要一個 MoodEntry
模型來儲存心情記錄。
// test/models/mood_entry_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mood_stamp/models/mood_entry.dart';
void main() {
group('MoodEntry Model Tests', () {
test('should create MoodEntry with required fields', () {
// Arrange
final now = [DateTime.now](http://DateTime.now)();
// Act
final entry = MoodEntry(
id: 'test-id',
content: '今天完成報告,很有成就感',
stamp: '😊',
date: now,
);
// Assert
expect([entry.id](http://entry.id), 'test-id');
expect(entry.content, '今天完成報告,很有成就感');
expect(entry.stamp, '😊');
expect([entry.date](http://entry.date), now);
expect(entry.aiResponse, null); // 初始沒有AI回應
});
test('should create MoodEntry with AI response', () {
// Arrange & Act
final entry = MoodEntry(
id: 'test-id',
content: '今天很棒',
stamp: '😊',
date: [DateTime.now](http://DateTime.now)(),
aiResponse: '繼續保持這份好心情!',
);
// Assert
expect(entry.aiResponse, '繼續保持這份好心情!');
});
test('should validate content length', () {
// Arrange
final longContent = 'a' * 201; // 超過200字
// Act & Assert
expect(
() => MoodEntry(
id: 'test-id',
content: longContent,
stamp: '😊',
date: [DateTime.now](http://DateTime.now)(),
),
throwsA(isA<ArgumentError>()),
);
});
test('should not allow empty content', () {
// Act & Assert
expect(
() => MoodEntry(
id: 'test-id',
content: '',
stamp: '😊',
date: [DateTime.now](http://DateTime.now)(),
),
throwsA(isA<ArgumentError>()),
);
});
test('should convert to JSON', () {
// Arrange
final entry = MoodEntry(
id: 'test-id',
content: '測試內容',
stamp: '😊',
date: DateTime(2025, 10, 7),
aiResponse: 'AI回應',
);
// Act
final json = entry.toJson();
// Assert
expect(json['id'], 'test-id');
expect(json['content'], '測試內容');
expect(json['stamp'], '😊');
expect(json['date'], '2025-10-07T00:00:00.000');
expect(json['aiResponse'], 'AI回應');
});
test('should create from JSON', () {
// Arrange
final json = {
'id': 'test-id',
'content': '測試內容',
'stamp': '😊',
'date': '2025-10-07T00:00:00.000',
'aiResponse': 'AI回應',
};
// Act
final entry = MoodEntry.fromJson(json);
// Assert
expect([entry.id](http://entry.id), 'test-id');
expect(entry.content, '測試內容');
expect(entry.stamp, '😊');
expect([entry.date](http://entry.date), DateTime(2025, 10, 7));
expect(entry.aiResponse, 'AI回應');
});
});
}
flutter test test/models/mood_entry_test.dart
結果:
🔴 Error: 'MoodEntry' isn't a type.
這正是我們要的!測試失敗了,因為我們還沒寫 MoodEntry
類別。
// lib/models/mood_entry.dart
class MoodEntry {
final String id;
final String content;
final String stamp;
final DateTime date;
final String? aiResponse;
MoodEntry({
required [this.id](http://this.id),
required this.content,
required this.stamp,
required [this.date](http://this.date),
this.aiResponse,
}) {
// 驗證內容長度
if (content.isEmpty) {
throw ArgumentError('內容不能為空');
}
if (content.length > 200) {
throw ArgumentError('內容不能超過200字');
}
}
// 轉換為 JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'content': content,
'stamp': stamp,
'date': date.toIso8601String(),
'aiResponse': aiResponse,
};
}
// 從 JSON 建立
factory MoodEntry.fromJson(Map<String, dynamic> json) {
return MoodEntry(
id: json['id'] as String,
content: json['content'] as String,
stamp: json['stamp'] as String,
date: DateTime.parse(json['date'] as String),
aiResponse: json['aiResponse'] as String?,
);
}
}
flutter test test/models/mood_entry_test.dart
結果:
🟢 All tests passed!
太好了!測試通過了!
現在程式碼可以動了,但可以更好。讓我們加入一些改進:
// lib/models/mood_entry.dart(優化版)
import 'package:uuid/uuid.dart';
class MoodEntry {
final String id;
final String content;
final String stamp;
final DateTime date;
final String? aiResponse;
// 常數定義
static const int maxContentLength = 200;
static const int minContentLength = 1;
MoodEntry({
String? id, // 改成可選,自動生成
required this.content,
required this.stamp,
DateTime? date, // 改成可選,預設今天
this.aiResponse,
}) : id = id ?? const Uuid().v4(),
date = date ?? [DateTime.now](http://DateTime.now)() {
_validateContent();
}
// 私有方法:驗證內容
void _validateContent() {
if (content.trim().isEmpty) {
throw ArgumentError('內容不能為空');
}
if (content.length > maxContentLength) {
throw ArgumentError('內容不能超過 $maxContentLength 字');
}
}
// 複製並修改
MoodEntry copyWith({
String? id,
String? content,
String? stamp,
DateTime? date,
String? aiResponse,
}) {
return MoodEntry(
id: id ?? [this.id](http://this.id),
content: content ?? this.content,
stamp: stamp ?? this.stamp,
date: date ?? [this.date](http://this.date),
aiResponse: aiResponse ?? this.aiResponse,
);
}
// 轉換為 JSON
Map<String, dynamic> toJson() => {
'id': id,
'content': content,
'stamp': stamp,
'date': date.toIso8601String(),
if (aiResponse != null) 'aiResponse': aiResponse,
};
// 從 JSON 建立
factory MoodEntry.fromJson(Map<String, dynamic> json) => MoodEntry(
id: json['id'] as String,
content: json['content'] as String,
stamp: json['stamp'] as String,
date: DateTime.parse(json['date'] as String),
aiResponse: json['aiResponse'] as String?,
);
@override
String toString() => 'MoodEntry(id: $id, date: $date, stamp: $stamp)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MoodEntry &&
runtimeType == other.runtimeType &&
id == [other.id](http://other.id);
@override
int get hashCode => id.hashCode;
}
test('should auto-generate ID if not provided', () {
final entry = MoodEntry(
content: '測試',
stamp: '😊',
);
expect([entry.id](http://entry.id), isNotEmpty);
expect([entry.id](http://entry.id).length, 36); // UUID v4 長度
});
test('should use current date if not provided', () {
final before = [DateTime.now](http://DateTime.now)();
final entry = MoodEntry(
content: '測試',
stamp: '😊',
);
final after = [DateTime.now](http://DateTime.now)();
expect([entry.date](http://entry.date).isAfter(before) || [entry.date](http://entry.date).isAtSameMomentAs(before), true);
expect([entry.date](http://entry.date).isBefore(after) || [entry.date](http://entry.date).isAtSameMomentAs(after), true);
});
test('copyWith should create new instance with updated values', () {
final original = MoodEntry(
id: 'test-id',
content: '原始內容',
stamp: '😊',
);
final updated = original.copyWith(
content: '新內容',
aiResponse: 'AI回應',
);
expect([updated.id](http://updated.id), [original.id](http://original.id));
expect(updated.content, '新內容');
expect(updated.aiResponse, 'AI回應');
expect(updated.stamp, original.stamp);
});
flutter test test/models/mood_entry_test.dart
🟢 9 tests passed!
完成第一個 TDD 循環後,我的感受:
但長期來看,TDD 絕對值得!