Share
Embed
TCAのようなアーキテクチャを作ってみた話
Ryu
October 20, 2023
Transcript
54 slides
-
ࣗݾհ w ΓΎ͏ !SZV@IV w ๏େֶిؾిࢠֶՊ# w ͘Β͍4XJGU6* 5$"ͰษڧܥͷΞϓϦΛ։ൃͯ͠Δ
ະϦϦʔε w $-*πʔϧϥΠϒϥϦΛ࡞Δͷ͕͖ -
ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ w 3FEVDFSͷΠϯλʔϑΣʔε΄΅5$" w 5$"ͱҧͬͯࢠ3FEVDFSʹ݁߹͠ͳ͍ w 7JFXͷঢ়ଶཧʹ!4UBUF!#JOEJOH !1VCMJTIFEͳͲ͕͑Δ w ςελϒϧ
w 3FEVDFSͷΈʹ༻Ͱ͖Δ3FEVDFS4UBUF3FEVDFS"DUJPOͳͲ͕͋Δ -
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Λ༻ͯ͠ঢ়ଶΛఆٛ -
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ͰมߋͰ͖Δ -
3FEVDFS4UBUF ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ @Reducer struct CounterReducer { ... struct ReducerState {
var totalCalledCount = 0 } func reduce( into state: StateContainer<CounterView>, action: Action ) -> SideEffect<Self> { state.reducerState.totalCalledCount += 1 ... } } -
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ΛఆٛͰ͖Δ -
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ͳʹ͔ʁ -
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) } } } -
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) } } } -
ςετ ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ 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 } } -
ςετ ࣗ࡞ΞʔΩςΫνϟͷ࣮ ݁ɺςετͷ࣌ͷΈ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 } } -
ࣗ࡞ΞʔΩςΫνϟͷ࣮ @ViewState struct MyView: View { @State var count =
0 @State var isLoading = false var body: some View { EmptyView() } } 7JFX4UBUFϚΫϩΛల։͢Δͱ -
ࣗ࡞ΞʔΩςΫνϟͷ࣮ @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ʹ४ڌ͞ΕΔ -
ࣗ࡞ΞʔΩςΫνϟͷ࣮ @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ʹϓϩύςΟ͕ίϐʔ͞ΕΔ -
ࣗ࡞ΞʔΩςΫνϟͷ࣮ 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 { } -
ࣗ࡞ΞʔΩςΫνϟͷ࣮ 7JFX4UBUFߏମʹͭͷॏཁͳׂ͕͋Δ CPEZTUPSFͳͲɺ7JFXͷঢ়ଶʹؔͷͳ͍ϓϩύςΟΛ4UBUF$POUBJOFSͰӅṭ͢Δ ςελϒϧʹ͢Δ func reduce( into state: StateContainer<CounterView>,
action: Action ) -> SideEffect<Self> { switch action { case .increment: state.counter += 1 return .none } } TUBUF͔ΒɺCPEZTUPSFʹΞΫηεͰ͖ͳ͍ -
ࣗ࡞ΞʔΩςΫνϟͷ࣮ 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 -
ࣗ࡞ΞʔΩςΫνϟͷ࣮ 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͔ΒCPEZTUPSFͳͲͷϓϩύςΟʹΞΫηεͰ͖ͳ͍Α͏ʹ͍ͯ͠Δ 4UBUF$POUBJOFS -
ࣗ࡞ΞʔΩςΫνϟͷ࣮ 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 -
ࣗ࡞ΞʔΩςΫνϟͷ࣮ 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 -
ࣗ࡞ΞʔΩςΫνϟͷ࣮ 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ͷಡΈɾॻ͖ࠐΈʹ͢Δ͜ͱʹΑͬͯɺ ςελϒϧʹ͍ͯ͠Δ -
࣮ࡍʹΞϓϦΛ࡞͢Δ @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 -
࣮ࡍʹΞϓϦΛ࡞͢Δ 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 } } ... } -
࣮ࡍʹΞϓϦΛ࡞͢Δ 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) } -
࣮ࡍʹΞϓϦΛ࡞͢Δ 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) } ݕࡧϘλϯ͕ԡ͞Εͨ࣌ɺϩʔυΛ։࢝͠ɺ ϦϙδτϦΛऔಘ͍ͯ͠Δ -
࣮ࡍʹΞϓϦΛ࡞͢Δ 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Λૹ৴͍ͯ͠Δ -
࣮ࡍʹΞϓϦΛ࡞͢Δ 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ʹೖ͍ͯ͠Δ -
࣮ࡍʹΞϓϦΛ࡞͢Δ 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 ͕ݺΕΔ -
࣮ࡍʹΞϓϦΛ࡞͢Δ 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) } ϦτϥΠ͕ԡ͞ΕΔͱɺ࠶ϩʔσΟϯάΛ։࢝ ͠ɺϦϙδτϦͷऔಘͷϦΫΤετΛૹ৴͢Δ -
࣮ࡍʹΞϓϦΛ࡞͢Δ 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Λۭʹ͍ͯ͠Δ -
࣮ࡍʹΞϓϦΛ࡞͢Δ 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ʹ ରԠ͍ͯ͠ΔͷͰɺ ϞοΫͷࠩ͠ସ͕͑Մೳ -
࣮ࡍʹΞϓϦΛ࡞͢Δ 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 } } ... } Ϙλϯ͕ԡ͞Εͨ࣌ɺϦΫΤετ͕ޭͨ͠ ࣌ͷͷঢ়ଶͷมߋΛςετ -
࣮ࡍʹΞϓϦΛ࡞͢Δ 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) } } } ࣍ʹϦΫΤετதʹΤϥʔΛൃੜͤͨ͞ ߹ͷςετΛ͢Δ -
࣮ࡍʹΞϓϦΛ࡞͢Δ 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) } } } Τϥʔ͕ฦ͖ͬͯͨ߹ͷঢ়ଶͷมߋΛςετ -
·ͱΊ 5$"ΑΓྑ͍ɾѱ͍ w ࢠΛυϝΠϯʹ݁߹͠ͳ͍ͷͰɺͲΕ͚ͩ7JFXͷ֊͕ԼͰύϑΥʔϚϯε͕มΘΒͳ͍ w ԿΑΓγϯϓϧʹΞϓϦ͕࡞ΕΔ w 5$"ʹ͞·͟·ͳϝιουNPEJ fi FS
ΧελϜ7JFX͕ଘࡏ͢Δ͕ɺͦΕΒͷ͍ํΛ֮͑Δඞཁ͕ͳ͍ ྑ͍ ѱ͍ w ࢠΛυϝΠϯʹ݁߹͠ͳ͍͜ͱʹΑΓɺ౷߹ςετ͕ॻ͚ͳ͘ͳΔ w Ұݩతͳঢ়ଶཧΛ͍ͯ͠ͳ͍ͨΊɺঢ়ଶͷෆ߹͕5$"ͷํ͕ൃੜ͠ʹ͍͘