[MVVM, Unit testing] 데이터 로드 추상화로 유닛테스팅 쉽게 하기 - 구현편

Young Bin Lee·2023년 1월 11일
0

TIL

목록 보기
1/1

목표

MVVM 패턴을 사용하는 프로젝트에서 View Model 내부에서 일어나는 데이터 로딩/저장 루틴을 DataProviding이라는 프로토콜을 만들어 추상화하고 이를 유닛 테스팅에 적용해봅니다.

아이디어

프로토콜 설정

다음과 같은 프로토콜들을 설정해봅니다.


protocol DataProviding {
    func fetch<Query: ModelQueryType>(_ query: Query) -> Query.QueryResult
    func mutate<Query: ModelQueryType>(_ query: Query, to value: Query.QueryResult)
}

extension DataProviding {
    func fetch<Query: ModelQueryType>(_ query: Query) -> Query.QueryResult {
        return query.fetch()
    }
    
    func mutate<Query: ModelQueryType>(_ query: Query, to value: Query.QueryResult) {
        query.mutate(to: value)
    }
}

protocol ModelType {}

protocol ModelQueryType {
    associatedtype QueryResult: ModelType
    
    func fetch() -> QueryResult
    func mutate(to value: QueryResult)
}

DataProviding

  • 뷰 모델에서 모델을 저장/로드하는 데 사용할 수 있는 추상화 프로토콜입니다.
  • extension에서 기본적인 동작을 미리 정의해 반복적인 코드 작성을 피합니다. 해당 구현을 외부에 맡길지에 대해서는 가독성 등 협업의 용이성 관점에서의 논의가 필요할 것 같습니다.

ModelType

  • 모델이 따라야 하는 프로토콜입니다.
  • 현재는 아무런 내용이 없으나 스펙에 따라 기능을 추가해 확장시키는 것이 가능합니다.

ModelQueryType

  • 모델을 불러오는 쿼리가 따라야 할 프로토콜입니다.

예시

class KioskViewModel: DataProviding {
    func getBurgerInstance() -> Burger {
        return self.fetch(SomeFoodQuery())
    }
}

struct SomeFoodQuery: ModelQueryType {
    func fetch() -> Burger {
        return Burger()
    }
    
    func mutate(to value: Burger) {
        print("Burger mutation requested!")
    }
}

struct Burger: ModelType {}

KioskViewModel: DataProviding

  • 필요한 곳(여기서는 KioskViewModel)에 DataProviding을 채택합니다.
  • extension에 쿼리에 관한 메소드를 미리 구현했기 때문에 바로 fetch, mutate 명령을 통해 데이터를 조회, 변경할 수 있습니다.

SomeFoodQuery: ModelQueryType

  • ModelQueryType을 채택해 실질적인 데이터 처리 로직을 구현했습니다.

struct Burger: ModelType

  • ModelType을 채택해 해당 인스턴스가 ModelQueryType에서 사용할 수 있는 모델임을 표시합니다.
  • 지금은 아무 내용이 없지만 필요에 따라 추가해서 유기적으로 사용할 수 있습니다.

간단한 세팅으로 데이터 조회/변경을 추상화 해 보았습니다. 하지만 문제가 하나 존재합니다. 현 상태로는 Food에 관한 쿼리 외에도 프로젝트 내 존재하는 온갖 ModelQueryType을 메소드의 파라미터로 사용할 수 있게 됩니다. 이를 개선해봅시다.

Type-specific하게 개선

struct RootDataProvider: DataProviding {}

protocol FoodDataProviding {
    func fetchFood<Query: FoodModelQueryType>(_ query: Query) -> Query.QueryResult
    func mutateFood<Query: FoodModelQueryType>(_ query: Query, to value: Query.QueryResult)
}

extension FoodDataProviding {
    private var rootDataProvider: some DataProviding {
        RootDataProvider()
    }

    func fetchFood<Query>(_ query: Query) -> Query.QueryResult where Query: FoodModelQueryType {
        return rootDataProvider.fetch(query)
    }

    func mutateFood<Query>(_ query: Query, to value: Query.QueryResult) where Query: FoodModelQueryType {
        return rootDataProvider.mutate(query, to: value)
    }
}

protocol FoodModelQueryType: ModelQueryType {}

// KioskViewModel
class KioskViewModel: FoodDataProviding {
func getBurgerInstance() -> Burger {
   return self.fetchFood(BurgerQuery())
}

FoodDataProviding

  • ModelQueryType보다 좁은 범위의 프로토콜인 FoodModelQueryType만을 허용하는 메소드를 갖는 DataProviding을 구현합니다.
  • RootDataProvider라는 인스턴스를 가지고 있으며 fetchmutate를 실행할 때마다 해당 인스턴스의 메소드를 실행하도록 구현되었습니다.

FoodModelQueryType

  • ModelQueryType을 상속(채택)해 DataProviding에서도 사용할 수 있게 만들어줍니다.

이 작업을 통해 이제 KioskViewModelFoodModelQueryType라는 특정 타입의 쿼리만 실행할 수 있게끔 개선되었습니다.

이 방식을 이용하면 유닛 테스트 또한 용이해집니다. 데이터를 불러올 때 구현부가 조작된 Mock 쿼리를 이용해 손쉽게 Mock ModelType을 stubbing 할 수 있기 때문입니다. 유닛 테스트에 대해서는 다음 포스트에 작성해보도록 하겠습니다.

정리

요점

  • Query를 인스턴스화 해 전달하는 방법은 iOS의 유명한 GraphQL 라이브러리 중 하나인 Apollo-iOS에서 사용하는 방식이기도 합니다. 다만 Apollo와 완전히 같은 방식은 아닙니다. 이를테면 Apollo는 사용부와 구현부 사이에서 쿼리를 처리하는 객체인 ApolloClient를 거쳐야합니다.

아쉬운 점

  • ModelQueryTypeenum에서 구현하면 쿼리 객체를 일일이 구현하는 수고를 덜 수 있을 것 같습니다.
    • enum의 인스턴스 프로퍼티를 만들고 그 안의 switch self구문에서 some 키워드를 이용해 모호한 타입을 리턴하는 방식을 사용해보려 했으나 "Function declares an opaque return type, but the return statements in its body do not have matching underlying types" 에러가 발생했고 해결하지 못했습니다.

참고

Type erasure, Generic

Apollo-iOS

profile
I can make your dream come true

0개의 댓글