Pengujian & CI

Uji email Playwright: membaca kode dari kotak masuk asli

Sebuah pengujian Playwright bisa mengisi formulir pendaftaran dalam dua detik lalu sama sekali tidak tahu apa yang terjadi setelahnya, karena langkah berikutnya adalah sebuah email. Berikut fixture yang membacanya — pesan sungguhan, dari kotak surat sungguhan, tanpa kunci API — dan tiga kebiasaan yang menjaga pengujian ini agar tidak flaky.

  • Menengah
  • 20 menit baca
Jendela peramban abu-abu dengan panah kursor biru, amplop biru meluncur masuk ke celah di sisinya, dan stopwatch abu-abu di depan

Di mana pengujian Playwright biasanya berhenti

Kebanyakan pengujian pendaftaran berhenti di kalimat “cek kotak masuk Anda”. Formulirnya sudah diisi, tombolnya sudah diklik, halamannya sudah menampilkan pesan yang benar — dan semua yang terjadi setelah kalimat itu hanya diasumsikan. Apakah emailnya benar-benar terkirim, apakah kode di dalamnya adalah kode yang diharapkan server, apakah tautan konfirmasinya membuka halaman yang berfungsi: semua itu diserahkan begitu saja ke production.

Itu dibiarkan begitu karena langkah berikutnya bersifat asinkron dan berada di luar browser, dan Playwright tidak punya apa pun untuk diklik. Ketiga cara umum untuk mengatasinya masing-masing membuktikan hal yang berbeda:

Mem-mock mailer
Membuktikan kode Anda memanggil send(). Tidak membuktikan apa pun soal template, tautan, atau provider yang menolak pesan itu.
Sebuah SMTP sink lokal (Mailpit, MailHog, smtp4dev)
Membuktikan sebuah pesan yang terbentuk dengan benar keluar dari aplikasi. Satu layanan tambahan lagi di CI, dan tidak ada apa pun yang hanya terjadi di internet publik — pencarian MX sungguhan, provider sungguhan, penerima sungguhan — yang terjadi di sini.
Kotak surat sekali pakai yang sungguhan
Membuktikan pesan itu keluar dari aplikasi, melintasi internet, diterima oleh server mail sungguhan, dan membawa kode yang benar-benar berfungsi. Satu-satunya biayanya adalah pengujian ini harus menunggu dengan benar — dan itulah keseluruhan isi panduan ini.

API di baliknya hanyalah tiga endpoint tanpa kunci, didokumentasikan di referensinya. Jika Anda ingin memahami disiplin umumnya dulu sebelum masuk ke hal-hal spesifik Playwright, menguji alur verifikasi dari ujung ke ujung membahasnya untuk runner apa pun; ini adalah versi Playwright-nya, lengkap dengan fixture yang membuatnya menyenangkan.

Sebuah fixture yang memberi setiap pengujian kotak masuknya sendiri

test.extend milik Playwright adalah tempat yang tepat untuk ini: kotak masuk menjadi sesuatu yang diminta pengujian lewat namanya, seperti page, dan alamatnya dibuat baru setiap kali. Tidak ada yang perlu dibuat di server — kotak surat ada begitu email mencapainya — jadi fixture-nya berupa sebuah class dengan alamat acak di dalamnya dan tiga metode kecil.

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 };

Ada dua hal yang disengaja dalam file itu. Alamatnya acak per pengujian, bukan per file atau per run, sehingga worker paralel tidak akan pernah bisa membaca email satu sama lain. Dan waitFor mengembalikan pesan lengkap, bukan ringkasannya — pada praktiknya Anda selalu butuh isi pesannya setelah itu, dan satu panggilan lebih sedikit di setiap pengujian jadi lumayan banyak jika dijumlahkan.

Menunggu pesan tanpa sleep

Email tidak bersifat sinkron. Biasanya sampai dalam dua atau tiga detik dan sesekali butuh dua puluh detik, dan cara pengujian menunggu itulah yang menentukan apakah suite-nya bisa dipercaya. Aturannya singkat:

  • Batas waktu, bukan hitungan retry. for (let i = 0; i < 30; i++) adalah tiga puluh kali percobaan pada kecepatan apa pun yang kebetulan dijalankan loop-nya — lebih singkat saat API-nya makin cepat, lebih lama saat pengirim Anda makin lambat. Batas waktu jam dinding berarti hal yang sama di mesin mana pun.
  • Satu kali baca per detik. Itulah ritme yang didokumentasikan dan tidak pernah dibatasi di luar itu. Lebih cepat dari itu akan ditolak dengan 429 dan header Retry-After, dan melakukan polling lebih cepat pun tidak akan membuat emailnya sampai lebih awal.
  • Jangan pakai waitForTimeout. Sleep tetap selalu terlalu singkat di hari yang lambat atau terlalu lama di hari-hari lainnya. Loop-nya berhenti begitu pesannya ada.
  • Gunakan filter; jangan asal ambil pesan terbaru. Sertakan subjectContains atau fromContains. Saat sebuah alur mengirim dua pesan — sambutan dan kode — pesan terbaru tidak selalu yang Anda inginkan.

Kode status yang akan ditemui loop ini, dan apa yang harus dilakukan untuk masing-masing:

KodeArtinyaYang dilakukan loop
200Kotak suratnya berhasil dibaca. count boleh saja 0 — kotak surat kosong tidak akan pernah menjadi 404.Cari yang cocok; jika tidak ada, sleep satu detik lalu coba lagi.
400Alamatnya salah format.Lempar error (throw). Mengulang percobaan pada salah ketik tidak akan memperbaikinya.
404Domainnya tidak di-hosting di sini.Lempar error (throw), dan periksa record MX-nya jika itu domain Anda sendiri.
429Lebih dari satu kali baca per detik untuk alamat itu, atau lebih dari 1200 request per menit dari runner ini.Sleep selama Retry-After detik lalu lanjutkan. Jangan pernah menggagalkan pengujian karena 429.

Mengambil kode, atau tautan, dari dalam pesan

Pesannya kembali dengan kedua bagian ini, dan bagian mana yang perlu di-parse tergantung apa yang dikirim aplikasi Anda:

text
Bagian teks polos. Parse bagian ini kalau ada — tanpa markup, dan kode enam digit ya tetap kode enam digit.
html
Bagian HTML-nya, atau null kalau pengirim hanya mengirim teks saja. Tautan konfirmasi sering kali hanya ada di sini, di dalam sebuah <a href>, dengan & ditulis sebagai &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];
}

Pola ini sengaja dipatok pada kata-kata di template Anda sendiri. [0-9]{6} sendirian dengan senang hati akan cocok dengan sebuah tahun, sebuah harga, atau nomor pesanan yang kebetulan muncul duluan; code is ([0-9]{6}) hanya cocok dengan kode Anda dan tidak ada yang lain — dan pada hari seseorang mengubah kata-kata di emailnya, pengujian ini akan gagal dan memberi tahu Anda, alih-alih lolos dengan angka yang salah.

Tautan dicocokkan berdasarkan fragmen path yang Anda kenal — /confirm/, /reset/ — bukan berdasarkan “URL pertama”, karena sebuah email transaksional biasanya membawa lima tautan sekaligus: logo, berhenti berlangganan, pusat bantuan, badge app store, dan yang benar-benar Anda inginkan.

Tiga alur, dari ujung ke ujung

Dengan fixture dan extractor sudah terpasang, setiap pengujian jadi terbaca seperti fitur yang sedang diujinya. Penantian, polling, dan parsing-nya ada di tempat lain, dan itulah keseluruhan alasan menaruhnya di sana.

Pendaftaran dengan kode konfirmasi

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();
});

Magic link yang langsung membuat pengguna masuk

Tidak ada yang perlu diketik: pengujiannya mengunjungi tautan yang dibawa pesan itu, lalu memastikan (assert) ke mana tautan itu bermuara.

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/);
});

Reset kata sandi, lalu login dengan kata sandi baru

Pengujian reset ini membutuhkan pengguna yang sudah ada, dan itu tugas untuk test seam milik aplikasi Anda sendiri — sebuah endpoint internal, fixture basis data, atau CLI — bukan tugas untuk browser. Setelah itu, alurnya berbentuk sama seperti yang lain: request, tunggu, ikuti, lalu assert.

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/);
});

Membuatnya bertahan di CI

Semua yang di atas berfungsi di laptop. Berikut ini hal-hal yang baru rusak begitu dijalankan dua puluh kali sehari di mesin milik orang lain.

GejalaPenyebabPerbaikan
Lolos secara lokal, gagal di CIRunner-nya tidak bisa menjangkau internet publik, atau koneksi keluarnya (egress) difilter.Izinkan grabmail.io lewat HTTPS. Tidak ada yang lain — tanpa port SMTP, tanpa koneksi masuk.
Gagal di percobaan pertama, lolos saat diulangPengirim Anda mengantre email dan batas waktunya lebih singkat daripada antreannya.Naikkan batas waktunya dulu sebelum mengubah apa pun yang lain. Enam puluh detik adalah batas atas yang wajar untuk email transaksional.
429 yang datang beruntunBeberapa pengujian melakukan polling ke satu alamat yang sama, atau seluruh runner melewati 1200 request per menit.Satu alamat per pengujian — fixture-nya sudah menangani itu. Batas atas klien adalah dua puluh kotak surat yang di-polling sekali per detik.
Build hijau, fitur rusakAlamat yang dipakai ulang menyajikan pesan lama.Alamat acak per pengujian. Inilah yang benar-benar penting.
Flaky hanya saat memakai beberapa workerDua pengujian berbagi satu kotak surat, atau sebuah assertion soal pesan mana yang paling baru.Alamat baru per pengujian dan filter subjectContains; jangan pernah asal ambil pesan terbaru.
Berfungsi selama seminggu, lalu tidak pernah lagiFixture yang meng-cache sebuah id pesan; semua di sini dihapus setelah 5 hari.Pengujian harus memicu emailnya sendiri di setiap run. Tidak ada yang bertahan melewati 5 hari.

Tidak ada secret yang perlu disimpan. Domain publiknya tidak butuh kunci, tidak butuh akun, dan tidak butuh header apa pun — jika pipeline Anda butuh kredensial untuk menjalankan pengujian ini, berarti ada sesuatu yang salah dipahami. Satu pengaturan yang layak dicatat adalah timeout-nya, karena itulah satu-satunya hal yang defaultnya keliru dari Playwright untuk pengujian yang menunggu email:

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
});

Sebuah workflow GitHub Actions yang menjalankan suite ini, dengan batas waktu dan urusan egress yang sudah beres, dituliskan lengkap di panduan CI.

Jika aplikasi Anda menolak domain sekali pakai

Sebagian formulir pendaftaran memeriksa alamatnya lewat daftar publik domain sekali pakai dan langsung menolak grabmail.io begitu saja. Itu adalah fitur dari aplikasi Anda, bukan cacat dari pengujiannya — dan solusinya bukan melemahkan pemeriksaan itu khusus untuk lingkungan pengujian. Sebagai gantinya, arahkan domain milik Anda sendiri ke layanan ini: satu record MX, tanpa akun, dan setiap alamat pada domain itu menjadi kotak surat yang bisa dibaca fixture yang sama hanya dengan mengubah satu konstanta.

Mengubah sebuah domain menjadi kotak masuk catch-all adalah pengaturannya; akun pengujian tanpa batas pada satu domain adalah wujudnya di dalam sebuah test suite.

Sebelum Anda menganggapnya selesai

  • Alamat yang berbeda untuk setiap pengujian, dari fixture-nya — jangan pernah konstanta.
  • Batas waktu jam dinding, dan pesan kegagalan yang menyebutkan alamat yang ditunggunya.
  • 429 ditangani dengan sleep selama Retry-After, bukan dengan menggagalkan pengujian.
  • Kode atau tautannya dicocokkan dengan kata-kata Anda sendiri, bukan pola yang polos.
  • Batas waktu pengujian yang cukup jauh di atas batas waktu emailnya.
  • Filter pada subjek atau pengirim, sehingga pesan yang tepat yang terpilih saat ada dua pesan yang datang.
  • Tidak ada assertion soal seberapa cepat emailnya sampai — hanya bahwa emailnya benar-benar sampai.

Itulah keseluruhan disiplinnya. Semua hal lain soal menguji email di Playwright sama saja dengan menguji hal asinkron lainnya. Pembantu yang sama dalam bentuk command Cypress ada di panduan Cypress; aturan ekstraksinya sendiri, untuk runner apa pun, ada di kode OTP dalam pengujian otomatis.

Pertanyaan

Apakah saya butuh kunci API untuk membaca kotak surat dari Playwright?

Tidak. Domain publiknya tidak butuh kunci, tidak butuh akun, dan tidak butuh header apa pun. Hanya kumpulan domain berbayar yang dijaga agar tidak masuk daftar blokir email sekali pakai yang membutuhkan header Authorization: Bearer, dan itu adalah produk yang terpisah.

Bisakah pengujian ini berjalan di worker paralel?

Bisa, dan itulah gunanya alamat acak per pengujian: dua worker tidak akan pernah bisa membaca email satu sama lain. Batas atas per klien adalah 1200 request per menit, yaitu dua puluh kotak surat yang di-polling sekali per detik — lebih dari cukup untuk sebuah suite, dan fixture-nya toh tidak pernah melakukan polling lebih cepat dari sekali per detik.

Sebaiknya saya pakai fixture request milik Playwright, bukan fetch?

Keduanya sama-sama berfungsi. fetch dipakai di sini karena pembantunya jadi bisa berjalan tanpa perubahan di skrip Node biasa, global setup, atau runner lain. request milik Playwright menambahkan tracing pada setiap pemanggilannya, yang berguna kalau Anda ingin polling-nya muncul di trace viewer.

Bagaimana jika emailnya sampai sebelum pengujian mulai melakukan polling?

Tidak ada yang berubah. Polling pertama akan langsung mengembalikannya. Kotak surat menyimpan apa pun yang tiba selama 5 hari, dibaca ataupun tidak, jadi pesan yang masuk tepat saat klik terjadi akan tetap ada saja pada request berikutnya.

Apakah kotak suratnya bersifat privat selama dipakai pengujian?

Tidak. Siapa pun yang tahu alamatnya bisa membacanya, baik di domain publik maupun di domain Anda sendiri. Untuk alamat acak yang hanya ada selama sebelas detik dan menyimpan satu kode sekali pakai, itu tidak relevan; untuk lingkungan staging yang mengirim email pelanggan sungguhan, ini jadi alasan diskualifikasi — jangan arahkan yang itu ke sini.

Bagaimana cara membersihkannya setelah selesai?

Sifatnya opsional, lewat DELETE pada pesannya, yang bersifat idempoten. Apa pun akan tetap kedaluwarsa setelah 5 hari, jadi run yang melewatkan pembersihan tidak menimbulkan biaya apa pun — menghapusnya hanya membuat kegagalan berikutnya lebih mudah dibaca.

Coba selagi masih segar

Alamat hanya perlu satu klik, tanpa akun dan tanpa kartu. Semua yang ada di panduan ini langsung berfungsi dengannya.

Selamat datang kembali

Kotak surat dan domain Anda, di satu tempat.