Jon reid swift try cover

Swift의 강력한 mock 객체 만들기

Swift에서는 mock 객체들을 직접 만들어 사용하며, mock 객체 구조에 맞춰 유닛 테스트 코드를 작성합니다. 표현력이 풍부한 테스트를 만들기 위해 mock 객체를 조금 더 강력하게 만들 수는 없을까요? mocking 라이브러리로부터 얻을 수 있는 교훈은 무엇일까요? 이 강연에서 Jon Reid는 Objective-C 라이브러리인 OCMockito를 작성한 경험을 바탕으로 Swift에서 mock을 작성하는 방법에 대해 알려줄 것입니다.


소개

American Express에서 iOS 개발자로 근무하고 있는 Jon Reid입니다. 저는 오늘 mock 객체에 대해 이야기할 예정이며, Swift에서 강력하고 유연한 mock 객체를 만드는 방법을 알려드리겠습니다.

인터렉션 테스트(Interaction Test)

왜 mock 객체가 필요할까요? 실제 객체(real object) 대신 mock 객체를 사용하는 이유는 무엇일까요?

레스토랑에서 웨이터에게 식사를 주문하는 상황을 가정해보겠습니다. 웨이터는 당신의 주문을 요리사에게 전달할 것입니다. 하지만 진짜 요리사(real cook)는 시간도 오래 걸리고 항상 주방에 있지는 않기 때문에 유닛 테스트에서 진짜 요리사를 사용하고 싶지 않네요. 또한, 실제로 요리를 한다면 자원을 사용하게 될 것이고 이것은 전역 상태(global state)에도 영향을 줍니다.

유닛 테스트를 위해 우리는 가짜 요리사(fake cook)를 사용하고 싶습니다. 실제 요리의 결과를 테스트하는 대신, 웨이터와 요리사가 어떻게 커뮤니케이션 하는지를 테스트합니다. 이것이 바로 인터렉션 테스트입니다. 여기서는 웨이터가 요리사에게 보내는 메시지에 대한 계약관계(contract)를 테스트합니다.

Swift에서는 실제 객체를 가짜 객체로 대체하기 위해 일반적으로 프로토콜을 사용합니다. 해당 프로토콜은 테스트뿐만 아니라 구현 코드에서도 사용합니다. 실제 객체를 사용하든, 가짜 객체를 사용하든 프로토콜을 만족하기만 하면 됩니다.

앞에서 언급한 요리 예제에 대해서 좀 더 이야기해보죠. cookRamen 이라는 메서드가 포함된 프로토콜이 있습니다.


enum RamenSoup {
    case shio // salt base
    case shoyu // soy sauce base
    case miso // miso paste base
    case tonkotsu // pork base, creamy
}

protocol CookProtocol {
    func cookRamen(
        bowls: Int,
        soup: RamenSoup,
        extras: [String]) -> Void
}


bowls 인자를 이용해 라면을 몇 그릇 주문할지 설정할 수 있으며 열거형으로 구성한 RamenSoup을 통해 soup의 종류를 정의할 수 있습니다. 또한, extra 인자를 통해 토핑같은 추가재료들을 정의할 수 있습니다. (_요리는 시간이 걸리기 때문에 completion handler가 있는 것이 보통이지만 예제를 단순하게 만들기 위해 추가하지 않았습니다.)

웨이터에 대한 코드입니다.


struct Waiter {
    let cook: CookProtocol

    func order() {
        cook.cookRamen(
            bowls: 2,
            soup: .miso,
            extras: ["wakame", 
                    "tamago",
        ])
    }
}


cook은 상수(constant)이며 Waiter를 초기화할 때 그 값을 설정합니다. 계속되는 주문을 처리하기 위해 Waiter 코드에 builder 디자인 패턴을 적용할 수도 있지만, 위의 예제코드에서는 order메서드에 간단히 인자를 넘겨주는 형태로 구현하였습니다. 예제에서는 미역(wakame)과 계란(tamago)이 추가된 미소(miso) 라면 두 그릇을 주문했습니다. 이 메서드를 어떻게 테스트할 수 있을까요?


class MockCook: CookProtocol {
	func cookRamen(
        bowls: Int,
        soup: RamenSoup,
        extras: [String]) -> Void {
  	}
}

class WaiterTests: XCTestCase {
	func testOrder_ShouldCookRamen() {
        let mockCook = MockCook()
        let waiter = Waiter(cook: mockCook)
        waiter.order()
	}
}

테스트 코드에서 MockCook을 생성합니다. 구현코드에서의 cookRamen 메서드는 어떠한 요리도 하지 않습니다. 그 대신 어떻게 호출되었는지에 대한 정보를 캡쳐하는 역할을 담당합니다. 전달받은 메시지를 기록하는 것은 mock 객체의 중요한 임무입니다.

유닛 테스트에서는 MockCook을 생성한 후, Waiter를 초기화할 때 이것을 인자로 전달(inject)합니다. 그리고 우리가 테스트하고 싶은 메서드인 order 메서드를 호출합니다. order 메서드는 내부에서 cookRamen 메서드를 호출하기 때문에, order 메서드를 호출하는 것은 실제로는 cookRamen 메서드를 호출하는 것입니다.

이런 개발 뉴스를 더 만나보세요

테스트 코드의 마지막 부분에서는 어떤 것을 가정(assert)해야 하는데, 인터렉션 테스트에서는 메서드를 호출할 때 캡처한 정보에 대한 가정을 사용합니다.

Swift에서 mock을 작성할 때 가장 많이 사용하는 패턴부터 살펴보겠습니다. 많은 사람들이 특정 메소드가 호출되었는지 확인하기 위해 다음과 같은 방법으로 assert를 구현합니다.


class WaiterTests: XCTestCase {
	func testOrder_ShouldCookRamen() {
        let mockCook = MockCook()
        let waiter = Waiter(cook: mockCook)
        waiter.order()

        XCTAssertEqual(mockCook.cookRamenWasCalled)
	}
}

이를 위해 많은 사람들이 Boolean 값을 사용하곤 합니다.


class MockCook: CookProtocol {
    var cookRamenWasCalled = false

    func cookRamen(
        bowls: Int,
        soup: RamenSoup,
        extras: [String]) -> Void {
        cookRamenWasCalled = true
    }
}

mock 메서드에서는 메소드가 호출되었다는 것을 기록하기 위해 cookRamenWasCalled 프로퍼티의 속성을 true로 설정합니다. 하지만 이 방법은 정보가 사라진다는 단점이 있습니다. Boolean값은 메서드의 호출 여부만 알려주기 때문에 해당 메서드가 여러 번 호출될 경우 문제가 될 수 있습니다. 이 방법 대신, 메서드가 몇 번이나 호출되었는지 기록해보기로 합시다.


class MockCook: CookProtocol {
    var cookRamenCallCount = 0

    func cookRamen(
        bowls: Int,
        soup: RamenSoup,
        extras: [String]) -> Void {
    	cookRamenWasCalled += 1
	}
}

프로퍼티의 데이터 타입을 Boolean에서 integer로 변경하였고, 카운트를 증가시키는 로직을 추가했습니다. 그리고 assert 테스트 코드를 다시 작성했습니다.


class WaiterTests: XCTestCase {
	func testOrder_ShouldCookRamen() {
        let mockCook = MockCook()
        let waiter = Waiter(cook: mockCook)
        waiter.order()

        XCTAssertEqual(mockCook.cookRamenCallCount, 1)
	}
}

이제 cookRamenCallCount는 1과 같아야 한다고 말할 수 있게 되었습니다. 최근 제 테스트 중의 하나가 메서드를 한 번이 아니라 두 번 이상 호출해서 유닛 테스트가 실패했다는 보고를 받았습니다. 리팩토링을 하면서 에러가 발생한 상황이었고, 테스트가 저에게 이러한 사실을 알려준 것이죠. 이같은 방식을 사용하면 첫 번째 구현코드를 확인할 때 뿐만 아니라 나중에 리팩토링할 때도 도움이 됩니다.

함수 호출 횟수를 관리하는 프로퍼티를 추가했고, 이제 메서드 인자들에 대한 정보를 캡쳐해봅시다.


class MockCook: CookProtocol {
    var cookRamenCallCount = 0
    var cookRamenBowls = 0
    var cookRamenSoup: RamenSoup?
    var cookRamenExtras: [String] = []

    func cookRamen(
        bowls: Int,
        soup: RamenSoup,
        extras: [String]) -> Void {
        cookRamenCallCount += 1
        cookRamenBowls = bowls
        cookRamenSoup = soup
        cookRamenExtras = extras
    }
}

각 인자들을 프로퍼티에 저장하겠습니다. 우선, 몇 그릇을 주문했는지, soup 종류는 무엇인지에 대한 정보와 extras에 대한 배열을 저장했습니다. 하지만 이번에도 정보가 사라져버렸네요. 우리는 마지막으로 주문받은 것을 인자로 넘겨서 저장하고 있습니다. 마지막 주문을 인자로 사용하는 것은 별다른 문제가 없습니다. 하지만 좀 더 명확하게 이같은 사실을 알릴 필요가 있습니다.


class MockCook: CookProtocol {
    var cookRamenCallCount = 0
    var cookRamenLastBowls = 0
    var cookRamenLastSoup: RamenSoup?
    var cookRamenLastExtras: [String] = []

    func cookRamen(
                   bowls: Int,
                   soup: RamenSoup,
                   extras: [String]) -> Void {
        cookRamenCallCount += 1
        cookRamenLastBowls = bowls
        cookRamenLastSoup = soup
        cookRamenLastExtras = extras
    }
}

마지막 주문의 인자들만 사용하고 있다는 사실을 보다 확실하게 전달하기 위해 cookRamenLastBowls와 같은 형태로 프로퍼티 이름을 변경했습니다. 이제 테스트 코드는 다음과 같은 모습일 것입니다.


class WaiterTests: XCTestCase {
	func testOrder_ShouldCookRamen() {
        let mockCook = MockCook()
        let waiter = Waiter(cook: mockCook)
        waiter.order()

        XCTAssertEqual(mockCook.cookRamenCallCount, 1)
        XCTAssertEqual(mockCook.cookRamenLastBowls, 2)
        XCTAssertEqual(mockCook.cookRamenLastSoup, RamenSoup.miso)
        XCTAssertEqual(mockCook.cookRamenLastExtras), ["wakame", "tamago"])
	}
}

호출 횟수는 한 번이고, 주문한 양은 두 그릇, 스프는 miso라고 가정(assert)하고 있네요. 추가재료인 wakame와 tamago에 대한 가정도 보이네요. 테스트는 잘 동작합니다.

유닛 테스트는 하나의 assertion만 있어야 한다는 규칙이 있습니다. 한 가지 사실에 대해서만 가정해야한다는 뜻이지요. 위의 예제에는 4가지 종류의 assertion이 있습니다. 이것을 마치 하나의 assertion인 것처럼 표현할 수 있다면 좀 더 좋을 것 같습니다. 특히, 여러 개의 테스트가 있는 경우라면 이런 방식이 더욱 유용하겠지요. 이 문제는 mock 객체 안에 assertion들을 모아 새로운 메서드를 생성하면 쉽게 해결할 수 있습니다.


	func verifyCookRamen(
        	bowls: Int,
        	soup: RamenSoup,
        	extras: [String]) -> Void {
    	XCTAssertEqual(cookRamenCallCount, 1)
        XCTAssertEqual(cookRamenLastBowls, bowls)
        XCTAssertEqual(cookRamenLastSoup, soup)
        XCTAssertEqual(cookRamenLastExtras, extras)
	}
}

class WaiterTests: XCTestCase {
    func testOrder_ShouldCookRamen() {
       	let mockCook = MockCook()
       	let waiter = Waiter(cook: mockCook)
        waiter.order()

        mockCook.verifyCookRamen(
            bowls: 2,
            soup: .miso,
            extras: ["wakame", "tamago"])
 	}
}

테스트 코드에서는 verifyCookRamen을 호출합니다. 하지만 이 메서드에서 에러가 발생할 경우 에러 메시지가 어떻게 처리될까요?

실패하는 테스트를 만들기 위해 우리가 기대하는 라면 주문량을 두 그릇에서 세 그릇으로 변경해봅시다. 우리는 two is not equal three라는 에러 메시지를 보게 될 것입니다. 또한, 실패한 assertion의 위치도 함께 알려줄 것입니다.

XCTAssertEqual(cookRamenLastBowls, bowls)

이것은 테스트가 아니라 헬퍼 메서드를 가리키고 있습니다. 동일한 헬퍼 메서드를 호출하는 테스트가 여러 개 있을 경우 이것은 문제가 될 수 있습니다.

헬퍼 메서드에 두 가지 인자를 추가하여 이 문제를 수정할 수 있습니다.


	func verifyCookRamen(
        	bowls: Int,
        	soup: RamenSoup,
        	extras: [String])
          file: StaticString = #file,
          line: UInt = #line) -> Void {
        XCTAssertEqual(cookRamenCallCount, 1, file: file, line: line)
        XCTAssertEqual(cookRamenLastBowls, bowls, file: file, line: line)
        XCTAssertEqual(cookRamenLastSoup, soup, file: file, line: line)
        XCTAssertEqual(cookRamenLastExtras, extras, file: file, line: line)
    }

파일 이름과 라인 넘버를 전달하기 위해 #file#line을 인자들에 대한 기본값으로 설정하여 정보를 캡처하였습니다. 그리고 이 인자들을 assert 구문에 전달했습니다. 이제는 테스트가 실패하면 테스트 안에서 실패한 verify 구문인 mockCook.verifyCookRamen(bowls: 3를 가리키게 될 것입니다. 하지만 이것은 괜찮은 에러 메시지는 아닌 것 같네요. 이 메시지는 단지 23과 같지 않다는 사실만을 알려줍니다. 메시지만 봐서는 2나 3이 의미하는 것이 무엇인지 알 수 없는 상태입니다.

assertion에 메시지를 추가하여 각각의 assertion들이 구별될 수 있게 만들어 봅시다. 아래 코드를 보면 “call count”, “bowls” 등의 메시지가 추가된 것을 확인할 수 있습니다. 이제 assertion 실패가 발생하면 two is not equal to three bowls처럼 보다 유용한 에러 메시지가 제공됩니다.


	func verifyCookRamen(
        	bowls: Int,
        	soup: RamenSoup,
        	extras: [String])
          file: StaticString = #file,
          line: UInt = #line) -> Void {
        XCTAssertEqual(cookRamenCallCount, 1, "call count", file: file, line: line);
        XCTAssertEqual(cookRamenLastBowls, bowls, "bowls", file: file, line: line);
        XCTAssertEqual(cookRamenLastSoup, soup, "soup", file: file, line: line);
        XCTAssertEqual(cookRamenLastExtras, extras, "extras", file: file, line: line)
    }

extras를 담은 배열을 살펴보겠습니다. 추가재료 순서가 “wakeme”, “tamago” 에서 “tamago”, “wakame”로 바뀌면 어떻게 될까요?

class WaiterTests: XCTestCase {
    func testOrder_ShouldCookRamen() {
       	let mockCook = MockCook()
       	let waiter = Waiter(cook: mockCook)

       	 waiter.order()

       	 mockCook.verifyCookRamen(
            bowls: 2,
            soup: .miso,
            extras: ["tamago", "wakame"])
 	}
}

우리의 관심은 배열안에 담긴 내용이지 내용의 순서가 아니기 때문에 이 테스트가 성공하길 원합니다. 추가재료의 순서를 위의 예제처럼 변경한 후, 테스트를 실행해보면 다음과 같은 에러 메시지가 출력됩니다.

XCTAssertEqual failed: ("["wakame", "tamago"]") is not equal to ("["tamago", "wakame"]") - extras

이것은 fragile 테스트의 전형적인 예라고 할 수 있습니다. 실패하지 않길 바라는 테스트에서 실패하는 것이죠. fragile 테스트는 지금은 테스트에 성공했더라도 실제 코드를 약간만 수정하면 테스트가 실패합니다. 원칙적으로는 유닛 테스트란 일종의 안전망이라고 할 수 있습니다. 코드 수정은 언제든지 발생할 수 있기 때문에 코드 변경을 막아서는 안되죠. 우리는 코드를 변경해도 괜찮다는 믿음을 주는 테스트를 만들고 싶습니다. 그런 환경에서라면 지속적인 리팩토링을 통해 코드를 개선해나갈 수 있습니다. 우리는 정확성(correctness)을 확인할 수 있는 테스트를 만들고자 합니다. 중요한 사항에 대해서는 민감하게 반응하고, 중요하지 않은 것은 무시할 수 있는 테스트를 만들고 싶습니다.

자, 그럼 이 테스트를 개선해볼까요? equality를 비교하기 위해 extra 배열을 넘겨주는 대신 predicate를 넘겨주는 건 어떨까요? 인자를 갖는 클로저를 정의하고 인자값이 true인지 false인지 평가합니다.


class WaiterTests: XCTestCase {
    func testOrder_ShouldCookRamen() {
       	let mockCook = MockCook()
       	let waiter = Waiter(cook: mockCook)

        waiter.order()

        mockCook.verifyCookRamen(
            bowls: 2,
            soup: .miso,
            extrasMatcher: { extras in
                extras.count == 2 &&
                extras.contains("tamago") &&
                extras.contains("wakame") })
    }

  }


위의 구문은 extras 배열에는 두 개의 요소만 있어야 하고, wakame와 tamago가 포함되어 있어야 한다는 것을 표현하고 있습니다. 배열 안의 요소 배치 순서와는 상관없이 이 테스트는 성공합니다. 이같은 방식은 중요한 것이 무엇인지 확인하고, 중요하지 않은 것은 무시합니다. 이 방식을 따르면 실제 코드를 쉽게 리팩토링할 수 있습니다.

이 테스트를 지원하기 위해 verify 메서드도 변경해보겠습니다.


func verifyCookRamen(
            bowls: Int,
            soup: RamenSoup,
            extrasMatcher: (([String]) -> Bool),
            file: StaticString = #file,
            line: UInt = #line) -> Void {
        XCTAssertEqual(cookRamenCallCount, 1, "call count", file: file, line: line)
        XCTAssertEqual(cookRamenLastBowls, bowls, "bowls", file: file, line: line)
        XCTAssertEqual(cookRamenLastSoup, soup, "soup", file: file, line: line)
        XCTAssertTrue(extrasMatcher(cookRamenLastExtras),
                "extras was \(cookRamenLastExtras)",
                file: file, line: line)
    }


전에는 String 배열을 인자로 전달했지만, 이제는 String 배열을 평가하여 Boolean을 반환하는 클로저를 인자로 설정했습니다. assertion에서는 캡처한 extras를 predicate에 전달하고 그 결과가 true인지 확인합니다. 마지막으로, XCTAssertEqual를 사용했을때는 기대하는 값과 실제 값을 비교할 수 있었지만, XCTAssertTrue는 그렇게 할 수 없기 때문에, 실패 시 출력되는 메시지에서 실제 값을 출력할 수 있게 수정했습니다.

정확한 extras를 넘겨준다면 extras의 순서와 관계없이 우리의 테스트는 성공할 것입니다. wakame 대신 nori라고 설정해서 테스트가 실패할 경우 아래와 같이 쓸만한 에러 메시지가 출력되지만 좀 더 개선할 수는 없을까요?

XCTAssertTrue failed - extra was ["tamago", "nori"]

IDE환경에서 데이터가 적을 때는 괜찮지만 데이터가 많을 경우 미스매치가 발생한 곳을 시각적으로 확인하기 어렵습니다. Xcode나 AppCode 환경이 아닌 CI(continuous integration)에서 에러를 기록해야 하는 상황도 발생할 수 있습니다. 또한, 에러 메시지에서 실제 값은 확인할 수 있지만 기대하는 값은 알 수가 없습니다.

Hamcrest matchers를 이용하면 좀 더 상세한 메시지를 보여주는 predicate를 제공할 수 있습니다.

Hamcrest Matchers

Matchers는 최초의 mock 객체 라이브러리인 jMock의 한 부분으로 개발되었습니다. 이후, Matchers는 독립적인 라이브러리 형태로 발전했으며 JUnit의 한 부분이 되었습니다. Hamcrest는 Objective-C, Swift뿐만 아니라 다양한 언어로 구현되어 제공되고 있습니다.

Hamcrest matchers는 여러 가지 일을 합니다. matcher는 값을 평가하는 predicate입니다. 기대하는 것(expectation)을 서술할 수 있습니다. 만약 미스매치가 발생하면, matcher는 기대하는 것과 무엇이 어떻게 다른지 정확한 설명을 제공합니다. 도메인 특화 언어(domain-specific language) 안에서는 다른 matcher들과 조합해서 사용할 수 있습니다. 다른 matcher들을 조합하여 컬렉션을 평가하는 matcher를 만들 수도 있습니다. Hamcrest는 미리 정의된 matcher 라이브러리일 뿐만 아니라 커스텀 matcher를 작성할 때 활용할 수 있는 프레임워크이기도 합니다.

mock 객체에서 Swift Hamcrest matchers를 쉽게 사용하기 위해 헬퍼 메서드를 만들어보겠습니다.


func applyMatcher<T>(_ matcher: Matcher<T>, label: String, toValue value: T, _ file: StaticString, _ line: UInt) -> Void {
    switch matcher.matches(value) {
    case .match:
        return
    case let .mismatch(mismatchDescription):
        XCTFail(label + " " + describeMismatch(value, matcher.description, mismatchDescription), file: file, line: line)
    }
}

switch 구문은 matches를 호출합니다. 그 결과가 match라면 아무것도 하지 않습니다. 미스매치라면 mismatchDescription이라는 값을 얻게 되고, 우리가 확인하고자하는 정보를 담은 레이블을 추가하여 XCTFail을 호출합니다. verify 구문에서 헬퍼 메서드를 어떻게 사용하는지 보여드리죠.


func verifyCookRamenUsingHamcrest(
        bowls: Matcher<Int>,
        soup: Matcher<RamenSoup>,
        extras: Matcher<Array<String>>,
        file: StaticString = #file,
        line: UInt = #line) -> Void {
    XCTAssertEqual(cookRamenCallCount, 1, "call count", file: file, line: line)
    XCTAssertEqual(cookRamenLastBowls, bowls, "bowls", file: file, line: line)
    XCTAssertEqual(cookRamenLastSoup, soup, "soup", file: file, line: line)
    applyMatcher(extras, label: "extras", toValue: cookRamenLastExtras, file, line)
}

matcher의 인자 타입으로 문자 배열을 사용했고, extras에 대한 레이블과 함께 새로운 헬퍼 메서드를 호출했습니다.

예제 코드에 Hamcrest 지원을 추가하면 아래와 같은 모습이 될 것입니다.


func testOrder_ShouldCookRamen_HamcrestVersion() {
    waiter.order()

    mockCook.verifyCookRamenUsingHamcrest(
        bowls: equalTo(2),
        soup: equalTo(.miso),
        extras: containsInAnyOrder("tamago", "wakame"))
}

containsInAnyOrder는 Hamcrest matcher에 정의된 메서드입니다. 실패 테스트를 작성하기 위해 추가재료를 wakame에서 chashu로 바꿔볼까요? 실패 메시지는 다음과 같을 것입니다.

failed: extras GOT ["wakame", "tamago"] (mismatch: GOT: "wakame", EXPECTED: "chashu"), EXPECTED: a sequence containing in any order ["tamago", "chashu"]

우선 뒷부분부터 해석해보죠. EXPECTED: a sequence containing in any order ["tamago", "chashu"]는 우리가 기대하는 것을 의미합니다. 앞부분인 failed: extras GOT ["wakame", "tamago"] (mismatch: GOT: "wakame", EXPECTED: "chashu")는 extras 인자로 넘겨준 실제 값이 무엇인지 알려주며 미스매치 발생원인에 대한 상세정보를 제공합니다. 테스트가 매우 상세한 에러 메시지를 제공하고 있으므로 더이상 fragile 테스트가 아닙니다. 유닛 테스트가 실패할 때 충분한 정보를 제공한다면 테스트 이름과 실패 메시지를 통해서 무엇이 문제인지 쉽게 확인할 수 있을 것입니다.

verify 메서드의 각각의 인자에 matcher를 사용하면 어떨까요?


func verifyCookRamenUsingHamcrest(
            bowls: Matcher<Int>,
            soup: Matcher<RamenSoup>,
            extras: Matcher<Array<String>>,
            file: StaticString = #file,
            line: UInt = #line) -> Void {
        applyMatcher(equalTo(1), label: "call count", toValue: cookRamenCallCount, file, line)
        applyMatcher(bowls, label: "bowls", toValue: cookRamenLastBowls, file, line)
        applyMatcher(soup, label: "soup", toValue: cookRamenLastSoup!, file, line)
        applyMatcher(extras, label: "extras", toValue: cookRamenLastExtras, file, line)
}

제일 처음 헬퍼 메서드를 작성할 때와 유사한 구조이지만 이제는 더이상 XCTAssertEqual을 호출하지 않습니다. call count에 대해서도 equalTo matcher를 사용했습니다.

테스트 코드에서 equalTo matcher를 이용해 equality를 테스트할 수 있지만, mock 객체는 equality뿐만 아니라 bowls 인자에 사용된 것처럼 greaterThanOrEqualTo와 같은 메서드를 이용할 수 있습니다. soup 종류가 상관없다면 anything matcher도 사용할 수 있습니다.


class WaiterTests: XCTestCase {
    func testOrder_ShouldCookRamen() {
        let mockCook = MockCook()
        let waiter = Waiter(cook: mockCook)
        waiter.order()

        mockCook.verifyCookRamen(
            bowls: greaterThanOrEqualTo(2),
            soup: anything(),
            extras: containsInAnyOrder(equalTo("tamago"),
                                      hasPrefix("chashu"))
    }
}

extras 인자에 사용된 matcher를 한번 살펴볼까요? 추가재료를 인자로 전달하기 위해 반드시 두 개의 문자열을 넘겨주지 않아도 됩니다. 대신, 두 개의 matcher를 전달했습니다. equalTo matcher를 사용하면 이전처럼 두 개의 문자열을 넘겨준 것과 동일한 역할을 수행할 것입니다. 이것 말고도, 문자열 prefix를 확인하는 hasPrefix를 사용할 수도 있습니다.

복 습

fake 객체를 언제 사용해야 할까요? 실제 객체가 비싸거나(expensive), 너무 느릴 때 또는 신뢰할 수 없는 경우라면 fake 객체를 사용하세요. 전역 상태를 변경시킬 경우에도 fake 객체를 사용하면 됩니다. mock 객체는 내가 기대하는 것들이 호출되는지 확인할 수 있는 fake라고 생각하시면 되겠습니다.

Mock 생성 시 주의사항

Swift에서 강력한 mock 객체를 생성할 때 주의할 사항들입니다.

  • 메서드가 호출되었는지 기록하기 위해 Boolean을 사용하지 말고 integer를 사용해서 “호출 횟수”를 캡처하세요.
  • mock에 대한 여러 개의 assert를 사용한 경우 헬퍼 메서드를 생성하여 별도 관리하세요.
  • 파일 이름과 라인 넘버를 헬퍼 메서드에 추가하고, assert에 전달하세요.
  • 구별하기 쉽게 각각의 assert에 메시지를 추가하세요.
  • 가능한 eqaulity를 사용한 assert를 작성하지 마세요. predicate를 사용하여 보다 유연하고 연약하지 않은 테스트 코드를 구성하세요.
  • predicate를 주로 사용하는 Swift Hamcrest matchers 도입을 적극적으로 고려해보세요.

우리의 목적은 테스트를 작성하는 것이 전부가 아니라는 사실을 기억하세요. 리팩토링이 가능한 테스트를 작성해야 합니다. 중요한 것은 민감하게 반응하고, 중요하지 않은 것은 무시하는 테스트를 작성해야 합니다. 강력하고 유연한 대나무와 같은 테스트를 작성하도록 노력해야 합니다.

예제 코드 또는 발표 자료가 필요하신 분은 제 웹사이트를 방문하시기 바랍니다.

다음: Realm Swift를 사용하면 iOS 앱 모델 레이어를 효과적으로 작성할 수 있습니다.

General link arrow white

컨텐츠에 대하여

2016년 3월에 진행한 try! Swift Tokyo 행사의 강연입니다. 영상 녹화와 제작, 정리 글은 Realm에서 제공하며, 주최 측의 허가 하에 이곳에서 공유합니다.

Jon Reid

Jon Reid는 American Express에서 iOS 개발자로 일하면서 ‘Code Janitor’라는 직책을 맡고 있습니다. Jon은 Swift를 오래 접하지는 않았지만 2001년부터 Test Driven Development를 해왔고 2005년부터 Objective-C에 적용했습니다. 그는 OCHamcrest와 OCMockito의 창시자입니다.

4 design patterns for a RESTless mobile integration »

close