Files
swift-composable-architectu…/Tests/ComposableArchitectureTests/TestStoreTests.swift
Stephen Celis fc5cbeec88 Infrastructure: Address concurrency warnings in tests (#3438)
* Infrastructure: Address concurrency warnings in tests

* wip

---------

Co-authored-by: Brandon Williams <mbrandonw@hey.com>
2024-10-15 10:03:23 -07:00

713 lines
17 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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))
}
}
}
}