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
을 메소드의 파라미터로 사용할 수 있게 됩니다. 이를 개선해봅시다.
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
라는 인스턴스를 가지고 있으며 fetch
와 mutate
를 실행할 때마다 해당 인스턴스의 메소드를 실행하도록 구현되었습니다.FoodModelQueryType
ModelQueryType
을 상속(채택)해 DataProviding
에서도 사용할 수 있게 만들어줍니다.이 작업을 통해 이제
KioskViewModel
은FoodModelQueryType
라는 특정 타입의 쿼리만 실행할 수 있게끔 개선되었습니다.
이 방식을 이용하면 유닛 테스트 또한 용이해집니다. 데이터를 불러올 때 구현부가 조작된 Mock 쿼리를 이용해 손쉽게 Mock
ModelType
을 stubbing 할 수 있기 때문입니다. 유닛 테스트에 대해서는 다음 포스트에 작성해보도록 하겠습니다.
ApolloClient
를 거쳐야합니다.ModelQueryType
을 enum
에서 구현하면 쿼리 객체를 일일이 구현하는 수고를 덜 수 있을 것 같습니다.enum
의 인스턴스 프로퍼티를 만들고 그 안의 switch self
구문에서 some
키워드를 이용해 모호한 타입을 리턴하는 방식을 사용해보려 했으나 "Function declares an opaque return type, but the return statements in its body do not have matching underlying types" 에러가 발생했고 해결하지 못했습니다.