Testing iOS network indicators: the limits of NWPathMonitor
A user taps refresh and sees “Network unavailable,” only for a later retry to succeed. Often, the request layer is not at fault: the app is using an outdated network path status to decide whether a request may proceed. When developing iOS apps on a cloud Mac, you can validate the state model with a deterministic sequence of events first, then use the simulator and a physical device to check system behavior. There is no need to keep disconnecting your remote session to simulate failures.
Separate network hints from request outcomes
NWPathMonitor observes network paths that meet its monitoring criteria. .satisfied does not guarantee that the server is reachable or that authentication is valid. .requiresConnection means a connection needs to be established; it should not be treated as permanently offline.
At the application level, retain at least four states: unknown, path available, connection required, and path unavailable. On initial launch, before the first callback arrives, show an unknown state rather than assuming the device is offline. API timeouts, authentication failures, and server errors belong in the request outcome model; they must not overwrite the path state.
Network status is useful as a hint, not as a master switch that blocks all requests. Let an actual request determine the outcome when a user explicitly retries.
This approach suits apps that need network banners or automatic retry prompts. If a feature only needs to send a single request, handling the request outcome directly is often simpler and does not require a separate monitor.
Build a standalone state model
From the command line on the remote macOS machine, create NetworkStateCheck.swift with the complete contents below. The model does not depend on a real network. A generation counter distinguishes monitor instances. This example assumes serial calls; do not mutate the model concurrently from multiple queues.
enum PathState: Equatable {
case unknown, available, needsConnection, unavailable
}
struct NetworkHint {
private(set) var generation = 0
private(set) var state: PathState = .unknown
private(set) var expensive = false
private(set) var constrained = false
mutating func restart() -> Int {
generation += 1
state = .unknown
expensive = false
constrained = false
return generation
}
mutating func accept(
generation incoming: Int,
state next: PathState,
expensive: Bool = false,
constrained: Bool = false
) {
guard incoming == generation else { return }
state = next
self.expensive = expensive
self.constrained = constrained
}
}
var hint = NetworkHint()
precondition(hint.state == .unknown)
let old = hint.restart()
hint.accept(generation: old, state: .available, expensive: true)
precondition(hint.state == .available && hint.expensive)
let current = hint.restart()
hint.accept(generation: old, state: .unavailable)
precondition(hint.state == .unknown)
hint.accept(generation: current, state: .needsConnection)
precondition(hint.state == .needsConnection)
hint.accept(generation: current, state: .available, constrained: true)
precondition(hint.constrained && !hint.expensive)
hint.accept(generation: current, state: .unavailable)
precondition(hint.state == .unavailable)
hint.accept(generation: current, state: .available)
precondition(hint.state == .available && !hint.constrained)
Run xcrun swift NetworkStateCheck.swift. A successful run produces no output; a failure exits with a nonzero status. This checks your app’s state contract, not the correctness of the system networking stack.
Preserve execution order when wiring up the monitor
In the production adapter, import Network and create NWPathMonitor(). Set pathUpdateHandler before calling start(queue:). Map the three NWPath.Status cases to the model’s enum, passing through isExpensive and isConstrained as well. Map any unknown future enum cases to the unknown state.
Capture the current generation when creating the monitor. Check it again before allowing a callback to update the state model. When restarting monitoring, first increment the generation to invalidate old events, then cancel the old instance, and finally create a new one. Do not start an instance again after it has been canceled.
Keep the monitor lifecycle and model updates within the same serial execution context. If you use the main execution context, check the generation after dispatching the callback there. Otherwise, queued events from an old monitor can still corrupt the new state. Preserve this order when publishing to the UI as well; multiple asynchronous dispatches must not overwrite updates out of sequence.
Do not use requiredInterfaceType: .wifi alone to determine whether the device has network access. It monitors paths matching a specific interface requirement and may miss other available paths. Use that initializer only when the feature genuinely requires a particular interface.
Cover edge cases with an event matrix
Move the script into the project’s unit test target. Create a separate model for each test case and cover at least the following:
- No callback after startup: the state remains unknown, and requests are still allowed.
- Path available but an API request fails: only the request error is updated; the UI does not report a disconnected path.
- Recovery after the path becomes unavailable: the banner disappears without triggering the same application operation again.
- An old event arrives after cancellation: neither the state nor the cost indicator changes.
- The same event arrives more than once: the hint remains consistent, and retry tasks do not accumulate.
- Expensive or constrained status changes: only download policies permitted by the application are adjusted.
isExpensive does not identify one fixed interface type, and isConstrained does not mean the device is completely offline. For large file transfers, you can also use the expensive and constrained network policies in URLSessionConfiguration. Do not silently discard user actions based solely on the hint model.
Distinguish automated tests from device validation
The simulator is useful for checking banners, buttons, and state model integration, but its network behavior depends on the host machine. A changed indicator in the simulator does not prove that cellular handoffs have been validated. Above all, do not disable the cloud Mac’s network interface for testing and lose your remote desktop connection.
On a physical device, add checks for wireless network switching, constrained networks, and returning from the background. Record the sequence of path events, UI changes, and request outcomes for each scenario. A server refusing a connection and an unavailable network path are different scenarios; keep separate evidence for each.
Finally, submit the state model, event test cases, and device validation records. Acceptance does not require the banner to predict network conditions instantly every time. It requires that hints never incorrectly block requests, old monitors never overwrite a new session’s state, and user actions after a failure have a clearly defined outcome.
Frequently asked questions
Does satisfied mean my API is reachable?
No. It indicates a path that meets the monitor’s conditions, not a successful server connection, authentication, or valid response. Use the actual request outcome to determine success.
How do I reject callbacks from a cancelled monitor?
Advance the generation in the same serial execution context before cancelling the old monitor. Tag callbacks with their original generation and accept only current events. Create a new monitor when restarting.
Test your remote Mac with your own tasks.
Rent an exclusively assigned Mac mini M4 and use it through the macOS GUI or command line. Ideal for testing connectivity and workflows before committing. If you need long-running offline jobs or professional external hardware, compare the cost with buying your own Mac.
Rent a dedicated Mac mini