앱을 만들다 보면 ViewModel부터 급하게 만들고 싶을 때가 많아요.
하지만! 데이터를 가져오는 Service 계층이 제대로 동작하지 않으면,
그 위에 아무리 깔끔한 ViewModel을 만들어도 UI에는 아무것도 표시되지 않죠.
그래서 오늘은 “ViewModel을 만들기 전에, Service 계층을 먼저 테스트하는 이유”와
그 실제 구현 예시(HappinessServiceTests)를 단계별로 정리해볼게요.
🧱 1. 먼저 개념 정리
MVVM 패턴에서 역할을 간단히 나누면 이렇습니다.
| 계층 | 역할 |
| Model | 실제 데이터 구조 정의 |
| Service (Network) | 외부 API 요청 및 데이터 가져오기 |
| ViewModel | UI에서 필요한 형태로 데이터 가공 및 상태 관리 |
| View | 사용자에게 데이터 표시 |
이 중 Service 계층은 앱의 “데이터 통신의 근본”이 되는 부분이에요.
즉, 여기서 한 줄이라도 잘못되면 ViewModel은 아무리 잘 만들어도 작동하지 않습니다.
🚀 2. 실제 예제: 외부 명언 API 호출
이번 예시에서는
“외부 API를 통해 명언 데이터를 가져오는 Service”를 만들어보겠습니다.
API URL은 다음과 같아요 👇
https://api.sobabear.com/happiness/random-quote

🧩 3. 데이터 모델 정의 (Model)
API의 응답 구조에 맞게 Swift 모델을 작성해야 합니다.
// MARK: - 전체 응답 구조
struct HappinessResponse: Decodable {
let message: String
let statusCode: Int
let data: HappinessQuote
}
// MARK: - 실제 명언 데이터
struct HappinessQuote: Decodable, Identifiable {
let id: Int
let content: String
let author: String
let description: String?
let link: String?
}
이렇게 두 개의 구조체를 만들어두면,
나중에 Combine을 사용해 JSON을 자동으로 디코딩할 수 있습니다.
⚙️ 4. 서비스 계층 구현 (HappinessService)
HappinessService는 외부 API를 실제로 호출하는 역할을 합니다.
Combine의 dataTaskPublisher를 이용하면 비동기 네트워크 코드도 깔끔하게 작성할 수 있어요.
import Foundation
import Combine
// MARK: - 🍋 행복 명언 서비스
final class HappinessService {
// MARK: ✅ Singleton
static let shared = HappinessService()
private init() {}
// MARK: ✅ Method
// 명언 가져오기 (Fetch Random Quote)
func fetchRandomQuote() -> AnyPublisher<HappinessQuote, Error> {
// 🔹 1. URL 생성
guard let url = URL(string: "https://api.sobabear.com/happiness/random-quote") else {
LogManager.print(.error, "잘못된 URL")
return Fail(error: URLError(.badURL)).eraseToAnyPublisher()
}
LogManager.print(.info, "요청 시작: \(url.absoluteString)")
// 🔹 2. 네트워크 요청 (URLSession)
return URLSession.shared.dataTaskPublisher(for: url)
// 🔹 3. 응답 처리 (상태 코드 확인)
.tryMap { output -> Data in
if let httpResponse = output.response as? HTTPURLResponse {
LogManager.print(.info, "상태 코드: \(httpResponse.statusCode)")
}
return output.data
}
// 🔹 4. JSON 디코딩
.decode(type: HappinessResponse.self, decoder: JSONDecoder())
// 🔹 5. 상태 코드 검증 + 데이터 추출
.tryMap { response in
guard response.statusCode == 200 else {
throw URLError(.badServerResponse)
}
LogManager.print(.success, "명언 데이터 수신 성공: \(response.data.author)")
return response.data
}
// 🔹 6. 메인 스레드에서 수신
.receive(on: DispatchQueue.main)
// 🔹 7. 이벤트 로그 처리
.handleEvents(
receiveCompletion: { completion in
switch completion {
case .failure(let error):
LogManager.print(.error, "API 호출 실패: \(error.localizedDescription)")
case .finished:
LogManager.print(.success, "API 호출 완료")
}
}
)
// 🔹 8. Publisher 타입 통합
.eraseToAnyPublisher()
}
}
🧠 5. ViewModel을 만들기 전에 “Service를 테스트하자!”
이제 본격적으로 테스트 코드를 작성해볼 차례입니다.
이 단계에서 우리는 ViewModel을 만들지 않습니다.
대신 Service가 “정상적으로 데이터를 가져오는지” 먼저 확인하죠.
🧪 6. HappinessServiceTests 작성
import XCTest
import Combine
@testable import LemonLog
final class HappinessServiceTests: XCTestCase {
private var cancellables: Set<AnyCancellable>!
private var service: HappinessService!
override func setUp() {
super.setUp()
cancellables = []
service = HappinessService.shared
}
override func tearDown() {
cancellables = nil
service = nil
super.tearDown()
}
func test_fetchRandomQuote_Success() {
// Given
let expectation = XCTestExpectation(description: "명언 API 호출이 성공적으로 완료되어야 함")
// When
service.fetchRandomQuote()
.sink(receiveCompletion: { completion in
if case .failure(let error) = completion {
XCTFail("API 호출 실패: \(error.localizedDescription)")
}
}, receiveValue: { quote in
// Then
XCTAssertFalse(quote.content.isEmpty, "명언 내용이 비어있으면 안 됨")
XCTAssertFalse(quote.author.isEmpty, "작가 정보가 비어있으면 안 됨")
LogManager.print(.success, "✅ 테스트 성공: \(quote.author) - \(quote.content)")
expectation.fulfill()
})
.store(in: &cancellables)
wait(for: [expectation], timeout: 5.0)
}
}
🧪 HappinessServiceTests.swift 설명
import XCTest
import Combine
@testable import LemonLog
- XCTest : Swift 기본 테스트 프레임워크
- Combine : 비동기 스트림을 처리하기 위해 필요
- @testable import LemonLog :
→ internal 접근 제어자까지 접근 가능하게 하여,
우리가 만든 HappinessService 클래스를 테스트 코드에서 직접 호출할 수 있게 해줍니다.
final class HappinessServiceTests: XCTestCase {
- XCTestCase 를 상속한 테스트 클래스입니다.
- Xcode가 테스트 메서드를 자동으로 탐지하려면 반드시 이 형태여야 합니다.
- final 로 선언하면 상속을 막고 테스트 클래스 자체만 실행되도록 보장합니다
private var cancellables: Set<AnyCancellable>!
private var service: HappinessService!
- Combine에서는 publisher를 구독(subscribe)하면 AnyCancellable 인스턴스가 생기는데,
이걸 저장하지 않으면 바로 해제되어 버립니다.
- 따라서 cancellables라는 Set을 만들어서, 구독이 유지되도록 관리합니다.
- service는 테스트할 실제 객체(HappinessService.shared)를 담기 위한 프로퍼티입니다.
override func setUp() {
super.setUp()
cancellables = []
service = HappinessService.shared
}
- 각 테스트가 실행되기 전에 자동으로 호출됩니다.
- 여기서 매번 새로운 cancellables 세트와 서비스 인스턴스를 초기화합니다.
- 테스트 간 데이터가 섞이지 않게 보장하는 역할이에요.
override func tearDown() {
cancellables = nil
service = nil
super.tearDown()
}
- 각 테스트가 끝날 때마다 호출됩니다.
- 메모리를 정리하고, 테스트 간 의존성 누수를 방지합니다.
func test_fetchRandomQuote_Success() {
// Given
let expectation = XCTestExpectation(description: "명언 API 호출이 성공해야 함")
- 테스트 메서드는 항상 test_로 시작해야 Xcode가 자동 인식합니다.
- XCTestExpectation 은 비동기 코드가 완료될 때까지 기다리게 하는 도구예요.
Combine은 비동기 스트림이기 때문에 expectation이 꼭 필요합니다.
// When
service.fetchRandomQuote()
.sink(receiveCompletion: { completion in
if case .failure(let error) = completion {
XCTFail("API 호출 실패: \(error.localizedDescription)")
}
}, receiveValue: { quote in
// Then
XCTAssertFalse(quote.content.isEmpty, "명언 내용이 비어 있으면 안 됨")
XCTAssertFalse(quote.author.isEmpty, "작가 이름이 비어 있으면 안 됨")
print("✅ 테스트 성공: \(quote.author) - \(quote.content)")
expectation.fulfill()
})
.store(in: &cancellables)
이 부분이 실제 테스트 로직의 핵심입니다.
| 구문 | 의미 |
| .sink(receiveCompletion:receiveValue:) | Combine의 Publisher를 구독하여 데이터 스트림을 처리합니다. |
| receiveCompletion | 네트워크 요청이 끝났을 때(.finished or .failure) 실행되는 클로저입니다. |
| receiveValue | 성공적으로 데이터를 받았을 때 실행됩니다. |
| XCTFail() | 실패 시 테스트를 즉시 중단하고 실패로 표시합니다. |
| XCTAssertFalse() | 주어진 조건이 false여야 통과합니다. (.isEmpty → false면 통과) |
| expectation.fulfill() | 비동기 테스트가 정상적으로 완료되었음을 알립니다. |
| .store(in: &cancellables) | Combine 구독이 테스트 도중 해제되지 않게 메모리에 유지합니다. |
wait(for: [expectation], timeout: 5.0)
}
}
- wait(for:timeout:) 은 비동기 테스트가 끝날 때까지 대기하는 역할입니다.
- 여기서는 최대 5초 동안 기다립니다.
- API 응답이 늦더라도 지정된 시간 내에 expectation.fulfill()이 호출되어야 테스트가 통과됩니다.
💡 전체 흐름 요약
1️⃣ setUp() → 테스트 초기화
2️⃣ fetchRandomQuote() 실행
3️⃣ Combine을 통해 비동기적으로 데이터 수신
4️⃣ 데이터가 오면 XCTAssert로 검증
5️⃣ 모든 조건을 통과하면 ✅ 테스트 성공
6️⃣ 실패 시 ❌ XCTFail() 로 즉시 실패 처리
🧠 초보자가 꼭 알아야 할 포인트
| 개념 | 설명 |
| XCTestExpectation | 비동기 코드를 테스트할 때 “완료될 때까지 기다리는 기능” |
| Combine + sink() | 네트워크 요청 결과를 구독하는 핵심 구조 |
| AnyCancellable | Combine 구독이 해제되지 않게 잡아두는 객체 |
| XCTAssert | 실제 값이 예상한 값과 일치하는지 검증하는 메서드 |
| Fail / Success 로그 분리 | 실패 원인을 한눈에 확인하기 위해 LogManager나 print로 로그를 구분 출력 |
🧩 예시 결과 콘솔 로그
ℹ️ [fetchRandomQuote():28] - 요청 시작: https://api.sobabear.com/happiness/random-quote
ℹ️ [fetchRandomQuote():33] - 상태 코드: 200
✅ [fetchRandomQuote():42] - 명언 데이터 수신 성공: C.블록
✅ [test_fetchRandomQuote_Success():43] - ✅ 테스트 성공: C.블록 - 행복이란 넘치는 것과 부족한 것의 중간쯤에 있는 간이역이다.
Test Case 'HappinessServiceTests' passed (0.68 seconds)
이 로그는 실제 테스트 실행 시 출력된 콘솔 메시지입니다.
HappinessService가 네트워크 통신 → JSON 디코딩 → 데이터 검증 과정을
모두 성공적으로 거쳤다는 뜻이에요 ✅
'감정일기(가칭)' 카테고리의 다른 글
| 🤔 프로토콜을 어디에 붙여야 하나? - MVVM 설계의 성숙도 (0) | 2025.10.27 |
|---|---|
| 📘 단위 테스트 vs 통합 테스트– HappinessViewModel 사례로 배우는 Combine 테스트 구조 (0) | 2025.10.27 |
| 🍋 iOS에서 외부 API 호출하기 – HappinessService 완전 해부 (0) | 2025.10.25 |
| “테스트 실행 타이밍”과 “ViewModel 초기화 타이밍”이 안 맞는 상황 해결 (0) | 2025.10.24 |
| 초보자도 쉽게 이해할 수 있는 단위 테스트의 기본 구조 - HomeViewModelTests (0) | 2025.10.24 |