테스트를 진행한다면 위의 항목들 중에서 어떤 테스트가 필요할지 결정하고, 결정한 테스트가 경계를 벗어나는지 보며 가능한 최소한의 테스트를 선택해야 한다. 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를 잘 분별해서 무엇을, 어떻게 테스트하고 싶은지 명확한 기준을 가져가는 것이 필요하다.