A player passes every automated test on a cloud Mac, then keeps playing through the speaker when a user unplugs their headphones. The decoder may be fine; what went untested is the decision the player makes after a route change. System audio interruptions raise a similar issue: an interruption ending does not mean the user wants playback to resume. Separate system notifications from playback decisions, and you can test that logic reliably on a build node without headphones.
Define events and expected behavior first
Write down the product rules before implementing notification handlers. This article uses conservative rules: an interruption pauses playback and records whether it was playing beforehand. Playback resumes only if the ending notification allows it and that intent remains. If the user explicitly pauses during the interruption, the earlier intent to resume must be cleared. Disconnecting an output device also pauses playback immediately; the new route’s name alone cannot tell you whether the user wants to continue listening through the speaker.
| Input event | Playback state | Intent to resume |
|---|---|---|
| Interruption begins during playback | Paused | Record that playback was active |
| Interruption ends and resuming is allowed | Determined by the saved intent | Clear |
| User explicitly pauses | Paused | Clear |
| Previous output device becomes unavailable | Paused | Clear |
“Previous output device becomes unavailable” is a specific event reason, not a label for every route change. Switching to another device that is already connected, for example, should not be treated like unplugging headphones without an explicit product decision.
Isolate playback decisions in a pure state machine
Leave notification parsing in an adapter and pass only well-defined events to the core logic. The following types can live in the app target and be imported by the test target. isPlaying represents the playback state the app wants; an outer layer should make the actual player calls when that state changes.
enum AudioEvent {
case interruptionBegan
case interruptionEnded(shouldResume: Bool)
case outputDisconnected
case userPaused
}
struct PlaybackState {
var isPlaying = false
var resumeAfterInterruption = false
mutating func apply(_ event: AudioEvent) {
switch event {
case .interruptionBegan:
resumeAfterInterruption = isPlaying
isPlaying = false
case .interruptionEnded(let shouldResume):
isPlaying = resumeAfterInterruption && shouldResume
resumeAfterInterruption = false
case .outputDisconnected, .userPaused:
isPlaying = false
resumeAfterInterruption = false
}
}
}
This example covers only one interruption at a time. If the app handles multiple playback sessions simultaneously or has a queue that remains actionable while paused, expand the state model before deciding how events should be ordered. Do not make one Boolean carry the semantics of every session.
Translate system notifications into events
The adapter observes AVAudioSession.interruptionNotification and reads the interruption type from userInfo. When the interruption ends, it reads the resume option and passes the result to the state machine. For AVAudioSession.routeChangeNotification, inspect the reason and translate only oldDeviceUnavailable into outputDisconnected. Log missing or unrecognized fields for investigation; do not assume they mean “resume allowed.”
Notification callbacks and UI actions may arrive from different execution contexts. Feed events serially to the same state owner, then compare the old and new states to call pause or play. Otherwise, when “user paused” and “interruption ended” occur concurrently, callback arrival order could inadvertently override the user’s intent.
Run edge-case tests on the build node
Start with XCTest coverage for two easy-to-miss paths: an idle player must not start on its own after an interruption, and a user pause during an interruption must prevent automatic resumption. These tests need neither real headphones nor system notifications delivered at a particular time.
import XCTest
final class PlaybackStateTests: XCTestCase {
func testInterruptionDoesNotStartIdlePlayer() {
var state = PlaybackState()
state.apply(.interruptionBegan)
state.apply(.interruptionEnded(shouldResume: true))
XCTAssertFalse(state.isPlaying)
}
func testUserPauseCancelsResume() {
var state = PlaybackState(isPlaying: true)
state.apply(.interruptionBegan)
state.apply(.userPaused)
state.apply(.interruptionEnded(shouldResume: true))
XCTAssertFalse(state.isPlaying)
}
func testDisconnectedOutputStopsPlayback() {
var state = PlaybackState(isPlaying: true)
state.apply(.outputDisconnected)
XCTAssertFalse(state.isPlaying)
XCTAssertFalse(state.resumeAfterInterruption)
}
}
On a cloud Mac, first check the project and schemes with xcodebuild -list -project App.xcodeproj, then run xcodebuild test -project App.xcodeproj -scheme App -destination 'platform=iOS Simulator,name=<已安装的设备名称>' using the scheme your team actually shares. Replace the example project name, scheme, and device name; use xcrun simctl list devices available to see which devices are available. The test target must be able to access the state-machine types, and the scheme must include that test target.
Passing unit tests show only that a given event sequence produces the expected decision. They do not prove that system notifications will arrive or that audio actually plays through a particular device.
Complete integration checks on a device
Run at least four checks on a physical device: unplug headphones during playback and confirm the speaker does not take over unexpectedly; trigger a controlled system audio interruption and observe its start and end notifications; pause manually during the interruption and confirm playback stays paused afterward; reconnect an output device and confirm the connection alone does not start playback. Record the event types, reasons, and final playback state. Do not put user content or access credentials in the logs.
If unit tests pass but the device still misbehaves, first confirm the notification observer remains active for the session’s lifetime. Then check notification field parsing, event delivery order, and whether the player’s actual state stays in sync with PlaybackState. In particular, distinguish “the state machine calls for a pause” from “the player actually paused”: tests establish the former; adapter checks and device verification are needed for the latter.
Keep the checks in the merge process
Before merging, confirm that state-machine tests cover idle playback, interruptions, user pauses, and device disconnections; that the notification adapter handles unknown fields conservatively; that every event enters the same serial execution path; and that the team has recorded at least one verification result from a target device. A cloud Mac is well suited to repeatedly running deterministic state tests, but actual audio routing still needs to be observed on a device. Keeping these two layers of verification separate makes it possible to tell whether a failure lies in the product rules, notification translation, or audio output itself.
Frequently asked questions
Can a simulator unit test verify a real headset disconnection?
No. It verifies the app's decision for an injected event. Check notification delivery and the actual output route on a physical device.
Should playback always resume when an interruption ends?
No. Resume only when playback was active before the interruption, resumption is permitted, and the user has not chosen to pause.
Move your next Xcode build to a dedicated physical machine.
Choose an Apple Silicon model, location, and rental term, then connect remotely to run your own build workflow. Availability is confirmed in the console when you place your order.