<?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: Daksh Gargas</title>
    <description>The latest articles on DEV Community by Daksh Gargas (@d4g4).</description>
    <link>https://dev.to/d4g4</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F81541%2F8e86e0e4-b356-4178-9fd3-9bde113d855e.jpg</url>
      <title>DEV Community: Daksh Gargas</title>
      <link>https://dev.to/d4g4</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/d4g4"/>
    <language>en</language>
    <item>
      <title>Our SwiftUI snapshot tests passed locally but failed on CI. Here's the actual fix.</title>
      <dc:creator>Daksh Gargas</dc:creator>
      <pubDate>Thu, 16 Apr 2026 03:07:52 +0000</pubDate>
      <link>https://dev.to/d4g4/our-swiftui-snapshot-tests-passed-locally-but-failed-on-ci-heres-the-actual-fix-5fhd</link>
      <guid>https://dev.to/d4g4/our-swiftui-snapshot-tests-passed-locally-but-failed-on-ci-heres-the-actual-fix-5fhd</guid>
      <description>&lt;p&gt;500+ snapshot tests, all green on every developer's Mac, all red on GitHub Actions. Sound familiar?&lt;/p&gt;

&lt;p&gt;The common advice is "record your reference images on CI" or "lower your precision threshold." We tried both. Neither felt right.&lt;/p&gt;

&lt;p&gt;Recording on CI means you can't verify snapshots locally anymore. Every UI change becomes a multi-step ritual: push a commit, wait for CI to fail, download the new reference PNGs from the artifacts, commit them, push again, wait for CI to pass. If you touch 10 views, that's 10 PNGs you need to pull down and commit blind — you're trusting CI's rendering as ground truth without ever seeing the images on your own screen. And if two people change UI on separate branches, you get merge conflicts in binary PNG files.&lt;/p&gt;

&lt;p&gt;Lowering precision thresholds is worse. Drop to 85% and you're not really testing the UI anymore — real regressions hide in the noise.&lt;/p&gt;

&lt;p&gt;It took us three wrong hypotheses and a lot of diff images to find the real cause. Sharing in case it saves someone else the same journey.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we moved from iOS Simulator to macOS
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Tests went from 170s to 7s locally (25x), CI from ~30 min to ~17 min.&lt;/p&gt;

&lt;p&gt;Before the snapshot story, some context on how we got here — because the move to macOS is what made this problem (and the fix) possible.&lt;/p&gt;

&lt;p&gt;Our test suite ran on the iOS Simulator. Every &lt;code&gt;xcodebuild test&lt;/code&gt; invocation booted a simulator, waited for it to become ready, deployed the test bundle, and ran. &lt;strong&gt;170 seconds&lt;/strong&gt; for a full run. Locally that's annoying; on CI it's brutal — you're paying for a macOS runner to sit idle while a virtual iPhone boots.&lt;/p&gt;

&lt;p&gt;We started asking: how many of these tests actually need a simulator? We audited the suite and the answer was almost none. Our app logic — state management, data parsing, network handling, navigation — is pure Swift. It doesn't call UIKit. And SwiftUI views? They render just fine on macOS through &lt;code&gt;NSHostingView&lt;/code&gt;. Apple's own framework handles the translation.&lt;/p&gt;

&lt;p&gt;So we flipped the destination from &lt;code&gt;platform=iOS Simulator&lt;/code&gt; to &lt;code&gt;platform=macOS&lt;/code&gt; and ran the suite. Most tests passed immediately. A handful needed &lt;code&gt;#if os(iOS)&lt;/code&gt; guards — things like &lt;code&gt;UIImage&lt;/code&gt; processing or &lt;code&gt;CLAuthorizationStatus&lt;/code&gt; that genuinely require iOS APIs. We kept those on the simulator and moved everything else to macOS.&lt;/p&gt;

&lt;p&gt;The result: &lt;strong&gt;7 seconds&lt;/strong&gt;. Same tests, same assertions, 25x faster. The CI improvement was even more dramatic — we switched to a build-once pattern (build the test target, upload the build artifact, then fan out parallel test jobs using &lt;code&gt;xcodebuild test-without-building&lt;/code&gt;). Total CI time dropped from ~30 minutes to ~17 minutes.&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.amazonaws.com%2Fuploads%2Farticles%2Fdguzv8jg63h02ccdeau3.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.amazonaws.com%2Fuploads%2Farticles%2Fdguzv8jg63h02ccdeau3.png" alt="Summary" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The logic tests worked perfectly on macOS. The snapshot tests did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we tried (and why it didn't work)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Hypothesis 1: "It's the resolution"
&lt;/h3&gt;

&lt;p&gt;Retina Macs render at 2x. CI VMs (GitHub Actions macOS runners) render at 1x. We built a custom rendering strategy that pins the bitmap to a fixed size — 390x844 at 1x scale. This fixed the dimension mismatch, but tests still failed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hypothesis 2: "It's font rendering"
&lt;/h3&gt;

&lt;p&gt;Physical Macs and CI VMs do render fonts slightly differently — roughly a 95% pixel match for identical views. We lowered precision thresholds: from 99.5% to 93% to 85%. Some tests still failed, and the threshold was getting uncomfortably low. At 85% precision, you're not really testing the UI anymore.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hypothesis 3: "It's non-deterministic animations"
&lt;/h3&gt;

&lt;p&gt;We disabled all SwiftUI animations via &lt;code&gt;.transaction { $0.animation = nil }&lt;/code&gt;. This helped with a few chart-related tests but didn't solve the core problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  What actually worked: measuring the images
&lt;/h3&gt;

&lt;p&gt;Each of those fixes solved a real problem — resolution normalization, font tolerance, animation disabling — and they all stayed in the final solution. But tests were &lt;em&gt;still&lt;/em&gt; failing after all three. Something else was going on.&lt;/p&gt;

&lt;p&gt;We opened the &lt;code&gt;.xcresult&lt;/code&gt; bundle and looked at the reference and failure images side by side. The content was clearly the same — but the images weren't aligned. The CI renders looked shorter, like something was clipping the bottom of the view. That was the clue.&lt;/p&gt;

&lt;p&gt;To confirm, we exported the failure attachments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;xcrun xcresulttool &lt;span class="nb"&gt;export &lt;/span&gt;attachments &lt;span class="nt"&gt;--path&lt;/span&gt; result.xcresult &lt;span class="nt"&gt;--output-path&lt;/span&gt; /tmp/ci-snapshot-compare
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then ran &lt;code&gt;sips&lt;/code&gt; — macOS's built-in image property tool — on the reference and failure PNGs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sips &lt;span class="nt"&gt;-g&lt;/span&gt; pixelWidth &lt;span class="nt"&gt;-g&lt;/span&gt; pixelHeight reference.png failure.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output was immediately conclusive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;weakSignal ref:    390 x 812
weakSignal fail:   390 x 645

disconnected ref:  390 x 812
disconnected fail: 390 x 645

noPulse ref:       390 x 812
noPulse fail:      390 x 645
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same width, but the CI images were &lt;strong&gt;167 pixels shorter&lt;/strong&gt;. Every single test showed the exact same pattern — that's not a rendering fluke, that's structural.&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.amazonaws.com%2Fuploads%2Farticles%2Fi2wmt52qhlyx6wit3oqg.jpeg" 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.amazonaws.com%2Fuploads%2Farticles%2Fi2wmt52qhlyx6wit3oqg.jpeg" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;swift-snapshot-testing&lt;/code&gt; renders views inside an &lt;code&gt;NSWindow&lt;/code&gt;. The window's title bar consumes part of the rendering area, and its height differs between a physical Mac and a headless CI VM. On CI, the title bar was eating 167 pixels out of the view's height — producing a shorter bitmap, not just a shifted one.&lt;/p&gt;

&lt;p&gt;That's it. Not fonts, not resolution, not animations. An NSWindow title bar stealing pixels from the rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Remove the window entirely. Render directly to an &lt;code&gt;NSHostingView&lt;/code&gt; and capture it with &lt;code&gt;cacheDisplay(in:to:)&lt;/code&gt; into a 1x &lt;code&gt;NSBitmapImageRep&lt;/code&gt;:&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;// Before: view inside NSWindow (title bar offset varies by environment)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NSWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;contentViewController&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hostingController&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;SnapshotTesting&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hostingController&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;as&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;image&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="c1"&gt;// After: standalone view, no window&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;hostingView&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NSHostingView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rootView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;hostingView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CGRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zero&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CGSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;390&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;844&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;bitmapRep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NSBitmapImageRep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* 390x844, 1x, deviceRGB */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;hostingView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;layoutSubtreeIfNeeded&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;hostingView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cacheDisplay&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="n"&gt;hostingView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bitmapRep&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Compare the resulting NSImage against the reference PNG&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No window = no title bar = no environment-dependent offset.&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.amazonaws.com%2Fuploads%2Farticles%2Fccn2i409ouraq7xu9ot7.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.amazonaws.com%2Fuploads%2Farticles%2Fccn2i409ouraq7xu9ot7.png" alt="Before and After" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reference images recorded on any developer's MacBook now pass on CI with no special setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  A subtle gotcha: &lt;code&gt;cacheDisplay&lt;/code&gt; vs &lt;code&gt;displayIgnoringOpacity&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If you search for "NSView to image" you'll find suggestions to use &lt;code&gt;bitmapImageRepForCachingDisplay&lt;/code&gt; + &lt;code&gt;displayIgnoringOpacity&lt;/code&gt;. That method doesn't render SwiftUI text content — labels come out invisible. &lt;code&gt;cacheDisplay(in:to:)&lt;/code&gt; renders the full view hierarchy correctly, including &lt;code&gt;Text&lt;/code&gt; views.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Error messages like "95.3% of pixels match" tell you &lt;em&gt;something&lt;/em&gt; is wrong but not &lt;em&gt;what&lt;/em&gt;. We spent days tuning thresholds and disabling animations based on that number alone.&lt;/p&gt;

&lt;p&gt;A single &lt;code&gt;sips&lt;/code&gt; command told us more than days of threshold tuning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your snapshot tests fail on CI:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Don't lower precision thresholds below ~95% — you're hiding real regressions&lt;/li&gt;
&lt;li&gt;Don't record on CI unless you have no alternative — it makes local iteration slow&lt;/li&gt;
&lt;li&gt;Extract the failure attachments (&lt;code&gt;xcrun xcresulttool export attachments&lt;/code&gt;) and run &lt;code&gt;sips -g pixelWidth -g pixelHeight&lt;/code&gt; on reference vs actual — if the dimensions don't match, it's not a rendering difference, it's structural&lt;/li&gt;
&lt;li&gt;If the images are shorter or offset, check whether you're rendering inside a window&lt;/li&gt;
&lt;/ol&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.amazonaws.com%2Fuploads%2Farticles%2Fcp6nw5hioru8zspsux1q.jpeg" 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.amazonaws.com%2Fuploads%2Farticles%2Fcp6nw5hioru8zspsux1q.jpeg" alt="Summary" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dls.com" rel="noopener noreferrer"&gt;Denver Life Sciences&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Relevant links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/pointfreeco/swift-snapshot-testing" rel="noopener noreferrer"&gt;swift-snapshot-testing&lt;/a&gt; by Point-Free&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/pointfreeco/swift-snapshot-testing/issues/313" rel="noopener noreferrer"&gt;Issue #313: Snapshot color differences local vs CI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/pointfreeco/swift-snapshot-testing/issues/926" rel="noopener noreferrer"&gt;Issue #926: Intermittent failures due to rendering differences&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/pointfreeco/swift-snapshot-testing/discussions/722" rel="noopener noreferrer"&gt;Discussion #722: Automatically record on failures&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ios</category>
      <category>swiftui</category>
      <category>testing</category>
      <category>cicd</category>
    </item>
  </channel>
</rss>
