APP範例:心情一句話日記(MoodDiary)
請AI生成這個教學的時候,還請它教我如何進行單元測試,所以這篇章就獨立出來。
專案設定
- Product Name:
MoodDiary - Interface:SwiftUI
- Language:Swift
- 取消勾選 Core Data(我們自己做)
資料模型
這邊的資料儲存方式是採用
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 內建的日期格式化,自動根據系統語言顯示。
假資料測試
在 DiaryStore 的 init() 加假資料僅僅只是方便的做法,但是忘了刪除的話就會變成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.swift 的 ForEach 部分:
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
- 頂部選單
File > New > Target - 選 Unit Testing Bundle
- Product Name 填
MoodDiaryTests - 確認
Target to be Tested選的是MoodDiary - 點 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
失敗訊息會告訴你:
- 哪個 Assert 失敗了
- 實際值(
"0")和預期值("1")各是什麼 - 在哪一行
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儲存會比較好,但這部分就被歸類為「挑戰功課」。