<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vadim Toptunov</title>
    <description>The latest articles on DEV Community by Vadim Toptunov (@vadimtoptunov).</description>
    <link>https://dev.to/vadimtoptunov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4202835%2F07bc66ed-966a-44c4-861e-43547800409a.jpg</url>
      <title>DEV Community: Vadim Toptunov</title>
      <link>https://dev.to/vadimtoptunov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vadimtoptunov"/>
    <language>en</language>
    <item>
      <title>I audited 100 third-party VS Code extensions. 19 ship known-CVE deps</title>
      <dc:creator>Vadim Toptunov</dc:creator>
      <pubDate>Tue, 10 Nov 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/vadimtoptunov/i-audited-100-third-party-vs-code-extensions-19-ship-known-cve-deps-4mmo</link>
      <guid>https://dev.to/vadimtoptunov/i-audited-100-third-party-vs-code-extensions-19-ship-known-cve-deps-4mmo</guid>
      <description>&lt;p&gt;Live Server — 84 million installs, the top row of the results below — bundles &lt;code&gt;websocket-driver 0.7.4&lt;/code&gt; and gets caught by CRITICAL &lt;a href="https://github.com/advisories/GHSA-xv26-6w52-cph6" rel="noopener noreferrer"&gt;GHSA-xv26-6w52-cph6&lt;/a&gt; / CVE-2026-54466. It is the loudest finding in a scan of the top 100 third-party extensions on the VS Code Marketplace, but not the only one.&lt;/p&gt;

&lt;p&gt;Every finding below is at the evidence level "Present": the vulnerable &lt;code&gt;(name, version)&lt;/code&gt; pair is inside the shipped &lt;code&gt;.vsix&lt;/code&gt; bundle. I haven't shown that any of them is reachable or exploitable; the levels are explained further down. The point of the post is not "these extensions can be hacked." It is that VS Code gives users no interface to see the dependency surface of what they install, and I could not find a publicly documented Marketplace mechanism that re-checks published bundles against advisories that appear after release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I ran this
&lt;/h2&gt;

&lt;p&gt;Two reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workspace Trust mitigated the easy extension attacks.&lt;/strong&gt; Since VS Code 1.57 (May 2021), an extension that does not declare &lt;code&gt;capabilities.untrustedWorkspaces&lt;/code&gt; support is disabled when you open a repo marked untrusted. The classic "clone a malicious repo, press F5, get RCE via &lt;code&gt;.vscode/settings.json&lt;/code&gt;" class is now gated behind the user's trust decision. The gate is not absolute — a user who clicks "Trust" through habit re-opens the surface, extensions that declare support run anyway, and the check does not reach dependency CVEs — but the easy path is meaningfully smaller than it was four years ago.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But what is under the hood?&lt;/strong&gt; VS Code extensions ship their own &lt;code&gt;node_modules&lt;/code&gt; inside the &lt;code&gt;.vsix&lt;/code&gt; bundle. Whatever the maintainer &lt;code&gt;npm install&lt;/code&gt;ed the day they published is frozen in. Advisories published after that release do not propagate backwards; I could not find a publicly documented mechanism in the Marketplace or in VS Code that tells either the maintainer or the user "an advisory now applies to something you shipped three months ago." That's the frozen-bundle problem.&lt;/p&gt;

&lt;p&gt;I wanted to measure it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method
&lt;/h2&gt;

&lt;p&gt;Reproducible in ~15 minutes with stdlib Python. No scanner install required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snapshot:&lt;/strong&gt; Marketplace and OSV queried on &lt;strong&gt;2026-11-08&lt;/strong&gt;. Install counts and advisory data are from that date; both change over time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Pull the top 200 extensions&lt;/strong&gt; from the Marketplace &lt;code&gt;extensionquery&lt;/code&gt; API, sorted by install count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Filter the sample.&lt;/strong&gt; Drop first-party publishers (&lt;code&gt;ms-*&lt;/code&gt;, &lt;code&gt;vscjava&lt;/code&gt;, &lt;code&gt;VisualStudioExptTeam&lt;/code&gt;, &lt;code&gt;MicrosoftDocs&lt;/code&gt;, &lt;code&gt;redhat&lt;/code&gt;, &lt;code&gt;Microsoft&lt;/code&gt;, &lt;code&gt;MS-CEINTL&lt;/code&gt;), &lt;code&gt;GitHub&lt;/code&gt;, and anything whose name or display title contains &lt;code&gt;language-pack&lt;/code&gt;, &lt;code&gt;icon-theme&lt;/code&gt;, &lt;code&gt;material-theme&lt;/code&gt;, &lt;code&gt;vscode-icons&lt;/code&gt;, &lt;code&gt;theme&lt;/code&gt;, &lt;code&gt;keybindings&lt;/code&gt;, or &lt;code&gt;cheatsheet&lt;/code&gt;. From 200 raw entries this kept 116; I took the top 100 by install count. The precise phrase is "top 100 non-first-party, non-theme code extensions by install count on 2026-11-08", not "top 100 extensions on the Marketplace".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Download each &lt;code&gt;.vsix&lt;/code&gt;&lt;/strong&gt; from the &lt;code&gt;/vspackage&lt;/code&gt; endpoint. A &lt;code&gt;.vsix&lt;/code&gt; is a zip. Gentle pacing at 1 req/sec. All 100 downloads succeeded (~1.6 GB total).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Enumerate every &lt;code&gt;node_modules/&amp;lt;pkg&amp;gt;/package.json&lt;/code&gt;&lt;/strong&gt; inside &lt;code&gt;extension/&lt;/code&gt; after unzipping. For the top 100 this gave 1,357 nested dependency manifests, of which 935 were unique by &lt;code&gt;(name, version)&lt;/code&gt; (712 unique package names, many shipped in two or more extensions at the same version). 64 of the 100 extensions had zero shipped &lt;code&gt;node_modules&lt;/code&gt; entries — some genuinely have no runtime deps, most are built with &lt;code&gt;esbuild&lt;/code&gt;/&lt;code&gt;webpack&lt;/code&gt;/&lt;code&gt;rollup&lt;/code&gt; into a single JS file, and the scanner is blind to CVEs inside those bundles. See Limitations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Batch-query OSV.dev&lt;/strong&gt; at &lt;code&gt;/v1/querybatch&lt;/code&gt; — 500 queries per request, free, no auth. OSV covers GHSA, so every npm advisory shows up. Collect every matched advisory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Score and rank.&lt;/strong&gt; For each &lt;code&gt;(extension, dependency, advisory)&lt;/code&gt; row I keep the worst severity and compute &lt;code&gt;score = installs × severity_weight&lt;/code&gt; with &lt;code&gt;CRITICAL=10, HIGH=5, MODERATE=2, LOW=1&lt;/code&gt;. The results table below is sorted by install count — most-shipped at the top. The &lt;code&gt;score&lt;/code&gt; column is in the companion CSV for anyone who wants to re-rank.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Affected&lt;/strong&gt; means: at least one &lt;code&gt;(dependency, version)&lt;/code&gt; pair inside the extension bundle has a matching OSV advisory of any severity. It does not imply the code path is loaded at runtime, that user input can reach it, or that any impact is realised.&lt;/p&gt;

&lt;p&gt;Six Python scripts, 527 lines, stdlib only, plus &lt;code&gt;diff_runs.py&lt;/code&gt; for comparing two runs. Three APIs: Marketplace's &lt;code&gt;extensionquery&lt;/code&gt;, Marketplace's &lt;code&gt;/vspackage&lt;/code&gt;, OSV's &lt;code&gt;querybatch&lt;/code&gt;. No auth. No quota.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Three different counts, because the numbers answer three different questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;19 / 100 sampled extensions had at least one &lt;code&gt;(dependency, version)&lt;/code&gt; inside their bundle matching an OSV advisory.&lt;/li&gt;
&lt;li&gt;103 unique &lt;code&gt;(extension, dependency)&lt;/code&gt; pairs with ≥1 matching advisory. A pair can match multiple advisories — a dep may have several open advisories or be below more than one fix threshold — so this is less than the total advisory matches.&lt;/li&gt;
&lt;li&gt;149 distinct OSV record IDs across those 103 pairs — all GHSAs; alias-overlap check against each record's CVE aliases found no duplicate underlying vulnerability (i.e. 149 records = 149 underlying issues for this sample, with 2 of the 149 having no CVE alias). One advisory often appears in many extensions (&lt;code&gt;lodash&lt;/code&gt;, &lt;code&gt;minimatch&lt;/code&gt;, &lt;code&gt;braces&lt;/code&gt;, &lt;code&gt;brace-expansion&lt;/code&gt; and &lt;code&gt;semver&lt;/code&gt; show up most often), so advisory-id-count and pair-count diverge.&lt;/li&gt;
&lt;li&gt;Severity breakdown of the 103 pairs, each counted once at its worst-severity advisory: 12 CRITICAL, 61 HIGH, 24 MODERATE, 6 LOW.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sorted by install count on the snapshot date. "Worst sev" is the maximum severity across all matched advisories inside that extension's bundle.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Installs&lt;/th&gt;
&lt;th&gt;Extension&lt;/th&gt;
&lt;th&gt;Worst sev&lt;/th&gt;
&lt;th&gt;Lead deps (abridged)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;84,254,316&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ritwickdey.LiveServer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CRITICAL&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;websocket-driver 0.7.4&lt;/code&gt;, &lt;code&gt;braces&lt;/code&gt;, &lt;code&gt;lodash&lt;/code&gt;, &lt;code&gt;js-yaml&lt;/code&gt;, +14 more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;42,727,107&lt;/td&gt;
&lt;td&gt;&lt;code&gt;formulahendry.code-runner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HIGH&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;braces&lt;/code&gt;, &lt;code&gt;picomatch&lt;/code&gt; (ReDoS class)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;19,651,537&lt;/td&gt;
&lt;td&gt;&lt;code&gt;christian-kohler.path-intellisense&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HIGH&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;json5&lt;/code&gt;, &lt;code&gt;minimatch&lt;/code&gt;, &lt;code&gt;brace-expansion&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;18,267,232&lt;/td&gt;
&lt;td&gt;&lt;code&gt;batisteo.vscode-django&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HIGH&lt;/td&gt;
&lt;td&gt;&lt;code&gt;toml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;15,805,395&lt;/td&gt;
&lt;td&gt;&lt;code&gt;xdebug.php-debug&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HIGH&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@xmldom/xmldom&lt;/code&gt;, &lt;code&gt;moment&lt;/code&gt;, &lt;code&gt;brace-expansion&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;15,603,346&lt;/td&gt;
&lt;td&gt;&lt;code&gt;octref.vetur&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HIGH&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lodash&lt;/code&gt;, &lt;code&gt;js-yaml&lt;/code&gt;, &lt;code&gt;cross-spawn&lt;/code&gt;, +3 more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;15,369,970&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EditorConfig.EditorConfig&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HIGH&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;brace-expansion&lt;/code&gt;, &lt;code&gt;minimatch&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;13,517,697&lt;/td&gt;
&lt;td&gt;&lt;code&gt;oderwat.indent-rainbow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CRITICAL&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;decompress&lt;/code&gt; (zip-slip), &lt;code&gt;koa&lt;/code&gt;, +4 more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;12,451,186&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cschlosser.doxdocgen&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CRITICAL&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;simple-git&lt;/code&gt;, &lt;code&gt;moment&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10,683,614&lt;/td&gt;
&lt;td&gt;&lt;code&gt;HookyQR.beautify&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CRITICAL&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;minimist&lt;/code&gt; (proto pollution), +4 more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;10,027,714&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Zignd.html-css-class-completion&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CRITICAL&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;form-data&lt;/code&gt;, &lt;code&gt;json-schema&lt;/code&gt;, &lt;code&gt;lodash&lt;/code&gt;, +3 more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;9,742,813&lt;/td&gt;
&lt;td&gt;&lt;code&gt;naumovs.color-highlight&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HIGH&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;brace-expansion&lt;/code&gt;, &lt;code&gt;minimatch&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;8,170,674&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DotJoshJohnson.xml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CRITICAL&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lodash&lt;/code&gt;, &lt;code&gt;minimist&lt;/code&gt;, &lt;code&gt;xmldom&lt;/code&gt;, +3 more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;7,479,892&lt;/td&gt;
&lt;td&gt;&lt;code&gt;platformio.platformio-ide&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CRITICAL&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tar&lt;/code&gt; (symlink override), +5 more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;6,766,249&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CoenraadS.bracket-pair-colorizer-2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CRITICAL&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;minimist&lt;/code&gt;, &lt;code&gt;json5&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;6,696,727&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Oracle.oracle-java&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HIGH&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;brace-expansion&lt;/code&gt;, &lt;code&gt;fast-uri&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;6,140,258&lt;/td&gt;
&lt;td&gt;&lt;code&gt;zobo.php-intellisense&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HIGH&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;brace-expansion&lt;/code&gt;, &lt;code&gt;cross-spawn&lt;/code&gt;, &lt;code&gt;minimatch&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;5,887,595&lt;/td&gt;
&lt;td&gt;&lt;code&gt;James-Yu.latex-workshop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MODERATE&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;glob&lt;/code&gt;, &lt;code&gt;minimatch&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;5,517,213&lt;/td&gt;
&lt;td&gt;&lt;code&gt;steoates.autoimport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HIGH&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lodash&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Full per-extension, per-advisory breakdown with versions and OSV IDs is in &lt;code&gt;SUMMARY.md&lt;/code&gt; and &lt;code&gt;DATASET.csv&lt;/code&gt; in the companion repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Levels of evidence
&lt;/h2&gt;

&lt;p&gt;The headline number is Present, not Exploitable. The distinction matters, and most writeups in this space blur it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;What has been established&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Present&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A vulnerable &lt;code&gt;(name, version)&lt;/code&gt; pair is in the shipped bundle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Loaded&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The extension's runtime actually loads this copy of the module&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reachable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A code path that reaches the vulnerable code is exercised during normal use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Exploitable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;An adversary-controlled input reaches the vulnerable code in a reproduced PoC&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every number in the Results table is Present. For a few deps I believe Loaded is very likely from static reading, but I did not instrument the extensions to prove it. Reachable and Exploitable are not claimed for any row.&lt;/p&gt;

&lt;p&gt;Publishing at Present is still worth it: the frozen-bundle pattern is a measurable property of the ecosystem, and 19 / 100 is a baseline you can compare future runs against. Upgrading individual rows to higher evidence levels is per-extension work, not a batch job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;Three things to flag before anyone cites a number from this post (and the evidence level above: every row is Present, nothing more):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The scanner cannot see dependencies inside bundled JS.&lt;/strong&gt; It walks &lt;code&gt;extension/node_modules/&amp;lt;pkg&amp;gt;/package.json&lt;/code&gt;. An extension built with &lt;code&gt;esbuild&lt;/code&gt;, &lt;code&gt;webpack&lt;/code&gt;, or &lt;code&gt;rollup&lt;/code&gt; into a single JS file contains its dependencies' code but no per-dep &lt;code&gt;package.json&lt;/code&gt;, so the scanner records zero deps for it. 64 of the 100 sampled extensions had zero shipped &lt;code&gt;node_modules&lt;/code&gt; entries (a measured count). Some genuinely have no runtime deps; the rest are most likely bundled (I did not individually inspect each build output), and whatever CVEs are riding inside those bundles are invisible to this method. So &lt;strong&gt;19 / 100 is the observed rate of extensions in this sample where this manifest-based inventory matched at least one OSV advisory&lt;/strong&gt; — not a lower bound on vulnerable code, and not an estimate of overall prevalence of vulnerable code in VS Code extensions. Covering the bundled case properly needs an SBOM at build time, which the Marketplace does not require.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sample is top-by-installs, not top-by-risk.&lt;/strong&gt; A niche extension with 50k installs might ship worse code than a top-10 extension and wouldn't appear in this cut. The chosen sample serves the "most-shipped bundles" question; a risk-weighted sample would look different.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One snapshot is a data point, not a trend.&lt;/strong&gt; The repo pins 2026-11-08 under &lt;code&gt;runs/&lt;/code&gt; and ships &lt;code&gt;diff_runs.py&lt;/code&gt; for quarterly comparisons. The next re-run is scheduled for 2027-02-08.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A closer look at Live Server
&lt;/h2&gt;

&lt;p&gt;The highest-install row in the table. Tracing it as far as the static scan allows.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ritwickdey.LiveServer 5.7.10&lt;/code&gt; ships &lt;code&gt;extension/node_modules/live-server/&lt;/code&gt; — the &lt;code&gt;live-server&lt;/code&gt; npm package, which the extension wraps as its HTTP + live-reload server. &lt;code&gt;live-server&lt;/code&gt; depends on &lt;code&gt;faye-websocket&lt;/code&gt;, which depends on &lt;code&gt;websocket-driver 0.7.4&lt;/code&gt; — present under &lt;code&gt;extension/node_modules/websocket-driver/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/advisories/GHSA-xv26-6w52-cph6" rel="noopener noreferrer"&gt;&lt;code&gt;GHSA-xv26-6w52-cph6&lt;/code&gt;&lt;/a&gt; / CVE-2026-54466 — &lt;em&gt;"websocket-driver: Message corruption via abuse of protocol length headers"&lt;/em&gt;. Severity critical per the GitHub Advisory API. Vulnerable range: &lt;code&gt;&amp;lt; 0.7.5&lt;/code&gt;; first patched version: &lt;code&gt;0.7.5&lt;/code&gt;. Advisory published on 2026-07-15 (cross-checked against the GitHub Advisory API on 2026-11-08).&lt;/p&gt;

&lt;p&gt;The chronology is the interesting part. Live Server v5.7.10 was last updated on the Marketplace on 2026-02-27 — about five months &lt;em&gt;before&lt;/em&gt; the GHSA on &lt;code&gt;websocket-driver&lt;/code&gt; was published. The maintainer wasn't ignoring a known advisory. The advisory simply did not exist yet. A reasonable &lt;code&gt;npm install&lt;/code&gt; on 2026-02-27 picks up &lt;code&gt;websocket-driver 0.7.4&lt;/code&gt;, which was clean at the time. Five months later an advisory is filed against that version. The bundle on disk does not change, and I could not find anything user-visible in the Marketplace that surfaces the new risk.&lt;/p&gt;

&lt;p&gt;Reachability without a PoC: &lt;code&gt;faye-websocket&lt;/code&gt; is &lt;code&gt;live-server&lt;/code&gt;'s WebSocket layer, and the live-reload channel is the extension's headline feature, so this copy of &lt;code&gt;websocket-driver&lt;/code&gt; is very likely loaded when Live Server is running. I did not instrument the extension to confirm, and I did not attempt a crafted-frame reproduction — the finding sits at Present and likely Loaded, no higher. If you do the dynamic work, the repo has everything needed to reproduce the inventory and I'll link verified follow-ups.&lt;/p&gt;

&lt;p&gt;Static takeaway: an extension ranked 8th by installs on the unfiltered Marketplace on 2026-11-08 (and 1st in this filtered third-party sample) ships a CRITICAL advisory on a module that sits under its headline feature, four months after the advisory was filed, with nothing user-visible in the install flow flagging it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three observations from the shape of this list
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Install count is not a maintenance signal.&lt;/strong&gt; 84M installs doesn't mean anyone upstream is watching the advisory feed on your behalf. It means a lot of people downloaded the thing once. The maintainer may or may not be active — in Live Server's case they shipped a release about eight months before this snapshot, and the vulnerable dep is still there because the advisory postdates the release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The still-on-disk-after-dormancy problem.&lt;/strong&gt; Bracket Pair Colorizer 2 was last updated on the Marketplace in March 2022. VS Code has shipped native bracket pair colorization in the core editor since 2021 (&lt;code&gt;editor.bracketPairColorization.enabled&lt;/code&gt;). The extension has accumulated roughly 6.7M Marketplace installs; the public count doesn't say how many active copies remain, but every active copy still loads on VS Code startup if enabled and still ships &lt;code&gt;minimist&lt;/code&gt; with a CRITICAL prototype-pollution advisory. VS Code does not uninstall dormant extensions on your behalf.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lots of overlap on the same tired deps.&lt;/strong&gt; &lt;code&gt;brace-expansion&lt;/code&gt;, &lt;code&gt;minimatch&lt;/code&gt;, &lt;code&gt;braces&lt;/code&gt;, &lt;code&gt;lodash&lt;/code&gt;, &lt;code&gt;semver&lt;/code&gt; show up across most of the affected extensions. One upstream fix, one maintainer release, several vulnerable extensions become clean. Maintainers usually don't notice until someone emails them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The size side of the problem
&lt;/h2&gt;

&lt;p&gt;Not every extension ships a big bundle, but the ones that do carry a lot of dead surface along for the ride. Live Server's unpacked &lt;code&gt;extension/&lt;/code&gt; directory is 30 MB, and almost all of that is &lt;code&gt;extension/node_modules/&lt;/code&gt;. The actual Live Server glue code — the TypeScript that bridges &lt;code&gt;live-server&lt;/code&gt; into the VS Code extension API — is tiny; the bulk is transitive dependencies the npm resolver dragged in.&lt;/p&gt;

&lt;p&gt;Bundling with &lt;code&gt;esbuild&lt;/code&gt; or &lt;code&gt;webpack&lt;/code&gt; can substantially reduce unused code — whether it removes a specific vulnerable path depends on the dependency graph, side effects, dynamic imports, and build configuration. A transitive dep that &lt;em&gt;is&lt;/em&gt; called stays in the bundle, and the vulnerable code path inside it still ships, but bundling typically shrinks both the bundle and the matched-advisory count. Measure the audit output before and after; do not assume the number went to zero.&lt;/p&gt;

&lt;p&gt;For comparison: Code Runner's whole unpacked tree is 1.8 MB (1.2 MB of &lt;code&gt;node_modules&lt;/code&gt;). Still shipping two HIGH advisories, but far less dead surface by volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  What VS Code could do
&lt;/h2&gt;

&lt;p&gt;OSV's &lt;code&gt;/v1/querybatch&lt;/code&gt; is free. The Marketplace already runs malware scans on &lt;code&gt;.vsix&lt;/code&gt; uploads. Running OSV against the shipped &lt;code&gt;node_modules&lt;/code&gt; of a &lt;code&gt;.vsix&lt;/code&gt; on upload — exactly what the scripts in the repo do — would surface the top-20 findings on this list in an afternoon. This same approach runs into the bundled-JS blind spot (see Limitations); covering bundled extensions properly needs an SBOM at build time, which &lt;code&gt;vsce&lt;/code&gt; could require as a publisher input.&lt;/p&gt;

&lt;p&gt;More importantly, the same scan could run periodically against already-published &lt;code&gt;.vsix&lt;/code&gt; artefacts and notify the publisher when a new advisory applies. A count surfaced on the public install page would let users factor it into their choice. That's the backfill mechanism I could not find in the public Marketplace feature set at the snapshot date — if one exists internally, I'd update the post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What maintainers can do
&lt;/h2&gt;

&lt;p&gt;For anyone shipping a VS Code extension right now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audit the final &lt;code&gt;.vsix&lt;/code&gt;, not just the source tree.&lt;/strong&gt; &lt;code&gt;npm audit&lt;/code&gt; against your working copy reports what's in your lockfile — a source-tree check, not an inventory of what shipped. For extensions that ship unpacked &lt;code&gt;node_modules&lt;/code&gt;: &lt;code&gt;vsce package&lt;/code&gt;, unzip, enumerate every &lt;code&gt;(name, version)&lt;/code&gt; under &lt;code&gt;extension/node_modules/*/package.json&lt;/code&gt; (and the &lt;code&gt;@scope/name&lt;/code&gt; variant), query OSV. That's an artifact-level inventory for the unpacked part — exactly what &lt;code&gt;audit_extensions.py&lt;/code&gt; in the repo does. For extensions you ship as a bundled JS file this inventory misses your deps entirely: generate an SBOM at build time (e.g. CycloneDX from your bundler's manifest), keep provenance linking the SBOM to the published &lt;code&gt;.vsix&lt;/code&gt;, and audit the SBOM alongside the artifact. Neither path proves anything about reachability or exploitability on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundle with &lt;code&gt;esbuild&lt;/code&gt; or &lt;code&gt;webpack&lt;/code&gt;.&lt;/strong&gt; Shipped &lt;code&gt;node_modules&lt;/code&gt; directories are often large and contain code the extension never calls. Tree-shaking typically reduces both size and the matched-advisory count. Not a guarantee — vulnerable paths inside deps you &lt;em&gt;do&lt;/em&gt; call still ship. Measure before/after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-audit already-published releases on a schedule.&lt;/strong&gt; The chronology in the Live Server case — advisory published five months after the release — isn't unusual. A quarterly re-check of your already-published &lt;code&gt;.vsix&lt;/code&gt; against OSV catches advisories that appear after you ship. Treat it as the backfill the Marketplace does not provide.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What users can do
&lt;/h2&gt;

&lt;p&gt;Honest answer: not much, today.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don't install extensions you don't use. It's the one step that removes the dependencies entirely.&lt;/li&gt;
&lt;li&gt;Prefer extensions whose Marketplace page shows a recent "last updated" date. A long-stale date is a yellow flag. Recent activity isn't a clean bill of health — Live Server was updated about eight months before this snapshot and still ships a CRITICAL — but a long gap is clearly worse.&lt;/li&gt;
&lt;li&gt;Keep Workspace Trust ON. It does not address dependency CVEs, but it gates the much worse "arbitrary RCE on repo open" class behind an explicit prompt.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Running it yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query_marketplace.py     87 lines — Marketplace extensionquery
filter_publishers.py     49 lines — drop first-party + themes
download_vsix.py         61 lines — /vspackage, 1 req/sec
audit_extensions.py     137 lines — unpack + OSV batch
rank_findings.py        113 lines — enrich with GHSA severity
summarize.py             80 lines — collapse + markdown
                        ---
                        527 lines — stdlib only

diff_runs.py            108 lines — compare two snapshots
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running it quarterly against the top 100 is a useful ambient check. Running against the top 500 is the same with an hour-long download. I'll probably do both and track the shape over the next year — next snapshot already scheduled for 2027-02-08.&lt;/p&gt;

&lt;p&gt;Full per-extension breakdown with every &lt;code&gt;(dep, version, advisory)&lt;/code&gt; row, the CSV dataset and the scripts are in the companion repo:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/vadimtoptunov/kassi-vsix-audit" rel="noopener noreferrer"&gt;https://github.com/vadimtoptunov/kassi-vsix-audit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you re-run this against a different sample or a later snapshot and the shape of the list has moved, send me the diff — I'll link it.&lt;/p&gt;




</description>
      <category>security</category>
      <category>opensource</category>
      <category>vscode</category>
      <category>javascript</category>
    </item>
    <item>
      <title>UI tests that stayed green on a broken app, and what it took to make them fail</title>
      <dc:creator>Vadim Toptunov</dc:creator>
      <pubDate>Mon, 02 Nov 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/vadimtoptunov/ui-tests-that-stayed-green-on-a-broken-app-and-what-it-took-to-make-them-fail-3ln</link>
      <guid>https://dev.to/vadimtoptunov/ui-tests-that-stayed-green-on-a-broken-app-and-what-it-took-to-make-them-fail-3ln</guid>
      <description>&lt;p&gt;I had 34 UI tests, 135 switchable bugs, a mock backend that answered in 120 ms, a price feed that ticked every second, and a minus sign that wasn’t a hyphen. Not that I needed all of that to test one banking app. But once you’ve built a serious collection of bugs, you want to switch every one of them on and see what happens.&lt;/p&gt;

&lt;p&gt;On the clean build, all 34 tests were green. That’s nice, but it only says the app works. It says nothing about whether the tests would notice if it didn’t.&lt;/p&gt;

&lt;p&gt;So I started breaking the app on purpose and watching which tests went red. The first results weren't great. Nine of the 34 tests never went red, whatever I broke. And when I switched on the 23 money bugs one at a time, 18 of them got through the whole suite without a single red test.&lt;/p&gt;

&lt;p&gt;Below are the four that taught me the most, and what I changed in the tests. None of it is specific to my app or my framework. I'd bet most UI suites have at least one of these.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the app gets broken
&lt;/h2&gt;

&lt;p&gt;The app is &lt;a href="https://github.com/VadimToptunov/ChaosBank-iOS" rel="noopener noreferrer"&gt;ChaosBank&lt;/a&gt;, a banking app I built to practise mobile testing on (&lt;a href="https://dev.to/vadimtoptunov/chaosbank-app-131-switchable-bugs-to-practice-mobile-testing-on-22ol"&gt;intro post&lt;/a&gt;). By default its code is correct. It also has 135 named bugs, and each one can be switched on at launch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kt"&gt;Defects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pnlSign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// the small, deliberate bug&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// the correct implementation&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests are the &lt;a href="https://github.com/VadimToptunov/ChaosBank-KassiOS-Tests" rel="noopener noreferrer"&gt;ChaosBank × KassiOS reference suite&lt;/a&gt;, written on &lt;a href="https://github.com/VadimToptunov/KassiOS" rel="noopener noreferrer"&gt;KassiOS&lt;/a&gt;, my layer over XCUITest (&lt;a href="https://dev.to/vadimtoptunov/kassios-a-kaspresso-style-layer-for-xcuitest-tested-against-a-broken-bank-4mef"&gt;the post about it&lt;/a&gt;). A bug is switched on through the test runner's environment, so the tests themselves stay the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TEST_RUNNER_CB_INJECT_DEFECTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pnlSign xcodebuild test-without-building …
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this sounds like mutation testing, it is, just at a different level. Tools like &lt;a href="https://pitest.org/" rel="noopener noreferrer"&gt;PIT&lt;/a&gt;, &lt;a href="https://stryker-mutator.io/" rel="noopener noreferrer"&gt;Stryker&lt;/a&gt; and &lt;a href="https://github.com/muter-mutation-testing/muter" rel="noopener noreferrer"&gt;Muter&lt;/a&gt; change your code automatically thousands of times and rerun the unit tests after each change. UI tests are far too slow for that, so ChaosBank's bugs are written by hand, at the level a user would notice: a loss shown as a gain, a total that skips an account. The suite runs once per bug. If some test goes red, the bug is "killed". If everything stays green, it "survived".&lt;/p&gt;

&lt;h2&gt;
  
  
  A wrong number is still a number
&lt;/h2&gt;

&lt;p&gt;This was the first test that stayed green with every bug on at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;test_portfolio_showsTotalAndPnl&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;launchUnlocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"portfolio"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PortfolioScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;totalValue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertVisible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pnl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertPresent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertExists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of those bugs, &lt;code&gt;pnlSign&lt;/code&gt;, shows TSLA's loss as a gain. The test didn't care. It checks that the numbers are on the screen, and a wrong number is on the screen just as well as a right one. To be fair, the name says exactly that: "shows total and P&amp;amp;L". It never promised they'd be correct.&lt;/p&gt;

&lt;p&gt;The fix should have been one line. TSLA is held at a loss, so its P&amp;amp;L should have a minus in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;test_portfolio_tslaLoss_isRenderedAsLoss&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;launchUnlocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"portfolio"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PortfolioScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;holdingPnl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"TSLA"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertLabelContains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"−"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It took two detours.&lt;/p&gt;

&lt;p&gt;The first: on the clean build the test failed with "does not exist", even though &lt;code&gt;portfolio.holding.TSLA.pnl&lt;/code&gt; was right there in the view code. I dumped the accessibility tree and saw that every text in the holdings list had the identifier of the list itself. It turns out that in SwiftUI, &lt;code&gt;.accessibilityIdentifier&lt;/code&gt; on a container gets passed down to its children and replaces their own identifiers, unless the container is marked &lt;code&gt;.accessibilityElement(children: .contain)&lt;/code&gt;. The row identifiers had never reached XCUITest at all.&lt;/p&gt;

&lt;p&gt;That explained the old test. It could only check presence, because there was no way to find the value. I looked for the same pattern in the rest of the app and found it in 14 more places: the login gate, the card, the market rows, the transaction rows, the confirm sheets. The tests had quietly worked around nearly all of them. The CVV, for example, was found by looking for a text containing "CVV" among everything on the card. &lt;a href="https://github.com/VadimToptunov/ChaosBank-iOS/pull/2" rel="noopener noreferrer"&gt;Fixing the app&lt;/a&gt; took one modifier per container, and after that I could delete 81 lines of workarounds from the tests.&lt;/p&gt;

&lt;p&gt;The second detour: my first version checked for &lt;code&gt;"-"&lt;/code&gt; and failed on the clean build with &lt;code&gt;expected label containing '-' but found '−$229.84'&lt;/code&gt;. ChaosBank formats money with U+2212, the real minus sign. It looks like a hyphen but it's a different character. If you check a formatted value, check it for the characters the formatter actually puts there.&lt;/p&gt;

&lt;h2&gt;
  
  
  €100 was the wrong amount to test with
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;transferRoundsUp&lt;/code&gt; takes €101 from your account when you send €100.40. The original transfer test sent €100. With €100, a correct transfer and a rounded-up one are the same €100, so no check in the world could have caught this with that input.&lt;/p&gt;

&lt;p&gt;There's a second bug on the same flow, &lt;code&gt;transferDebitsWrongAccount&lt;/code&gt;, which takes the money from USD instead of EUR. That one is easy to catch, as long as the test looks at EUR at all. The old test only checked that the confirm sheet closed.&lt;/p&gt;

&lt;p&gt;The new test sends an amount with cents, and also checks the account that shouldn't change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// A €100.40 transfer debits exactly €100.40 from EUR and leaves USD alone.&lt;/span&gt;
&lt;span class="c1"&gt;/// Catches `transferRoundsUp` and `transferDebitsWrongAccount`.&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;test_transfer_debitsExactlyTheAmountFromEUR&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;launchUnlocked&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;eurBefore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usdBefore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nan&lt;/span&gt;
    &lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;HomeScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="n"&gt;eurBefore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastNumber&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;usdBefore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastNumber&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;home&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transferAction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;TransferScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;transfer&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recipientField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;typeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Alex"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amountField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;typeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"100.40"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;continueButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confirmButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confirmButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;within&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntilGone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;HomeScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="kt"&gt;XCTAssertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;home&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastNumber&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;eurBefore&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;100.40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;accuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.005&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kt"&gt;XCTAssertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;home&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastNumber&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;usdBefore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;accuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.005&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Every number was right except the total
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;totalValueOmitsHolding&lt;/code&gt; leaves ETH out of the portfolio total. Each holding shows the right value. The total is formatted correctly. It's just the wrong sum, and none of my tests ever compared the total with the holdings.&lt;/p&gt;

&lt;p&gt;So I wrote that test: read the holdings, add them up, compare with the total. It failed on the clean build every now and then. Prices in ChaosBank tick, and reading four labels one after another can catch two different ticks. The values have to be read at the same moment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;test_portfolio_total_equalsSumOfHoldings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;launchUnlocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"portfolio"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AAPL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"TSLA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ETH"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"portfolio.holding.&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;.value"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[:]&lt;/span&gt;
    &lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PortfolioScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readTexts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"portfolio.totalValue"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;XCTUnwrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;numbers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;XCTUnwrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;XCTUnwrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;numbers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;XCTUnwrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"portfolio.totalValue"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;XCTAssertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;accuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;readTexts&lt;/code&gt; reads all the labels from one snapshot of the screen. Once I had it, I wrote a few more checks of the same kind: the Home total against its three accounts at the app's exchange rates, a holding's value against the market price, and the loan's monthly payment against the advertised rate. On a money screen, a lot of what can go wrong is in how the numbers relate to each other, not in any single number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The double tap that never raced
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;exchangeDoubleSubmit&lt;/code&gt; removes the guard that stops you from tapping Execute twice, so a fast double tap makes two exchanges. I had a test that taps twice. It stayed green.&lt;/p&gt;

&lt;p&gt;There were two reasons. The mock backend answers in about 120 ms, and two separate synthesized taps arrive further apart than that. So the first exchange had already finished when the second tap came in, the guard had nothing to stop, and two exchanges in a row is also what two deliberate taps would give you. &lt;code&gt;XCUIElement.doubleTap()&lt;/code&gt; wouldn't help either: it's one system gesture, and the button turns it into one action. It can't race anything.&lt;/p&gt;

&lt;p&gt;What worked was slowing the network down. ChaosBank's dev menu has a "Slow" network setting (a network condition, not a bug), which keeps the request in flight for 3 seconds. The test turns it on, taps twice on the same spot without checking the button in between, and then counts rows in the history instead of checking that the sheet closed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ExchangeScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amountField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;typeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"100"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executeButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rapidTap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;times&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executeButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;within&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntilGone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;HomeScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seeAllActivity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;TransactionsScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;closeTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;countBefore&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;tolerance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.001&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the guard in place there's one new row, without it there are two. The same trick caught the double charge on transfers and the double buy on orders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whole groups that turned nothing red
&lt;/h2&gt;

&lt;p&gt;In the first run, the &lt;code&gt;localization&lt;/code&gt; and &lt;code&gt;flaky&lt;/code&gt; groups didn't turn a single test red, and &lt;code&gt;security&lt;/code&gt; turned one. Nothing was wrong with the tests there. There just weren't any tests for those things. You don't see that from a green run on the clean build. You only see it when you switch the bugs on.&lt;/p&gt;

&lt;p&gt;Most of the tests I added for them are short:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;under the German locale, numbers group as &lt;code&gt;1.234.567,89&lt;/code&gt; and the euro sign comes last;&lt;/li&gt;
&lt;li&gt;a transfer amount typed as &lt;code&gt;1,000.50&lt;/code&gt; parses correctly;&lt;/li&gt;
&lt;li&gt;a wrong OTP is rejected, and three wrong ones lock the step;&lt;/li&gt;
&lt;li&gt;the card number is masked except for the last four digits;&lt;/li&gt;
&lt;li&gt;the session token is in the Keychain (the dev menu reports where it is);&lt;/li&gt;
&lt;li&gt;a deep link opened while the app is locked doesn't skip the login screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two bugs no test can catch on this data
&lt;/h2&gt;

&lt;p&gt;Of the 23 money bugs, 21 are caught now. The other two, &lt;code&gt;detailChangeWrongBase&lt;/code&gt; and &lt;code&gt;pnlPercentAbsOnly&lt;/code&gt;, look exactly like the correct code. The first computes a percentage against the wrong base, but on the simulated price feed both bases give the same number. The second drops the sign from a P&amp;amp;L percentage, but the seeded portfolio is up overall, so the sign is positive anyway.&lt;/p&gt;

&lt;p&gt;In mutation testing these are called equivalent mutants. Here they're only equivalent on this particular data. I didn't write tests for them, but they're a good reminder that the test data decides which bugs you can see in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the suite is now
&lt;/h2&gt;

&lt;p&gt;There are 71 tests. Every test that's meant to catch a bug says which one in a doc comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// A €100.40 transfer debits exactly €100.40 from EUR and leaves USD alone.&lt;/span&gt;
&lt;span class="c1"&gt;/// Catches `transferRoundsUp` and `transferDebitsWrongAccount`.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A script collects these lines into a list, and &lt;code&gt;kassios mutants&lt;/code&gt; switches on each listed bug and runs its tests, one bug at a time. Before that it runs all the tests on the clean build, and they all have to pass. All 59 bugs on the list are caught. That's 59 of ChaosBank's 135. The performance and network bugs have no tests yet, and most of the UI and state ones aren't covered by any test.&lt;/p&gt;

&lt;p&gt;There's one problem I haven't fixed. On a slower CI machine running two simulators at once, two of the 59 got through. One was the deep link test. It waits a fixed 2 seconds and then checks that the login screen is still there. Under load, the app most likely hadn't handled the link within those 2 seconds even with the bug on, so the test passed. A test that checks "nothing happened" by waiting a while will pass whenever the machine is too slow for anything to happen yet. The fix is to wait for a sign that the link was handled, and that's still on my list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying this on your own app
&lt;/h2&gt;

&lt;p&gt;You don't need ChaosBank or KassiOS for any of this. Pick one screen with numbers on it. In a local build, break one thing on purpose: flip a sign, drop an item from a total, round an amount. Run your tests. If nothing goes red, you know which test to write next. And before you trust a new test, make it fail once on a build where the thing it checks is broken.&lt;/p&gt;

&lt;p&gt;If you'd rather have something ready to break, every ChaosBank bug is listed in &lt;a href="https://github.com/VadimToptunov/ChaosBank-iOS/blob/main/exercises.json" rel="noopener noreferrer"&gt;&lt;code&gt;exercises.json&lt;/code&gt;&lt;/a&gt;, with the screen, the locators and what the broken build does. You can switch one on from the command line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;xcrun simctl launch booted VadimToptunov.ChaosBank &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-ChaosBankStartUnlocked&lt;/span&gt; 1 &lt;span class="nt"&gt;-ChaosBankTab&lt;/span&gt; portfolio &lt;span class="nt"&gt;-ChaosBankDefects&lt;/span&gt; pnlSign
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody has touched the performance and network bugs yet, if you're looking for somewhere to start. And I'd like to hear which of these mistakes you've seen in your own tests, and which ones I haven't run into yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/VadimToptunov/ChaosBank-iOS" rel="noopener noreferrer"&gt;ChaosBank for iOS&lt;/a&gt; and &lt;a href="https://github.com/VadimToptunov/ChaosBank-Android" rel="noopener noreferrer"&gt;ChaosBank for Android&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/VadimToptunov/ChaosBank-KassiOS-Tests" rel="noopener noreferrer"&gt;ChaosBank × KassiOS reference suite&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/VadimToptunov/KassiOS" rel="noopener noreferrer"&gt;KassiOS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pitest.org/" rel="noopener noreferrer"&gt;PIT&lt;/a&gt;, &lt;a href="https://stryker-mutator.io/" rel="noopener noreferrer"&gt;Stryker&lt;/a&gt;, &lt;a href="https://github.com/muter-mutation-testing/muter" rel="noopener noreferrer"&gt;Muter&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/swiftui/view/accessibilityelement%28children:%29" rel="noopener noreferrer"&gt;&lt;code&gt;accessibilityElement(children:)&lt;/code&gt; in Apple's documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>testing</category>
      <category>qa</category>
      <category>mobile</category>
      <category>ios</category>
    </item>
    <item>
      <title>KassiOS: a Kaspresso-style layer for XCUITest, tested against a broken bank</title>
      <dc:creator>Vadim Toptunov</dc:creator>
      <pubDate>Wed, 28 Oct 2026 16:12:26 +0000</pubDate>
      <link>https://dev.to/vadimtoptunov/kassios-a-kaspresso-style-layer-for-xcuitest-tested-against-a-broken-bank-4mef</link>
      <guid>https://dev.to/vadimtoptunov/kassios-a-kaspresso-style-layer-for-xcuitest-tested-against-a-broken-bank-4mef</guid>
      <description>&lt;p&gt;My background is QA automation, mostly mobile and mostly Appium. When I decided to learn native iOS UI testing properly, &lt;a href="https://developer.apple.com/documentation/xctest/user_interface_tests" rel="noopener noreferrer"&gt;XCUITest&lt;/a&gt; looked like the obvious choice: it's Apple's own framework, it needs no automation server, and it works with the accessibility hierarchy directly. Then a simple login turned into this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;textFields&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"login.email"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="kt"&gt;XCTAssertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForExistence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"Email field did not appear"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;typeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"test@example.com"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;loginButton&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buttons&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"login.submit"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="kt"&gt;XCTAssertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loginButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForExistence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"Login button did not appear"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;loginButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;XCUITest gives you primitives and leaves waiting, retries, screen objects and reporting to you. In most projects I've seen, that gap gets filled by a &lt;code&gt;waitAndTap()&lt;/code&gt; extension, then a few more, until there's a small framework that nobody tests. I decided to write that framework on purpose and see where it went. That became &lt;a href="https://github.com/VadimToptunov/KassiOS" rel="noopener noreferrer"&gt;KassiOS&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The ideas come from &lt;a href="https://github.com/KasperskyLab/Kaspresso" rel="noopener noreferrer"&gt;Kaspresso&lt;/a&gt;, which I liked on Android: a test describes what the user does, the framework handles the waiting, and every step shows up in the report. The API is my own, and underneath it's still plain XCUITest in your UI-test target, with no dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a test looks like
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;LoginScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;login&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;typeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"test@example.com"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;typeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"secret"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loginButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;HomeScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;welcome&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertVisible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;LoginScreen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;KassScreen&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;lazy&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;textField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"login.email"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;lazy&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;secureTextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"login.password"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;lazy&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;loginButton&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"login.submit"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;onLoad&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;KassElement&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;loginButton&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the block runs, KassiOS checks the elements in &lt;code&gt;onLoad&lt;/code&gt;. If the app ended up on a different screen, the test fails right there, in the "On LoginScreen" step, instead of on whichever field it happened to touch first. Every &lt;code&gt;onScreen&lt;/code&gt; block is an activity in Xcode's report, so a red run reads as a list of steps.&lt;/p&gt;

&lt;p&gt;The test is shorter, but that isn't what I was after. I wanted the waiting and retrying to live in one place, in the framework, so that each test doesn't carry its own copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making one tap trustworthy
&lt;/h2&gt;

&lt;p&gt;The first version of &lt;code&gt;tap()&lt;/code&gt; waited for the element and tapped it. Most of the work since then went into the cases where that isn't enough. Today a single &lt;code&gt;login.loginButton.tap()&lt;/code&gt;, within one time budget:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;waits for the element to exist and be hittable. Internal retries share the budget, so three attempts at a 10-second action take 10 seconds, not 30;&lt;/li&gt;
&lt;li&gt;scrolls to the element if it's off screen, and dismisses the keyboard if it covers the target. It uses Done or Hide for that, never Return, because Return can submit a form;&lt;/li&gt;
&lt;li&gt;taps once. Only the preconditions are retried. A framework that taps again "just in case" can cause a double submit, or hide a real one;&lt;/li&gt;
&lt;li&gt;finds the element again on every attempt. A &lt;code&gt;KassElement&lt;/code&gt; stores how to find the element (&lt;code&gt;() -&amp;gt; XCUIElement&lt;/code&gt;), not the element itself, so if SwiftUI rebuilds the view, the next attempt gets the new one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some actions also check what they did. &lt;code&gt;setSwitch(on:)&lt;/code&gt;, &lt;code&gt;toggle()&lt;/code&gt;, &lt;code&gt;replaceText&lt;/code&gt; and &lt;code&gt;adjustPicker&lt;/code&gt; read the element back and fail if its state didn't change. Without it, a switch whose tap lands on its label, next to the control, returns as if it had worked, and the test can pass with the switch still off.&lt;/p&gt;

&lt;p&gt;When something does fail, the report gets a screenshot of the screen at that moment and the full accessibility tree, and the message tries to say why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KassiOS: text 'welcom' — assertVisible failed: does not exist
  ↳ element not found in the current hierarchy
  ↳ did you mean: 'welcome', 'Welcome!'?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Waiting has a limit too. A retry that forgives too much turns a broken app into a green test, so in KassiOS waiting only gives the UI time to reach a state. When the budget runs out, the test fails with the last reason the framework saw. Each of these behaviors can be turned off in &lt;code&gt;KassConfig&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it against a broken bank
&lt;/h2&gt;

&lt;p&gt;At some point I had tidy abstractions, a lot of unit tests, and no idea whether any of it caught bugs. A green UI test tells you the app did what the test checked. It doesn't tell you the test would turn red if the bug it was written for showed up.&lt;/p&gt;

&lt;p&gt;To find out, I built &lt;a href="https://github.com/VadimToptunov/ChaosBank-iOS" rel="noopener noreferrer"&gt;ChaosBank&lt;/a&gt;, a banking app with bugs you switch on at launch (&lt;a href="https://dev.to/vadimtoptunov/chaosbank-app-131-switchable-bugs-to-practice-mobile-testing-on-22ol"&gt;previous post&lt;/a&gt;), and a &lt;a href="https://github.com/VadimToptunov/ChaosBank-KassiOS-Tests" rel="noopener noreferrer"&gt;reference suite&lt;/a&gt; that tests it through KassiOS. Every test in the suite has the same contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;clean app        →  test passes
enable a defect  →  the same test fails
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first full run had 34 tests (Xcode 27.1, iPhone 17 simulator, M1 Max), and it was humbling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;test_portfolio_showsTotalAndPnl&lt;/code&gt; stayed green with every defect on, including one that shows a loss as a gain. It checked that the numbers were on screen, not that they were right.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;localization&lt;/code&gt; and &lt;code&gt;flaky&lt;/code&gt; defect groups didn't turn a single test red. The suite had no locale or race checks at all.&lt;/li&gt;
&lt;li&gt;Red runs were much slower than green ones. Most of the extra time went into my own "did you mean?" hint, which queried about 150 elements one by one. Reading a single hierarchy snapshot instead took the failing transfer test from 85 seconds to 23.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The suite also found a bug in KassiOS. After I added the read-back, one test failed like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KassiOS: textField 'order.limitPriceField' — replaceText('179.00') failed:
  typed '179.00' but the field reads '179.009.07'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The price field was right-aligned and pre-filled with &lt;code&gt;189.07&lt;/code&gt;. The tap at its centre put the cursor in the middle of the text, the deletes removed only &lt;code&gt;18&lt;/code&gt;, and the new value went in front of &lt;code&gt;9.07&lt;/code&gt;. The previous version cleared fields the same way. It just didn't look, so the test would have failed a line later on a wrong order total, with nothing pointing at the field. Now KassiOS taps the trailing edge before deleting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the defects added
&lt;/h2&gt;

&lt;p&gt;Several checks came from defects that slipped through. Take &lt;code&gt;successToastMissing&lt;/code&gt;: the transfer goes through and the sheet closes, but the success toast never appears. A test that waits for the sheet to close stays green. Catching it needed a different kind of wait:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// Catches `successToastMissing`.&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;test_transfer_happyPath_completes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;launchUnlocked&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;HomeScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transferAction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;TransferScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;transfer&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recipientField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;typeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Alex"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amountField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;typeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"100"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;continueButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confirmButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;successToast&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertAppears&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;within&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;transfer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confirmButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;within&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntilGone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The toast is on screen for about 1.4 seconds. The normal budget is built for "wait until it's ready", and &lt;code&gt;assertAppears&lt;/code&gt; polls a short window instead, for "catch it while it's there". A few more that I kept needing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A double submit: two taps on the same spot, no re-check in between.&lt;/span&gt;
&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;placeButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rapidTap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;times&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// A value that must not move while nothing happens.&lt;/span&gt;
&lt;span class="n"&gt;home&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;totalBalance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertUnchanged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Prices that tick: read related values from one hierarchy snapshot.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readTexts&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;"home.total"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"home.account.EUR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"home.account.USD"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;// The right number of rows can still be the wrong rows.&lt;/span&gt;
&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertNoDuplicates&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"is money-in"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readLabel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"+"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;KassError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"expected a money-in row"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;verifyAll&lt;/code&gt; runs every assertion in its block and reports all the mismatches on a screen, not only the first. For conditions you compose yourself there's &lt;code&gt;eventually&lt;/code&gt;, &lt;code&gt;continuously(during:)&lt;/code&gt;, &lt;code&gt;anyOf&lt;/code&gt; and &lt;code&gt;retry(times:)&lt;/code&gt;, as in Kaspresso.&lt;/p&gt;

&lt;p&gt;Validation defects tend to need the same form filled with many inputs, so there's a parameterized runner as well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;test_login_validatesTheEmail&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;parameterized&lt;/span&gt;&lt;span class="p"&gt;([(&lt;/span&gt;&lt;span class="s"&gt;"a@b.c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bad"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="nf"&gt;relaunch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// a clean app for every case&lt;/span&gt;
        &lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;LoginScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;login&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loginButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;onScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;HomeScreen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;welcome&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertVisible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertVisible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each case is its own activity in the report, named by the &lt;code&gt;name&lt;/code&gt; closure, so a red run says the "bad" case failed. A failing case doesn't stop the rest. &lt;a href="https://developer.apple.com/xcode/swift-testing/" rel="noopener noreferrer"&gt;Swift Testing&lt;/a&gt; has &lt;code&gt;@Test(arguments:)&lt;/code&gt;, but in Xcode 27.1 it only has a unit-test template, and the UI-test templates are still XCTest. I use Swift Testing for model and view-model logic and KassiOS for the flows, in separate targets.&lt;/p&gt;

&lt;h2&gt;
  
  
  One defect at a time
&lt;/h2&gt;

&lt;p&gt;Running the suite against groups of defects tells you that something went red. It doesn't tell you which defect each test catches, or which defect nobody catches. For that, KassiOS has a command-line tool, &lt;code&gt;kassios mutants&lt;/code&gt;. You list the defects and the tests each one must turn red, and it runs them one defect at a time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mutant                 verdict
zeroAmountAccepted     SURVIVED  (still green: …/TransferTests/test_transfer_zeroAmount_disablesContinue)
balanceAfterAdds       killed
...
58 of 59 killed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--clean&lt;/code&gt; first runs every listed test with no defect on, because a test that's already red "catches" everything. In the reference suite the list is generated from a &lt;code&gt;/// Catches `id`&lt;/code&gt; comment above each test, like the one on the toast test above, and CI fails if the generated file is out of date.&lt;/p&gt;

&lt;p&gt;The suite has since grown from 34 tests to 71, and all 59 defects on the list are caught. Running them one by one takes about 70 minutes on my Mac. With &lt;code&gt;--parallel 3&lt;/code&gt;, which runs three defects at once on clones of the simulator, it takes 28. Which tests stayed green the longest, and what it took to make them fail, is the next post.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few other parts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;kassios lint&lt;/code&gt; reads screen objects before anything runs and flags those without &lt;code&gt;onLoad&lt;/code&gt; or with computed identifiers. Apple's accessibility audit can run from a test.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kassios agent start&lt;/code&gt; starts a small loopback-only Mac process for things a test can't do from inside the simulator: grant permissions, freeze the status bar, set a location. It picks its own token and port and tells the test where to find them. Without it, those calls skip with a message instead of hanging.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KassHTTP&lt;/code&gt; arranges state through the app's API, so a transfer test doesn't have to create an account through the UI first.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;webView()&lt;/code&gt;, &lt;code&gt;link&lt;/code&gt; and &lt;code&gt;links()&lt;/code&gt; reach into a &lt;code&gt;WKWebView&lt;/code&gt;. HTML has no accessibility identifiers, so web elements are found by label.&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;recordVideoOnFailure&lt;/code&gt; and the agent running, a failed test also gets a video of the simulator screen. Allure and JUnit reporters are built in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scenarios, network stubs, snapshot tests and the rest are in the &lt;a href="https://github.com/VadimToptunov/KassiOS" rel="noopener noreferrer"&gt;README&lt;/a&gt; and the &lt;a href="https://github.com/VadimToptunov/KassiOS/blob/main/Documentation/Guide.md" rel="noopener noreferrer"&gt;Guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who it's for, and who it isn't
&lt;/h2&gt;

&lt;p&gt;I think KassiOS fits if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;your XCUITest suite has outgrown its helpers.&lt;/strong&gt; There's a &lt;code&gt;waitAndTap()&lt;/code&gt;, a base test class and page objects, and every new flaky test gets its own &lt;code&gt;sleep&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a silent failure is expensive in what you test.&lt;/strong&gt; Forms, payments and settings are where a tap that missed or a field that kept part of its old value lets a test pass on a broken app;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;your team knows Kaspresso from Android&lt;/strong&gt; and wants the same structure on iOS: screens, steps, scenarios, Allure;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;you want to know what your suite actually catches.&lt;/strong&gt; &lt;code&gt;kassios mutants&lt;/code&gt; only needs the app to switch a defect on at launch, through a launch argument or an environment variable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's probably not for you if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the suite is small.&lt;/strong&gt; A dozen tests over a few screens are fine on raw XCUITest with a couple of extensions that every iOS developer can read;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;you need one suite for iOS and Android.&lt;/strong&gt; Appium and Maestro do that, through a server or a runtime of their own. KassiOS is iOS only, Swift inside the app's Xcode project;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;you need the app's own idle state.&lt;/strong&gt; &lt;a href="https://github.com/google/EarlGrey" rel="noopener noreferrer"&gt;EarlGrey&lt;/a&gt; runs inside the app and knows when it's idle. KassiOS stays out of process and polls. It has a synchronizer hook, with an example EarlGrey adapter in the repo;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;you want nothing between a failure and its line.&lt;/strong&gt; KassiOS actions call &lt;code&gt;XCTFail&lt;/code&gt; internally and return the element, so with &lt;code&gt;continueAfterFailure = true&lt;/code&gt; a chain keeps going after a failure;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;you'd rather stay with Apple's tools&lt;/strong&gt; than depend on a third-party layer with one maintainer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this fixes flakiness that comes from the app, the test data or the environment. When something behaves oddly, you still need to know what XCUITest does underneath. Building KassiOS taught me more about that than writing tests would have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built with AI, checked like QA
&lt;/h2&gt;

&lt;p&gt;I built KassiOS with a lot of help from AI coding tools. I'm a QA engineer, not a Swift framework developer, and that help let me go much further than I would have alone. It also meant checking everything: 172 unit tests, 59 UI tests against a small SwiftUI demo app in the KassiOS repo, the ChaosBank reference suite, and SwiftLint, all in CI on every pull request. The cursor bug above was found by the ChaosBank suite, not by me reading the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;package&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"https://github.com/VadimToptunov/KassiOS.git"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"1.10.0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add it to your UI-test target, make the test class a &lt;code&gt;KassTestCase&lt;/code&gt; instead of &lt;code&gt;XCTestCase&lt;/code&gt;, and write one screen object, for example for the screen your flakiest test starts on. Plain XCUITest calls keep working next to it. The public API has followed semantic versioning since 1.0.&lt;/p&gt;

&lt;p&gt;The command-line tools (the agent, the linter and the mutation runner) are one Homebrew formula, prebuilt, so there's nothing to build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;vadimtoptunov/tap/kassios
kassios agent start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The library doesn't need them; install them when you want one of those features.&lt;/p&gt;

&lt;p&gt;If you try it, I'd like to hear what feels unnecessary, what's missing, and where it gets in your way. Issues and PRs are welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/VadimToptunov/KassiOS" rel="noopener noreferrer"&gt;KassiOS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/VadimToptunov/ChaosBank-iOS" rel="noopener noreferrer"&gt;ChaosBank for iOS&lt;/a&gt; and &lt;a href="https://dev.to/vadimtoptunov/chaosbank-app-131-switchable-bugs-to-practice-mobile-testing-on-22ol"&gt;the post about it&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/VadimToptunov/ChaosBank-KassiOS-Tests" rel="noopener noreferrer"&gt;ChaosBank × KassiOS reference suite&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/xctest" rel="noopener noreferrer"&gt;XCTest documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/KasperskyLab/Kaspresso" rel="noopener noreferrer"&gt;Kaspresso&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/google/EarlGrey" rel="noopener noreferrer"&gt;EarlGrey&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://allurereport.org/" rel="noopener noreferrer"&gt;Allure Report&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>testing</category>
      <category>ios</category>
      <category>swift</category>
      <category>opensource</category>
    </item>
    <item>
      <title>ChaosBank app — 131 switchable bugs to practice mobile testing on</title>
      <dc:creator>Vadim Toptunov</dc:creator>
      <pubDate>Fri, 23 Oct 2026 15:01:45 +0000</pubDate>
      <link>https://dev.to/vadimtoptunov/chaosbank-app-131-switchable-bugs-to-practice-mobile-testing-on-22ol</link>
      <guid>https://dev.to/vadimtoptunov/chaosbank-app-131-switchable-bugs-to-practice-mobile-testing-on-22ol</guid>
      <description>&lt;p&gt;For a while now I've been playing with different approaches to mobile UI test automation: plain XCUITest and Espresso/Compose, Appium, Maestro, page objects versus small DSLs, different ways of waiting and of dealing with flaky tests. Pretty quickly I ran into a boring but real problem: &lt;em&gt;what do I actually run all this against?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The open-source apps I found mostly fell into two groups. Some only exist for Android, which doesn't help much when you want to compare approaches across both platforms. Others are intentionally simple: a login screen, a list, a details page. They're great for learning how to drive a simulator, but they don't have many of the bugs I've seen cause trouble in real projects, like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a loss rendered as a gain,&lt;/li&gt;
&lt;li&gt;a double-tap that sends a transfer twice,&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1,000.50&lt;/code&gt; parsed as &lt;code&gt;1.0005&lt;/code&gt; because the device locale is German,&lt;/li&gt;
&lt;li&gt;a stale network response overwriting fresh state,&lt;/li&gt;
&lt;li&gt;a CVV printed right there on the card face.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A fintech app seemed like a good place for that kind of bug, since money, auth, live data, network timing and security all meet on a handful of screens. So I ended up building my own. Two, actually, one per platform, where &lt;strong&gt;the bugs are the feature&lt;/strong&gt;. They're planted on purpose, written down, and you can switch each one on and off.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ChaosBank-iOS&lt;/strong&gt; — SwiftUI (plus a UIKit build): &lt;a href="https://github.com/VadimToptunov/ChaosBank-iOS" rel="noopener noreferrer"&gt;https://github.com/VadimToptunov/ChaosBank-iOS&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ChaosBank-Android&lt;/strong&gt; — Kotlin + Jetpack Compose (plus an XML build): &lt;a href="https://github.com/VadimToptunov/ChaosBank-Android" rel="noopener noreferrer"&gt;https://github.com/VadimToptunov/ChaosBank-Android&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are MIT-licensed. Functionally it's a small neobank with a stock/crypto broker on top, loosely in the style of Revolut and Robinhood, and it currently has &lt;strong&gt;131 named defects&lt;/strong&gt;, each with a short written exercise.&lt;/p&gt;

&lt;p&gt;I started it as a test bench for my own experiments, but it might also be useful as a practice ground if you're just getting into mobile development or mobile QA. Below is how it's put together, how I'd try using it, and a few things I'm still not sure about.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fejkz5mrhdbm8f1npzq81.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fejkz5mrhdbm8f1npzq81.png" alt="ChaosBank iOS: markets" width="800" height="1739"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's a working app rather than a mock-up. There's a web login (deliberately inside a &lt;code&gt;WKWebView&lt;/code&gt; / &lt;code&gt;WebView&lt;/code&gt;, more on that below), then OTP, a 6-digit passcode and Face ID. After that: a multi-currency home screen, transfers, currency exchange, transaction history with search and pagination, a markets screen with ticking prices, an order ticket, a portfolio with live P&amp;amp;L, and card controls.&lt;/p&gt;

&lt;p&gt;The Android app tries to be a 1:1 port: same screens, same defects, same locator names.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzdgsx5uibvvetiic7vwq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzdgsx5uibvvetiic7vwq.png" alt="ChaosBank Android: web login, home, markets" width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Keeping the correct code and the bugs apart
&lt;/h2&gt;

&lt;p&gt;This is the decision most of the project depends on.&lt;/p&gt;

&lt;p&gt;The obvious way to make a buggy app is to just write buggy code. For what I wanted, that didn't work well: you can't easily tell whether a test caught the bug or failed for some unrelated reason, and the "correct" behaviour only exists in the author's head. When comparing frameworks, I wanted to know exactly what each run was &lt;em&gt;supposed&lt;/em&gt; to catch.&lt;/p&gt;

&lt;p&gt;So the production logic is written to be correct (money in &lt;code&gt;Decimal&lt;/code&gt;, correct order pricing, correct balances), and each bug is a small override behind one check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kt"&gt;Defects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doubleCharge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// small, isolated buggy override&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// the correct reference implementation&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Defects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DefectId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doubleCharge&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// small, isolated buggy override&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this gives you is a simple way to check that a test really guards something:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run the test against the &lt;code&gt;clean&lt;/code&gt; profile. It should pass.&lt;/li&gt;
&lt;li&gt;Relaunch with the defect switched on. It should fail.&lt;/li&gt;
&lt;li&gt;If it doesn't fail, the test probably isn't checking what you think it is.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's how that looks on screen. Same build, same screen. On the left is &lt;code&gt;clean&lt;/code&gt;; on the right, two security defects are on:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs257w56if52tinclrwgj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs257w56if52tinclrwgj.png" alt="Card screen, clean vs. cardNumberFullyVisible + cardCvvVisible" width="800" height="827"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This one is less obvious. Have a look at TSLA:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F05u61g2kvt2a92am7z2j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F05u61g2kvt2a92am7z2j.png" alt="Portfolio, clean vs. pnlSign" width="800" height="827"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the left, TSLA is down $290.56 and shown in red. On the right (&lt;code&gt;pnlSign&lt;/code&gt;), the same loss shows up as a green &lt;strong&gt;+$290.56&lt;/strong&gt;. All the elements are still there, so a test that only checks that they exist will pass. You need an assertion on the &lt;em&gt;value&lt;/em&gt; to notice it.&lt;/p&gt;

&lt;p&gt;And one more: is the total on the right the sum of the accounts?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuztn1n3itqdny95fghrn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuztn1n3itqdny95fghrn.png" alt="Home, clean vs. homeTotalOmitsAccount" width="800" height="827"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It isn't. The account cards scroll horizontally, and the third one (GBP) is just off screen. With &lt;code&gt;homeTotalOmitsAccount&lt;/code&gt; on, the total quietly leaves it out: €16,626.11 instead of €17,379.28. A check like "the total is a positive number" won't catch that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Locators stay put
&lt;/h2&gt;

&lt;p&gt;The second rule is that &lt;strong&gt;a defect changes behaviour or values, but not the accessibility identifiers&lt;/strong&gt;. All IDs live in one file (&lt;code&gt;Core/A11y.swift&lt;/code&gt; on iOS, &lt;code&gt;core/A11y.kt&lt;/code&gt; on Android), and the strings are the same on both platforms: &lt;code&gt;card.cvv&lt;/code&gt; is &lt;code&gt;card.cvv&lt;/code&gt; everywhere.&lt;/p&gt;

&lt;p&gt;The idea is that a suite written against &lt;code&gt;clean&lt;/code&gt; should keep &lt;em&gt;finding&lt;/em&gt; its elements when defects are switched on, and fail on the &lt;em&gt;assertions&lt;/em&gt; instead.&lt;/p&gt;

&lt;p&gt;That's also what I needed for my own experiments. You can point XCUITest, Espresso/Compose, Appium or Maestro at the same screens and compare how they deal with changed behaviour, how they wait, and how readable the tests end up, without the comparison being mostly about chasing moved locators.&lt;/p&gt;

&lt;p&gt;Same bug, same ID, two platforms:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe86gtauuepvs8ckdsncy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe86gtauuepvs8ckdsncy.png" alt="The same defect on iOS and Android" width="800" height="807"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Switching bugs on
&lt;/h2&gt;

&lt;p&gt;Defects are set by configuration, so you don't need a separate build for each one. There are three ways to do it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Launch arguments.&lt;/strong&gt; This is handy for CI and quick runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# iOS&lt;/span&gt;
xcrun simctl launch booted VadimToptunov.ChaosBank &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-ChaosBankDefects&lt;/span&gt; pnlSign &lt;span class="nt"&gt;-ChaosBankStartUnlocked&lt;/span&gt; 1 &lt;span class="nt"&gt;-ChaosBankTab&lt;/span&gt; portfolio

&lt;span class="c"&gt;# Android&lt;/span&gt;
adb shell am start &lt;span class="nt"&gt;-n&lt;/span&gt; com.vadimtoptunov.chaosbank_android/.MainActivity &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; CHAOSBANK_DEFECTS pnlSign &lt;span class="nt"&gt;-e&lt;/span&gt; CHAOSBANK_START_UNLOCKED 1 &lt;span class="nt"&gt;-e&lt;/span&gt; CHAOSBANK_TAB portfolio
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can enable a single defect, a whole profile (&lt;code&gt;ui&lt;/code&gt;, &lt;code&gt;validation&lt;/code&gt;, &lt;code&gt;security&lt;/code&gt;, &lt;code&gt;network&lt;/code&gt;, &lt;code&gt;flaky&lt;/code&gt;…), or a difficulty bundle (&lt;code&gt;beginner&lt;/code&gt;, &lt;code&gt;middle&lt;/code&gt;, &lt;code&gt;senior&lt;/code&gt;). &lt;code&gt;StartUnlocked&lt;/code&gt; skips the auth flow so you land straight on the screen you care about. It only changes where you start, not how the app behaves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The in-app developer menu.&lt;/strong&gt; Long-press the build badge in the top-right corner. From there you can switch profiles, toggle individual defects, simulate offline/slow/flaky networking, flip RTL, and browse the exercises.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flwit3jidvrh3hudhiz8o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flwit3jidvrh3hudhiz8o.png" alt="Developer menu" width="414" height="900"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Prebuilt variants.&lt;/strong&gt; Xcode build configurations (product flavors on Android) such as &lt;strong&gt;ChaosBank Flaky&lt;/strong&gt; install next to the clean app under their own name. That can be useful if someone wants to do manual exploratory testing without touching launch arguments.&lt;/p&gt;




&lt;h2&gt;
  
  
  The exercises
&lt;/h2&gt;

&lt;p&gt;Each defect has a matching entry in &lt;a href="https://github.com/VadimToptunov/ChaosBank-iOS/blob/main/exercises.json" rel="noopener noreferrer"&gt;&lt;code&gt;exercises.json&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"IOS-MON-02"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Loss displayed as gain"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"difficulty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"junior"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"money"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"defects"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"pnlSign"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"launchArgument"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-ChaosBankDefects pnlSign"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expectedClean"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Loss shows negative / loss color."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expectedBuggy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Loss shows positive / gain color."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"task"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Open Portfolio and assert TSLA's P&amp;amp;L renders as a loss (negative, red)."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"keyLocators"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"portfolio.holding.TSLA.pnl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"portfolio.pnl"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Right now there are 12 junior, 97 middle and 22 senior exercises across ten categories: money, state, security, UI, validation, localization, networking, accessibility, concurrency and performance. The difficulty labels are my own guess and are probably off in places.&lt;/p&gt;

&lt;p&gt;The junior ones are things like "a zero-amount transfer is allowed" or "the full card number isn't masked". The senior ones are mostly about timing: double-submits, a late response overwriting fresh state, animations that never quite settle.&lt;/p&gt;




&lt;h2&gt;
  
  
  If you're starting out in mobile QA
&lt;/h2&gt;

&lt;p&gt;This is roughly the order I'd try things in. It's just one way to do it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. No code at first.&lt;/strong&gt; Install the &lt;code&gt;beginner&lt;/code&gt; build and poke around. Try not to read the defect list first. Write down what you find, then compare your notes with &lt;code&gt;exercises.json&lt;/code&gt;. It's a fairly honest way to see what you notice and what you miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A first automated test.&lt;/strong&gt; Pick a junior exercise and write a plain XCUITest for it. Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;test_portfolio_lossIsRenderedAsLoss&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;XCUIApplication&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;launchArguments&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"-ChaosBankStartUnlocked"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-ChaosBankTab"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"portfolio"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;pnl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;descendants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;matching&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="s"&gt;"portfolio.holding.TSLA.pnl"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="kt"&gt;XCTAssertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pnl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForExistence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="kt"&gt;XCTAssertFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pnl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"+"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"A loss must not be shown as a gain: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;pnl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or something similar with Compose on Android:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;card_doesNotShowCvv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;intent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ApplicationProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getApplicationContext&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;MainActivity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;putExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CHAOSBANK_START_UNLOCKED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;putExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CHAOSBANK_TAB"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"card"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;ActivityScenario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MainActivity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;composeRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onNodeWithTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"card.cvv"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;assertDoesNotExist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are rough sketches, not a finished suite. Run each one on &lt;code&gt;clean&lt;/code&gt; (green), then with &lt;code&gt;-ChaosBankDefects pnlSign&lt;/code&gt; or &lt;code&gt;CHAOSBANK_DEFECTS=cardCvvVisible&lt;/code&gt; (red). If it stays green, that's worth digging into.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Timing and flakiness.&lt;/strong&gt; The &lt;code&gt;flaky&lt;/code&gt; profile has race conditions pinned to a seed. It's a decent place to see why &lt;code&gt;sleep(2)&lt;/code&gt; tends to backfire. The &lt;code&gt;network&lt;/code&gt; profile plus "slow" in the dev menu is another one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Hybrid screens.&lt;/strong&gt; The web login sits inside a WebView on purpose. By default it's opaque: Appium only sees &lt;code&gt;NATIVE_APP&lt;/code&gt;, so you reach the fields through the native accessibility tree. With &lt;code&gt;-ChaosBankInspectableWeb 1&lt;/code&gt; (Android: &lt;code&gt;-e CHAOSBANK_INSPECTABLE_WEB 1&lt;/code&gt;), a &lt;code&gt;WEBVIEW_…&lt;/code&gt; context shows up and you can &lt;code&gt;switch_to.context(...)&lt;/code&gt; into it. It's a small thing, but it tends to be confusing the first time you run into it in a real app.&lt;/p&gt;

&lt;p&gt;There's also a hand-written UI suite for the iOS app: &lt;a href="https://github.com/VadimToptunov/ChaosBank-KassiOS-Tests" rel="noopener noreferrer"&gt;ChaosBank ⨯ KassiOS&lt;/a&gt;. It's another experiment of mine, a small Kaspresso-style DSL over XCUITest called &lt;a href="https://github.com/VadimToptunov/KassiOS" rel="noopener noreferrer"&gt;KassiOS&lt;/a&gt;. It passes on &lt;code&gt;clean&lt;/code&gt; and goes red on each injected defect. It might be more useful to look at after writing a few tests of your own.&lt;/p&gt;




&lt;h2&gt;
  
  
  If you're starting out in mobile development
&lt;/h2&gt;

&lt;p&gt;The apps are also a small codebase with no third-party dependencies, so they might be worth reading. A few parts I spent the most time on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Money.&lt;/strong&gt; &lt;code&gt;Decimal&lt;/code&gt; everywhere, except for one deliberate spot: the &lt;code&gt;roundingDrift&lt;/code&gt; defect routes a single calculation through &lt;code&gt;Double&lt;/code&gt;, so you can see what that does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Determinism.&lt;/strong&gt; The simulated price feed and the race-condition "coin flips" come from a seeded RNG (SplitMix64), so a failing run can be reproduced. Live Yahoo Finance quotes are available, but opt-in, because they make tests non-deterministic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A mock backend as an actor&lt;/strong&gt;, with timeouts, retries, offline mode and out-of-order responses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testable view models.&lt;/strong&gt; Logic-layer coverage is around 97% on iOS and 95% on Android, checked in CI. The UI is left to UI tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two UI stacks.&lt;/strong&gt; The UIKit (&lt;code&gt;UITableView&lt;/code&gt;) and XML (&lt;code&gt;RecyclerView&lt;/code&gt;) builds exist because some bugs only really happen there, like a recycled cell showing the previous row's amount.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One exercise: pick a defect, find its &lt;code&gt;Defects.isActive&lt;/code&gt; check, and read the correct &lt;code&gt;else&lt;/code&gt; branch next to the buggy one. Another: try adding a defect of your own. The &lt;a href="https://github.com/VadimToptunov/ChaosBank-iOS/blob/main/docs/DEVELOPERS_GUIDE.md" rel="noopener noreferrer"&gt;developer's guide&lt;/a&gt; has the steps. Note that CI compares the exercise catalogs of the two repos, so a new defect has to land on both platforms.&lt;/p&gt;




&lt;h2&gt;
  
  
  Using it for take-home assignments
&lt;/h2&gt;

&lt;p&gt;This one I didn't plan for, but it seems to fit: test assignments for hiring, or onboarding someone onto a team. Since the bug set is just configuration, you can build a variant around what your team cares about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;For a security-focused role&lt;/strong&gt;, use the &lt;code&gt;security&lt;/code&gt; build: unmasked card data, an expired OTP that's still accepted, a deep link that skips the auth gate, a token in &lt;code&gt;UserDefaults&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For a team fighting flaky tests&lt;/strong&gt;, use &lt;code&gt;flaky&lt;/code&gt; or &lt;code&gt;senior&lt;/code&gt;, and ask for a suite that stays stable across, say, ten runs in a row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For a junior role&lt;/strong&gt;, use &lt;code&gt;beginner&lt;/code&gt; and ask for a written bug report before any code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For something narrower&lt;/strong&gt;, pass your own list with &lt;code&gt;-ChaosBankDefects&lt;/code&gt; / &lt;code&gt;CHAOSBANK_DEFECTS&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Profile builds install as a separate app with their own name (&lt;strong&gt;ChaosBank Security&lt;/strong&gt;, &lt;strong&gt;ChaosBank Flaky&lt;/strong&gt;…), so a candidate can get a single APK or simulator build. The &lt;code&gt;clean&lt;/code&gt; build gives the reviewer a baseline to run the submitted tests against.&lt;/p&gt;

&lt;p&gt;One caveat: the catalog is public, so a candidate can look up the defect list. I'd probably treat it as open-book and focus the review on &lt;em&gt;how&lt;/em&gt; the tests are written (waits, value assertions, how findings are explained) rather than on how many bugs were found.&lt;/p&gt;




&lt;h2&gt;
  
  
  A few things I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Locators stay put" was harder than I expected.&lt;/strong&gt; Some realistic bugs, like a recycled cell keeping a stale accessibility ID, are &lt;em&gt;about&lt;/em&gt; locators. I ended up making those separate, clearly labelled defects instead of exceptions to the rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Randomness works better as an opt-in.&lt;/strong&gt; Live prices are fun, but any assertion on a price or P&amp;amp;L stops meaning much when the number changes every second. So the seeded feed is the default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keeping two codebases in sync needs automation.&lt;/strong&gt; Doing it by hand drifts. A CI script now compares the two exercise catalogs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The clean build has to really be clean.&lt;/strong&gt; If &lt;code&gt;clean&lt;/code&gt; has an accidental bug, the "green on clean, red on buggy" idea falls apart. That's the main reason the reference suite exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;iOS needs a Mac with Xcode 26+ and an iOS 17+ simulator. Android needs JDK 21 and API 29+.&lt;/li&gt;
&lt;li&gt;There's a reference UI suite for iOS, but none yet for Android. Espresso/Compose, Appium and Maestro suites are on the &lt;a href="https://github.com/VadimToptunov/ChaosBank-iOS/blob/main/ROADMAP.md" rel="noopener noreferrer"&gt;roadmap&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;It's a sandbox. The "bank" lives in memory and the card number is a test one.&lt;/li&gt;
&lt;li&gt;I'm one person maintaining two apps, so some defects are probably less realistic than others.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;iOS app: &lt;a href="https://github.com/VadimToptunov/ChaosBank-iOS" rel="noopener noreferrer"&gt;https://github.com/VadimToptunov/ChaosBank-iOS&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Android app: &lt;a href="https://github.com/VadimToptunov/ChaosBank-Android" rel="noopener noreferrer"&gt;https://github.com/VadimToptunov/ChaosBank-Android&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;User guide for QA/SDET: &lt;a href="https://github.com/VadimToptunov/ChaosBank-iOS/blob/main/docs/USER_GUIDE.md" rel="noopener noreferrer"&gt;docs/USER_GUIDE.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Reference UI suite: &lt;a href="https://github.com/VadimToptunov/ChaosBank-KassiOS-Tests" rel="noopener noreferrer"&gt;https://github.com/VadimToptunov/ChaosBank-KassiOS-Tests&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next on my list are Espresso/Compose, Appium and Maestro suites against the same screens, to see how the approaches compare in practice. If you give the apps a try, I'd be glad to hear which defects were hard to catch, which ones feel unrealistic, and which bugs from your own work are missing. Comments, issues and PRs are all welcome.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>ios</category>
      <category>android</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
