May 07, 2021, TIL (Today I Learned) - Mocking the Right Way

Inwoo Hwang·2021년 8월 26일
0
post-thumbnail

학습 내용


Unit Tests: Mocking the right way

Fast(빠르게) : 테스트는 빨리 실행되야 한다. 그래서 사람들이 신경쓰지 않는다.

Independent/Isolated(독립적/분리된) : 테스트는 따로 설정이나 분리를 해서는 안된다.

Repeatable(반복가능한) : 테스트 수행할때마다 동일한 결과를 얻어야 한다. 외부의 데이터 공급자나 동시성(concurrency) 문제로 인해 일시적으로 오류가 발생 할수 있다.

Self-validating(자체 검증) : 테스트는 완전히 자동화 되어야 한다. 로그 파일에 대한 프로그래머의 해석보다는 패스(pass)또는 실패(fail)를 출력해야 한다.

Timely(적시에) : 이상적인 테스트는 테스트한 생산 코드를 작성하기 전에 작성해야 한다.

Given - When - Then

Given: 전제조건, 현재 상황

When: 행위

Then: 행위에 의한 결과

Test Double

Test Double이란?

실제 객체를 대신하여 테스트 목적으로 설계된 다양한 테스트 객체들을 의미합니다.

아래 코드 예시에는 PhoneShoppingMall 이라는 클래스 객체를 확인할 수 있습니다.

해당 객체를 여러가지 Test Double을 활용하여 테스트를 진행할 수 있습니다.

final class PhoneShoppingMall {
    private let mailService: MailServiceProtocol
    private let phoneStorageManager: PhoneStorageManager
    
    private var phones: [Phone]
    
    init?(_ mailService: MailServiceProtocol, _ phoneStorageManager: PhoneStorageManager) {
        self.mailService = mailService
        self.phoneStorageManager = phoneStorageManager
        
        let result = phoneStorageManager.getAllPhones()
        
        switch result {
        case .success(let phones):
            self.phones = phones
        case .failure(let error):
            NSLog(error.localizedDescription)
            return nil
        }
    }
    
    var totalPhoneStocks: Int {
        return phones.count
    }
    
    func add(_ newPhones: [Phone]) {
        phones.append(contentsOf: newPhones)
    }
    
    func remove(amount: Int) {
        if phones.count > amount {
            phones.removeLast(amount)
        } else {
            mailService.send(message: Message(name: "ShoppingMall", content: "Dear Customer, Sorry we are out of stock", address: "동작구", contact: 01012122323))
        }
    }
}

해당 객체는 mailService 를 통해 메세지를 전달하는 기능을 수행할 수 있고 phoneStorageManager 를 통해 핸드폰 재고를 관리할 수 있습니다.

각 기능은 외부로부터 주입 받게 설계되었기에 독립적인 테스트가 가능합니다.

PhoneShoppingMall 의 프로퍼티가 채택하고 있는 프로토콜은 아래와 같습니다.

protocol MailServiceProtocol {
    func send(message: Message)
}
protocol PhoneStorageManager {
    func getAllPhones() -> Result<[Phone], Error>
}

Dummy: 유닛 테스트 결과에는 아무런 영향을 미치지 않는 것. 테스트 중 의존성이 단순히 명시되어야 하는 부분에 넣으면 됩니다. 의미는 없으나 파라미터에 들어가야 하는 값

final class PhoneStorageDummy: PhoneStorageManager {
    func getAllPhones() -> Result<[Phone], Error> {
        return .success([])
        
    }
}

PhoneShoppingMall 클래스를 초기화 하기위해 필요한 dummy 객체는 위와 같습니다.

해당 dummy 객체를 활용하여 PhoneShoppingMall이 정상적으로 초기화되는지 아래와 같이 테스트 해 볼 수 있습니다.

class UnitTestPractice: XCTestCase {
    var sut_phoneshoppingmall: PhoneShoppingMall!
  
  func test_dummy객체를_활용하여_sut_phoneshoppingmall_이_정상적으로_초기화되는지_체크() {
        
        // given
        let mockMailService = MailServiceMock()
        let dummyPhoneStorage = PhoneStorageDummy() // dummy 객체
        
        // when
        sut_phoneshoppingmall = PhoneShoppingMall(mockMailService, dummyPhoneStorage)
        
        // then
        XCTAssertNotNil(sut_phoneshoppingmall)
    }
  
}

Stub: 유닛 테스트시 호출된 요청에 미리 준비해 둔 결과를 제공하는 것. 테스트를 위해 프로그래밍된 내용 이외에는 응답하지 않음

PhoneShoppingMall 실패할 수 있는 이니셜라이저를 통해 초기화됩니다. 이는 모든 전달인지가 정상적인 값을 가지고 있어야 초기화가 가능하다는 것을 의미합니다. nil값이 들어오면 초기화가 되지 않는다는 것을 stub 객체를 활용하여 테스트 해 볼수 있습니다.

final class PhoneStorageStub: PhoneStorageManager {
    
    enum DataError: Error {
        case dataFetchError
    }
    
    func getAllPhones() -> Result<[Phone], Error> {
        return .failure(DataError.dataFetchError)
        
    }
}

위 stub 객체를 통해 PhoneShoppingMall 을 초기화 하면 getAllPhones() 메서드 호출 시 에러를 반환하기에 아래와 같이 phoneShoppingMall 초기화시 nil을 반환하게 됩니다.

func test_stub객체를_활용하여_sut_phoneshoppingmall의_초기화가_실패하는지_체크() {
        
        // given
        let mockMailService = MailServiceMock()
        let stubPhoneStorage = PhoneStorageStub()
        
        // when
        sut_phoneshoppingmall = PhoneShoppingMall(mockMailService, stubPhoneStorage)
        
        // then
        XCTAssertNil(sut_phoneshoppingmall)
    }

Spies: Stub 역할을 하며 호출된 내용에 대해 정보를 기록하는 것. 테스트에서 확인하기 위한 정보. 이메일 서비스 같은 경우 stub로 활용하면서 몇 개의 메세지를 해당 객체가 보냈는지 기록할 수 있습니다.

final class MailServiceSpy: MailServiceProtocol {
    var numberOfMailSent = 0
    
    func send(message: Message) {
        numberOfMailSent += 1
    }
}
func test_spy객체를_활용하여_mailService의_send메서드_호출시_몇개의_메세지를_보냈는지_체크() {
        
        // given
        let spyMailService = MailServiceSpy()
        let dummyPhoneStorage = PhoneStorageDummy()
        sut_phoneshoppingmall = PhoneShoppingMall(mockMailService, dummyPhoneStorage)
        
        // when
        sut_phoneshoppingmall.add([
            Phone(brand: "삼성", price: 123)
        ])
        sut_phoneshoppingmall.remove(amount: 2)
        
        // given
        XCTAssertEqual(spyMailService.numberOfMailSent, 1)

핸드폰의 재고는 총 한개로 구현 해 놓은 상태에서 2 대의 핸드폰을 재고에서 없애도록 테스트 로직을 짜봤습니다. remove() 메서드의 로직은 재고보다 더 많은 양의 핸드폰을 필요로 하면 send() 메서드 호출시 mailService 가 메세지를 보내도록 설계를 해 놓았습니다. 총 한 번만 메세지를 보내야 하고 정상적으로 한 번만 보내지는지 메세지 숫자를 기록하였습니다.

Fake: 동작하지만 실제 프로덕션에서는 적합하지 않거나 동일하지 않은 객체(in-memory database가 좋은 예시)

final class PhoneStorageFake: PhoneStorageManager {
    
    enum dbError: Error {
        case decodingError
    }
    
    func getAllPhones() -> Result<[Phone], Error> {
        guard let path = Bundle.main.path(forResource: "phone_sample", ofType: "json"),
              let data = FileManager.default.contents(atPath: path),
              let result = try? JSONDecoder().decode([Phone].self, from: data) else {
            return .failure(dbError.decodingError)
        }
        
        return .success(result)
    }
}

Resource 파일 내에 존재하는 phone_sample 파일을 활용하여 실제 database를 대체할 수 있습니다. sample JSON file을 읽는 fake phone storage를 위와 같이 만든 뒤 add와 remove 기능이 정상적으로 동작하는지 테스트 해 볼 수 있습니다.

func test_sut_phoneshoppingmall_fake객체를_활용하여_add_remove메서드가_정상적으로_작동하는지_체크() {
        
        // given
        let mockMailService = MailServiceMock()
        let fakePhoneStorage = PhoneStorageFake()
        sut_phoneshoppingmall = PhoneShoppingMall(mockMailService, fakePhoneStorage)
        
        // when
        sut_phoneshoppingmall.add([
            Phone(brand: "apple", price: 132),
            Phone(brand: "Samsung", price: 112)
        ])
        sut_phoneshoppingmall.remove(amount: 1)
        
        // then
        XCTAssertEqual(sut_phoneshoppingmall.totalPhoneStocks, 5)
    }

JSON 파일로부터 읽어온 데이터는 총 3개고 테스트시 2개를 추가하였으니 총 5개가 맞는지 테스트를 진행 했습니다.

Mock: 호출에 대한 기대를 명세하고 그에 따라 동작하도록 프로그래밍 된 테스트 객체

Mock과 Stub의 차이

위에 명시된 테스트 더블 중 mock 객체만 behavior verification 행위 검증을 고집합니다. 다른 테스트 더블 같은 경우 state verification상태 검증을 사용합니다.

여기서 behavior verification은 객체의 behavior 즉 동작을 검증하는 테스트이고 반면에 state verification 같은 경우 객체에 의해서 변화된 값을 검증하는 테스트라고 저는 이해했습니다.

State Verification

we determine whether the exercised method worked correctly by examining the state of the SUT and its collaborators after the method was exercised.

Mocks Aren't Stubs - Martin Fowler

Behavior Verification

Mocks use behavior verification, where we instead check to see if the order made the correct calls..We do this check by telling the mock what to expect during setup and asking the mock to verify itself during verification

final class MailServiceMock: MailServiceProtocol {
    
    var didSendMethodCalled = false
    var numberOfMailSent = 0
    var name = ""
    var content = ""
    
    func send(message: Message) {
        didSendMethodCalled = true
        numberOfMailSent += 1
        name = message.getName()
        content = message.getContent()
    }
}

Mock 객체를 통해서 특정한 메서드가 몇 번 호출 되었고 더 나아가서 mockc 객체를 활용하여 해당 메서드의 기능을 통해 특정 클래스의 행위와 데이터 흐름을 점검할 수 있습니다.

MailServiceMock 에서는 send() 메서드가 호출되면 send() 메서드가 정상적으로 호출되었는지 그리고 메일 보낸 횟수 그리고 메일의 내용이 정확한지 확인하는 절차를 거치게 됩니다.

func test_mock객체를_활용하여_mailService의_send동작이_문제없는지_체크() {
        
        // given
        let mockMailService = MailServiceMock()
        let dummyPhoneStorage = PhoneStorageDummy()
        sut_phoneshoppingmall = PhoneShoppingMall(mockMailService, dummyPhoneStorage)
        
        // when
        sut_phoneshoppingmall.add([
            Phone(brand: "삼성", price: 123)
        ])
        sut_phoneshoppingmall.remove(amount: 2)
        
        // given
        
        // verify that send(message:) is called
        XCTAssertTrue(mockMailService.didSendMethodCalled)
        
        // verify that only 1 message is sent
        XCTAssertEqual(mockMailService.numberOfMailSent, 1)
        
        // veryfy that the content is an expected content
        XCTAssertEqual(mockMailService.content, "Dear Customer, Sorry we are out of stock")
    }

실제 테스트시 원하는데로 메일서비스가 동작하는지 체크할 수 있습니다.

Classical TDD vs Mockist TDD

Classical TDD

  • Test 작성 시 타겟외에 필요한 모든 클래스를 실제 객체 또는 공통으로 사용될 수 있는 fixture를 활용하여 만들어 나가는 방식입니다.
    • fixture에 대한 자세한 내용은 여기 를 참고하세요.
  • 실제 객체를 사용하지 못할 경우에만 test double을 이용합니다.

Mockist TDD

  • Test 작성시 타겟이외에 필요한 모든 개겣를 mock으로 대체하여 만들어 나가는 방식입니다.
  • test마다 fixture를 필요로 합니다.

[참고자료]:

TDD와 Xcode에서 XCTest 활용하기 (velog.io)

Unit Tests (Swift): Mocking the right way. | by Saad El Oulladi | Medium

때로는 까칠하게..

iOS Unit Testing and UI Testing Tutorial | raywenderlich.com

Test Doubles in Swift: Dummy, Fake, Stub, Mock

Mocks Aren't Stubs - Martin Fowler

profile
james, the enthusiastic developer

0개의 댓글