mirror of
https://github.com/pointfreeco/swift-composable-architecture.git
synced 2025-12-20 09:11:33 +01:00
* Infrastructure: Address concurrency warnings in tests * wip --------- Co-authored-by: Brandon Williams <mbrandonw@hey.com>
713 lines
17 KiB
Swift
713 lines
17 KiB
Swift
@preconcurrency import Combine
|
||
import ComposableArchitecture
|
||
import XCTest
|
||
|
||
final class TestStoreTests: BaseTCATestCase {
|
||
func testEffectConcatenation() async {
|
||
struct State: Equatable {}
|
||
|
||
enum Action: Equatable {
|
||
case a, b1, b2, b3, c1, c2, c3, d
|
||
}
|
||
|
||
let mainQueue = DispatchQueue.test
|
||
let store = await TestStore(initialState: State()) {
|
||
Reduce<State, Action> { _, action in
|
||
switch action {
|
||
case .a:
|
||
return .merge(
|
||
.run { send in
|
||
try await mainQueue.sleep(for: .seconds(1))
|
||
await send(.b1)
|
||
await send(.c1)
|
||
},
|
||
.run { _ in try await Task.never() }
|
||
.cancellable(id: 1)
|
||
)
|
||
case .b1:
|
||
return .concatenate(.send(.b2), .send(.b3))
|
||
case .c1:
|
||
return .concatenate(.send(.c2), .send(.c3))
|
||
case .b2, .b3, .c2, .c3:
|
||
return .none
|
||
case .d:
|
||
return .cancel(id: 1)
|
||
}
|
||
}
|
||
}
|
||
|
||
await store.send(.a)
|
||
|
||
await mainQueue.advance(by: 1)
|
||
|
||
await store.receive(.b1)
|
||
await store.receive(.b2)
|
||
await store.receive(.b3)
|
||
|
||
await store.receive(.c1)
|
||
await store.receive(.c2)
|
||
await store.receive(.c3)
|
||
|
||
await store.send(.d)
|
||
}
|
||
|
||
func testAsync() async {
|
||
enum Action: Equatable {
|
||
case tap
|
||
case response(Int)
|
||
}
|
||
let store = await TestStore(initialState: 0) {
|
||
Reduce<Int, Action> { state, action in
|
||
switch action {
|
||
case .tap:
|
||
return .run { send in await send(.response(42)) }
|
||
case let .response(number):
|
||
state = number
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
|
||
await store.send(.tap)
|
||
await store.receive(.response(42)) {
|
||
$0 = 42
|
||
}
|
||
}
|
||
|
||
func testExpectedStateEquality() async {
|
||
struct State: Equatable {
|
||
var count: Int = 0
|
||
var isChanging: Bool = false
|
||
}
|
||
|
||
enum Action: Equatable {
|
||
case increment
|
||
case changed(from: Int, to: Int)
|
||
}
|
||
|
||
let store = await TestStore(initialState: State()) {
|
||
Reduce<State, Action> { state, action in
|
||
switch action {
|
||
case .increment:
|
||
state.isChanging = true
|
||
return .send(.changed(from: state.count, to: state.count + 1))
|
||
case let .changed(from, to):
|
||
state.isChanging = false
|
||
if state.count == from {
|
||
state.count = to
|
||
}
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
|
||
await store.send(.increment) {
|
||
$0.isChanging = true
|
||
}
|
||
await store.receive(.changed(from: 0, to: 1)) {
|
||
$0.isChanging = false
|
||
$0.count = 1
|
||
}
|
||
|
||
XCTExpectFailure()
|
||
await store.send(.increment) {
|
||
$0.isChanging = false
|
||
}
|
||
|
||
XCTExpectFailure()
|
||
await store.receive(.changed(from: 1, to: 2)) {
|
||
$0.isChanging = true
|
||
$0.count = 1100
|
||
}
|
||
}
|
||
|
||
func testExpectedStateEqualityMustModify() async {
|
||
struct State: Equatable {
|
||
var count: Int = 0
|
||
}
|
||
|
||
enum Action: Equatable {
|
||
case noop, finished
|
||
}
|
||
|
||
let store = await TestStore(initialState: State()) {
|
||
Reduce<State, Action> { state, action in
|
||
switch action {
|
||
case .noop:
|
||
return .send(.finished)
|
||
case .finished:
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
|
||
await store.send(.noop)
|
||
await store.receive(.finished)
|
||
|
||
XCTExpectFailure()
|
||
await store.send(.noop) {
|
||
$0.count = 0
|
||
}
|
||
|
||
XCTExpectFailure()
|
||
await store.receive(.finished) {
|
||
$0.count = 0
|
||
}
|
||
}
|
||
|
||
func testReceiveActionMatchingPredicate() async {
|
||
enum Action: Equatable {
|
||
case noop, finished
|
||
}
|
||
|
||
let store = await TestStore(initialState: 0) {
|
||
Reduce<Int, Action> { state, action in
|
||
switch action {
|
||
case .noop:
|
||
return .send(.finished)
|
||
case .finished:
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
|
||
let predicateShouldBeCalledExpectation = expectation(
|
||
description: "predicate should be called")
|
||
await store.send(.noop)
|
||
await store.receive { action in
|
||
predicateShouldBeCalledExpectation.fulfill()
|
||
return action == .finished
|
||
}
|
||
_ = { wait(for: [predicateShouldBeCalledExpectation], timeout: 0) }()
|
||
|
||
await store.send(.noop)
|
||
XCTExpectFailure()
|
||
await store.receive(.noop)
|
||
|
||
await store.send(.noop)
|
||
XCTExpectFailure()
|
||
await store.receive { $0 == .noop }
|
||
}
|
||
|
||
@MainActor
|
||
func testStateAccess() async {
|
||
enum Action { case a, b, c, d }
|
||
let store = TestStore(initialState: 0) {
|
||
Reduce<Int, Action> { count, action in
|
||
switch action {
|
||
case .a:
|
||
count += 1
|
||
return .merge(.send(.b), .send(.c), .send(.d))
|
||
case .b, .c, .d:
|
||
count += 1
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
|
||
await store.send(.a) {
|
||
$0 = 1
|
||
XCTAssertEqual(store.state, 0)
|
||
}
|
||
XCTAssertEqual(store.state, 1)
|
||
await store.receive(.b) {
|
||
$0 = 2
|
||
XCTAssertEqual(store.state, 1)
|
||
}
|
||
XCTAssertEqual(store.state, 2)
|
||
await store.receive(.c) {
|
||
$0 = 3
|
||
XCTAssertEqual(store.state, 2)
|
||
}
|
||
XCTAssertEqual(store.state, 3)
|
||
await store.receive(.d) {
|
||
$0 = 4
|
||
XCTAssertEqual(store.state, 3)
|
||
}
|
||
XCTAssertEqual(store.state, 4)
|
||
}
|
||
|
||
@Reducer
|
||
struct Feature_testOverrideDependenciesDirectlyOnReducer {
|
||
@Dependency(\.calendar) var calendar
|
||
@Dependency(\.locale) var locale
|
||
@Dependency(\.timeZone) var timeZone
|
||
@Dependency(\.urlSession) var urlSession
|
||
|
||
var body: some Reducer<Int, Bool> {
|
||
Reduce { state, action in
|
||
_ = self.calendar
|
||
_ = self.locale
|
||
_ = self.timeZone
|
||
_ = self.urlSession
|
||
state += action ? 1 : -1
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
func testOverrideDependenciesDirectlyOnReducer() async {
|
||
let store = await TestStore(initialState: 0) {
|
||
Feature_testOverrideDependenciesDirectlyOnReducer()
|
||
.dependency(\.calendar, Calendar(identifier: .gregorian))
|
||
.dependency(\.locale, Locale(identifier: "en_US"))
|
||
.dependency(\.timeZone, TimeZone(secondsFromGMT: 0)!)
|
||
.dependency(\.urlSession, URLSession(configuration: .ephemeral))
|
||
}
|
||
|
||
await store.send(true) { $0 = 1 }
|
||
}
|
||
|
||
@Reducer
|
||
struct Feature_testOverrideDependenciesOnTestStore {
|
||
@Dependency(\.calendar) var calendar
|
||
@Dependency(\.locale) var locale
|
||
@Dependency(\.timeZone) var timeZone
|
||
@Dependency(\.urlSession) var urlSession
|
||
|
||
var body: some Reducer<Int, Bool> {
|
||
Reduce { state, action in
|
||
_ = self.calendar
|
||
_ = self.locale
|
||
_ = self.timeZone
|
||
_ = self.urlSession
|
||
state += action ? 1 : -1
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
@MainActor
|
||
func testOverrideDependenciesOnTestStore() async {
|
||
let store = TestStore(initialState: 0) {
|
||
Feature_testOverrideDependenciesOnTestStore()
|
||
}
|
||
store.dependencies.calendar = Calendar(identifier: .gregorian)
|
||
store.dependencies.locale = Locale(identifier: "en_US")
|
||
store.dependencies.timeZone = TimeZone(secondsFromGMT: 0)!
|
||
store.dependencies.urlSession = URLSession(configuration: .ephemeral)
|
||
|
||
await store.send(true) { $0 = 1 }
|
||
}
|
||
|
||
@Reducer
|
||
struct Feature_testOverrideDependenciesOnTestStore_MidwayChange {
|
||
@Dependency(\.date.now) var now
|
||
|
||
var body: some Reducer<Int, Void> {
|
||
Reduce { state, _ in
|
||
state = Int(self.now.timeIntervalSince1970)
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
@MainActor
|
||
func testOverrideDependenciesOnTestStore_MidwayChange() async {
|
||
let store = TestStore(initialState: 0) {
|
||
Feature_testOverrideDependenciesOnTestStore_MidwayChange()
|
||
} withDependencies: {
|
||
$0.date.now = Date(timeIntervalSince1970: 1_234_567_890)
|
||
}
|
||
|
||
await store.send(()) { $0 = 1_234_567_890 }
|
||
|
||
store.dependencies.date.now = Date(timeIntervalSince1970: 987_654_321)
|
||
|
||
await store.send(()) { $0 = 987_654_321 }
|
||
}
|
||
|
||
@Reducer
|
||
struct Feature_testOverrideDependenciesOnTestStore_Init {
|
||
@Dependency(\.calendar) var calendar
|
||
@Dependency(\.client.fetch) var fetch
|
||
@Dependency(\.locale) var locale
|
||
@Dependency(\.timeZone) var timeZone
|
||
@Dependency(\.urlSession) var urlSession
|
||
|
||
var body: some Reducer<Int, Bool> {
|
||
Reduce { state, action in
|
||
_ = self.calendar
|
||
_ = self.fetch()
|
||
_ = self.locale
|
||
_ = self.timeZone
|
||
_ = self.urlSession
|
||
state += action ? 1 : -1
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
func testOverrideDependenciesOnTestStore_Init() async {
|
||
let store = await TestStore(initialState: 0) {
|
||
Feature_testOverrideDependenciesOnTestStore_Init()
|
||
} withDependencies: {
|
||
$0.calendar = Calendar(identifier: .gregorian)
|
||
$0.client.fetch = { 1 }
|
||
$0.locale = Locale(identifier: "en_US")
|
||
$0.timeZone = TimeZone(secondsFromGMT: 0)!
|
||
$0.urlSession = URLSession(configuration: .ephemeral)
|
||
}
|
||
|
||
await store.send(true) { $0 = 1 }
|
||
}
|
||
|
||
@Reducer
|
||
struct Feature_testDependenciesEarlyBinding {
|
||
struct State: Equatable {
|
||
var count = 0
|
||
var date: Date
|
||
init() {
|
||
@Dependency(\.date.now) var now: Date
|
||
self.date = now
|
||
}
|
||
}
|
||
enum Action: Equatable {
|
||
case tap
|
||
case response(Int)
|
||
}
|
||
@Dependency(\.date.now) var now: Date
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .tap:
|
||
state.count += 1
|
||
return .run { send in await send(.response(42)) }
|
||
case let .response(number):
|
||
state.count = number
|
||
state.date = now
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
}
|
||
func testDependenciesEarlyBinding() async {
|
||
let store = await TestStore(initialState: Feature_testDependenciesEarlyBinding.State()) {
|
||
Feature_testDependenciesEarlyBinding()
|
||
} withDependencies: {
|
||
$0.date = .constant(Date(timeIntervalSince1970: 1_234_567_890))
|
||
}
|
||
|
||
await store.send(.tap) {
|
||
@Dependency(\.date.now) var now: Date
|
||
$0.count = 1
|
||
$0.date = now
|
||
}
|
||
await store.receive(.response(42)) {
|
||
@Dependency(\.date.now) var now: Date
|
||
$0.count = 42
|
||
$0.date = now
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
func testPrepareDependenciesCalledOnce() {
|
||
var count = 0
|
||
let store = TestStore(initialState: 0) {
|
||
EmptyReducer<Int, Void>()
|
||
} withDependencies: { _ in
|
||
count += 1
|
||
}
|
||
|
||
XCTAssertEqual(count, 1)
|
||
_ = store
|
||
}
|
||
|
||
func testEffectEmitAfterSkipInFlightEffects() async {
|
||
let mainQueue = DispatchQueue.test
|
||
enum Action: Equatable { case tap, response }
|
||
let store = await TestStore(initialState: 0) {
|
||
Reduce<Int, Action> { state, action in
|
||
switch action {
|
||
case .tap:
|
||
return .run { send in
|
||
try await mainQueue.sleep(for: .seconds(1))
|
||
await send(.response)
|
||
}
|
||
case .response:
|
||
state = 42
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
|
||
await store.send(.tap)
|
||
await store.skipInFlightEffects()
|
||
await mainQueue.advance(by: .seconds(1))
|
||
await store.receive(.response) {
|
||
$0 = 42
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
func testAssert_NonExhaustiveTestStore() async {
|
||
let store = TestStore(initialState: 0) {
|
||
EmptyReducer<Int, Void>()
|
||
}
|
||
store.exhaustivity = .off
|
||
|
||
store.assert {
|
||
$0 = 0
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
func testAssert_NonExhaustiveTestStore_Failure() async {
|
||
let store = TestStore(initialState: 0) {
|
||
EmptyReducer<Int, Void>()
|
||
}
|
||
store.exhaustivity = .off
|
||
|
||
XCTExpectFailure {
|
||
store.assert {
|
||
$0 = 1
|
||
}
|
||
} issueMatcher: {
|
||
$0.compactDescription == """
|
||
failed - A state change does not match expectation: …
|
||
|
||
− 1
|
||
+ 0
|
||
|
||
(Expected: −, Actual: +)
|
||
"""
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
func testSubscribeReceiveCombineScheduler() async {
|
||
let subject = PassthroughSubject<Void, Never>()
|
||
let scheduler = DispatchQueue.test
|
||
|
||
struct State: Equatable {
|
||
var count: Int = 0
|
||
}
|
||
|
||
enum Action: Equatable {
|
||
case increment
|
||
case start
|
||
}
|
||
|
||
let store = TestStore(initialState: State()) {
|
||
Reduce<State, Action> { state, action in
|
||
switch action {
|
||
case .start:
|
||
return .publisher {
|
||
subject
|
||
.subscribe(on: scheduler)
|
||
.receive(on: scheduler)
|
||
.map { .increment }
|
||
}
|
||
case .increment:
|
||
state.count += 1
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
|
||
let task = await store.send(.start)
|
||
await scheduler.advance()
|
||
subject.send()
|
||
await scheduler.advance()
|
||
await store.receive(.increment) { $0.count = 1 }
|
||
await task.cancel()
|
||
}
|
||
|
||
func testMainSerialExecutor_AutoAssignsAndResets_False() async {
|
||
uncheckedUseMainSerialExecutor = false
|
||
XCTAssertFalse(uncheckedUseMainSerialExecutor)
|
||
var store: TestStore? = await TestStore(initialState: 0) {
|
||
EmptyReducer<Int, Void>()
|
||
}
|
||
XCTAssertTrue(uncheckedUseMainSerialExecutor)
|
||
store = nil
|
||
XCTAssertFalse(uncheckedUseMainSerialExecutor)
|
||
_ = store
|
||
}
|
||
|
||
func testMainSerialExecutor_AutoAssignsAndResets_True() async {
|
||
uncheckedUseMainSerialExecutor = true
|
||
XCTAssertTrue(uncheckedUseMainSerialExecutor)
|
||
var store: TestStore? = await TestStore(initialState: 0) {
|
||
EmptyReducer<Int, Void>()
|
||
}
|
||
XCTAssertTrue(uncheckedUseMainSerialExecutor)
|
||
store = nil
|
||
XCTAssertTrue(uncheckedUseMainSerialExecutor)
|
||
_ = store
|
||
}
|
||
|
||
func testReceiveCaseKeyPathWithValue() async {
|
||
let store = await TestStore<Int, Action>(initialState: 0) {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .tap:
|
||
return .send(.delegate(.success(42)))
|
||
case .delegate:
|
||
return .none
|
||
case .view:
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
|
||
await store.send(.tap)
|
||
await store.receive(\.delegate.success, 42)
|
||
|
||
XCTExpectFailure {
|
||
$0.compactDescription == """
|
||
failed - Received unexpected action: …
|
||
|
||
Action.delegate(
|
||
− .success(43)
|
||
+ .success(42)
|
||
)
|
||
|
||
(Expected: −, Actual: +)
|
||
"""
|
||
}
|
||
await store.send(.tap)
|
||
await store.receive(\.delegate.success, 43)
|
||
}
|
||
|
||
func testSendCaseKeyPath() async {
|
||
let store = await TestStore<Int, Action>(initialState: 0) {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .tap:
|
||
return .send(.delegate(.success(42)))
|
||
case .delegate:
|
||
return .none
|
||
case .view(.tap):
|
||
state = state + 1
|
||
return .send(.delegate(.success(42 * 42)))
|
||
case let .view(.delete(indexSet)):
|
||
let sum = indexSet.reduce(0, +)
|
||
if sum == 42 {
|
||
state = state + 1
|
||
}
|
||
return .send(.delegate(.success(sum)))
|
||
}
|
||
}
|
||
}
|
||
await store.send(\.tap)
|
||
await store.receive(\.delegate.success, 42)
|
||
|
||
await store.send(\.view.tap) {
|
||
$0 = 1
|
||
}
|
||
await store.receive(\.delegate.success, 42 * 42)
|
||
|
||
await store.send(\.view.delete, [0])
|
||
await store.receive(\.delegate.success, 0)
|
||
|
||
await store.send(\.view.delete, [19, 23]) {
|
||
$0 = 2
|
||
}
|
||
await store.receive(\.delegate.success, 42)
|
||
}
|
||
|
||
func testBindingTestStore_WhenStateAndActionHaveSameName() async {
|
||
let store = await TestStore(initialState: .init()) {
|
||
SameNameForStateAndAction()
|
||
}
|
||
await store.send(.onAppear)
|
||
await store.receive(\.isOn)
|
||
await store.receive(\.binding.isOn) {
|
||
$0.isOn = true
|
||
}
|
||
}
|
||
|
||
@Reducer
|
||
struct TestDismissCancelsEffects {
|
||
struct State: Equatable {}
|
||
enum Action {
|
||
case dismiss
|
||
case onTask
|
||
}
|
||
@Dependency(\.dismiss) var dismiss
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .dismiss:
|
||
return .run { _ in
|
||
await dismiss()
|
||
}
|
||
case .onTask:
|
||
return .run { _ in
|
||
try await Task.never()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func testDismissCancelsEffects() async {
|
||
let store = await TestStore(initialState: TestDismissCancelsEffects.State()) {
|
||
TestDismissCancelsEffects()
|
||
}
|
||
await store.send(.onTask)
|
||
await store.send(.dismiss)
|
||
}
|
||
|
||
func testDismissedStoreSend() async {
|
||
let store = await TestStore(initialState: TestDismissCancelsEffects.State()) {
|
||
TestDismissCancelsEffects()
|
||
}
|
||
await store.send(.dismiss)
|
||
XCTExpectFailure {
|
||
$0.compactDescription == """
|
||
failed - Can't send action to dismissed test store.
|
||
"""
|
||
}
|
||
await store.send(.onTask)
|
||
}
|
||
}
|
||
|
||
private struct Client: DependencyKey {
|
||
var fetch: @Sendable () -> Int
|
||
static let liveValue = Client(fetch: { 42 })
|
||
}
|
||
extension DependencyValues {
|
||
fileprivate var client: Client {
|
||
get { self[Client.self] }
|
||
set { self[Client.self] = newValue }
|
||
}
|
||
}
|
||
|
||
@CasePathable
|
||
private enum Action {
|
||
case tap
|
||
case delegate(Delegate)
|
||
case view(View)
|
||
@CasePathable
|
||
enum Delegate {
|
||
case success(Int)
|
||
}
|
||
@CasePathable
|
||
enum View {
|
||
case tap
|
||
case delete(IndexSet)
|
||
}
|
||
}
|
||
|
||
@Reducer
|
||
struct SameNameForStateAndAction {
|
||
@ObservableState
|
||
struct State: Equatable { var isOn = false }
|
||
enum Action: BindableAction {
|
||
case binding(BindingAction<State>)
|
||
case onAppear
|
||
case isOn(Bool)
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
BindingReducer()
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .binding:
|
||
return .none
|
||
case .onAppear:
|
||
return .send(.isOn(true))
|
||
case .isOn:
|
||
return .send(.set(\.isOn, true))
|
||
}
|
||
}
|
||
}
|
||
}
|