[Swift] XCTest 코드 분석하기

팔랑이·2026년 1월 9일

iOS/Swift

목록 보기
84/89

이전 글에 이어서, 이번에는 클로드로 생성한 테스트 코드를 따라가며
각 코드가 어떤 역할을 하고 어떤 테스트 효과를 가지는지를 중심으로 정리하는 포스팅

분석 대상은 다음 두 파일인데,

  • AuthRepositoryTests.swift
  • LoginViewModelTests.swift

이 두 파일을 함께 보며 Clean Architecture에서 계층별로 테스트가 어떻게 분리되고, Mock이 어떻게 전파되는지 이해해보도록 하겠다!


1. 핵심 전제

본격적으로 코드를 보기 전에, 이 테스트 코드 전반에 깔려 있는 핵심 전제를 먼저 짚고 넘어간다.

단위 테스트의 목적은 SUT(System Under Test) 만 검증하는 것이다.

sut: 실제로 검증하고 싶은 대상(System Under Test)

SUT가 의존하는 외부 객체는 모두 Mock으로 대체한다.

  • Repository 테스트
    → NetworkManager, TokenManager를 Mock으로 대체

  • ViewModel 테스트
    → UseCase를 Mock으로 대체

🌟 실제 서버 통신, 토큰 저장 여부, UI 상태 변화는 모두 테스트 범위 밖이다.

Mock 객체 설계에서 공통적으로 보이는 패턴

Claude가 생성한 Mock 코드를 보면, 공통적으로 두 가지 축을 가진다.

1. Configurable Results (결과 주입)

var requestVoidResult: Single<Void> = .just(())
var requestResult: Any!

테스트마다 성공 / 실패 / 특정 DTO 반환을 자유롭게 설정 가능
SUT의 분기 로직을 의도적으로 통과시키기 위함

2. Call Tracking (호출 추적)

var requestVoidCallCount = 0
var lastEndpoint: String?
var lastMethod: HTTPMethod?
var lastHeaders: [String: String]?

메서드가 호출되었는지
몇 번 호출되었는지
어떤 인자로 호출되었는지 기록한다.

→ 단순히 “성공했다”가 아니라
→ “올바른 요청을 보냈는지”까지 검증 가능


2. Repository 테스트 기본 세팅

Repository 테스트 세팅 방식을 알아보자.

var sut: AuthRepositoryImpl!
var mockNetwork: MockNetworkManager!
var mockTokenManager: MockTokenManager!
var disposeBag: DisposeBag!

override func setUp() {
    super.setUp()
    mockNetwork = MockNetworkManager()
    mockTokenManager = MockTokenManager()
    sut = AuthRepositoryImpl(
        network: mockNetwork,
        tokenManager: mockTokenManager
    )
    disposeBag = DisposeBag()
}

이 코드는 테스트 환경을 통제하기 위한 기본 세팅이다.

  • mockNetwork, mockTokenManager: 외부 의존성을 모두 Mock으로 대체
  • disposeBag: RxSwift 구독이 테스트 간 누적되지 않도록 관리

이 구조 덕분에 Repository 테스트에서는
네트워크 상태, 서버 응답, 토큰 저장 여부와 무관하게
Repository 내부 로직만 검증할 수 있다.

예시 1) 토큰이 없을 때: 에러 반환 + 네트워크 미호출 검증

func test_validateAccessToken_whenNoToken_returnsAuthError() {
    // Given
    mockTokenManager.accessToken = nil

    let expectation = expectation(description: "validate")

    // When
    sut.validateAccessToken()
        .subscribe(
            onSuccess: { XCTFail("Should not succeed") },
            onFailure: { error in
                guard case AuthError.noToken = error else {
                    XCTFail("Expected AuthError.noToken but got \(error)")
                    return
                }
                expectation.fulfill()
            }
        )
        .disposed(by: disposeBag)

    wait(for: [expectation], timeout: 1.0)

    // Then
    XCTAssertEqual(mockNetwork.requestVoidCallCount, 0)
}

이 테스트에서 실제로 검증하는 것은 두 가지다.

  1. 결과: 토큰이 없으면 AuthError.noToken이 반환되는가
  2. 부수 효과: 이 상황에서 네트워크 요청이 발생하지 않았는가
XCTAssertEqual(mockNetwork.requestVoidCallCount, 0)

이 한 줄이 핵심이다.
에러를 반환했더라도 내부에서 네트워크 요청을 보내고 있었다면 Repository의 책임이 깨진 것이다.

“실패했다”가 아니라 “올바르게 실패했다”를 검증하는 테스트

예시 2) 토큰이 있을 때: 요청 스펙 검증

func test_validateAccessToken_whenTokenExists_callsNetwork() {
    // Given
    mockTokenManager.accessToken = "valid_token"
    mockNetwork.requestVoidResult = .just(())

    let expectation = expectation(description: "validate")

    // When
    sut.validateAccessToken()
        .subscribe(onSuccess: { expectation.fulfill() })
        .disposed(by: disposeBag)

    // Then
    wait(for: [expectation], timeout: 1.0)
    XCTAssertEqual(mockNetwork.requestVoidCallCount, 1)
    XCTAssertEqual(mockNetwork.lastEndpoint, "/auth/validate")
    XCTAssertEqual(
        mockNetwork.lastHeaders?["Authorization"],
        "Bearer valid_token"
    )
}

여기서 검증 포인트는 반환값이 아니다.

  • 네트워크가 호출되었는가
  • 정확한 endpoint를 사용했는가
  • Authorization 헤더에 올바른 토큰을 넣었는가

즉, 이 테스트는
“Repository가 올바른 요청을 구성했는가”를 검증한다.

Repository 테스트의 본질이 잘 드러나는 부분이다.

Mock에서 Call Tracking이 왜 중요한지

final class MockNetworkManager: NetworkManagerProtocol {

    var requestVoidResult: Single<Void> = .just(())
    var requestVoidCallCount = 0
    var lastEndpoint: String?
    var lastMethod: HTTPMethod?
    var lastHeaders: [String: String]?

    func requestVoid(
        endpoint: String,
        method: HTTPMethod,
        body: Encodable?,
        headers: [String: String]?
    ) -> Single<Void> {
        requestVoidCallCount += 1
        lastEndpoint = endpoint
        lastMethod = method
        lastHeaders = headers
        return requestVoidResult
    }
}

이 Mock은 단순히 값을 반환하는 용도가 아니다.

  • 결과 주입: 성공 / 실패를 테스트마다 바꿀 수 있음
  • 호출 기록 : 몇 번 호출됐는지, 어떤 endpoint, method, header로 호출됐는지

덕분에 테스트에서는
“무슨 값을 받았는가”뿐 아니라 “어떤 요청을 보냈는가”까지 검증할 수 있다.

여기까지가 Repository 테스트에서 검증하는 책임이다.
이제 관점을 한 단계 위로 올려, ViewModel 테스트에서는 무엇을 검증하는지 살펴본다.


3. ViewModel 테스트

ViewModel 테스트에서는 네트워크 요청 자체에는 관심이 없다.
대신 사용자 이벤트가 들어왔을 때, 올바른 UseCase 호출과 Output 방출이 이루어지는지를 검증한다.

ViewModel 테스트의 시작점: Input 생성

let trigger = PublishSubject<Void>()
let input = LoginViewModel.Input(
    appleLoginTapped: trigger.asObservable()
)
let output = sut.transform(input: input)

ViewModel 테스트에서는 버튼을 직접 누르지 않는다.

  • PublishSubject로 사용자 이벤트를 직접 흘려보낸다
  • transform(input:)의 결과로 나온 Output을 구독한다

이 구조 덕분에 ViewController 없이도 ViewModel을 독립적으로 테스트할 수 있다.

로그인 성공 시 Output 검증

func test_appleLogin_whenSuccess_emitsSuccess() {
    // Given
    mockUseCase.socialLoginResult = .just(
        LoginResultEntity(
            accessToken: "a",
            refreshToken: "r",
            signUpToken: nil,
            isSignUpNeeded: false
        )
    )

    let trigger = PublishSubject<Void>()
    let input = LoginViewModel.Input(
        appleLoginTapped: trigger.asObservable()
    )
    let output = sut.transform(input: input)

    let expectation = expectation(description: "loginSuccess")

    output.loginSuccess
        .emit(onNext: { result in
            if case .success = result {
                expectation.fulfill()
            }
        })
        .disposed(by: disposeBag)

    // When
    trigger.onNext(())

    // Then
    wait(for: [expectation], timeout: 1.0)
    XCTAssertEqual(mockUseCase.socialLoginCallCount, 1)
    XCTAssertEqual(mockUseCase.lastLoginPlatform, .apple)
}

이 테스트에서 확인하는 것은 다음이다.

  • 버튼 탭 이벤트가 발생했을 때
  • UseCase가 실제로 호출되는지
  • 그 결과가 ViewModel Output으로 올바르게 변환되는지

즉, ViewModel이

  • 이벤트를 받고
  • UseCase를 호출하고
  • 결과를 가공해서 Output으로 내보내는

중간 조정자 역할을 제대로 하고 있는지를 검증한다.

분기 로직 테스트: 회원가입 필요 / 에러 케이스

mockUseCase.socialLoginResult = .just(
    LoginResultEntity(
        accessToken: nil,
        refreshToken: nil,
        signUpToken: "signup_token",
        isSignUpNeeded: true
    )
)

또는

mockUseCase.socialLoginResult = .error(
    NetworkError.serverError(500)
)

Mock의 반환값만 바꿔서:

  • 회원가입 필요 분기
  • 에러 메시지 방출

을 각각 테스트한다.

테스트 구조는 동일하고, Given에서 주입하는 결과만 달라진다는 점이 중요하다.
이는 ViewModel 로직이 분기 처리만 담당하고 외부 조건에 강하게 묶여 있지 않다는 증거가 된다.


추가) RxSwift 비동기 테스트 패턴

let expectation = expectation(description: "something")

sut.someRxMethod()
    .subscribe(
        onSuccess: {
            expectation.fulfill()
        },
        onFailure: { error in
            XCTFail("Should not fail: \(error)")
        }
    )
    .disposed(by: disposeBag)

wait(for: [expectation], timeout: 1.0)
  • 비동기 스트림이 실제로 방출되는지 보장
  • 의도하지 않은 경로를 XCTFail로 즉시 감지
  • 테스트가 끝날 때까지 확실히 대기

Clean Architecture의 각 계층이 구현이 아니라 추상에 의존하고, 의존성 방향이 한쪽으로만 흐르다 보니
테스트에서는 SUT 단위로 깔끔하게 검증할 수 있었다.
클린 아키텍처가 왜 테스트에 유리한 구조인지 체감할 수 있었다!

profile
정체되지 않는 성장

0개의 댓글