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

✅ ViewModel을 만들기 전에, Service 계층이 제대로 동작하는지 단위 테스트로 검증하자

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

앱을 만들다 보면 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 디코딩 → 데이터 검증 과정을

모두 성공적으로 거쳤다는 뜻이에요 ✅

 

728x90
LIST