Skip to content

Commit 84b9a00

Browse files
authored
Failing Effects (#453)
1 parent fcb84e1 commit 84b9a00

File tree

4 files changed

+110
-52
lines changed

4 files changed

+110
-52
lines changed

Package.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,22 @@ let package = Package(
1717
)
1818
],
1919
dependencies: [
20-
.package(url: "https://github.com/pointfreeco/combine-schedulers", from: "0.3.1"),
20+
.package(url: "https://github.com/pointfreeco/combine-schedulers", from: "0.4.0"),
2121
.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: [
2728
"CasePaths",
2829
"CombineSchedulers",
30+
"XCTestDynamicOverlay"
2931
]
3032
),
3133
.testTarget(
3234
name: "ComposableArchitectureTests",
3335
dependencies: [
34-
"CombineSchedulers",
3536
"ComposableArchitecture",
3637
]
3738
),
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 Combine
33
import Foundation
4+
import XCTestDynamicOverlay
45

56
/// A testable runtime for a reducer.
67
///
@@ -225,7 +226,7 @@
225226

226227
private func completed() {
227228
if !self.receivedActions.isEmpty {
228-
_XCTFail(
229+
XCTFail(
229230
"""
230231
The store received \(self.receivedActions.count) unexpected \
231232
action\(self.receivedActions.count == 1 ? "" : "s") after this one: …
@@ -236,7 +237,7 @@
236237
)
237238
}
238239
for effect in self.longLivingEffects {
239-
_XCTFail(
240+
XCTFail(
240241
"""
241242
An effect returned for this action is still running. It must complete before the end of \
242243
the test. …
@@ -310,7 +311,7 @@
310311
_ update: @escaping (inout LocalState) throws -> Void = { _ in }
311312
) {
312313
if !self.receivedActions.isEmpty {
313-
_XCTFail(
314+
XCTFail(
314315
"""
315316
Must handle \(self.receivedActions.count) received \
316317
action\(self.receivedActions.count == 1 ? "" : "s") before sending an action: …
@@ -331,7 +332,7 @@
331332
do {
332333
try update(&expectedState)
333334
} catch {
334-
_XCTFail("Threw error: \(error)", file: file, line: line)
335+
XCTFail("Threw error: \(error)", file: file, line: line)
335336
}
336337
self.expectedStateShouldMatch(
337338
expected: expectedState,
@@ -351,7 +352,7 @@
351352
_ update: @escaping (inout LocalState) throws -> Void = { _ in }
352353
) {
353354
guard !self.receivedActions.isEmpty else {
354-
_XCTFail(
355+
XCTFail(
355356
"""
356357
Expected to receive an action, but received none.
357358
""",
@@ -372,7 +373,7 @@
372373
\(String(describing: receivedAction).indent(by: 2))
373374
"""
374375

375-
_XCTFail(
376+
XCTFail(
376377
"""
377378
Received unexpected action: …
378379
@@ -385,7 +386,7 @@
385386
do {
386387
try update(&expectedState)
387388
} catch {
388-
_XCTFail("Threw error: \(error)", file: file, line: line)
389+
XCTFail("Threw error: \(error)", file: file, line: line)
389390
}
390391
expectedStateShouldMatch(
391392
expected: expectedState,
@@ -425,7 +426,7 @@
425426

426427
case let .environment(work):
427428
if !self.receivedActions.isEmpty {
428-
_XCTFail(
429+
XCTFail(
429430
"""
430431
Must handle \(self.receivedActions.count) received \
431432
action\(self.receivedActions.count == 1 ? "" : "s") before performing this work: …
@@ -438,12 +439,12 @@
438439
do {
439440
try work(&self.environment)
440441
} catch {
441-
_XCTFail("Threw error: \(error)", file: step.file, line: step.line)
442+
XCTFail("Threw error: \(error)", file: step.file, line: step.line)
442443
}
443444

444445
case let .do(work):
445446
if !receivedActions.isEmpty {
446-
_XCTFail(
447+
XCTFail(
447448
"""
448449
Must handle \(self.receivedActions.count) received \
449450
action\(self.receivedActions.count == 1 ? "" : "s") before performing this work: …
@@ -456,7 +457,7 @@
456457
do {
457458
try work()
458459
} catch {
459-
_XCTFail("Threw error: \(error)", file: step.file, line: step.line)
460+
XCTFail("Threw error: \(error)", file: step.file, line: step.line)
460461
}
461462

462463
case let .sequence(subSteps):
@@ -487,7 +488,7 @@
487488
\(String(describing: actual).indent(by: 2))
488489
"""
489490

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

Tests/ComposableArchitectureTests/EffectTests.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,15 @@ final class EffectTests: XCTestCase {
158158
XCTAssertEqual(values, [1])
159159
XCTAssertEqual(isComplete, true)
160160
}
161+
162+
#if compiler(>=5.4)
163+
func testFailing() {
164+
let effect = Effect<Never, Never>.failing("failing")
165+
XCTExpectFailure {
166+
effect
167+
.sink(receiveValue: { _ in })
168+
.store(in: &self.cancellables)
169+
}
170+
}
171+
#endif
161172
}

0 commit comments

Comments
 (0)