
테스트를 진행한다면 위의 항목들 중에서 어떤 테스트가 필요할지 결정하고, 결정한 테스트가 경계를 벗어나는지 보며 가능한 최소한의 테스트를 선택해야 한다. Dummy -> Stub -> Fake -> Spy -> Mock 순으로 구현이 어려워지며 더 복잡한 테스트가 가능하다.
아래 코드 예시와 함께 설명을 이어가려고 한다.
// 예제에 사용할 데이터
class User {
let id: String
let password: String
init(_ id: String, _ password: String) {
self.id = id
self.password = password
}
}
extension User: Equatable {
static func == (lhs: User, rhs: User) -> Bool {
return lhs.id == rhs.id
}
}
// 그냥 더미 객체
class OtherRepository {
func settings() {
// ...
}
}
// 실제 통신하게 되는 DB
var realDB = [
"선아":"123",
"형석":"456"
]
// Protocol로 Manager 간접화
protocol TestNetworkManagerType {
func createUser(id: String, password: String)
func fetchUser(id: String, password: String) -> User?
}
// 실제 DB와 통신하는 Manager
class TestNetworkManager: TestNetworkManagerType {
func createUser(id: String, password: String) {
realDB[id] = password
}
func fetchUser(id: String, password: String) -> User? {
if realDB[id] == password {
return User(id, password)
} else {
return nil
}
}
}
// 사용자 데이터 읽기 쓰기 비지니스 로직을 담당하는 서비스 + 의존성 주입
class AccountService {
let repository: OtherRepository
let manager: TestNetworkManagerType
init(manager: TestNetworkManagerType,
repository: OtherRepository) {
self.manager = manager
self.repository = repository
}
func createUser(id: String, password: String) {
manager.createUser(id: id, password: password)
}
func fetchUser(id: String, password: String) -> User? {
return manager.fetchUser(id: id, password: password)
}
}
기본적인 세팅부터 해보자. SUT는 TestNetworkManager를 DOC로 받고 있는 AccountService이다. 따라서 해당 DOC를 프로토콜로 간접화 및 의존성 주입을 해놓은 상태이다. 우리는 TestNetworkManager를 TestDouble로 변경해서 해당 기능이 정상적으로 작동하는지 테스트를 해보려고 한다.
// 파라미터에 넣을 그냥 더미 객체, 객체만 필요O, 기능이 필요X
let repository = OtherRepository()
더미의 정체성을 보여주기 위해 테스트마다 넣어놓았다. 말 그대로 파라미터를 채우기 위한 혹은 기능이 비어있는 객체이다.

// Stub
// 실제로 동작하는 것처럼 보이게 만들어 놓은 객체
class NetworkManagerStub: TestNetworkManagerType {
// 단순 메시지 출력
func createUser(id: String, password: String) {
print(id)
print(password)
}
// 특정 상태 -> 실제 DB와 통신하지 않고 그냥 DB에 해당 내역이 없다는 '실패 상태'를 표현. nil을 하드 코딩한 상태.
// id나 password를 변경한다고 해서 다른 상태를 반환하지 않음. 즉 행위 검증이 아닌 상태 검증일 뿐
func fetchUser(id: String, password: String) -> User? {
return nil
}
}
func testFetchUser_WhenUsingStub() {
// 파라미터에 넣을 그냥 더미 객체, 객체만 필요O, 기능이 필요X
let repository = OtherRepository()
// 스텁을 이용한 테스트 : 특정 상태만 테스트
let managerStub = NetworkManagerStub()
let service = AccountService(manager: managerStub, repository: repository)
let user = service.fetchUser(id: dummyID, password: dummyPassword)
// 스텁을 통해 특정 상태(nil)을 고정하고 해당 값이 반영되는지를 테스트
// managerStub에는 nil을 반환하도록 미리 구현, 테스트 통과
XCTAssertNil(user)
}

// Fake
// 실제 로직이 수행되고 다양한 상태를 반영할 수 있도록 구현한다.
class FakeNetworkManager: TestNetworkManagerType {
// 객체 내부에 실제와 동일한 모양으로 상태 변화를 반영하도록 구축
// 가짜 DB 사용
var fakeDB = ["형석":"456"]
func createUser(id: String, password: String) {
fakeDB[id] = password
}
// 실제처럼 구현하지만 완전 실제X (물론 여기는 fakeDB를 제외하면 동일)
func fetchUser(id: String, password: String) -> User? {
if fakeDB[id] == password {
return User(id, password)
} else {
return nil
}
}
}
func testFetchUser_WhenUsingFake() {
// Fake를 이용한 테스트 : 여러 상태를 테스트
let repository = OtherRepository()
let fakeManager = FakeNetworkManager()
let service = AccountService(manager: fakeManager, repository: repository)
// DB에 있는 데이터 Fetch Test
let user = service.fetchUser(id: "형석", password: "456")
XCTAssertNotNil(user)
XCTAssertEqual(user, User(dummyID, dummyPassword))
let nilUser = service.fetchUser(id: "하연", password: "123")
XCTAssertNil(nilUser)
// DB에 데이터 넣기 Test
service.createUser(id: "선아", password: "123")
XCTAssertEqual(fakeManager.fakeDB.count, 2)
service.createUser(id: "하연", password: "678")
XCTAssertEqual(fakeManager.fakeDB.count, 3)
// 기존 데이터 수정, 카운트 늘어나지 않는지 테스트
service.createUser(id: "형석", password: "123")
XCTAssertEqual(fakeManager.fakeDB.count, 3)
}
행위 검증 vs 상태 검증
상태검증은 메서드가 수행된 후 SUT나 DOC의상태를 살펴봄으로써 올바로 동작했는지를 판단하게 된다.
행위검증은 상태검증과는 다르게 SUT가 DOC의 특정 메서드가 호출되었지 등의행위를 검사함으로써 올바로 동작했는지 판단하게 된다. 아래 Spy와 Mock은 행위 검증에 해당, 위의 Stub과 Fake는 상태 검증에 해당한다.

// Spy
// 테스트에서 사용되는 개체, 메소드의 사용 여부 및 정상 호출 여부를 기록하고 요청시 알려줌
// 테스트 더블에 스파이처럼 잠입해서 위의 내용을 기록
class NetworkManagerSpy: TestNetworkManagerType {
// 특정 테스트 메서드가 몇번 호출 되었는지 필요한 경우 전역 변수로 카운트를 설정
public var createCallCount: Int = 0
public var fetchCallCount: Int = 0
// 가장 최근에 생성된 아이디와 패스워드 기록
public var checkIDAndPassword: (id: String, password: String)?
func createUser(id: String, password: String) {
createCallCount += 1
checkIDAndPassword = (id: id, password: password)
}
func fetchUser(id: String, password: String) -> User? {
fetchCallCount += 1
return nil
}
}
func testFetchAndCreateUser_WhenUsingSpy() {
// spy
let repository = OtherRepository()
let spy = NetworkManagerSpy()
let service = AccountService(manager: spy, repository: repository)
// 스파이 검증
let _ = service.fetchUser(id: dummyID, password: dummyPassword)
service.createUser(id: dummyID, password: dummyPassword)
XCTAssertEqual(spy.createCallCount, 1)
XCTAssertEqual(spy.fetchCallCount, 1)
XCTAssertEqual(spy.checkIDAndPassword?.id, dummyID)
XCTAssertEqual(spy.checkIDAndPassword?.password, dummyPassword)
}

// Mock
// 테스트 내용
// fist useCase : 서비스에서 유저 생성하기
// 파라미터가 정상 전달 되는지, 한 번의 호출로 목적을 이룰 수 있는지,
// 해당 파라미터로 정상적인 생성이 가능한지, DB에 잘 저장이 되는지
// second useCase : 서비스에서 유저 가져오기
// 파라미터가 정상 전달 되는지, 한 번의 호출로 목적을 이룰 수 이는지,
// 해당 파라미터로 정상적인 가져오기가 가능한지
class TestNetworkManagerMock: TestNetworkManagerType {
typealias Account = (id: String, password: String)
// 스파이의 기록, 스텁의 가짜 데이터까지 모두 검증해서 테스트의 결과까지 내어놓는 객체
private var fakeDB = [String:String]()
private var createCallCount: Int = 0
private var fetchCallCount: Int = 0
private var createIDAndPassword: Account?
private var fetchIDAndPassword: Account?
func createUser(id: String, password: String) {
createCallCount += 1
createIDAndPassword = (id: id, password: password)
fakeDB[id] = password
}
func fetchUser(id: String, password: String) -> User? {
fetchCallCount += 1
fetchIDAndPassword = (id: id, password: password)
if fakeDB[id] == password {
return User(id, password)
} else {
return nil
}
}
// mock은 총체적 행동으로 테스트를 자체적으로 성공, 실패시킬 수 있음
func verifyCreate(
callCount: Int,
parameters: Account,
file: StaticString = #file,
line: UInt = #line) {
XCTAssertEqual(createCallCount, callCount,
"call count", file: file, line: line)
XCTAssertEqual(createIDAndPassword?.id, parameters.id,
"valid id", file: file, line: line)
XCTAssertEqual(createIDAndPassword?.password, parameters.password,
"valid password", file: file, line: line)
let valid = fakeDB.contains { data in
data.key == parameters.id
}
XCTAssertTrue(valid, "isCreateed", file: file, line: line)
}
func verifyFetch(
callCount: Int,
parameters: Account,
file: StaticString = #file,
line: UInt = #line) {
XCTAssertEqual(fetchCallCount, callCount)
XCTAssertEqual(fetchIDAndPassword?.id, parameters.id,
"valid id", file: file, line: line)
XCTAssertEqual(fetchIDAndPassword?.password, parameters.password,
"valid password", file: file, line: line)
XCTAssertEqual(fakeDB[parameters.id], parameters.password,
"valid fetch", file: file, line: line)
}
}
func testFetchAndCreateUser_WhenUsingMock() {
// dummy
let repository = OtherRepository()
let mock = TestNetworkManagerMock()
let service = AccountService(manager: mock, repository: repository)
service.createUser(id: "형석", password: "1234")
let _ = service.fetchUser(id: "형석", password: "1234")
mock.verifyCreate(callCount: 1, parameters: (id: "형석", password: "1234"))
mock.verifyFetch(callCount: 1, parameters: (id: "형석", password: "1234"))
}

위의 Test Double이 명확하게 나눠지는 것은 아닌 것 같다. 특히 테스트하는 상황이 복잡하거나 필요에 따라서 형태를 달리 할 수 있을 듯하다. useCase를 분명히 하고 SUT와 DOC를 잘 분별해서 무엇을, 어떻게 테스트하고 싶은지 명확한 기준을 가져가는 것이 필요하다.