How do you test tvOS focus and remote input, and watchOS connectivity and complications, when UI testing on those platforms is limited?

22.6.2 · L5 · Interview

Predict first

Does a tvOS focus test using XCUIRemote and hasFocus compile for tvOS?

import XCTest

final class FocusTests: XCTestCase {
    @MainActor func testMoveFocus() {
        let app = XCUIApplication()
        app.launch()
        XCUIRemote.shared.press(.right)
        XCTAssertTrue(app.buttons["Play"].hasFocus)
        XCUIRemote.shared.press(.select)
    }
}
  • Yes
  • No, there is no remote API

Predict first

Is the same XCUIRemote test code valid on iOS?

import XCTest

final class FocusTests: XCTestCase {
    @MainActor func testMoveFocus() {
        let app = XCUIApplication()
        app.launch()
        XCUIRemote.shared.press(.right)
        XCTAssertTrue(app.buttons["Play"].hasFocus)
        XCUIRemote.shared.press(.select)
    }
}
  • Yes, it is shared
  • No, XCUIRemote does not exist on iOS

Predict first

A sync service takes a fake phone link. What do the reachable and unreachable cases do?

import Foundation

protocol PhoneLink { var isReachable: Bool { get }; func send(_ message: [String: String]) throws }

struct SyncService {
    let link: PhoneLink
    func syncSteps(_ n: Int) -> String {
        guard link.isReachable else { return "queued" }
        do { try link.send(["steps": "\(n)"]); return "sent" } catch { return "failed" }
    }
}
final class FakeLink: PhoneLink {
    var isReachable: Bool; var sent: [[String: String]] = []
    init(reachable: Bool) { isReachable = reachable }
    func send(_ message: [String: String]) throws { sent.append(message) }
}

let online = FakeLink(reachable: true), offline = FakeLink(reachable: false)
print(SyncService(link: online).syncSteps(500), online.sent)
print(SyncService(link: offline).syncSteps(500), offline.sent.count)
  • sent [["steps": "500"]], then queued 0
  • sent, then sent 1
  • queued, then failed

Mental model. When the road is closed, you test the engine on the bench. Keep the driving logic in something you can run on a bench, and take the car out only for a short drive.

tvOS UI tests use the same XCUIApplication but with the remote as the input: XCUIRemote.shared.press(.right) (and .up, .down, .select, .menu, .playPause) and the element property hasFocus to assert where focus landed. XCUIRemote exists only on tvOS (the check below compiles for tvOS and is an error on iOS), so keep those tests in the tvOS target. A focus test confirms the navigation that matters: that you can reach each control, that focus returns to a sensible place after a screen closes, and that a focus guide fixes a dead end (Q420). Test the preferred focus and focus environment logic by calling the focus APIs directly on a hosted view hierarchy, and keep most behaviour in a model.

watchOS has far less UI-test tooling and slower, flakier simulators, so push as much as possible into logic tests. Hide Watch Connectivity behind a protocol (isReachable, send, transferUserInfo) and test your sync service against a fake, including the offline and failing paths (the check below), because real WCSession cannot be driven from a unit test (Q410). Test complications and widgets by putting the decision of what entries to produce in a pure function (entries(now:data:) -> [Entry]) and asserting on it with a fixed now (the provider then only calls it, Q411). Test extended runtime and background tasks through the scheduling logic you own, not the system’s delivery, which needs a device. On every platform, add a handful of device or simulator smoke tests for launch and the critical path in CI, and treat the rest as unit tests.

XCUIRemote and hasFocus are the tvOS UI-testing APIs for moving and reading focus.

The remote is a tvOS concept, so the compiler cannot find the type on iOS.

The logic is plain Swift behind a protocol, so both paths are testable without a watch.

Common traps

  • Testing Watch Connectivity through the real session. It cannot be driven from a unit test; wrap it and fake it.
  • Writing logic inside a TimelineProvider method. It needs system-created contexts, so move the decision into a pure function.

They might follow up with

  • How do you assert focus on tvOS? Press remote buttons and check hasFocus on the expected element.
  • What stays device-only? Real delivery of background tasks, push, Watch Connectivity reachability and system refresh budgets.

Related questions

  • 22.6.1 How do you test SwiftUI: view models, hosting a view, ImageRenderer snapshots and ViewInspector?
  • 22.5.2 How do you write UI tests that stay stable: accessibility identifiers, page objects, launch arguments…
  • 20.4.1 How does the tvOS focus engine work: focusable items, preferred focus, focus guides, and SwiftUI’s focusable…
  • 19.6.2 How does Watch Connectivity work: the transfer types, reachability, and how do you choose between them?
  • 19.6.3 How have watch complications moved from ClockKit to WidgetKit, and what are accessory families and the Smart…

← How do you test SwiftUI: view models, hosting a view, ImageRenderer…