Skip to main content
All articles

Negative Waits: The Seconds Every Green XCUITest Run Burns

Ben Van AkenCo-Founder & CTO10 min read

Part 4 covered the cost of queries on a heavy accessibility tree. This part covers a different family: waits whose cost does not depend on the tree, because they are paid in full on every passing run. A wait for something that is not supposed to appear succeeds only by running out its clock. These waits are easy to write, never fail, and accumulate one reasonable-looking line at a time. This part explains how to recognise them, how to shorten them without weakening what they assert, and how to prove each change can still fail.

Two ways to assert absence, with opposite costs

Swift
// Spends the full 4 s on EVERY green run: success means the deadline passed.
XCTAssertFalse(deleteButton.waitForExistence(timeout: 4))

// Returns on the first snapshot in which the element is absent.
// On green that is one poll, about 1.1 to 2 s, at a 4 s or a 45 s budget alike.
XCTAssertTrue(spinner.waitForNonExistence(timeout: 45))

The first form's budget is a cost on every green run. The second form's budget only matters when the assertion is going to fail, which makes it a bound on the failure path. Measured across 77 waitForNonExistence calls in one baseline run, every one returned after a single poll. The practical consequence: shortening a waitForNonExistence budget buys nothing on a passing run, and the 36 such sites in this suite were deliberately left alone.

Shortening a negative wait safely

An absence assertion on its own is also a weak assertion: the element is equally absent on a blank window, a loading state or the wrong screen (part 5). The rewrite that makes a negative wait cheap is the same one that makes it meaningful. A positive assertion that the surface has rendered goes above it; once the surface is proven present, the absence check needs only one or two polls.

Swift
// Before: 4 s on every green run, and green on a blank window too.
XCTAssertFalse(window.buttons["customer-delete"].waitForExistence(timeout: 4))

// After: prove the surface is up, then a short negative.
XCTAssertTrue(window.staticTexts["customer-detail-title"].waitForExistence(timeout: 10))
XCTAssertFalse(window.buttons["customer-delete"].waitForExistence(timeout: 1))

In this suite, eight sites rewritten this way went from 4.11 to 4.43 seconds each (5.25 on the login smoke test) to 1.12 to 1.46 seconds. A second batch of nine went from 4.08 to 4.76 seconds to 1.12 to 1.72 seconds at a 1 second bound, or 2.07 to 2.11 seconds at a 2 second bound. Those per-site figures are the ones to trust: the runs they came from overlapped foreign host load, so no wall-clock saving was claimed, only the per-site bounds and the pass sets (part 7 explains why).

How short is safe

The right bound depends on how the surface under test comes into existence.

  • One second where the surface is fully determined when it is created: a configuration-gated form, a permission-gated control rendered from data that is already loaded.
  • Two seconds where the view forks after loading and fails closed in between. A shell renders first, then either the editable or the read-only form; a one-second negative there can pass inside the pre-load window and prove nothing.
  • One second for a rung that waits on a SwiftUI Table by identifier, because a Table's identifier never surfaces as an accessibility node. The row predicate carries the settle, not the table.

Do

  • Put a positive landing assertion above every negative wait, then set the negative to one or two seconds according to the surface.
  • Leave waitForNonExistence budgets alone unless they are failing.
  • Treat a helper whose timeout serves several classes as a unit. It owes a red-proof per consumer, so either prove them all or leave it and document which call sites it serves.

Don't

  • Don't shorten a negative wait that has no positive landing above it. The shorter wait is just a faster vacuous pass.
  • Don't apply one bound everywhere. A fork-or-shell surface needs the longer one.

Proving a shortened wait can still fail

A shortened negative is only an improvement if it still detects the defect it guards against. The check is to plant that defect in a scratch copy of the tree and run the test: it has to fail, and it has to fail at the shortened line. A failure elsewhere does not count, because it means something upstream masked the negative.

One re-size in this suite failed that check three times. Its red-proof appeared to hang upstream of the negative on a quiet host. The planted defect had caused an unbounded exists check over a descendants(matching: .any) predicate to walk the entire tree, and each walk hit a 30 second snapshot timeout. The change was withdrawn rather than carried. It landed later, after the upstream probe was scoped to the view's own subtree and to the element type the positive landing had resolved as: 24 milliseconds under the same plant, and a failure at the re-sized line in 54 seconds.

Do

  • Red-prove each re-sized wait at its own line, in a scratch tree, on a quiet host.
  • When a red-proof fails elsewhere, check every query between the landing and the negative for an unbounded walk before blaming the negative.

Don't

  • Don't carry a re-size whose red-proof fails at a different line.
  • Don't treat one red-proof as covering a shared helper's other consumers.

Positive waits that never succeed

The largest single saving in this family was not a negative wait. A navigation helper tried to resolve a timesheets Table by identifier with an 8 second budget before falling back to a row predicate. Because a SwiftUI Table's identifier never surfaces, the first rung could not succeed, and it spent 8.22 to 8.41 seconds failing, 29 times per run. At a 1 second bound it costs 1.25 to 1.31 seconds, about 200 seconds saved per run; two classes that use it went from 87.2 to 65.9 and from 211.4 to 161.7 seconds median. A landing probe with the same shape, rungs of 2, 10 and 2 seconds that never resolved on green, took 11 to 15 seconds off every test in its classes once it became a single any-of wait.

These are found in the result bundle's activity tree: a wait that runs to its full timeout and is followed by a fallback that succeeds is a rung that never resolves on green. A census of this suite found 364 positive waits of 10 seconds or more. Most are fine; the ones that always time out are the targets.

Two primitives, two floors

On the iPhone lane, waitForExistence costs about 1.07 seconds even when it succeeds immediately. Across 139 calls it ranged from 1.030 to 1.110 seconds, at a 10 second and a 3 second budget alike, because the first predicate evaluation does not land sooner. A cap below about 1.2 seconds therefore recovers nothing on green. An any-of wait built on a 0.5 second polling loop behaves differently: each round costs about 0.55 seconds, and the budget quantises to whole rounds, so a 1.2 second timeout runs two rounds, about 1.25 seconds. Know which primitive a wait uses before choosing its number.

Settle waits whose result nothing reads

A settle wait is one that pauses for a state to stabilise before the next step. It is only useful if something reads its result. In this suite's iPad lane, several flows rotated to landscape and then waited on the sidebar toggle to settle; in landscape the split view shows its sidebar and the toggle disappears, so the wait always ran out its budget, and at four sites the boolean it returned was discarded. Removing them took eleven misses worth 54.2 seconds to zero and a 14-test scope from 948.5 to 859.8 seconds. The red control was to typo the identifier of each flow's next asserted destination in a scratch tree; each test then failed at that destination, showing the removed wait had protected nothing.

A related batch replaced green-path misses with any-of polls or fast-path probes at eight sites: fifteen misses worth 101.5 seconds went to zero, and a 26-test scope went from 1,310.7 to 1,213.0 seconds. Each site was red-proved by blocking every candidate the new code accepts.

Do

  • Read every settle's result, or delete the settle and let the next asserted landing carry the proof.
  • Look for waits on a control that the preceding action removes; they always run out.

Don't

  • Don't keep a wait whose boolean is discarded. It is a sleep with an exit.

The login path: priced, not yet fixed

The largest remaining candidate in this family is reported here as a projection, not a result. Two pairs of waits in the iOS login prologue are paid on nearly every login.

  • A wait for the submit button, placed after the keyboard is dismissed and before the workspace check. The keyboard's done key has already submitted the form, so the probe waits on a control that is gone: 5.181 seconds on 138 of 139 logins. The fix is to assert the landing instead of the submit control.
  • A two-step check for the AutoFill save-password sheet: 5.195 seconds per login, and the sheet appeared 0 times in 139 logins. Capping it is safe only while that frequency holds, and with zero appearances in the corpus its safety margin cannot yet be measured.

Priced from the stored logs of two whole-target runs, the two pairs come to roughly 1,007 seconds per whole iPhone run, about 11.1 percent. That is arithmetic over past runs, not a measured delta, and the macOS equivalent (about 5.5 seconds per login on the same AutoFill check) has to be measured on its own lane before it is claimed.

Checklist

  1. Classify each wait: negative with waitForExistence (paid on green), waitForNonExistence (paid on red), positive rung, settle.
  2. For each negative: add a positive landing above it, set 1 or 2 seconds by surface, red-prove at its own line.
  3. Leave waitForNonExistence budgets alone.
  4. In the activity tree, find positive rungs that always time out on green and either bound them at 1 second or replace them with an any-of wait.
  5. Delete settles whose result is discarded and let the next landing carry the proof.
  6. Know each primitive's floor and quantum before picking a number.

Previously (Part 8): The Launch-and-Login Tax: Per-Class App Lifecycle in XCUITest

Next up (Part 10): Animations Off and Parallel Simulators: Measured XCUITest Speed-Ups

Comments

Loading comments…

How many seconds does your suite spend waiting for nothing?

We audit XCUITest waits in production SwiftUI apps, price each one from the result bundle, and shorten only the ones we can prove red. Book a call and let's find them.