Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Examples の Search プロジェクトから学ぶ The Composable Architecture

Aikawa
October 24, 2020

Examples の Search プロジェクトから学ぶ The Composable Architecture

Aikawa

October 24, 2020
Tweet

More Decks by Aikawa

Other Decks in Programming

Transcript

  1. 今回紹介する題材 TCA(The Composable Architecture) の Exmaples の Search アプリ 地名を⼊⼒する

    300ms 何も打たない API Request が⾶んで、該当する地名があれば表⽰される 表⽰された地名をタップすると、その地域の天気情報が⾒れる Search アプリの Test TCA の テストサポート機能 テストを書くのが楽・テスト結果もわかりやすい 3
  2. 4

  3. まずは Search ⾃体について Search のファイルツリー /Search |--- SearchView.swift // TCA

    の⾊々な要素* が詰め込まれています |--- ActivityIndicator.swift // ただの ActivityIndicator |--- SceneDelegate.swift // SearchView の初期化 |--- WeatherClient.swift // Model と API client の実装 |--- Info.plist |--- Assets.xcassets TCA の⾊々な要素* State, Action, Environment, Reducer, Effect, View 7
  4. Models struct Location: Decodable, Equatable { // <- 今回は主にこちらだけ気にします var

    id: Int var title: String } struct LocationWeather: Decodable, Equatable { var consolidatedWeather: [ConsolidatedWeather] var id: Int struct ConsolidatedWeather: Decodable, Equatable { ... } } 8
  5. API client interface struct WeatherClient { var searchLocation: (String) ->

    Effect<[Location], Failure> var weather: (Int) -> Effect<LocationWeather, Failure> struct Failure: Error, Equatable {} } Effect はアプリケーションの副作⽤です。 TCA において副作⽤は Effect にのみ発⽣すべきとされています。 9
  6. API implementation / 全体像 extension WeatherClient { static let live

    = WeatherClient( searchLocation: { query in ... }, weather: { id in ... }) } テスト⽤に利⽤することになる Mock API implementation も ありますがそちらは後ほど紹介します 10
  7. API implementation / searchLocation extension WeatherClient { static let live

    = WeatherClient( searchLocation: { query in var components = URLComponents(string: "https://www.metaweather.com/api/location/search")! components.queryItems = [URLQueryItem(name: "query", value: query)] return URLSession.shared.dataTaskPublisher(for: components.url!) .map { data, _ in data } .decode(type: [Location].self, decoder: jsonDecoder) .mapError { _ in Failure() } .eraseToEffect() }, weather: { id in ... }) } 11
  8. API implementation / weather extension WeatherClient { static let live

    = WeatherClient( searchLocation: { query in ... }, weather: { id in let url = URL(string: "https://www.metaweather.com/api/location/\(id)")! return URLSession.shared.dataTaskPublisher(for: url) .map { data, _ in data } .decode(type: LocationWeather.self, decoder: jsonDecoder) .mapError { _ in Failure() } .eraseToEffect() }) } 12
  9. State, Action struct SearchState: Equatable { var locations: [Location] =

    [] var locationWeather: LocationWeather? var locationWeatherRequestInFlight: Location? var searchQuery = "" } enum SearchAction: Equatable { case locationsResponse(Result<[Location], WeatherClient.Failure>) case locationTapped(Location) case locationWeatherResponse(Result<LocationWeather, WeatherClient.Failure>) case searchQueryChanged(String) } 13
  10. Environment struct SearchEnvironment { var weatherClient: WeatherClient var mainQueue: AnySchedulerOf<DispatchQueue>

    } Environment で定義するのは以下のようなものです API Client, Scheduler などの依存関係 ⾃分は、外部から注⼊するとテストが楽になるものを定義する というイメージを持っています 14
  11. Reducer let searchReducer = Reducer<SearchState, SearchAction, SearchEnvironment> { state, action,

    environment in switch action { case .locationsResponse(.failure): case let .locationsResponse(.success(response)): case let .locationTapped(location): case let .searchQueryChanged(query): case let .locationWeatherResponse(.failure(locationWeather)): case let .locationWeatherResponse(.success(locationWeather)): } } 15
  12. View struct SearchView: View { let store: Store<SearchState, SearchAction> var

    body: some View { WithViewStore(self.store) { viewStore in ... } } View では store を定義して、 ViewStore 経由でアクセスします 16
  13. 検索 TextField の動作( View, State ) View TextField("New York, San

    Francisco, ...", text: viewStore.binding( get: { $0.searchQuery }, send: SearchAction.searchQueryChanged) ) State struct SearchState: Equatable { var searchQuery = "" } 17
  14. 検索 TextField の動作( Reducer ) let searchReducer = Reducer<SearchState, SearchAction,

    SearchEnvironment> { state, action, environment in switch action { case .locationsResponse(.failure): case let .locationsResponse(.success(response)): case let .locationTapped(location): case let .searchQueryChanged(query): <------------- これが呼ばれる case let .locationWeatherResponse(.failure(locationWeather)): case let .locationWeatherResponse(.success(locationWeather)): } } 18
  15. 検索 TextField の動作( Reducer ) case let .searchQueryChanged(query): struct SearchLocationId:

    Hashable {} state.searchQuery = query guard !query.isEmpty else { state.locations = [] state.locationWeather = nil return .cancel(id: SearchLocationId()) } return environment.weatherClient .searchLocation(query) .receive(on: environment.mainQueue) .catchToEffect() .debounce(id: SearchLocationId(), for: 0.3, scheduler: environment.mainQueue) .map(SearchAction.locationsResponse) 19
  16. 検索 TextField の動作( Reducer ) let searchReducer = Reducer<SearchState, SearchAction,

    SearchEnvironment> { state, action, environment in switch action { case .locationsResponse(.failure): <-------------------- 失敗すればこれ case let .locationsResponse(.success(response)): <------ 成功すればこれ case let .locationTapped(location): case let .searchQueryChanged(query): case let .locationWeatherResponse(.failure(locationWeather)): case let .locationWeatherResponse(.success(locationWeather)): } } 20
  17. 検索 TextField の動作( Reducer ) success case let .locationsResponse(.success(response)): state.locations

    = response return .none failure case .locationsResponse(.failure): state.locations = [] return .none 21
  18. 次は SearchTests について SearchTests に関係するファイルツリー /Search |--- SearchView.swift // 先ほど紹介した各ロジックを使⽤します

    |--- WeatherClient.swift // mock の API Client が定義されています /SearchTests |--- SearchTests.swift // テスト本体です 22
  19. SearchTests 内で使⽤する変数 private let mockLocations = [ Location(id: 1, title:

    "Brooklyn"), Location(id: 2, title: "Los Angeles"), Location(id: 3, title: "San Francisco"), ] 23
  20. SearchTests 内で使⽤する Mock Client extension WeatherClient { static func mock(

    searchLocation: @escaping (String) -> Effect<[Location], Failure> = { _ in fatalError("Unmocked") }, weather: @escaping (Int) -> Effect<LocationWeather, Failure> = { _ in fatalError("Unmocked") } ) -> Self { Self( searchLocation: searchLocation, weather: weather ) } } 24
  21. SearchTests の全体感 import Combine import ComposableArchitecture import XCTest @testable import

    Search class SearchTests: XCTestCase { // テスト⽤スケジューラー let scheduler = DispatchQueue.testScheduler func testSearchAndClearQuery() { ... } func testSearchFailure() { ... } func test...() { ... } 25
  22. 検索成功・その後にクエリを消す動作のテスト func testSearchAndClearQuery() { let store = TestStore( initialState: .init(),

    reducer: searchReducer, environment: SearchEnvironment( weatherClient: .mock(), mainQueue: self.scheduler.eraseToAnyScheduler() ) ) store.assert( ... ) } 27
  23. 検索成功・その後にクエリを消す動作のテスト store.assert( .environment { // mock client に 成功時の searchLocation

    を注⼊ $0.weatherClient.searchLocation = { _ in Effect(value: mockLocations) } }, .send(.searchQueryChanged("S")) { // "S" で検索する Action を実⾏ $0.searchQuery = "S" }, .do { self.scheduler.advance(by: 0.3) }, // 300ms 時間を進める .receive(.locationsResponse(.success(mockLocations))) { // 成功であることを確認 $0.locations = mockLocations // state の locations が 結果と等しいことを確認 }, .send(.searchQueryChanged("")) { // 検索クエリを空にする Action を実⾏ $0.locations = [] // state の locations は空になり $0.searchQuery = "" // state の searchQuery も空になっていることを確認 } ) 28
  24. 先ほどのテストをわざと失敗させてみます store.assert( .environment { $0.weatherClient.searchLocation = { _ in Effect(value:

    mockLocations) } }, .send(.searchQueryChanged("S")) { $0.searchQuery = "Failed" // わざと違う⽂字(Failed )で失敗させる! }, .do { self.scheduler.advance(by: 0.3) }, .receive(.locationsResponse(.success(mockLocations))) { $0.locations = mockLocations }, .send(.searchQueryChanged("")) { $0.locations = [] $0.searchQuery = "" } ) 29
  25. 検索が失敗した時の動作のテスト func testSearchFailure() { let store = TestStore( initialState: .init(),

    reducer: searchReducer, environment: SearchEnvironment( weatherClient: .mock(), mainQueue: self.scheduler.eraseToAnyScheduler() ) ) store.assert( ... ) } 31
  26. 検索が失敗した時の動作のテスト store.assert( .environment { // mock client に 失敗時の searchLocation

    を注⼊ $0.weatherClient.searchLocation = { _ in Effect(error: .init()) } }, .send(.searchQueryChanged("S")) { // "S" で検索した時の Action を実⾏ $0.searchQuery = "S" // state の searchQuery が "S" であることを確認 }, .do { self.scheduler.advance(by: 0.3) }, // 300ms 進める .receive(.locationsResponse(.failure(.init()))) // エラー時の Action であることを確認 ) 32