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

📘 Combine 기반 ViewModel 테스트: 시행착오로 배우는 HappinessViewModelTests 완성기

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

테스트를 처음 짜보면 항상 이런 생각이 듭니다.

“코드는 잘 짰는데… 왜 테스트가 안 되지?”

 

이번 글에서는 실제로 제가 HappinessViewModel을 테스트하면서 겪은 시행착오를 단계별로 정리했습니다.
Combine + MVVM 구조를 사용하는 분이라면, 분명히 비슷한 문제를 마주하게 될 거예요.


🧩 1. 처음 시도: 그냥 ViewModel만 테스트하면 되겠지?

처음에는 단순히 ViewModel이 API에서 데이터를 잘 받아오는지만 확인하려고 했습니다.

final class HappinessViewModel: ObservableObject {
    @Published var quote: String = ""
    @Published var author: String = ""

    private let service = HappinessService() // ← 직접 의존
    private var cancellables = Set<AnyCancellable>()

    func loadQuote() {
        service.fetchRandomQuote()
            .sink(receiveCompletion: { _ in },
                  receiveValue: { [weak self] quoteData in
                self?.quote = quoteData.content
                self?.author = quoteData.author
            })
            .store(in: &cancellables)
    }
}

 

그리고 테스트 코드를 작성했죠.

final class HappinessViewModelTests: XCTestCase {
    func test_loadQuote() {
        let viewModel = HappinessViewModel()
        viewModel.loadQuote()
        XCTAssertFalse(viewModel.quote.isEmpty)
    }
}

하지만 결과는 실패 💥
테스트가 비동기 코드를 기다리지 못하고 끝나버리거나,
Combine이 업데이트를 감지하지 못했습니다.


🚨 2. 문제 1: final class 구조의 한계

테스트를 위해 API 결과를 바꿔치기(mock)하려 했지만,
HappinessService가 final class라 상속이 불가능했습니다.

 

즉, “진짜 API를 호출하는 로직”만 존재해서
테스트 환경에서 데이터를 조작할 방법이 없었던 거죠.

 

이 문제를 해결하기 위해 프로토콜 기반 추상화를 도입했습니다.


✅ 3. 해결 1: 프로토콜로 추상화하기

HappinessService를 프로토콜로 감싸면
ViewModel은 “어떤 서비스”를 쓰는지는 몰라도,
“명언을 가져올 수 있는 객체”라는 사실만 알면 됩니다.

protocol HappinessServiceProviding {
    func fetchRandomQuote() -> AnyPublisher<HappinessQuote, Error>
}

final class HappinessService: HappinessServiceProviding {
    static let shared = HappinessService()
    private init() {}
    
    func fetchRandomQuote() -> AnyPublisher<HappinessQuote, Error> {
        // API 호출
    }
}

 

이제 ViewModel은 구체 타입 대신 프로토콜을 의존성 주입(Dependency Injection) 으로 받습니다.

@MainActor
final class HappinessViewModel: ObservableObject {
    @Published var quote = ""
    @Published var author = ""

    private let service: HappinessServiceProviding
    private var cancellables = Set<AnyCancellable>()

    init(service: HappinessServiceProviding = HappinessService.shared) {
        self.service = service
    }

    func loadQuote() {
        service.fetchRandomQuote()
            .sink(receiveCompletion: { _ in },
                  receiveValue: { [weak self] quote in
                self?.quote = quote.content
                self?.author = quote.author
            })
            .store(in: &cancellables)
    }
}

 

이제 테스트 환경에서는 실제 API가 아닌 “Mock Service”를 주입할 수 있습니다.


🧪 4. Mock Service로 테스트 구조 완성

final class MockHappinessService: HappinessServiceProviding {
    func fetchRandomQuote() -> AnyPublisher<HappinessQuote, Error> {
        let mock = HappinessQuote(
            id: 999,
            content: "행복은 마음가짐의 문제다.",
            author: "익명",
            description: "테스트용 Mock 데이터",
            link: nil
        )
        return Just(mock)
            .setFailureType(to: Error.self)
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

 

이제 테스트에서 실제 API를 부르지 않아도
ViewModel이 데이터를 잘 받아오는지를 검증할 수 있습니다.


⚠️ 5. 문제 2: Combine의 비동기 타이밍 이슈

처음 테스트를 실행했을 때는 이런 에러가 떴습니다.

Asynchronous wait failed: Exceeded timeout of 5 seconds,
with unfulfilled expectations: "ViewModel이 HappinessService 결과로 상태를 업데이트해야 함".

 

즉, sink 구독 타이밍이 맞지 않아 값이 emit되지 않은 것이죠.
Combine의 비동기 흐름이 테스트 스레드와 어긋난 겁니다.


✅ 6. 해결 2: 타이밍 문제 조정

이 문제를 해결하기 위해 세 가지를 수정했습니다.

(1) sink → loadQuote() 순서 변경

구독을 먼저 걸고 나중에 이벤트를 발생시킵니다.

viewModel.$quote
    .dropFirst()
    .sink { quote in
        XCTAssertEqual(quote, "행복은 마음가짐의 문제다.")
        expectation.fulfill()
    }
    .store(in: &cancellables)

viewModel.loadQuote()

 

(2) 메인 스레드 런루프 실행 보장

Combine 이벤트가 메인 스레드에서 emit될 수 있도록 보장합니다.

RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1))

 

(3) MockPublisher의 .receive(on: DispatchQueue.main) 명시

Mock 데이터가 항상 메인 큐에서 방출되도록 설정했습니다.


🧩 7. 최종 테스트 코드

@MainActor
final class HappinessViewModelTests: XCTestCase {

    private var cancellables: Set<AnyCancellable>!
    private var viewModel: HappinessViewModel!
    private var mockService: MockHappinessService!

    override func setUp() {
        cancellables = []
        mockService = MockHappinessService()
        viewModel = HappinessViewModel(service: mockService)
    }

    func test_loadQuote_UpdatesPublishedValues() {
        let expectation = XCTestExpectation(description: "ViewModel 상태 업데이트")

        viewModel.$quote
            .dropFirst()
            .sink { quote in
                XCTAssertEqual(quote, "행복은 마음가짐의 문제다.")
                XCTAssertEqual(self.viewModel.author, "익명")
                LogManager.print(.success, "✅ ViewModel 상태 업데이트 성공")
                expectation.fulfill()
            }
            .store(in: &cancellables)

        viewModel.loadQuote()
        RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1))
        wait(for: [expectation], timeout: 3.0)
    }
}

✅ 8. 테스트 결과

✅ ViewModel 상태 업데이트 성공
✅ 명언 업데이트 완료
Test Case 'HappinessViewModelTests.test_loadQuote_UpdatesPublishedValues' passed (0.107 seconds)

🧠 9. 이번 경험에서 배운 점

문제 원인 해결 방법
final class라 테스트 불가 상속 불가 구조 프로토콜(HappinessServiceProviding)로 추상화
ViewModel이 실제 네트워크 의존 구체 타입 의존성 의존성 주입(Dependency Injection) 도입
비동기 타이밍 오류 Combine 이벤트 타이밍 불일치 구독 순서 변경 + 메인 큐 실행 보장
728x90
LIST