From f12cc7942c029119c87f3671ab8c5f6f09c75b7a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 11 Sep 2024 14:16:27 -0700 Subject: [PATCH] wip --- Package@swift-6.0.swift | 10 +-- README.md | 39 ++++++---- Sources/Dependencies/Dependency.swift | 17 +++-- Sources/Dependencies/DependencyKey.swift | 10 +-- .../Dependencies/DependencyValues/Date.swift | 4 +- .../DependencyValues/Locale.swift | 4 +- .../DependencyValues/MainQueue.swift | 23 +++--- .../DependencyValues/MainRunLoop.swift | 27 +++---- .../Dependencies/DependencyValues/UUID.swift | 19 +++-- .../WithRandomNumberGenerator.swift | 22 +++--- .../Articles/DesigningDependencies.md | 4 +- .../Documentation.docc/Articles/Lifetimes.md | 10 ++- .../Articles/LivePreviewTest.md | 31 ++++---- .../Articles/OverridingDependencies.md | 31 ++++---- .../Documentation.docc/Articles/QuickStart.md | 38 ++++++---- .../Articles/RegisteringDependencies.md | 14 ++-- .../Articles/SingleEntryPointSystems.md | 24 ++++--- .../Documentation.docc/Articles/Testing.md | 72 ++++++++----------- .../Articles/UsingDependencies.md | 25 ++++--- .../Articles/WhatAreDependencies.md | 56 ++++++++------- 20 files changed, 277 insertions(+), 203 deletions(-) diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 019f8b2e..9a2950e3 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -20,10 +20,12 @@ let package = Package( name: "DependenciesMacros", targets: ["DependenciesMacros"] ), - .library( - name: "DependenciesTestSupport", - targets: ["DependenciesTestSupport"] - ), + // NB: A Swift bug prevents the test trait from being useful at the moment. + // https://github.com/swiftlang/swift/issues/76409 + // .library( + // name: "DependenciesTestSupport", + // targets: ["DependenciesTestSupport"] + // ), ], dependencies: [ .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "1.0.2"), diff --git a/README.md b/README.md index 4081305b..b3174486 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,17 @@ is a good chance you can immediately make use of one. If you are using `Date()`, this library. ```swift -final class FeatureModel: ObservableObject { +@Observable +final class FeatureModel { + var items: [Item] = [] + + @ObservationIgnored @Dependency(\.continuousClock) var clock // Controllable way to sleep a task + @ObservationIgnored @Dependency(\.date.now) var now // Controllable way to ask for current date + @ObservationIgnored @Dependency(\.mainQueue) var mainQueue // Controllable scheduling on main queue + @ObservationIgnored @Dependency(\.uuid) var uuid // Controllable UUID creation // ... @@ -96,16 +103,17 @@ Once your dependencies are declared, rather than reaching out to the `Date()`, ` directly, you can use the dependency that is defined on your feature's model: ```swift -final class FeatureModel: ObservableObject { +@Observable +final class FeatureModel { // ... func addButtonTapped() async throws { - try await self.clock.sleep(for: .seconds(1)) // 👈 Don't use 'Task.sleep' - self.items.append( + try await clock.sleep(for: .seconds(1)) // 👈 Don't use 'Task.sleep' + items.append( Item( - id: self.uuid(), // 👈 Don't use 'UUID()' + id: uuid(), // 👈 Don't use 'UUID()' name: "", - createdAt: self.now // 👈 Don't use 'Date()' + createdAt: now // 👈 Don't use 'Date()' ) ) } @@ -120,16 +128,17 @@ inside the `addButtonTapped` method, you can use the `.dependency` test trait to override any dependencies for the scope of one single test. It's as easy as 1-2-3: ```swift -@Test( - // 1️⃣ Override any dependencies that your feature uses. - .dependency(\.clock, .immediate), - .dependency(\.date.now, Date(timeIntervalSinceReferenceDate: 1234567890)), - .dependency(\.uuid, .incrementing) -) +@Test func add() async throws { - // 2️⃣ Construct the feature's model - let model = FeatureModel() - + withDependencies { + // 1️⃣ Override any dependencies that your feature uses. + $0.clock = .immediate + $0.date.now = Date(timeIntervalSinceReferenceDate: 1234567890) + $0.uuid = .incrementing + } operation: { + // 2️⃣ Construct the feature's model + FeatureModel() + } // 3️⃣ The model now executes in a controlled environment of dependencies, // and so we can make assertions against its behavior. try await model.addButtonTapped() diff --git a/Sources/Dependencies/Dependency.swift b/Sources/Dependencies/Dependency.swift index c1220715..514b0e89 100644 --- a/Sources/Dependencies/Dependency.swift +++ b/Sources/Dependencies/Dependency.swift @@ -6,9 +6,13 @@ /// an observable object: /// /// ```swift - /// final class FeatureModel: ObservableObject { + /// @Observable + /// final class FeatureModel { + /// @ObservationIgnored /// @Dependency(\.apiClient) var apiClient + /// @ObservationIgnored /// @Dependency(\.continuousClock) var clock + /// @ObservationIgnored /// @Dependency(\.uuid) var uuid /// /// // ... @@ -18,7 +22,8 @@ /// Or, if you are using [the Composable Architecture][tca]: /// /// ```swift - /// struct Feature: ReducerProtocol { + /// @Reducer + /// struct Feature { /// @Dependency(\.apiClient) var apiClient /// @Dependency(\.continuousClock) var clock /// @Dependency(\.uuid) var uuid @@ -108,7 +113,9 @@ /// reflect: /// /// ```swift - /// final class FeatureModel: ObservableObject { + /// @Observable + /// final class FeatureModel { + /// @ObservationIgnored /// @Dependency(\.date) var date /// /// // ... @@ -158,7 +165,9 @@ extension Dependency { /// One can access the dependency using this property wrapper: /// /// ```swift - /// final class FeatureModel: ObservableObject { + /// @Observable + /// final class FeatureModel { + /// @ObservationIgnored /// @Dependency(Settings.self) var settings /// /// // ... diff --git a/Sources/Dependencies/DependencyKey.swift b/Sources/Dependencies/DependencyKey.swift index 3b1f65a7..0b51375b 100644 --- a/Sources/Dependencies/DependencyKey.swift +++ b/Sources/Dependencies/DependencyKey.swift @@ -169,11 +169,13 @@ extension DependencyKey { /// accessed: /// /// ```swift - /// @Test( - /// .dependency(\.myDependency, .mock) // Override dependency - /// ) + /// @Test /// func featureThatUsesMyDependency() { - /// // Test feature with dependency overridden + /// withDependencies { + /// $0.myDependency = .mock // Override dependency + /// } operation: { + /// // Test feature with dependency overridden + /// } /// } /// ``` /// diff --git a/Sources/Dependencies/DependencyValues/Date.swift b/Sources/Dependencies/DependencyValues/Date.swift index 98d072c0..984e204f 100644 --- a/Sources/Dependencies/DependencyValues/Date.swift +++ b/Sources/Dependencies/DependencyValues/Date.swift @@ -11,7 +11,9 @@ extension DependencyValues { /// wrapper to the generator's ``DateGenerator/now`` property: /// /// ```swift - /// final class FeatureModel: ObservableObject { + /// @Observable + /// final class FeatureModel { + /// @ObservationIgnored /// @Dependency(\.date.now) var now /// // ... /// } diff --git a/Sources/Dependencies/DependencyValues/Locale.swift b/Sources/Dependencies/DependencyValues/Locale.swift index 9ecea951..789679fe 100644 --- a/Sources/Dependencies/DependencyValues/Locale.swift +++ b/Sources/Dependencies/DependencyValues/Locale.swift @@ -10,7 +10,9 @@ extension DependencyValues { /// wrapper to the property: /// /// ```swift - /// final class FeatureModel: ObservableObject { + /// @Observable + /// final class FeatureModel { + /// @ObservationIgnored /// @Dependency(\.locale) var locale /// // ... /// } diff --git a/Sources/Dependencies/DependencyValues/MainQueue.swift b/Sources/Dependencies/DependencyValues/MainQueue.swift index 350ca5ec..2cf8ed49 100644 --- a/Sources/Dependencies/DependencyValues/MainQueue.swift +++ b/Sources/Dependencies/DependencyValues/MainQueue.swift @@ -13,15 +13,17 @@ /// counts the number of seconds it's onscreen: /// /// ``` - /// final class TimerModel: ObservableObject { - /// @Published var elapsed = 0 + /// @Observable + /// final class TimerModel { + /// var elapsed = 0 /// + /// @ObservationIgnored /// @Dependency(\.mainQueue) var mainQueue /// /// @MainActor /// func onAppear() async { - /// for await _ in self.mainQueue.timer(interval: .seconds(1)) { - /// self.elapsed += 1 + /// for await _ in mainQueue.timer(interval: .seconds(1)) { + /// elapsed += 1 /// } /// } /// } @@ -30,13 +32,14 @@ /// And you could test this model by overriding its main queue with a test scheduler: /// /// ``` - /// let mainQueue = DispatchQueue.test - /// - /// @Test( - /// .dependency(\.mainQueue, mainQueue.eraseToAnyScheduler()) - /// ) + /// @Test /// func feature() { - /// let model = TimerModel() + /// let mainQueue = DispatchQueue.test + /// let model = withDependencies { + /// $0.mainQueue = mainQueue.eraseToAnyScheduler() + /// } operation: { + /// TimerModel() + /// } /// /// Task { await model.onAppear() } /// diff --git a/Sources/Dependencies/DependencyValues/MainRunLoop.swift b/Sources/Dependencies/DependencyValues/MainRunLoop.swift index b63f5d28..ddf702fc 100644 --- a/Sources/Dependencies/DependencyValues/MainRunLoop.swift +++ b/Sources/Dependencies/DependencyValues/MainRunLoop.swift @@ -12,16 +12,18 @@ /// For example, you could introduce controllable timing to an observable object model that /// counts the number of seconds it's onscreen: /// - /// ``` - /// struct TimerModel: ObservableObject { - /// @Published var elapsed = 0 + /// ```swift + /// @Observable + /// struct TimerModel { + /// var elapsed = 0 /// + /// @ObservationIgnored /// @Dependency(\.mainRunLoop) var mainRunLoop /// /// @MainActor /// func onAppear() async { - /// for await _ in self.mainRunLoop.timer(interval: .seconds(1)) { - /// self.elapsed += 1 + /// for await _ in mainRunLoop.timer(interval: .seconds(1)) { + /// elapsed += 1 /// } /// } /// } @@ -29,14 +31,15 @@ /// /// And you could test this model by overriding its main run loop with a test scheduler: /// - /// ``` - /// let mainRunLoop = RunLoop.test - /// - /// @Test( - /// .dependency(\.mainRunLoop, mainRunLoop.eraseToAnyScheduler()) - /// ) + /// ```swift + /// @Test /// func feature() { - /// let model = TimerModel() + /// let mainRunLoop = RunLoop.test + /// let model = withDependencies { + /// $0.mainRunLoop = mainRunLoop.eraseToAnyScheduler() + /// } operation: { + /// TimerModel() + /// } /// /// Task { await model.onAppear() } /// diff --git a/Sources/Dependencies/DependencyValues/UUID.swift b/Sources/Dependencies/DependencyValues/UUID.swift index b465c835..ac175e3b 100644 --- a/Sources/Dependencies/DependencyValues/UUID.swift +++ b/Sources/Dependencies/DependencyValues/UUID.swift @@ -13,12 +13,15 @@ extension DependencyValues { /// that creates to-dos with unique identifiers: /// /// ```swift - /// final class TodosModel: ObservableObject { - /// @Published var todos: [Todo] = [] + /// @Observable + /// final class TodosModel { + /// var todos: [Todo] = [] + /// + /// @ObservationIgnored /// @Dependency(\.uuid) var uuid /// /// func addButtonTapped() { - /// self.todos.append(Todo(id: self.uuid())) + /// todos.append(Todo(id: uuid())) /// } /// } /// ``` @@ -39,11 +42,13 @@ extension DependencyValues { /// ``UUIDGenerator/incrementing`` generator as a dependency: /// /// ```swift - /// @Test( - /// .dependency(\.uuid, .incrementing) - /// ) + /// @Test /// func feature() { - /// let model = TodosModel() + /// let model = withDependencies { + /// $0.uuid = .incrementing + /// } operation: { + /// TodosModel() + /// } /// /// model.addButtonTapped() /// #expect( diff --git a/Sources/Dependencies/DependencyValues/WithRandomNumberGenerator.swift b/Sources/Dependencies/DependencyValues/WithRandomNumberGenerator.swift index bfb88c12..b7274d89 100644 --- a/Sources/Dependencies/DependencyValues/WithRandomNumberGenerator.swift +++ b/Sources/Dependencies/DependencyValues/WithRandomNumberGenerator.swift @@ -14,16 +14,18 @@ extension DependencyValues { /// handles rolling a couple dice: /// /// ```swift - /// final class GameModel: ObservableObject { - /// @Published var dice = (1, 1) + /// @Observable + /// final class GameModel { + /// var dice = (1, 1) /// + /// @ObservationIgnored /// @Dependency(\.withRandomNumberGenerator) var withRandomNumberGenerator /// /// func rollDice() { - /// self.dice = self.withRandomNumberGenerator { generator in + /// dice = withRandomNumberGenerator { generator in /// ( - /// Int.random(in: 1...6, using: &generator), - /// Int.random(in: 1...6, using: &generator) + /// .random(in: 1...6, using: &generator), + /// .random(in: 1...6, using: &generator) /// ) /// } /// } @@ -40,11 +42,13 @@ extension DependencyValues { /// a game's model by supplying a seeded random number generator as a dependency: /// /// ```swift - /// @Test( - /// .dependency(\.withRandomNumberGenerator, WithRandomNumberGenerator(LCRNG(seed: 0))) - /// ) + /// @Test /// func roll() { - /// let model = GameModel() + /// let model = withDependencies { + /// $0.withRandomNumberGenerator = WithRandomNumberGenerator(LCRNG(seed: 0)) + /// } operation: { + /// GameModel() + /// } /// /// model.rollDice() /// XCTAssert(model.dice == (1, 3)) diff --git a/Sources/Dependencies/Documentation.docc/Articles/DesigningDependencies.md b/Sources/Dependencies/Documentation.docc/Articles/DesigningDependencies.md index 8d332f92..dca1fa16 100644 --- a/Sources/Dependencies/Documentation.docc/Articles/DesigningDependencies.md +++ b/Sources/Dependencies/Documentation.docc/Articles/DesigningDependencies.md @@ -143,7 +143,9 @@ needs the `play` endpoint, and doesn't need to loop, set volume or stop audio, t a dependency on just that one function: ```swift -final class FeatureModel: ObservableObject { +@Observable +final class FeatureModel { + @ObservationIgnored @Dependency(\.audioPlayer.play) var play // ... } diff --git a/Sources/Dependencies/Documentation.docc/Articles/Lifetimes.md b/Sources/Dependencies/Documentation.docc/Articles/Lifetimes.md index a03fe161..e48f7760 100644 --- a/Sources/Dependencies/Documentation.docc/Articles/Lifetimes.md +++ b/Sources/Dependencies/Documentation.docc/Articles/Lifetimes.md @@ -139,12 +139,16 @@ with a few tools to prolong the change in a well-defined manner. For example, suppose you have a feature that needs access to an API client for fetching a user: ```swift -class FeatureModel: ObservableObject { +@Observable +class FeatureModel { + var user: User? + + @ObservationIgnored @Dependency(\.apiClient) var apiClient func onAppear() async { do { - self.user = try await self.apiClient.fetchUser() + user = try await apiClient.fetchUser() } catch {} } } @@ -200,7 +204,7 @@ This makes `FeatureModel`'s dependencies inherit from the parent feature, and yo override any additional dependencies you want. In general, if you want dependencies to be properly inherited through every layer of feature in your -application, you should make sure to create any `ObservableObject` models inside a +application, you should make sure to create any observable models inside a ``withDependencies(from:operation:file:line:)-8e74m`` scope. If you do this, it also allows you to run previews in a very specific environment. Dependencies diff --git a/Sources/Dependencies/Documentation.docc/Articles/LivePreviewTest.md b/Sources/Dependencies/Documentation.docc/Articles/LivePreviewTest.md index fe558c4b..07759451 100644 --- a/Sources/Dependencies/Documentation.docc/Articles/LivePreviewTest.md +++ b/Sources/Dependencies/Documentation.docc/Articles/LivePreviewTest.md @@ -37,8 +37,6 @@ be written to disk, which will bleed into other tests, and more. Using live dependencies in tests are so problematic that the library will cause a test failure if you ever interact with a live dependency while tests are running: - - ```swift @Test func feature() async throws { @@ -56,17 +54,18 @@ func feature() async throws { } ``` -If you truly want to use -live dependencies in tests you have to make it explicit by overriding the dependency the -`.dependency` testing trait and setting the live value: +If you truly want to use live dependencies in tests you have to make it explicit by overriding the +dependency and setting the live value: ```swift -@Test( - // ⚠️ Explicitly say you want to use a live dependency. - .dependency(\.apiClient, .liveValue) -) +@Test func feature() async throws { - let model = FeatureModel() + let model = withDependencies { + // ⚠️ Explicitly say you want to use a live dependency. + $0.apiClient = .liveValue + } operation: { + FeatureModel() + } // ... } @@ -268,12 +267,14 @@ understand the risks of using a live dependency in tests. To confirm that you tr live dependency you can override the dependency with `.liveValue`: ```swift -@Test( - // ⚠️ Explicitly say you want to use a live dependency. - .dependency(\.apiClient, .liveValue) -) +@Test func feature() async throws { - let model = FeatureModel() + let model = withDependencies { + // ⚠️ Explicitly say you want to use a live dependency. + $0.apiClient = .liveValue + } operation: { + FeatureModel() + } // ... } diff --git a/Sources/Dependencies/Documentation.docc/Articles/OverridingDependencies.md b/Sources/Dependencies/Documentation.docc/Articles/OverridingDependencies.md index ddcb2943..fd56dc39 100644 --- a/Sources/Dependencies/Documentation.docc/Articles/OverridingDependencies.md +++ b/Sources/Dependencies/Documentation.docc/Articles/OverridingDependencies.md @@ -23,11 +23,12 @@ method, which allows you to inherit the dependencies from an existing object _an override some of those dependencies: ```swift -final class AppModel: ObservableObject { - @Published var onboardingTodos: TodosModel? +@Observable +final class AppModel { + var onboardingTodos: TodosModel? func tutorialButtonTapped() { - self.onboardingTodos = withDependencies(from: self) { + onboardingTodos = withDependencies(from: self) { $0.apiClient = .mock $0.fileManager = .mock $0.userDefaults = .mock @@ -67,16 +68,20 @@ edit screen for a particular todo. You could model that with an `EditTodoModel` optional state that when hydrated causes the drill down: ```swift -final class TodosModel: ObservableObject { - @Published var todos: [Todo] = [] - @Published var editTodo: EditTodoModel? +@Observable +final class TodosModel { + var todos: [Todo] = [] + var editTodo: EditTodoModel? + @ObservationIgnored @Dependency(\.apiClient) var apiClient + @ObservationIgnored @Dependency(\.fileManager) var fileManager + @ObservationIgnored @Dependency(\.userDefaults) var userDefaults func tappedTodo(_ todo: Todo) { - self.editTodo = EditTodoModel(todo: todo) + editTodo = EditTodoModel(todo: todo) } // ... @@ -93,7 +98,7 @@ must wrap the creation of the child model in ```swift func tappedTodo(_ todo: Todo) { - self.editTodo = withDependencies(from: self) { + editTodo = withDependencies(from: self) { EditTodoModel(todo: todo) } } @@ -111,11 +116,13 @@ a user when the view appears, a test for this functionality could be written by `apiClient` to return some mock data: ```swift -@Test( - .dependency(\.apiClient.fetchUser, { _ in User(id: 42, name: "Blob") }) -) +@Test func onAppear() async { - let model = FeatureModel() + let model = withDependencies { + $0.apiClient.fetchUser = { _ in User(id: 42, name: "Blob") } + } operation: { + FeatureModel() + } #expect(model.user == nil) await model.onAppear() diff --git a/Sources/Dependencies/Documentation.docc/Articles/QuickStart.md b/Sources/Dependencies/Documentation.docc/Articles/QuickStart.md index c3aef129..fbc165c5 100644 --- a/Sources/Dependencies/Documentation.docc/Articles/QuickStart.md +++ b/Sources/Dependencies/Documentation.docc/Articles/QuickStart.md @@ -35,10 +35,17 @@ is a good chance you can immediately make use of one. If you are using `Date()`, this library. ```swift -final class FeatureModel: ObservableObject { +@Observable +final class FeatureModel { + var items: [Item] = [] + + @ObservationIgnored @Dependency(\.continuousClock) var clock // Controllable way to sleep a task + @ObservationIgnored @Dependency(\.date.now) var now // Controllable way to ask for current date + @ObservationIgnored @Dependency(\.mainQueue) var mainQueue // Controllable scheduling on main queue + @ObservationIgnored @Dependency(\.uuid) var uuid // Controllable UUID creation // ... @@ -49,16 +56,17 @@ Once your dependencies are declared, rather than reaching out to the `Date()`, ` directly, you can use the dependency that is defined on your feature's model: ```swift -final class FeatureModel: ObservableObject { +@Observable +final class FeatureModel { // ... func addButtonTapped() async throws { - try await self.clock.sleep(for: .seconds(1)) // 👈 Don't use 'Task.sleep' - self.items.append( + try await clock.sleep(for: .seconds(1)) // 👈 Don't use 'Task.sleep' + items.append( Item( - id: self.uuid(), // 👈 Don't use 'UUID()' + id: uuid(), // 👈 Don't use 'UUID()' name: "", - createdAt: self.now // 👈 Don't use 'Date()' + createdAt: now // 👈 Don't use 'Date()' ) ) } @@ -73,15 +81,17 @@ inside the `addButtonTapped` method, you can use the ``withDependencies(_:operat function to override any dependencies for the scope of one single test. It's as easy as 1-2-3: ```swift -@Test( - // 1️⃣ Override any dependencies that your feature uses. - .dependency(\.clock, .immediate), - .dependency(\.date.now, Date(timeIntervalSinceReferenceDate: 1234567890)), - .dependency(\.uuid, .incrementing) -) +@Test func add() async throws { - // 2️⃣ Construct the feature's model - let model = FeatureModel() + let model = withDependencies { + // 1️⃣ Override any dependencies that your feature uses. + $0.clock = .immediate + $0.date.now = Date(timeIntervalSinceReferenceDate: 1234567890) + $0.uuid = .incrementing) + } operation: { + // 2️⃣ Construct the feature's model + FeatureModel() + } // 3️⃣ The model now executes in a controlled environment of dependencies, // and so we can make assertions against its behavior. diff --git a/Sources/Dependencies/Documentation.docc/Articles/RegisteringDependencies.md b/Sources/Dependencies/Documentation.docc/Articles/RegisteringDependencies.md index 4c1157f7..657ca00e 100644 --- a/Sources/Dependencies/Documentation.docc/Articles/RegisteringDependencies.md +++ b/Sources/Dependencies/Documentation.docc/Articles/RegisteringDependencies.md @@ -34,7 +34,9 @@ extension APIClient: DependencyKey { With that done you can instantly access your API client dependency from any part of your code base: ```swift -final class TodosModel: ObservableObject { +@Observable +final class TodosModel { + @ObservationIgnored @Dependency(APIClient.self) var apiClient // ... } @@ -45,11 +47,13 @@ you can override the dependency to return mock data: ```swift @MainActor -@Test( - .dependency(\.[APIClient.self].fetchTodos = { _ in Todo(id: 1, title: "Get milk") }) -) +@Test func fetchUser() async { - let model = TodosModel() + let model = withDependencies { + $0[APIClient.self].fetchTodos = { _ in Todo(id: 1, title: "Get milk") } + } operation: { + TodosModel() + } await store.loadButtonTapped() #expect( diff --git a/Sources/Dependencies/Documentation.docc/Articles/SingleEntryPointSystems.md b/Sources/Dependencies/Documentation.docc/Articles/SingleEntryPointSystems.md index 6ef06303..a1d42aa0 100644 --- a/Sources/Dependencies/Documentation.docc/Articles/SingleEntryPointSystems.md +++ b/Sources/Dependencies/Documentation.docc/Articles/SingleEntryPointSystems.md @@ -60,9 +60,9 @@ up a response to send back to the client. This again describes just a single poi be executed for a particular request. So, there are a lot of examples of "single entry point" systems out there, but it's also not the -majority. There are plenty of examples that do not fall into this paradigm, such as -`ObservableObject` conformances, all of UIKit and more. If you _are_ dealing with a single entry -point system, then there are some really great superpowers that can be unlocked... +majority. There are plenty of examples that do not fall into this paradigm, such as observable +objects, all of UIKit and more. If you _are_ dealing with a single entry point system, then there +are some really great superpowers that can be unlocked... ## Altered execution environments @@ -159,12 +159,15 @@ with other kinds of systems. You just have to be a little more careful. In parti careful where you add dependencies to your features and how you construct features that use dependencies. -When adding a dependency to a feature's `ObservableObject` conformance, you should make use of +When adding a dependency to a feature modeled in an observable object, you should make use of `@Dependency` only for the object's instance properties: ```swift -final class FeatureModel: ObservableObject { +@Observable +final class FeatureModel { + @ObservationIgnored @Dependency(\.apiClient) var apiClient + @ObservationIgnored @Dependency(\.date) var date // ... } @@ -191,14 +194,17 @@ hydrating that state you will want to wrap it in ``withDependencies(from:operation:file:line:)-8e74m``: ```swift -final class FeatureModel: ObservableObject { - @Published var editModel: EditModel? +@Observable +final class FeatureModel { + var editModel: EditModel? + @ObservationIgnored @Dependency(\.apiClient) var apiClient + @ObservationIgnored @Dependency(\.date) var date func editButtonTapped() { - self.editModel = withDependencies(from: self) { + editModel = withDependencies(from: self) { EditModel() } } @@ -221,7 +227,7 @@ final class FeatureViewController: UIViewController { let controller = withDependencies(from: self) { EditViewController() } - self.present(controller, animated: true, completion: nil) + present(controller, animated: true, completion: nil) } } ``` diff --git a/Sources/Dependencies/Documentation.docc/Articles/Testing.md b/Sources/Dependencies/Documentation.docc/Articles/Testing.md index 45ac5629..3d705b76 100644 --- a/Sources/Dependencies/Documentation.docc/Articles/Testing.md +++ b/Sources/Dependencies/Documentation.docc/Articles/Testing.md @@ -22,53 +22,38 @@ great for tests. It means your feature doesn't need to actually make network req how your feature deals with data returned from an API, and your feature doesn't need to interact with the file system just to test how data gets loaded or persisted. -The tool for doing this is using `.dependency` test trait when using Swift's Testing framework, -or ``withDependencies(_:operation:)-3vrqy`` when using XCTest, both of which allow you to specify -which dependencies should be overridden for the test: - -@Row { - @Column { - ###### Testing - ```swift - @Test( - .dependency(\.continuousClock, .immediate), - .dependency(\.date.now, Date(timeIntervalSince1970: 1234567890)) - ) - func feature() async { - let model = FeatureModel() - // Call methods on `model` and make assertions - } - ``` - } - @Column { - ###### XCTest - ```swift - func testFeature() async { - let model = withDependencies { - $0.continuousClock = .immediate - $0.date.now = Date(timeIntervalSince1970: 1234567890) - } operation: { - FeatureModel() - // Call methods on `model` and make assertions - } - } - ``` +The tool for doing this is ``withDependencies(_:operation:)-3vrqy``, which allows you to specify +which dependencies should be overridden for the test, and then construct your feature's model in +that context: + +```swift +@Test +func feature() async { + let model = withDependencies { + $0.continuousClock = .immediate + $0.date.now = Date(timeIntervalSince1970: 1234567890) + } operation: { + FeatureModel() } + + // Call methods on `model` and make assertions } +``` As long as all of your dependencies are declared with `@Dependency` as instance properties on `FeatureModel`, its entire execution will happen in a context in which any reference to `continuousClock` is an `ImmediateClock` and any reference to `date.now` will always report that the date is "Feb 13, 2009 at 3:31 PM". -> Note: If you are using XCTest it is important to note that if `FeatureModel` creates _other_ -models inside its methods, then it has to be careful about how it does so. In order for -`FeatureModel`'s dependencies to propagate to the new child model, it must construct the child -model in an altered execution context that passes along the dependencies. The tool for this is -``withDependencies(from:operation:file:line:)-2qx0c`` and can be used simply like this: +> Note: If you are using XCTest it is important to note that if `FeatureModel` creates _other_ +> models inside its methods, then it has to be careful about how it does so. In order for +> `FeatureModel`'s dependencies to propagate to the new child model, it must construct the child +> model in an altered execution context that passes along the dependencies. The tool for this is +> ``withDependencies(from:operation:file:line:)-2qx0c`` and can be used simply like this: > > ```swift -> class FeatureModel: ObservableObject { +> @Observable +> class FeatureModel { > // ... > > func buttonTapped() { @@ -96,11 +81,16 @@ login fails, and then later change the dependency so that it succeeds using ``withDependencies(_:operation:)-3vrqy``: ```swift -@Test( - .dependency(\.apiClient.login, { _, _ in throw LoginFailure() }) -) +@Test func retryFlow() async { - let model = LoginModel() + let model = withDependencies { + $0.apiClient.login = { email, password in + struct LoginFailure: Error {} + throw LoginFailure() + } + } operation: { + LoginModel() + } await model.loginButtonTapped() #expect(model.errorMessage == "We could not log you in. Please try again") diff --git a/Sources/Dependencies/Documentation.docc/Articles/UsingDependencies.md b/Sources/Dependencies/Documentation.docc/Articles/UsingDependencies.md index a2342906..0fa40193 100644 --- a/Sources/Dependencies/Documentation.docc/Articles/UsingDependencies.md +++ b/Sources/Dependencies/Documentation.docc/Articles/UsingDependencies.md @@ -6,7 +6,7 @@ Learn how to use the dependencies that are registered with the library. Once a dependency is registered with the library (see for more info), one can access the dependency with the ``Dependency`` property wrapper. This is most commonly done -by adding `@Dependency` properties to your feature's model, such as an `ObservableObject`, or +by adding `@Dependency` properties to your feature's model, such as an observable object, or controller, such as `UIViewController` subclass. It can be used in other scopes too, such as functions, methods and computed properties, but there are caveats to consider, and so doing that is not recommended until you are very comfortable with the library. @@ -19,10 +19,11 @@ clock for time-based asynchrony, and a UUID initializer. All 3 dependencies can feature's model: ```swift -final class TodosModel: ObservableObject { - @Dependency(\.continuousClock) var clock - @Dependency(\.date) var date - @Dependency(\.uuid) var uuid +@Observable +final class TodosModel { + @ObservationIgnored @Dependency(\.continuousClock) var clock + @ObservationIgnored @Dependency(\.date) var date + @ObservationIgnored @Dependency(\.uuid) var uuid // ... } @@ -33,13 +34,15 @@ feature: ```swift @MainActor -@Test( - .dependency(\.continuousClock, .immediate), - .dependency(\.date.now, Date(timeIntervalSinceReferenceDate: 1234567890), - .dependency(\.uuid, .incrementing) -) +@Test func todos() async { - let model = TodosModel() + let model = withDependencies { + $0.continuousClock = .immediate + $0.date.now = Date(timeIntervalSinceReferenceDate: 1234567890 + $0.uuid = .incrementing + } operation: { + TodosModel() + } // Invoke methods on `model` and make assertions... } diff --git a/Sources/Dependencies/Documentation.docc/Articles/WhatAreDependencies.md b/Sources/Dependencies/Documentation.docc/Articles/WhatAreDependencies.md index a342216e..6dbc835f 100644 --- a/Sources/Dependencies/Documentation.docc/Articles/WhatAreDependencies.md +++ b/Sources/Dependencies/Documentation.docc/Articles/WhatAreDependencies.md @@ -20,13 +20,14 @@ Suppose that you are building a feature that displays a message to the user afte logic can be packaged up into an observable object: ```swift -final class FeatureModel: ObservableObject { - @Published var message: String? +@Observable +final class FeatureModel { + var message: String? func onAppear() async { do { try await Task.sleep(for: .seconds(10)) - self.message = "Welcome!" + message = "Welcome!" } catch {} } } @@ -36,17 +37,17 @@ And a view can make use of that model: ```swift struct FeatureView: View { - @ObservedObject var model: FeatureModel + let model: FeatureModel var body: some View { Form { - if let message = self.model.message { + if let message = model.message { Text(message) } // ... } - .task { await self.model.onAppear() } + .task { await model.onAppear() } } } ``` @@ -77,14 +78,17 @@ time-based asynchrony by holding onto a clock in the feature's model by using th property wrapper and ``DependencyValues/continuousClock`` dependency value: ```swift -final class FeatureModel: ObservableObject { - @Published var message: String? +@Observable +final class FeatureModel { + var message: String? + + @ObservationIgnored @Dependency(\.continuousClock) var clock func onAppear() async { do { - try await self.clock.sleep(for: .seconds(10)) - self.message = "Welcome!" + try await clock.sleep(for: .seconds(10)) + message = "Welcome!" } catch {} } } @@ -92,21 +96,21 @@ final class FeatureModel: ObservableObject { That small change makes this feature much friendlier to Xcode previews and testing. -For previews, you can use ``withDependencies(_:operation:)-4uz6m`` to override the +For previews, you can use the `.dependencies` preview trait to override the ``DependencyValues/continuousClock`` dependency to be an "immediate" clock, which is a clock that does not actually sleep for any amount of time: ```swift -struct Feature_Previews: PreviewProvider { - static var previews: some View { - FeatureView( - model: withDependencies { - $0.continuousClock = ImmediateClock() - } operation: { - FeatureModel() - } - ) - } +#Preview( + .dependencies { $0.continuousClock = .immediate } +) { + FeatureView( + model: withDependencies { + $0.continuousClock = ImmediateClock() + } operation: { + FeatureModel() + } + ) } ``` @@ -119,11 +123,13 @@ Further, in tests you can also override the clock dependency to use an immediate the ``withDependencies(_:operation:)-4uz6m`` helper: ```swift -@Test( - .dependency(\.continuousClock, .immediate) -) +@Test func message() async { - let model = FeatureModel() + let model = withDependencies { + $0.continuousClock = .immediate + } operation: { + FeatureModel() + } #expect(model.message == nil) await model.onAppear()