Spy(스파이) — 실제 인스턴스를 래핑하고 각 호출에 대한 정보(어떤 메서드가 호출되었는지, 어떤 인수로, 몇 번인지)를 기록하는 테스트 객체입니다. mock과 달리 spy는 래핑된 객체의 실제 구현을 사용합니다 — 호출은 실제 코드를 통해 진행되며 spy는 사실만 기록합니다. 테스트 실행 후 개발자는 스파이의 기록을 확인합니다: "sendAnalytics 메서드가 세 번 호출되었습니까?". 자세한 내용은 Android 테스트 가이드를 참조하세요.
주요 내용
Spy — 실제 객체를 감싸는 래퍼로, 모든 메서드 호출을 가로채고 기록합니다. 객체의 실제 로직이 실행됩니다: 메서드가 데이터를 저장하거나, 값을 계산하거나, 요청을 하는 경우 — 모든 것이 정상적으로 발생합니다. 추가적으로 spy는 메타데이터(메서드 이름, 인수, 호출 횟수, 실행 시간)를 기록합니다. 이 용어는 Meszaros(2007) 분류의 일부이며 Martin Fowler의 기사 "Mocks Aren't Stubs"에 자세히 설명되어 있습니다.
Mock과의 주요 차이점 — mock은 객체를 테스트 스텁으로 완전히 대체합니다. 모든 메서드는 기본적으로 아무것도 하지 않습니다. Spy는 기존 객체를 래핑합니다: 모든 메서드는 기본적으로 정상적으로 작동하지만 기록도 됩니다. 이 차이는 근본적입니다: mock은 테스트 대상 코드를 현실로부터 격리하는 반면, spy는 현실을 유지하고 관찰할 수 있게 합니다. 둘 중 선택은 무엇을 테스트하는지에 따라 달라집니다.
Spy가 올바른 선택 — 테스트 대상 코드가 실제 객체의 상태를 수정하고, 테스트가 결과(상태) 확인과 호출이 올바른 순서로 이루어졌는지 확인을 모두 수행해야 하는 경우. mock은 실제 구현을 실행하지 않기 때문에 적합하지 않습니다. stub은 호출을 기록하지 않기 때문에 적합하지 않습니다. Spy는 실제 로직을 유지하면서 호출에 대한 정보를 제공하는 유일한 테스트 더블입니다.
Mock — 완전한 격리. 테스트가 실제 객체의 구현에 의존해서는 안 되는 경우(예: 데이터베이스 또는 네트워크 클라이언트), mock을 사용하세요. mock은 어떤 호출도 실제 구성 요소에 도달하지 않도록 보장합니다. 안전하고 예측 가능합니다. 단점: mock은 실제 로직을 실행하지 않으므로, 테스트 대상 코드가 반환 값에 의존하는 경우 when/stub을 통해 명시적으로 구성해야 합니다.
Spy — 실제 로직 + 관찰. 테스트 대상 코드가 데이터뿐만 아니라 로직이 테스트에 중요한 객체와 상호 작용하는 경우 — spy를 사용하세요. 예를 들어, 이벤트를 수집하고 주기적으로 전송하는 AnalyticsTracker. 테스트는 이벤트가 버퍼에 추가되었고 전송 후 버퍼가 비워졌는지 확인합니다. mock은 트래커의 실제 로직을 실행하지 않기 때문에 이를 확인할 수 없습니다.
| 시나리오 | Spy | Mock |
|---|---|---|
| 실제 로직 필요 | 예 | 아니오(stub) |
| 호출 검증 | 예(횟수, 인수) | 예(횟수, 인수) |
| 부분 스터빙 | 예(일부 메서드는 spy, 다른 메서드는 stub) | 아니오(모든 메서드가 stub) |
| 부작용 위험 | 높음(실제 코드) | 없음 |
| 속도 | 낮음(실제 로직) | 높음(stub) |
| 가독성 | 낮음(무엇이 실제인지 이해하기 어려움) | 높음(모든 것이 명시적) |
모든 것에 spy — 모든 테스트에서 mock 대신 spy를 사용하는 것은 오류입니다. spy는 실제 코드를 실행하며 파일 쓰기, HTTP 전송, 전역 상태 변경 등의 부작용이 있을 수 있습니다. 테스트 대상 모듈이 HTTP 요청을 하는 spy 객체의 메서드를 호출하는 경우, 테스트는 단위 테스트가 아닌 통합 테스트가 됩니다. 규칙: spy가 I/O 작업이 있는 객체를 래핑하는 경우 — 더 이상 단위 테스트가 아닙니다. I/O를 격리하려면 mock을 사용하고, spy는 외부 효과가 없는 인메모리 객체에만 사용하세요.
Mockito.spy() — Java/Kotlin 프로젝트에서 스파이를 만드는 고전적인 방법입니다. spy()는 실제 객체를 받아 래퍼를 반환합니다. 모든 호출은 기본적으로 실제 객체에 위임되고 결과가 기록됩니다. 테스트 후 verify()를 사용하여 호출 횟수와 인수를 확인할 수 있습니다. 테스트 데이터를 반환해야 하는 메서드에는 doReturn/when을 사용합니다 — 이를 "부분 모킹"이라고 합니다.
class AnalyticsReporterTest {
private val realTracker = AnalyticsTracker()
private val spyTracker = Mockito.spy(realTracker)
fun test_event_tracked() {
val event = AnalyticsEvent("login")
spyTracker.track(event)
Mockito.verify(spyTracker).track(event)
assertEquals(1, spyTracker.getBufferedCount())
}
fun test_track_with_exception() {
Mockito.doThrow(RuntimeException("network"))
.when(spyTracker).flush()
spyTracker.track(AnalyticsEvent("login"))
assertTrue(spyTracker.hasPendingEvents())
}
}
MockK.spyk() — 코루틴 및 sealed 클래스에 대한 더 나은 지원을 제공하는 Kotlin 프로젝트용 대안입니다. MockK.spyk()는 Mockito.spy()에 해당하는 스파이를 만듭니다. suspend 함수용 coVerify와 부분 스터빙용 every를 지원합니다. Mockito와 달리 MockK는 final 클래스에 대한 spy를 지원하지 않습니다(Kotlin의 모든 클래스는 기본적으로 final) — 클래스를 열거나(open) 인터페이스를 사용해야 합니다.
class LoginUseCaseTest {
private val realRepo = UserRepository()
private val spyRepo = spyk(realRepo)
private val useCase = LoginUseCase(spyRepo)
fun test_login_calls_save() = runTest {
every { spyRepo.getUser(any()) } returns User("test")
val result = useCase.login("test", "pass")
coVerify { spyRepo.saveLoginTime(any()) }
assertTrue(result.isSuccess)
}
}
부분 스터빙 — 강력하지만 위험한 기술입니다. 객체의 spy를 만들고 일부 메서드만 재정의(stub)하고 나머지는 실제로 남겨둘 수 있습니다. 예: getUser()가 테스트 데이터를 반환하고 saveUser()가 실제로 인메모리 목록에 저장하는 spy 리포지토리. 이를 통해 stub(제어된 데이터)과 spy(실제 로직)의 이점을 결합할 수 있습니다. 단점: 테스트 가독성이 떨어집니다 — 어떤 메서드가 실제이고 어떤 것이 stub인지 명확하지 않습니다.
Objective-C용 OCMock — niceMock을 통해 spy 객체 생성을 지원하는 라이브러리입니다. OCMock은 Objective-C 런타임을 사용하여 메서드 호출을 가로채고 기록합니다. 테스트 후 verify가 호출됩니다. OCMock은 모든 객체에 대한 spy를 지원하며(Objective-C의 모든 메서드는 동적), 프로토콜을 통해서만 spy가 가능한 Swift보다 장점이 있습니다.
// 실제 객체에 대한 spy 생성
AnalyticsTracker *realTracker = [[AnalyticsTracker alloc] init];
AnalyticsTracker *spy = [OCMockObject partialMockForObject:realTracker];
// 테스트 실행
[spy trackEvent:@"login"];
// 검증
[[spy verify] trackEvent:@"login"];
XCTAssertEqual([realTracker eventCount], 1);
Swift 프로토콜 기반 spy — Swift에는 Objective-C와 같은 런타임 리플렉션이 없으므로 spy는 수동으로 생성됩니다. 테스트 구조체가 프로토콜을 구현하고 내부적으로 실제 객체를 호출하면서 동시에 호출을 기록합니다. 더 많은 코드가 필요하지만 완전히 제어 가능하고 타입 안전합니다. 수동 스파이는 외부 라이브러리가 필요 없고 런타임을 사용하지 않습니다 — 모든 것이 컴파일 타임에 확인됩니다.
protocol AnalyticsProtocol {
func trackEvent(name: String)
}
final class SpyAnalytics: AnalyticsProtocol {
private let real: AnalyticsProtocol
private var events: [String] = []
init(real: AnalyticsProtocol) {
self.real = real
}
func trackEvent(name: String) {
events.append(name)
real.trackEvent(name: name)
}
func verifyTracked(name: String) -> Bool {
return events.contains(name)
}
}
OCMock 대 수동 spy 사용 시기 — Objective-C 코드에는 OCMock을 사용하세요(보일러플레이트 감소). Swift의 경우 프로토콜을 통한 수동 스파이가 선호됩니다. 수동 spy는 호출 기록을 완전히 제어할 수 있고 리플렉션이 필요 없으며 값 유형(struct)에서 작동합니다. 유일한 단점은 새 메서드를 추가할 때 spy 클래스 코드를 프로토콜과 동기화 상태로 유지해야 한다는 것입니다.
분석 검증 — 스파이의 가장 일반적인 사용 사례입니다. 프로덕션 코드에서 분석 호출은 애플리케이션 전체에 흩어져 있습니다: 로그인, 로그아웃, 구매, 오류. 테스트는 AnalyticsTracker의 spy 래퍼를 만들고 시나리오(로그인, 제품 보기, 장바구니에 추가, 구매)를 실행한 후 필요한 모든 이벤트가 올바른 순서로 전송되었는지 확인합니다. AnalyticsTracker에는 버퍼링 및 전송 로직이 포함되어 있으므로 mock은 적합하지 않습니다.
타이머 및 스케줄러 — Handler(Android) 또는 Timer(iOS)를 사용하는 코드 테스트는 실시간으로 인해 어렵습니다. Scheduler의 spy 래퍼는 어떤 작업이 어떤 지연으로 예약되었는지 기록합니다. 테스트는 실제 Handler의 spy를 만들고 작업을 수행한 후 Handler.postDelayed(runnable, delay)가 올바른 지연으로 호출되었는지 확인합니다. 실제 작업은 실행되지 않습니다 — spy가 호출을 가로채서 기록합니다.
로깅 및 디버그 정보 — 프로덕션에서 로그는 비활성화되거나 파일에 기록될 수 있습니다. Logger의 spy 래퍼는 모든 메시지를 인메모리 목록에 기록하며 테스트는 실행 후 이를 확인합니다. 이를 통해 콘솔을 어지럽히지 않고 오류 시 올바른 메시지가 기록되었는지 확인할 수 있습니다. Logger용 수동 스파이는 OSLog에 테스트 API가 없는 iOS에서 특히 유용합니다.
호출 순서 검증 — 일부 시나리오는 엄격한 작업 순서가 필요합니다: 연결 열기, 데이터 보내기, 연결 닫기. Mockito는 InOrder.verify()를 통해 순서 확인을 지원합니다. spy도 동일한 작업을 수행하지만 실제 실행을 유지합니다. 순서와 각 단계의 결과(연결이 실제로 열렸는지)가 모두 중요한 경우 — mock이 아닌 spy를 사용하세요.
자주 묻는 질문
Spy는 실제 객체를 래핑하고 로직을 실행하면서 호출을 기록합니다. Mock은 객체를 완전히 스텁으로 대체합니다 — 실제 로직이 실행되지 않습니다. spy는 동작을 유지하고 mock은 유지하지 않습니다. 객체의 실제 작업이 중요할 때는 spy를 선택하세요; 외부 종속성으로부터 테스트를 격리해야 할 때는 mock을 선택하세요.
spy 래퍼가 실제 I/O 작업으로 이어질 때. spy가 파일에 쓰거나, HTTP를 보내거나, 디스크에서 읽는 객체를 래핑하는 경우 — 테스트는 단위 테스트가 아닙니다. 두 번째 경우: 테스트가 호출에 관심 없이 반환 값만 확인하는 경우 — 여기서는 stub으로 충분하며 spy는 불필요합니다. 세 번째: 코드가 spy의 내부 상태에 의존하는 경우 — 이는 취약한 테스트입니다.
예, spyk()를 통해 — Mockito.spy()에 해당합니다. MockK.spyk()는 실제 객체 주위에 스파이를 만들고, 부분 스터빙용 every와 suspend 함수용 coVerify/coroutinesVerify를 지원합니다. 제한 사항: final 클래스에서 작동하지 않습니다(open 또는 인터페이스 필요). Java 클래스의 경우 MockK도 spyk()를 지원하지만 @MockKJvmInline 어노테이션이 필요합니다.
기술적으로는 불가능합니다. Mock은 실제 구현이 없는 스텁입니다. Spy는 정의상 실제 객체를 래핑합니다. Mockito에서는 mock을 spy로 변환할 수 없습니다. 하지만 반대는 가능합니다: spy를 만들고 doReturn/when(부분 모킹)을 통해 일부 메서드를 재정의합니다. 이는 spy 객체의 선택된 메서드에 대해 mock과 유사한 동작을 제공합니다.
예, 필요합니다. Swift에는 Java/Kotlin과 같은 동적 프록시가 없습니다. spy를 만들려면 프로덕션 클래스와 spy 클래스가 모두 구현하는 프로토콜이 필요합니다. Swift 프로토콜 기반 spy는 실제 객체를 받아 호출을 위임하고 메타데이터를 기록하는 수동 구현입니다. 대안: SourceKit을 통해 spy 클래스를 생성하는 Cuckoo 라이브러리.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.