{"id":"l-sw-4-4","courseId":"swift-essentials","lang":"en","kind":"lesson","summary":"Protect Pocket Budget’s logic with Swift Testing using @Test, #expect, #require and parameterized tests, then archive the app and ship a build to testers with TestFlight.","html":"<p>You’ve built Pocket Budget over fourteen lessons. Now picture changing <code dir=\"ltr\">CategoryBudget</code> next month to support rollover between months. How do you know the over-limit rule still works? You could tap through the app every time, or you could have the computer check it in a fraction of a second, on every change, forever. Then, once it works, you need real people using it on real phones before you trust it with an App Store launch.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">Swift Testing in five minutes</h2>\n<p>Swift Testing is Apple’s testing framework, built into Xcode 16 and later. When you create a project, pick Swift Testing as its testing system and Xcode adds a test target; for an existing project, choose File › New › Target › Unit Testing Bundle and pick Swift Testing. Test the budget logic from the error-handling lesson:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"swift\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">PocketBudgetTests/CategoryBudgetTests.swift</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#953800;--shiki-dark:#FFA657\"> Testing</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">@testable</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> import</span><span style=\"color:#953800;--shiki-dark:#FFA657\"> PocketBudget</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">struct</span><span style=\"color:#953800;--shiki-dark:#FFA657\"> CategoryBudgetTests</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    @Test</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> func</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> recordingAnExpenseIncreasesSpent</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">throws</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">        var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> food </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> CategoryBudget</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">limit</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">300</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">        try</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> food.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">record</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">title</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"Groceries\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">amount</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">64.2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">        #expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(food.spent </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">==</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 64.2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">        #expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(food.limit </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">-</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> food.spent </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">==</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 235.8</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    @Test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"Going over the limit throws and leaves the budget unchanged\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    func</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> overLimitThrows</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">throws</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">        var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> food </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> CategoryBudget</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">limit</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">50</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">        try</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> food.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">record</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">title</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"Lunch\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">amount</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">12</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">        #expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">throws</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: BudgetError.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">overLimit</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">by</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">7</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">            try</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> food.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">record</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">title</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"Dinner\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">amount</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">45</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">        }</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">        #expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(food.spent </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">==</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 12</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p><code dir=\"ltr\">@testable import</code> gives the tests access to your app’s internal types. A test is any function marked <code dir=\"ltr\">@Test</code>; the optional string is a readable name for the test navigator. Grouping tests in a struct makes it a <strong>suite</strong>, and each test gets a fresh instance, so tests can’t leak state into each other.</p>\n<p><code dir=\"ltr\">#expect</code> takes an ordinary Swift expression. When it fails, the report includes the actual values, for example <code dir=\"ltr\">Expectation failed: (food.spent → 12) == 57</code>, which usually tells you what went wrong before you open the debugger. <code dir=\"ltr\">#expect(throws:)</code> passes only if the closure throws that exact error. That’s why <code dir=\"ltr\">BudgetError</code> conforms to <code dir=\"ltr\">Equatable</code> back in the error-handling lesson. Run everything with Cmd-U, or click the diamond next to a single test.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">#require and parameterized tests</h2>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"swift\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">Swift</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">@Test</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> func</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> parsesValidAmount</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">throws</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    let</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> amount </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> try</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> #require</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">parseAmount</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"12.50\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">))</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">    #expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(amount </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">==</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 12.5</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">@Test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(arguments</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">:</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"abc\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"0\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"-3\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">])</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">func</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> rejectsInvalidAmounts</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">text</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">String</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">    #expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">parseAmount</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(text) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">==</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> nil</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p><code dir=\"ltr\">#require</code> is <code dir=\"ltr\">#expect</code> with a stop button: if the condition fails, the test ends right there. Given an optional, it unwraps it, so the rest of the test uses a real <code dir=\"ltr\">Decimal</code>. Use it for preconditions; use <code dir=\"ltr\">#expect</code> for the actual claims, so one run reports every broken claim, not just the first.</p>\n<p><code dir=\"ltr\">@Test(arguments:)</code> runs the same test once per input and reports each case separately. Four bad inputs, one function, and the failure tells you exactly which input broke.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Force unwrapping in tests</span></p>\n<p><code dir=\"ltr\">let amount = parseAmount(&quot;12.50&quot;)!</code> crashes the test process when the value is <code dir=\"ltr\">nil</code>, and you lose the rest of the run along with a useful message. <code dir=\"ltr\">try #require(…)</code> turns the same situation into one clearly reported failure.</p>\n</aside>\n<h2 id=\"sec-3\" tabindex=\"-1\">What to test first</h2>\n<p>Start where bugs would hurt users and where tests are cheap: pure logic with no UI. In Pocket Budget that’s budget arithmetic, amount parsing, currency conversion and <code dir=\"ltr\">LoadState</code> transitions. Those tests run in milliseconds and never flake. For code that needs SwiftData, create the in-memory container from the previous lesson inside the test, and mark the test <code dir=\"ltr\">@MainActor</code> when it touches the main context. Leave pixel-level UI checks for later; previews catch most layout problems faster.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 700 230\" role=\"img\" aria-labelledby=\"l-sw-4-4-t1\"><defs><marker id=\"arrow-l-sw-4-4-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-sw-4-4-t1\">Archive in Xcode, upload to App Store Connect, wait for processing. Then internal testers get the build immediately, while external testers get it after Beta App Review. Testers install with the TestFlight app.</title>\n  <rect class=\"d-box\" x=\"10\" y=\"80\" width=\"120\" height=\"60\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"70\" y=\"108\" text-anchor=\"middle\">Archive</text>\n  <text class=\"d-label-muted\" x=\"70\" y=\"128\" text-anchor=\"middle\">Xcode</text>\n  <path class=\"d-arrow\" d=\"M130 110 L158 110\" marker-end=\"url(#arrow-l-sw-4-4-1)\"/>\n  <rect class=\"d-box-accent\" x=\"160\" y=\"80\" width=\"130\" height=\"60\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"225\" y=\"108\" text-anchor=\"middle\">Upload</text>\n  <text class=\"d-label-muted\" x=\"225\" y=\"128\" text-anchor=\"middle\">Organizer</text>\n  <path class=\"d-arrow\" d=\"M290 110 L318 110\" marker-end=\"url(#arrow-l-sw-4-4-1)\"/>\n  <rect class=\"d-box-primary\" x=\"320\" y=\"80\" width=\"140\" height=\"60\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"390\" y=\"108\" text-anchor=\"middle\">Processing</text>\n  <text class=\"d-label-muted\" x=\"390\" y=\"128\" text-anchor=\"middle\">App Store Connect</text>\n  <path class=\"d-arrow\" d=\"M460 95 L518 50\" marker-end=\"url(#arrow-l-sw-4-4-1)\"/>\n  <path class=\"d-arrow\" d=\"M460 125 L518 170\" marker-end=\"url(#arrow-l-sw-4-4-1)\"/>\n  <rect class=\"d-box-success\" x=\"520\" y=\"15\" width=\"170\" height=\"64\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"605\" y=\"42\" text-anchor=\"middle\">Internal testers</text>\n  <text class=\"d-label-muted\" x=\"605\" y=\"63\" text-anchor=\"middle\">up to 100, no review</text>\n  <rect class=\"d-box-warn\" x=\"520\" y=\"140\" width=\"170\" height=\"64\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"605\" y=\"167\" text-anchor=\"middle\">External testers</text>\n  <text class=\"d-label-muted\" x=\"605\" y=\"188\" text-anchor=\"middle\">up to 10,000, reviewed</text>\n</svg><figcaption>From archive to testers</figcaption></figure>\n<h2 id=\"sec-4\" tabindex=\"-1\">Shipping a beta with TestFlight</h2>\n<p>TestFlight is how you get a build onto other people’s iPhones before release. You need a paid Apple Developer Program membership and an app record in App Store Connect with the same bundle identifier as your project. Then:</p>\n<ol>\n<li>In the target’s General settings, set the <strong>Version</strong> (what users see, such as 1.0) and the <strong>Build</strong> number (1, 2, 3…).</li>\n<li>Choose a generic iOS device as the run destination, then Product › Archive.</li>\n<li>In the Organizer window that opens, select the archive, click Distribute App and choose the App Store Connect option. Xcode signs and uploads the build.</li>\n<li>Wait for App Store Connect to process it, usually minutes, and answer the export compliance question about encryption.</li>\n<li>Add the build to a tester group. <strong>Internal testers</strong>, up to 100 people on your App Store Connect team, can install it right away. <strong>External testers</strong>, up to 10,000 people invited by email or a public link, get it after the build passes Beta App Review.</li>\n</ol>\n<p>Testers install the free TestFlight app, accept the invite and install your beta. They can send feedback with screenshots straight from the app, and you’ll find their reports, plus crash logs, in App Store Connect. Each build stays testable for 90 days.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Bump the build number every upload</span></p>\n<p>App Store Connect rejects an upload whose build number it has already seen for that version. Increase <strong>Build</strong> before every archive, or let Xcode manage it during distribution. Keep <strong>Version</strong> for meaningful releases.</p>\n</aside>\n<p>That’s the whole loop a professional iOS developer runs every week: model the data with types, build the screens in SwiftUI, fetch and store data safely under Swift 6’s concurrency checks, prove the logic with tests and put builds in testers’ hands. From here, good next steps are adding charts of spending by category with Swift Charts, syncing with iCloud through SwiftData, and preparing your App Store listing. You have the foundations for all of them.</p>\n","toc":[{"id":"sec-1","text":"Swift Testing in five minutes","level":2},{"id":"sec-2","text":"#require and parameterized tests","level":2},{"id":"sec-3","text":"What to test first","level":2},{"id":"sec-4","text":"Shipping a beta with TestFlight","level":2}],"takeaways":["Swift Testing tests are plain functions marked <code dir=\"ltr\">@Test</code>; <code dir=\"ltr\">#expect</code> checks a condition and reports the actual values when it fails.","<code dir=\"ltr\">#require</code> stops a test early when a precondition fails, and also unwraps optionals so the rest of the test works with real values.","<code dir=\"ltr\">#expect(throws:)</code> checks that code throws a specific error, and <code dir=\"ltr\">@Test(arguments:)</code> runs one test for many inputs.","Test pure logic first, such as budgets, parsing and formatting, because it’s fast, stable and holds the rules users care about most.","TestFlight distributes builds to up to 100 internal testers without review, and up to 10,000 external testers after Beta App Review."],"further":[{"title":"Swift Testing","url":"https://developer.apple.com/documentation/testing"},{"title":"TestFlight","url":"https://developer.apple.com/testflight/"},{"title":"Distributing your app for beta testing and releases","url":"https://developer.apple.com/documentation/xcode/distributing-your-app-for-beta-testing-and-releases"}],"quiz":[{"q":"<p>A test needs a parsed amount before it can check anything else, and <code dir=\"ltr\">parseAmount</code> returns <code dir=\"ltr\">Decimal?</code>. Which line fits best?</p>","options":[{"text":"<code dir=\"ltr\">let amount = parseAmount(&quot;12.50&quot;)!</code>","why":"A force unwrap crashes the whole test process on <code dir=\"ltr\">nil</code> instead of failing one test with a clear message."},{"text":"<code dir=\"ltr\">let amount = try #require(parseAmount(&quot;12.50&quot;))</code>","why":"Correct. It unwraps the value, and if it’s <code dir=\"ltr\">nil</code> the test stops there and is reported as failed."},{"text":"<code dir=\"ltr\">#expect(parseAmount(&quot;12.50&quot;) != nil)</code>, then use the optional.","why":"The test would carry on after a failure and every later line would have to deal with the optional."}],"answer":1},{"q":"<p><code dir=\"ltr\">#expect(food.spent == 57)</code> fails. What does Swift Testing report?</p>","options":[{"text":"Only “Expectation failed”, with no further detail.","why":"The macro captures the sub-expressions, so you also see the actual value."},{"text":"Nothing; the test keeps running and passes at the end.","why":"A failed <code dir=\"ltr\">#expect</code> is recorded as an issue, so the test fails even though it keeps running."},{"text":"The expression with its actual value, such as <code dir=\"ltr\">(food.spent → 12) == 57</code>.","why":"Correct. Seeing the real value usually tells you the bug without a debugger."}],"answer":2},{"q":"<p>Who can install a TestFlight build without it going through Beta App Review?</p>","options":[{"text":"Internal testers, who are members of your App Store Connect team.","why":"Correct. Internal testing exists for fast iteration within the team."},{"text":"Anyone who opens your public link.","why":"Public links are for external testers, whose builds need Beta App Review first."},{"text":"External testers you invite by email.","why":"External testers, however they’re invited, need a build approved by Beta App Review."}],"answer":0}],"exercise":{"kind":"fill","language":"swift","template":"import Testing\n@testable import PocketBudget\n\nstruct ParsingTests {\n    @{{0}} func parsesValidAmount() throws {\n        let amount = try {{1}}(parseAmount(\"12.50\"))\n        {{2}}(amount == 12.5)\n    }\n\n    @Test({{3}}: [\"\", \"abc\", \"0\", \"-3\"])\n    func rejectsInvalidAmounts(text: String) {\n        #expect(parseAmount(text) == nil)\n    }\n}","blanks":[{"answers":["Test"]},{"answers":["#require"]},{"answers":["#expect"]},{"answers":["arguments"]}],"title":"Test the amount parser","prompt":"<p>Write Swift Testing tests for <code dir=\"ltr\">parseAmount(_:)</code>, which returns a positive <code dir=\"ltr\">Decimal</code> or <code dir=\"ltr\">nil</code>. The first test must unwrap the parsed value and stop if it’s <code dir=\"ltr\">nil</code>, then check it equals 12.5. The second test must run once for each invalid input.</p>\n","hints":["Every test function carries the same attribute as the one on the second test.","One macro unwraps an optional and ends the test if it’s <code dir=\"ltr\">nil</code>; it’s used with <code dir=\"ltr\">try</code>.","The other macro records a failure but lets the test continue. For the last blank, look at the parameter the second test receives."],"explanation":"<p><code dir=\"ltr\">@Test</code> marks a test function. <code dir=\"ltr\">try #require(parseAmount(&quot;12.50&quot;))</code> unwraps the optional, and if parsing returned <code dir=\"ltr\">nil</code> the test stops with a clear failure instead of crashing. <code dir=\"ltr\">#expect(amount == 12.5)</code> then checks the result and, on failure, reports the actual value. <code dir=\"ltr\">@Test(arguments:)</code> runs <code dir=\"ltr\">rejectsInvalidAmounts</code> once per string, and each input shows up as its own test case, so a failure names the exact input that broke.</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"swift\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">Swift</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">@Test</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> func</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> parsesValidAmount</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">throws</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    let</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> amount </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> try</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> #require</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">parseAmount</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"12.50\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">))</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">    #expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(amount </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">==</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 12.5</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n","checkLabels":{}},"words":792,"readMinutes":4}