Skip to content

Commit 5361983

Browse files
Add #Preview and @Test traits for overriding dependencies (#274)
* Add `#Preview` and `@Test` traits for overriding dependencies * wip * wip * wip * wip * Fix * wip * docs updates * wip * wip * wip * Update Sources/DependenciesTestSupport/TestTrait.swift * Update README.md * Update Sources/Dependencies/Documentation.docc/Articles/QuickStart.md * Update Sources/Dependencies/Documentation.docc/Articles/Testing.md * Update Sources/Dependencies/Documentation.docc/Articles/UsingDependencies.md * wip * A few renames * wip --------- Co-authored-by: Brandon Williams <[email protected]>
1 parent 9d1e15f commit 5361983

30 files changed

+476
-253
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
strategy:
2121
matrix:
2222
config: ['debug', 'release']
23-
xcode: ['15.4']
23+
xcode: ['15.4', '16_beta_6']
2424
steps:
2525
- uses: actions/checkout@v4
2626
- name: Select Xcode ${{ matrix.xcode }}

Dependencies.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.resolved

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ let package = Package(
2525
.package(url: "https://github.com/pointfreeco/combine-schedulers", from: "1.0.2"),
2626
.package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.4"),
2727
.package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.0.0"),
28-
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.3.0"),
28+
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.4.0"),
2929
.package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"601.0.0-prerelease"),
3030
],
3131
targets: [

[email protected]

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@ let package = Package(
2020
name: "DependenciesMacros",
2121
targets: ["DependenciesMacros"]
2222
),
23+
.library(
24+
name: "DependenciesTestSupport",
25+
targets: ["DependenciesTestSupport"]
26+
),
2327
],
2428
dependencies: [
2529
.package(url: "https://github.com/pointfreeco/combine-schedulers", from: "1.0.2"),
2630
.package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.4"),
2731
.package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.0.0"),
28-
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.3.0"),
32+
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.4.0"),
2933
.package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"601.0.0-prerelease"),
3034
],
3135
targets: [
@@ -45,11 +49,18 @@ let package = Package(
4549
.product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"),
4650
]
4751
),
52+
.target(
53+
name: "DependenciesTestSupport",
54+
dependencies: [
55+
"Dependencies",
56+
]
57+
),
4858
.testTarget(
4959
name: "DependenciesTests",
5060
dependencies: [
5161
"Dependencies",
5262
"DependenciesMacros",
63+
"DependenciesTestSupport",
5364
.product(name: "IssueReportingTestSupport", package: "xctest-dynamic-overlay"),
5465
]
5566
),

README.md

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,17 @@ is a good chance you can immediately make use of one. If you are using `Date()`,
8282
this library.
8383

8484
```swift
85-
final class FeatureModel: ObservableObject {
85+
@Observable
86+
final class FeatureModel {
87+
var items: [Item] = []
88+
89+
@ObservationIgnored
8690
@Dependency(\.continuousClock) var clock // Controllable way to sleep a task
91+
@ObservationIgnored
8792
@Dependency(\.date.now) var now // Controllable way to ask for current date
93+
@ObservationIgnored
8894
@Dependency(\.mainQueue) var mainQueue // Controllable scheduling on main queue
95+
@ObservationIgnored
8996
@Dependency(\.uuid) var uuid // Controllable UUID creation
9097

9198
// ...
@@ -96,16 +103,17 @@ Once your dependencies are declared, rather than reaching out to the `Date()`, `
96103
directly, you can use the dependency that is defined on your feature's model:
97104

98105
```swift
99-
final class FeatureModel: ObservableObject {
106+
@Observable
107+
final class FeatureModel {
100108
// ...
101109

102110
func addButtonTapped() async throws {
103-
try await self.clock.sleep(for: .seconds(1)) // 👈 Don't use 'Task.sleep'
104-
self.items.append(
111+
try await clock.sleep(for: .seconds(1)) // 👈 Don't use 'Task.sleep'
112+
items.append(
105113
Item(
106-
id: self.uuid(), // 👈 Don't use 'UUID()'
114+
id: uuid(), // 👈 Don't use 'UUID()'
107115
name: "",
108-
createdAt: self.now // 👈 Don't use 'Date()'
116+
createdAt: now // 👈 Don't use 'Date()'
109117
)
110118
)
111119
}
@@ -120,23 +128,22 @@ inside the `addButtonTapped` method, you can use the [`withDependencies`][withde
120128
function to override any dependencies for the scope of one single test. It's as easy as 1-2-3:
121129

122130
```swift
123-
func testAdd() async throws {
124-
let model = withDependencies {
131+
@Test
132+
func add() async throws {
133+
withDependencies {
125134
// 1️⃣ Override any dependencies that your feature uses.
126-
$0.clock = ImmediateClock()
135+
$0.clock = .immediate
127136
$0.date.now = Date(timeIntervalSinceReferenceDate: 1234567890)
128137
$0.uuid = .incrementing
129138
} operation: {
130139
// 2️⃣ Construct the feature's model
131140
FeatureModel()
132141
}
133-
134142
// 3️⃣ The model now executes in a controlled environment of dependencies,
135143
// and so we can make assertions against its behavior.
136144
try await model.addButtonTapped()
137-
XCTAssertEqual(
138-
model.items,
139-
[
145+
#expect(
146+
model.items == [
140147
Item(
141148
id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!,
142149
name: "",
@@ -158,19 +165,17 @@ But, controllable dependencies aren't only useful for tests. They can also be us
158165
previews. Suppose the feature above makes use of a clock to sleep for an amount of time before
159166
something happens in the view. If you don't want to literally wait for time to pass in order to see
160167
how the view changes, you can override the clock dependency to be an "immediate" clock using the
161-
[`withDependencies`][withdependencies-docs] helper:
168+
`.dependencies` preview trait:
162169

163170
```swift
164-
struct Feature_Previews: PreviewProvider {
165-
static var previews: some View {
166-
FeatureView(
167-
model: withDependencies {
168-
$0.clock = ImmediateClock()
169-
} operation: {
170-
FeatureModel()
171-
}
172-
)
171+
#Preview(
172+
traits: .dependencies {
173+
$0.continuousClock = .immediate
173174
}
175+
) {
176+
// All access of '@Dependency(\.continuousClock)' in this preview will
177+
// use an immediate clock.
178+
FeatureView(model: FeatureModel())
174179
}
175180
```
176181

Sources/Dependencies/Dependency.swift

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@
66
/// an observable object:
77
///
88
/// ```swift
9-
/// final class FeatureModel: ObservableObject {
9+
/// @Observable
10+
/// final class FeatureModel {
11+
/// @ObservationIgnored
1012
/// @Dependency(\.apiClient) var apiClient
13+
/// @ObservationIgnored
1114
/// @Dependency(\.continuousClock) var clock
15+
/// @ObservationIgnored
1216
/// @Dependency(\.uuid) var uuid
1317
///
1418
/// // ...
@@ -18,7 +22,8 @@
1822
/// Or, if you are using [the Composable Architecture][tca]:
1923
///
2024
/// ```swift
21-
/// struct Feature: ReducerProtocol {
25+
/// @Reducer
26+
/// struct Feature {
2227
/// @Dependency(\.apiClient) var apiClient
2328
/// @Dependency(\.continuousClock) var clock
2429
/// @Dependency(\.uuid) var uuid
@@ -108,7 +113,9 @@
108113
/// reflect:
109114
///
110115
/// ```swift
111-
/// final class FeatureModel: ObservableObject {
116+
/// @Observable
117+
/// final class FeatureModel {
118+
/// @ObservationIgnored
112119
/// @Dependency(\.date) var date
113120
///
114121
/// // ...
@@ -158,7 +165,9 @@ extension Dependency {
158165
/// One can access the dependency using this property wrapper:
159166
///
160167
/// ```swift
161-
/// final class FeatureModel: ObservableObject {
168+
/// @Observable
169+
/// final class FeatureModel {
170+
/// @ObservationIgnored
162171
/// @Dependency(Settings.self) var settings
163172
///
164173
/// // ...

Sources/Dependencies/DependencyKey.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,14 +162,15 @@ extension DependencyKey {
162162
/// ``TestDependencyKey``.
163163
public static var previewValue: Value { Self.liveValue }
164164

165-
/// A default implementation that provides the ``previewValue`` to XCTest runs (or ``liveValue``,
165+
/// A default implementation that provides the ``previewValue`` to test runs (or ``liveValue``,
166166
/// if no preview value is implemented), but will trigger a test failure when accessed.
167167
///
168168
/// To prevent test failures, explicitly override the dependency in any tests in which it is
169169
/// accessed:
170170
///
171171
/// ```swift
172-
/// func testFeatureThatUsesMyDependency() {
172+
/// @Test
173+
/// func featureThatUsesMyDependency() {
173174
/// withDependencies {
174175
/// $0.myDependency = .mock // Override dependency
175176
/// } operation: {

Sources/Dependencies/DependencyValues.swift

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,11 @@ public struct DependencyValues: Sendable {
184184
#endif
185185
}
186186

187+
package init(context: DependencyContext) {
188+
self.init()
189+
self.context = context
190+
}
191+
187192
@_disfavoredOverload
188193
public subscript<Key: TestDependencyKey>(type: Key.Type) -> Key.Value {
189194
get { self[type] }
@@ -356,6 +361,8 @@ private let defaultContext: DependencyContext = {
356361

357362
@_spi(Internals)
358363
public final class CachedValues: @unchecked Sendable {
364+
@TaskLocal static var isAccessingCachedDependencies = false
365+
359366
public struct CacheKey: Hashable, Sendable {
360367
let id: TypeIdentifier
361368
let context: DependencyContext
@@ -397,9 +404,24 @@ public final class CachedValues: @unchecked Sendable {
397404
case .live:
398405
value = (key as? any DependencyKey.Type)?.liveValue as? Key.Value
399406
case .preview:
400-
value = Key.previewValue
407+
if !CachedValues.isAccessingCachedDependencies {
408+
value = CachedValues.$isAccessingCachedDependencies.withValue(true) {
409+
previewValues.withValue { $0[key] }
410+
}
411+
} else {
412+
value = Key.previewValue
413+
}
401414
case .test:
402-
value = Key.testValue
415+
if !CachedValues.isAccessingCachedDependencies,
416+
case let .swiftTesting(.some(testing)) = TestContext.current,
417+
let testValues = testValuesByTestID.withValue({ $0[testing.test.id.rawValue] })
418+
{
419+
value = CachedValues.$isAccessingCachedDependencies.withValue(true) {
420+
testValues[key]
421+
}
422+
} else {
423+
value = Key.testValue
424+
}
403425
}
404426

405427
guard let value

Sources/Dependencies/DependencyValues/Date.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ extension DependencyValues {
1111
/// wrapper to the generator's ``DateGenerator/now`` property:
1212
///
1313
/// ```swift
14-
/// final class FeatureModel: ObservableObject {
14+
/// @Observable
15+
/// final class FeatureModel {
16+
/// @ObservationIgnored
1517
/// @Dependency(\.date.now) var now
1618
/// // ...
1719
/// }

0 commit comments

Comments
 (0)