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

TCAのようなアーキテクチャを作ってみた話

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.
Avatar for Ryu Ryu
October 20, 2023

 TCAのようなアーキテクチャを作ってみた話

Avatar for Ryu

Ryu

October 20, 2023
Tweet

More Decks by Ryu

Other Decks in Programming

Transcript

  1. 7JFX @ViewState struct CounterView: View { @State var counter =

    0 let store: Store<CounterReducer> = Store(reducer: CounterReducer()) var body: some View { VStack { Text("\(counter)") Button("+") { send(.increment) } Button("-") { send(.decrement) } } } } ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ !4UBUFΛ࢖༻ͯ͠ঢ়ଶΛఆٛ
  2. 3FEVDFS ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ @Reducer struct CounterReducer { enum ViewAction { case

    increment case decrement } func reduce( into state: StateContainer<CounterView>, action: Action ) -> SideEffect<Self> { switch action { case .increment: state.counter += 1 return .none case .decrement: state.counter -= 1 return .none } } } @ViewState struct CounterView: View { @State var counter = 0 ... } ઌ΄Ͳ7JFXͰఆٛͨ͠ঢ়ଶ͕3FEVDFS಺ͰมߋͰ͖Δ
  3. 3FEVDFS4UBUF ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ @Reducer struct CounterReducer { ... struct ReducerState {

    var totalCalledCount = 0 } func reduce( into state: StateContainer<CounterView>, action: Action ) -> SideEffect<Self> { state.reducerState.totalCalledCount += 1 ... } }
  4. 3FEVDFS"DUJPO ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ @Reducer struct CounterReducer { enum ViewAction { case

    onAppear } enum ReducerAction { case response(TaskResult<Response>) } func reduce( into state: StateContainer<CounterView>, action: Action ) -> SideEffect<Self> { switch action { case .onAppear: return .run { send in await send( .response( TaskResult { try await request() } ) ) } case .response(let result): // ... do something } } } w 5$"Ͱ͸3FEVDFS಺ͷΈΞΫηεͰ͖Δ QSJWBUFͳ"DUJPO͸࡞Εͳ͔͕ͬͨɺຊΞʔ ΩςΫνϟͰ͸αϙʔτ w ྫ͑͹ɺ"1*͔Βͷ3FTQPOTFͳͲɺ7JFX͔ Β͸ݺͼͨ͘ͳ͍"DUJPOΛఆٛͰ͖Δ
  5. 3FEVDFS"DUJPO ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ @Reducer struct CounterReducer { enum ViewAction { case

    onAppear } enum ReducerAction { case response(TaskResult<Response>) } func reduce( into state: StateContainer<CounterView>, action: Action ) -> SideEffect<Self> { switch action { case .onAppear: return .run { send in await send( .response( TaskResult { try await request() } ) ) } case .response(let result): // ... do something } } } 3FEVDFS಺ʹ7JFX"DUJPOͱ3FEVDFS"DUJPO͠ ͔ఆ͍ٛͯ͠ͳ͍ͷʹɺ ͳ͔ͥ"DUJPO͕ར༻Ͱ͖Δɻ ͜ͷ"DUJPO͸ͳʹ͔ʁ
  6. 3FEVDFS"DUJPO ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ @Reducer struct CounterReducer { enum ViewAction { case

    onAppear } enum ReducerAction { case response(TaskResult<Response>) } func reduce( into state: StateContainer<CounterView>, action: Action ) -> SideEffect<Self> { switch action { case .onAppear: return .run { send in await send( .response( TaskResult { try await request() } ) ) } case .response(let result): // ... do something } } } 3FEVDFSϚΫϩʹΑͬͯɺ7JFX"DUJPOͱ 3FEVDFS"DUJPOΛ߹ମͨ͠"DUJPO͕ੜ੒͞ΕΔ enum Action: ActionProtocol { case onAppear case response(TaskResult<Response>) init(viewAction: ViewAction) { switch viewAction { case .onAppear: self = .onAppear } } init(reducerAction: ReducerAction) { switch reducerAction { case .response(let arg1): self = .response(arg1) } } }
  7. 0CTFSWBCMF0CKFDU ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ @ViewState final class MyModel: ObservableObject { @Published var

    count: Int = 0 let store: Store<MyReducer> = .init(reducer: MyReducer()) } w 0CTFSWBCMF0CKFDUͷ࢖༻΋ՄೳͰɺϩδοΫɾঢ়ଶΛڞ༗͍ͨ͠৔߹ ΍ɺ&OWJSPONFOU0CKFDUΛ࢖༻͍ͨ͠৔߹ͳͲʹ༗ޮ struct MyView: View { @ObservedObject private var model = MyModel() var body: some View { Button("+") { model.send(.increment) } } }
  8. ςετ ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ 5$"ͱ΄΅ಉ͡Α͏ʹςετ͕ॻ͚Δ func testSearchButtonTapped() async { let store =

    SearchView().testStore( viewState: SearchView.ViewState(), withDependencies: { $0.itemClient = ItemClient { _ in .stub } } ) await store.send(.onSearchButtonTapped) { $0.isLoading = true } await store.receive(.fetchItemsResponse(.success(.stub))) { $0.items = .stub $0.isLoading = false } }
  9. ςετ ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ ݁࿦ɺςετͷ࣌ͷΈ7JFXͷϓϩύςΟΛมߋ͢ΔͷͰ͸ͳ͘ɺ !7JFX4UBUFϚΫϩʹΑͬͯੜ੒͞Εͨߏ଄ମͷϓϩύςΟΛมߋ͍ͯ͠Δ func testSearchButtonTapped() async { let store

    = SearchView().testStore( viewState: SearchView.ViewState(), withDependencies: { $0.itemClient = ItemClient { _ in .stub } } ) await store.send(.onSearchButtonTapped) { $0.isLoading = true } await store.receive(.fetchItemsResponse(.success(.stub))) { $0.items = .stub $0.isLoading = false } }
  10. ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ @ViewState struct MyView: View { @State var count =

    0 @State var isLoading = false var body: some View { EmptyView() } } 7JFX4UBUFϚΫϩΛల։͢Δͱ
  11. ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ @ViewState struct MyView: View { @State var count =

    0 @State var isLoading = false var body: some View { EmptyView() } internal struct ViewState: ViewStateProtocol { var count = 0 var isLoading = false internal static let keyPathMap: [PartialKeyPath<ViewState>: PartialKeyPath<MyView>] = [ \.count: \.count, \.isLoading: \.isLoading ] } } extension MyView: ActionSendable { } 7JFX4UBUFߏ଄ମ͕ੜ੒͞Ε "DUJPO4FOEBCMFʹ४ڌ͞ΕΔ
  12. ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ @ViewState struct MyView: View { @State var count =

    0 @State var isLoading = false var body: some View { EmptyView() } internal struct ViewState: ViewStateProtocol { var count = 0 var isLoading = false internal static let keyPathMap: [PartialKeyPath<ViewState>: PartialKeyPath<MyView>] = [ \.count: \.count, \.isLoading: \.isLoading ] } } extension MyView: ActionSendable { } !4UBUF͕෇༩͞ΕͨϓϩύςΟͷΈ7JFX4UBUFʹϓϩύςΟ͕ίϐʔ͞ΕΔ
  13. ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ 7JFX4UBUFͷ,FZ1BUI͔Β.Z7JFXͷ,FZ1BUI΁ͱม׵Ͱ͖ΔLFZ1BUI.BQΛఆٛ ޙ΄Ͳ࢖༻ @ViewState struct MyView: View { @State var

    count = 0 @State var isLoading = false var body: some View { EmptyView() } internal struct ViewState: ViewStateProtocol { var count = 0 var isLoading = false internal static let keyPathMap: [PartialKeyPath<ViewState>: PartialKeyPath<MyView>] = [ \.count: \.count, \.isLoading: \.isLoading ] } } extension MyView: ActionSendable { }
  14. ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ public subscript<U>(dynamicMember keyPath: WritableKeyPath<Target.ViewState, U>) -> U { _read

    { #if DEBUG guard !isTesting else { yield viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath<Target, U> { yield target[keyPath: viewKeyPath] } else { fatalError() } } _modify { #if DEBUG guard !isTesting else { yield &viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath<Target, U> { yield &target[keyPath: viewKeyPath] } else { fatalError() } } } EZOBNJD.FNCFS-PPLVQͱ͸ɺΦϒδΣΫτʹଘࡏ͠ͳ͍ϓϩύςΟΛ ͔͋ͨ΋ଘࡏ͢ΔΑ͏ʹݟͤΔ͜ͱ͕Ͱ͖Δػೳ 4UBUF$POUBJOFS
  15. ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ public subscript<U>(dynamicMember keyPath: WritableKeyPath<Target.ViewState, U>) -> U { _read

    { #if DEBUG guard !isTesting else { yield viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath<Target, U> { yield target[keyPath: viewKeyPath] } else { fatalError() } } _modify { #if DEBUG guard !isTesting else { yield &viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath<Target, U> { yield &target[keyPath: viewKeyPath] } else { fatalError() } } } ද޲͖͸7JFX4UBUFͷϓϩύςΟΛ͍࣋ͬͯΔ͔ͷΑ͏ʹݟͤΔ͜ͱͰɺ 3FEVDFS͔ΒCPEZ΍TUPSFͳͲͷϓϩύςΟʹΞΫηεͰ͖ͳ͍Α͏ʹ͍ͯ͠Δ 4UBUF$POUBJOFS
  16. ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ public subscript<U>(dynamicMember keyPath: WritableKeyPath<Target.ViewState, U>) -> U { _read

    { #if DEBUG guard !isTesting else { yield viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath<Target, U> { yield target[keyPath: viewKeyPath] } else { fatalError() } } _modify { #if DEBUG guard !isTesting else { yield &viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath<Target, U> { yield &target[keyPath: viewKeyPath] } else { fatalError() } } } ཪͰ͸ɺҾ਺Ͱड͚औͬͨ,FZ1BUIΛLFZ1BUI.BQΛ࢖༻ͯ͠7JFX4UBUF͔Β7JFXͷ ,FZ1BUI΁ͱม׵ͯ͠ 4UBUF$POUBJOFS
  17. ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ public subscript<U>(dynamicMember keyPath: WritableKeyPath<Target.ViewState, U>) -> U { _read

    { #if DEBUG guard !isTesting else { yield viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath<Target, U> { yield target[keyPath: viewKeyPath] } else { fatalError() } } _modify { #if DEBUG guard !isTesting else { yield &viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath<Target, U> { yield &target[keyPath: viewKeyPath] } else { fatalError() } } } 7JFXͷ஋ΛಡΈࠐΜͩΓॻ͖ࠐΜͩΓ͍ͯ͠Δɻ 4UBUF$POUBJOFS
  18. ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ public subscript<U>(dynamicMember keyPath: WritableKeyPath<Target.ViewState, U>) -> U { _read

    { #if DEBUG guard !isTesting else { yield viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath<Target, U> { yield target[keyPath: viewKeyPath] } else { fatalError() } } _modify { #if DEBUG guard !isTesting else { yield &viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath<Target, U> { yield &target[keyPath: viewKeyPath] } else { fatalError() } } } 4UBUF$POUBJOFS ςετ࣌͸7JFX΁ͷॻ͖ࠐΈͰ͸ͳ͘ɺ7JFX4UBUF΁ͷಡΈɾॻ͖ࠐΈʹ͢Δ͜ͱʹΑͬͯɺ ςελϒϧʹ͍ͯ͠Δ
  19. ࣮ࡍʹΞϓϦΛ࡞੒͢Δ @ViewState struct RootView: View { @State var searchText =

    "" @State var isLoading = false @State var repositories: [Repository] = [] @State var alertState: AlertState<Reducer.Action>? let store: Store<RootReducer> = Store(reducer: RootReducer()) var body: some View { NavigationStack { List { ForEach(repositories) { repository in NavigationLink(repository.fullName) { RepositoryView(repository: repository) .navigationTitle(repository.fullName) } } } .overlay { if isLoading { ProgressView() } } .searchable(text: $searchText) .onSubmit(of: .search) { send(.onSearchButtonTapped) } .onChange(of: searchText) { _, newValue in send(.onTextChanged(newValue)) } .alert(target: self, unwrapping: $alertState) } } 7JFX
  20. ࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS "DUJPO @Reducer struct RootReducer { enum ViewAction: Equatable

    { case onSearchButtonTapped case onTextChanged(String) } enum ReducerAction: Equatable { case fetchRepositoriesResponse(TaskResult<[Repository]>) case alert(Alert) enum Alert: Equatable { case retry } } ... }
  21. ࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS func reduce(into state: StateContainer<RootView>, action: Action) -> SideEffect<Self>

    { switch action { case .onSearchButtonTapped: state.isLoading = true return fetchRepositories(query: state.searchText) case .onTextChanged(let text): if text.isEmpty { state.repositories = [] } return .none case let .fetchRepositoriesResponse(.success(repositories)): state.isLoading = false state.repositories = repositories return .none case let .fetchRepositoriesResponse(.failure(error)): state.isLoading = false state.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } return .none case .alert(.retry): state.isLoading = true return fetchRepositories(query: state.searchText) }
  22. ࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS func reduce(into state: StateContainer<RootView>, action: Action) -> SideEffect<Self>

    { switch action { case .onSearchButtonTapped: state.isLoading = true return fetchRepositories(query: state.searchText) case .onTextChanged(let text): if text.isEmpty { state.repositories = [] } return .none case let .fetchRepositoriesResponse(.success(repositories)): state.isLoading = false state.repositories = repositories return .none case let .fetchRepositoriesResponse(.failure(error)): state.isLoading = false state.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } return .none case .alert(.retry): state.isLoading = true return fetchRepositories(query: state.searchText) } .onSubmit(of: .search) { send(.onSearchButtonTapped) } ݕࡧϘλϯ͕ԡ͞Εͨ࣌ɺϩʔυΛ։࢝͠ɺ ϦϙδτϦΛऔಘ͍ͯ͠Δ
  23. ࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS func reduce(into state: StateContainer<RootView>, action: Action) -> SideEffect<Self>

    { switch action { case .onSearchButtonTapped: state.isLoading = true return fetchRepositories(query: state.searchText) case .onTextChanged(let text): if text.isEmpty { state.repositories = [] } return .none case let .fetchRepositoriesResponse(.success(repositories)): state.isLoading = false state.repositories = repositories return .none case let .fetchRepositoriesResponse(.failure(error)): state.isLoading = false state.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } return .none case .alert(.retry): state.isLoading = true return fetchRepositories(query: state.searchText) } func fetchRepositories( query: String ) -> SideEffect<Self> { .run { send in await send( .fetchRepositoriesResponse( TaskResult { try await fetchRepositories(query) } ) ) } } ϦΫΤετΛૹ৴͠ɺ݁ՌΛ3FEVDFS"DUJPOͷ GFUDI3FQPTJUPSJFT3FTQPOTFΛૹ৴͍ͯ͠Δ
  24. ࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS func reduce(into state: StateContainer<RootView>, action: Action) -> SideEffect<Self>

    { switch action { case .onSearchButtonTapped: state.isLoading = true return fetchRepositories(query: state.searchText) case .onTextChanged(let text): if text.isEmpty { state.repositories = [] } return .none case let .fetchRepositoriesResponse(.success(repositories)): state.isLoading = false state.repositories = repositories return .none case let .fetchRepositoriesResponse(.failure(error)): state.isLoading = false state.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } return .none case .alert(.retry): state.isLoading = true return fetchRepositories(query: state.searchText) } ੒ޭͷ৔߹͸ϩʔσΟϯάΛதࢭ͠ɺ ݁ՌΛSFQPTJUPSJFTʹ୅ೖ͍ͯ͠Δ
  25. ࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS func reduce(into state: StateContainer<RootView>, action: Action) -> SideEffect<Self>

    { switch action { case .onSearchButtonTapped: state.isLoading = true return fetchRepositories(query: state.searchText) case .onTextChanged(let text): if text.isEmpty { state.repositories = [] } return .none case let .fetchRepositoriesResponse(.success(repositories)): state.isLoading = false state.repositories = repositories return .none case let .fetchRepositoriesResponse(.failure(error)): state.isLoading = false state.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } return .none case .alert(.retry): state.isLoading = true return fetchRepositories(query: state.searchText) } ࣦഊͷ৔߹΋ϩʔσΟϯάΛதࢭ͠ɺΞϥʔτΛग़ ͠ɺ3FUSZϘλϯΛ͓͢ͱɺ3FEVDFS"DUJPOͷ BMFSU SFUSZ ͕ݺ͹ΕΔ
  26. ࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS func reduce(into state: StateContainer<RootView>, action: Action) -> SideEffect<Self>

    { switch action { case .onSearchButtonTapped: state.isLoading = true return fetchRepositories(query: state.searchText) case .onTextChanged(let text): if text.isEmpty { state.repositories = [] } return .none case let .fetchRepositoriesResponse(.success(repositories)): state.isLoading = false state.repositories = repositories return .none case let .fetchRepositoriesResponse(.failure(error)): state.isLoading = false state.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } return .none case .alert(.retry): state.isLoading = true return fetchRepositories(query: state.searchText) } ϦτϥΠ͕ԡ͞ΕΔͱɺ࠶౓ϩʔσΟϯάΛ։࢝ ͠ɺϦϙδτϦͷऔಘͷϦΫΤετΛૹ৴͢Δ
  27. ࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS func reduce(into state: StateContainer<RootView>, action: Action) -> SideEffect<Self>

    { switch action { case .onSearchButtonTapped: state.isLoading = true return fetchRepositories(query: state.searchText) case .onTextChanged(let text): if text.isEmpty { state.repositories = [] } return .none case let .fetchRepositoriesResponse(.success(repositories)): state.isLoading = false state.repositories = repositories return .none case let .fetchRepositoriesResponse(.failure(error)): state.isLoading = false state.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } return .none case .alert(.retry): state.isLoading = true return fetchRepositories(query: state.searchText) } ςΩετͷೖྗΛPO$IBOHF PGJOJUJBM@ Ͱݕ஌͠ɺ 7JFX"DUJPOPO5FYU$IBOHFEΛૹ৴͍ͯ͠Δ .onChange(of: searchText) { _, newValue in send(.onTextChanged(newValue)) } UFYU͕ۭʹͳͬͨ࣌ʹɺTUBUFSFQPTJUPSJFTΛۭʹ͍ͯ͠Δ
  28. ࣮ࡍʹΞϓϦΛ࡞੒͢Δ 5FTU @MainActor final class RootReducerTests: XCTestCase { func testSearchButtonTapped()

    async { let store = RootView().testStore(viewState: RootView.ViewState()) { $0.repositoryClient = RepositoryClient( fetchRepositories: { _ in [.stub] } ) } await store.send(.onSearchButtonTapped) { $0.isLoading = true } await store.receive(.fetchRepositoriesResponse(.success([.stub]))) { $0.repositories = [.stub] $0.isLoading = false } } ... } ຊΞʔΩςΫνϟ͸TXJGUEFQFOEFODJFTʹ ରԠ͍ͯ͠ΔͷͰɺ ϞοΫ΁ͷࠩ͠ସ͕͑Մೳ
  29. ࣮ࡍʹΞϓϦΛ࡞੒͢Δ 5FTU @MainActor final class RootReducerTests: XCTestCase { func testSearchButtonTapped()

    async { let store = RootView().testStore(viewState: RootView.ViewState()) { $0.repositoryClient = RepositoryClient( fetchRepositories: { _ in [.stub] } ) } await store.send(.onSearchButtonTapped) { $0.isLoading = true } await store.receive(.fetchRepositoriesResponse(.success([.stub]))) { $0.repositories = [.stub] $0.isLoading = false } } ... } Ϙλϯ͕ԡ͞Εͨ࣌ɺϦΫΤετ͕੒ޭͨ͠ ࣌ͷ஋ͷঢ়ଶͷมߋΛςετ
  30. ࣮ࡍʹΞϓϦΛ࡞੒͢Δ 5FTU func testSearchButtonTappedInFailure() async { let error = CancellationError()

    let store = RootView().testStore(viewState: RootView.ViewState()) { $0.repositoryClient = RepositoryClient( fetchRepositories: { _ in throw error } ) } await store.send(.onSearchButtonTapped) { $0.isLoading = true } await store.receive(.fetchRepositoriesResponse(.failure(error))) { $0.isLoading = false $0.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } } } ࣍ʹϦΫΤετதʹΤϥʔΛൃੜͤͨ͞৔ ߹ͷςετΛ͢Δ
  31. ࣮ࡍʹΞϓϦΛ࡞੒͢Δ 5FTU func testSearchButtonTappedInFailure() async { let error = CancellationError()

    let store = RootView().testStore(viewState: RootView.ViewState()) { $0.repositoryClient = RepositoryClient( fetchRepositories: { _ in throw error } ) } await store.send(.onSearchButtonTapped) { $0.isLoading = true } await store.receive(.fetchRepositoriesResponse(.failure(error))) { $0.isLoading = false $0.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } } } Τϥʔ͕ฦ͖ͬͯͨ৔߹ͷঢ়ଶͷมߋΛςετ
  32. ·ͱΊ 5$"ΑΓྑ͍఺ɾѱ͍఺ w ࢠΛ਌υϝΠϯʹ݁߹͠ͳ͍ͷͰɺͲΕ͚ͩ7JFXͷ֊૚͕ԼͰ΋ύϑΥʔϚϯε͕มΘΒͳ͍ w ԿΑΓ΋γϯϓϧʹΞϓϦ͕࡞ΕΔ w 5$"ʹ͸͞·͟·ͳϝιου΍NPEJ fi FS

    ΧελϜ7JFX͕ଘࡏ͢Δ͕ɺͦΕΒͷ࢖͍ํΛ֮͑Δඞཁ͕ͳ͍ ྑ͍఺ ѱ͍఺ w ࢠΛ਌υϝΠϯʹ݁߹͠ͳ͍͜ͱʹΑΓɺ౷߹ςετ͕ॻ͚ͳ͘ͳΔ w Ұݩతͳঢ়ଶ؅ཧΛ͍ͯ͠ͳ͍ͨΊɺঢ়ଶͷෆ੔߹͕5$"ͷํ͕ൃੜ͠ʹ͍͘