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

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

Ryu
October 20, 2023

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

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$"ͷํ͕ൃੜ͠ʹ͍͘