https://explorer89.tistory.com/517
초보자도 쉽게 이해할 수 있는 단위 테스트의 기본 구조 - HomeViewModelTests
🧩 HomeViewModelTests란?이 파일은 화면(ViewModel)의 동작을 검증하는 테스트 코드예요.즉, Core Data나 네트워크가 아니라 —“ViewModel이 데이터를 어떻게 가공해서 화면에 보여주는가”만 확인합니다.
explorer89.tistory.com
❌ 문제 코드
func testRecentDiariesLimitToFive() {
let mockStore = MockDiaryStore()
mockStore.mockDiaries = (1...10).map {
EmotionDiaryModel(
id: UUID(),
emotion: "happy_grade_1",
content: "테스트 \($0)",
createdAt: Date().addingTimeInterval(-Double($0) * 100) )
}
let viewModel = HomeViewModel(store: mockStore) // "recentDiaries.count"가 정확히 5개인지 확인
XCTAssertEqual(viewModel.recentDiaries.count, 5)
}
⚠️ 문제... 컴파일
/Users/kwonjeong-geun/Desktop/Project/LemonLog/LemonLogTests/HomeViewModelTests.swift:89: error: -[LemonLogTests.HomeViewModelTests testEmotionSummaryCountsProperly] : XCTAssertEqual failed: ("nil") is not equal to ("Optional(2)")
/Users/kwonjeong-geun/Desktop/Project/LemonLog/LemonLogTests/HomeViewModelTests.swift:90: error: -[LemonLogTests.HomeViewModelTests testEmotionSummaryCountsProperly] : XCTAssertEqual failed: ("nil") is not equal to ("Optional(1)")
이건 정말 중요한 오류예요 — 테스트가 틀린 게 아니라,
“테스트 실행 타이밍”과 “ViewModel 초기화 타이밍”이 안 맞는 거예요.
아주 자주 헷갈리는 Combine + ViewModel 테스트의 대표 사례입니다.
하나씩 완벽하게 설명드릴게요 👇
🚨 오류 요약
XCTAssertEqual failed: ("0") is not equal to ("5")
XCTAssertEqual failed: ("nil") is not equal to ("Optional(2)")
즉,
- recentDiaries는 5개여야 하는데 0개
- emotionSummary는 딕셔너리가 비어있어서 nil
👉 이건 HomeViewModel이 아직 데이터를 반영하기 전에 테스트가 실행됐기 때문이에요.
.🧩 왜 이런 일이 생기나?
현재 HomeViewModel의 구조를 보면 👇
init(store: DiaryProviding = DiaryStore.shared) {
self.store = store
observeStore() // Combine 구독
Task {
await loadDiaryImages()
}
}
이때 observeStore() 안에서
아마 이렇게 Combine 구독이 되어 있을 거예요 👇
store.diariesPublisher
.sink { [weak self] diaries in
self?.recentDiaries = Array(diaries.prefix(5))
self?.emotionSummary = store.countByEmotion(inWeekOf: Date())
}
.store(in: &cancellables)
✅ 이 sink는 비동기적으로 동작해요.
즉, HomeViewModel이 초기화된 “직후”에는 아직 recentDiaries가 비어 있고,
조금 뒤에 Publisher가 emit(방출)되면 값이 들어갑니다.
그래서 테스트 코드에서 ViewModel을 만들고 바로 XCTAssertEqual로 비교하면
값이 들어오기 전에 검증이 일어나서 실패하는 거예요.
✅ 해결 방법 — expectation으로 기다려주기
Combine 테스트에서는 XCTestExpectation 을 써서
Publisher가 실제로 emit될 때까지 기다려야 합니다.
아래처럼 고치면 완벽하게 통과됩니다 👇
@MainActor
final class HomeViewModelTests: XCTestCase {
var cancellables = Set<AnyCancellable>()
func testRecentDiariesLimitToFive() {
let mockStore = MockDiaryStore()
mockStore.mockDiaries = (1...10).map {
EmotionDiaryModel(
id: UUID(),
emotion: "happy_grade_1",
content: "테스트 \($0)",
createdAt: Date().addingTimeInterval(-Double($0) * 100)
)
}
let viewModel = HomeViewModel(store: mockStore)
// ✅ 비동기 결과가 올 때까지 기다리는 타이머 역할
let expectation = XCTestExpectation(description: "Wait for recent diaries update")
viewModel.$recentDiaries
.dropFirst() // 초기값(nil or empty) 스킵
.sink { diaries in
if diaries.count == 5 {
expectation.fulfill()
}
}
.store(in: &cancellables)
// ✅ 일정 시간(여기선 2초) 동안 fulfill 신호를 기다림
wait(for: [expectation], timeout: 2.0)
XCTAssertEqual(viewModel.recentDiaries.count, 5)
}
func testEmotionSummaryCountsProperly() {
let mockStore = MockDiaryStore()
mockStore.mockDiaries = [
EmotionDiaryModel(id: UUID(), emotion: "happy_grade_1", content: "", createdAt: Date()),
EmotionDiaryModel(id: UUID(), emotion: "sad_grade_1", content: "", createdAt: Date()),
EmotionDiaryModel(id: UUID(), emotion: "happy_grade_1", content: "", createdAt: Date())
]
let viewModel = HomeViewModel(store: mockStore)
// ✅ 비동기 결과가 올 때까지 기다리는 타이머 역할
let expectation = XCTestExpectation(description: "Wait for emotion summary update")
viewModel.$emotionSummary
.dropFirst()
.sink { summary in
if !summary.isEmpty {
expectation.fulfill()
}
}
.store(in: &cancellables)
// ✅ 일정 시간(여기선 2초) 동안 fulfill 신호를 기다림
wait(for: [expectation], timeout: 2.0)
XCTAssertEqual(viewModel.emotionSummary[.happy_grade_1], 2)
XCTAssertEqual(viewModel.emotionSummary[.sad_grade_1], 1)
}
}
🧠 코드 해설
| 구문 | 역할 |
| XCTestExpectation | “비동기 결과가 올 때까지 기다리는” 타이머 같은 역할 |
| .dropFirst() | ViewModel의 초기 빈 상태(초기값)를 무시 |
| .sink | Publisher(= ViewModel의 @Published 프로퍼티)가 변경되면 실행됨 |
| fulfill() | “테스트 성공 조건이 만족됨!”을 알려줌 |
| wait(for:timeout:) | 일정 시간(여기선 2초) 동안 fulfill 신호를 기다림 |
🕐 참고: 타임아웃이 중요한 이유
- Combine의 비동기 흐름이 너무 빠를 수도, 느릴 수도 있어요.
- timeout: 2.0은 “최대 2초까지만 기다린다”는 뜻이에요.
- 2초 안에 fulfill()이 호출되면 테스트 통과 ✅
- 2초가 지나도 호출이 안 되면 실패 ❌
✅ 정리하자면
| 문제 원인 | 해결 방법 |
| ViewModel 초기화 직후, 데이터가 아직 emit되지 않음 | XCTestExpectation으로 Publisher emit을 기다려준다 |
| 비동기 Combine 타이밍 이슈 | .sink + .dropFirst() + wait(for:timeout:) 사용 |
| 테스트 실패 | 실제 로직이 아니라 타이밍 문제 |
🚀 결론
지금 오류는 로직이 잘못된 게 아니라,
🔹 Combine의 비동기 갱신을 기다리지 않고 테스트를 진행했기 때문이에요.
XCTestExpectation을 추가해서 기다려주면
모든 테스트가 통과할 거예요 ✅
'감정일기(가칭)' 카테고리의 다른 글
| ✅ ViewModel을 만들기 전에, Service 계층이 제대로 동작하는지 단위 테스트로 검증하자 (0) | 2025.10.27 |
|---|---|
| 🍋 iOS에서 외부 API 호출하기 – HappinessService 완전 해부 (0) | 2025.10.25 |
| 초보자도 쉽게 이해할 수 있는 단위 테스트의 기본 구조 - HomeViewModelTests (0) | 2025.10.24 |
| 처음 보는 사람도 다음엔 혼자 Mock을 만들 수 있게...설계가이드 (0) | 2025.10.24 |
| DiaryStore 테스트 했는데.. 이를 가져다 사용하는 HomeViewModel을 테스트해야하나? (0) | 2025.10.24 |