Lesson 5 of 6
Lesson 05 — Gestures, Waits, and App Lifecycle
Title: Stable mobile interactions beyond simple taps
Description: Learn how to automate mobile behavior that does not exist on desktop web: swipes, scrolling, long press, app backgrounding, orientation changes, and permission dialogs. This lesson focuses on reliable patterns and explains why hardcoded coordinates and sleeps create flaky tests.
Why it matters for QA: Mobile users swipe, rotate, background apps, receive dialogs, and use unstable networks. Appium tests that only tap buttons miss the most important mobile risks.
1. Wait for conditions, not time
Avoid fixed sleeps:
// Bad: slow and unreliable
await browser.pause(3000);
Prefer explicit waits:
const loginButton = await $("~loginButton");
await loginButton.waitForEnabled({
timeout: 10_000,
timeoutMsg: "Login button did not become enabled",
});
await loginButton.click();
Wait for the condition your test actually needs.
2. Tap by locator first
const submitButton = await $("~submitOrderButton");
await submitButton.waitForDisplayed({ timeout: 10_000 });
await submitButton.click();
If an element has a good accessibility id, use it. Coordinates should be a last resort.
3. Swipe with percentages
Hardcoded pixels fail across devices. Use the screen size.
async function swipeUp() {
const { width, height } = await driver.getWindowSize();
const startX = Math.floor(width * 0.5);
const startY = Math.floor(height * 0.8);
const endY = Math.floor(height * 0.2);
await driver.performActions([
{
type: "pointer",
id: "finger1",
parameters: { pointerType: "touch" },
actions: [
{ type: "pointerMove", duration: 0, x: startX, y: startY },
{ type: "pointerDown", button: 0 },
{ type: "pointerMove", duration: 600, x: startX, y: endY },
{ type: "pointerUp", button: 0 },
],
},
]);
await driver.releaseActions();
}
This still uses coordinates, but they adapt to the device size.
4. Scroll until an element is visible
async function scrollUntilVisible(selector, maxSwipes = 5) {
for (let attempt = 0; attempt < maxSwipes; attempt++) {
const element = await $(selector);
if (await element.isDisplayed()) {
return element;
}
await swipeUp();
}
throw new Error(`Element ${selector} was not visible after scrolling`);
}
Use a maximum swipe count so a broken test does not scroll forever.
5. Long press
async function longPress(selector) {
const element = await $(selector);
await element.waitForDisplayed({ timeout: 10_000 });
const location = await element.getLocation();
const size = await element.getSize();
const centerX = Math.floor(location.x + size.width / 2);
const centerY = Math.floor(location.y + size.height / 2);
await driver.performActions([
{
type: "pointer",
id: "finger1",
parameters: { pointerType: "touch" },
actions: [
{ type: "pointerMove", duration: 0, x: centerX, y: centerY },
{ type: "pointerDown", button: 0 },
{ type: "pause", duration: 1000 },
{ type: "pointerUp", button: 0 },
],
},
]);
await driver.releaseActions();
}
The locator finds the element. Coordinates are calculated from the element's current position.
6. Background and foreground
it("should preserve draft text after backgrounding", async () => {
const notesInput = await $("~notesInput");
await notesInput.setValue("Draft text");
await driver.background(5); // app goes to background for 5 seconds
await notesInput.waitForDisplayed({ timeout: 10_000 });
await expect(await notesInput.getText()).toContain("Draft text");
});
This catches state loss bugs that never appear in simple happy-path tests.
7. Orientation changes
it("should keep login form values after rotation", async () => {
const emailInput = await $("~emailInput");
await emailInput.setValue("qa@example.com");
await driver.setOrientation("LANDSCAPE");
await driver.setOrientation("PORTRAIT");
await emailInput.waitForDisplayed({ timeout: 10_000 });
await expect(await emailInput.getText()).toContain("qa@example.com");
});
On Android, rotation can recreate the Activity. That makes this a valuable test.
8. Android permission dialogs
Permission dialogs are system UI, not your app UI. The resource IDs come from Android.
async function denyAndroidPermission() {
const denyButton = await $(
"id=com.android.permissioncontroller:id/permission_deny_button"
);
await denyButton.waitForDisplayed({ timeout: 10_000 });
await denyButton.click();
}
For tests where permission is not the focus, auto-grant permissions in capabilities:
{
"appium:autoGrantPermissions": true
}
For tests where permission denial is the focus, start from a clean app state and tap "Deny" intentionally.
Official docs
Quick recap
| Need | Recommended pattern |
|---|---|
| Tap button | Locate by accessibility id, wait, click |
| Wait for loading | Explicit wait for a specific condition |
| Swipe | Percentages of screen size |
| Scroll to element | Loop with max attempts |
| Long press | Calculate center from located element |
| Permissions | Auto-grant by default, deny intentionally in dedicated tests |
| Lifecycle | Background, foreground, rotate, then assert state |
Practice exercises
- Write a
swipeUphelper using screen size percentages. - Write
scrollUntilVisible(selector, maxSwipes). - Automate a long press on a list item.
- Background an app for 5 seconds and verify the screen state.
- Rotate a form screen and verify typed text remains.
Homework
Create a small mobile interaction suite:
- One test scrolls to an item and taps it.
- One test long-presses an item and verifies a context menu.
- One test backgrounds the app and verifies state preservation.
- One test rotates the screen and verifies layout/state.
- No test uses
browser.pause()or absolute XPath.
Next: Lesson 06 — CI, cloud devices, and Appium maintenance (lab06.md).