Skip to content

Commit b017871

Browse files
When exhaustivity is off receive now waits for the expected action rather than the first action (#2100)
* Tests passing * cleanup * Add tests * cleanup * rollback package resolved * Clean up * wip * Bump up timeouts in tests from hundredths to tenths of a second * bump timeouts * fix test --------- Co-authored-by: Brandon Williams <[email protected]> Co-authored-by: Brandon Williams <[email protected]>
1 parent 3688b3d commit b017871

File tree

2 files changed

+122
-11
lines changed

2 files changed

+122
-11
lines changed

Sources/ComposableArchitecture/TestStore.swift

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1428,7 +1428,12 @@ extension TestStore where ScopedState: Equatable, Action: Equatable {
14281428
}()
14291429
return
14301430
}
1431-
await self.receiveAction(timeout: nanoseconds, file: file, line: line)
1431+
await self.receiveAction(
1432+
matching: { expectedAction == $0 },
1433+
timeout: nanoseconds,
1434+
file: file,
1435+
line: line
1436+
)
14321437
_ = {
14331438
self.receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line)
14341439
}()
@@ -1445,7 +1450,6 @@ extension TestStore where ScopedState: Equatable {
14451450
/// - Parameters:
14461451
/// - isMatching: A closure that attempts to match an action. If it returns `false`, a test
14471452
/// failure is reported.
1448-
/// - nanoseconds: The amount of time to wait for the expected action.
14491453
/// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to
14501454
/// the store. The mutable state sent to this closure must be modified to match the state of
14511455
/// the store after processing the given action. Do not provide a closure if no change is
@@ -1612,7 +1616,7 @@ extension TestStore where ScopedState: Equatable {
16121616
}()
16131617
return
16141618
}
1615-
await self.receiveAction(timeout: nanoseconds, file: file, line: line)
1619+
await self.receiveAction(matching: isMatching, timeout: nanoseconds, file: file, line: line)
16161620
_ = {
16171621
self.receive(isMatching, assert: updateStateToExpectedResult, file: file, line: line)
16181622
}()
@@ -1666,7 +1670,12 @@ extension TestStore where ScopedState: Equatable {
16661670
}()
16671671
return
16681672
}
1669-
await self.receiveAction(timeout: nanoseconds, file: file, line: line)
1673+
await self.receiveAction(
1674+
matching: { actionCase.extract(from: $0) != nil },
1675+
timeout: nanoseconds,
1676+
file: file,
1677+
line: line
1678+
)
16701679
_ = {
16711680
self.receive(actionCase, assert: updateStateToExpectedResult, file: file, line: line)
16721681
}()
@@ -1722,7 +1731,12 @@ extension TestStore where ScopedState: Equatable {
17221731
}()
17231732
return
17241733
}
1725-
await self.receiveAction(timeout: duration.nanoseconds, file: file, line: line)
1734+
await self.receiveAction(
1735+
matching: { actionCase.extract(from: $0) != nil },
1736+
timeout: duration.nanoseconds,
1737+
file: file,
1738+
line: line
1739+
)
17261740
_ = {
17271741
self.receive(actionCase, assert: updateStateToExpectedResult, file: file, line: line)
17281742
}()
@@ -1821,6 +1835,7 @@ extension TestStore where ScopedState: Equatable {
18211835
}
18221836

18231837
private func receiveAction(
1838+
matching predicate: (Action) -> Bool,
18241839
timeout nanoseconds: UInt64?,
18251840
file: StaticString,
18261841
line: UInt
@@ -1832,8 +1847,14 @@ extension TestStore where ScopedState: Equatable {
18321847
while !Task.isCancelled {
18331848
await Task.detached(priority: .background) { await Task.yield() }.value
18341849

1835-
guard self.reducer.receivedActions.isEmpty
1836-
else { break }
1850+
switch self.exhaustivity {
1851+
case .on:
1852+
guard self.reducer.receivedActions.isEmpty
1853+
else { return }
1854+
case .off:
1855+
guard !self.reducer.receivedActions.contains(where: { predicate($0.action) })
1856+
else { return }
1857+
}
18371858

18381859
guard start.distance(to: DispatchTime.now().uptimeNanoseconds) < nanoseconds
18391860
else {
@@ -1861,7 +1882,7 @@ extension TestStore where ScopedState: Equatable {
18611882
}
18621883
XCTFail(
18631884
"""
1864-
Expected to receive an action, but received none\
1885+
Expected to receive \(self.exhaustivity == .on ? "an action" : "a matching action"), but received none\
18651886
\(nanoseconds > 0 ? " after \(Double(nanoseconds)/Double(NSEC_PER_SEC)) seconds" : "").
18661887
18671888
\(suggestion)
@@ -1872,9 +1893,6 @@ extension TestStore where ScopedState: Equatable {
18721893
return
18731894
}
18741895
}
1875-
1876-
guard !Task.isCancelled
1877-
else { return }
18781896
}
18791897
}
18801898

Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,99 @@
716716
}
717717
}
718718

719+
func testReceiveNonExhuastiveWithTimeout() async {
720+
struct Feature: ReducerProtocol {
721+
struct State: Equatable {}
722+
enum Action: Equatable { case tap, response1, response2 }
723+
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
724+
switch action {
725+
726+
case .tap:
727+
return .run { send in
728+
try await Task.sleep(nanoseconds: 10_000_000)
729+
await send(.response1)
730+
try await Task.sleep(nanoseconds: 10_000_000)
731+
await send(.response2)
732+
}
733+
case .response1, .response2:
734+
return .none
735+
}
736+
}
737+
}
738+
739+
let store = TestStore(initialState: Feature.State(), reducer: Feature())
740+
store.exhaustivity = .off
741+
742+
await store.send(.tap)
743+
await store.receive(.response2, timeout: 1_000_000_000)
744+
}
745+
746+
func testReceiveNonExhuastiveWithTimeoutMultipleNonMatching() async {
747+
struct Feature: ReducerProtocol {
748+
struct State: Equatable {}
749+
enum Action: Equatable { case tap, response1, response2 }
750+
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
751+
switch action {
752+
753+
case .tap:
754+
return .run { send in
755+
try await Task.sleep(nanoseconds: 10_000_000)
756+
await send(.response1)
757+
try await Task.sleep(nanoseconds: 10_000_000)
758+
await send(.response1)
759+
}
760+
case .response1:
761+
return .none
762+
case .response2:
763+
return .none
764+
}
765+
}
766+
}
767+
768+
let store = TestStore(initialState: Feature.State(), reducer: Feature())
769+
store.exhaustivity = .off
770+
771+
await store.send(.tap)
772+
XCTExpectFailure { issue in
773+
issue.compactDescription.contains(
774+
"""
775+
Expected to receive a matching action, but received none after 1.0 seconds.
776+
""")
777+
|| (issue.compactDescription.contains(
778+
"Expected to receive the following action, but didn't")
779+
&& issue.compactDescription.contains("Action.response2"))
780+
}
781+
await store.receive(.response2, timeout: 1_000_000_000)
782+
}
783+
784+
func testReceiveNonExhuastiveWithTimeoutMultipleMatching() async {
785+
struct Feature: ReducerProtocol {
786+
struct State: Equatable {}
787+
enum Action: Equatable { case tap, response1, response2 }
788+
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
789+
switch action {
790+
791+
case .tap:
792+
return .run { send in
793+
try await Task.sleep(nanoseconds: 10_000_000)
794+
await send(.response2)
795+
try await Task.sleep(nanoseconds: 10_000_000)
796+
await send(.response2)
797+
}
798+
case .response1, .response2:
799+
return .none
800+
}
801+
}
802+
}
803+
804+
let store = TestStore(initialState: Feature.State(), reducer: Feature())
805+
store.exhaustivity = .off
806+
807+
await store.send(.tap)
808+
await store.receive(.response2, timeout: 1_000_000_000)
809+
await store.receive(.response2, timeout: 1_000_000_000)
810+
}
811+
719812
// This example comes from Krzysztof Zabłocki's blog post:
720813
// https://www.merowing.info/exhaustive-testing-in-tca/
721814
func testKrzysztofExample1() {

0 commit comments

Comments
 (0)