Mock 객체 만드는 방법

SteadySlower·2022년 9월 28일
0
post-custom-banner

Mock 객체가 필요한 이유: Test는 독립적으로

이제 ViewModel에 대한 Unit Test를 본격적으로 작성해보고자 하는데요. 이 작업을 위해서는 Unit Test의 대상 즉 ViewModel이 의존하는 객체를 Mocking해야 합니다. 즉 가짜 객체를 만드는 일입니다.

이러한 Mock이 필요한 이유는 Unit Test는 독립적으로 실시되어야 하기 때문입니다. ViewModel을 테스트할 때 진짜 객체에 의존할 경우를 생각해봅시다. 만약에 어떤 테스트가 통과하지 못한다면 이 원인이 ViewModel에 있는지 아니면 ViewModel이 의존하는 다른 객체인지 알 수 없습니다. 따라서 Mock 객체를 통해서 다른 객체와 독립적으로 해당 ViewModel을 테스트할 수 있도록 합시다.

또한 Test는 네트워크나 DB에 독립적으로 작동해야 합니다. 만약에 Unit Test가 네트워크에 의존한다면 네트워크 속도에 따라서 Test가 오래 걸릴 수도 있습니다. 또한 네트워크 비용도 그만큼 발생을 하겠지요. 만약에 DB에 의존한다면 마찬가지로 시간이 오래 걸릴 수도 있을 뿐더라 테스트를 할 때마다 DB에 쓸모없는 가짜 데이터들이 쌓일 것입니다.

Test는 독립적이어야 합니다. 따라서 Mock 객체를 통해 ViewModel이 의존하는 객체들과의 관계를 끊어줍시다.

Mock 객체 만들기: Serivce 객체

Mocking 하고자 하는 객체

Mocking 하고자 하는 객체는 아래와 같습니다. 이 객체는 db와 wordService라는 다른 객체에 의존하고 있는데요. db는 Firebase로 네트워크를 사용하는 객체입니다. 즉 Unit test를 네트워크에 독립적으로 만들기 위해서 Mocking을 하고자 하는 객체에서도 네트워크에 의존하면 안됩니다.

Mocking을 위해서 Protocol을 만들어두었고 ViewModel 역시 내부에 프로토콜로 해당 객체의 타입을 정의해두었습니다. 보시면 protocol에는 db나 wordService가 전혀 정의되어 있지 않습니다. 따라서 Mocking을 만들 때는 해당 객체들을 구현하지 않고 메소드만 구현하면 됩니다.

protocol WordBookService {
    func saveBook(title: String, completionHandler: @escaping CompletionWithoutData)
    func getWordBooks(completionHandler: @escaping CompletionWithData<[WordBook]>)
    func checkIfOverlap(in wordBook: WordBook, meaningText: String, completionHandler: @escaping CompletionWithData<Bool>)
    func closeWordBook(of toClose: WordBook, to destination: WordBook?, toMove: [Word], completionHandler: @escaping CompletionWithoutData)
}

class WordBookServiceImpl: WordBookService {
    
    let db: Database
    let wordService: WordService
    
    init(database: Database, wordService: WordService) {
        self.db = database
        self.wordService = wordService
    }
    
    func saveBook(title: String, completionHandler: @escaping CompletionWithoutData) {
        db.insertWordBook(title: title, completionHandler: completionHandler)
    }
    
    func getWordBooks(completionHandler: @escaping CompletionWithData<[WordBook]>) {
        db.fetchWordBooks(completionHandler: completionHandler)
    }
    
    func checkIfOverlap(in wordBook: WordBook, meaningText: String, completionHandler: @escaping CompletionWithData<Bool>) {
        db.checkIfOverlap(wordBook: wordBook, meaningText: meaningText, completionHandler: completionHandler)
    }
    
    func closeWordBook(of toClose: WordBook, to destination: WordBook?, toMove: [Word], completionHandler: @escaping CompletionWithoutData) {
        if let destination = destination {
            wordService.copyWords(toMove, to: destination) { [weak self] error in
                if let error = error { completionHandler(error) }
                self?.db.closeWordBook(of: toClose, completionHandler: completionHandler)
            }
        } else {
            db.closeWordBook(of: toClose, completionHandler: completionHandler)
        }
    }
    
}

Mocking을 위한 변수

먼저 Mocking을 위한 객체들입니다. 해당 객체들은 프로토콜에 정의되어 있지 않습니다. 해당 객체들은 각각의 메소드들의 결과(성공 or 실패)를 나타냅니다.

먼저 completionHandler에 데이터를 전달하지 않는 메소드들입니다. 예를 들면 saveBook 같은 메소드가 여기에 해당됩니다. 이 메소드는 Error?만을 completionHandler에 전달하는데요. 만약에 에러를 발생시키고 싶다면 saveBookError에 에러를 할당하면 됩니다. 그렇지 않다면 saveBookError를 nil로 두면 됩니다.

이번에는 completionHandler에 데이터에 전달하는 메소드들입니다. 예를 들면 getWordBooks 같은 메소드입니다. 이 메소드는 데이터를 성공적으로 받아왔다면 [WordBook]과 nil을 전달하고 데이터를 받아오는데 실패했다면 nil과 Error를 전달합니다. 따라서 만약에 데이터를 정상적으로 받아오는 것을 Mocking하고 싶다면 getWordBooksSuccess에 데이터를 할당하면 됩니다. 그렇지 않다면 getWordBooksSuccess를 nil로 두면 됩니다.

class MockWordBookService {
    var saveBookError: Error?
    var getWordBooksSuccess: [WordBook]?
    var checkIfOverlapSuccess: Bool?
    var closeWordBookError: Error?
}

🤔  왜 Success와 Error를 따로 정의하지 않을까?

getWordBooks 같은 메소드들을 위한 변수를 정의할 때 왜 Success와 Error를 구분해서 넣지 않을까요? Success가 있다면 Success를 언래핑해서 completionHandler에 전달하면 되고 Error가 있는 경우 Error를 언래핑해서 전달하면 되는데요.

Mocking된 메소드는 실제 메소드와 동일한 return 값 (여기서는 completionHandler에 전달하는 인자)를 가져야 합니다. getWordBooksSuccess의 경우 데이터를 불러오는데 성공하거나 실패하는 두 가지 밖에 없습니다. 성공하는 경우 데이터와 nil을 실패하는 경우 nil과 Error를 completionHandler에 전달합니다.

하지만 Success와 Error를 별도로 만들 경우 2가지 케이스가 더 생깁니다. Success와 Error가 모두 nil이 아닌 경우 혹은 Success와 Error가 둘 다 nil 인 경우 입니다. 테스트 케이스도 사람이 작성하는 것이다보니 작성하다가 보면 둘 다 할당하거나 할당하지 않는 경우가 있을수도 있습니다.

이 경우 Mocking된 메소드는 실제 메소드와 전혀 다른 방식으로 동작할 위험이 있습니다. 이런 위험을 방지하기 위해서 각 메소드 별로 하나의 변수만을 설정했습니다. 해당 변수의 nil 여부로 메소드가 성공 / 실패 중 하나의 동작만을 하는 것을 보장하기 위함입니다.

Protocol에 정의된 메소드 Mocking 하기

위의 변수 부분에서 Mocking의 원리는 대부분 설명을 했습니다. 각 메소드 별로 변수들이 하나씩 있습니다. 해당 변수의 nil 여부에 따라서 completionHandler에 적합한 인자를 전달해서 메소드 내부에서 실행하면 됩니다.

중요한 것은 모든 분기에 completionHandler는 반드시 1번씩만 실행되어야 한다는 것입니다. 실수해서 completionHandler가 2번 실행되거나 1번도 실행되지 않도록 주의합시다!

extension MockWordBookService: WordBookService {
    func saveBook(title: String, completionHandler: @escaping CompletionWithoutData) {
        if let saveBookError = saveBookError {
            completionHandler(saveBookError)
            return
        }
        completionHandler(nil)
    }
    
    func getWordBooks(completionHandler: @escaping CompletionWithData<[WordBook]>) {
        guard let getWordBooksSuccess = getWordBooksSuccess else {
            let error = AppError.generic(massage: "Mock Error from MockWordBookService.getWordBooks")
            completionHandler(nil, error)
            return
        }
        completionHandler(getWordBooksSuccess, nil)
    }
    
    func checkIfOverlap(in wordBook: WordBook, meaningText: String, completionHandler: @escaping CompletionWithData<Bool>) {
        guard let checkIfOverlapSuccess = checkIfOverlapSuccess else {
            let error = AppError.generic(massage: "Mock Error from MockWordBookService.checkIfOverlap")
            completionHandler(nil, error)
            return
        }
        completionHandler(checkIfOverlapSuccess, nil)
    }
    
    func closeWordBook(of toClose: WordBook, to destination: WordBook?, toMove: [Word], completionHandler: @escaping CompletionWithoutData) {
        if let closeWordBookError = closeWordBookError {
            completionHandler(closeWordBookError)
            return
        }
        completionHandler(nil)
    }
}

Mocking 객체 사용하기

마지막으로 Mocking한 객체를 활용하는 방법입니다. saveBook을 예시로 들겠습니다. saveBook의 경우 saveBookError에 Error를 할당하지 않으면 성공, Error를 할당하면 실패입니다.

context("when saveBook succeeded") {
    it("should be emtpy") {
        viewModel.saveBook()
        expect(viewModel.bookName.isEmpty).toEventually(beTrue())
    }
}
context("when saveBook failed") {
    it("should be not be empty") {
        wordBookService.saveBookError = AppError.generic(massage: "Mock Error")
        viewModel.saveBook()
        expect(viewModel.bookName.isEmpty).toEventually(beFalse())
    }
}

마치며…

회사에서 시니어분이 전수해주신🙏 방법대로 Mocking을 해서 테스트를 해봤습니다. 시니어분 말씀이 여기서 배운 것으로 만족하지 말고 더 연구해서 더 좋은 방법이 있다면 꼭 알려달라고 하시더군요ㅎㅎ 좀 더 좋은 방법이 있는지 계속 배워봐야겠네요👍

profile
백과사전 보다 항해일지(혹은 표류일지)를 지향합니다.
post-custom-banner

0개의 댓글