Skip to content

Page Object Pattern for UI Tests (Swift)

A SwiftUI guide for coding agents. Also covers page object pattern, screen object ui tests, chainable xcuitest, reusable login flow test, maintainable ui tests ios, failure screenshot attachment.

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.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-swiftui-guide({ topic: "swiftui-ui-testing-using-page-object-pattern-in-swift" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems