본문 바로가기
Swift

Data Testing in Swift

by SeoB-P 2022. 5. 12.

계기

TestCode를 짜다가 Data를 만드는데 너무 많은 코드가 발생함을 인식하고 article을 참고하여 개선 방향을 공부해보고자 한다.

 

Test Data

UnitTest를 작성할 때 우리는 종종 고립된 환경에서 타입이나 함수를 테스트한다.

하지만, 이렇게 고립된 환경에서 대부분의 코드는 그 자체로 적절히 작동하지 않는다.

제대로 작동하기 위해서는, 추가적인 Dependecy와 Data를 추가해줘야한다.

테스트 가능한 코드를 작성하는 것의 큰 부분은 우리의 의존성이 어떻게 관리되고, 얼마나 쉽게 대체 될수 있느냐로 귀결되지만 Test Data를 어떻게 구성하고 관리하는지도 똑같이 중요하다.

어떻게 하면 Test Data를 좀더 잘 관리 할 수 있을까?

 

Input and verification

Test Data를 효율적으로 관리하는 법을 살펴보기 전에 UnitTest에서 Data는 어떤 목적으로 주로 사용이 되는지 생각해보자.

unitTest에서 data는 종종 두가지 목적으로 사용이된다.

1. 주어진 API의 요구사항을 충족시키기 위해서 사용 (해당 데이터가 심지어 test 그자체에는 중요하지 않더라도.)

 

2. 우리가 테스트 하는 코드의 outcome을 확인 할 수 있는 방법을 제공해 준다.

 

예를 들어서 User를 만드는 함수가 있다고 해보자.

func makeUser(ID: ID:, password: Password) -> User {
	return User(ID:ID, password: password)
}

주어진 함수의 요구사항은 ID와 password이다.

UnitTest시, 요구 사항을 만족시키기 위해서 우리는 ID와 password라는 Test Data를 만들어서 넣어주어야 한다.

또한 Return된 User가 적절히 생성이 되었는지 Test Data를 만들어서 확인해야 한다.

 

문제점

예를 들어서 연락처앱을 만든다고 했을때 해당 연락처가 올바르게 포함되어 있는지를 Test하는 코드를 작성한다고 해보자.

class ContactListTests: XCTestCase {
    func testAddingContact() {
        var list = ContactList()

        let contact = Contact(
            name: "John Sundell",
            email: "contact@swiftbysundell.com"
        )

        // Make sure that the list doesn't contain the contact
        // initially (to avoid persistence-based flakiness):
        XCTAssertFalse(list.contains(contact))

        // Add the contact to the list, and verify that its
        // 'contains' API correctly returns 'true':
        list.add(contact)
        XCTAssertTrue(list.contains(contact))
    }
}

 

위와 같은 테스트는 꽤나 직관적이고 만들기도 쉽다.

왜냐하면, contact라는 data model이 아주 심플한 Case이기 때문이다.

 

하지만, 현실은 그렇지않다.

우리가 주로 사용하는 DataModel들은 밑과 같은 모습을 취하는 경우가 많다. Identifiable 프로토콜?

struct Book: Codable, Equatable, Identifiable {
    let id: Identifier<Book>
    var name: String
    var genres: [Genre]
    var author: Author
    var chapters: [Chapter]
}

 

위 Struct를 Test하기 위한 코드를 짜면 다음과 같은 문제가 발생한다.

class LibraryTests: XCTestCase {
    func testQueryingBooksByAuthor() {
        var library = Library()

        // Creating even the simplest set of books requires quite
        // a lot of code, since we have to provide values for all
        // of our model's properties:
        let books = [
            Book(
                id: "1",
                name: "Book1",
                genres: [],
                author: "John Appleseed",
                chapters: []
            ),
            Book(
                id: "2",
                name: "Book2",
                genres: [],
                author: "John Appleseed",
                chapters: []
            )
        ]

        library.add(books)

        let booksByAuthor = library.books(by: "John Appleseed")
        XCTAssertEqual(booksByAuthor, books)
    }
}

위 코드는 분명 잘 작동을 하지만, model Data를 settingup 하는데에 많은 코드를 써야하고 정작 우리가 원하는 것은 book들이 제대로 만들어 지고 들어갔냐를 알고싶은데 핵심 코드들은 저 큰 코드 덩어리에 묻혀서 잘 안보인다.

 

해결방안

위 문제를 해결하기 위한 방법으로는 다양한 방법이 있다.

1. 속성에 Default값 주기

가장 쉬운 예로  Book모델의 일부 속성에 Default값을 줌으로써 Book()과 같은 형태로 손쉽게 만들 수도 있지만, 이는 우리의 앱 상황에 따라 많이 다를 수 있다.

2. Helper Method

생성에 도움을 주는 Helper Method를 사용할 수도 있다.

예시.(makeBooks라는 Helper Method를 정의 및 테스트에 사용)

extension LibraryTests {
    func makeBooks(withAuthor author: Author,
                   count: Int) -> [Book] {
        return (0..<count).map { index in
            Book(
                id: Identifier(rawValue: "\(index)"),
                name: "Book\(index)",
                genres: [],
                author: author,
                chapters: []
            )
        }
    }
}
class LibraryTests: XCTestCase {
    func testQueryingBooksByAuthor() {
        var library = Library()

        let books = makeBooks(withAuthor: "John Appleseed",
                              count: 2)

        library.add(books)

        let booksByAuthor = library.books(by: "John Appleseed")
        XCTAssertEqual(booksByAuthor, books)
    }
}

하지만 이 방법은 매우 구체적인 Case에서만 작동하기때문에..

모든 TestCase를 작성하려고하면 아주 많은 Helper Method를 만들어야 할 수도 있다.

 

3. Generic과 Protocol을 이용해서 생성을 통일 시켜본다.

위와같은 문제로 Article에서는 Generic과 Protocol 을 이용해서 이 문제를 해결해보자 한다.

Stubbing values

우리가 Testing Data를 정의할때 본질적으로 수행하는 작업은 Stub을 만드는 것 이다.   

더 자세한 내용: Stub vs Mock

 

또한, 개념적으로 Stubbing이란 위에서 정의한 Book model처럼 특별한 타입에만 있지않고, 있을지 모르는 다른 모델도 적용이 가능해야한다.

-> 프로젝트 내에서나 프로젝트 전반에 걸쳐 우리가 재사용할 수 있는 것으로 일반화 해야한다.(그래야 HelperMethod의 단점을 없앨 수 있으니..)

 

그렇게 하기 위해서, 어떤 유형이라도 stub가 될 수 있도록 프로토콜을 만들어본다.(이 프로토콜은 Unit Test Target에만 추가한다.)

밑 프로토콜은  Identifier를 제네릭으로 매개변수를 받아서, 그 ID를 가지고 자기 자신의 타입의 객체를 새로 만들어서 리턴한다.

protocol Stubbable: Identifiable {
    static func stub(withID id: Identifier<Self>) -> Self
}

 

extension Book: Stubbable {
    static func stub(withID id: Identifier<Book>) -> Book {
        return Book(
            id: id,
            name: "Book",
            genres: [],
            author: "Author",
            chapters: []
        )
    }
}

이제 각 모델의 extension내에서 캡슐화 할 수 있고 모델이 stubbable을 채택할 수 있다.

 

Stub를 만들때 ID말고 다른 속성을 바꿀수 있는 방법은 없을까?

Article에서는 한가지 방법으로 Key Path를 알려준다.

extension Stubbable {
    func setting<T>(_ keyPath: WritableKeyPath<Self, T>,
                    to value: T) -> Self {
        var stub = self
        stub[keyPath: keyPath] = value
        return stub
    }
}

KeyPath를 이용하면 declarative syntax(선언적 구문)으로 쉽게 stub을 정의할 수 있다.

또한, mutable한 model안의 속성을 쉽게 추적이 가능하다. 

 

위 기능과 generic type constraint 를 이용해서 모든 utilites가 이 Stub기능을 사용하도록 정의 내릴 수 있다.

 

Ex) Array

// 배열안의 Element가 Stubbable을 채택하고 있다면 배열을 만들 수 있다.
extension Array where Element: Stubbable, Element.RawIdentifier == String {
    static func stub(withCount count: Int) -> Array {
        return (0..<count).map {
            .stub(withID: Identifier(rawValue: "\($0)"))
        }
    }
}

 

 

 

Ex)Collection

extension MutableCollection where Element: Stubbable {
    func setting<T>(_ keyPath: WritableKeyPath<Element, T>,
                    to value: T) -> Self {
        var collection = self

        for index in collection.indices {
            let element = collection[index]
            collection[index] = element.setting(keyPath, to: value)
        }

        return collection
    }
}

 

Stubbing API 

LibaryTest를  Stubbing API를 이용해서 다시 만들어 보자.

class LibraryTests: XCTestCase {
    func testQueryingBooksByAuthor() {
        var library = Library()

        let books = [Book]
            .stub(withCount: 3)
            .setting(\.author, to: "John Appleseed")

        library.add(books)

        let booksByAuthor = library.books(by: "John Appleseed")
        XCTAssertEqual(booksByAuthor, books)
    }
}

 

let bundle = Book.Bundle
    .stub(withID: "fantasy")
    .setting(\.name, to: "Fantasy Bundle")
    .setting(\.books, to: [Book]
        .stub(withCount: 5)
        .setting(\.genres, to: [.fantasy])
    )

코드출처:https://www.swiftbysundell.com/articles/defining-testing-data-in-swift/#stubbing-values

결론

- 입력 및 검증에 어떤 데이터를 사용해야하는지 정의 하는 것은 중요하다.

 

- Test Data가 정의 되는 방식을 통합함(Protocol + Generic)으로써 읽기 및 쓰기 테스트를 더 쉽게 만드는 collection of utilities를 쉽게 구축 할 수 있다.

 

- 추상화에는 비용이 든다, 위 코드를 작성하기에는 상당히 복잡한 과정이 필요함. -> 꼭 해야만 되는지 한번더 확인을 해보자.

 

 

댓글