tvOS Testing: Focus Engine, Remote Simulation, and XCTest on Apple TV

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:

  1. File → New → Target → tvOS → Unit Testing Bundle
  2. Set the Host Application to your Apple TV app target
  3. 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 isFocusable elements 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.xcresult

Boot 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)" -b

What 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.shared to 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 preferredFocusEnvironments to 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

Read more