Test iOS Combine Cancellation and Object Lifetimes on a Cloud Mac
If search results still update state after you leave the screen, or requests multiply each time you reopen it, the cause is often a Combine subscription that lives too long—not a slow network. When running regression tests on a cloud Mac, start by replacing the real network with a controlled event source. Establish when subscriptions begin, when they are canceled, and which objects should be released before reconnecting the actual service. This makes failures reproducible without waiting for remote responses.
Separate the cancellation contract into distinct guarantees
Cancellation involves at least three distinct outcomes: downstream stops receiving values, upstream receives a cancellation signal, and the underlying task stops. The first two can be tested through the publisher pipeline. The third requires inspecting the specific task implementation; you cannot infer it from the subscription's state.
Define four acceptance criteria for the screen: values remain unchanged after cancellation, repeated cancellation performs cleanup only once, the screen model is released, and an old request cannot overwrite a newer request's result. Test normal completion separately, because a completion notification is not a cancellation notification.
cancel is not a rollback. State already written, files already persisted, and server-side operations already committed before cancellation are not automatically undone.
First, pin the Xcode toolchain and add the tests to your existing XCTest unit test target. The code below depends only on Combine and XCTest. It requires neither a simulator UI nor permission to access the network.
Check cancellation propagation with a synchronous event source
Add the following code to the test target. The counter here is for testing only; do not use a global counter as cancellation state in production code. The test sends one value, cancels twice, and then sends another value. Its three assertions check delivery, cancellation propagation, and isolation from values sent after cancellation.
import Combine
import XCTest
final class SubscriptionLifetimeTests: XCTestCase {
func testCancelStopsDeliveryAndPropagatesOnce() {
let source = PassthroughSubject<Int, Never>()
var received: [Int] = []
var cancellations = 0
let token = source
.handleEvents(receiveCancel: {
cancellations += 1
})
.sink { value in
received.append(value)
}
source.send(1)
XCTAssertEqual(received, [1])
token.cancel()
token.cancel()
source.send(2)
XCTAssertEqual(cancellations, 1)
XCTAssertEqual(received, [1])
}
}
PassthroughSubject delivers synchronously in this pipeline because there are no scheduler changes, so assertions can follow each operation immediately. Do not add sleep to “give the system time to process.” That only hides an unclear event order.
This test verifies the example pipeline. When integrating your own service, inject the event source into the business model and make the same assertions against the model's public state. Testing a pipeline unrelated to the product is not enough.
Check ownership, not just cancel
A typical retain cycle occurs when the model holds an AnyCancellable and the sink closure strongly captures the model. Expecting the model's deinitialization to release the subscription then creates a circular dependency: neither can be released first. The following example breaks the cycle with a weak capture and uses a weak reference to verify that the object is actually released.
private final class ScreenModel {
var value = 0
private var token: AnyCancellable?
init(source: PassthroughSubject<Int, Never>) {
token = source.sink { [weak self] value in
self?.value = value
}
}
}
extension SubscriptionLifetimeTests {
func testModelReleaseCancelsSubscription() {
let source = PassthroughSubject<Int, Never>()
var cancellations = 0
let publisher = source.handleEvents(receiveCancel: {
cancellations += 1
})
var token: AnyCancellable?
weak var weakModel: ScreenModel?
do {
let model = ScreenModel(source: source)
weakModel = model
token = publisher.sink { [weak model] value in
model?.value = value
}
}
XCTAssertNil(weakModel)
token?.cancel()
XCTAssertEqual(cancellations, 1)
}
}
The second test observes model release and cancellation of the external subscription separately; it does not treat them as a single action. The model's internal subscription is canceled when the model is released. The external token is still held by the test and must be ended explicitly. When moving this into a real project, add a cancellation observation point for the internal subscription and assert each subscription separately.
Weak captures are not a universal fix, either. If a task must continue across screens, a longer-lived service should own it, while the screen subscribes only to progress. This avoids accidentally stopping the task when the screen is released.
Add identity checks to asynchronous state updates
After you cancel search A and start search B, work already queued on another execution queue may still attempt to write back to state. Do not treat “cancellation was called” as a state-safety barrier.
Assign each request a monotonically increasing identifier and check it before writing back. Keep identifier changes and state writes within the same actor or serial execution context. Fix the test order as follows: start A, start B, return B, and finally return A. The final state must still belong to B.
Add a screen-exit scenario as well: start a request, leave the screen, and trigger a late response. Verify that no visible state is updated. If the business requirements allow work to continue in the background, assert that the service state updates while screen callbacks stop. These are separate checks.
When receive(on:) is involved, verify the synchronous core first, then test the scheduling boundary. Use a controllable scheduler to advance the queue. If that infrastructure is unavailable, wait for an explicit execution-completion signal. Waiting for an arbitrary interval does not establish that no late events remain.
Turn the tests into diagnosable regression checks
For each failing test case, record the request identifier, event order, cancellation count, and final state. Do not record request tokens or real user data. Change only one condition at a time: verify a single subscription first, then cover rapid resubscription, normal completion, termination with an error, and screen release.
When connecting a real network task, check separately whether the cancellation callback invokes the underlying cancel and what final state the task reaches. Do not declare success merely because the server returned no result. A race between cancellation and completion should allow termination orders that satisfy the contract, but it must not allow business-state updates after termination.
Finally, deliberately break the implementation to validate the tests. Removing the weak capture should expose the retain cycle; removing the request-identifier check should make the out-of-order response test fail. Only when the tests catch deliberately injected defects do they demonstrate that they protect the actual boundaries, rather than merely making the pipeline show a passing result.
Frequently asked questions
Does cancel always stop the network request?
No. Stopping the underlying task depends on the publisher implementation. Separately verify that cancellation reaches the task and check the task's state.
How can these tests avoid fixed sleeps?
Drive synchronous events with PassthroughSubject. For asynchronous delivery, use a controllable scheduler or explicit completion signals instead of inferring silence from a sleep.
Reserve a cloud Mac for your next build
Choose a model, location, and rental period, then run your own development tools on a dedicated physical node. Availability is confirmed in real time by the console.