지난 글에 이어서 Swift의 Test에 대해 더 알아보겠다.
테스트에서 가장 중요한 점은 몇 번의 테스트를 하든 동일한 결과가 나와야 한다는 점이다.
그런데 네트워크 통신 메서드의 경우 서버의 상황에 의해서 테스트가 실패할 가능성이 생긴다.
즉, 네트워크 통신 테스트의 유의점은 다음과 같다.
- 네트워크 통신이 잘 되는 상황에 대해서만 일관적인 결과를 얻을 수 있다.
- 통신에 문제가 있는 경우 결과가 달라질 수 있다. 즉, 외부 환경에 영향을 받는다.
- 네트워크 통신과 무관한 상태로 테스트 코드를 작성하는게 중요하다.
이러한 특징 때문에 네트워크 통신을 추상화하고, 실제 데이터가 아닌 Mock 데이터로 테스트를 하는게 중요하다.
이때 사용하는게 Test Double이라는 객체이다.
Test Double이란 실제 객체 대신 테스트를 수행하는 객체를 말하는데, 흔히 말하는 Mock데이터나 Stub 데이터가 여기에 속한다.
Test Double의 종류는 다음과 같다
- Dummy
- Fake
- Stub
- Spy
- Mock
언뜻보면 비슷해보이지만 이들은 서로 구분되며,
또 그러면서도 명확히 경계가 있다기보단 연속적인 개념인 이 5개의 Test Double에 대해 알아보겠다.
Test를 위한 네트워크 로직 추상화
Test Double의 예시로 로또 번호를 받아오는 API를 사용할 예정이며,
그 모델과, 실제로 네트워크 통신을 하는 NetworkManager가 있다.
그리고 테스트를 위해 먼저 NetworkPrvider라는 프로토콜로 추상화를 한 상태이다.
struct Lotto: Codable {
let drwNoDate: String
let bnusNo: Int
let drwtNo1: Int
}
// 프로토콜로 추상화
protocol NetworkProvider {
func fetchLotto(completionHandler: @escaping (Lotto) -> Void)
}
// 실제 네트워크 통신을 하는 NetworkManager
class NetworkManager: NetworkProvider {
static let shared = NetworkManager()
private init() { }
let url = "https://www.dhlottery.co.kr/common.do?method=getLottoNumber&drwNo=1000"
func fetchLotto(completionHandler: @escaping (Lotto) -> Void) {
AF.request(url).responseDecodable(of: Lotto.self) { response in
switch response.result {
case .success(let success):
completionHandler(success)
case .failure(let failure):
print(failure)
}
}
}
}
그리고 LottoViewModel이라는 로직을 담고잇는 ViewModel이 있다고 가정하면 다음과 같은 형태일 것이다.
class LottoViewModel {
private let networkManager: NetworkProvider
init(networkManager: NetworkProvider) {
self.networkManager = networkManager
}
func transform() {
networkManager.fetchLotto { lotto in
print(lotto)
}
}
}
이 상태에서 5가지 Test Double의 사례를 살펴보겠다.
Test Double 예시
* sut의 선언과 데이터를 초기화하는 부분은 test 코드 외부이지만, 편의를 위해 내부에 작성
1. Dummy
Dummy는 인스턴스만 필요하고, 실제 호출은 전혀 사용되지 않는 경우 사용한다.
메서드 구현부를 비워놓는 등 아무런 동작을 하지 않는다.
class DummyNetworkManager: NetworkProvider {
func fetchLotto(completionHandler: @escaping (Lotto) -> Void) {
// 아무런 동작 X
}
}
func testTransform_withDummy_doesNotCrash() {
let vm = LottoViewModel(networkManager: DummyNetworkManager())
vm.transform()
}
2. Stub
Stub은 특정 입력에 대해 정해진 값을 반환하도록 설정된 객체이다.
하드코딩된 고정된 값을 반환한다.
class StubNetworkManager: NetworkProvider {
let stubbedLotto: Lotto
init(stubbedLotto: Lotto) {
self.stubbedLotto = stubbedLotto
}
func fetchLotto(completionHandler: @escaping (Lotto) -> Void) {
completionHandler(stubbedLotto)
}
}
func testFetchLotto_returnsStubbedLotto() {
// 1) 테스트용 데이터
let expected = Lotto(drwNoDate: "2025-05-01", bnusNo: 17, drwtNo1: 3)
sut = StubNetworkManager(stubbedLotto: expected)
// 2) API 호출
var received: Lotto?
sut.fetchLotto { lotto in
received = lotto
}
// 3) 고정 응답 검증
XCTAssertEqual(received?.drwNoDate, expected.drwNoDate)
XCTAssertEqual(received?.bnusNo, expected.bnusNo)
XCTAssertEqual(received?.drwtNo1, expected.drwtNo1)
}
3. Fake
Fake는 실제 로직을 흉내내지만, 더 가볍고 테스트에 적합한 구현체이다.
실제 네트워크 로직처럼 JSON 디코딩 로직은 있지만, 외부 네트워크 없이 처리한다.
class FakeNetworkManager: NetworkProvider {
private let jsonData: Data
// JSON 문자열을 받아서 디코딩 로직까지 실제처럼 수행
init(jsonString: String) {
self.jsonData = Data(jsonString.utf8)
}
func fetchLotto(completionHandler: @escaping (Lotto) -> Void) {
let decoder = JSONDecoder()
if let lotto = try? decoder.decode(Lotto.self, from: jsonData) {
completionHandler(lotto)
} else {
fatalError("JSON 디코딩 실패")
}
}
}
func testFetchLotto_withFake_parsesJSONCorrectly() {
// 1) 실제처럼 JSON 디코딩
let json = """
{"drwNoDate":"2025-05-01","bnusNo":17,"drwtNo1":3}
"""
sut = FakeNetworkManager(jsonString: json)
// 2) 호출
var received: Lotto?
sut.fetchLotto { received = $0 }
// 3) 파싱 로직 검증
XCTAssertEqual(received?.drwNoDate, "2025-05-01")
XCTAssertEqual(received?.bnusNo, 17)
XCTAssertEqual(received?.drwtNo1, 3)
}
4. Spy
Spy는 어떠한 메서드가 호출됨으로써 발생할 수 있는 사이드이펙트를 기록할 때 사용한다.
메서드 호출 여부나 호출 횟수 등을 검증할 때 사용한다.
class SpyNetworkManager: NetworkProvider {
private(set) var fetchCalled = false
private(set) var callCount = 0
func fetchLotto(completionHandler: @escaping (Lotto) -> Void) {
fetchCalled = true
callCount += 1
completionHandler(Lotto(drwNoDate: "", bnusNo: 0, drwtNo1: 0))
}
}
func testTransform_callsFetchExactlyOnce() {
let spy = SpyNetworkManager()
let vm = LottoViewModel(networkManager: spy)
vm.transform()
vm.transform() // 두 번 호출해보기
XCTAssertTrue(spy.fetchCalled)
XCTAssertEqual(spy.callCount, 2)
}
5. Mock
Mock은 사전에 기대(expetation)를 설정하고 그 기대에 맞게 호출됐는지 검증하기 위해 사용한다.
Mock은 Spy의 기능을 포함할 뿐 아니라, Dummy, Stub 처럼 동작할 수 있는만큼 넓은 범위를 차지한다.
class MockNetworkManager: NetworkProvider {
var expectedCalls = 0
private(set) var actualCalls = 0
func fetchLotto(completionHandler: @escaping (Lotto) -> Void) {
actualCalls += 1
completionHandler(Lotto(drwNoDate: "X", bnusNo: 0, drwtNo1: 0))
}
func verify() {
XCTAssertEqual(actualCalls, expectedCalls,
"fetchLotto()가 \(expectedCalls)번 호출되어야 하지만, \(actualCalls)번 호출됨")
}
}
func testTransform_verifiesExpectedCallCount() {
let mock = MockNetworkManager()
mock.expectedCalls = 1
let vm = LottoViewModel(networkManager: mock)
vm.transform()
mock.verify()
}
각 Test Double은 상황에 맞게 적절하게 사용함으로써 Test의 일관성을 보장할 수 있을 것이다.
그리고 이들은 명확하게 구분되는 것은 아니고, 다음과 같이 연속된 형태로 이해하면 된다.

*참고자료
Unit Testing: Exploring The Continuum Of Test Doubles
Ask Learn Ask Learn Read in English Save Table of contents Read in English Add Add to plan Share via Facebook x.com LinkedIn Email Print Note Access to this page requires authorization. You can try signing in or changing directories. Access to this page re
learn.microsoft.com
Test Double (Swift)
Test Double (Swift) 간단 용어 정리테스트 더블이란, doc와 동일한 API를 제공 sut (system under test): 테스트 대상 doc (depended-on component): sut이 의존하고 있는 구성요소 왜 필요할까?Solitary or Sociable?, 테스
rldd.tistory.com
https://cliearl.github.io/posts/android/android-test-basic/
안드로이드 테스트 자동화 기초
이번 영상에서는 안드로이드의 테스트 자동화 기초에 대한 이론을 다루어 보도록 하겠습니다.\n들어가기 테스트는 소프트웨어 개발과 함께 시작된 행위니만큼 긴 역사를 가지고 있습니다. The Hi
cliearl.github.io
'iOS > Swift' 카테고리의 다른 글
| [iOS/Swift] 새로 등장한 접근제어자, package (0) | 2025.04.12 |
|---|---|
| [iOS/Swift] 별도의 프로세스 - PHPicker를 사용할때 권한요청이 필요 없는 이유 (0) | 2025.04.07 |
| [iOS/Swift] Swift의 UnitTest 살펴보기 (0) | 2025.03.31 |
| [iOS/Swift] 컴파일러 최적화 관점에서 본 Opaque Type (0) | 2025.03.28 |
| [iOS/Swift] Kingfisher를 쓰지 않고 이미지 캐싱 구현하기 - NSCache (0) | 2025.03.25 |