Raw XCUITest code mixes what a test does with how it does it: identifiers and tap sequences leak into every test, and one screen change breaks them all. A Page Object (here, a Screen) owns one screen's identifiers, interactions, and assertions, so tests read as user flows.
A Shared Test Case Base
Move launch and teardown into a base class. The teardown screenshot attaches only for failing tests, which is usually enough to see what state the UI was in.
class UITestCase: XCTestCase {
var app: XCUIApplication!
override func setUp() {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["testing"]
app.launch()
}
override func tearDown() {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.lifetime = .deleteOnSuccess
add(attachment)
app.terminate()
}
}
Screen Objects
Each screen struct hides its identifiers in a private enum and exposes named actions. Every
method returns Self, or the next screen's object when the action navigates, which is what
makes tests chainable.
protocol Screen {
var app: XCUIApplication { get }
}
struct LoginScreen: Screen {
let app: XCUIApplication
private enum Identifiers {
static let email = "email"
static let password = "password"
static let login = "login"
static let error = "error"
}
func typeEmail(_ emailAddr: String) -> Self {
let email = app.textFields[Identifiers.email]
email.tap()
email.typeText(emailAddr)
return self
}
func tapLoginExpectingError() -> Self {
app.buttons[Identifiers.login].tap()
XCTAssertTrue(app.staticTexts[Identifiers.error].waitForExistence(timeout: 5))
return self
}
func tapLogin() -> MessageScreen {
app.buttons[Identifiers.login].tap()
return MessageScreen(app: app)
}
}
The Test Becomes a Sentence
final class LoginTests: UITestCase {
func testLoginFlow() {
LoginScreen(app: app)
.typeEmail("[email protected]")
.typePassword("password")
.tapLoginExpectingError()
.typePassword("pwd")
.tapLogin()
.verifyMessage("Hello World!")
}
}
When the login screen's hierarchy changes, only LoginScreen changes; every test that logs
in keeps compiling. UI tests are expensive and fragile, so they deserve the same structural
care as production code.