Test Double 파헤치기

박형석·2021년 12월 14일
0

Unit Test

목록 보기
2/3
post-thumbnail

Test Double의 분류 및 개념

테스트를 진행한다면 위의 항목들 중에서 어떤 테스트가 필요할지 결정하고, 결정한 테스트가 경계를 벗어나는지 보며 가능한 최소한의 테스트를 선택해야 한다. Dummy -> Stub -> Fake -> Spy -> Mock 순으로 구현이 어려워지며 더 복잡한 테스트가 가능하다.

Setting

아래 코드 예시와 함께 설명을 이어가려고 한다.

// 예제에 사용할 데이터
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로 변경해서 해당 기능이 정상적으로 작동하는지 테스트를 해보려고 한다.

Dummy

  • 객체가 필요하지만 내부 기능이 필요하지는 않을 때 사용
  • 단순히 인스턴스화될 수 있는 수준으로만 객체를 구현
  • 인스턴스화된 객체가 필요할 뿐 해당 객체의 기능까지는 필요하지 않은 경우에 사용
  • 단순히 파라미터로 넣어줄 데이터를 의미
 // 파라미터에 넣을 그냥 더미 객체, 객체만 필요O, 기능이 필요X
let repository = OtherRepository()

더미의 정체성을 보여주기 위해 테스트마다 넣어놓았다. 말 그대로 파라미터를 채우기 위한 혹은 기능이 비어있는 객체이다.

Stub

  • 테스트만을 위해 프로그래밍된 항목
  • 데미데이터 보다 좀 더 구현된 객체
  • 마치 실제로 동작하는 것처럼 보이게 만들어 놓은 객체인데, 특정 상태를 가정해서 만들어 특정 값을 리턴해주거나, 메시지를 출력해 준다.
  • 특정 상태를 가정한 하드코딩된 상태(반환 값을 미리 정의), 따라서 로직에 따른 값의 변경을 테스트할 수는 없다.
  • 어떤 행위가 호출됐을 때 특정 값으로 리턴시켜주는 형태
// 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

  • 여러 상태를 대표할 수 있도록 구현된 객체, 실제 로직이 구현된 것처럼 보인게 하는게 목적
  • 실제로 DB에 접속해서 비교할 때와 동일한 모양이 보이도록 객체 내부에 구현할 수 있음
    • 테스트 케이스 작성을 위해 다른 객체들과의 의존성을 제거하기 위해 사용
    • 페이스 객체를 만들 때 복잡도로 인해서 노력이 많이 들어갈 경우
      • 적절한 수준에서 구현, 아니면 Mock 프레임 워크 사용
      • 그냥 실제 객체를 가져와 테스트

// 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

  • 테스트를 위해 구현하는 항목
  • 메소드의 사용 여부, 정상 호출 여부를 기록하고 요청시 알려주는 객체
  • 테스트 더블로 구현된 객체에 자기 자신이 호출 되었을 때 확인이 필요한 부분을 기록하도록 구현
  • 특정 메소드가 호출 되었을 때 또 다른 메서드가 실행이 되어야 한다와 같은 행위 기반 테스트가 필요한 경우 사용
  • 특정 테스트 메서드가 몇번 호출되었는지 필요한 경우 전역 변수로 카운트를 설정, 특정 테스트 메서드에 카운트를 올리는 부분을 추가한 후 이 카운트를 가져오는 메서드를 추가한다.
// 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

  • 행위 검증 테스트 → 사실상 위의 모든 테스트가 엮여진 테스트
  • 스파이의 기록, 스텁의 가짜 데이터까지 모두 검증해서 테스트의 결과까지 내어놓는다.
    다시 말해서 mock은 총체적 행동으로 테스트를 자체적으로 성공, 실패시킬 수 있다. (mock 내부에 Assert 문이 있음)
  • Mock을 제작한 후에 우리가 할 일은 이 mock에 테스트하고자 하는 내용을 넣고 테스트의 성공과 실패 여부를 확인하는 것 뿐이다.
  • 구현이 복잡하고 어렵기 때문에 상태 검증으로 가능한지 먼저 판단, 어렵다면 어떤 프레임워크로 Mock을 구현할건지 생각해보고 사용.
// 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를 잘 분별해서 무엇을, 어떻게 테스트하고 싶은지 명확한 기준을 가져가는 것이 필요하다.

profile
IOS Developer

0개의 댓글