
이전 글에 이어서, 이번에는 클로드로 생성한 테스트 코드를 따라가며
각 코드가 어떤 역할을 하고 어떤 테스트 효과를 가지는지를 중심으로 정리하는 포스팅
분석 대상은 다음 두 파일인데,
AuthRepositoryTests.swiftLoginViewModelTests.swift이 두 파일을 함께 보며 Clean Architecture에서 계층별로 테스트가 어떻게 분리되고, Mock이 어떻게 전파되는지 이해해보도록 하겠다!
본격적으로 코드를 보기 전에, 이 테스트 코드 전반에 깔려 있는 핵심 전제를 먼저 짚고 넘어간다.
단위 테스트의 목적은 SUT(System Under Test) 만 검증하는 것이다.
sut: 실제로 검증하고 싶은 대상(System Under Test)
SUT가 의존하는 외부 객체는 모두 Mock으로 대체한다.
Repository 테스트
→ NetworkManager, TokenManager를 Mock으로 대체
ViewModel 테스트
→ UseCase를 Mock으로 대체
🌟 실제 서버 통신, 토큰 저장 여부, UI 상태 변화는 모두 테스트 범위 밖이다.
Claude가 생성한 Mock 코드를 보면, 공통적으로 두 가지 축을 가진다.
var requestVoidResult: Single<Void> = .just(())
var requestResult: Any!
테스트마다 성공 / 실패 / 특정 DTO 반환을 자유롭게 설정 가능
SUT의 분기 로직을 의도적으로 통과시키기 위함
var requestVoidCallCount = 0
var lastEndpoint: String?
var lastMethod: HTTPMethod?
var lastHeaders: [String: String]?
메서드가 호출되었는지
몇 번 호출되었는지
어떤 인자로 호출되었는지 기록한다.
→ 단순히 “성공했다”가 아니라
→ “올바른 요청을 보냈는지”까지 검증 가능
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 내부 로직만 검증할 수 있다.
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)
}
이 테스트에서 실제로 검증하는 것은 두 가지다.
AuthError.noToken이 반환되는가XCTAssertEqual(mockNetwork.requestVoidCallCount, 0)
이 한 줄이 핵심이다.
에러를 반환했더라도 내부에서 네트워크 요청을 보내고 있었다면 Repository의 책임이 깨진 것이다.
→ “실패했다”가 아니라 “올바르게 실패했다”를 검증하는 테스트
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"
)
}
여기서 검증 포인트는 반환값이 아니다.
즉, 이 테스트는
“Repository가 올바른 요청을 구성했는가”를 검증한다.
Repository 테스트의 본질이 잘 드러나는 부분이다.
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은 단순히 값을 반환하는 용도가 아니다.
덕분에 테스트에서는
“무슨 값을 받았는가”뿐 아니라 “어떤 요청을 보냈는가”까지 검증할 수 있다.
여기까지가 Repository 테스트에서 검증하는 책임이다.
이제 관점을 한 단계 위로 올려, ViewModel 테스트에서는 무엇을 검증하는지 살펴본다.
ViewModel 테스트에서는 네트워크 요청 자체에는 관심이 없다.
대신 사용자 이벤트가 들어왔을 때, 올바른 UseCase 호출과 Output 방출이 이루어지는지를 검증한다.
let trigger = PublishSubject<Void>()
let input = LoginViewModel.Input(
appleLoginTapped: trigger.asObservable()
)
let output = sut.transform(input: input)
ViewModel 테스트에서는 버튼을 직접 누르지 않는다.
PublishSubject로 사용자 이벤트를 직접 흘려보낸다transform(input:)의 결과로 나온 Output을 구독한다이 구조 덕분에 ViewController 없이도 ViewModel을 독립적으로 테스트할 수 있다.
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)
}
이 테스트에서 확인하는 것은 다음이다.
즉, ViewModel이
중간 조정자 역할을 제대로 하고 있는지를 검증한다.
mockUseCase.socialLoginResult = .just(
LoginResultEntity(
accessToken: nil,
refreshToken: nil,
signUpToken: "signup_token",
isSignUpNeeded: true
)
)
또는
mockUseCase.socialLoginResult = .error(
NetworkError.serverError(500)
)
Mock의 반환값만 바꿔서:
을 각각 테스트한다.
테스트 구조는 동일하고, Given에서 주입하는 결과만 달라진다는 점이 중요하다.
이는 ViewModel 로직이 분기 처리만 담당하고 외부 조건에 강하게 묶여 있지 않다는 증거가 된다.
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 단위로 깔끔하게 검증할 수 있었다.
클린 아키텍처가 왜 테스트에 유리한 구조인지 체감할 수 있었다!