diff --git a/.jazzy.yaml b/.jazzy.yaml index 2e7c57c..1083931 100644 --- a/.jazzy.yaml +++ b/.jazzy.yaml @@ -8,6 +8,11 @@ documentation: "Documentation/Guides/*.md" abstract: "Documentation/Abstracts/*.md" custom_categories: + - name: Guides + children: + - Testing Reducers + - Testing Selectors + - Testing Effects - name: Store children: - Store diff --git a/Documentation/Abstracts/Test Support.md b/Documentation/Abstracts/Test Support.md index e5970a1..a0d9824 100644 --- a/Documentation/Abstracts/Test Support.md +++ b/Documentation/Abstracts/Test Support.md @@ -1,6 +1,6 @@ -Every part of an application using Fluxor is highly testable. The separation of the `Action` (instructions), `Selectors` (reading), `Reducer` (mutating) and `Effect` (asynchronous) make each part decoupled, testable and easier to grasp. +Every part of an application using Fluxor is highly testable. The separation of the `Action` (instructions), `Selector` (reading), `Reducer` (mutating) and `Effect` (asynchronous) make each part decoupled, testable and easier to grasp. -But to help out when testing components using Fluxor or asynchronous `Effects`, Fluxor comes with a separate package (**FluxorTestSupport**) with a `MockStore` and a runner for running `Effect` syncronously. +But to help out when testing components using Fluxor or asynchronous `Effect`s, Fluxor comes with a separate package (**FluxorTestSupport**) with a `MockStore`, `TestInterceptor` and an `EffectRunner` to make `Effect`s run syncronously. FluxorTestSupport should only be linked in unit testing targets. @@ -17,32 +17,32 @@ import FluxorTestSupport import XCTest class GreetingView: XCTestCase { - func testGreeting() { - let mockStore = MockStore(initialState: AppState()) - let view = GreetingView(store: mockStore) - XCTAssert(...) - mockStore.setState(AppState(greeting: "Hi Bob!")) - XCTAssert(...) - } + func testGreeting() { + let mockStore = MockStore(initialState: AppState()) + let view = GreetingView(store: mockStore) + XCTAssert(...) + mockStore.setState(AppState(greeting: "Hi Bob!")) + XCTAssert(...) + } } ``` ### Overriding `Selectors` -The `MockStore` can be used to override `Selectors` so that they always return a specific value. +The `MockStore` can be used to override `Selector`s so that they always return a specific value. ```swift import FluxorTestSupport import XCTest class GreetingView: XCTestCase { - func testGreeting() { - let greeting = "Hi Bob!" - let mockStore = MockStore(initialState: AppState(greeting: "Hi Steve!")) - mockStore.overrideSelector(Selectors.getGreeting, value: greeting) - let view = GreetingView(store: mockStore) - XCTAssertEqual(view.greeting, greeting) - } + func testGreeting() { + let greeting = "Hi Bob!" + let mockStore = MockStore(initialState: AppState(greeting: "Hi Steve!")) + mockStore.overrideSelector(Selectors.getGreeting, value: greeting) + let view = GreetingView(store: mockStore) + XCTAssertEqual(view.greeting, greeting) + } } ``` @@ -55,15 +55,15 @@ import FluxorTestSupport import XCTest class GreetingView: XCTestCase { - func testGreeting() { - let testInterceptor = TestInterceptor() - let store = Store(initialState: AppState()) - store.register(interceptor: self.testInterceptor) - let view = GreetingView(store: store) - XCTAssertEqual(testInteceptor.stateChanges.count, 0) - view.updateGreeting() - XCTAssertEqual(testInteceptor.stateChanges.count, 1) - } + func testGreeting() { + let testInterceptor = TestInterceptor() + let store = Store(initialState: AppState()) + store.register(interceptor: self.testInterceptor) + let view = GreetingView(store: store) + XCTAssertEqual(testInteceptor.stateChanges.count, 0) + view.updateGreeting() + XCTAssertEqual(testInteceptor.stateChanges.count, 1) + } } ``` @@ -85,7 +85,7 @@ class SettingsEffectsTests: XCTestCase { func testSetBackground() { let effects = SettingsEffects() let action = Actions.setBackgroundColor(payload: .red) - let result = try EffectRunner.run(effects.setBackgroundColor, with: action, environment: AppEnvironment())! + let result = try EffectRunner.run(effects.setBackgroundColor, with: action)! XCTAssertEqual(result.count, 1) XCTAssertEqual(result[0], Actions.hideColorPicker()) } diff --git a/Documentation/Guides/Testing Effects.md b/Documentation/Guides/Testing Effects.md new file mode 100644 index 0000000..f86b8dd --- /dev/null +++ b/Documentation/Guides/Testing Effects.md @@ -0,0 +1,22 @@ +# Testing `Effect`s + +In Fluxor `Effect`s are `Publisher`s based on the actions dispatched in the `Store`. They are inherently asynchronous, so in order to test it in a synchronous test some boilerplate code is needed. + +Fluxor comes with an `EffectRunner` (in the FluxorTestSupport package), which will run the `Effect` with a specified `Action` and waits for a given number of expected `Action`s published by the `Effect`. + +```swift +import FluxorTestSupport +import XCTest + +class SettingsEffectsTests: XCTestCase { + func testSetBackground() { + let effects = SettingsEffects() + let action = Actions.setBackgroundColor(payload: .red) + let result = try EffectRunner.run(effects.setBackgroundColor, with: action)! + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0], Actions.hideColorPicker()) + } +} +``` + +To read more about the `EffectRunner`, take a look at the documentation for [FluxorTestSupport](https://fluxor.dev/Test%20Support.html). \ No newline at end of file diff --git a/Documentation/Guides/Testing Reducers.md b/Documentation/Guides/Testing Reducers.md new file mode 100644 index 0000000..b865cdf --- /dev/null +++ b/Documentation/Guides/Testing Reducers.md @@ -0,0 +1,31 @@ +# Testing `Reducer`s + +In Fluxor `Reducer`s are basically pure functions which takes an instance of `State` and an `Action`, and returns a new `State`. +This means that given the same parameters, the `Reducer` will always return the same output. + +```swift +let appReducer = Reducer( + ReduceOn(IncrementAction.self) { state, action in + state.counter += action.value + } +) + +class ReducersTests: XCTestCase { + func testIncrementAction() { + // Given + var state = CounterState(counter: 0) + // When + appReducer.reduce(&state, IncrementAction(value: 1)) + // Then + XCTAssertEqual(state.counter, 1) + } +} + +struct CounterState { + var counter: Int +} + +struct IncrementAction: Action { + let value: Int +} +``` \ No newline at end of file diff --git a/Documentation/Guides/Testing Selectors.md b/Documentation/Guides/Testing Selectors.md new file mode 100644 index 0000000..d78d3b8 --- /dev/null +++ b/Documentation/Guides/Testing Selectors.md @@ -0,0 +1,43 @@ +# Testing `Selector`s + +In Fluxor `Selector`s are projectors of `State`. `Selector`s can be created by a `KeyPath`, by a closure or based on up to 5 other `Selector`s. +When a `Selector` is based on other `Selector`s, the projector takes the `Value`s from the others as parameters. + +## Testing basic `Selector`s + +The `Selector`'s `map` function takes the `State` and returns a `Value`. + +```swift +struct Selectors { + static let getNameState = Selector(keyPath: \AppState.name) +} + +class SelectorsTests: XCTestCase { + func testGetNameState() { + // Given + let state = AppState(name: NameState(firstName: "Tim", lastName: "Cook")) + // Then + XCTAssertEqual(Selectors.getNameState.map(state), state.name) + } +} +``` + +## Testing `Selector`s based on `Selector`s + +If a `Selector` is based on the `Value`s from other `Selector`s, it will also have a `projector` property. +The `projector` can be used in tests, to easily test the `Selector` without creating the full `State` instance. + +```swift +extension Selectors { + static let congratulations = Selector.with(getFullName, getBirthday) { fullName, birthday in + "Congratulations \(fullName)! Today is \(birthday.month) \(birthday.day) - your birthday!" + } +} + +class SelectorsTests: XCTestCase { + func testCongratulations() { + XCTAssertEqual(Selectors.congratulations.projector("Tim Cook", Birthday(month: "November", day: "1")), + "Congratulations Tim Cook! Today is November 1 - your birthday!") + } +} +``` diff --git a/Sources/FluxorTestSupport/EffectRunner.swift b/Sources/FluxorTestSupport/EffectRunner.swift index 38a6632..96c4c04 100644 --- a/Sources/FluxorTestSupport/EffectRunner.swift +++ b/Sources/FluxorTestSupport/EffectRunner.swift @@ -9,8 +9,8 @@ import Dispatch import Fluxor import XCTest -/// A helper for running `Effect`s in tests. -public struct EffectRunner { +/// The `EffectRunner` can be used to run `Effect`s with a specified `Action`. +public struct EffectRunner { /** Run the `Effect` with the specified `Action` and return the published `Action`s. @@ -24,10 +24,10 @@ public struct EffectRunner { - Returns: The `Action`s published by the `Effect` if it is dispatching */ @discardableResult - public static func run(_ effect: Effect, - with action: Action, - environment: Environment, - expectedCount: Int = 1) throws -> [Action]? { + public static func run(_ effect: Effect, + with action: Action, + environment: Environment, + expectedCount: Int = 1) throws -> [Action]? { let actions = PassthroughSubject() let runDispatchingEffect = { (publisher: AnyPublisher<[Action], Never>) throws -> [Action] in let recorder = ActionRecorder(expectedNumberOfActions: expectedCount) @@ -50,6 +50,26 @@ public struct EffectRunner { } } +public extension EffectRunner where Environment == Void { + /** + Run the `Effect` with the specified `Action` and return the published `Action`s. + + The `expectedCount` defines how many `Action`s the `Publisher` should publish before they are returned. + If the `Effect` is `.nonDispatching`, the `expectedCount` is ignored. + + - Parameter effect: The `Effect` to run + - Parameter action: The `Action` to send to the `Effect` + - Parameter expectedCount: The count of `Action`s to wait for + - Returns: The `Action`s published by the `Effect` if it is dispatching + */ + @discardableResult + static func run(_ effect: Effect, + with action: Action, + expectedCount: Int = 1) throws -> [Action]? { + return try run(effect, with: action, environment: Void(), expectedCount: expectedCount) + } +} + /** The `ActionRecorder` records published `Action`s from a `Publisher`. diff --git a/Tests/FluxorTests/EffectsTests.swift b/Tests/FluxorTests/EffectsTests.swift index 4f0d50d..1136921 100644 --- a/Tests/FluxorTests/EffectsTests.swift +++ b/Tests/FluxorTests/EffectsTests.swift @@ -118,7 +118,7 @@ class EffectsTests: XCTestCase { } return publisher.eraseToAnyPublisher() } - try EffectRunner.run(effect, with: Test1Action(), environment: Void(), expectedCount: 1) + try EffectRunner.run(effect, with: Test1Action(), expectedCount: 1) XCTAssertNotNil(cancellable) } }