Skip to content

Commit 669f438

Browse files
authored
Merge pull request #13 from ra1028/feat/test-interface
feat: Add a new testing interface `AtomContext/waitUntilNextUpdate(timeout:)`
2 parents 59b2ad1 + c0a3c26 commit 669f438

File tree

9 files changed

+350
-271
lines changed

9 files changed

+350
-271
lines changed

Sources/Atoms/Context/AtomTestContext.swift

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import Combine
2+
import Foundation
3+
14
/// A context structure that to read, watch, and otherwise interacting with atoms in testing.
25
///
36
/// This context has an internal Store that manages atoms, so it can be used to test individual
@@ -19,6 +22,58 @@ public struct AtomTestContext: AtomWatchableContext {
1922
nonmutating set { container.onUpdate = newValue }
2023
}
2124

25+
/// Waits until any of atoms watched through this context is updated for up to
26+
/// the specified timeout, and then return a boolean value indicating whether an update is done.
27+
///
28+
/// - Parameter interval: The maximum timeout interval that this function can wait until
29+
/// the next update. The default timeout interval is `60`.
30+
/// - Returns: A boolean value indicating whether an update is done.
31+
@discardableResult
32+
public func waitUntilNextUpdate(timeout interval: TimeInterval = 60) async -> Bool {
33+
let updates = AsyncStream<Void> { continuation in
34+
let cancellable = container.notifier.sink(
35+
receiveCompletion: { completion in
36+
continuation.finish()
37+
},
38+
receiveValue: {
39+
continuation.yield()
40+
}
41+
)
42+
43+
let box = UnsafeUncheckedSendableBox(cancellable)
44+
continuation.onTermination = { termination in
45+
switch termination {
46+
case .cancelled:
47+
box.unboxed.cancel()
48+
49+
case .finished:
50+
break
51+
52+
@unknown default:
53+
break
54+
}
55+
}
56+
}
57+
58+
return await withTaskGroup(of: Bool.self) { group in
59+
group.addTask {
60+
var iterator = updates.makeAsyncIterator()
61+
await iterator.next()
62+
return true
63+
}
64+
65+
group.addTask {
66+
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
67+
return false
68+
}
69+
70+
let didUpdate = await group.next() ?? false
71+
group.cancelAll()
72+
73+
return didUpdate
74+
}
75+
}
76+
2277
/// Accesses the value associated with the given atom without watching to it.
2378
///
2479
/// This method returns a value for the given atom. Even if you access to a value with this method,
@@ -199,7 +254,7 @@ private extension AtomTestContext {
199254
final class Container {
200255
private let storeContainer = StoreContainer()
201256
private var relationshipContainer = RelationshipContainer()
202-
257+
let notifier = PassthroughSubject<Void, Never>()
203258
var overrides: AtomOverrides
204259
var observers = [AtomObserver]()
205260
var onUpdate: (() -> Void)?
@@ -227,6 +282,7 @@ private extension AtomTestContext {
227282
shouldNotifyAfterUpdates: shouldNotifyAfterUpdates
228283
) { [weak self] in
229284
self?.onUpdate?()
285+
self?.notifier.send()
230286
}
231287
}
232288
}

Tests/AtomsTests/Atom/ObservableObjectAtomTests.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,19 @@ final class ObservableObjectAtomTests: XCTestCase {
2121
}
2222
}
2323

24-
func test() {
24+
func test() async {
2525
let atom = TestAtom(value: 100)
2626
let context = AtomTestContext()
2727
let object = context.watch(atom)
28-
let expectation = expectation(description: "test")
2928
var updatedValue: Int?
3029

3130
context.onUpdate = {
3231
updatedValue = object.value
33-
expectation.fulfill()
3432
}
3533

3634
object.value = 200
35+
await context.waitUntilNextUpdate()
3736

38-
wait(for: [expectation], timeout: 1)
3937
XCTAssertEqual(updatedValue, 200)
4038
}
4139
}

Tests/AtomsTests/Context/AtomTestContext.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,25 @@ final class AtomTestContextTests: XCTestCase {
3131
XCTAssertTrue(isCalled)
3232
}
3333

34+
func testWaitUntilNextUpdate() async {
35+
let atom = TestStateAtom(defaultValue: 0)
36+
let context = AtomTestContext()
37+
38+
context.watch(atom)
39+
40+
Task {
41+
context[atom] = 1
42+
}
43+
44+
let didUpdate0 = await context.waitUntilNextUpdate()
45+
46+
XCTAssertTrue(didUpdate0)
47+
48+
let didUpdate1 = await context.waitUntilNextUpdate(timeout: 1)
49+
50+
XCTAssertFalse(didUpdate1)
51+
}
52+
3453
func testOverride() {
3554
let atom0 = TestValueAtom(value: 100)
3655
let atom1 = TestValueAtom(value: 200)

Tests/AtomsTests/Core/Hook/AsyncSequenceHookTests.swift

Lines changed: 63 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -51,73 +51,64 @@ final class AsyncSequenceHookTests: XCTestCase {
5151
XCTAssertEqual(hook.value(context: context).value, 100)
5252
}
5353

54-
func testUpdate() {
54+
func testUpdate() async {
5555
let pipe = AsyncThrowingStreamPipe<Int>()
5656
let hook = AsyncSequenceHook { _ in pipe.stream }
5757
let atom = TestAtom(key: 0, hook: hook)
5858
let context = AtomTestContext()
5959

60-
XCTContext.runActivity(named: "Initially suspending") { _ in
60+
do {
6161
XCTAssertTrue(context.watch(atom).isSuspending)
6262
}
6363

64-
XCTContext.runActivity(named: "Value") { _ in
65-
let expectation = expectation(description: "Update")
66-
context.onUpdate = expectation.fulfill
64+
do {
65+
// Value
6766
pipe.continuation.yield(0)
67+
await context.waitUntilNextUpdate()
6868

69-
wait(for: [expectation], timeout: 1)
7069
XCTAssertEqual(context.watch(atom).value, 0)
7170
}
7271

73-
XCTContext.runActivity(named: "Error") { _ in
74-
let expectation = expectation(description: "Update")
75-
context.onUpdate = expectation.fulfill
72+
do {
73+
// Failure
7674
pipe.continuation.finish(throwing: URLError(.badURL))
75+
await context.waitUntilNextUpdate()
7776

78-
wait(for: [expectation], timeout: 1)
7977
XCTAssertEqual(context.watch(atom).error as? URLError, URLError(.badURL))
8078
}
8179

82-
XCTContext.runActivity(named: "Value after finished") { _ in
83-
let expectation = expectation(description: "Update")
84-
expectation.isInverted = true
85-
context.onUpdate = expectation.fulfill
80+
do {
81+
// Yield value after finish
8682
pipe.continuation.yield(1)
83+
let didUpdate = await context.waitUntilNextUpdate(timeout: 1)
8784

88-
wait(for: [expectation], timeout: 1)
89-
XCTAssertEqual(context.watch(atom).error as? URLError, URLError(.badURL))
85+
XCTAssertFalse(didUpdate)
9086
}
9187

92-
XCTContext.runActivity(named: "Value after termination") { _ in
93-
context.unwatch(atom)
88+
do {
89+
// Yield value after termination
9490
pipe.reset()
95-
context.watch(atom)
9691
context.unwatch(atom)
9792

98-
let expectation = expectation(description: "Update")
99-
expectation.isInverted = true
100-
context.onUpdate = expectation.fulfill
10193
pipe.continuation.yield(0)
94+
let didUpdate = await context.waitUntilNextUpdate(timeout: 1)
10295

103-
wait(for: [expectation], timeout: 1)
96+
XCTAssertFalse(didUpdate)
10497
}
10598

106-
XCTContext.runActivity(named: "Error after termination") { _ in
107-
context.unwatch(atom)
99+
do {
100+
// Yield error after termination
108101
pipe.reset()
109-
context.watch(atom)
110102
context.unwatch(atom)
111103

112-
let expectation = expectation(description: "Update")
113-
expectation.isInverted = true
114-
context.onUpdate = expectation.fulfill
115104
pipe.continuation.finish(throwing: URLError(.badURL))
105+
let didUpdate = await context.waitUntilNextUpdate(timeout: 1)
116106

117-
wait(for: [expectation], timeout: 1)
107+
XCTAssertFalse(didUpdate)
118108
}
119109

120-
XCTContext.runActivity(named: "Override") { _ in
110+
do {
111+
// Override
121112
context.override(atom) { _ in .success(100) }
122113

123114
XCTAssertEqual(context.watch(atom).value, 100)
@@ -129,50 +120,60 @@ final class AsyncSequenceHookTests: XCTestCase {
129120
let hook = AsyncSequenceHook { _ in pipe.stream }
130121
let atom = TestAtom(key: 0, hook: hook)
131122
let context = AtomTestContext()
132-
var updateCount = 0
133-
134-
context.onUpdate = { updateCount += 1 }
135123

136-
// Refresh
137-
138-
XCTAssertTrue(context.watch(atom).isSuspending)
139-
140-
Task {
141-
pipe.continuation.yield(0)
142-
pipe.continuation.finish(throwing: nil)
124+
do {
125+
XCTAssertTrue(context.watch(atom).isSuspending)
143126
}
144127

145-
pipe.reset()
146-
let phase0 = await context.refresh(atom)
128+
do {
129+
// Refresh
130+
var updateCount = 0
131+
context.onUpdate = { updateCount += 1 }
132+
pipe.reset()
147133

148-
XCTAssertEqual(phase0.value, 0)
149-
XCTAssertEqual(updateCount, 1)
134+
Task {
135+
pipe.continuation.yield(0)
136+
pipe.continuation.finish(throwing: nil)
137+
}
150138

151-
// Cancellation
139+
let phase = await context.refresh(atom)
152140

153-
let refreshTask = Task {
154-
await context.refresh(atom)
141+
XCTAssertEqual(phase.value, 0)
142+
XCTAssertEqual(updateCount, 1)
155143
}
156144

157-
Task {
158-
pipe.continuation.yield(1)
159-
refreshTask.cancel()
160-
}
145+
do {
146+
// Cancellation
147+
var updateCount = 0
148+
context.onUpdate = { updateCount += 1 }
149+
pipe.reset()
161150

162-
pipe.reset()
163-
let phase1 = await refreshTask.value
151+
let refreshTask = Task {
152+
await context.refresh(atom)
153+
}
164154

165-
XCTAssertEqual(phase1.value, 1)
166-
XCTAssertEqual(updateCount, 2)
155+
Task {
156+
pipe.continuation.yield(1)
157+
refreshTask.cancel()
158+
}
167159

168-
// Override
160+
let phase = await refreshTask.value
169161

170-
context.override(atom) { _ in .success(200) }
162+
XCTAssertEqual(phase.value, 1)
163+
XCTAssertEqual(updateCount, 1)
164+
}
165+
166+
do {
167+
// Override
168+
var updateCount = 0
169+
context.onUpdate = { updateCount += 1 }
170+
context.override(atom) { _ in .success(200) }
171+
pipe.reset()
171172

172-
pipe.reset()
173-
let phase2 = await context.refresh(atom)
173+
let phase = await context.refresh(atom)
174174

175-
XCTAssertEqual(phase2.value, 200)
176-
XCTAssertEqual(updateCount, 3)
175+
XCTAssertEqual(phase.value, 200)
176+
XCTAssertEqual(updateCount, 1)
177+
}
177178
}
178179
}

0 commit comments

Comments
 (0)