UI testability advisory
Purpose: tell a team what makes their own application hard to automate, in terms a developer can act on — before anyone writes the tests that will break.
Every crawl grades each element on how well a test can refer to it, and returns a verdict on the whole surface. Web, Android and iOS use the same grades, the same score and the same bands, so an app can be compared with itself across platforms.
This is the one output of a crawl that improves the application rather than describing it.
Why it exists
A test finds an element by something. If that something is a data-testid, the test survives a
redesign. If it is the visible text, the test breaks on the next copy edit and does not work in
another language. If there is nothing at all, the test can only use position on screen — and that
breaks the first time anything moves.
Teams usually discover which of these they have by watching tests break for months. The crawl already knows on day one. It just never said so.
What you get
Every element carries:
| Field | Meaning |
|---|---|
humanName | The best readable name for it, or null when it has none |
nameQuality | GOOD · WEAK · MISSING |
namingSuggestion | What to change and why. null when nothing needs doing |
selectorType | Which kind of locator the selector is — data-testid, accessibility, id, name, … |
clickable isInput isPassword enabled | What kind of control it is |
platform | WEB · ANDROID · IOS |
And the crawl itself carries the verdict:
"testabilityAdvisory": {
"score": 62,
"band": "NEEDS_WORK",
"controlsAssessed": 34,
"wellNamed": 21,
"needsAttention": 13,
"summary": "21 of 34 controls have a stable identifier. 13 can only be found by text or
position, so tests touching them will break when the layout or wording changes.",
"examples": [ … up to 10 … ]
}
How an element is graded
GOOD — it has an identifier a developer chose and meant to be addressed.
- Web:
data-testid, oraria-label - Android:
content-desc, or a meaningful resource id - iOS: the accessibility identifier
A test written against it keeps working.
WEAK — addressable, but by something that will not last. Two cases:
- Findable only by its visible text. Works today; breaks on the next wording change and fails in every other language.
- An identifier that looks auto-generated —
a3f9c2b1e4d5,view123, a UUID,untitled. This is the case worth understanding: it is addressable right now and different after the next build, which is worse than having none, because it looks fine.
MISSING — nothing to refer to at all. Only position or structure can find it.
Calling a good name bad trains people to ignore the report, which costs more than staying quiet about a borderline one. When in doubt the grade is the kinder one.
How the score is calculated
score = well-named controls / interactive controls (as a percentage)
| Band | Score | What it means |
|---|---|---|
READY | 80–100 | Tests written today will survive UI changes |
NEEDS_WORK | 50–79 | A meaningful share of controls will break tests when the layout moves |
HARD_TO_AUTOMATE | 0–49 | Most controls can only be found by text or position |
NO_CONTROLS | — | Nothing interactive was found, so there is nothing to assess |
Only interactive controls are scored — buttons, inputs, links, selects, forms, and their mobile equivalents. Body text having no identifier does not make an application harder to automate, and a score that punished prose is one people stop reading.
A page of pure content returns band: "NO_CONTROLS" and no score, rather than 0. Zero out of
zero is not a failing grade.
Reading the result
The examples list is the actionable part. Each entry names a control, why it is a problem, and
what to do:
{
"type": "Button",
"nameQuality": "WEAK",
"text": "Continue",
"selector": "button.btn-primary:nth-of-type(2)",
"suggestion": "This button can only be found by its text (\"Continue\"), so tests break when
the wording changes and do not work in another language. Give it a `data-testid`."
}
The advice names the thing to add in the vocabulary of that platform — data-testid on web, an
accessibility identifier on mobile — because that is what the developer reading it will search for.
Capped at ten examples. The point is to show the shape of the problem, not to hand the element list back.
Getting one
Any crawl produces it. No flag to set.
# Web
POST /api/crawl { "target": "https://your-app.example.com", "engine": "PLAYWRIGHT", "maxDepth": 3 }
# Mobile
POST /api/mobile-apps/{id}/inspect { "crawlMode": true, "maxDepth": 3 }
Where to read it
In the UI: open the crawl's report. The advisory sits above the element inventory — score, band, and the specific controls to fix, each with the attribute to add.
Via MCP — advise_ui:
advise_ui(job_id="<the UUID from crawl_ui>")
Returns the score, band, counts, and examples with a concrete suggestion per control, for a few
hundred tokens rather than the thousands an element inventory costs. This is the web counterpart to
advise_swagger, and the same advice applies: call it before you author anything. A page whose
controls have no stable identifier will produce brittle tests however carefully they are written,
and that is worth knowing before you write fifty of them.
get_crawl_results(job_id) carries the advisory too, so you see the grade without having to ask.
Crawls that finished before 2026-08-17 predate the advisory; advise_ui says so rather than
returning an empty verdict. Re-run the crawl to get one.
Did the crawl see enough to judge? — saturation
A score on its own can mislead, and it misleads in the flattering direction. This is the part worth reading twice.
The score is well-named controls ÷ controls found. Controls the crawl never reached are in neither number. So a crawl that stopped early does not score worse for having missed things — it simply does not count them, and a thin crawl can come back looking better than a thorough one of the same application.
Every crawl therefore carries a second verdict saying whether it ran out of application or ran out of budget:
"saturation": {
"verdict": "TRUNCATED",
"mayBackCoverage": false,
"pagesRead": 20,
"distinctFound": 4,
"dryStreakAtEnd": 11,
"discoveryCurve": [ {"afterPages": 5, "newFound": 4}, … ],
"stopReason": "BUDGET_EXHAUSTED",
"summary": "The crawl stopped because it hit its page limit, not because it ran out of app…"
}
The verdicts
verdict | What happened | mayBackCoverage |
|---|---|---|
SATURATED | The last several pages found nothing new. The surface looks exhausted. | true |
NOT_SATURATED | Still finding new screens when the crawl ended. There is more out there. | false |
TRUNCATED | It stopped at its page or depth limit, not because it ran out of app. | false |
INSUFFICIENT_DATA | Too few pages to say either way. | false |
mayBackCoverage is the field to branch on. When it is false, the score describes the pages
that were crawled and nothing more — do not put it in front of a customer as a verdict on their
application, and do not feed it into a coverage number.
TRUNCATED is deliberately unforgiving. However flat the discovery curve looked, a crawl that hit
its limit is one that was prevented from finding out whether there was more. A flat tail proves
nothing when something else stopped you.
Why a deeper crawl often scores LOWER
This surprises people, so it is worth stating plainly: going deeper usually makes the number go down, and that is the number getting more honest, not the application getting worse.
The first pages of most applications are the polished ones — a login, a dashboard. The admin screen nobody has touched in two years is four clicks in, and it is where the unnamed buttons live. A three-page crawl never meets it.
So treat a score from a truncated crawl as a floor on the good news, not a measurement.
stopReason, and the trap inside it
stopReason | Meaning |
|---|---|
LINKS_EXHAUSTED | Ran out of links to follow — the genuine end |
BUDGET_EXHAUSTED | Hit maxUrls — raise it and run again |
DEPTH_LIMIT | Hit maxDepth — pages it had already found were left unopened |
DEPTH_LIMIT is called out separately because it is the one that hides. At maximum depth the
crawler stops collecting links, so its queue empties — which looks exactly like running out of
application. Reported as LINKS_EXHAUSTED it would let a depth-truncated crawl be called
SATURATED with a whole tier of the app never opened. It is reported distinctly so that cannot
happen.
A worked example
A real crawl of a demo application, deliberately shallow — maxDepth: 1, maxUrls: 3:
score 22 band HARD_TO_AUTOMATE 9 controls assessed, 2 well-named, 7 need attention
examples[0]:
type button
text "Login"
quality WEAK
selector button.oxd-button
suggestion "This button can only be found by its text ("Login"), so tests break
when the wording changes and do not work in another language.
Give it a `data-testid`."
saturation INSUFFICIENT_DATA mayBackCoverage: false
Read in order:
band: HARD_TO_AUTOMATE— fewer than half the controls have an identifier a developer chose. Tests written against this page today will break on almost any change.2 of 9— the denominator is interactive controls only. Nine is small because this is a login page, not because the crawler missed things on it.- The example is the deliverable. Not "your selectors are weak" but: this button, found only by
the word Login, needs a
data-testid. That is a sentence a developer can act on without knowing anything about test automation. It also names the two concrete consequences — a copy edit breaks it, and it never worked in the German build. mayBackCoverage: false— and this is the honest part. Three pages is not enough to judge an application. The 22 is real for what was seen and must not be reported as the score. RaisemaxDepthandmaxUrlsand run it again.
The right conclusion from this output is "this login page needs work, and we have not yet measured the application" — not "this application scores 22".
What to do with a bad score
- Check
mayBackCoveragefirst. If it isfalse, crawl deeper before doing anything else. You are about to send developers to fix a sample. - Work the
exampleslist, not the score. Each entry is one attribute on one element. Most teams find the same missing convention repeated — a component library that never forwardsdata-testid, say — so a handful of fixes moves a lot of controls at once. - Re-crawl and compare. The score is only interesting as a trend. A single number is a conversation; a number that moved is a result.
- Do not chase 100.
READYat 80 means tests written today survive UI changes. The last twenty points are usually decorative controls nobody will ever write a test against.
Known limits
It scores what was crawled — see saturation
above, and always check mayBackCoverage before quoting a number.
A good score is not a guarantee. It says your controls are addressable, which means tests written against them will not break for naming reasons. It says nothing about whether the tests exist, or whether they assert anything useful.
Naming is one dimension of testability. Timing, animation, iframes, shadow DOM and dynamic ids all make an app harder to automate too. The score is about the one thing that is both measurable from a crawl and squarely in a developer's control.
Related
- Crawler & Discovery — how the inventory is produced
- Swagger test-readiness advisory — the same idea for an API spec
- How scoring works — where this feeds the readiness score