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

📘 단위 테스트 vs 통합 테스트– HappinessViewModel 사례로 배우는 Combine 테스트 구조

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

iOS 개발을 하다 보면 “테스트를 어떻게 해야 하지?”라는 고민을 자주 하게 됩니다.
특히 Combine을 쓰기 시작하면, 비동기 데이터 흐름 때문에 테스트가 조금 헷갈리죠.

 

오늘은 HappinessViewModel을 예시로,
단위 테스트(Unit Test)통합 테스트(Integration Test) 의 차이를 실제 코드와 함께 알아봅시다 👇


🧩 1. 테스트의 두 가지 종류

구분 설명
단위 테스트(Unit Test) 코드의 가장 작은 단위(예: ViewModel, Service 등)가 독립적으로 올바르게 동작하는지 검증
🌐 통합 테스트(Integration Test) 실제 환경(네트워크, DB 등)을 포함한 여러 계층이 함께 잘 동작하는지 검증

 

즉,

단위 테스트 → “내 코드 로직이 제대로 동작하나?”

통합 테스트 → “외부 시스템과 연결해도 제대로 작동하나?”


🧠 2. 상황 예시: 명언 API 불러오기

우리가 만든 앱에는 “오늘의 명언”을 보여주는 기능이 있습니다.
이를 위해 외부 API(https://api.sobabear.com/happiness/random-quote)를 통해 데이터를 받아오죠.

 

API 응답 예시 👇

{
  "message": "Quote fetched successfully",
  "statusCode": 200,
  "data": {
    "id": 60,
    "content": "행복은 내면에서 오지 않고 사이에서 온다.",
    "author": "Jonathan Haidt, 사회심리학자",
    "description": null,
    "link": null
  }
}

 

🧩 3. 구조 설계 (MVVM + Combine)

우리는 다음 세 가지 계층으로 설계했습니다.

View (UI)
  ↓
ViewModel (상태 관리)
  ↓
Service (API 호출)

 

- HappinessService → 실제 API 통신 담당

- HappinessViewModel → 데이터를 받아 화면에 표시할 수 있도록 가공

- HappinessQuote → 명언 모델 구조체


⚙️ 4. 단위 테스트: Mock 데이터를 이용한 독립 검증

단위 테스트의 목표는

“네트워크를 타지 않고도, ViewModel의 로직이 올바르게 작동하는가?”

즉, 외부 환경 없이 코드 자체만 검증합니다.

 

✅ Mock Service 만들기

final class MockHappinessService: HappinessServiceProviding {
    func fetchRandomQuote() -> AnyPublisher<HappinessQuote, Error> {
        let mock = HappinessQuote(
            id: 1,
            content: "행복은 마음가짐의 문제다.",
            author: "익명",
            description: nil,
            link: nil
        )
        return Just(mock)
            .setFailureType(to: Error.self)
            .eraseToAnyPublisher()
    }
}

 

➡️ 이렇게 하면 실제 네트워크 대신
HappinessServiceProviding 프로토콜을 통해 “가짜 데이터”를 반환합니다.

 

✅ 단위 테스트 코드

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, "익명")
                expectation.fulfill()
            }
            .store(in: &cancellables)

        viewModel.loadQuote()

        wait(for: [expectation], timeout: 3.0)
    }
}
 

✅ 테스트 결과

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

 

➡️ 네트워크를 타지 않아 빠르고 안정적입니다.
➡️ ViewModel의 “로직”만 완전히 검증할 수 있습니다.


✅ 통합 테스트 코드

final class HappinessViewModelIntegrationTests: XCTestCase {

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

    override func setUp() {
        cancellables = []
        // 🔹 실제 API 사용
        viewModel = HappinessViewModel(service: HappinessService.shared)
    }

    func test_loadQuote_FetchesRealData() {
        let expectation = XCTestExpectation(description: "실제 API 데이터 수신 테스트")

        viewModel.$quote
            .dropFirst()
            .sink { quote in
                XCTAssertFalse(quote.isEmpty, "명언 내용이 비어 있으면 안 됨")
                XCTAssertFalse(self.viewModel.author.isEmpty, "작가 이름이 비어 있으면 안 됨")
                expectation.fulfill()
            }
            .store(in: &cancellables)

        viewModel.loadQuote()

        wait(for: [expectation], timeout: 5.0)
    }
}

 

✅ 실제 실행 결과

ℹ️ [fetchRandomQuote()] - 요청 시작: https://api.sobabear.com/happiness/random-quote
✅ [fetchRandomQuote()] - 명언 데이터 수신 성공: 벤자민 프랭클린
✅ [loadQuote()] - 명언 업데이트 완료
Test Case 'HappinessViewModelIntegrationTests.test_loadQuote_FetchesRealData' passed (0.854 seconds)

 

✅ 진짜 서버에서 데이터를 받아 ViewModel이 정상적으로 업데이트됨을 확인할 수 있습니다.
⚠️ 다만, 네트워크가 끊기거나 API 서버가 느리면 이 테스트는 실패할 수 있습니다.


🧠 6. 단위 테스트 vs 통합 테스트 비교

항목 단위 테스트 (Mock)  통합 테스트 (실제 API)
목적 ViewModel 로직만 검증 API 연결 포함 전체 플로우 검증
속도 매우 빠름 ⚡ 느림 🐢
안정성 항상 일정한 결과 ✅ 네트워크 상태에 따라 실패 가능 ⚠️
의존성 없음 (Mock 사용) 있음 (실제 서버)
실행 환경 완전 독립 외부 의존
주 사용 시점 개발 중, CI/CD 자동화 QA, 사전 배포, 연결 확인

⚙️ 7. 실무에서의 전략

실무에서는 이 두 가지를 둘 다 사용합니다.

Tests/
 ┣ Unit/
 ┃ ┗ HappinessViewModelTests.swift
 ┗ Integration/
   ┗ HappinessViewModelIntegrationTests.swift

 

- Unit Tests: 매일 빌드 때마다 자동 실행 → 빠르고 신뢰성 높음

- Integration Tests: 실제 서버 변경 시 실행 → 환경 검증용


🧩 8. Combine 테스트 시 주의할 점

1️⃣ sink 구독 시점을 먼저 설정하고 loadQuote()를 호출할 것
2️⃣ RunLoop.main.run(until:) 또는 expectation.wait()으로 비동기 대기 처리
3️⃣ MockService에서는 .receive(on: DispatchQueue.main)으로 메인 스레드 emit 보장

 

이 세 가지만 지켜도 Combine 기반 테스트는 안정적으로 돌아갑니다 ✅


💬 9. 마무리

💡 “단위 테스트는 ViewModel이 올바르게 작동하는지 확인하는 것이고,
통합 테스트는 진짜 세상에서도 잘 작동하는지 확인하는 것이다.”

 

728x90
LIST