{"id":"l-sw-4-4","courseId":"swift-essentials","lang":"ar","kind":"lesson","summary":"احمِ منطق Pocket Budget بـ Swift Testing باستخدام @Test و#expect و#require والاختبارات ذات المعاملات، ثم أرشِف التطبيق وأرسل نسخة إلى المختبِرين عبر TestFlight.","html":"<p>بنيت Pocket Budget على مدى أربعة عشر درسًا. تخيّل الآن أنك ستغيّر <code dir=\"ltr\">CategoryBudget</code> الشهر القادم لدعم ترحيل الرصيد بين الأشهر. كيف تعرف أن قاعدة تجاوز الحدّ ما زالت تعمل؟ يمكنك أن تنقر في التطبيق في كل مرة، أو أن تجعل الحاسوب يتحقّق منها في جزء من الثانية، مع كل تغيير، إلى الأبد. ثم، بعد أن يعمل، تحتاج إلى أشخاص حقيقيين يستخدمونه على هواتف حقيقية قبل أن تثق به في إطلاق على App Store.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">Swift Testing في خمس دقائق</h2>\n<p>Swift Testing هو إطار الاختبار من Apple، المدمج في Xcode 16 وما بعده. حين تنشئ مشروعًا، اختر Swift Testing كنظام الاختبار وسيضيف Xcode هدف اختبار (test target)؛ ولمشروع موجود، اختر File › New › Target › Unit Testing Bundle ثم اختر Swift Testing. اختبر منطق الميزانية من درس معالجة الأخطاء:</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=\"نسخ الكود\"><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>نسخ</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> الاختبارات صلاحية الوصول إلى الأنواع الداخلية في تطبيقك. الاختبار هو أي دالة معلّمة بـ <code dir=\"ltr\">@Test</code>؛ والنص الاختياري اسم مقروء يظهر في متصفّح الاختبارات. وتجميع الاختبارات في struct يجعله <strong>suite</strong> (مجموعة اختبارات)، ويحصل كل اختبار على instance جديد، فلا تستطيع الاختبارات تسريب state إلى بعضها.</p>\n<p>يأخذ <code dir=\"ltr\">#expect</code> تعبير Swift عاديًا. وحين يفشل، يتضمّن التقرير القيم الفعلية، مثل <code dir=\"ltr\">Expectation failed: (food.spent → 12) == 57</code>، وهذا يخبرك في الغالب بما حدث قبل أن تفتح الـ debugger. أما <code dir=\"ltr\">#expect(throws:)</code> فلا ينجح إلا إن رمى الـ closure ذلك الخطأ بالضبط. ولهذا يتوافق <code dir=\"ltr\">BudgetError</code> مع <code dir=\"ltr\">Equatable</code> منذ درس معالجة الأخطاء. شغّل كل شيء بـ Cmd-U، أو انقر على المعيّن بجانب اختبار واحد.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">‏#require والاختبارات ذات المعاملات</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=\"نسخ الكود\"><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>نسخ</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> هو <code dir=\"ltr\">#expect</code> مع زر إيقاف: إن فشل الشرط، ينتهي الاختبار عند ذلك السطر. وإن أعطيته optional، يفكّه، فيستخدم باقي الاختبار قيمة <code dir=\"ltr\">Decimal</code> حقيقية. استخدمه للشروط المسبقة؛ واستخدم <code dir=\"ltr\">#expect</code> للادّعاءات الفعلية، فيُبلغ التشغيل الواحد عن كل ادّعاء مكسور، لا عن الأول فقط.</p>\n<p>يشغّل <code dir=\"ltr\">@Test(arguments:)</code> الاختبار نفسه مرة لكل مُدخَل ويُبلغ عن كل حالة على حدة. أربعة مدخلات سيئة، ودالة واحدة، والفشل يخبرك بالضبط أيّ مُدخَل كسر الكود.</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\">خطأ شائع</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>الفكّ القسري في الاختبارات</span></p>\n<p><code dir=\"ltr\">let amount = parseAmount(&quot;12.50&quot;)!</code> يُسقط عملية الاختبار حين تكون القيمة <code dir=\"ltr\">nil</code>، فتخسر باقي التشغيل ومعه رسالة مفيدة. أما <code dir=\"ltr\">try #require(…)</code> فيحوّل الموقف نفسه إلى فشل واحد مُبلَّغ عنه بوضوح.</p>\n</aside>\n<h2 id=\"sec-3\" tabindex=\"-1\">ماذا تختبر أولًا</h2>\n<p>ابدأ حيث تؤذي الأخطاء المستخدمين وحيث تكون الاختبارات رخيصة: المنطق الخالص بلا واجهة. في Pocket Budget هذا يعني حسابات الميزانية، وتحليل المبالغ، وتحويل العملات، وانتقالات <code dir=\"ltr\">LoadState</code>. تعمل هذه الاختبارات في أجزاء من الثانية ولا تتذبذب نتائجها أبدًا. وللكود الذي يحتاج SwiftData، أنشئ الـ container في الذاكرة من الدرس السابق داخل الاختبار، وعلّم الاختبار بـ <code dir=\"ltr\">@MainActor</code> حين يلمس الـ context الرئيسي. اترك فحوصات الواجهة على مستوى البكسل لوقت لاحق؛ فالـ previews تكتشف معظم مشاكل التخطيط أسرع.</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\">أرشِف في Xcode، وارفع إلى App Store Connect، وانتظر المعالجة. بعدها يحصل المختبِرون الداخليون على النسخة فورًا، بينما يحصل عليها المختبِرون الخارجيون بعد Beta App Review. ويثبّت المختبِرون النسخة عبر تطبيق TestFlight.</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\">أرشفة</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\">رفع</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\">معالجة</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\">مختبِرون داخليون</text>\n  <text class=\"d-label-muted\" x=\"605\" y=\"63\" text-anchor=\"middle\">حتى 100، بلا مراجعة</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\">مختبِرون خارجيون</text>\n  <text class=\"d-label-muted\" x=\"605\" y=\"188\" text-anchor=\"middle\">حتى 10,000، بعد مراجعة</text>\n</svg><figcaption>من الأرشيف إلى المختبِرين</figcaption></figure>\n<h2 id=\"sec-4\" tabindex=\"-1\">إطلاق نسخة تجريبية عبر TestFlight</h2>\n<p>TestFlight هو طريقتك لإيصال نسخة إلى أجهزة iPhone الخاصة بأشخاص آخرين قبل الإطلاق. تحتاج إلى عضوية مدفوعة في Apple Developer Program وسجلّ للتطبيق في App Store Connect بمعرّف الحزمة (bundle identifier) نفسه الذي في مشروعك. ثم:</p>\n<ol>\n<li>في إعدادات General للهدف، اضبط <strong>Version</strong> (ما يراه المستخدمون، مثل 1.0) ورقم <strong>Build</strong> (‏1، 2، 3…).</li>\n<li>اختر جهاز iOS عامًا (generic) كوجهة للتشغيل، ثم Product › Archive.</li>\n<li>في نافذة Organizer التي تُفتح، اختر الأرشيف، وانقر Distribute App واختر خيار App Store Connect. يوقّع Xcode النسخة ويرفعها.</li>\n<li>انتظر حتى يعالجها App Store Connect، وهذا يستغرق دقائق عادةً، وأجب عن سؤال الامتثال لقيود التصدير المتعلّق بالتشفير.</li>\n<li>أضف النسخة إلى مجموعة مختبِرين. <strong>المختبِرون الداخليون</strong>، وهم حتى 100 شخص في فريقك على App Store Connect، يستطيعون تثبيتها فورًا. أما <strong>المختبِرون الخارجيون</strong>، وهم حتى 10,000 شخص تدعوهم بالبريد الإلكتروني أو برابط عام، فيحصلون عليها بعد أن تجتاز النسخة Beta App Review.</li>\n</ol>\n<p>يثبّت المختبِرون تطبيق TestFlight المجاني، ويقبلون الدعوة، ويثبّتون نسختك التجريبية. ويستطيعون إرسال ملاحظات مع لقطات شاشة مباشرة من التطبيق، وستجد تقاريرهم، مع سجلّات الأعطال، في App Store Connect. وتبقى كل نسخة قابلة للاختبار 90 يومًا.</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\">نصيحة</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>ارفع رقم الـ Build مع كل رفع</span></p>\n<p>يرفض App Store Connect أي رفع رأى رقم الـ Build الخاص به من قبل لذلك الإصدار. زِد <strong>Build</strong> قبل كل أرشفة، أو دع Xcode يديره أثناء التوزيع. واحتفظ بـ <strong>Version</strong> للإصدارات ذات المعنى.</p>\n</aside>\n<p>هذه هي الدورة الكاملة التي يمرّ بها مطوّر iOS المحترف كل أسبوع: صمّم البيانات بالأنواع، وابنِ الشاشات بـ SwiftUI، واجلب البيانات واحفظها بأمان تحت فحوصات الـ concurrency في Swift 6، وأثبت المنطق بالاختبارات، وضع النسخ بين أيدي المختبِرين. ومن هنا، من الخطوات التالية الجيدة إضافة رسوم بيانية للإنفاق حسب التصنيف باستخدام Swift Charts، والمزامنة مع iCloud عبر SwiftData، وتجهيز صفحة تطبيقك على App Store. لديك الأساسات اللازمة لكل ذلك.</p>\n","toc":[{"id":"sec-1","text":"Swift Testing في خمس دقائق","level":2},{"id":"sec-2","text":"‏#require والاختبارات ذات المعاملات","level":2},{"id":"sec-3","text":"ماذا تختبر أولًا","level":2},{"id":"sec-4","text":"إطلاق نسخة تجريبية عبر TestFlight","level":2}],"takeaways":["اختبارات Swift Testing دوال عادية معلّمة بـ <code dir=\"ltr\">@Test</code>؛ و<code dir=\"ltr\">#expect</code> يفحص شرطًا ويُبلغ عن القيم الفعلية حين يفشل.","<code dir=\"ltr\">#require</code> يوقف الاختبار مبكرًا حين يفشل شرط مسبق، ويفكّ الـ optionals أيضًا فيعمل باقي الاختبار بقيم حقيقية.","<code dir=\"ltr\">#expect(throws:)</code> يتحقّق من أن الكود يرمي خطأً محدّدًا، و<code dir=\"ltr\">@Test(arguments:)</code> يشغّل اختبارًا واحدًا على مدخلات كثيرة.","اختبر المنطق الخالص أولًا، مثل الميزانيات والتحليل والتنسيق، لأنه سريع ومستقر ويحمل القواعد التي تهمّ المستخدمين أكثر.","يوزّع TestFlight النسخ على ما يصل إلى 100 مختبِر داخلي دون مراجعة، وعلى ما يصل إلى 10,000 مختبِر خارجي بعد 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>يحتاج اختبار إلى مبلغ محلَّل قبل أن يستطيع فحص أي شيء آخر، و<code dir=\"ltr\">parseAmount</code> تعيد <code dir=\"ltr\">Decimal?</code>. أيّ سطر هو الأنسب؟</p>","options":[{"text":"<code dir=\"ltr\">let amount = parseAmount(&quot;12.50&quot;)!</code>","why":"الفكّ القسري يُسقط عملية الاختبارات كلها عند <code dir=\"ltr\">nil</code> بدل أن يُفشل اختبارًا واحدًا برسالة واضحة."},{"text":"<code dir=\"ltr\">let amount = try #require(parseAmount(&quot;12.50&quot;))</code>","why":"صحيح. يفكّ القيمة، وإن كانت <code dir=\"ltr\">nil</code> يتوقّف الاختبار عندها ويُسجَّل فاشلًا."},{"text":"<code dir=\"ltr\">#expect(parseAmount(&quot;12.50&quot;) != nil)</code>، ثم تستخدم الـ optional.","why":"سيستمر الاختبار بعد الفشل، وسيضطر كل سطر لاحق إلى التعامل مع الـ optional."}],"answer":1},{"q":"<p>فشل <code dir=\"ltr\">#expect(food.spent == 57)</code>. بماذا يُبلغ Swift Testing؟</p>","options":[{"text":"فقط “Expectation failed”، دون أي تفاصيل أخرى.","why":"يلتقط الماكرو التعبيرات الفرعية، فترى القيمة الفعلية أيضًا."},{"text":"لا شيء؛ يستمر الاختبار وينجح في النهاية.","why":"الـ <code dir=\"ltr\">#expect</code> الفاشل يُسجَّل كمشكلة، فيفشل الاختبار رغم أنه يستمر في العمل."},{"text":"التعبير مع قيمته الفعلية، مثل <code dir=\"ltr\">(food.spent → 12) == 57</code>.","why":"صحيح. رؤية القيمة الحقيقية تكشف لك الخطأ في الغالب دون debugger."}],"answer":2},{"q":"<p>من يستطيع تثبيت نسخة TestFlight دون أن تمرّ بـ Beta App Review؟</p>","options":[{"text":"المختبِرون الداخليون، وهم أعضاء فريقك في App Store Connect.","why":"صحيح. الاختبار الداخلي موجود للتكرار السريع داخل الفريق."},{"text":"أي شخص يفتح الرابط العام الخاص بك.","why":"الروابط العامة مخصّصة للمختبِرين الخارجيين، الذين تحتاج نسخهم إلى Beta App Review أولًا."},{"text":"المختبِرون الخارجيون الذين تدعوهم بالبريد الإلكتروني.","why":"المختبِرون الخارجيون، أيًّا كانت طريقة دعوتهم، يحتاجون إلى نسخة اعتمدتها 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":"اختبر محلّل المبالغ","prompt":"<p>اكتب اختبارات Swift Testing للدالة <code dir=\"ltr\">parseAmount(_:)</code>، التي تعيد <code dir=\"ltr\">Decimal</code> موجبًا أو <code dir=\"ltr\">nil</code>. يجب أن يفكّ الاختبار الأول القيمة المحلَّلة ويتوقّف إن كانت <code dir=\"ltr\">nil</code>، ثم يتحقّق من أنها تساوي 12.5. ويجب أن يعمل الاختبار الثاني مرة لكل مُدخَل غير صالح.</p>\n","hints":["كل دالة اختبار تحمل السمة نفسها الموجودة على الاختبار الثاني.","هناك ماكرو يفكّ الـ optional وينهي الاختبار إن كانت القيمة <code dir=\"ltr\">nil</code>؛ ويُستخدم مع <code dir=\"ltr\">try</code>.","الماكرو الآخر يسجّل الفشل لكنه يترك الاختبار يستمر. وللفراغ الأخير، انظر إلى المعامل الذي يستقبله الاختبار الثاني."],"explanation":"<p>تعلّم <code dir=\"ltr\">@Test</code> دالة اختبار. ويفكّ <code dir=\"ltr\">try #require(parseAmount(&quot;12.50&quot;))</code> الـ optional، وإن أعاد التحليل <code dir=\"ltr\">nil</code> يتوقّف الاختبار بفشل واضح بدل أن يتعطّل. ثم يتحقّق <code dir=\"ltr\">#expect(amount == 12.5)</code> من النتيجة، وعند الفشل يُبلغ عن القيمة الفعلية. أما <code dir=\"ltr\">@Test(arguments:)</code> فيشغّل <code dir=\"ltr\">rejectsInvalidAmounts</code> مرة لكل نص، ويظهر كل مُدخَل كحالة اختبار مستقلّة، فيسمّي الفشل المُدخَل الذي كسر الكود بالضبط.</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=\"نسخ الكود\"><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>نسخ</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":716,"readMinutes":4}