tvOS Testing: Focus Engine, Remote Simulation, and XCTest on Apple TV
tvOS apps run on Apple TV, where the primary input is a Siri Remote rather than a touchscreen. Testing a tvOS app means verifying that focus moves correctly between elements, that button presses trigger the right actions, and that your layout works on a 1080p or 4K screen. XCTest supports tvOS with the Apple TV Simulator, giving you unit tests and limited UI testing.
Project Setup
Add a tvOS test target in Xcode:
- File → New → Target → tvOS → Unit Testing Bundle
- Set the Host Application to your Apple TV app target
- Select the Apple TV Simulator as the destination
For UI tests, add a tvOS UI Testing Bundle target.
Unit Tests
Business logic, data models, content loading, and view models are all fully testable with XCTest on tvOS. The same patterns as iOS apply:
import XCTest
@testable import StreamingApp
class ContentLoaderTests: XCTestCase {
func testCategoryFilteringByGenre() {
let items = [
ContentItem(title: "Movie A", genre: .action),
ContentItem(title: "Movie B", genre: .comedy),
ContentItem(title: "Movie C", genre: .action)
]
let filtered = ContentLoader.filter(items, by: .action)
XCTAssertEqual(filtered.count, 2)
XCTAssertTrue(filtered.allSatisfy { $0.genre == .action })
}
func testPlaybackPositionResumeLogic() {
let progress = PlaybackProgress(position: 3600, duration: 7200)
XCTAssertTrue(progress.shouldResume)
XCTAssertEqual(progress.resumePosition, 3600)
}
}The Focus Engine
tvOS doesn't have a touchscreen. Navigation is driven by the Focus Engine — Apple's system that determines which UI element has focus and moves focus in response to directional swipes on the Siri Remote.
Focus Engine Rules
- Only
isFocusableelements can receive focus (UIKit sets this automatically for interactive elements) - Focus moves in 4 directions: up, down, left, right
- When a view is not reachable in a direction, focus stays on the current element
- The focused element is styled differently (TVUIKit provides the standard "bubble" effect)
Testing Focus Behavior in UI Tests
In XCUITest on tvOS, you simulate remote input using XCUIRemote:
import XCTest
class FocusNavigationTests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}
func testFocusMovesToSecondRowOnDownSwipe() {
let remote = XCUIRemote.shared
// Assume first row is focused on launch
XCTAssertTrue(app.cells["row-0-col-0"].hasFocus)
remote.press(.down)
XCTAssertTrue(app.cells["row-1-col-0"].waitForExistence(timeout: 2))
XCTAssertTrue(app.cells["row-1-col-0"].hasFocus)
}
func testSelectFocusedItem() {
let remote = XCUIRemote.shared
app.cells["Featured Movie"].press(forDuration: 0)
remote.press(.select)
XCTAssertTrue(app.buttons["Play"].waitForExistence(timeout: 3))
}
}XCUIRemote Buttons
| Button | Constant |
|---|---|
| Up | .up |
| Down | .down |
| Left | .left |
| Right | .right |
| Select (click) | .select |
| Menu | .menu |
| Play/Pause | .playPause |
| Home | .home |
Checking Focus Programmatically
Use hasFocus on XCUIElement:
XCTAssertTrue(app.buttons["Play"].hasFocus)
XCTAssertFalse(app.buttons["Pause"].hasFocus)In UIKit code, observe focus changes with didUpdateFocus(in:with:):
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
super.didUpdateFocus(in: context, with: coordinator)
if context.nextFocusedView == self {
// element just gained focus
}
}Testing Focus with preferredFocusEnvironments
If your view controller controls focus order explicitly, test that the override works:
class MenuViewController: UIViewController {
@IBOutlet weak var playButton: UIButton!
@IBOutlet weak var settingsButton: UIButton!
override var preferredFocusEnvironments: [UIFocusEnvironment] {
return [playButton, settingsButton]
}
}In a UI test, launch the screen and immediately assert which element has focus:
func testPlayButtonReceivesFocusOnLaunch() {
app.launch()
XCTAssertTrue(app.buttons["Play"].waitForExistence(timeout: 3))
XCTAssertTrue(app.buttons["Play"].hasFocus)
}Testing Long Press (Context Menus)
Long press on the select button triggers context menus on tvOS:
func testLongPressShowsContextMenu() {
let remote = XCUIRemote.shared
// Navigate to a content item
app.cells["Movie Title"].press(forDuration: 1.0)
XCTAssertTrue(app.buttons["Add to Watchlist"].waitForExistence(timeout: 3))
}Testing Scroll Views and Collection Views
tvOS collection views scroll when the focused element reaches the edge. Test that scrolling exposes off-screen content:
func testScrollRevealsFifthItem() {
let remote = XCUIRemote.shared
// Press right 4 times to move from item 0 to item 4
for _ in 0..<4 {
remote.press(.right)
}
XCTAssertTrue(app.cells["item-4"].waitForExistence(timeout: 5))
XCTAssertTrue(app.cells["item-4"].hasFocus)
}CI Setup
Run tvOS tests in Xcode Cloud or GitHub Actions using the Apple TV Simulator:
xcodebuild test \
-scheme StreamingApp \
-destination <span class="hljs-string">'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' \
-resultBundlePath TestResults.xcresultBoot the simulator before the test run to avoid cold-start timeouts:
xcrun simctl boot "Apple TV 4K (3rd generation)"
xcrun simctl bootstatus <span class="hljs-string">"Apple TV 4K (3rd generation)" -bWhat Works vs. What Doesn't
| Test type | Support |
|---|---|
| Unit tests (logic, models) | ✅ Full |
| XCUIRemote navigation | ✅ Simulator only |
hasFocus assertions |
✅ |
| Physical Apple TV UI testing | ❌ Not supported |
| TVML/TVMLKit testing | ⚠️ No official support |
| Video playback assertions | ⚠️ Limited (AVKit doesn't expose much) |
Physical Apple TV does not support XCUITest. All automated UI tests run in the simulator only.
Key Points
- Use
XCUIRemote.sharedto simulate D-pad, Select, Menu, and Play/Pause button presses - Assert focus with
element.hasFocus— this is the core assertion for tvOS navigation tests - Override
preferredFocusEnvironmentsto control focus order; test that the override is respected - Test long-press context menus with
press(forDuration: 1.0) - Physical Apple TV does not support UI testing — simulator only
- Unit tests for business logic work identically to iOS — abstract platform dependencies behind protocols