Skip to content

Commit 38c650e

Browse files
authored
Transition from XCTest to Swift Testing (fixes #196) (#214)
* Transition from XCTest to Swift Testing (fixes #196) * Rename StateTests to PublisherTests * Bump workflow to Xcode 16.2 (for Swift 6.0.3 with swift-testing)
1 parent af54165 commit 38c650e

File tree

3 files changed

+145
-123
lines changed

3 files changed

+145
-123
lines changed

.github/workflows/build-test-and-docs.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ on:
1212
jobs:
1313
macos:
1414
runs-on: macos-14
15-
steps:
16-
- name: Force Xcode 15.4
17-
run: sudo xcode-select -switch /Applications/Xcode_15.4.app
15+
steps: # For swift-testing (everything else works with 5.10)
16+
- name: Force Xcode 16.2
17+
run: sudo xcode-select -switch /Applications/Xcode_16.2.app
1818

1919
- name: Swift version
2020
run: swift --version
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import Testing
2+
import Foundation
3+
4+
@testable import SwiftCrossUI
5+
6+
#if canImport(AppKitBackend)
7+
@testable import AppKitBackend
8+
#endif
9+
10+
@Suite("Publisher-related tests")
11+
struct PublisherTests {
12+
@Test("Ensures that basic Publisher operations can be observed")
13+
func testPublisherObservation() {
14+
class NestedState: SwiftCrossUI.ObservableObject {
15+
@SwiftCrossUI.Published
16+
var count = 0
17+
}
18+
19+
class MyState: SwiftCrossUI.ObservableObject {
20+
@SwiftCrossUI.Published
21+
var count = 0
22+
@SwiftCrossUI.Published
23+
var publishedNestedState = NestedState()
24+
var unpublishedNestedState = NestedState()
25+
}
26+
27+
let state = MyState()
28+
var observedChange = false
29+
let cancellable = state.didChange.observe {
30+
observedChange = true
31+
}
32+
33+
// Ensures that published value type mutation triggers observation
34+
observedChange = false
35+
state.count += 1
36+
#expect(observedChange, "Expected value type mutation to trigger observation")
37+
38+
// Ensure that published nested ObservableObject triggers observation
39+
observedChange = false
40+
state.publishedNestedState.count += 1
41+
#expect(observedChange, "Expected nested published observable object mutation to trigger observation")
42+
43+
// Ensure that replacing published nested ObservableObject triggers observation
44+
observedChange = false
45+
state.publishedNestedState = NestedState()
46+
#expect(observedChange, "Expected replacing nested published observable object to trigger observation")
47+
48+
// Ensure that replaced published nested ObservableObject triggers observation
49+
observedChange = false
50+
state.publishedNestedState.count += 1
51+
#expect(observedChange, "Expected replaced nested published observable object mutation to trigger observation")
52+
53+
// Ensure that non-published nested ObservableObject doesn't trigger observation
54+
observedChange = false
55+
state.unpublishedNestedState.count += 1
56+
#expect(!observedChange, "Expected nested unpublished observable object mutation to not trigger observation")
57+
58+
// Ensure that cancelling the observation prevents future observations
59+
cancellable.cancel()
60+
observedChange = false
61+
state.count += 1
62+
#expect(!observedChange, "Expected mutation not to trigger cancelled observation")
63+
}
64+
65+
#if canImport(AppKitBackend)
66+
// TODO: Create mock backend so that this can be tested on all platforms. There's
67+
// nothing AppKit-specific about it.
68+
@Test("Ensure that Publisher.observeAsUIUpdater throttles observations")
69+
func testThrottledPublisherObservation() async {
70+
class MyState: SwiftCrossUI.ObservableObject {
71+
@SwiftCrossUI.Published
72+
var count = 0
73+
}
74+
75+
/// A thread-safe count.
76+
actor Count {
77+
var count = 0
78+
79+
func update(_ action: (Int) -> Int) {
80+
count = action(count)
81+
}
82+
}
83+
84+
// Number of mutations to perform
85+
let mutationCount = 20
86+
// Length of each fake state update
87+
let updateDuration = 0.02
88+
// Delay between observation-causing state mutations
89+
let mutationGap = 0.01
90+
91+
let state = MyState()
92+
let updateCount = Count()
93+
94+
let backend = await AppKitBackend()
95+
let cancellable = state.didChange.observeAsUIUpdater(backend: backend) {
96+
Task {
97+
await updateCount.update { $0 + 1 }
98+
}
99+
// Simulate an update of duration `updateDuration` seconds
100+
Thread.sleep(forTimeInterval: updateDuration)
101+
}
102+
_ = cancellable // Silence warning about cancellable being unused
103+
104+
let start = ProcessInfo.processInfo.systemUptime
105+
for _ in 0..<mutationCount {
106+
state.count += 1
107+
try? await Task.sleep(nanoseconds: UInt64(1_000_000_000 * mutationGap))
108+
}
109+
let elapsed = ProcessInfo.processInfo.systemUptime - start
110+
111+
// Compute percentage of main thread's time taken up by updates.
112+
let ratio = Double(await updateCount.count) * updateDuration / elapsed
113+
#expect(
114+
ratio <= 0.85,
115+
"""
116+
Expected throttled updates to take under 85% of the main \
117+
thread's time. Took \(Int(ratio * 100))%
118+
"""
119+
)
120+
}
121+
#endif
122+
}

Tests/SwiftCrossUITests/SwiftCrossUITests.swift

Lines changed: 20 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import XCTest
1+
import Testing
2+
import Foundation
23

34
@testable import SwiftCrossUI
45

56
#if canImport(AppKitBackend)
7+
import AppKit
8+
import CoreGraphics
69
@testable import AppKitBackend
710
#endif
811

@@ -18,15 +21,17 @@ struct CounterView: View {
1821
}
1922
}
2023

21-
struct XCTError: LocalizedError {
24+
struct TestError: LocalizedError {
2225
var message: String
2326

2427
var errorDescription: String? {
2528
message
2629
}
2730
}
2831

29-
final class SwiftCrossUITests: XCTestCase {
32+
@Suite("Testing for SwiftCrossUI")
33+
struct SwiftCrossUITests {
34+
@Test("Ensures that a NavigationPath can be round tripped to and from JSON")
3035
func testCodableNavigationPath() throws {
3136
var path = NavigationPath()
3237
path.append("a")
@@ -45,12 +50,13 @@ final class SwiftCrossUITests: XCTestCase {
4550
String.self, Int.self, [Int].self, Double.self,
4651
])
4752

48-
XCTAssert(Self.compareComponents(ofType: String.self, components[0], decodedComponents[0]))
49-
XCTAssert(Self.compareComponents(ofType: Int.self, components[1], decodedComponents[1]))
50-
XCTAssert(Self.compareComponents(ofType: [Int].self, components[2], decodedComponents[2]))
51-
XCTAssert(Self.compareComponents(ofType: Double.self, components[3], decodedComponents[3]))
53+
#expect(Self.compareComponents(ofType: String.self, components[0], decodedComponents[0]))
54+
#expect(Self.compareComponents(ofType: Int.self, components[1], decodedComponents[1]))
55+
#expect(Self.compareComponents(ofType: [Int].self, components[2], decodedComponents[2]))
56+
#expect(Self.compareComponents(ofType: Double.self, components[3], decodedComponents[3]))
5257
}
5358

59+
/// Helper function for `testCodableNavigationPath`.
5460
static func compareComponents<T: Equatable>(
5561
ofType type: T.Type, _ original: Any, _ decoded: Any
5662
) -> Bool {
@@ -64,114 +70,8 @@ final class SwiftCrossUITests: XCTestCase {
6470
return original == decoded
6571
}
6672

67-
func testStateObservation() {
68-
class NestedState: SwiftCrossUI.ObservableObject {
69-
@SwiftCrossUI.Published
70-
var count = 0
71-
}
72-
73-
class MyState: SwiftCrossUI.ObservableObject {
74-
@SwiftCrossUI.Published
75-
var count = 0
76-
@SwiftCrossUI.Published
77-
var publishedNestedState = NestedState()
78-
var unpublishedNestedState = NestedState()
79-
}
80-
81-
let state = MyState()
82-
var observedChange = false
83-
let cancellable = state.didChange.observe {
84-
observedChange = true
85-
}
86-
87-
// Ensures that published value type mutation triggers observation
88-
observedChange = false
89-
state.count += 1
90-
XCTAssert(observedChange, "Expected value type mutation to trigger observation")
91-
92-
// Ensure that published nested ObservableObject triggers observation
93-
observedChange = false
94-
state.publishedNestedState.count += 1
95-
XCTAssert(observedChange, "Expected nested published observable object mutation to trigger observation")
96-
97-
// Ensure that replacing published nested ObservableObject triggers observation
98-
observedChange = false
99-
state.publishedNestedState = NestedState()
100-
XCTAssert(observedChange, "Expected replacing nested published observable object to trigger observation")
101-
102-
// Ensure that replaced published nested ObservableObject triggers observation
103-
observedChange = false
104-
state.publishedNestedState.count += 1
105-
XCTAssert(observedChange, "Expected replaced nested published observable object mutation to trigger observation")
106-
107-
// Ensure that non-published nested ObservableObject doesn't trigger observation
108-
observedChange = false
109-
state.unpublishedNestedState.count += 1
110-
XCTAssert(!observedChange, "Expected nested unpublished observable object mutation to not trigger observation")
111-
112-
// Ensure that cancelling the observation prevents future observations
113-
cancellable.cancel()
114-
observedChange = false
115-
state.count += 1
116-
XCTAssert(!observedChange, "Expected mutation not to trigger cancelled observation")
117-
}
118-
11973
#if canImport(AppKitBackend)
120-
// TODO: Create mock backend so that this can be tested on all platforms. There's
121-
// nothing AppKit-specific about it.
122-
func testThrottledStateObservation() async {
123-
class MyState: SwiftCrossUI.ObservableObject {
124-
@SwiftCrossUI.Published
125-
var count = 0
126-
}
127-
128-
/// A thread-safe count.
129-
actor Count {
130-
var count = 0
131-
132-
func update(_ action: (Int) -> Int) {
133-
count = action(count)
134-
}
135-
}
136-
137-
// Number of mutations to perform
138-
let mutationCount = 20
139-
// Length of each fake state update
140-
let updateDuration = 0.02
141-
// Delay between observation-causing state mutations
142-
let mutationGap = 0.01
143-
144-
let state = MyState()
145-
let updateCount = Count()
146-
147-
let backend = await AppKitBackend()
148-
let cancellable = state.didChange.observeAsUIUpdater(backend: backend) {
149-
Task {
150-
await updateCount.update { $0 + 1 }
151-
}
152-
// Simulate an update of duration `updateDuration` seconds
153-
Thread.sleep(forTimeInterval: updateDuration)
154-
}
155-
_ = cancellable // Silence warning about cancellable being unused
156-
157-
let start = ProcessInfo.processInfo.systemUptime
158-
for _ in 0..<mutationCount {
159-
state.count += 1
160-
try? await Task.sleep(for: .seconds(mutationGap))
161-
}
162-
let elapsed = ProcessInfo.processInfo.systemUptime - start
163-
164-
// Compute percentage of main thread's time taken up by updates.
165-
let ratio = Double(await updateCount.count) * updateDuration / elapsed
166-
XCTAssert(
167-
ratio <= 0.85,
168-
"""
169-
Expected throttled updates to take under 85% of the main \
170-
thread's time. Took \(Int(ratio * 100))%
171-
"""
172-
)
173-
}
174-
74+
@Test("Ensure that a basic view has the expected dimensions under AppKitBackend")
17575
@MainActor
17676
func testBasicLayout() async throws {
17777
let backend = AppKitBackend()
@@ -200,31 +100,31 @@ final class SwiftCrossUITests: XCTestCase {
200100
backend.setSize(of: view, to: result.size.size)
201101
backend.setSize(ofWindow: window, to: result.size.size)
202102

203-
XCTAssertEqual(
204-
result.size,
205-
ViewSize(fixedSize: SIMD2(92, 96)),
103+
#expect(
104+
result.size == ViewSize(fixedSize: SIMD2(92, 96)),
206105
"View update result mismatch"
207106
)
208107

209-
XCTAssert(
108+
#expect(
210109
result.preferences.onOpenURL == nil,
211110
"onOpenURL not nil"
212111
)
213112
}
214113

114+
/// Snapshots an AppKit view to a TIFF image.
215115
@MainActor
216116
static func snapshotView(_ view: NSView) throws -> Data {
217117
view.wantsLayer = true
218118
view.layer?.backgroundColor = CGColor.white
219119

220120
guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else {
221-
throw XCTError(message: "Failed to create bitmap backing")
121+
throw TestError(message: "Failed to create bitmap backing")
222122
}
223123

224124
view.cacheDisplay(in: view.bounds, to: bitmap)
225125

226126
guard let data = bitmap.tiffRepresentation else {
227-
throw XCTError(message: "Failed to create tiff representation")
127+
throw TestError(message: "Failed to create tiff representation")
228128
}
229129

230130
return data

0 commit comments

Comments
 (0)