Stop Fighting Your UI Tests
How abstraction patterns eliminate the pain of hunting for UI elements
Not all teams test, and if they do, they usually focus on unit tests.
The most common argument against UI tests is that they're slower to run, write, and harder to debug. That's fair.
But there are ways to make the process much smoother. UI tests offer real value and scale well. You can have all critical flows ready as automated tests, then run them against multiple devices and OS versions every day. Try asking that from one QA person.
This article shows you how to speed up your UI testing and get more coverage. You don't even need CI setup: locally running UI tests can help tremendously.
I'll cover the basic abstraction patterns that anyone can implement, plus advanced techniques for handling real-world scenarios with different users and data sets.
1 How we usually write tests
Let's start with how we'd "normally" write UI tests and take it from there.
We'll use Swift and iOS as an example, but this is a universal concept that applies to Android, too.
In the example below, we are working with an app that sells learning courses.
We find a button in a scroll view and tap it to open a new screen, after which the test presses the back button in the navigation-bar to return. Then we check we're back on an overview screen by verifying that overviewLabel exists.
func testMakeSureCourseNavigationWorks() {
let app = XCUIApplication()
// Find button and tap it
app.scrollViews["Courses"].children(matching: .button).tap()
// Press back button
app.navigationBars.buttons.element(boundBy: 0).tap()
// Verify we're on the overview screen
XCTAssert(app.staticTexts["overviewLabel"].exists)
}
But, this "regular" way of writing UI tests becomes hard to comprehend once they grow. Searching and finding elements through identifiers all the time can be a pain and it's slower to make adjustments since a developer would have to read and deduce what each step is doing.
In the end, the cognitive complexity is high for something so small, and the readability becomes worse once we're testing larger flows.
Instead, what if we write UI Tests on a higher abstraction? Something more like a script that's readable for programmers and non-programmers alike.
We can achieve this by putting the element matching and verification methods in tiny methods. Let's call ours verifyUserIsOnCourseOverviewScreen() or navigateToCourseOverview().
We'll update the test into a collection of static methods defining each step.
func testMakeSureCourseNavigationWorks() {
Navigation.navigateToCourseOverview()
Navigation.navigateToPreviousScreen()
Course.verifyUserIsOnCourseOverviewScreen()
}
After our change, the test reads more like English sentences, which makes it easier to understand what is going on without adding comments or playing the mental compiler.
The UI Test details are abstracted. We find and match on the elements once and hide the matching logic into their own functions, so we don't have to worry about that anymore.
1.1 Creating a language
We can create this "language" by placing the element-hunting and their actions inside tiny methods. This prevents us from having to keep searching for elements and it gives more context to the "why" we're testing something.
In our case, these methods can have tiny bodies where we define the actions inside Navigation and Course.
We use enums to mimic a namespace in Swift. Since enums cannot be instantiated in Swift, they are good candidates.
// We define enum namespaces
enum Navigation {
static func navigateToCourseOverview() {
XCUIApplication().scrollViews["Courses"].children(matching: .button).tap()
}
static func navigateToPreviousScreen() {
XCUIApplication().navigationBars.buttons.element(boundBy: 0).tap()
}
}
enum Course {
static func verifyUserIsOnCourseOverviewScreen() {
XCTAssert(XCUIApplication().staticTexts["overviewLabel"].exists)
}
}
With little effort, we end up with a simple, yet expressive, script language.
We can reference the currently tested app via XCUIApplication(). Because of that, we can define these small methods without requiring (many) dependencies, which improves the readability of our test scripts.
1.2 Easy to change
Within this language, it's easier to change things around or add and remove elements, without having to keep track of element matching mentally.
For instance, let's say we want to grow the test-script by adding a few more checks. Reading and writing tests this way is friendlier than a low-level language. Below, we easily grow the script yet it remains readable.
The only work we have to perform is match on the elements and put that in small methods again.
func testMakeSureCourseNavigationWorks() {
Navigation.navigateToCourseOverview()
Course.waitUntilDetailScreenIsOpen() // new
Navigation.goToPreviousScreen()
Course.verifyUserIsOnCourseOverviewScreen()
Course.openNewCourse() // new
Course.verifyNewCourseScreenIsOpen() // new
}
These new methods would be tiny methods on the enums we defined earlier.
1.3 Going higher-level
We can take this idea and go even more high-level as an expressive language. We can take the test-script from above, and abstract that method into its own scenario, such as Course.makeSureCourseNavigationWorks().
enum Course {
// ... rest is omitted
// We move this to the Course namespace
func makeSureCourseNavigationWorks() {
Navigation.navigateToCourseOverview()
Course.waitUntilDetailScreenIsOpen()
Navigation.goToPreviousScreen()
Course.verifyUserIsOnCourseOverviewScreen()
Course.openNewCourse()
Course.verifyNewCourseScreenIsOpen()
}
}
Then we can combine this scenario with other scenarios into an even larger one. For instance, we can verify that a new user can sign up for the app, and then browse and pay for a course. We'll call this test scenario testMakeSureNewUserCanSignUpAndPayForCourse().
Notice how we combine testing scenarios from Registration, Course, and Account into one huge testing script.
func testMakeSureUserCanSignUpAndPayForCourse() {
Registration.registerUser()
Registration.login()
Navigation.navigateToCourseOverview()
Course.makeSureCourseNavigationWorks() // The method we just defined
Course.signUpForCourse()
Navigation.navigateToAccountDetails()
Account.makeSureUserHasOneCourse()
}
With just a few lines, the UI test script can run a giant flow throughout the entire app, yet remains readable and expressive. And if we were to deep-dive into these methods, we'll end up with element matching or other methods, nothing more.
The time investment here is to wrap element-hunting into methods, and wrapping those methods into scenarios. But it's not too much work, and this way you'd be making an expressive language with little effort.
By composing tiny methods, writing scripts for UI tests will be a breeze.
As your app grows, however, you'll face new challenges: testing the same flows with different user types, handling multiple data sets, and maintaining tests as requirements change. Hardcoded values become a maintenance nightmare, and team members spend hours updating scattered test data instead of building features.
The next section shows how to make these patterns truly scalable.
2 Testing with Real-World Data
While our abstraction layer makes tests more readable, real applications often require testing with different data, user types, or configurations. What happens when you need to test the same flow with free vs premium users? Or run the same scenarios against multiple courses?
Let's extend our approach to handle these real-world complexities and learn how to compose these patterns for complete user journeys that can test your entire app with just a few lines of code.
2.1 Basic Parameter Passing
Instead of hardcoding values, we can pass parameters to our methods:
enum Course {
static func navigateToCourse(named courseName: String) {
let app = XCUIApplication()
app.scrollViews["Courses"]
.staticTexts[courseName]
.tap()
}
static func verifyCoursePrice(_ expectedPrice: String) {
let app = XCUIApplication()
XCTAssert(app.staticTexts["price-\(expectedPrice)"].exists)
}
}
enum Registration {
static func registerUser(email: String, password: String) {
let app = XCUIApplication()
app.textFields["email"].tap()
app.textFields["email"].typeText(email)
app.secureTextFields["password"].tap()
app.secureTextFields["password"].typeText(password)
app.buttons["Register"].tap()
}
}
2.2 Using Test Data Objects
For more complex scenarios, create data objects that encapsulate test parameters. We define a TestUser struct and a few default users we can grab.
struct TestUser {
let email: String
let password: String
let firstName: String
let lastName: String
let userType: UserType
enum UserType {
case free, premium, admin
}
static let defaultUser = TestUser(
email: "test@example.com",
password: "password123",
firstName: "John",
lastName: "Doe",
userType: .free
)
static let premiumUser = TestUser(
email: "premium@example.com",
password: "premium123",
firstName: "Jane",
lastName: "Smith",
userType: .premium
)
}
struct CourseData {
let title: String
let price: String
let duration: String
let difficulty: String
static let beginnerCourse = CourseData(
title: "iOS Basics",
price: "$99",
duration: "2 weeks",
difficulty: "Beginner"
)
}
2.3 Enhanced Methods with Data Objects
Now our methods become more flexible. Notice how we pass TestUser and use its values for UI Tests.
enum Registration {
static func registerUser(_ user: TestUser) {
let app = XCUIApplication()
app.textFields["email"].tap()
app.textFields["email"].typeText(user.email)
app.secureTextFields["password"].tap()
app.secureTextFields["password"].typeText(user.password)
app.textFields["firstName"].tap()
app.textFields["firstName"].typeText(user.firstName)
app.textFields["lastName"].tap()
app.textFields["lastName"].typeText(user.lastName)
app.buttons["Register"].tap()
}
static func loginUser(_ user: TestUser) {
let app = XCUIApplication()
app.textFields["email"].tap()
app.textFields["email"].typeText(user.email)
app.secureTextFields["password"].tap()
app.secureTextFields["password"].typeText(user.password)
app.buttons["Login"].tap()
}
}
enum Course {
static func navigateToCourse(_ course: CourseData) {
let app = XCUIApplication()
app.scrollViews["Courses"]
.staticTexts[course.title]
.tap()
}
static func verifyCourseDetails(_ course: CourseData) {
let app = XCUIApplication()
XCTAssert(app.staticTexts[course.title].exists)
XCTAssert(app.staticTexts[course.price].exists)
XCTAssert(app.staticTexts[course.duration].exists)
}
}
2.4 Parameterized Scenarios
Our high-level scenarios can now handle different configurations. Notice how we test for different flows for different user types.
enum Course {
static func purchaseCourseFlow(user: TestUser, course: CourseData) {
Registration.loginUser(user)
Navigation.navigateToCourseOverview()
Course.navigateToCourse(course)
Course.verifyCourseDetails(course)
// Premium users get different purchase flow
if user.userType == .premium {
Course.purchaseWithPremiumDiscount()
} else {
Course.purchaseAtFullPrice()
}
Course.verifyPurchaseSuccess()
}
}
2.5 Test Cases with Parameters
Our actual test methods become data-driven, where we pass a user and course, and we test whether they can purchase it.
func testPurchaseFlowForDifferentUsers() {
Course.purchaseCourseFlow(
user: TestUser.defaultUser,
course: CourseData.beginnerCourse
)
}
func testPremiumUserExperience() {
Course.purchaseCourseFlow(
user: TestUser.premiumUser,
course: CourseData.beginnerCourse
)
}
In fact, we can make a whole list of courses and easily test these.
func testMultipleCourses() {
let courses = [
CourseData.beginnerCourse,
CourseData.advancedCourse,
CourseData.masterCourse
]
for course in courses {
Course.purchaseCourseFlow(
user: TestUser.defaultUser,
course: course
)
}
}
2.6 Benefits of Parameterized Abstraction
When we combine our abstraction layer with parameters and data objects, our tests become much more powerful.
First, they are data driven. Instead of hardcoding values in multiple places, we can reuse the same scenario with different users, courses, or configurations by passing in new parameters. This makes it easy to cover edge cases such as a free user, a premium user, or an admin without writing duplicate test logic.
This approach also improves maintainability. When test data changes, we update it in one place inside the data objects instead of searching through many individual tests. As the product grows, the test suite remains easy to keep up to date. If a matched object breaks, we don’t have to update a large number of tests, we merely find the broken object and all tests should pass again.
There is also a clear benefit in reusability. The same high level scenarios can run against different environments or feature flags without rewriting any tests. This lets you test mock servers, staging, or even production with confidence.
Finally, parameterization keeps tests readable. They remain short and expressive while covering more scenarios. You still get a clean script, but now it is flexible enough to handle many cases with minimal extra code.
With little effort, we can keep expanding. For example, we can define a list of users (free default, premium, and admin), and pair those with the expected feature set. Then we test against that.
func testCourseExperienceAcrossUserTypes() {
let testCases = [
(user: TestUser.defaultUser, expectedFeatures: .basicFeatures),
(user: TestUser.premiumUser, expectedFeatures: .premiumFeatures),
(user: TestUser.adminUser, expectedFeatures: .allFeatures)
]
for (user, expectedFeatures) in testCases {
Course.verifyUserExperience(user: user, expects: expectedFeatures)
// Custom method: Clears data so the next test runs in a fresh state
Application.resetToCleanState()
}
}
2.7 Conclusion
UI testing doesn't have to be a chore. With the right abstraction layer, writing tests becomes almost as easy as describing user behavior in plain English.
Yes, abstractions add indirection, which can make debugging trickier. But in my experience, this trade-off is worth it. The upfront investment in creating this "testing language" pays dividends as your app grows. Your future self (and your teammates) will thank you when they can quickly understand and modify tests without deciphering element selectors and complex interaction chains.
Start with one test, prove the value, and gradually build your testing vocabulary. Pick a simple but important user flow, refactor it using this pattern, and see how much easier it becomes to read. You'll be surprised how quickly this approach transforms UI testing from a necessary evil into a powerful development tool.

