테스트를 처음 짜보면 항상 이런 생각이 듭니다.
“코드는 잘 짰는데… 왜 테스트가 안 되지?”
이번 글에서는 실제로 제가 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 이벤트 타이밍 불일치 | 구독 순서 변경 + 메인 큐 실행 보장 |
'감정일기(가칭)' 카테고리의 다른 글
| 🧩 HomeSection은 어디에 두는 게 맞을까? (0) | 2025.10.28 |
|---|---|
| 🏠 HomeViewModel에 HappinessViewModel을 통합한 이유와 구조 정리 (0) | 2025.10.28 |
| 🤔 프로토콜을 어디에 붙여야 하나? - MVVM 설계의 성숙도 (0) | 2025.10.27 |
| 📘 단위 테스트 vs 통합 테스트– HappinessViewModel 사례로 배우는 Combine 테스트 구조 (0) | 2025.10.27 |
| ✅ ViewModel을 만들기 전에, Service 계층이 제대로 동작하는지 단위 테스트로 검증하자 (0) | 2025.10.27 |