Test Double, SUT
- Swift
테스트 더블(Test Double)이란?


테스트 더블은 소프트웨어 개발 과정에서 사용되는 테스트 방법론 중 하나이다.
이때 테스트 더블이라는 용어는 영화를 촬영할 때 스턴드 더블(stunt double)이라는 용어가 있는데 해당 용어에서 차용된 용어이기도 하다.
즉, 테스트 중인 프로그램의 일부가 온전히 준비가 되지 않았거나 테스트하기 어려운 상황에서 대안으로 활용할 수 있는 가짜 를 말한다.
이로 인해 테스트 더블은 실제 객체의 행동을 모방하여 테스트가 특정한 조건과 상호작용과 같이 사용자 시나리오를 쉽게 재현하고 검증할 수 있게 해준다.
🤔 테스트 더블을 왜 사용하지?
테스트 더블을 사용하는 주된 이유가 뭘까?
그 전에 SUT라는 요소를 알고 넘어가야할 필요가 있다.
📌 SUT(System Under Test)

SUT는 테스트에서 실제로 검증하려는 대상을 말한다.
이때 대상은 타입, 모듈, 타겟, 객체, 시스템 등 말 그대로 테스트하고자 하는 모든 요소를 말한다.
예를 들자면.. 서비스 클래스나 특정한 함수, 하나의 컴포넌트 등이 될 수 있다.
만약, 로그인 로직을 담당하는 LoginViewModel에 대해 테스트하고자 한다면 해당 LoginViewModel 자체가 SUT가 된다.
이때 테스트 더블이 개입하게 되는데 LoginVIewModel이라는 SUT을 테스트하기 위해 SUT가 의존하는 다른 외부 컴포넌트(유스케이스, 데이터베이스, 네트워크 등..)등을 대체하는 가짜 객체가 테스트 더블 이다.
⭐ 따라서 테스트 더블을 적절하게 사용한다면 SUT의 테스트가 외부 환경에 영향받지 않고 독립적으로 검증할 수 있는 효과를 얻을 수 있다!
📌 테스트 더블의 종류

테스트 더블은 5가지 대표 유형으로 분리할 수 있다.
1. 더미(Dummy Object)
이름과 같이 말 그대로 전달만 되고 실제 사용되지 않는 객체를 의미한다.
단순히 파라미터를 채우기 위함이거나 실제로 사용되지 않는 모든 객체를 의미한다.
아래 코드를 예시로 들어보자.
protocol Notifier {
func send(message: String)
}
final class DummyNotifier: Notifier {
func send(message: String) {
// 아무 동작이 없다!
}
}
final class UserTests: XCTestCase {
func test_createUser_doesNotUseNotifier() {
let dummy = DummyNotifier()
let user = User(name: "Kim", notifier: dummy)
XCTAssertEqual(user.name, "Kim")
}
}
User라는 타입은 생성 시 Notifier라는 의존성을 요구한다.
하지만 실제 테스트하는 목적은 User의 name이 정상적으로 잘 설정되는지만 확인하면 된다.
따라서 실제로 알림을 보낼 필요가 없으므로 더미를 전달하여 테스트를 진행한다.
2. 스텁(Stub)
스텁은 호출 시 정해진 값을 반환하는 것을 말한다.
즉, 미리 준비된 변환값 등 최소한의 동작만을 제공하며 조건에 따른 하드코딩된 값을 주는 객체를 말한다!
더 쉽게 말하면 미리 정의된 동작을 하는 특별한 Fake 객체를 말한다.

만약, 이슈를 가져오는 요청을 수행시켜 화면에 이슈 리스트가 잘 나오는지 확인하기 위한 테스트를 진행한다 가정했을 때 SUT는 외부 API나 네트워크 등 외부 요인에 의존하게 되므로 Stub을 둬 서버로 갔다왔다 치고 테스트 대상에게 결과값을 제공하는 객체를 의미한다.
코드로 직접 봐보자.
protocol AuthService {
func login(id: String, pw: String) -> Bool
}
final class StubAuthService: AuthService {
var result: Bool = true
func login(id: String, pw: String) -> Bool {
return result
}
}
final class AuthTests: XCTestCase {
func test_login_success() {
let stub = StubAuthService()
stub.result = true
let vm = LoginViewModel(auth: stub)
XCTAssertTrue(vm.login(id: "id", pw: "pw"))
}
}
위와 같이 Login이 성공적으로 수행되었는지 확인하기 위해 LoginViewModel을 SUT으로 두고 테스트를 진행하고자 한다.
이때 외부 API를 사용했다 치고 StubAuthService를 정의해 미리 준비된 반환값을 제공하는 역할만 수행시켜 테스트를 진행할 수 있다.
3. 스파이(Spy 혹은 Spies)
이는 다른 객체와의 인터액션을 기록하는 역할을 수행한다.
어떤 의미냐면 호출 횟수나 파라미터 를 기록하는 역할을 의미한다.
final class SpyAuthService: AuthService {
private(set) var calledCount = 0
func login(id: String, pw: String) -> Bool {
calledCount += 1
return true
}
}
final class AuthSpyTests: XCTestCase {
func test_login_calledOnce() {
let spy = SpyAuthService()
let vm = LoginViewModel(auth: spy)
_ = vm.login(id: "id", pw: "pw")
XCTAssertEqual(spy.calledCount, 1)
}
}
위 코드에서 login 요청이 실제로 1번만 수행되었는지 확인할 때 활용할 수 있다.
4. 목(Mock)
테스트 더블에서 가장 중요한 요소이기도 하며 특정 메서드가 실제로 호출 되었는지, 어떤 인자와 함께 호출되었는지, 호출의 기대치를 미리 정해놓고 충족했는지를 검증하는 역할이다.
쉽게 말하면 경계값을 미리 산정하여 테스트를 진행하므로 특정한 기능의 동작을 점검하고 확인할 때 호출하여 활용한다.

아래 코드를 확인해보자.
final class MockAuthService: AuthService {
var expectedId: String?
var expectedPw: String?
private(set) var called = false
func login(id: String, pw: String) -> Bool {
called = (id == expectedId && pw == expectedPw)
return called
}
}
final class AuthMockTests: XCTestCase {
func test_login_calledWithExpectedValues() {
let mock = MockAuthService()
mock.expectedId = "user"
mock.expectedPw = "1234"
let vm = LoginViewModel(auth: mock)
_ = vm.login(id: "user", pw: "1234")
XCTAssertTrue(mock.called)
}
}
위 코드는 SUT가 협력 객체이자 점검 주체를 올바른 인자로 호출했는지 검증하기 위해 Mock을 사용하는 예시이다.
SUT는 LoginViewModel이며 점검 주체는 AuthService이다.
위 코드의 테스트 목적은 LoginViewModel의 login(id:pw:)가 AuthService.login(id:pw:)를 기대한 인자로 호출되는지 점검하기 위함이다.
5. 페이크(Fake)
페이크는 뜻 그대로 실제 구현체처럼 동작하지만 실제 환경에는 적절하지 않은 대체 요소를 말한다.
이는 아래 코드로 적절하게 설명할 수 있다.
protocol UserRepository {
func save(user: String)
func findAll() -> [String]
}
final class FakeUserRepository: UserRepository {
private var storage: [String] = []
func save(user: String) {
storage.append(user)
}
func findAll() -> [String] {
return storage
}
}
final class UserRepoTests: XCTestCase {
func test_saveAndFindAll() {
let repo = FakeUserRepository()
repo.save(user: "Kim")
repo.save(user: "Lee")
XCTAssertEqual(repo.findAll(), ["Kim", "Lee"])
}
}
👀 5가지 테스트 더블을 이해하기 쉽게 AI로 비유해보기
좀 더 이해하기 쉽게 AI를 사용해서 테스트 더블의 5가지 유형을 일상적 비유로 예를 들어보자.
1. 더미(Dummy)
존재만 채우는 인형
만약 회의 자리에 의자가 몇 개 있어야 해요 라는 요구사항이 있을 때 그냥 인형(더미)를 앉혀놓는 것이다.
테스트 측면에서 보면 생성자 매개변수는 꼭 필요한 경우가 있지만 실제 로직에선 전혀 쓰이지 않을 때 사용한다.
2. 스텁(Stub)
대본이 정해진 배우
연극에서 대사 몇 줄만 미리 정해져 있는 엑스트라를 의미한다.
어떠한 상황이 오는 그냥 미리 정해둔 대사만 말하면 된다.
테스트 측면으로 바라본다면 로그인에 성공하면 true를 반환하라. 처럼 항상 같은 값만을 반환하게 두는 경우를 말한다.
3. 스파이(Spy 또는 Spies)
회의 내용을 기록하는 서기
회사에서 회의를 진행한다고 가정해보자. 스파이는 회의실 한 쪽 구석에서 회의엔 참여하지 않고 어떤 인물이 몇 번 발언했는지 기록 하는 역할만을 담당한다.
테스트 측면으로 바라본다면 이 메서드가 몇 번 호출되었는지, 무슨 인자로 호출되었는지를 확인할 때를 말한다.
4. 목(Mock)
심사위원
유명한 오디션 면접 중일 때 심사위원이 참가자들에게 이 대사를 정확히 이 톤으로 말해야 합격입니다~ 하는 조건을 미리 걸어놓고 체크하는 것을 말한다.
더 쉽게 말하면 학교에서 이번 기말고사 80점 이상이어야 합격입니다. 라는 임계치를 걸어놓는다는 의미이다.
테스트 측면으로 바라본다면 해당 메서드가 특정 인자로 정확히 호출되었는가? 결과값이 미리 설정한 임계치에 포함되는지 검증할 때 활용된다.
5. 페이크(Fake)
대체물
실제 현금은 아니더라도 동네 치킨 가게에서 치킨을 주문하면 현금처럼 사용할 수 있는 쿠폰을 의미한다.
테스트 측면으로 보면 실제 데이터베이스를 통해 테스트를 진행해야하지만 인메모리로 테스트를 진행하거나 실제 결제 대신 가짜 포인트를 차감시키는 것과 같이 대체 구현을 만들어서 테스트를 하는 것을 말한다.