감정일기(가칭)

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

밤새는 탐험가89 2025. 10. 27. 10:50
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