Pengujian & CI

Uji email dengan Cypress: pendaftaran, OTP, dan reset sandi

Cypress menjalankan pengujian Anda di dalam browser, dan browser tidak bisa membaca kotak surat. Solusi umumnya adalah sebuah task di sisi Node yang melakukan polling ke kotak surat itu — inilah task tersebut, terhadap kotak masuk sekali pakai yang sungguhan tanpa kunci API, plus dua custom command yang membuat spec pendaftaran terbaca seperti fitur yang sedang diujinya.

  • Menengah
  • 19 menit baca
Lengan mekanis abu-abu menurunkan amplop biru ke bangku berisi tiga kubus abu-abu bertanda centang biru

Mengapa Cypress butuh sebuah task untuk ini

Sebuah spec Cypress berjalan di dalam browser, di jendela yang sama dengan halaman yang sedang diuji. Itulah yang membuat cy.get dan cy.contains begitu langsung, dan itu juga sebabnya spec-nya tidak bisa begitu saja melakukan loop terhadap sebuah API HTTP selama satu menit: command queue bukan tempat untuk sebuah loop while dengan sleep di dalamnya, dan sebuah rangkaian panggilan cy.request yang diulang-ulang sulit dibaca dan lebih sulit lagi dihentikan.

Tiga cara umum untuk menguji separuh-email dari sebuah alur masing-masing membuktikan hal yang berbeda, dan hanya satu di antaranya yang membuktikan hal yang benar-benar Anda rilis:

Men-stub mailer
Membuktikan send() dipanggil dengan argumen yang benar. Tidak mengatakan 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 container 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. Biayanya adalah pengujian ini harus menunggu dengan benar, dan di Cypress tempat yang tepat untuk menunggu adalah sebuah task.

API yang dipanggil task ini hanyalah tiga endpoint tanpa kunci — referensinya singkat saja. Versi disiplin ini yang tidak terikat runner tertentu ada di menguji alur verifikasi dari ujung ke ujung; versi Playwright-nya, dengan fixture alih-alih task, ada di panduan Playwright.

Task-nya: sebuah loop polling di sisi Node

Semua yang harus menunggu ada di sini, di dalam setupNodeEvents. Ini Node biasa: fetch, sebuah batas waktu, satu kali baca per detik, dan sebuah cabang 429 yang sleep alih-alih gagal. Spec-nya sama sekali tidak melihat semua ini.

cypress.config.ts
import { defineConfig } from 'cypress';

const API = 'https://grabmail.io/api/v1';
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

type Args = { address: string; subjectContains?: string; fromContains?: string; timeoutMs?: number };

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    taskTimeout: 90_000,                  // above the mail deadline below, always
    setupNodeEvents(on) {
      on('task', {
        /** Poll a mailbox until a matching message arrives, or the deadline passes. */
        async waitForMail({ address, subjectContains, fromContains, timeoutMs = 60_000 }: Args) {
          const deadline = Date.now() + timeoutMs;

          while (Date.now() < deadline) {
            const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(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 ${address}`);

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

            if (hit) {
              const full = await fetch(`${API}/message/${hit.id}?mailbox=${encodeURIComponent(address)}`);
              if (!full.ok) throw new Error(`GET /message answered ${full.status}`);
              return full.json();                      // the whole message, both parts
            }
            await sleep(1000);                         // one read a second, never throttled
          }
          return null;                                 // "not yet" is an answer, not an error
        },
      });
    },
  },
});

Ada dua keputusan dalam file itu yang layak dijelaskan. Task-nya mengembalikan pesan lengkap, bukan ringkasannya, karena hal berikutnya yang selalu diinginkan setiap spec adalah isi pesannya, dan panggilan task kedua untuk itu hanya jadi gangguan. Dan task ini mengembalikan null saat batas waktu tercapai alih-alih melempar error: “belum ada pesan” adalah jawaban yang sah untuk diberikan sebuah task, dan command di bawah inilah tempat itu diubah menjadi kegagalan dengan pesan yang berguna.

Dua custom command dan dua extractor

Command-commandnya sengaja dibuat tipis. freshAddress membuat sebuah kotak surat; waitForMail memanggil task-nya dengan timeout yang cukup jauh di atas batas waktunya dan melakukan assert pada jawabannya. Extractor-extractornya adalah fungsi biasa, karena itu memang cuma pekerjaan string biasa dan sebuah command Cypress hanya akan membuatnya lebih sulit di-unit-test.

cypress/support/commands.ts
export type Message = {
  id: string; from: string; to: string; subject: string; date: string;
  text: string | null; html: string | null;
};
type WaitOpts = { subjectContains?: string; fromContains?: string; timeoutMs?: number };

declare global {
  namespace Cypress {
    interface Chainable {
      /** A mailbox nothing else in this run, or any previous run, is using. */
      freshAddress(prefix?: string): Chainable<string>;
      /** Block until a matching message arrives. Fails the test at the deadline. */
      waitForMail(address: string, opts?: WaitOpts): Chainable<Message>;
    }
  }
}

Cypress.Commands.add('freshAddress', (prefix = 'cy') =>
  cy.wrap(`${prefix}-${Math.random().toString(36).slice(2, 10)}@grabmail.io`, { log: false }));

Cypress.Commands.add('waitForMail', (address, opts = {}) =>
  cy.task<Message | null>('waitForMail', { address, ...opts }, { timeout: (opts.timeoutMs ?? 60_000) + 10_000 })
    .then(m => {
      expect(m, `a message for ${address}`).not.to.be.null;
      return cy.wrap(m as Message, { log: false });
    }));

/** The whole body, both parts, with the 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 hit = bodyOf(m).match(new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`));
  if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
  return hit[0];
}

Perhatikan timeout milik command itu sendiri: nilainya adalah batas waktu task-nya ditambah sepuluh detik, sehingga task-nya selalu sempat memberikan jawabannya. Tanpa itu, task timeout default Cypress yang enam puluh detik akan berlomba dengan batas waktu email yang juga enam puluh detik dan menang beberapa milidetik lebih dulu, lalu kegagalannya akan menyalahkan task-nya.

Tiga spec, dari ujung ke ujung

Dengan task dan command-command sudah terpasang, setiap spec jadi terbaca seperti fitur yang sedang diujinya. Penantian dan parsing-nya ada di tempat lain, dan itulah keseluruhan maksud dari menaruhnya di sana.

Pendaftaran dengan kode konfirmasi

cypress/e2e/signup.cy.ts
import { codeFrom } from '../support/commands';

describe('sign-up', () => {
  it('confirms the address with the emailed code', () => {
    cy.freshAddress().then(address => {
      cy.visit('/signup');
      cy.get('input[name="email"]').type(address);
      cy.get('input[name="password"]').type('correct-horse-battery-staple');
      cy.contains('button', 'Create account').click();
      cy.contains('Check your inbox').should('be.visible');

      cy.waitForMail(address, { subjectContains: 'confirm' }).then(message => {
        cy.get('input[name="code"]').type(codeFrom(message));
        cy.contains('button', 'Confirm').click();
        cy.contains('h1', 'Welcome').should('be.visible');
      });
    });
  });
});

Login yang meminta kode sekali pakai lewat email

Penggunanya harus sudah ada lebih dulu, dan itu tugas untuk test seam milik aplikasi Anda sendiri — sebuah endpoint internal, fixture basis data, atau CLI — yang dijangkau lewat cy.request, bukan lewat browser.

cypress/e2e/otp-login.cy.ts
import { codeFrom } from '../support/commands';

describe('login with an emailed one-time code', () => {
  it('asks for the code and accepts it', () => {
    cy.freshAddress().then(address => {
      // Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
      cy.request('POST', '/internal/test/users', { email: address, password: 'hunter2hunter2', otpByEmail: true });

      cy.visit('/login');
      cy.get('input[name="email"]').type(address);
      cy.get('input[name="password"]').type('hunter2hunter2');
      cy.contains('button', 'Log in').click();
      cy.contains('Enter the code we emailed you').should('be.visible');

      cy.waitForMail(address, { subjectContains: 'code' }).then(message => {
        cy.get('input[name="otp"]').type(codeFrom(message, /code is\s*([0-9]{6})/i));
        cy.contains('button', 'Continue').click();
        cy.url().should('include', '/dashboard');
      });
    });
  });
});

Reset kata sandi, lalu login dengan kata sandi baru

Tautan resetnya diikuti dengan cy.visit biasa kalau tautan itu menunjuk ke origin yang sama dengan baseUrl. Jika aplikasi Anda mengarahkan pengguna ke origin lain untuk halaman reset — misalnya sebuah subdomain auth — bungkus langkah-langkah pada halaman itu dengan cy.origin(); ekstraksi tautannya tetap tidak berubah.

cypress/e2e/password-reset.cy.ts
import { linkFrom } from '../support/commands';

describe('password reset', () => {
  it('changes the password through the emailed link', () => {
    cy.freshAddress().then(address => {
      cy.request('POST', '/internal/test/users', { email: address, password: 'old-password-1' });

      cy.visit('/forgot-password');
      cy.get('input[name="email"]').type(address);
      cy.contains('button', 'Send reset link').click();

      cy.waitForMail(address, { subjectContains: 'reset' }).then(message => {
        cy.visit(linkFrom(message, '/reset/'));      // same origin as baseUrl: a plain visit
        cy.get('input[name="password"]').type('new-password-2');
        cy.contains('button', 'Change password').click();
      });

      cy.visit('/login');
      cy.get('input[name="email"]').type(address);
      cy.get('input[name="password"]').type('new-password-2');
      cy.contains('button', 'Log in').click();
      cy.url().should('include', '/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 dari sisi Node. Tidak ada yang lain — tanpa port SMTP, tanpa koneksi masuk.
“cy.task timed out” tanpa keterangan apa pun soal emailtaskTimeout (defaultnya 60 detik) berada di bawah batas waktu emailnya.Atur taskTimeout di atas batas waktunya di dalam config, dan sertakan timeout per-panggilan yang sudah dihitung command-nya.
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 spec melakukan polling ke satu alamat yang sama, atau runner-nya melewati 1200 request per menit.Satu alamat per spec. Batas atas klien adalah dua puluh kotak surat yang di-polling sekali per detik.
Build hijau, fitur rusakAlamat yang dipakai ulang menyajikan pesan lama.freshAddress di setiap badan pengujian. Inilah yang benar-benar penting.
Berfungsi selama seminggu, lalu tidak pernah lagiSebuah fixture meng-cache sebuah id pesan; semua di sini dihapus setelah 5 hari.Spec 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 spec-spec ini, berarti ada sesuatu yang salah dipahami. Sebuah workflow GitHub Actions yang menjalankan suite semacam ini, dengan urusan egress yang sudah beres, ada 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 spec-nya — 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 task 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 spec, dibuat langsung di dalam badan pengujian — jangan pernah konstanta.
  • Penantiannya di dalam sebuah task dengan batas waktu jam dinding; null saat batas waktu tercapai, jangan pernah undefined.
  • taskTimeout dan timeout milik command-nya, keduanya di atas batas waktu emailnya.
  • 429 ditangani dengan sleep selama Retry-After, bukan dengan menggagalkan pengujian.
  • Kode atau tautannya dicocokkan dengan kata-kata Anda sendiri, bukan pola yang polos.
  • 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. Aturan ekstraksinya sendiri, untuk runner apa pun, ada di kode OTP dalam pengujian otomatis.

Pertanyaan

Bisakah saya memakai cy.request dalam sebuah loop, bukan sebuah task?

Bisa: cy.request juga berjalan dari sisi Node, jadi tidak terkena CORS, dan sebuah fungsi rekursif yang melakukan request ulang sampai cocok atau sampai batas waktu tercapai bisa berfungsi. Hanya saja itu lebih sulit dibaca dan lebih sulit dihentikan dibanding sebuah task dengan loop while di dalamnya, dan task-nya menjaga spec-nya tetap bebas dari logika retry.

Apakah saya butuh kunci API atau environment variable Cypress?

Tidak. Domain publiknya tidak butuh kunci, tidak butuh akun, dan tidak butuh header apa pun, jadi tidak ada yang perlu ditaruh di cypress.env.json atau di CI secrets. Hanya kumpulan domain berbayar yang dijaga agar tidak masuk daftar blokir email sekali pakai yang memakai bearer token, dan itu adalah produk yang terpisah.

Apakah ini berfungsi dengan test retries dan paralelisasi milik Cypress?

Ya, justru karena alamatnya dibuat di dalam badan pengujian: setiap retry dan setiap mesin paralel mendapat kotak suratnya sendiri. Batas atas per klien sebesar 1200 request per menit setara dua puluh kotak surat yang di-polling sekali per detik, dan sebuah run Cypress tidak akan pernah mendekati angka itu.

Bagaimana jika emailnya sampai sebelum task-nya 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 spec-nya?

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 dari task-nya, 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.