<?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: Taras Leskiv</title>
    <description>The latest articles on DEV Community by Taras Leskiv (@tarasosiris).</description>
    <link>https://dev.to/tarasosiris</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%2F448257%2F8644128d-85b9-4a25-9bac-bc8d651ce954.png</url>
      <title>DEV Community: Taras Leskiv</title>
      <link>https://dev.to/tarasosiris</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tarasosiris"/>
    <language>en</language>
    <item>
      <title>How I QA a Video Editor With One Test That Uses the App Like a Person</title>
      <dc:creator>Taras Leskiv</dc:creator>
      <pubDate>Mon, 05 Oct 2026 08:11:10 +0000</pubDate>
      <link>https://dev.to/tarasosiris/how-i-qa-a-video-editor-with-one-test-that-uses-the-app-like-a-person-5fb6</link>
      <guid>https://dev.to/tarasosiris/how-i-qa-a-video-editor-with-one-test-that-uses-the-app-like-a-person-5fb6</guid>
      <description>&lt;p&gt;&lt;em&gt;Unit tests can't see a preview that blinks or an export that drifts from what you edited. Here's the end-to-end Appium journey that catches both in Captions Bro.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://captionsbro.app/" rel="noopener noreferrer"&gt;Captions Bro&lt;/a&gt; is an iOS video editor for short vertical videos: a multi-track timeline, on-device transcription, animated karaoke-style captions, text and media overlays, and an export built for TikTok, Reels and Shorts.&lt;/p&gt;

&lt;p&gt;Video editors break in ways unit tests can't see. A drag lands a frame off. The preview blinks black for a fraction of a second while a composition rebuilds. The exported file doesn't quite match what you saw while editing. Every one of those can happen with a fully green unit suite.&lt;/p&gt;

&lt;p&gt;So on top of the unit tests, Captions Bro has one end-to-end test that uses the app the way a person does. It opens the app on a fresh simulator, picks clips in the Photos picker, trims, splits, adds a transition, generates captions, styles them, adds a text overlay, plays the project, exports it and opens the share screen. Then it checks what came out.&lt;/p&gt;

&lt;p&gt;Here's how it works.&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%2F588ezz9at53c7n7x4mei.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%2F588ezz9at53c7n7x4mei.png" alt="The e2e journey in real app screens: empty library, Photos picker, editor, transition, captions, export" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One command, a fresh device every time
&lt;/h2&gt;

&lt;p&gt;Everything starts with one script: &lt;code&gt;appium/run.sh&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It leases a simulator&lt;/strong&gt; from a pool of two, so at most two runs share the machine, and erases it before every run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It pins everything that changes pixels.&lt;/strong&gt; That means the status bar (always 09:41), the locale, and every preference that changes the UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It builds the app&lt;/strong&gt; in its own derived-data folder, and skips the build if no input changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It fills the Photos library with test clips rendered by ffmpeg.&lt;/strong&gt; They're the same bytes every run, and each file is dated so the picker grid always shows them in the same order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It starts its own Appium server&lt;/strong&gt; and hands everything to a pytest journey.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then Appium drives the real UI: real taps, real drags, real sheets, even Apple's out-of-process Photos picker. The smoke profile is 15 steps and takes about seven minutes. The full journey is 25 steps.&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%2Fi91b37y1qqcw2c1hqgi5.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%2Fi91b37y1qqcw2c1hqgi5.png" alt="What appium/run.sh does: set up, drive, judge" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  No "tap and hope"
&lt;/h2&gt;

&lt;p&gt;The classic way UI tests go wrong is tapping a button and assuming the thing happened. Or worse, sleeping for two seconds and hoping it did.&lt;/p&gt;

&lt;p&gt;Captions Bro avoids that by letting the app describe itself. Under test, the editor mounts a hidden accessibility element called &lt;code&gt;uitest-state&lt;/code&gt;. Its value is a compact string with the playhead, whether it's playing, which sheet is open, how many clips, captions and overlays the project has, and one short signature per clip: duration, speed, and a letter for each capability applied to it (T for transition, R for reversed, K for keyframes, and so on).&lt;/p&gt;

&lt;p&gt;So the "add a transition" step looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mix transition A→B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_mix_transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deselect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;state&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrub_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;nominal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cl&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dur&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="n"&gt;seam&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timeline.clip.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tap_xy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mix&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transition applied&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;T&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cl&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transition-panel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask_names&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TRANSITION_TILES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It taps "Mix", then waits until the first clip actually carries the T flag. Every step works this way: it proves the edit landed, not just that a button was tapped. There isn't a single fixed sleep in the journey. Every wait polls the app's state or an element, with a timeout that fails loudly.&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%2Fg4zlj89wr907nfr542h0.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%2Fg4zlj89wr907nfr542h0.png" alt="A journey step and the uitest-state it waits on" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Screens are compared, not eyeballed
&lt;/h2&gt;

&lt;p&gt;At key moments the journey takes a screenshot and compares it with an approved baseline using SSIM. The threshold is 0.985. Getting a screenshot to be the same twice in a row took more work than I expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Settle first.&lt;/strong&gt; A shot is taken only after two identical captures in a row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exact frame.&lt;/strong&gt; Every seek aims at the middle of a 30 fps output frame, never its edge. Then the test waits until the preview reports that it's really showing that frame. The model's playhead settles the instant the finger lifts, but the still for it decodes a moment later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A little slack where drags are involved.&lt;/strong&gt; Block widths on the timeline can differ by a point between runs, so the timeline band is compared at its best ±3 px alignment, with its own threshold of 0.95.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mask what never stops moving.&lt;/strong&gt; Transition previews and caption style tiles loop forever, so their area is masked out of the comparison.&lt;/li&gt;
&lt;/ul&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%2F30biq588herit7tuen4o.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%2F30biq588herit7tuen4o.png" alt="Baseline checkpoints with masked animated tiles" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The export must look exactly like the preview
&lt;/h2&gt;

&lt;p&gt;This is the rule I care about most. Whatever you see in the editor is what should end up in the video you post.&lt;/p&gt;

&lt;p&gt;During the journey, the test pauses the editor at 1, 5.5, 8 and 11.5 seconds and saves what the canvas shows. After the export, it pulls the frames at the same instants out of the .mp4 and compares each pair. If captions, text, or a transition render differently in the export than in the preview, the run fails, and the report shows the two frames side by side.&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%2Fvkdqw941i0u43agv5h3x.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%2Fvkdqw941i0u43agv5h3x.png" alt="Paused preview stills compared with export frames" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the file itself gets checked
&lt;/h2&gt;

&lt;p&gt;The export is pulled out of the app's container and checked with ffprobe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;H.264, SDR (bt709), 8-bit 4:2:0&lt;/li&gt;
&lt;li&gt;1080×1920 at 30 fps&lt;/li&gt;
&lt;li&gt;duration within one frame of the timeline&lt;/li&gt;
&lt;li&gt;an audio track is present&lt;/li&gt;
&lt;li&gt;no flat black frame anywhere in the video&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why so strict about SDR? An HDR or HEVC export plays perfectly on your phone. Then TikTok re-encodes it on the server and the back half of the video freezes. That's the kind of bug you only hear about from users, so the test makes sure it can't ship.&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%2Fjtnkdaz7b3vpfo2r08d5.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%2Fjtnkdaz7b3vpfo2r08d5.png" alt="Export contract checklist" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole run is filmed, then scanned frame by frame
&lt;/h2&gt;

&lt;p&gt;The simulator screen is recorded for the entire journey. Afterwards, the recording is scanned for four things that should never happen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blink:&lt;/strong&gt; the canvas drops to flat black for up to six frames and comes back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Black canvas:&lt;/strong&gt; the canvas stays black for more than a second, outside steps that are allowed to be empty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flicker:&lt;/strong&gt; a single frame that differs from both of its neighbours, while the neighbours match each other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freeze:&lt;/strong&gt; the picture stops changing for more than 0.6 s during a step that's supposed to be playing. Freeze-frame clips in the project are exempt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are thresholds, not baselines. The recording's frame timing varies from run to run, so it's never compared against a reference video. Each detector asks a question whose answer should always be no. On top of that, any alert nobody asked for (like "Playback unavailable") fails the run.&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%2Fw7uchhsy8rx30bl8ad04.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%2Fw7uchhsy8rx30bl8ad04.png" alt="Recording analysis: blink, black canvas, flicker, freeze" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part: making it repeatable
&lt;/h2&gt;

&lt;p&gt;Writing the steps was the easy part. Making every run produce the same pixels was the real work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Slow drags.&lt;/strong&gt; Above roughly 200 pt/s, a scroller keeps momentum after the finger lifts, and the deceleration differs every time. Every drag is capped in speed and holds still before letting go.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mid-frame seeks.&lt;/strong&gt; A seek that lands near a frame edge ends up on either side of it from run to run, so seeks always aim at the middle of a frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trims are undone.&lt;/strong&gt; Trims snap to the continuous playhead, not to frames, so a tiny residual would shift every later frame of the project. A trim step drags the real handle, checks it, then undoes it. The lasting cut is made with Split, which lands on an exact frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generated, not committed, media.&lt;/strong&gt; The test clips are ffmpeg test sources at 30, 24 and 60 fps, one of them landscape, encoded with a one-second keyframe interval like a phone camera.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry the system, not the app.&lt;/strong&gt; Apple's Photos picker sometimes sits on "Loading…" over a freshly seeded library. The test cancels and reopens it (up to three tries), and records each retry as a known limitation rather than an app bug.&lt;/li&gt;
&lt;/ul&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%2Fakbooik8ww1u5p6mbf8f.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%2Fakbooik8ww1u5p6mbf8f.png" alt="Lessons for making the run repeatable" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a run leaves behind
&lt;/h2&gt;

&lt;p&gt;Every run leaves a folder with &lt;code&gt;report.html&lt;/code&gt;, the full screen recording, the exported video, every checkpoint screenshot (with a diff image when one drifts), the side-by-side parity frames, and a &lt;code&gt;verdict.json&lt;/code&gt;. The exit code is the verdict.&lt;/p&gt;

&lt;p&gt;I also don't run it by hand most of the time. Claude Code runs the whole thing through a project skill, reads the report and looks at the failure frames itself, and tells me whether a failure is an app regression, a harness problem, or an intended UI change that needs new baselines.&lt;/p&gt;

&lt;p&gt;Unit tests prove each capability on its own. This one test proves the app works when a person uses it.&lt;/p&gt;

&lt;p&gt;If you make short videos, try the result at &lt;a href="https://captionsbro.app/" rel="noopener noreferrer"&gt;captionsbro.app&lt;/a&gt;. It's on the &lt;a href="https://apps.apple.com/us/app/captions-bro-auto-subtitles/id6770987720" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;, with guides and help docs at &lt;a href="https://captionsbro.app/guides/" rel="noopener noreferrer"&gt;captionsbro.app/guides&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://medium.com/@tarasleskiv/how-i-qa-a-video-editor-with-one-test-that-uses-the-app-like-a-person-d0389e2fbfcd" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>testing</category>
      <category>appium</category>
      <category>automation</category>
    </item>
    <item>
      <title>As the developer of the Screenshot Bro, I approve :)</title>
      <dc:creator>Taras Leskiv</dc:creator>
      <pubDate>Wed, 10 Jun 2026 13:19:58 +0000</pubDate>
      <link>https://dev.to/tarasosiris/as-the-developer-of-the-screenshot-bro-i-approve--3jnh</link>
      <guid>https://dev.to/tarasosiris/as-the-developer-of-the-screenshot-bro-i-approve--3jnh</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/andrew_ivaniv_8d140452956/weve-built-a-local-first-app-store-screenshot-native-mac-tool-where-the-project-file-is-a-plain-2980" class="crayons-story__hidden-navigation-link"&gt;We’ve built a local-first App Store screenshot native Mac tool where the project file is a plain JSON&lt;/a&gt;
    &lt;div class="crayons-article__cover crayons-article__cover__image__feed"&gt;
      &lt;iframe src="https://www.youtube.com/embed/ZhXR7TUyLfY" title="We’ve built a local-first App Store screenshot native Mac tool where the project file is a plain JSON"&gt;&lt;/iframe&gt;
    &lt;/div&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/andrew_ivaniv_8d140452956" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3977179%2Fc172c60c-a5cd-49e9-9e15-a4d39a8fd321.webp" alt="andrew_ivaniv_8d140452956 profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/andrew_ivaniv_8d140452956" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Andrew Ivaniv
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Andrew Ivaniv
                
                
              
              &lt;div id="story-author-preview-content-3863860" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/andrew_ivaniv_8d140452956" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3977179%2Fc172c60c-a5cd-49e9-9e15-a4d39a8fd321.webp" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Andrew Ivaniv&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/andrew_ivaniv_8d140452956/weve-built-a-local-first-app-store-screenshot-native-mac-tool-where-the-project-file-is-a-plain-2980" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jun 10&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/andrew_ivaniv_8d140452956/weve-built-a-local-first-app-store-screenshot-native-mac-tool-where-the-project-file-is-a-plain-2980" id="article-link-3863860"&gt;
          We’ve built a local-first App Store screenshot native Mac tool where the project file is a plain JSON
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/programming"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;programming&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/design"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;design&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/productivity"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;productivity&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/marketing"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;marketing&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/andrew_ivaniv_8d140452956/weve-built-a-local-first-app-store-screenshot-native-mac-tool-where-the-project-file-is-a-plain-2980" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;3&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/andrew_ivaniv_8d140452956/weve-built-a-local-first-app-store-screenshot-native-mac-tool-where-the-project-file-is-a-plain-2980#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            3 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>productivity</category>
      <category>showdev</category>
      <category>sideprojects</category>
      <category>tooling</category>
    </item>
  </channel>
</rss>
