Skip to content

Commit eac9469

Browse files
committed
Failing Effects (#453)
1 parent 55e8a4e commit eac9469

File tree

4 files changed

+112
-53
lines changed

4 files changed

+112
-53
lines changed

Package.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,23 @@ let package = Package(
1717
)
1818
],
1919
dependencies: [
20-
.package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.1.3"),
2120
.package(url: "https://github.com/ReactiveCocoa/ReactiveSwift", from: "6.4.0"),
21+
.package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.1.3"),
22+
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.1.0"),
2223
],
2324
targets: [
2425
.target(
2526
name: "ComposableArchitecture",
2627
dependencies: [
27-
.product(name: "CasePaths", package: "swift-case-paths"),
2828
"ReactiveSwift",
29+
"CasePaths",
30+
"XCTestDynamicOverlay",
2931
]
3032
),
3133
.testTarget(
3234
name: "ComposableArchitectureTests",
3335
dependencies: [
34-
"ComposableArchitecture"
36+
"ComposableArchitecture",
3537
]
3638
),
3739
]
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#if DEBUG
2+
import XCTestDynamicOverlay
3+
4+
extension Effect {
5+
/// An effect that causes a test to fail if it runs.
6+
///
7+
/// This effect can provide an additional layer of certainty that a tested code path does not
8+
/// execute a particular effect.
9+
///
10+
/// For example, let's say we have a very simple counter application, where a user can increment
11+
/// and decrement a number. The state and actions are simple enough:
12+
///
13+
/// struct CounterState: Equatable {
14+
/// var count = 0
15+
/// }
16+
///
17+
/// enum CounterAction: Equatable {
18+
/// case decrementButtonTapped
19+
/// case incrementButtonTapped
20+
/// }
21+
///
22+
/// Let's throw in a side effect. If the user attempts to decrement the counter below zero, the
23+
/// application should refuse and play an alert sound instead.
24+
///
25+
/// We can model playing a sound in the environment with an effect:
26+
///
27+
/// struct CounterEnvironment {
28+
/// let playAlertSound: () -> Effect<Never, Never>
29+
/// }
30+
///
31+
/// Now that we've defined the domain, we can describe the logic in a reducer:
32+
///
33+
/// let counterReducer = Reducer<
34+
/// CounterState, CounterAction, CounterEnvironment
35+
/// > { state, action, environment in
36+
/// switch action {
37+
/// case .decrementButtonTapped:
38+
/// if state > 0 {
39+
/// state.count -= 0
40+
/// return .none
41+
/// } else {
42+
/// return environment.playAlertSound()
43+
/// .fireAndForget()
44+
/// }
45+
///
46+
/// case .incrementButtonTapped:
47+
/// state.count += 1
48+
/// return .non
49+
/// }
50+
/// }
51+
///
52+
/// Let's say we want to write a test for the increment path. We can see in the reducer that it
53+
/// should never play an alert, so we can configure the environment with an effect that will
54+
/// fail if it ever executes:
55+
///
56+
/// func testIncrement() {
57+
/// let store = TestStore(
58+
/// initialState: CounterState(count: 0)
59+
/// reducer: counterReducer,
60+
/// environment: CounterEnvironment(
61+
/// playSound: .failing("playSound")
62+
/// )
63+
/// )
64+
///
65+
/// store.send(.increment) {
66+
/// $0.count = 1
67+
/// }
68+
/// }
69+
///
70+
/// By using a `.failing` effect in our environment we have strengthened the assertion and made
71+
/// the test easier to understand at the same time. We can see, without consulting the reducer
72+
/// itself, that this particular action should not access this effect.
73+
///
74+
/// - Parameter prefix: A string that identifies this scheduler and will prefix all failure
75+
/// messages.
76+
/// - Returns: An effect that causes a test to fail if it runs.
77+
public static func failing(_ prefix: String) -> Self {
78+
.fireAndForget {
79+
XCTFail("\(prefix.isEmpty ? "" : "\(prefix) - ")A failing effect ran.")
80+
}
81+
}
82+
}
83+
#endif

Sources/ComposableArchitecture/TestSupport/TestStore.swift

Lines changed: 13 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#if DEBUG
22
import ReactiveSwift
33
import Foundation
4+
import XCTestDynamicOverlay
45

56
/// A testable runtime for a reducer.
67
///
@@ -221,7 +222,7 @@
221222

222223
private func completed() {
223224
if !self.receivedActions.isEmpty {
224-
_XCTFail(
225+
XCTFail(
225226
"""
226227
The store received \(self.receivedActions.count) unexpected \
227228
action\(self.receivedActions.count == 1 ? "" : "s") after this one: …
@@ -232,7 +233,7 @@
232233
)
233234
}
234235
for effect in self.longLivingEffects {
235-
_XCTFail(
236+
XCTFail(
236237
"""
237238
An effect returned for this action is still running. It must complete before the end of \
238239
the test. …
@@ -306,7 +307,7 @@
306307
_ update: @escaping (inout LocalState) throws -> Void = { _ in }
307308
) {
308309
if !self.receivedActions.isEmpty {
309-
_XCTFail(
310+
XCTFail(
310311
"""
311312
Must handle \(self.receivedActions.count) received \
312313
action\(self.receivedActions.count == 1 ? "" : "s") before sending an action: …
@@ -327,7 +328,7 @@
327328
do {
328329
try update(&expectedState)
329330
} catch {
330-
_XCTFail("Threw error: \(error)", file: file, line: line)
331+
XCTFail("Threw error: \(error)", file: file, line: line)
331332
}
332333
self.expectedStateShouldMatch(
333334
expected: expectedState,
@@ -347,7 +348,7 @@
347348
_ update: @escaping (inout LocalState) throws -> Void = { _ in }
348349
) {
349350
guard !self.receivedActions.isEmpty else {
350-
_XCTFail(
351+
XCTFail(
351352
"""
352353
Expected to receive an action, but received none.
353354
""",
@@ -368,7 +369,7 @@
368369
\(String(describing: receivedAction).indent(by: 2))
369370
"""
370371

371-
_XCTFail(
372+
XCTFail(
372373
"""
373374
Received unexpected action: …
374375
@@ -381,7 +382,7 @@
381382
do {
382383
try update(&expectedState)
383384
} catch {
384-
_XCTFail("Threw error: \(error)", file: file, line: line)
385+
XCTFail("Threw error: \(error)", file: file, line: line)
385386
}
386387
expectedStateShouldMatch(
387388
expected: expectedState,
@@ -421,7 +422,7 @@
421422

422423
case let .environment(work):
423424
if !self.receivedActions.isEmpty {
424-
_XCTFail(
425+
XCTFail(
425426
"""
426427
Must handle \(self.receivedActions.count) received \
427428
action\(self.receivedActions.count == 1 ? "" : "s") before performing this work: …
@@ -434,12 +435,12 @@
434435
do {
435436
try work(&self.environment)
436437
} catch {
437-
_XCTFail("Threw error: \(error)", file: step.file, line: step.line)
438+
XCTFail("Threw error: \(error)", file: step.file, line: step.line)
438439
}
439440

440441
case let .do(work):
441442
if !receivedActions.isEmpty {
442-
_XCTFail(
443+
XCTFail(
443444
"""
444445
Must handle \(self.receivedActions.count) received \
445446
action\(self.receivedActions.count == 1 ? "" : "s") before performing this work: …
@@ -452,7 +453,7 @@
452453
do {
453454
try work()
454455
} catch {
455-
_XCTFail("Threw error: \(error)", file: step.file, line: step.line)
456+
XCTFail("Threw error: \(error)", file: step.file, line: step.line)
456457
}
457458

458459
case let .sequence(subSteps):
@@ -483,7 +484,7 @@
483484
\(String(describing: actual).indent(by: 2))
484485
"""
485486

486-
_XCTFail(
487+
XCTFail(
487488
"""
488489
State change does not match expectation: …
489490
@@ -656,44 +657,6 @@
656657
}
657658
}
658659

659-
// NB: Dynamically load XCTest to prevent leaking its symbols into our library code.
660-
private func _XCTFail(_ message: String = "", file: StaticString = #file, line: UInt = #line) {
661-
guard
662-
let _XCTFailureHandler = _XCTFailureHandler,
663-
let _XCTCurrentTestCase = _XCTCurrentTestCase
664-
else {
665-
assertionFailure(
666-
"""
667-
Couldn't load XCTest. Are you using a test store in application code?"
668-
""",
669-
file: file,
670-
line: line
671-
)
672-
return
673-
}
674-
_XCTFailureHandler(_XCTCurrentTestCase(), true, "\(file)", line, message, nil)
675-
}
676-
677-
private typealias XCTCurrentTestCase = @convention(c) () -> AnyObject
678-
private typealias XCTFailureHandler = @convention(c) (
679-
AnyObject, Bool, UnsafePointer<CChar>, UInt, String, String?
680-
) -> Void
681-
682-
private let _XCTest = NSClassFromString("XCTest")
683-
.flatMap(Bundle.init(for:))
684-
.flatMap { $0.executablePath }
685-
.flatMap { dlopen($0, RTLD_NOW) }
686-
687-
private let _XCTFailureHandler =
688-
_XCTest
689-
.flatMap { dlsym($0, "_XCTFailureHandler") }
690-
.map { unsafeBitCast($0, to: XCTFailureHandler.self) }
691-
692-
private let _XCTCurrentTestCase =
693-
_XCTest
694-
.flatMap { dlsym($0, "_XCTCurrentTestCase") }
695-
.map { unsafeBitCast($0, to: XCTCurrentTestCase.self) }
696-
697660
#endif
698661

699662
extension ImmediateScheduler: DateScheduler {

Tests/ComposableArchitectureTests/EffectTests.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,15 @@ final class EffectTests: XCTestCase {
146146
XCTAssertEqual(values, [1])
147147
XCTAssertEqual(isComplete, true)
148148
}
149+
150+
#if compiler(>=5.4)
151+
func testFailing() {
152+
let effect = Effect<Never, Never>.failing("failing")
153+
XCTExpectFailure {
154+
effect
155+
.sink(receiveValue: { _ in })
156+
.store(in: &self.cancellables)
157+
}
158+
}
159+
#endif
149160
}

0 commit comments

Comments
 (0)