iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0
佛心分享-SideProject30

AI-Driven Development 實戰篇:30 天 Side Project 開發全紀錄系列 第 23

Day23 - MoodStamp Day 5 - TDD 開發起步:第一個測試

  • 分享至 

  • xImage
  •  

經過前四天的準備工作(PRD、User Story、AC、UI/UX),今天我們終於要開始寫 code 了!但不是直接寫功能,而是用 TDD(Test-Driven Development) 的方式:先寫測試,再寫實作

為什麼要用 TDD?

很多人(包括以前的我)覺得 TDD 很麻煩:「為什麼要先寫測試?直接寫功能不是更快嗎?」
但經歷過幾次「改A壞B」的痛苦後,我才理解 TDD 的價值:
傳統開發:功能 → 測試 → 發現bug → 修bug → 又壞了別的地方
TDD 開發:測試 → 功能 → 通過測試 → 有保護網可以放心重構

TDD 的核心是 Red-Green-Refactor 循環:

🔴 Red(紅燈):寫一個失敗的測試
    ↓
🟢 Green(綠燈):寫最少的code讓測試通過  
    ↓
♻️ Refactor(重構):優化code但保持測試通過
    ↓
   重複循環

建立 Flutter 專案

初始化專案

# 建立 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)

第一個測試:MoodEntry Model

根據 Day 3 的 User Story,我們需要一個 MoodEntry 模型來儲存心情記錄。

Red Phase:寫失敗的測試

建立測試檔案

// 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 類別。

Green Phase:寫最少的實作

建立 Model 檔案

// 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!

太好了!測試通過了!

Refactor Phase:優化程式碼

現在程式碼可以動了,但可以更好。讓我們加入一些改進:

// 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 循環後,我的感受:

好處

  1. 更清晰的需求:寫測試時必須想清楚「這個類別要做什麼」
  2. 更好的設計:為了讓測試好寫,自然會設計出更簡潔的API
  3. 安全的重構:有測試保護,可以放心優化程式碼
  4. 即時回饋:每次改code都能立刻知道有沒有壞掉

挑戰

  1. 初期比較慢:寫測試確實要花時間
  2. 需要練習:剛開始不知道該測什麼
  3. 心態轉換:要克服「先寫功能」的習慣

但長期來看,TDD 絕對值得


上一篇
Day22 - MoodStamp Day 4 - UI/UX Design:讓 AI 當你的設計師
下一篇
Day 6 - MoodStamp 專案回顧:6 天 AI-DLC Sprint 的真實心得
系列文
AI-Driven Development 實戰篇:30 天 Side Project 開發全紀錄24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言