希紋的部落格

APP範例:心情一句話日記(MoodDiary)

請AI生成這個教學的時候,還請它教我如何進行單元測試,所以這篇章就獨立出來。

專案設定

資料模型

這邊的資料儲存方式是採用UserDefault的方式儲存,記得是就教學難度這個是比較簡單的關係,就先從這邊入門概念。

一篇日記包含什麼? (DiaryEntry.swift)

DiaryEntry
├── id         唯一識別碼
├── date       日期
├── mood       心情 emoji(字串)
└── text       一句話

轉換成程式碼:

import Foundation

struct DiaryEntry: Identifiable, Codable {
    var id: UUID = UUID()
    var date: Date = Date()
    var mood: String
    var text: String
}

重點觀念:

關鍵字 意思
Identifiable 每筆資料有唯一 id,List 需要這個
Codable 可以轉成 JSON,方便存檔
UUID() 自動產生唯一識別碼
Date() 預設為現在時間

資料管理器 DiaryStore.swift

用於管理讀出來的資料

import Foundation

class DiaryStore: ObservableObject {
    @Published var entries: [DiaryEntry] = []
    
    func add(mood: String, text: String) {
        let entry = DiaryEntry(mood: mood, text: text)
        entries.insert(entry, at: 0) // 最新的放最前面
    }
    
    func delete(at offsets: IndexSet) {
        entries.remove(atOffsets: offsets)
    }
}

重點觀念:

關鍵字 意思
ObservableObject 這個 class 可以被 SwiftUI 觀察
@Published 當 entries 改變,所有用到它的畫面自動更新

這是 SwiftUI 最核心的概念:資料變了,UI 自動跟著變。

主畫面

基礎ContentView.swift

import SwiftUI

struct ContentView: View {
    @StateObject private var store = DiaryStore()
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(store.entries) { entry in
                    EntryRow(entry: entry)
                }
                .onDelete(perform: store.delete)
            }
            .navigationTitle("我的日記")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        // 之後放「新增」的動作
                    } label: {
                        Image(systemName: "plus")
                    }
                }
            }
        }
    }
}

重點觀念:

關鍵字 意思
@StateObject 建立並擁有這個資料物件,畫面關掉物件也跟著消失
NavigationStack 讓畫面可以有 push/pop 導航
List + ForEach 把陣列變成一個個列表項目
.onDelete 滑動刪除

EntryRow.swift

import SwiftUI

struct EntryRow: View {
    let entry: DiaryEntry
    
    var body: some View {
        HStack(spacing: 12) {
            Text(entry.mood)
                .font(.system(size: 36))
            
            VStack(alignment: .leading, spacing: 4) {
                Text(entry.text)
                    .font(.body)
                    .lineLimit(1)
                
                Text(entry.date, style: .date)
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
        }
        .padding(.vertical, 4)
    }
}

Text(entry.date, style: .date) 是 SwiftUI 內建的日期格式化,自動根據系統語言顯示。

假資料測試

DiaryStoreinit() 加假資料僅僅只是方便的做法,但是忘了刪除的話就會變成APP每次都會只有假資料存在。

DiaryStore.swift

init() {
	// 測試用假資料,等一下會刪掉
	entries = [
		DiaryEntry(mood: "😊", text: "今天天氣很好,心情不錯"),
		DiaryEntry(mood: "😴", text: "工作太累了,早點睡"),
		DiaryEntry(mood: "🎉", text: "專案終於上線了!"),
	]
}

新增日記(Sheet 彈出表單)

AddEntryView.swift

import SwiftUI

struct AddEntryView: View {
    @Environment(\.dismiss) private var dismiss
    
    let onSave: (String, String) -> Void
    
    @State private var selectedMood: String = "😊"
    @State private var text: String = ""
    
    let moods = ["😊", "😴", "😢", "😡", "🎉", "😰", "🥰", "😌"]
    
    var body: some View {
        NavigationStack {
            Form {
                Section("今天的心情") {
                    ScrollView(.horizontal, showsIndicators: false) {
                        HStack(spacing: 12) {
                            ForEach(moods, id: \.self) { mood in
                                Text(mood)
                                    .font(.system(size: 40))
                                    .padding(8)
                                    .background(
                                        selectedMood == mood
                                            ? Color.blue.opacity(0.2)
                                            : Color.clear
                                    )
                                    .clipShape(RoundedRectangle(cornerRadius: 10))
                                    .onTapGesture {
                                        selectedMood = mood
                                    }
                            }
                        }
                        .padding(.vertical, 4)
                    }
                }
                
                Section("今天一句話") {
                    TextField("發生了什麼?", text: $text, axis: .vertical)
                        .lineLimit(3...)
                }
            }
            .navigationTitle("新增日記")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button("取消") {
                        dismiss()
                    }
                }
                ToolbarItem(placement: .topBarTrailing) {
                    Button("儲存") {
                        onSave(selectedMood, text)
                        dismiss()
                    }
                    .disabled(text.trimmingCharacters(in: .whitespaces).isEmpty)
                }
            }
        }
    }
}

重點觀念:

關鍵字 意思
@Environment(\.dismiss) 取得關閉當前畫面的功能
@State 這個畫面私有的狀態,改變就重新渲染
$text 雙向綁定,TextField 改了 text 也跟著改
Form iOS 原生表單樣式
.disabled(...) 條件為真時按鈕變灰

dismiss是SwiftUI常用到的內建環境功能,只是要使用這個功能之前都要宣告後才能使用。

加了AddEntry的ContentView.swift

struct ContentView: View {
    @StateObject private var store = DiaryStore()
    @State private var showingAddSheet = false  // 新增這行
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(store.entries) { entry in
                    EntryRow(entry: entry)
                }
                .onDelete(perform: store.delete)
            }
            .navigationTitle("我的日記")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        showingAddSheet = true  // 改這裡
                    } label: {
                        Image(systemName: "plus")
                    }
                }
            }
            .sheet(isPresented: $showingAddSheet) {  // 新增這段
                AddEntryView { mood, text in
                    store.add(mood: mood, text: text)
                }
            }
        }
    }
}

現在點 + 號就會彈出新增表單,填完儲存後列表會立刻更新。

Detail 詳細頁

EntryDetailView.swift

import SwiftUI

struct EntryDetailView: View {
    let entry: DiaryEntry
    
    var body: some View {
        VStack(spacing: 32) {
            Text(entry.mood)
                .font(.system(size: 100))
            
            Text(entry.text)
                .font(.title3)
                .multilineTextAlignment(.center)
                .padding(.horizontal)
            
            Text(entry.date, style: .date)
                .font(.subheadline)
                .foregroundColor(.secondary)
            
            Spacer()
        }
        .padding(.top, 60)
        .navigationBarTitleDisplayMode(.inline)
    }
}

讓列表可以點進 Detail

更新 ContentView.swiftForEach 部分:

ForEach(store.entries) { entry in
    NavigationLink(destination: EntryDetailView(entry: entry)) {
        EntryRow(entry: entry)
    }
}

NavigationLink 會在列表右側自動加上箭頭,點進去就 push 到 Detail 頁。

資料持久化

使用 UserDefaults + Codable的DiaryStore.swift

import Foundation

class DiaryStore: ObservableObject {
    @Published var entries: [DiaryEntry] = [] {
        didSet { save() }  // 每次 entries 改變就自動存檔
    }
    
    private let key = "diary_entries"
    
    init() {
        load()
    }
    
    func add(mood: String, text: String) {
        let entry = DiaryEntry(mood: mood, text: text)
        entries.insert(entry, at: 0)
    }
    
    func delete(at offsets: IndexSet) {
        entries.remove(atOffsets: offsets)
    }
    
    private func save() {
        if let data = try? JSONEncoder().encode(entries) {
            UserDefaults.standard.set(data, forKey: key)
        }
    }
    
    private func load() {
        guard let data = UserDefaults.standard.data(forKey: key),
              let saved = try? JSONDecoder().decode([DiaryEntry].self, from: data)
        else { return }
        entries = saved
    }
}

重點觀念:

關鍵字 意思
didSet entries 被賦值後馬上執行
JSONEncoder 把 Codable struct 轉成 JSON Data
JSONDecoder 把 JSON Data 轉回 struct
UserDefaults iOS 的簡單鍵值存儲(適合小資料)

收尾優化

空列表提示

當沒有任何日記時,顯示引導文字。

SwiftUI 從 iOS 17 起內建了 ContentUnavailableView,專門用來處理「清單是空的」這種情況,三個參數依序是:標題、SF Symbol 圖示名稱、說明文字。

如果你的 Deployment Target 設在 iOS 16 以下,這行會報錯。確認方法:點左側 Project 檔 > Targets > MoodDiary > General > Minimum Deployments,確保是 iOS 17.0 以上。

加入ContentUnavailableView的ContentView.swift

struct ContentView: View {
    @StateObject private var store = DiaryStore()
    @State private var showingAddSheet = false
    
    //MARK: - view
    var body: some View {
        NavigationStack {
            List {
                if store.entries.isEmpty {
                    ContentUnavailableView(
                        "還沒有日記",
                        systemImage: "book.closed",
                        description: Text("點右上角 + 開始記錄今天")
                    )
                    .listRowBackground(Color.clear)
                } else {
                    ForEach(store.entries) { entry in
                        NavigationLink(destination: EntryDetailView(entry: entry)) {
                            EntryRow(entry: entry)
                        }
                    }
                    .onDelete(perform: store.delete)
                }
            }
            .navigationTitle("我的日記")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button { showingAddSheet = true } label: { Image(systemName: "plus") }
                }
            }
            .sheet(isPresented: $showingAddSheet) {
                AddEntryView { mood, text in store.add(mood: mood, text: text) }
            }
        }
    }
}

最終檔案結構

MoodDiary/
├── MoodDiaryApp.swift      (Xcode 自動產生,不需改)
├── ContentView.swift       主畫面
├── DiaryEntry.swift        資料模型
├── DiaryStore.swift        資料管理
├── EntryRow.swift          列表的每一行
├── AddEntryView.swift      新增日記的 Sheet
└── EntryDetailView.swift   詳細頁

完整程式碼彙整

以下是六個檔案的最終版本,可以直接對照你的 Xcode 專案逐一確認。


DiaryEntry.swift

import Foundation

struct DiaryEntry: Identifiable, Codable {
    var id: UUID = UUID()
    var date: Date = Date()
    var mood: String
    var text: String
}

DiaryStore.swift

import Foundation

class DiaryStore: ObservableObject {
    @Published var entries: [DiaryEntry] = [] {
        didSet { save() }
    }

    private let key = "diary_entries"

    init() {
        load()
    }

    func add(mood: String, text: String) {
        let entry = DiaryEntry(mood: mood, text: text)
        entries.insert(entry, at: 0)
    }

    func delete(at offsets: IndexSet) {
        entries.remove(atOffsets: offsets)
    }

    private func save() {
        if let data = try? JSONEncoder().encode(entries) {
            UserDefaults.standard.set(data, forKey: key)
        }
    }

    private func load() {
        guard let data = UserDefaults.standard.data(forKey: key),
              let saved = try? JSONDecoder().decode([DiaryEntry].self, from: data)
        else { return }
        entries = saved
    }
}

EntryRow.swift

import SwiftUI

struct EntryRow: View {
    let entry: DiaryEntry

    var body: some View {
        HStack(spacing: 12) {
            Text(entry.mood)
                .font(.system(size: 36))

            VStack(alignment: .leading, spacing: 4) {
                Text(entry.text)
                    .font(.body)
                    .lineLimit(1)

                Text(entry.date, style: .date)
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
        }
        .padding(.vertical, 4)
    }
}

AddEntryView.swift

import SwiftUI

struct AddEntryView: View {
    @Environment(\.dismiss) private var dismiss

    let onSave: (String, String) -> Void

    @State private var selectedMood: String = "😊"
    @State private var text: String = ""

    let moods = ["😊", "😴", "😢", "😡", "🎉", "😰", "🥰", "😌"]

    var body: some View {
        NavigationStack {
            Form {
                Section("今天的心情") {
                    ScrollView(.horizontal, showsIndicators: false) {
                        HStack(spacing: 12) {
                            ForEach(moods, id: \.self) { mood in
                                Text(mood)
                                    .font(.system(size: 40))
                                    .padding(8)
                                    .background(
                                        selectedMood == mood
                                            ? Color.blue.opacity(0.2)
                                            : Color.clear
                                    )
                                    .clipShape(RoundedRectangle(cornerRadius: 10))
                                    .onTapGesture {
                                        selectedMood = mood
                                    }
                            }
                        }
                        .padding(.vertical, 4)
                    }
                }

                Section("今天一句話") {
                    TextField("發生了什麼?", text: $text, axis: .vertical)
                        .lineLimit(3...)
                }
            }
            .navigationTitle("新增日記")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button("取消") { dismiss() }
                }
                ToolbarItem(placement: .topBarTrailing) {
                    Button("儲存") {
                        onSave(selectedMood, text)
                        dismiss()
                    }
                    .disabled(text.trimmingCharacters(in: .whitespaces).isEmpty)
                }
            }
        }
    }
}

EntryDetailView.swift

import SwiftUI

struct EntryDetailView: View {
    let entry: DiaryEntry

    var body: some View {
        VStack(spacing: 32) {
            Text(entry.mood)
                .font(.system(size: 100))

            Text(entry.text)
                .font(.title3)
                .multilineTextAlignment(.center)
                .padding(.horizontal)

            Text(entry.date, style: .date)
                .font(.subheadline)
                .foregroundColor(.secondary)

            Spacer()
        }
        .padding(.top, 60)
        .navigationBarTitleDisplayMode(.inline)
    }
}

ContentView.swift

import SwiftUI

struct ContentView: View {
    @StateObject private var store = DiaryStore()
    @State private var showingAddSheet = false

    var body: some View {
        NavigationStack {
            List {
                if store.entries.isEmpty {
                    ContentUnavailableView(
                        "還沒有日記",
                        systemImage: "book.closed",
                        description: Text("點右上角 + 開始記錄今天")
                    )
                    .listRowBackground(Color.clear)
                } else {
                    ForEach(store.entries) { entry in
                        NavigationLink(destination: EntryDetailView(entry: entry)) {
                            EntryRow(entry: entry)
                        }
                    }
                    .onDelete(perform: store.delete)
                }
            }
            .navigationTitle("我的日記")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        showingAddSheet = true
                    } label: {
                        Image(systemName: "plus")
                    }
                }
            }
            .sheet(isPresented: $showingAddSheet) {
                AddEntryView { mood, text in
                    store.add(mood: mood, text: text)
                }
            }
        }
    }
}

學到了什麼?

SwiftUI 概念 在哪裡用到
@State AddEntryView 的 selectedMood, text
@StateObject ContentView 持有 DiaryStore
@ObservedObject / ObservableObject DiaryStore 被觀察
@Published entries 陣列,改變自動觸發 UI 更新
@Environment(\.dismiss) AddEntryView 關閉自己
NavigationStack + NavigationLink List → Detail push 導航
.sheet(isPresented:) 彈出 AddEntryView
List + ForEach + .onDelete 可刪除的列表
Codable + UserDefaults 資料持久化

單元測試教學:以 MoodDiary 為例

使用 XCTest 框架,針對 MoodDiary 的商業邏輯進行測試。
學習重點:測試目標設定、XCTestCase、Arrange-Act-Assert、setUp/tearDown。


為什麼要寫測試?

寫測試不是為了「證明程式沒有 bug」,而是:


哪些東西值得測試?

MoodDiary 的結構

┌─────────────────────────────────────────┐
│              UI 層(View)               │  ← 不測(需要模擬器,成本高)
│  ContentView / AddEntryView / ...       │
└─────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────┐
│           商業邏輯層(Store)            │  ← ✅ 重點測試對象
│  DiaryStore:add / delete / save / load │
└─────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────┐
│            資料模型(Model)             │  ← ✅ 可測(Codable 行為)
│  DiaryEntry                             │
└─────────────────────────────────────────┘

單元測試的原則是:測邏輯,不測 UI。View 的外觀用眼睛看,邏輯用測試驗。


1. 建立測試目標

1-1 Xcode 新增測試 Target

  1. 頂部選單 File > New > Target
  2. Unit Testing Bundle
  3. Product Name 填 MoodDiaryTests
  4. 確認 Target to be Tested 選的是 MoodDiary
  5. 點 Finish

Xcode 會自動建立一個 MoodDiaryTests 資料夾,裡面有一個範例檔案。

1-2 確認設定正確

打開 MoodDiaryTests.swift,應該長這樣:

import Testing

struct MoodDiaryTests {
    @Test func example() async throws {
        // 範例測試
    }
}

注意:Xcode 16 預設用新的 Swift Testing 框架(import Testing)。
本教學使用更普及、資料更多的 XCTest 框架。
請把這個檔案的內容全部換成下面這樣:

import XCTest

final class MoodDiaryTests: XCTestCase {
    func testExample() {
        XCTAssert(1 + 1 == 2)
    }
}

Cmd+U 跑測試,左側看到綠色勾勾就代表環境設定成功。


2. XCTest 基本結構

2-1 一個測試的組成

┌─────────────────────────────────────────────────┐
│  final class MoodDiaryTests: XCTestCase {       │
│                                                 │
│    func setUp() { ... }    ← 每個測試前執行      │
│    func tearDown() { ... } ← 每個測試後執行      │
│                                                 │
│    func testXxx() { ... }  ← 測試本體           │
│    func testYyy() { ... }                       │
│  }                                              │
└─────────────────────────────────────────────────┘

測試函式必須以 test 開頭,XCTest 才會自動辨識並執行它。

2-2 Arrange-Act-Assert(AAA 模式)

每個測試函式都遵守這個三段結構:

┌─────────────────────────────────────────────────┐
│  func testSomething() {                         │
│                                                 │
│    // Arrange(準備)                            │
│    // 建立測試需要的物件和初始狀態                 │
│                                                 │
│    // Act(執行)                                │
│    // 呼叫你要測試的那一個函式                    │
│                                                 │
│    // Assert(驗證)                             │
│    // 確認結果是否符合預期                        │
│  }                                              │
└─────────────────────────────────────────────────┘

2-3 常用的 XCTAssert 系列

函式 用途
XCTAssertEqual(a, b) a 和 b 相等
XCTAssertNotEqual(a, b) a 和 b 不相等
XCTAssertTrue(condition) 條件為真
XCTAssertFalse(condition) 條件為假
XCTAssertNil(value) 值為 nil
XCTAssertNotNil(value) 值不為 nil

3. 測試 DiaryEntry(資料模型)

3-1 建立測試檔案

MoodDiaryTests 資料夾建立 DiaryEntryTests.swift

import XCTest
@testable import MoodDiary

final class DiaryEntryTests: XCTestCase {

}

@testable import MoodDiary 讓測試可以存取 App 裡的 internal 型別,不加這行會看不到 DiaryEntry

3-2 測試 Codable(encode → decode 要一致)

final class DiaryEntryTests: XCTestCase {

    func testCodableRoundtrip() throws {
        // Arrange
        let original = DiaryEntry(mood: "😊", text: "今天很好")

        // Act
        let data = try JSONEncoder().encode(original)
        let decoded = try JSONDecoder().decode(DiaryEntry.self, from: data)

        // Assert
        XCTAssertEqual(decoded.id, original.id)
        XCTAssertEqual(decoded.mood, original.mood)
        XCTAssertEqual(decoded.text, original.text)
    }

    func testDefaultValues() {
        // Arrange & Act
        let entry = DiaryEntry(mood: "😴", text: "隨便")

        // Assert
        XCTAssertNotNil(entry.id)
        XCTAssertNotNil(entry.date)
    }
}

為什麼測 Codable?

因為 DiaryStore 靠 Codable 把資料存到 UserDefaults。如果 DiaryEntry 的欄位改了卻沒更新 Codable 實作,存進去的資料讀出來就會錯。這個測試確保 encode/decode 是對稱的。


4. 測試 DiaryStore(商業邏輯)

4-1 一個問題:UserDefaults 的副作用

DiaryStore 每次 entries 改變就會自動寫入 UserDefaults。如果測試寫進去的資料沒有清掉,下一次跑測試時讀到的初始狀態就會不乾淨,測試結果會變得不穩定。

解決方式:在每個測試前後,清掉我們用到的 UserDefaults key。

┌──────────────────────────────────────────────────────┐
│  測試執行順序                                         │
│                                                      │
│  setUp()    → 清掉 "diary_entries" 這個 key          │
│      │                                               │
│      ▼                                               │
│  testXxx()  → 執行測試邏輯                           │
│      │                                               │
│      ▼                                               │
│  tearDown() → 再清一次,確保不污染下一個測試           │
└──────────────────────────────────────────────────────┘

4-2 建立 DiaryStoreTests.swift

import XCTest
@testable import MoodDiary

final class DiaryStoreTests: XCTestCase {

    let testKey = "diary_entries"

    override func setUp() {
        super.setUp()
        UserDefaults.standard.removeObject(forKey: testKey)
    }

    override func tearDown() {
        super.tearDown()
        UserDefaults.standard.removeObject(forKey: testKey)
    }
}

override 是因為 setUp() / tearDown() 是從 XCTestCase 繼承來的,我們要覆寫它加上自己的清除動作。

4-3 測試 add()

func testAddInsertsAtFront() {
    // Arrange
    let store = DiaryStore()

    // Act
    store.add(mood: "😊", text: "第一筆")
    store.add(mood: "😴", text: "第二筆")

    // Assert
    XCTAssertEqual(store.entries.count, 2)
    XCTAssertEqual(store.entries[0].text, "第二筆") // 最新的在最前面
    XCTAssertEqual(store.entries[1].text, "第一筆")
}

func testAddSetsCorrectMood() {
    // Arrange
    let store = DiaryStore()

    // Act
    store.add(mood: "🎉", text: "慶祝")

    // Assert
    XCTAssertEqual(store.entries.first?.mood, "🎉")
}

4-4 測試 delete()

func testDeleteRemovesEntry() {
    // Arrange
    let store = DiaryStore()
    store.add(mood: "😊", text: "要刪掉的")
    store.add(mood: "😴", text: "要留下的")

    // Act
    store.delete(at: IndexSet(integer: 0)) // 刪掉 index 0(最新那筆)

    // Assert
    XCTAssertEqual(store.entries.count, 1)
    XCTAssertEqual(store.entries[0].text, "要留下的")
}

func testDeleteEmptyDoesNotCrash() {
    // Arrange
    let store = DiaryStore()

    // 對空陣列刪除,不應該 crash
    // 因為 IndexSet(integer: 0) 對空陣列來說是無效的 index
    // 這個測試確認不會發生 index out of range

    // Act & Assert(沒有 crash 就算通過)
    // 注意:這個情況在正常 UI 流程不會發生,但邊界測試是好習慣
}

4-5 測試持久化(save → load)

func testPersistenceAfterReload() {
    // Arrange
    let store = DiaryStore()
    store.add(mood: "😊", text: "存進去的資料")

    // Act:建立一個新的 DiaryStore,模擬 App 重開後重新讀取
    let reloadedStore = DiaryStore()

    // Assert
    XCTAssertEqual(reloadedStore.entries.count, 1)
    XCTAssertEqual(reloadedStore.entries.first?.text, "存進去的資料")
    XCTAssertEqual(reloadedStore.entries.first?.mood, "😊")
}

func testEmptyOnFirstLaunch() {
    // setUp 已清掉 UserDefaults,所以這是「全新安裝」的狀態
    let store = DiaryStore()
    XCTAssertTrue(store.entries.isEmpty)
}

5. 跑測試 + 解讀結果

5-1 執行測試的方式

操作 快捷鍵 / 方法
跑全部測試 Cmd + U
跑單一測試函式 點函式旁邊的菱形圖示
跑單一測試檔案 點檔案旁邊的菱形圖示
重跑上次失敗的測試 Cmd + Ctrl + Option + G

5-2 看懂測試結果

測試通過:
✅ testAddInsertsAtFront     → 綠色勾勾

測試失敗:
❌ testPersistenceAfterReload
   XCTAssertEqual failed: ("0") is not equal to ("1")
   DiaryStoreTests.swift:52

失敗訊息會告訴你:

  1. 哪個 Assert 失敗了
  2. 實際值("0")和預期值("1")各是什麼
  3. 在哪一行

5-3 測試數量的心理建設

不用追求 100% 覆蓋率。對 MoodDiary 來說,測好這幾件事就夠了:

✅ DiaryEntry Codable 來回轉換不失真
✅ add() 新增到最前面
✅ add() mood 和 text 正確寫入
✅ delete() 正確移除
✅ 關掉重開資料還在
✅ 全新安裝時 entries 是空的

六個測試,涵蓋了最核心的商業邏輯。


6. 最終測試檔案結構

MoodDiaryTests/
├── DiaryEntryTests.swift    ← 測資料模型的 Codable
└── DiaryStoreTests.swift    ← 測商業邏輯和持久化

DiaryEntryTests.swift(完整版)

import XCTest
@testable import MoodDiary

final class DiaryEntryTests: XCTestCase {

    func testCodableRoundtrip() throws {
        let original = DiaryEntry(mood: "😊", text: "今天很好")

        let data = try JSONEncoder().encode(original)
        let decoded = try JSONDecoder().decode(DiaryEntry.self, from: data)

        XCTAssertEqual(decoded.id, original.id)
        XCTAssertEqual(decoded.mood, original.mood)
        XCTAssertEqual(decoded.text, original.text)
    }

    func testDefaultValues() {
        let entry = DiaryEntry(mood: "😴", text: "隨便")

        XCTAssertNotNil(entry.id)
        XCTAssertNotNil(entry.date)
    }
}

DiaryStoreTests.swift(完整版)

import XCTest
@testable import MoodDiary

final class DiaryStoreTests: XCTestCase {

    let testKey = "diary_entries"

    override func setUp() {
        super.setUp()
        UserDefaults.standard.removeObject(forKey: testKey)
    }

    override func tearDown() {
        super.tearDown()
        UserDefaults.standard.removeObject(forKey: testKey)
    }

    func testEmptyOnFirstLaunch() {
        let store = DiaryStore()
        XCTAssertTrue(store.entries.isEmpty)
    }

    func testAddInsertsAtFront() {
        let store = DiaryStore()

        store.add(mood: "😊", text: "第一筆")
        store.add(mood: "😴", text: "第二筆")

        XCTAssertEqual(store.entries.count, 2)
        XCTAssertEqual(store.entries[0].text, "第二筆")
        XCTAssertEqual(store.entries[1].text, "第一筆")
    }

    func testAddSetsCorrectMood() {
        let store = DiaryStore()

        store.add(mood: "🎉", text: "慶祝")

        XCTAssertEqual(store.entries.first?.mood, "🎉")
    }

    func testDeleteRemovesEntry() {
        let store = DiaryStore()
        store.add(mood: "😊", text: "要刪掉的")
        store.add(mood: "😴", text: "要留下的")

        store.delete(at: IndexSet(integer: 0))

        XCTAssertEqual(store.entries.count, 1)
        XCTAssertEqual(store.entries[0].text, "要留下的")
    }

    func testPersistenceAfterReload() {
        let store = DiaryStore()
        store.add(mood: "😊", text: "存進去的資料")

        let reloadedStore = DiaryStore()

        XCTAssertEqual(reloadedStore.entries.count, 1)
        XCTAssertEqual(reloadedStore.entries.first?.text, "存進去的資料")
        XCTAssertEqual(reloadedStore.entries.first?.mood, "😊")
    }
}

學到了什麼?

概念 說明
XCTestCase 所有測試類別的基底
setUp() / tearDown() 每個測試前後的準備與清理
@testable import 讓測試可以看到 App 的 internal 型別
Arrange-Act-Assert 每個測試函式的標準三段結構
XCTAssertEqual 驗證預期結果的工具函式
測試副作用隔離 用 setUp/tearDown 清 UserDefaults,確保每次測試環境乾淨

總結

大部分的內容都直接拷貝過去,對於單元測試的該測試哪些東西其實就蠻講究經驗的。在撰寫測試的部分多少都會預期出輸入與輸出的結果,設計哪些應該是「正確」或「錯誤」。

而這次的這個範例設計著重要「一句話」身上,所以採用UserDefaults是個合理的考量,不過如果這個APP要長期使用的話,或多或少會建議改做成SwiftData儲存會比較好,但這部分就被歸類為「挑戰功課」。

#swift