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

الاختبار وCI

اختبار البريد مع Playwright: قراءة رمز من صندوق حقيقي

يستطيع اختبار Playwright تعبئة نموذج تسجيل خلال ثانيتين، ثم لا يعرف شيئًا عمّا يحدث بعد ذلك، لأن الخطوة التالية بريد إلكتروني. وفيما يلي fixture يقرأ ذلك البريد — رسالة حقيقية، من صندوق بريد حقيقي، بلا مفتاح API — والعادات الثلاث التي تمنع الاختبار من أن يصبح غير مستقر.

  • متوسط
  • 20 دقيقةً للقراءة
نافذة متصفح رمادية عليها سهم مؤشر أزرق، ومظروف أزرق ينزلق في فتحة على جانبها، وساعة إيقاف رمادية في المقدمة

أين يتوقف اختبار Playwright عادةً

تنتهي معظم اختبارات التسجيل عند جملة «تحقق من بريدك الوارد». فقد عُبِّئ النموذج، وضُغط الزر، وقالت الصفحة الشيء الصحيح — وكل ما يحدث بعد تلك الجملة يُفترَض افتراضًا. هل خرج البريد فعلاً، وهل الرمز فيه هو الرمز الذي يتوقعه الخادم، وهل يفتح رابط التأكيد صفحة تعمل: كل ذلك متروك للإنتاج.

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

محاكاة مُرسِل البريد
يثبت أن الكود لديك استدعى send(). ولا يثبت شيئًا عن القالب، أو الرابط، أو المزوّد الذي رفض الرسالة.
ملتقط SMTP محلي (Mailpit وMailHog وsmtp4dev)
يثبت أن رسالة سليمة التكوين خرجت من التطبيق. خدمة إضافية في CI، ولا يحدث هنا أي شيء مما يحدث فقط على الإنترنت العام — بحث MX حقيقي، ومزوّد حقيقي، ومستلم حقيقي.
صندوق بريد مؤقت حقيقي
يثبت أن الرسالة خرجت من التطبيق، وعبرت الإنترنت، وقبِلها خادم بريد حقيقي، وتحمل رمزًا يعمل فعلاً. والتكلفة الوحيدة أن على الاختبار الانتظار بالطريقة الصحيحة — وهذا هو موضوع هذا الدليل بأكمله.

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

fixture يمنح كل اختبار صندوق وارده الخاص

test.extend في Playwright هو المكان الصحيح لذلك: يصبح صندوق الوارد شيئًا يطلبه الاختبار بالاسم، مثل page، ويُبتكر العنوان من جديد في كل مرة. لا شيء يحتاج إلى إنشاء على الخادم — يبدأ وجود صندوق البريد لحظة وصول البريد إليه — لذا فالـfixture عبارة عن صنف (class) فيه عنوان عشوائي وثلاث دوال (methods) صغيرة.

tests/fixtures.ts
import { test as base, expect } from '@playwright/test';

const API = 'https://grabmail.io/api/v1';
const DOMAIN = 'grabmail.io';

export type Summary = { id: string; from: string; subject: string; date: string };
export type Message = {
  id: string; from: string; to: string; subject: string; date: string;
  text: string | null; html: string | null;
};

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

export class Inbox {
  readonly address: string;

  /** A mailbox nothing else in this run, or any previous run, is using. */
  constructor(prefix = 'e2e') {
    this.address = `${prefix}-${Math.random().toString(36).slice(2, 10)}@${DOMAIN}`;
  }

  /** Block until a matching message arrives, or the deadline passes. */
  async waitFor(opts: { timeoutMs?: number; subjectContains?: string; fromContains?: string } = {}): Promise<Message> {
    const deadline = Date.now() + (opts.timeoutMs ?? 60_000);

    while (Date.now() < deadline) {
      const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(this.address)}`);

      if (res.status === 429) {                       // slow down, do not fail
        await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000);
        continue;
      }
      if (!res.ok) throw new Error(`GET /mailbox answered ${res.status} for ${this.address}`);

      const { messages } = (await res.json()) as { messages: Summary[] };
      const hit = messages.find(m =>
        (!opts.subjectContains || m.subject.toLowerCase().includes(opts.subjectContains.toLowerCase())) &&
        (!opts.fromContains    || m.from.toLowerCase().includes(opts.fromContains.toLowerCase())));
      if (hit) return this.read(hit.id);

      await sleep(1000);                              // one read a second, never throttled
    }
    throw new Error(`no message for ${this.address} within the deadline`);
  }

  async read(id: string): Promise<Message> {
    const res = await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`);
    if (!res.ok) throw new Error(`GET /message answered ${res.status}`);
    return res.json() as Promise<Message>;
  }

  /** Optional: everything expires on its own after a few days. Idempotent. */
  async delete(id: string): Promise<void> {
    await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`, { method: 'DELETE' });
  }
}

export const test = base.extend<{ inbox: Inbox }>({
  inbox: async ({}, use) => {
    await use(new Inbox());
  },
});

export { expect };

هناك أمران مقصودان في ذلك الملف. الأول أن العنوان عشوائي لكل اختبار، لا لكل ملف ولا لكل تشغيلة، بحيث لا تستطيع العمليات (workers) المتوازية أبدًا قراءة بريد بعضها بعضًا. والثاني أن waitFor يُعيد الرسالة كاملة بدل الملخص — فأنت عمليًا تريد المتن دائمًا بعد ذلك، ونداء واحد أقل في كل اختبار له قيمته متراكمًا.

انتظار الرسالة بلا sleep

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

  • مهلة زمنية، لا عدد محاولات. for (let i = 0; i < 30; i++) هي ثلاثون محاولة بأي سرعة تعمل بها الحلقة مصادفةً — أقصر كلما أسرعت API، وأطول كلما تباطأ مُرسِلك. أما المهلة الزمنية الفعلية فتعني الشيء نفسه على كل جهاز.
  • قراءة واحدة في الثانية. هذا هو الإيقاع الموثَّق، ولا يُقيَّد أبدًا. وما هو أسرع من ذلك يُرفض بـ429 وترويسة Retry-After، والاستطلاع الأسرع لن يجعل البريد يصل أبكر.
  • بلا waitForTimeout. فالانتظار الثابت إما قصير جدًا في يوم بطيء أو طويل جدًا في كل يوم آخر. وتتوقف الحلقة لحظة وجود الرسالة.
  • رشِّح؛ ولا تأخذ أحدث رسالة عشوائيًا. مرِّر subjectContains أو fromContains. فحين يرسل مسار ما رسالتين — ترحيب ورمز — لا تكون الأحدث دائمًا هي التي تريدها.

رموز الحالة التي ستقابلها الحلقة، وما يجب أن تفعله مع كل واحد منها:

الرمزالمعنىما تفعله الحلقة
200تمت قراءة صندوق البريد. قد تكون count تساوي 0 — فصندوق البريد الفارغ ليس أبدًا 404.ابحث عن تطابق؛ فإن لم يوجد، انتظر ثانية واحدة وحاول مجددًا.
400العنوان غير سليم الصياغة.أطلق استثناءً (throw). فإعادة محاولة خطأ إملائي لا تصلحه.
404النطاق غير مستضاف هنا.أطلق استثناءً، وتحقق من سجل MX إن كان نطاقك الخاص.
429أكثر من قراءة واحدة في الثانية لذلك العنوان، أو أكثر من 1200 طلب في الدقيقة من هذا المُشغِّل.انتظر عدد ثوانٍ Retry-After ثم تابع. لا تُسقِط الاختبار أبدًا بسبب 429.

استخراج الرمز، أو الرابط، من الرسالة

تعود الرسالة بكلا الجزأين، ويعتمد أيهما تُحلِّل على ما يرسله تطبيقك:

text
الجزء النصي البسيط. حلِّل هذا حين يوجد — بلا ترميز، والرمز المكوَّن من ست خانات هو ست خانات لا غير.
html
جزء HTML، أو null حين يرسل المرسِل نصًا فقط. وغالبًا ما توجد روابط التأكيد هنا فقط، داخل <a href>، مع كتابة & على شكل &amp;.
tests/extract.ts
import type { Message } from './fixtures';

/** The whole body, both parts, with HTML entities a link may carry undone. */
const bodyOf = (m: Message) => `${m.text ?? ''}\n${m.html ?? ''}`.replace(/&amp;/g, '&');

/** Anchored on your own wording, so a reworded template fails loudly. */
export function codeFrom(m: Message, pattern = /code is\s*([0-9]{6})/i): string {
  const hit = bodyOf(m).match(pattern);
  if (!hit) throw new Error(`no confirmation code in "${m.subject}"`);
  return hit[1];
}

/** The link whose path contains a fragment you know — never "the first URL". */
export function linkFrom(m: Message, pathContains: string): string {
  const re = new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`);
  const hit = bodyOf(m).match(re);
  if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
  return hit[0];
}

النمط مُثبَّت على صياغة قالبك أنت عمدًا. فـ[0-9]{6} وحده يطابق بسهولة سنة، أو سعرًا، أو رقم طلب صادف أن يظهر أولاً؛ أما code is ([0-9]{6}) فيطابق رمزك أنت ولا شيء غيره — واليوم الذي يُعيد فيه أحدهم صياغة البريد، يفشل الاختبار ويخبرك بذلك، بدل أن ينجح برقم خطأ.

تُطابَق الروابط بجزء من المسار تعرفه — /confirm/، /reset/ — بدلاً من «أول URL»، لأن البريد المعاملاتي يحمل عادةً خمسة روابط: الشعار، وإلغاء الاشتراك، ومركز المساعدة، وشارة متجر التطبيقات، والرابط الذي تريده.

ثلاثة مسارات، من طرف إلى طرف

مع وجود الـfixture ودوال الاستخراج جاهزة، يُقرأ كل اختبار كأنه الميزة التي يختبرها. فالانتظار والاستطلاع والتحليل موجودة في مكان آخر، وهذا هو السبب الكامل لوضعها هناك.

التسجيل برمز التحقق

tests/signup.spec.ts
import { test, expect } from './fixtures';
import { codeFrom } from './extract';

test('a new account confirms its email address', async ({ page, inbox }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('correct-horse-battery-staple');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Check your inbox')).toBeVisible();

  const message = await inbox.waitFor({ subjectContains: 'confirm' });

  await page.getByLabel('Confirmation code').fill(codeFrom(message));
  await page.getByRole('button', { name: 'Confirm' }).click();
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

رابط سحري يسجِّل دخول المستخدم

لا شيء لكتابته: يزور الاختبار الرابط الذي تحمله الرسالة، ويتحقق (assert) من الوجهة التي يصل إليها.

tests/magic-link.spec.ts
import { test, expect } from './fixtures';
import { linkFrom } from './extract';

test('a magic link signs the user in', async ({ page, inbox }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Email me a link' }).click();

  const message = await inbox.waitFor({ subjectContains: 'sign in' });
  await page.goto(linkFrom(message, '/auth/magic/'));

  await expect(page).toHaveURL(/\/dashboard/);
});

إعادة تعيين كلمة المرور، ثم تسجيل الدخول بكلمة المرور الجديدة

يحتاج اختبار إعادة التعيين إلى مستخدم موجود مسبقًا، وهذه مهمة يتولاها منفذ الاختبار الخاص بتطبيقك — نقطة نهاية داخلية، أو fixture لقاعدة البيانات، أو CLI — لا المتصفح. وبعد ذلك يكون المسار بالشكل نفسه للمسارات الأخرى: طلب، انتظار، متابعة، تحقق.

tests/password-reset.spec.ts
import { test, expect } from './fixtures';
import { linkFrom } from './extract';

test('a password reset link changes the password', async ({ page, inbox, request }) => {
  // Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
  await request.post('/internal/test/users', { data: { email: inbox.address, password: 'old-password-1' } });

  await page.goto('/forgot-password');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Send reset link' }).click();

  const message = await inbox.waitFor({ subjectContains: 'reset' });
  await page.goto(linkFrom(message, '/reset/'));
  await page.getByLabel('New password').fill('new-password-2');
  await page.getByRole('button', { name: 'Change password' }).click();

  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('new-password-2');
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page).toHaveURL(/\/dashboard/);
});

جعله يصمد في CI

كل ما سبق يعمل على حاسوب محمول. وهذه هي الأشياء التي لا تتعطل إلا حين يعمل عشرين مرة في اليوم على جهاز شخص آخر.

العرضالسببالحل
ينجح محليًا، ويفشل في CIلا يستطيع المُشغِّل الوصول إلى الإنترنت العام، أو تُصفَّى حركة الاتصال الصادرة.اسمح بـgrabmail.io عبر HTTPS. لا شيء آخر — لا منفذ SMTP، ولا اتصال وارد.
يفشل في المرة الأولى، وينجح عند إعادة المحاولةمُرسِلك يضع البريد في طابور، والمهلة أقصر من زمن الطابور.ارفع المهلة قبل لمس أي شيء آخر. ستون ثانية سقف معقول لبريد معاملاتي.
429 على شكل دفعاتعدة اختبارات تستطلع عنوانًا واحدًا، أو تجاوز المُشغِّل كله 1200 طلب في الدقيقة.عنوان واحد لكل اختبار — والـfixture يفعل ذلك. وسقف العميل عشرون صندوق بريد تُستطلَع كل واحدة مرة في الثانية.
بناء ناجح، وميزة معطَّلةعنوان أُعيد استخدامه قدَّم رسالة قديمة.عنوان عشوائي لكل اختبار. هذا هو الأمر المهم.
غير مستقر مع عدة عمليات (workers) فقطاختباران يتشاركان صندوق بريد، أو تحقق يعتمد على أي رسالة هي الأحدث.عنوان جديد لكل اختبار، ومرشِّح subjectContains؛ لا تأخذ أحدث رسالة عشوائيًا أبدًا.
يعمل لمدة أسبوع، ثم لا يعمل أبدًاfixture خزَّن معرِّف رسالة مؤقتًا؛ وكل شيء هنا يُحذف بعد 5 يومًا.يجب أن تُطلِق الاختبارات بريدها الخاص في كل تشغيلة. لا شيء يصمد 5 يومًا.

لا يوجد سر يجب تخزينه. فالنطاقات العامة لا تحتاج مفتاحًا، ولا حسابًا، ولا ترويسة — وإن احتاج خط أنابيبك (pipeline) إلى بيانات اعتماد لتشغيل هذه الاختبارات، فهناك شيء أُسيء فهمه. والإعداد الوحيد الذي يستحق التدوين هو المهلة، لأنها الشيء الوحيد الذي تخطئ فيه إعدادات Playwright الافتراضية بالنسبة لاختبار ينتظر بريدًا:

playwright.config.ts
// playwright.config.ts — the project that reads mail gets a timeout above the mail deadline
export default defineConfig({
  timeout: 120_000,
  expect: { timeout: 10_000 },
  fullyParallel: true,          // safe: every test has its own inbox
});

وسير عمل GitHub Actions الذي يشغِّل مجموعة الاختبارات هذه، بعد حسم مسألتَي المهلة والاتصال الصادر، مكتوب بالتفصيل في دليل CI.

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

تتحقق بعض نماذج التسجيل من العنوان مقابل القوائم العامة للنطاقات المؤقتة وترفض grabmail.io فور رؤيته. وهذه ميزة في تطبيقك، لا عيب في الاختبار — والحل ليس إضعاف الفحص في بيئة الاختبار. بل وجِّه نطاقًا تملكه إلى هذه الخدمة بدلاً من ذلك: سجل MX واحد، بلا حساب، ويصبح كل عنوان عليه صندوق بريد يستطيع الـfixture نفسه قراءته بتغيير ثابت واحد.

وتحويل نطاق إلى صندوق وارد catch-all هو الإعداد؛ وحسابات اختبار غير محدودة على نطاق واحد هو شكل ذلك داخل مجموعة اختبارات.

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

  • عنوان مختلف لكل اختبار، من الـfixture — لا ثابت أبدًا.
  • مهلة زمنية فعلية، وفشل يذكر اسم العنوان الذي انتظر عليه.
  • التعامل مع 429 بالانتظار حسب Retry-After، لا بالفشل.
  • مطابقة الرمز أو الرابط بصياغتك أنت، لا بنمط مجرد.
  • مهلة الاختبار أعلى من مهلة البريد بفارق مريح.
  • مرشِّح على الموضوع أو المرسِل، بحيث تفوز الرسالة الصحيحة حين تصل رسالتان.
  • بلا أي تحقق على سرعة وصول البريد — فقط أنه وصل.

هذا هو الانضباط كله. وكل شيء آخر عن اختبار البريد في Playwright يماثل اختبار أي شيء آخر غير متزامن. والمساعد نفسه بصيغة أوامر Cypress موجود في دليل Cypress؛ وقواعد الاستخراج وحدها، لأي مُشغِّل، موجودة في رموز OTP في الاختبارات الآلية.

أسئلة

هل أحتاج إلى مفتاح API لقراءة صندوق البريد من Playwright؟

لا. فالنطاقات العامة لا تحتاج مفتاحًا، ولا حسابًا، ولا ترويسة. ولا تحتاج ترويسة Authorization: Bearer إلا مجموعة النطاقات المدفوعة التي تبقى بعيدة عن قوائم حظر البريد المؤقت، وهذا منتج منفصل.

هل يمكن تشغيل الاختبارات في عمليات (workers) متوازية؟

نعم، وهذا بالضبط سبب وجود عنوان عشوائي لكل اختبار: لا تستطيع عمليتان أبدًا قراءة بريد بعضهما بعضًا. وسقف كل عميل 1200 طلب في الدقيقة، أي عشرون صندوق بريد تُستطلَع كل واحدة مرة في الثانية — وهذا أكثر من كافٍ لمجموعة اختبارات، والـfixture لا يستطلع على أي حال أسرع من مرة في الثانية.

هل يجب أن أستخدم fixture الخاص بـrequest في Playwright بدلاً من fetch؟

كلاهما يعمل. استُخدم fetch هنا لأن المساعد (helper) يعمل بذلك دون تغيير في سكربت Node عادي، أو إعداد عام، أو مُشغِّل آخر. ويضيف request الخاص بـPlaywright تتبعًا (tracing) للنداءات، وهذا يستحق وجوده إذا أردت أن يظهر الاستطلاع في trace viewer.

ماذا لو وصل البريد قبل أن يبدأ الاختبار الاستطلاع؟

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

هل صندوق البريد خاص أثناء استخدام الاختبار له؟

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

كيف أنظِّف بعد ذلك؟

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

تابع القراءة

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

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

أهلًا بعودتك

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