افتح صندوق بريد

الاختبار وCI

GitHub Actions: مهمة شاملة تقرأ بريدًا حقيقيًا

اختبار التسجيل الذي يقرأ رمز التأكيد من صندوق بريد يعمل على حاسوب محمول، ثم يقابل مُشغِّل CI: بلا سر يجب تركيبه، ومسألة اتصال صادر، ومهلة يجب أن تلائم المهمة، وصندوق بريد يجب أن يكون جديدًا في كل تشغيلة وكل إعادة محاولة. وفيما يلي سير العمل، لـPlaywright ولـpytest، بالأجزاء التي لا تهم إلا على مُشغِّل.

  • متوسط
  • 15 دقيقةً للقراءة
ثلاثة تروس رمادية تُدير سيرًا يحمل مظروفًا أزرق نحو بوابة رمادية يعلوها مصباح أزرق

ما الذي يتغير على المُشغِّل

الاختبار نفسه لا يتغير. ما يتغير هو كل ما حوله، ولكل واحد من هذه إجابة محددة لا مجرد هزّة كتفين:

لا يوجد سر يجب تركيبه
قراءة صندوق بريد عام لا تحتاج مفتاحًا، ولا حسابًا، ولا ترويسة، فلا يضيف جانب صندوق البريد شيئًا إلى secrets. وبيانات الاعتماد الوحيدة في المهمة هي التي يحتاجها تطبيقك أصلاً لـإرسال البريد — SendGrid أو Postmark أو SES، أيًا كان — وهي ملك لتطبيقك، لا للاختبار.
يجب أن يصل المُشغِّل إلى الإنترنت
اتصال صادر عبر HTTPS إلى grabmail.io، واتصال صادر إلى أيًا كان ما يستخدمه مُرسِلك. تسمح مُشغِّلات GitHub المستضافة بكليهما افتراضيًا؛ أما المُشغِّل ذاتي الاستضافة خلف مرشِّح للاتصال الصادر فيحتاج إلى إضافة قاعدة واحدة.
التشغيلات تتداخل
طلبا سحب، وأربعة أجزاء، وإعادة محاولة لمهمة غير مستقرة — عدة نسخ من الاختبار نفسه تقرأ البريد في الوقت نفسه. وعنوان مشترك سيسمح لها بقراءة رموز بعضها بعضًا؛ أما عنوان جديد لكل اختبار فيجعل هذه الفئة كاملة من المشكلات مستحيلة.
الوقت محسوب بعدّاد
اختبار ينتظر البريد ستين ثانية أمر لا بأس به. أما مهمة تنتظر ستين ثانية في كل اختبار من أربعين اختبارًا فهي أربعون دقيقة من وقت مُشغِّل مفوتَر. يجب أن تكون الانتظارات محدودة، ويجب تجزئة مجموعة الاختبارات حالما تكبر.

وكود الاختبار الذي تُشغِّله سيرا العمل هذان هو نفسه من دليل Playwright أو دليل Python: عنوان جديد، وانتظار بمهلة زمنية، ومستخرِج مثبَّت على قالبك. ولا شيء فيه خاص بـCI، وهذا بالضبط المقصود — فالأجزاء الخاصة بالمُشغِّل تعيش كلها في ملف سير العمل.

سير العمل، لـPlaywright

مهمة واحدة. تُشغِّل تطبيقك بمُرسِل بريد صادر حقيقي، وتنتظر رده، وتشغِّل مجموعة الاختبارات، وتحتفظ بالتقرير فقط حين يفشل شيء ما.

.github/workflows/e2e.yml
name: e2e

on:
  push:
    branches: [main]
  pull_request:

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 20                 # the whole job, comfortably above every wait inside it

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npx playwright install --with-deps chromium

      # Your application, started the way it runs in staging: a REAL outbound
      # mailer. Its credentials are YOUR secret; the mailbox side needs none.
      - name: Start the application
        run: npm run start:test &
        env:
          MAILER_API_KEY: ${{ secrets.MAILER_API_KEY }}
          APP_URL: http://localhost:3000

      - name: Wait for the application
        run: npx wait-on --timeout 60000 http://localhost:3000/health

      - name: Run the suite
        run: npx playwright test
        env:
          BASE_URL: http://localhost:3000

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

ثلاثة أسطر تحمل الثقل. فـtimeout-minutes: 20 هو الحد الخارجي الذي يتداخل كل شيء آخر بداخله. ويُشغَّل التطبيق ببيانات اعتماد مُرسِله الحقيقية، لأن اختبارًا يقرأ بريدًا حقيقيًا يحتاج إلى إرسال بريد حقيقي. ولا يُرفَع التقرير إلا عند الفشل، بمدة احتفاظ قصيرة — فالتشغيلة الناجحة لا تحمل شيئًا يستحق الاحتفاظ به.

سير العمل، لـpytest

الشكل نفسه بسلسلة أدوات Python: شغِّل التطبيق، وانتظر نقطة فحص صحته، وشغِّل مجموعة الاختبارات بمهلة لكل اختبار أعلى من مهلة البريد، واحتفظ بتقرير JUnit عند الفشل.

.github/workflows/e2e.yml
name: e2e

on:
  push:
    branches: [main]
  pull_request:

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: pip

      - run: pip install -r requirements.txt -r requirements-test.txt

      - name: Start the application
        run: python -m app.server &
        env:
          MAILER_API_KEY: ${{ secrets.MAILER_API_KEY }}
          APP_URL: http://localhost:8000

      - name: Wait for the application
        run: |
          for i in $(seq 1 60); do
            curl -sf http://localhost:8000/health && exit 0
            sleep 1
          done
          echo "application did not come up" >&2; exit 1

      - name: Run the suite
        run: pytest tests/e2e -q --timeout=120 --junitxml=report.xml
        env:
          BASE_URL: http://localhost:8000

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: pytest-report
          path: report.xml

و--timeout=120 يأتي من إضافة (plugin) pytest-timeout وهو السقف لكل اختبار؛ ومهلة البريد داخل المساعد ستون ثانية، بحيث يبقى الاختبار الذي ينتظر رسالة واحدة ثم يقوم ببعض عمل المتصفح ضمن الحد. وحلقة فحص الصحة مكتوبة كاملة بدل استيرادها كـaction لأنها ثمانية أسطر ولا شيء فيها يمكن أن يُخطأ فيه.

قاعدة الشبكة الوحيدة

قراءة صندوق بريد هي طلب HTTPS صادر من المُشغِّل إلى grabmail.io. وهذا هو أثر الشبكة كاملاً:

  • بلا اتصال وارد. لا شيء يتصل بالمُشغِّل. لا webhook يجب استقباله، ولا خادم SMTP يجب تشغيله، ولا منفذ يجب كشفه.
  • بلا SMTP من المُشغِّل. يُرسَل البريد بواسطة تطبيقك عبر مزوّده، عبر API أو نقطة SMTP الخاصة بذلك المزوّد — بالطريقة نفسها التي يفعلها في الإنتاج. ولا يتحدث المُشغِّل SMTP بنفسه أبدًا.
  • لا يلزم إضافة سوى grabmail.io:443 على مُشغِّل له قائمة سماح للاتصال الصادر — إضافة إلى مزوّد بريدك وسجل حزمك، وهو ما احتاجته المهمة أصلاً.
على مُشغِّل مُحصَّن، اسمح بهذا بالضبط
      - uses: step-security/harden-runner@v2
        with:
          egress-policy: block
          allowed-endpoints: >
            grabmail.io:443
            api.your-mail-provider.example:443
            registry.npmjs.org:443

إذا نجحت مجموعة الاختبارات محليًا وفشلت في CI بخطأ اتصال من المساعد، فهذه القاعدة هي أول ما يجب فحصه، وهي تكاد تكون الإجابة كاملةً دائمًا. وعادةً ما يكون الاتصال الصادر عبر HTTPS في مُشغِّل ذاتي الاستضافة داخل شبكة شركة مُصفّى حسب اسم المضيف؛ واسم المضيف الذي يجب السماح به هو اسم API، والطلب هو HTTPS عادي على المنفذ 443.

الأجزاء، ومهام المصفوفة، وإعادات المحاولة

حالما تصبح مجموعة الاختبارات بطيئة بما يكفي لتجزئتها، جزِّئها. يقسِّم Playwright تشغيلة عبر عدة مهام بـ--shard، ولأن كل اختبار يفتح صندوق بريده الخاص، فالأجزاء لا تحتاج شيئًا من بعضها بعضًا:

أربعة أجزاء، كل منها مهمة منفصلة
  e2e:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      # ... the same steps as above, then:
      - run: npx playwright test --shard=${{ matrix.shard }}/4

والخاصية نفسها تغطي الطريقتين الأخريين اللتين ينتهي بهما الاختبار إلى العمل مرتين في الوقت نفسه:

طلبا سحب في الوقت نفسه
مهمتان، ومجموعتان من العناوين العشوائية، بلا أي تداخل. وسقف كل عميل في API هو 1200 طلب في الدقيقة، أي عشرون صندوق بريد تُستطلَع كل واحدة مرة في الثانية — ومهمة تستطلع صندوق بريد واحدًا في كل مرة بعيدة تمامًا عن ذلك الحد.
مهمة أُعيدت محاولتها
تُشغِّل إعادة المحاولة متن الاختبار مجددًا، وهو ما يبتكر عنوانًا جديدًا مجددًا. ويظل صندوق البريد القديم يحتفظ بالرسالة القديمة لمدة 5 يومًا، ولا شيء يقرؤها — فإعادة المحاولة لا تراها أبدًا.
خاصية retries في Playwright نفسها
الشيء نفسه، بمستوى أدنى: تُشغِّل كل محاولة الـfixture مجددًا. لا تنقل العنوان إلى beforeAll توفيرًا للوقت؛ فهذا بالضبط هو التشارك الذي يسمح لمحاولة بقراءة رمز المحاولة السابقة.

مهلات تتسع داخل المهمة

هناك أربع ساعات ويجب أن تتداخل، بحيث تكون الأقصر هي الأعمق. وحين لا تفعل ذلك، يُبلَّغ عن الفشل من الساعة الخطأ ويشير إلى السبب الخطأ.

الساعةتُضبط أينقيمة معقولة
مهلة البريدداخل المساعد (timeoutMs، timeout=)60 ثانية. يصل البريد المعاملاتي خلال ثوانٍ؛ ودقيقة واحدة تغطي طابور مزوّد بطيء.
مهلة الاختبارplaywright.config.ts / --timeout120 ثانية. أعلى من المهلة زائد عمل المتصفح المحيط بها.
مهلة الخطوةtimeout-minutes على الخطوة، إن وُجدتغير مضبوطة عادةً؛ فحد المهمة يكفي.
مهلة المهمةtimeout-minutes على المهمة20 دقيقة. تكفي للتثبيت، والتشغيل، ومجموعة الاختبارات، والرفع؛ ومنخفضة بما يكفي حتى لا يُفوتَر تطبيق مُعلَّق ساعة كاملة.

عرض التداخل المكسور محدد: فاختبار ينتظر ستين ثانية داخل مهلة اختبار Playwright الافتراضية البالغة ثلاثين ثانية يموت عند الثانية الثلاثين برسالة عن الاختبار، في كل مرة، ولا يقول شيئًا عن البريد. اضبط مهلة الاختبار أولاً، ثم كل ما هو أبعد منها.

قراءة الفشل

يجب أن تُخبرك التشغيلة الفاشلة بأي من ثلاثة أشياء حدث — لم يصل البريد أبدًا، أو وصل البريد الخطأ، أو كان الرمز فيه خطأً — دون إعادة تشغيل أي شيء. وأربع عادات تحقق ذلك:

  1. سجِّل العنوان. تذكر رسالة فشل المساعد اسم صندوق البريد الذي انتظر عليه. اطبعه مرة أخرى في أعلى الاختبار بحيث يكون في سجل المهمة حتى حين يكون التحقق (assert) في مكان آخر.
  2. افتح صندوق البريد يدويًا. تبقى الرسائل لمدة 5 يومًا، فـ/inbox/<address> على هذا الموقع يُظهر بالضبط ما رآه المُشغِّل — أو لم يره — لبقية الأسبوع. وهذا أكثر شيء مفيد في صندوق بريد حقيقي مقارنة بآخر مُحاكى (mocked).
  3. احتفظ بالتقرير عند الفشل. يُظهر trace الخاص بـPlaywright النقرة التي كان يفترض أن ترسل البريد؛ ويُظهر ملف JUnit أي اختبار وكم انتظر.
  4. تحقق من حالة الخدمة قبل إلقاء اللوم على الاختبار. تُفحَص صفحة الحالة من الخارج كل دقيقتين؛ فإن كان البريد الوارد معطَّلاً وقت التشغيلة، فالفشل كان حقيقيًا وليس خطأك.

التنظيف، اختياريًا

كل شيء تنتهي صلاحيته بعد 5 يومًا سواء حذفه أحد أم لا، فتشغيلة تتجاوز التنظيف لا تكلِّف شيئًا. ومع ذلك يستحق حذف ما قرأته التشغيلة خطوة، لأن الفشل التالي يُقرأ عندئذٍ مقابل صندوق بريد فارغ حقًا. وهو idempotent — فالحذف مرتين لا يزال يجيب بـ200 — فلا يمكنه أبدًا أن يُفشِل بناءً بمفرده:

خطوة تنظيف تعمل دائمًا
      - name: Delete what the run read
        if: always()
        run: |
          for addr in $(cat .e2e-addresses 2>/dev/null); do
            curl -sG https://grabmail.io/api/v1/mailbox --data-urlencode "address=$addr" \
            | jq -r '.messages[].id' \
            | xargs -r -I{} curl -sX DELETE -G "https://grabmail.io/api/v1/message/{}" --data-urlencode "mailbox=$addr" -o /dev/null
          done

اجعلها if: always() ولا تجعلها إلزامية أبدًا: فتنظيف يفشل يجب أن يكون تحذيرًا في سجل، لا بناءً فاشلاً.

قبل أن تعتبره منتهيًا

  • لا بيانات اعتماد لصندوق البريد في secrets؛ فقط مفتاح مُرسِل تطبيقك الخاص.
  • اتصال صادر عبر HTTPS إلى grabmail.io مسموح به، ولا شيء واردًا.
  • عنوان جديد لكل اختبار، مُبتكَر داخل متن الاختبار — آمن تحت الأجزاء وإعادات المحاولة.
  • المهلات الأربع متداخلة: المهلة < الاختبار < الخطوة < المهمة.
  • التقرير مرفوع عند الفشل، والعنوان موجود في السجل.
  • التنظيف كخطوة تعمل دائمًا وغير إلزامية أبدًا.

هذا كل ما يضيفه المُشغِّل. وانضباط الاختبار الأساسي — المهلة، والعنوان الجديد، والنمط المثبَّت — موجود في اختبار مسار تحقق من طرف إلى طرف، وقواعد الاستخراج وحدها في رموز OTP في الاختبارات الآلية.

أسئلة

هل أحتاج إلى إضافة سر GrabMail إلى المستودع؟

لا. فالنطاقات العامة لا تحتاج مفتاحًا، ولا حسابًا، ولا ترويسة، فلا يوجد ما يجب إضافته إلى secrets. وبيانات الاعتماد الوحيدة في سير العمل هي التي يستخدمها تطبيقك لإرسال البريد، وهي ما كان سيحتاجه ليعمل أصلاً.

هل يعمل هذا في مستودع خاص أو على مُشغِّل ذاتي الاستضافة؟

نعم. يُجري المُشغِّل طلبات HTTPS صادرة إلى grabmail.io ولا شيء غير ذلك؛ ومكان استضافتك للمُشغِّل غير ذي صلة. وعلى مُشغِّل ذاتي الاستضافة خلف مرشِّح للاتصال الصادر، اسمح باسم المضيف ذاك على المنفذ 443.

هل ستصطدم المهام المتزامنة بحد المعدل؟

ليس عمليًا. فالحد لكل عنوان قراءة واحدة في الثانية، وهو ما يحترمه المساعد، وسقف كل عميل 1200 طلب في الدقيقة — عشرون صندوق بريد تُستطلَع كل واحدة مرة في الثانية، من مُشغِّل واحد. وعدة مُشغِّلات هي عدة عملاء. ويُجاب عن 429 بـRetry-After، وينتظر المساعد بدل أن يفشل.

هل يمكنني تشغيل هذا وفق جدول زمني، كفحص اصطناعي لتسجيل الإنتاج؟

نعم، وهذا استخدام جيد له: فسير عمل بـon: schedule يسجِّل بعنوان جديد كل ساعة ويقرأ الرمز يُثبت مسار بريد الإنتاج كاملاً، بما فيه المزوّد. أبقِ بادئة العنوان قابلة للتمييز بحيث يسهل حذف عمليات التسجيل من جهتك.

ماذا لو كان تطبيقي يرفض النطاقات المؤقتة؟

وجِّه نطاقًا تملكه إلى الخدمة — سجل MX واحد، بلا حساب — واستخدم ذلك النطاق في الـfixture. يستغرق الإعداد دقائق معدودة، ويُظهر دليل حسابات اختبار غير محدودة على نطاق واحد هذا النمط داخل مجموعة اختبارات.

هل أي شيء في صندوق البريد خاص؟

لا. يستطيع أي شخص يعرف عنوانًا قراءته، سواء على نطاق عام أو على نطاقك الخاص. وبالنسبة لعنوان عشوائي يحمل رمزًا عابرًا واحدًا، هذا أمر لا صلة له بالموضوع؛ أما بالنسبة لبيئة staging ترسل بيانات عملاء حقيقية فهذا أمر يستبعد استخدامها هنا — لا توجِّه واحدة إلى هنا.

تابع القراءة

جرّبه وهو جديد

عنوان واحد يستغرق نقرة واحدة، بلا حساب وبلا بطاقة. كل ما في هذا الدليل يعمل عليه فورًا.

أهلًا بعودتك

صناديقك ونطاقاتك في مكان واحد.