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이 올바르게 작동하는지 확인하는 것이고,
통합 테스트는 진짜 세상에서도 잘 작동하는지 확인하는 것이다.”
'감정일기(가칭)' 카테고리의 다른 글
| 📘 Combine 기반 ViewModel 테스트: 시행착오로 배우는 HappinessViewModelTests 완성기 (0) | 2025.10.27 |
|---|---|
| 🤔 프로토콜을 어디에 붙여야 하나? - MVVM 설계의 성숙도 (0) | 2025.10.27 |
| ✅ ViewModel을 만들기 전에, Service 계층이 제대로 동작하는지 단위 테스트로 검증하자 (0) | 2025.10.27 |
| 🍋 iOS에서 외부 API 호출하기 – HappinessService 완전 해부 (0) | 2025.10.25 |
| “테스트 실행 타이밍”과 “ViewModel 초기화 타이밍”이 안 맞는 상황 해결 (0) | 2025.10.24 |