🍋 들어가며
iOS 앱을 만들다 보면
“내가 만든 Core Data, ViewModel 코드가 진짜 제대로 동작하는지”
눈으로만 확인하기 어렵죠.
이때 바로 사용하는 게 XCTest입니다.
Xcode에 기본 내장된 공식 단위 테스트 프레임워크로,
UI 없이 코드만으로 기능을 자동으로 검증할 수 있습니다.
🧠 테스트를 위한 기본 개념 정리
| 개념 | 설명 |
| Unit Test (단위 테스트) | 특정 클래스나 함수 하나가 “제대로 동작하는지” 확인하는 테스트 |
| XCTestCase | 테스트를 작성할 수 있는 클래스 (테스트의 기본 단위) |
| 테스트 메서드 이름 규칙 | 반드시 test로 시작해야 Xcode가 인식합니다 (func testSaveDiary()) |
| XCTAssert | 실제 값과 기대 값을 비교하는 함수 (XCTAssertEqual, XCTAssertTrue, 등) |
| 비동기 테스트 | async/await 또는 XCTestExpectation을 이용해 비동기 결과를 기다릴 수 있음 |
✅ XCTAssertTrue(_:)
👉 조건이 참(true) 이면 테스트 통과
let isEmpty = list.isEmpty
XCTAssertTrue(isEmpty)
📘 설명
- list.isEmpty 가 true 면 테스트 통과
- false 면 “❌ 실패: 기대한 결과는 true인데 false였다”
💡 예:
“일기 저장 후 reload 했을 때 데이터가 존재해야 한다”
XCTAssertTrue(store.snapshot.contains { $0.id == dummy.id })
🚫 XCTAssertFalse(_:)
👉 조건이 거짓(false) 이어야 테스트 통과
let hasError = false
XCTAssertFalse(hasError)
📘 설명
- hasError 가 false 면 ✅ 통과
- true 면 ❌ 실패
💡 예:
“삭제 후에는 해당 일기가 없어야 한다”
XCTAssertFalse(store.snapshot.contains { $0.id == dummy.id })
⚖️ XCTAssertEqual(_:_:)
👉 두 값이 같으면 통과, 다르면 실패
let count = 5
XCTAssertEqual(count, 5)
📘 설명
첫 번째 인자: 실제 값
두 번째 인자: 기대하는 값
💡 예:
“일기 내용을 수정했는데, DB에 반영된 값이 맞는지 확인”
XCTAssertEqual(found?.content, "수정된 내용 ✏️")
이건 실제 found.content 값이 "수정된 내용 ✏️"과 같아야 통과예요 ✅
🧠 _ = store.save(diary) 의 의미
Swift에서는 함수가 반환값을 가질 때,
그 값을 “사용하지 않으면” 경고가 나올 수 있어요 ⚠️
func save(_ diary: EmotionDiaryModel) -> Bool
save()는 Bool을 반환하죠?
(저장 성공 여부를 알려줌)
하지만 테스트에서는 단순히 실행만 하고 결과값은 필요 없을 때,
그 반환값을 명시적으로 무시하기 위해 _ =를 붙입니다.
_ = store.save(dummy)
📘 의미
“이 함수를 실행하긴 하지만, 반환값은 굳이 안 쓸 거야.”
즉 Bool을 리턴하지만, 굳이 let success = …로 받을 필요 없다는 뜻이에요.
⏳ fulfillment(of:) — 비동기 결과 기다리기
비동기 테스트에서는 바로 결과가 안 나오잖아요?
그래서 기다려야 해요.
이를 위해 사용하는 게
✅ XCTestExpectation 과 fulfillment(of:) 입니다.
🔹 1단계: 기대(Expectation) 생성
let expectation = XCTestExpectation(description: "Publisher 업데이트 대기")
이건 “곧 Publisher가 emit(발행)될 거야”라는 기대를 세우는 거예요.
🔹 2단계: 실제 이벤트 기다리기
await fulfillment(of: [expectation], timeout: 3.0)
이건 “최대 3초 동안 기다릴게”라는 뜻이에요.
3초 안에 expectation.fulfill()이 호출되면 테스트 통과 ✅
그게 안 되면 테스트 실패 ❌
🔹 3단계: 조건 충족 시 fulfill() 호출
예를 들어 Combine 구독 중에 Publisher가 emit 되면 이렇게 부릅니다 👇
store.diariesPublisher
.sink { diaries in
if diaries.contains(where: { $0.content == "새 일기 저장 테스트" }) {
expectation.fulfill() // 조건 충족 → 완료!
}
}
.store(in: &cancellables)
⚙️ 1️⃣ 테스트 파일 생성하기
1️⃣ Xcode → File → New → File… (⌘ + N)
2️⃣ iOS → Testing → Unit Test Case Class 선택
3️⃣ 이름을 DiaryStoreTests.swift 로 입력
4️⃣ Targets에서 LemonLogTests만 체크 후 생성
생성 후 경로:
LemonLogTests/DiaryStoreTests.swift
🧩 2️⃣ 테스트 환경 준비
테스트는 앱의 실제 인스턴스에 영향을 주지 않게
별도의 인스턴스를 만들어 진행합니다.
예를 들어 이번 테스트 대상은
✅ DiaryStore (SSOT + Core Data + Combine 구조)
그래서 테스트 클래스 시작 부분은 이렇게 작성합니다 👇
import XCTest
import Combine
@testable import LemonLog
@MainActor
final class DiaryStoreTests: XCTestCase {
private var store: DiaryStore!
private var cancellables: Set<AnyCancellable> = []
override func setUpWithError() throws {
store = DiaryStore(manager: DiaryCoreDataManager.shared)
cancellables = []
}
override func tearDownWithError() throws {
store = nil
cancellables.removeAll()
}
}
✅ setUpWithError()
→ 각 테스트 전에 항상 실행됩니다.
✅ tearDownWithError()
→ 테스트가 끝난 뒤 리소스를 정리합니다.
🧱 3️⃣ 테스트 데이터 준비 (더미 생성기)
테스트용 감정일기 데이터를 만드는 헬퍼 함수를 만들어 둡니다 👇
private func makeDummyDiary(content: String = "테스트 일기") -> EmotionDiaryModel {
EmotionDiaryModel(
id: UUID(),
emotion: "happy_grade_1",
content: content,
createdAt: Date(),
images: nil
)
}
이렇게 하면 각 테스트마다 샘플 데이터를 쉽게 만들 수 있습니다.
🧪 4️⃣ 첫 번째 테스트 — 데이터 불러오기
func testReloadLoadsAllDiaries() async {
let dummy = makeDummyDiary()
_ = store.save(dummy)
await store.reload()
XCTAssertFalse(store.snapshot.isEmpty)
XCTAssertTrue(store.snapshot.contains { $0.id == dummy.id })
}
📍 설명
- save()로 더미 데이터를 저장하고,
- reload()로 Core Data에서 다시 불러옵니다.
- XCTAssertFalse → 데이터가 비어있지 않아야 함
- XCTAssertTrue → 방금 저장한 일기가 존재해야 함
🔁 5️⃣ 두 번째 테스트 — Publisher 업데이트 검증
Combine의 CurrentValueSubject를 통해 실시간 업데이트를 감지하는 구조를 테스트합니다 👇
func testSaveDiaryUpdatesPublisher() async {
let expectation = XCTestExpectation(description: "Publisher 업데이트 대기")
store.diariesPublisher
.dropFirst() // 초기값 무시
.sink { diaries in
if diaries.contains(where: { $0.content == "새 일기 저장 테스트" }) {
expectation.fulfill()
}
}
.store(in: &cancellables)
let dummy = makeDummyDiary(content: "새 일기 저장 테스트")
_ = store.save(dummy)
await fulfillment(of: [expectation], timeout: 3.0)
}
📍 핵심 포인트
- XCTestExpectation → 비동기 이벤트 기다리기
- sink → Publisher가 발행하는 값을 구독
- fulfill() → 조건 만족 시 테스트 성공
✏️ 6️⃣ 세 번째 테스트 — 수정 기능 검증
func testUpdateDiaryReflectsChanges() async {
let dummy = makeDummyDiary(content: "수정 전 내용")
_ = store.save(dummy)
var updated = dummy
updated.content = "수정된 내용 ✏️"
_ = store.update(updated)
await store.reload()
let found = store.snapshot.first(where: { $0.id == dummy.id })
XCTAssertEqual(found?.content, "수정된 내용 ✏️")
}
📍 설명
- 기존 일기 내용을 수정하고
- 다시 불러왔을 때 수정된 내용이 반영됐는지 검증
🗑️ 7️⃣ 네 번째 테스트 — 삭제 기능 검증
func testDeleteDiaryRemovesFromPublisher() async {
let dummy = makeDummyDiary()
_ = store.save(dummy)
await store.reload()
XCTAssertTrue(store.snapshot.contains { $0.id == dummy.id })
_ = store.delete(id: dummy.id.uuidString)
await store.reload()
XCTAssertFalse(store.snapshot.contains { $0.id == dummy.id })
}
📍 설명
- 삭제 전후 snapshot 비교로 데이터가 실제로 제거됐는지 확인
🧩 9️⃣ 실행 결과
Test Suite 'DiaryStoreTests' started at 2025-10-23 23:16:11.664.
Test Case '-[LemonLogTests.DiaryStoreTests testDeleteDiaryRemovesFromPublisher]' started.
✅ [persistentContainer:29] - Core Data 초기화 성공
✅ [saveContext():52] - Core Data 저장 성공
✅ [saveDiary(_:):98] - 감정일기 저장 성공 (DB1566D8-BFC8-41C8-B31D-32F01AAFFE2D)
✅ [fetchDiaries(mode:):128] - 총 1개의 감정일기 로드 완료
✅ [fetchDiaries(mode:):128] - 총 1개의 감정일기 로드 완료
⚠️ [deleteDiaryFolder(for:):125] - 삭제할 폴더 없음: DB1566D8-BFC8-41C8-B31D-32F01AAFFE2D
✅ [saveContext():52] - Core Data 저장 성공
✅ [deleteDiary(by:):299] - 감정읽기 삭제 완료 [DB1566D8-BFC8-41C8-B31D-32F01AAFFE2D]
✅ [fetchDiaries(mode:):128] - 총 0개의 감정일기 로드 완료
Test Case '-[LemonLogTests.DiaryStoreTests testDeleteDiaryRemovesFromPublisher]' passed (0.238 seconds).
Test Case '-[LemonLogTests.DiaryStoreTests testReloadLoadAllDiaries]' started.
✅ [saveContext():52] - Core Data 저장 성공
✅ [saveDiary(_:):98] - 감정일기 저장 성공 (A640354D-B226-4B70-8D96-831B05CAF6B8)
✅ [fetchDiaries(mode:):128] - 총 1개의 감정일기 로드 완료
✅ [fetchDiaries(mode:):128] - 총 1개의 감정일기 로드 완료
Test Case '-[LemonLogTests.DiaryStoreTests testReloadLoadAllDiaries]' passed (0.003 seconds).
Test Case '-[LemonLogTests.DiaryStoreTests testSaveDiaryUpdatePublisher]' started.
✅ [saveContext():52] - Core Data 저장 성공
✅ [saveDiary(_:):98] - 감정일기 저장 성공 (B965EAAE-9F6C-4962-A97E-4F66CE2C0C6D)
✅ [fetchDiaries(mode:):128] - 총 2개의 감정일기 로드 완료
Test Case '-[LemonLogTests.DiaryStoreTests testSaveDiaryUpdatePublisher]' passed (0.008 seconds).
Test Case '-[LemonLogTests.DiaryStoreTests testUpdateDiaryReflectsChanges]' started.
✅ [saveContext():52] - Core Data 저장 성공
✅ [saveDiary(_:):98] - 감정일기 저장 성공 (30B71C5E-F07B-4B6D-BA4B-67DA26406B8A)
⚠️ [deleteDiaryFolder(for:):125] - 삭제할 폴더 없음: 30B71C5E-F07B-4B6D-BA4B-67DA26406B8A
✅ [saveContext():52] - Core Data 저장 성공
✅ [updateDiary(_:):273] - 감정일기 수정 성공 (30B71C5E-F07B-4B6D-BA4B-67DA26406B8A)
✅ [fetchDiaries(mode:):128] - 총 3개의 감정일기 로드 완료
✅ [fetchDiaries(mode:):128] - 총 3개의 감정일기 로드 완료
Test Case '-[LemonLogTests.DiaryStoreTests testUpdateDiaryReflectsChanges]' passed (0.009 seconds).
Test Suite 'DiaryStoreTests' passed at 2025-10-23 23:16:12.300.
Executed 4 tests, with 0 failures (0 unexpected) in 0.258 (0.636) seconds
✅ 전체 결과 요약
| 항목 | 결과 | 설명 |
| 테스트 파일명 | DiaryStoreTests | 우리가 만든 DiaryStore 검증용 테스트 |
| 총 실행 테스트 수 | 4개 | 각각 Save / Update / Delete / Reload 검증 |
| 실패(Fail) | 0건 | 모든 케이스 정상 작동 |
| 총 실행 시간 | 약 0.26초 | 가벼운 CRUD 테스트라 빠르게 완료 |
🧩 세부 해석
🧠 1️⃣ testSaveDiaryUpdatePublisher
새 일기 저장 시, Publisher가 emit(방출)되고 subscribers가 최신 데이터를 받는지 확인
✅ [saveDiary(_:):98] - 감정일기 저장 성공 (B965EAAE-9F6C-4962-A97E-4F66CE2C0C6D)
✅ [fetchDiaries(mode:):128] - 총 2개의 감정일기 로드 완료
→ 데이터 저장이 성공했고, Core Data에서 실제로 2개 일기가 조회됨
즉 CurrentValueSubject 가 정상적으로 emit됨 🔥
🧠 2️⃣ testUpdateDiaryReflectsChanges
이미 저장된 일기를 수정했을 때, Publisher와 snapshot 모두 최신 데이터로 반영되는지 테스트
✅ [updateDiary(_:):273] - 감정일기 수정 성공
✅ [fetchDiaries(mode:):128] - 총 3개의 감정일기 로드 완료
→ 수정 로직(updateDiary)이 잘 반영되어 Core Data에서도 변경사항이 정상 반영 ✅
Publisher를 통해 업데이트된 배열이 재발행(emit)됨을 확인
🧠 3️⃣ testDeleteDiaryRemovesFromPublisher
삭제 시 Publisher가 즉시 업데이트되어 snapshot에서 제거되는지 검증
✅ [deleteDiary(by:):299] - 감정읽기 삭제 완료 [DB1566D8-BFC8-41C8-B31D-32F01AAFFE2D]
✅ [fetchDiaries(mode:):128] - 총 0개의 감정일기 로드 완료
→ Core Data에서 삭제 성공 → emit → subscribers가 업데이트된 리스트(빈 배열)를 받음.
Publisher 동기화 완벽히 동작 ✅
⚠️ "삭제할 폴더 없음"은 단순히 이미지가 첨부되지 않아 폴더가 없다는 로그 경고로, 실제 오류 아님.
🧠 4️⃣ testReloadLoadAllDiaries
reload() 호출 시 Core Data에서 데이터를 다시 읽고 Publisher에 반영되는지 테스트
✅ [saveDiary(_:):98] - 감정일기 저장 성공
✅ [fetchDiaries(mode:):128] - 총 1개의 감정일기 로드 완료
→ reload 메서드에서 비동기로 데이터를 fetch → MainActor로 돌아와 emit 성공 ✅
💡 결론적으로
✔️ DiaryStore — CoreData와의 연동 / Publisher 갱신 / Snapshot 반영
모든 흐름이 정상적으로 작동하고 있음