diff --git a/CHANGELOG.md b/CHANGELOG.md index 795b1a0..4523a27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ **Other:** +- Adds `ExpectThunk` testing helper and corresponding CocoaPods subspec (#19) -- @jjgp + # 1.1.0 *Released: 01/16/2019* diff --git a/README.md b/README.md index 59c3ce0..6724448 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,22 @@ store.dispatch(thunkWithParams(10)) // Note that these actions won't reach the reducers, instead, the thunks middleware will catch it and execute its body, producing the desired side effects. ``` +## Testing + +The `ExpectThunk` helper, available as a CocoaPods subspec, allows for testing the order and actions of `dispatch` as well as the +dependencies on `getState`. + +```swift +ExpectThunk(thunk) + .getsState(RequestState(loading: false)) + // If the action is Equatable it will be asserted for equality with `dispatches`. + .dispatches(RequestStart()) + .dispatches { action in + XCTAssert(action.something == expectedSomething) + } + .wait() // or simply run() for synchronous flows +``` + ## Installation ReSwift-Thunk requires the [ReSwift](https://github.com/ReSwift/ReSwift/) base module. @@ -67,12 +83,24 @@ ReSwift-Thunk requires the [ReSwift](https://github.com/ReSwift/ReSwift/) base m ### CocoaPods You can install ReSwift-Thunk via CocoaPods by adding it to your `Podfile`: + ``` -pod 'ReSwiftThunk' +target 'TARGET' do + pod 'ReSwiftThunk' +end + +target 'TARGET-TESTS' do + pod 'ReSwiftThunk/ExpectThunk' +end ``` And run `pod install`. +#### A Note on Including ExpectThunk + +If the `ExpectThunk` subspec is used, the tests target cannot be nested in another target due to current limitations. The tests target must +be a standalone target as shown in the snippet above. + ### Carthage You can install ReSwift-Thunk via [Carthage](https://github.com/Carthage/Carthage) by adding the following line to your `Cartfile`: diff --git a/ReSwift-Thunk.xcodeproj/project.pbxproj b/ReSwift-Thunk.xcodeproj/project.pbxproj index b733562..a5880ab 100644 --- a/ReSwift-Thunk.xcodeproj/project.pbxproj +++ b/ReSwift-Thunk.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 65A3D6F0218B8A300075CB92 /* Thunk.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A3D6EF218B8A300075CB92 /* Thunk.swift */; }; 65A3D6F3218C91F20075CB92 /* ReSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 65A3D6F2218C91F20075CB92 /* ReSwift.framework */; }; 65A3D6F5218C92420075CB92 /* createThunksMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A3D6F4218C92420075CB92 /* createThunksMiddleware.swift */; }; + D28E4F562214F37100DEFA7D /* ExpectThunk.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28E4F552214F37100DEFA7D /* ExpectThunk.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -35,6 +36,7 @@ 65A3D6EF218B8A300075CB92 /* Thunk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thunk.swift; sourceTree = ""; }; 65A3D6F2218C91F20075CB92 /* ReSwift.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReSwift.framework; path = Carthage/Build/iOS/ReSwift.framework; sourceTree = ""; }; 65A3D6F4218C92420075CB92 /* createThunksMiddleware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = createThunksMiddleware.swift; sourceTree = ""; }; + D28E4F552214F37100DEFA7D /* ExpectThunk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpectThunk.swift; sourceTree = ""; }; D4286E7721EFED3F00D5749B /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; D4286E7821EFED4D00D5749B /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; /* End PBXFileReference section */ @@ -94,6 +96,7 @@ 65A3D6E2218B89A60075CB92 /* ReSwift-ThunkTests */ = { isa = PBXGroup; children = ( + D28E4F552214F37100DEFA7D /* ExpectThunk.swift */, 65A3D6E3218B89A60075CB92 /* Tests.swift */, 65A3D6E5218B89A60075CB92 /* Info.plist */, ); @@ -227,6 +230,7 @@ buildActionMask = 2147483647; files = ( 65A3D6E4218B89A60075CB92 /* Tests.swift in Sources */, + D28E4F562214F37100DEFA7D /* ExpectThunk.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -458,6 +462,7 @@ "@loader_path/../Frameworks", "$(FRAMEWORK_SEARCH_PATHS)", ); + OTHER_SWIFT_FLAGS = "-D RESWIFT_THUNKTESTS"; PRODUCT_BUNDLE_IDENTIFIER = "reswift.github.io.ReSwift-ThunkTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 4.2; @@ -480,6 +485,7 @@ "@loader_path/../Frameworks", "$(FRAMEWORK_SEARCH_PATHS)", ); + OTHER_SWIFT_FLAGS = "-D RESWIFT_THUNKTESTS"; PRODUCT_BUNDLE_IDENTIFIER = "reswift.github.io.ReSwift-ThunkTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 4.2; diff --git a/ReSwift-ThunkTests/ExpectThunk.swift b/ReSwift-ThunkTests/ExpectThunk.swift new file mode 100644 index 0000000..451900a --- /dev/null +++ b/ReSwift-ThunkTests/ExpectThunk.swift @@ -0,0 +1,148 @@ +// +// ExpectThunk.swift +// ReSwift-Thunk-Tests +// +// Created by Jason Prasad on 2/13/19. +// Copyright © 2019 ReSwift. All rights reserved. +// + +import XCTest +import ReSwift + +#if RESWIFT_THUNKTESTS +import ReSwiftThunk +#endif + +private struct ExpectThunkAssertion { + fileprivate let associated: T + private let description: String + private let file: StaticString + private let line: UInt + + init(description: String, file: StaticString, line: UInt, associated: T) { + self.associated = associated + self.description = description + self.file = file + self.line = line + } + + fileprivate func failed() { + XCTFail(description, file: file, line: line) + } +} + +public class ExpectThunk { + private var dispatch: DispatchFunction { + return { action in + self.dispatched.append(action) + guard self.dispatchAssertions.isEmpty == false else { + return + } + self.dispatchAssertions.remove(at: 0).associated(action) + } + } + private var dispatchAssertions = [ExpectThunkAssertion]() + public var dispatched = [Action]() + private var getState: () -> State? { + return { + return self.getStateAssertions.isEmpty ? nil : self.getStateAssertions.removeFirst().associated + } + } + private var getStateAssertions = [ExpectThunkAssertion]() + private let thunk: Thunk + + public init(_ thunk: Thunk) { + self.thunk = thunk + } +} + +extension ExpectThunk { + @discardableResult + public func dispatches(_ expected: A, + file: StaticString = #file, + line: UInt = #line) -> Self { + dispatchAssertions.append( + ExpectThunkAssertion( + description: "Unfulfilled dispatches: \(expected)", + file: file, + line: line + ) { received in + XCTAssert( + received as? A == expected, + "Dispatched action does not equal expected: \(received) \(expected)", + file: file, + line: line + ) + } + ) + return self + } + + @discardableResult + public func dispatches(file: StaticString = #file, + line: UInt = #line, + dispatch assertion: @escaping DispatchFunction) -> Self { + dispatchAssertions.append( + ExpectThunkAssertion( + description: "Unfulfilled dispatches: dispatch assertion", + file: file, + line: line, + associated: assertion + ) + ) + return self + } +} + +extension ExpectThunk { + @discardableResult + public func getsState(_ state: State, + file: StaticString = #file, + line: UInt = #line) -> Self { + getStateAssertions.append( + ExpectThunkAssertion( + description: "Unfulfilled getsState: \(state)", + file: file, + line: line, + associated: state + ) + ) + return self + } +} + +extension ExpectThunk { + @discardableResult + public func run(file: StaticString = #file, line: UInt = #line) -> Self { + createThunksMiddleware()(dispatch, getState)({ _ in })(thunk) + failLeftovers() + return self + } + + @discardableResult + public func wait(timeout seconds: TimeInterval = 1, + file: StaticString = #file, + line: UInt = #line, + description: String = "\(ExpectThunk.self)") -> Self { + let expectation = XCTestExpectation(description: description) + defer { + if XCTWaiter().wait(for: [expectation], timeout: seconds) != .completed { + XCTFail("Asynchronous wait failed: unfulfilled dispatches", file: file, line: line) + } + failLeftovers() + } + let dispatch: DispatchFunction = { + self.dispatch($0) + if self.dispatchAssertions.isEmpty == true { + expectation.fulfill() + } + } + createThunksMiddleware()(dispatch, getState)({ _ in })(thunk) + return self + } + + private func failLeftovers() { + dispatchAssertions.forEach { $0.failed() } + getStateAssertions.forEach { $0.failed() } + } +} diff --git a/ReSwift-ThunkTests/Tests.swift b/ReSwift-ThunkTests/Tests.swift index 7f95935..587d875 100644 --- a/ReSwift-ThunkTests/Tests.swift +++ b/ReSwift-ThunkTests/Tests.swift @@ -13,6 +13,7 @@ import ReSwift private struct FakeState: StateType {} private struct FakeAction: Action {} +private struct AnotherFakeAction: Action, Equatable {} private func fakeReducer(action: Action, state: FakeState?) -> FakeState { return state ?? FakeState() } @@ -58,4 +59,46 @@ class Tests: XCTestCase { store.dispatch(thunk) XCTAssertTrue(thunkBodyCalled) } + + func testExpectThunkRuns() { + let thunk = Thunk { dispatch, getState in + dispatch(FakeAction()) + XCTAssertNotNil(getState()) + dispatch(FakeAction()) + } + let expectThunk = ExpectThunk(thunk) + .dispatches { + XCTAssert($0 is FakeAction) + } + .getsState(FakeState()) + .dispatches { + XCTAssert($0 is FakeAction) + } + .run() + XCTAssertEqual(expectThunk.dispatched.count, 2) + } + + func testExpectThunkWaits() { + let thunk = Thunk { dispatch, getState in + dispatch(FakeAction()) + XCTAssertNotNil(getState()) + DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 0.5) { + dispatch(AnotherFakeAction()) + XCTAssertNotNil(getState()) + } + dispatch(FakeAction()) + } + let expectThunk = ExpectThunk(thunk) + .dispatches { + XCTAssert($0 is FakeAction) + } + .getsState(FakeState()) + .dispatches { + XCTAssert($0 is FakeAction) + } + .dispatches(AnotherFakeAction()) + .getsState(FakeState()) + .wait() + XCTAssertEqual(expectThunk.dispatched.count, 3) + } } diff --git a/ReSwiftThunk.podspec b/ReSwiftThunk.podspec index 8ca43ad..e1b1869 100644 --- a/ReSwiftThunk.podspec +++ b/ReSwiftThunk.podspec @@ -20,7 +20,19 @@ Pod::Spec.new do |spec| spec.source = { :git => "https://github.com/ReSwift/ReSwift-Thunk.git", :tag => spec.version.to_s } - spec.source_files = "ReSwift-Thunk" + + spec.subspec "Core" do |sp| + sp.source_files = "ReSwift-Thunk" + end + + spec.subspec "ExpectThunk" do |sp| + sp.dependency "ReSwiftThunk/Core" + sp.pod_target_xcconfig = { "ENABLE_BITCODE" => "NO" } + sp.framework = "XCTest" + sp.source_files = "ReSwift-ThunkTests/ExpectThunk.swift" + end + + spec.default_subspec = "Core" spec.dependency "ReSwift", "~> 4.0" end