본문 바로가기
감정일기(가칭)

“테스트 실행 타이밍”과 “ViewModel 초기화 타이밍”이 안 맞는 상황 해결

by 밤새는 탐험가89 2025. 10. 24.
728x90
SMALL

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을 추가해서 기다려주면
모든 테스트가 통과할 거예요 ✅

 

728x90
LIST