Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions WorkflowTesting/Sources/WorkflowActionTester.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import XCTest
@testable import Workflow

extension WorkflowAction {
/// Returns a state tester containing `self`.
/// Returns a `WorkflowActionTester` with the given state before the `WorkflowAction` has been applied to it.
///
/// - Parameters:
/// - state: The `WorkflowType.State` instance that specifies the state before the `WorkflowAction` has been applied.
/// - workflow: An optional `WorkflowType` instance to be used if the `WorkflowAction` needs to read workflow properties off of the `ApplyContext` parameter during action application. If this parameter is unspecified, attempts to access the `WorkflowType`'s properties will error in the testing runtime.
/// - Returns: An appropriately-configured `WorkflowActionTester`.
public static func tester(
withState state: WorkflowType.State,
workflow: WorkflowType? = nil
Expand Down Expand Up @@ -70,6 +75,19 @@ extension WorkflowAction {
/// .assert(output: .finished)
/// .assert(state: .differentState)
/// ```
///
/// If the `Action` under test uses the runtime's `ApplyContext` to read values from the
/// current `Workflow` instance, then an instance of the `Workflow` with the expected
/// properties that will be read during `send(action:)` must be supplied like:
/// ```
/// MyWorkflow.Action
/// .tester(
/// withState: .firstState,
/// workflow: MyWorkflow(prop: 42)
/// )
/// .send(action: .exampleActionThatReadsWorkflowProp)
/// .assert(...)
/// ```
public struct WorkflowActionTester<WorkflowType, Action: WorkflowAction> where Action.WorkflowType == WorkflowType {
/// The current state
let state: WorkflowType.State
Expand Down Expand Up @@ -209,10 +227,25 @@ struct TestApplyContext<Wrapped: Workflow>: ApplyContextType {
case .workflow(let workflow):
return workflow[keyPath: keyPath]
case .expectations(var expectedValues):
guard let value = expectedValues.removeValue(forKey: keyPath) as? Value else {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

without separating the dictionary lookup from the cast, if you didn't pass in a Workflow instance to the .tester() API and the apply() implementation was reading a value of optional type, this line would always 'pass' and return nil when we really want it to fail with the informative error messages below.

fatalError("Attempted to read value \(keyPath as AnyKeyPath), when applying an action, but no value was present. Pass an instance of the Workflow to the ActionTester to enable this functionality.")
guard
// We have an expected value
let value = expectedValues.removeValue(forKey: keyPath),
// And it's the right type
let value = value as? Value
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a case where a completely different type could be stored here, not related to the optionality? since these are both in the same guard we're still lumping those two conditions together - "did I get a value" vs. "is it the right type".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's a good point... currently i think it's impossible since this path isn't fully implemented to support actually putting things into the expectations dictionary, but i think you're right the cases should be handled more granularly. thanks!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated to just mark this path as currently unsupported rather than piggy-backing on the unimplemented expectations dictionary feature. now we don't care about the actual type, just the optionality and will error in all cases if there's no workflow provided to resolve the values from.

else {
// We're expecting a value of optional type. Error, but don't crash
// since we can just return nil.
if Value.self is OptionalProtocol.Type {
reportIssue("Attempted to read value \(keyPath as AnyKeyPath), when applying an action, but no value was present. Pass an instance of the Workflow to the ActionTester to enable this functionality.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not crash like you do below?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this way we can just fail the test and allow other tests to proceed rather than halting the entire process

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that desirable? I worry that this will hide bugs if you don't happen to notice the log amidst log noise.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the expectation is that this would be called from a test, so failing the test vs terminating the process seems marginally better to me. i don't feel particularly strongly about it though, so happy to leave it as it was. there is some precedent for this in the way RenderTester handles certain cases IIRC.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess another benefit of this approach is it allows unit tests in this repo to exercise that path somewhat

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, if reportIssue fails the test then it's fine I suppose

return Any?.none as! Value
} else {
fatalError("Attempted to read value \(keyPath as AnyKeyPath), when applying an action, but no value was present. Pass an instance of the Workflow to the ActionTester to enable this functionality.")
}
}
return value
}
}
}

private protocol OptionalProtocol {}
extension Optional: OptionalProtocol {}
49 changes: 43 additions & 6 deletions WorkflowTesting/Tests/WorkflowActionTesterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
* limitations under the License.
*/

import IssueReporting
import Workflow
import XCTest

@testable import WorkflowTesting

final class WorkflowActionTesterTests: XCTestCase {
Expand Down Expand Up @@ -112,14 +114,34 @@ extension WorkflowActionTesterTests {
withState: true,
workflow: TestWorkflow(prop: 42)
)
.send(action: .readProps)
.send(action: .readProp)
.assert(state: true)
.assert(output: .value("read prop: 42"))
}

func test_new_api_works_with_optional_props() {
TestActionWithProps
.tester(
withState: true,
workflow: TestWorkflow(prop: 42, optionalProp: 22)
)
.send(action: .readOptionalProp)
.assert(state: true)
.assert(output: .value("read optional prop: 22"))

TestActionWithProps
.tester(
withState: true,
workflow: TestWorkflow(prop: 42, optionalProp: nil)
)
.send(action: .readOptionalProp)
.assert(state: true)
.assert(output: .value("read props: 42"))
.assert(output: .value("read optional prop: <nil>"))
}

// FIXME: ideally an 'exit/death test' would somehow be used for this...
/*
func test_old_api_explodes_if_you_use_props() {
func test_old_api_explodes_if_accessing_through_apply_context() {
XCTExpectFailure("This test should fail")

TestActionWithProps
Expand All @@ -128,14 +150,24 @@ extension WorkflowActionTesterTests {
.assert(state: true)
}
*/

func test_old_api_errors_accessing_optional_through_apply_context_without_proper_setup() {
withExpectedIssue("reading optional value through context without workflow should fail but not crash") {
TestActionWithProps
.tester(withState: true)
.send(action: .readOptionalProp)
.assert(state: true)
}
}
}

// MARK: -

private enum TestActionWithProps: WorkflowAction {
typealias WorkflowType = TestWorkflow

case readProps
case readProp
case readOptionalProp
case dontReadProps

func apply(
Expand All @@ -146,9 +178,13 @@ private enum TestActionWithProps: WorkflowAction {
case .dontReadProps:
return .value("did not read props")

case .readProps:
case .readProp:
let prop = context[workflowValue: \.prop]
return .value("read props: \(prop)")
return .value("read prop: \(prop)")

case .readOptionalProp:
let optionalProp = context[workflowValue: \.optionalProp]
return .value("read optional prop: \(optionalProp?.description ?? "<nil>")")
}
}
}
Expand Down Expand Up @@ -179,6 +215,7 @@ private struct TestWorkflow: Workflow {
}

var prop = 0
var optionalProp: Int? = 42

func makeInitialState() -> Bool {
true
Expand Down
Loading