Skip to content
intermediate Phase 11 · Mobile Testing

Gestures, Network, and Offline Testing

Verify touch gestures, offline caching, traffic interception, and push deep links.

1h
0 problems
Topic Progress 0%

Swipe, Pinch, Long Press, Drag: Automating What Fingers Do

A carousel that will not scroll, a photo that will not zoom, a list item that needs a long press to reveal Delete — none of these are reachable by click(), and every mobile suite eventually hits them. The first thing to know is that the API changed and never changed back: Appium 1.x's touchAction().moveTo().release().perform() chain is deprecated and removed in Appium 2, replaced by two supported routes — the W3C Actions API, and platform gesture commands that the drivers expose directly.

The W3C route is the portable one: it models input as pointer devices with explicit timelines, so a swipe is a pressed finger that moves and lifts.

// swipe up inside the list: press, move, release — element-relative coordinates
const list = await driver.$("~product-list");
const box = await list.getBoundingClientRect();
await driver.performActions([
  { type: "pointer", id: "finger1", parameters: { pointerType: "touch" }, actions: [
    { type: "pointerMove", duration: 0,     x: box.x + box.width / 2, y: box.y + box.height * 0.7 },
    { type: "pointerDown", button: 0 },
    { type: "pause",       duration: 120 },
    { type: "pointerMove", duration: 400,   x: box.x + box.width / 2, y: box.y + box.height * 0.3,
      origin: "viewport" },
    { type: "pointerUp",   button: 0 },
  ]},
]);
await driver.releaseActions();

The driver-level commands are shorter and read better where they exist — prefer them for the common gestures:

Gesture Android (UiAutomator2) iOS (XCUITest) Typical failure
swipe mobile: swipeGesture (element, direction, percent) mobile: swipe percent too small — no scroll
pinch open/close mobile: pinchOpenGesture mobile: pinch webview ignores native pinch
long press mobile: longClickGesture mobile: touchAndHold fires context menu on the wrong item
drag mobile: dragGesture mobile: dragFromToForDuration drop point outside the target
double tap mobile: doubleTapGesture mobile: doubleTap zoom toggles instead of firing

Three rules keep gesture tests from becoming a spreadsheet of magic numbers. Never hard-code absolute pixels — a 1080×2340 coordinate is wrong on every other device; use element-relative boxes (percent for the driver gestures, boundingRect for actions) so the same test runs on a 5-inch phone and a foldable. Give the gesture a destination to verify: swipe until ~next-section exists (waitForExist) instead of swiping twice and hoping, because scroll distance is a function of item height, which changes with content and font scaling. Distinguish the gesture's intent from its side effects — the long-press test must assert the context menu opened for the intended item (the id in the menu header), not merely that a menu appeared. When a gesture passes in the inspector but fails in CI, suspect timing first: the finger's pause and move duration interact with frame rate, and a 0 ms move is often interpreted as a tap by the gesture recognizer — 150–400 ms of duration is the range that behaves across devices.

Diagnose a failing gesture the way you diagnose a failing click: record the session (locally adb shell screenrecord /sdcard/gesture.mp4, remotely the farm's video) and check whether the finger landed or the target moved first. Two environmental culprits repeat across projects. Animations — a carousel still settling when the pointer goes down absorbs the swipe into its own inertia — so wait for idle rather than pausing a fixed 500 ms: read the target's bounding box twice and require it to be identical, or use the driver's idle wait where available. Overlays — the keyboard, a cookie banner, a system tooltip — intercept the pointer at its destination, which is why hideKeyboard() and dismissing system dialogs belong in setup for gesture-heavy screens instead of in the one test that happened to trip over them.

Airplane Mode, Slow Links, Retries: Proving the App Survives Bad Networks

Every mobile user spends real minutes per day on a lift, a tunnel, or a congested café Wi-Fi — and offline behavior is where mobile apps earn or destroy trust. The states you must script are not one state but a ladder, and each rung has a different expected app behavior:

Network state How to create it What the app must do
offline adb shell cmd connectivity airplane-mode enable show cached content, label its age, queue writes
off then on toggle airplane mode after a failed submit replay the queued write exactly once
slow emulator -netdelay 500 -netspeed edge show progress, never a dead spinner
lossy/dropping mitmproxy/Toxiproxy drop 30% of connections retry with backoff, then a clear error
endpoint 5xx proxy returns 500 for /api/orders friendly failure, no crash, no data loss
adb shell cmd connectivity airplane-mode enable     # radios off — true offline
adb shell svc wifi disable && svc data disable      # narrower: one transport at a time
emulator -avd ci_pixel7 -netdelay 1000 -netspeed edge &   # slow-link boot

The assertions are the part most suites skip, and they are where the defects hide. Offline read: cached screens render, a "last updated 14:32" label (or equivalent staleness cue) appears, and nothing spins forever — an indefinite spinner on a dead network is the single most common offline bug. Offline write: the mutation is persisted locally, reported to the user as pending, and replayed exactly once on reconnect; asserting exactly once matters because the naive retry sends the order twice, which is a duplicate-charge incident rather than a UI nit. Retry discipline: with a proxy dropping connections, count the attempts and assert the backoff curve (say 3 attempts with growing delays) — unbounded immediate retries turn a flaky server into a self-inflicted denial of service on your own API. Idempotency on replay: the retried request carries an idempotency key or the server deduplicates, and your test proves it by asserting one created resource after the reconnect.

Connect these to the contract layer: the queued-write payload must still validate against the request schema after it has been reconstructed from local storage — a serializer that drops a field while queueing produces an offline-only bug that no online test will ever see.

One further state hides in plain sight because the radio says connected: Wi-Fi with no working path to the internet (hotel portals, blackholed cafés). The OS reports a link, so an app that trusts connectivity state skips its offline fast-path, fires requests, and waits — the famous spinner-forever bug that airplane mode never reproduces. Script it with a network-shaping proxy rather than a radio toggle: Wi-Fi stays on while Toxiproxy or mitmproxy applies a timeout toxic (connections accepted, never answered), and the app must take its error path, surface a retry, and fall back to cache. The assertion set differs from airplane mode precisely because the app's own connectivity check passes — which is the point of the rung.

Environment tools matter too: the emulator's -netdelay/-netspeed presets are cheap and CI-friendly, while mitmproxy or Toxiproxy give programmatic control (latency, abort, status codes) against real devices, where you cannot root them to install tc traffic control. Script the ladder as a reusable suite — Mobile Automation with Appium gave you the sessions; this gives them network adversity.

Intercepting Traffic and Push Deep Links: Verifying What Leaves and What Arrives

Two questions sit at opposite ends of the request lifecycle and neither can be answered by looking at the screen: what exactly did the app send? and where does a tapped notification take the user? Both get scripted answers.

Interception. Point the device at a mitmproxy or Charles instance and you can assert payloads and headers that the UI never shows:

mitmdump --mode reverse:https://api.acme.dev -p 8080 -w traffic.flow
# device: install the CA, then set the proxy to this host:8080
// assert on captured traffic, not on the spinner
const checkout = traffic.find(r => r.path.endsWith("/orders") && r.method === "POST");
expect(JSON.parse(checkout.body)).toMatchObject({ currency: "EUR", items: expect.any(Array) });
expect(checkout.requestHeaders["idempotency-key"]).toBeTruthy();
expect(checkout.responseHeaders["cache-control"]).toBe("no-store");

That surfaces a class of bugs invisible to UI assertions: a field serialized as null instead of omitted, a locale sending 19,99 where the API expects 19.99, an auth header missing on retries, or a response marked Cache-Control: public on an authenticated endpoint — a caching bug that will eventually serve one user's data to another through the HTTP cache. Two platform gotchas cost teams a day each: Android 7+ ignores user-installed CAs by default, so a debug build needs a networkSecurityConfig trusting user certificates (or you assert via a debug-only proxy setting); and iOS requires installing the profile and toggling full trust for it under certificate settings — otherwise TLS simply refuses the proxy. Capture ETag/If-None-Match pairs too, and assert a refresh returns 304 Not Modified — caching working correctly is a behavior worth a test, not just a header worth seeing.

Push → deep link. The hard variant is the one users actually hit: the app is terminated, the notification arrives, the tap must cold-start the app and land on the right screen with the right data.

adb shell cmd notification post -t Acme 'Order 6f1c shipped'   # synthetic push on emulator
# real path: trigger your push provider's API with the expected payload, then tap via Appium
await driver.execute("mobile: terminateApp", { bundleId: BUNDLE });
await pushProvider.send({ template: "order-shipped", to: deviceToken, data: { orderId: "6f1c" } });
await waitForNotification("Order 6f1c shipped");
await tapNotification("Order 6f1c shipped");
await driver.$("~order-detail-6f1c").waitForDisplayed({ timeout: 15000 });

Two setup steps make that run trustworthy. First, assert token registration before sending: verify the app registered its push token to the test account (GET /devices on your backend, or a debug screen showing the token prefix) — otherwise the notification is delivered to some other handset, the tap never appears, and the failure tells you nothing about the build. Second, keep both push paths: cmd notification post (or a scheduled local notification) proves your shade-to-intent handling in seconds and runs on every PR, while one run per cycle through the real provider — FCM/APNs with the actual template — is what catches payload serialization, topic/audience selection, and transport failures, because those are where production notification outages actually live.

Test the whole ladder of start states — terminated, backgrounded, and on the lock screen — because each routes through a different path (cold launch with launch options/intent extras versus onNewIntent). Then test the failure side: a deep link for a deleted order must land on a friendly not-found screen rather than a blank spinner, and a malformed link must not crash the launch. mobile: deepLink exercises the routing table in milliseconds (great for coverage), while the provider-to-tap run proves the real end-to-end path — you want both, at different cadences, because only one of them can catch a broken notification payload. Running that gesture-and-network ladder against dozens of real hardware models is the job of Device Farms in CI.

Quiz

1. Your swipe test passes on one device and does nothing on a smaller screen. What is the root cause?

Question 1 options

2. After the network returns, a queued order is submitted twice. Which assertion would have caught it, and what does it require?

Question 2 options

3. A Charles/mitmproxy setup shows no HTTPS traffic from an Android 7+ debug build. What is the most likely cause?

Question 3 options

4. What must a push-notification test verify beyond 'the notification arrived'?

Question 4 options

Flashcards

Question

How do you perform a swipe in Appium 2, and what happened to TouchAction?

Answer

TouchAction chains were deprecated and removed in Appium 2. Use either the W3C Actions API (pointerDown → pointerMove with duration → pointerUp via driver.performActions) or the driver gesture commands: `mobile: swipeGesture`, `pinchOpenGesture`, `dragGesture`, `longClickGesture` on Android; `swipe`, `pinch`, `touchAndHold`, `dragFromToForDuration` on iOS.

Question

Why gesture tests fail across devices, and the two rules that fix it.

Answer

Absolute pixel coordinates don't map across resolutions/densities, and zero-duration moves get read as taps. Fix: compute coordinates from the element's bounding rect (or use percent-based driver gestures) and give gestures 150–400 ms of duration; verify by waiting for a post-scroll element rather than assuming a number of swipes.

Question

Name the offline test states and the behavior each must prove.

Answer

Offline (airplane mode): cached content plus a staleness label, never an infinite spinner. Reconnect: queued write replayed exactly once (idempotency key). Slow (emulator -netdelay/-netspeed edge): progress UI. Lossy (proxy drops): bounded retries with backoff, then a clear error. 5xx: friendly failure, no crash, no data loss.

Question

What can traffic interception assert that UI tests cannot?

Answer

The exact serialized request body (null vs omitted, decimal separator), required headers (auth on retries, idempotency keys), and response caching directives (Cache-Control: public on an authenticated endpoint, ETag/If-None-Match yielding 304). Platform prerequisite: trust the proxy CA (Android networkSecurityConfig; iOS profile + full trust).

Question

The three app start states a push deep-link test must cover, and why they differ.

Answer

Terminated (cold launch — data comes via intent extras / launch options), backgrounded (onNewIntent / resumed with the link), and lock-screen (render over a secured UI). Each routes differently, so only testing one misses the others; also test a dead link landing on a friendly not-found screen instead of crashing.

Revision Notes

Key Takeaways

  • 1. TouchAction is gone in Appium 2 — W3C actions or driver gesture commands, with element-relative coordinates and real durations
  • 2. Gesture fragility is geometry and timing, not devices: percent/bounding-rect math plus 150–400 ms of movement
  • 3. Offline is a ladder of states, not a switch — and the assertion that matters is exactly-once replay, which requires idempotency
  • 4. A proxy sees what the screen hides: serialized payloads, auth and idempotency headers, and caching directives that can leak user data
  • 5. Push testing starts where the app is dead: cold-start deep links, backgrounded routing, and lock-screen rendering are three different code paths

Interview Tips

  • • "How do you test swipe/pinch without flaking?" — element-relative coordinates or percent-based driver gestures, real durations, condition waits on a post-gesture element; explicitly say TouchAction was removed in Appium 2
  • • "How would you test an offline-capable feature?" — airplane-mode states + a lossy proxy, assert cached UI with staleness, exactly-once replay on reconnect, bounded backoff; mention idempotency keys as the enabling contract
  • • "What can you verify with a proxy that you can't in the UI?" — exact request bodies, missing auth headers on retry, Cache-Control/ETag behavior (304s), and locale-formatting bugs
  • • "How do you test push notifications?" — trigger through the real provider, tap, assert the destination screen; enumerate the three start states and the dead-link fallback to show you know the routing paths

Cheat Sheet

Gestures, Network, and Offline Testing

Gestures (Appium 2): no TouchAction —

await driver.performActions([{ type: "pointer", parameters: { pointerType: "touch" }, actions: [
  { type: "pointerDown" }, { type: "pause", duration: 120 },
  { type: "pointerMove", duration: 400, x, y, origin: "viewport" }, { type: "pointerUp" } ]}]);
// or: mobile: swipeGesture { elementId, direction, percent }  ·  mobile: pinchOpenGesture
//     mobile: longClickGesture  ·  mobile: dragGesture   (iOS: swipe / pinch / touchAndHold / dragFromToForDuration)

Rules: element-relative coordinates only · 150–400 ms duration · wait for a post-gesture element, not a fixed count.

Network ladder:

State Trigger Must prove
offline cmd connectivity airplane-mode enable cached + age label, no infinite spinner
reconnect toggle mode replay exactly once
slow emulator -netdelay 1000 -netspeed edge progress UI
lossy mitmproxy/Toxiproxy drops bounded backoff, then clear error
5xx proxy error no crash, no data loss

Interception: mitmdump --mode reverse:https://api.acme.dev -p 8080 → assert body fields, idempotency key, auth header, Cache-Control, ETag → 304. Prereq: Android networkSecurityConfig trusting user CAs (7+), iOS profile + full trust.

Push deep link: terminate app → send via push provider → tap → assert destination screen; also backgrounded and lock-screen start states, plus dead-link → friendly fallback. mobile: deepLink covers routing cheaply; provider→tap covers the real path.

Continue Learning

Next Topics

Related Reading