Test ve CI

Cypress ile e-posta testi: kayıt, OTP ve parola sıfırlama

Cypress, testinizi tarayıcı içinde çalıştırır ve tarayıcı bir posta kutusunu okuyamaz. Alışılmış çözüm, bunu yoklayan Node tarafında bir task'tır — işte o, API anahtarı olmadan gerçek bir tek kullanımlık gelen kutusuna karşı — artı, bir kayıt spec'ini test ettiği özellik gibi okunur hale getiren iki özel komut.

  • Orta düzey
  • 19 dk okuma
Mavi onay işaretli üç gri küpün durduğu bir tezgâha mavi bir zarfı indiren gri bir mekanik kol

Cypress bunun için neden bir task'a ihtiyaç duyar

Bir Cypress spec'i, test edilen sayfayla aynı pencerede, tarayıcının içinde çalışır. cy.get ve cy.contains'i bu kadar doğrudan yapan da budur, ve spec'in bir dakika boyunca bir HTTP API üzerinde basitçe döngü kuramamasının nedeni de budur: komut kuyruğu, içinde bir sleep bulunan bir while döngüsü için bir yer değildir, ve yeniden denenen bir cy.request çağrıları zinciri okunması zor, durdurulması daha da zordur.

Bir akışın e-posta yarısını test etmenin üç alışılmış yolu, her biri farklı bir şeyi kanıtlar ve yalnızca biri, yayımladığınız şeyi kanıtlar:

E-posta gönderimini stub'lamak
send()'in doğru argümanlarla çağrıldığını kanıtlar. Şablon, bağlantı ya da mesajı reddeden sağlayıcı hakkında hiçbir şey söylemez.
Yerel bir SMTP yakalayıcısı (Mailpit, MailHog, smtp4dev)
İyi biçimlendirilmiş bir mesajın uygulamadan çıktığını kanıtlar. CI'da bir container daha demektir ve yalnızca genel internette gerçekleşen hiçbir şey — gerçek bir MX araması, gerçek bir sağlayıcı, gerçek bir alıcı — burada gerçekleşmez.
Gerçek bir tek kullanımlık posta kutusu
Mesajın uygulamadan çıktığını, interneti geçtiğini, gerçek bir posta sunucusu tarafından kabul edildiğini ve işleyen bir kod taşıdığını kanıtlar. Bedeli, testin doğru şekilde beklemesi gerekmesidir ve Cypress'te beklemenin doğru yeri bir task'tır.

Task'ın çağırdığı API, anahtar gerektirmeyen üç endpoint'tir — referans kısadır. Bu disiplinin test çalıştırıcısından bağımsız sürümü bir doğrulama akışını uçtan uca test etmek rehberinde; task yerine bir fixture kullanan Playwright sürümü ise Playwright rehberinde.

Task: Node tarafında bir yoklama döngüsü

Beklemesi gereken her şey burada, setupNodeEvents içinde yaşar. Bu, düz bir Node'dur: fetch, bir son tarih, saniyede bir okuma ve başarısız olmak yerine uyuyan bir 429 dalı. Spec bunların hiçbirini görmez.

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

O dosyadaki iki karar belirtilmeye değer. Task, özet yerine mesajın tamamını döndürür, çünkü her spec'in bir sonraki istediği şey gövdedir ve bunun için ikinci bir task çağrısı gürültüdür. Ve son tarihte hata fırlatmak yerine null döndürür: "henüz mesaj yok", bir task'ın verebileceği meşru bir yanıttır ve bunun kullanışlı bir mesajla bir başarısızlığa dönüştüğü yer aşağıdaki komuttur.

İki özel komut ve iki extractor

Komutlar bilinçli olarak inceltilmiştir. freshAddress bir posta kutusu uydurur; waitForMail, son tarihin rahatça üzerinde bir zaman aşımıyla task'ı çağırır ve yanıtı doğrular. Extractor'lar düz fonksiyonlardır, çünkü bunlar düz string işleridir ve bir Cypress komutu onları yalnızca birim test etmeyi zorlaştırırdı.

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

Komutun kendi timeout'una dikkat edin: bu, task'ın son tarihi artı on saniyedir, böylece task her zaman yanıtını verme fırsatı bulur. Bu olmadan, Cypress'in varsayılan altmış saniyelik task zaman aşımı, altmış saniyelik posta son tarihiyle yarışır ve birkaç milisaniye farkla kazanır; başarısızlık da task'ı suçlar.

Üç spec, uçtan uca

Task ve komutlar yerli yerindeyken, her spec test ettiği özellik gibi okunur. Bekleme ve ayrıştırma başka bir yerdedir — onları oraya koymanın tüm amacı da budur.

Onay koduyla kayıt

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

E-postayla gönderilen tek seferlik bir kod isteyen giriş

Kullanıcının önce var olması gerekir ve bu, tarayıcı üzerinden değil cy.request ile ulaşılan, uygulamanızın kendi test arayüzünün işidir — dahili bir endpoint, bir veritabanı fixture'ı, bir CLI.

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

Bir parola sıfırlama, ardından yeni parolayla giriş

Sıfırlama bağlantısı, baseUrl ile aynı origin'i gösteriyorsa düz bir cy.visit ile takip edilir. Uygulamanız kullanıcıları sıfırlama sayfası için başka bir origin'e gönderiyorsa — örneğin bir auth alt alan adına — o sayfadaki adımları cy.origin() içine sarın; bağlantı ayıklama değişmez.

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

Bunu CI'da hayatta tutmak

Yukarıdakilerin hepsi bir dizüstü bilgisayarda çalışır. Bunlar, yalnızca başkasının makinesinde günde yirmi kez çalıştığında bozulan şeylerdir.

BelirtiNedenÇözüm
Yerelde geçiyor, CI'da başarısız oluyorRunner genel internete ulaşamıyor ya da giden trafik filtreleniyor.Node tarafından grabmail.io adresine HTTPS üzerinden izin verin. Başka hiçbir şeye gerek yok — SMTP portu yok, gelen trafik yok.
Posta hakkında tek kelime etmeden "cy.task timed out"taskTimeout (varsayılan olarak 60 sn) posta son tarihinin altında.Konfigürasyonda taskTimeout'u son tarihin üzerine ayarlayın ve komutun zaten hesapladığı çağrı başına zaman aşımını geçirin.
İlk seferde başarısız, yeniden denemede geçiyorGöndereniniz postayı kuyruğa alıyor ve son tarih kuyruktan daha kısa.Başka bir şeye dokunmadan önce son tarihi yükseltin. Altmış saniye, işlemsel bir posta için makul bir üst sınırdır.
Patlamalar halinde 429Birkaç spec'in tek bir adresi yoklaması ya da runner'ın dakikada 1200 isteği aşması.Spec başına bir adres. İstemci üst sınırı, saniyede bir kez yoklanan yirmi posta kutusudur.
Yeşil build, bozuk özellikYeniden kullanılan bir adres eski bir mesajı sundu.Her test gövdesinde freshAddress. Önemli olan budur.
Bir hafta çalışıyor, sonra hiç çalışmıyorBir fixture bir mesaj kimliğini önbelleğe aldı; buradaki her şey 5 gün sonra silinir.Spec'ler her çalıştırmada kendi postalarını tetiklemelidir. Hiçbir şey 5 günden fazla hayatta kalmaz.

Saklanacak bir secret yoktur: genel alan adları anahtar, hesap ya da başlık gerektirmez. Pipeline'ınızın bu spec'leri çalıştırmak için bir kimlik bilgisine ihtiyacı varsa, bir şey yanlış anlaşılmış demektir. Giden trafik meselesi çözülmüş halde böyle bir paketi çalıştıran bir GitHub Actions workflow'u CI rehberinde.

Uygulamanız tek kullanımlık alan adlarını reddediyorsa

Bazı kayıt formları, adresi tek kullanımlık alan adlarının genel listelerine karşı kontrol eder ve grabmail.io alan adını görür görmez reddeder. Bu, uygulamanızın bir özelliğidir, spec'in bir kusuru değildir — ve çözüm, kontrolü test ortamı için zayıflatmak değildir. Bunun yerine sahibi olduğunuz bir alan adını bu hizmete yönlendirin: tek bir MX kaydı, hesap yok, ve üzerindeki her adres, tek bir sabiti değiştirerek aynı task'ın okuyabileceği bir posta kutusu haline gelir.

Bir alan adını catch-all bir gelen kutusuna dönüştürmek kurulumdur; tek bir alan adında sınırsız test hesabı ise bunun bir test paketinde nasıl göründüğüdür.

Bitti demeden önce

  • Test gövdesinde uydurulan, her spec için farklı bir adres — asla bir sabit değil.
  • Bekleme, gerçek zamanlı bir son tarihi olan bir task içinde; son tarihte null, asla undefined değil.
  • taskTimeout ve komutun zaman aşımı, ikisi de posta son tarihinin üzerinde.
  • 429, başarısız sayılarak değil, Retry-After süresince uyuyarak ele alınır.
  • Kod ya da bağlantı, yalın bir desen değil, kendi ifadenize karşı eşleştirilir.
  • Konu ya da gönderen üzerinde bir filtre, böylece iki mesaj geldiğinde doğru olan kazanır.
  • Postanın ne kadar hızlı geldiğine dair bir doğrulama yok — yalnızca geldiğine dair.

Disiplinin tamamı bu kadar. Herhangi bir test çalıştırıcısı için tek başına ayıklama kuralları, otomatik testlerde OTP kodları rehberinde.

Sorular

Bir task yerine döngü içinde cy.request kullanabilir miyim?

Kullanabilirsiniz: cy.request de Node tarafından çalışır, bu yüzden CORS'a tabi değildir ve bir eşleşmeye ya da bir son tarihe kadar yeniden istek yapan özyinelemeli bir fonksiyon işe yarar. Yalnızca içinde bir while döngüsü olan bir task'tan daha zor okunur ve daha zor durdurulur, ve task, spec'i yeniden deneme mantığından uzak tutar.

Bir API anahtarına ya da bir Cypress ortam değişkenine ihtiyacım var mı?

Hayır. Genel alan adları anahtar, hesap ya da başlık gerektirmez, bu yüzden cypress.env.json'a ya da CI secret'larına koyacak hiçbir şey yoktur. Yalnızca tek kullanımlık posta kara listelerinin dışında tutulan ücretli alan adı havuzu bir bearer token kullanır ve bu ayrı bir üründür.

Bu, Cypress'in test yeniden denemeleri ve paralelleştirmesiyle çalışır mı?

Evet, tam olarak adresin test gövdesinin içinde uydurulması sayesinde: her yeniden deneme ve her paralel makine kendi posta kutusunu alır. İstemci başına dakikada 1200 isteklik üst sınır, saniyede bir kez yoklanan yirmi posta kutusudur ve bir Cypress çalıştırması buna hiçbir zaman yaklaşmaz.

Ya e-posta, task yoklamaya başlamadan önce gelirse?

Hiçbir şey değişmez. İlk yoklama onu döndürür. Bir posta kutusu, kimse okusun ya da okumasın, gelen her şeyi 5 gün boyunca tutar; bu yüzden tıklama sırasında düşen bir mesaj, bir sonraki istekte orada bulunur.

Spec onu kullanırken posta kutusu özel midir?

Hayır. Genel bir alan adında da kendi alan adınızda da, adresi bilen herkes onu okuyabilir. On bir saniye var olan ve tek bir kullan-at kod tutan rastgele bir adres için bu önemsizdir; gerçek müşteri postası gönderen bir staging ortamı için ise bu diskalifiye edici bir durumdur — böyle birini buraya yönlendirmeyin.

Sonrasında nasıl temizlik yaparım?

İsteğe bağlı olarak, task'tan mesaj üzerinde idempotent olan bir DELETE ile. Zaten her şeyin süresi 5 gün sonra doluyor, bu yüzden temizliği atlayan bir çalıştırmanın hiçbir bedeli yoktur — silmek yalnızca bir sonraki başarısızlığı okumayı kolaylaştırır.

Henüz tazeyken deneyin

Bir adres tek tıkla alınır, hesap ve kart gerekmez. Bu rehberdeki her şey onunla hemen çalışır.

Tekrar hoş geldiniz

Kutularınız ve alan adlarınız tek bir yerde.