Kiểm thử & CI

Test email với Playwright: đọc mã từ một hộp thư thật

Một bài kiểm thử Playwright có thể điền xong một biểu mẫu đăng ký chỉ trong hai giây, rồi sau đó chẳng còn biết chuyện gì xảy ra tiếp theo, vì bước kế tiếp là một email. Đây là fixture đọc email đó — một thư thật, từ một hộp thư thật, không cần API key — cùng ba thói quen giúp bài kiểm thử không bị chập chờn.

  • Trung cấp
  • 20 phút đọc
Một cửa sổ trình duyệt xám với mũi tên con trỏ xanh, một phong bì xanh đang trượt vào khe ở cạnh, và một đồng hồ bấm giờ xám phía trước

Nơi một bài kiểm thử Playwright thường dừng lại

Phần lớn các bài kiểm thử đăng ký dừng lại ở câu “hãy kiểm tra hộp thư của bạn”. Biểu mẫu đã được điền, nút đã được bấm, trang đã hiển thị đúng thông điệp — còn mọi thứ xảy ra sau câu đó thì chỉ được mặc định là đúng. Thư có thực sự được gửi đi hay không, mã trong đó có phải là mã mà máy chủ mong đợi hay không, liên kết xác nhận có mở ra một trang hoạt động đúng hay không: tất cả những điều đó đều bị bỏ mặc cho môi trường production tự lo.

Nó bị bỏ ngỏ như vậy vì bước kế tiếp là bất đồng bộ và diễn ra bên ngoài trình duyệt, còn Playwright thì không có gì để bấm vào cả. Ba cách thường dùng để vòng qua vấn đề đó, mỗi cách chứng minh một điều khác nhau:

Mock trình gửi thư
Chỉ chứng minh được rằng code của bạn đã gọi send(). Không chứng minh được gì về template, về liên kết, hay về việc nhà cung cấp có từ chối thư hay không.
Một SMTP sink cục bộ (Mailpit, MailHog, smtp4dev)
Chứng minh được rằng một thư đúng định dạng đã rời khỏi ứng dụng. Nhưng lại là thêm một dịch vụ nữa cần chạy trong CI, và không điều gì chỉ xảy ra trên Internet công khai — một lượt tra cứu MX thật, một nhà cung cấp thật, một người nhận thật — lại xảy ra ở đây.
Một hộp thư dùng một lần có thật
Chứng minh được rằng thư đã rời khỏi ứng dụng, đi qua Internet, được một máy chủ thư thật chấp nhận, và mang theo một mã hoạt động thật sự. Cái giá duy nhất phải trả là bài kiểm thử phải biết chờ đúng cách — và đó chính là toàn bộ nội dung của hướng dẫn này.

API đứng sau việc này chỉ gồm ba endpoint, không cần key, được ghi chép đầy đủ trong tài liệu tham chiếu. Nếu bạn muốn nắm nguyên tắc chung trước khi đi vào chi tiết riêng của Playwright, bài kiểm thử một luồng xác minh từ đầu đến cuối trình bày điều đó cho mọi runner; còn đây là phiên bản dành riêng cho Playwright, với fixture giúp mọi việc trở nên dễ chịu.

Một fixture cấp cho mỗi bài kiểm thử hộp thư riêng của nó

test.extend của Playwright chính là chỗ phù hợp để làm việc này: một hộp thư trở thành thứ mà bài kiểm thử có thể gọi ra theo tên, giống như page, và địa chỉ được tạo mới hoàn toàn mỗi lần. Không có gì cần được tạo trên máy chủ — một hộp thư chỉ tồn tại vào đúng khoảnh khắc thư đến — nên fixture ở đây chỉ là một class chứa một địa chỉ ngẫu nhiên cùng ba phương thức nhỏ.

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

Có hai điều trong tệp đó là chủ đích chứ không phải ngẫu nhiên. Địa chỉ được tạo ngẫu nhiên theo từng bài kiểm thử, chứ không theo từng tệp hay từng lượt chạy, để các worker song song không bao giờ đọc nhầm thư của nhau. Và waitFor trả về toàn bộ thư thay vì chỉ bản tóm tắt — trên thực tế bạn luôn cần đến phần nội dung ngay sau đó, và bớt được một lệnh gọi trong mỗi bài kiểm thử thì cộng dồn lại cũng đáng kể.

Chờ thư mà không dùng sleep

Thư không phải là chuyện đồng bộ. Nó thường đến trong hai đến ba giây, và thỉnh thoảng mất đến hai mươi giây, còn cách bài kiểm thử chờ đợi sẽ quyết định liệu cả bộ kiểm thử có đáng tin hay không. Các quy tắc rất ngắn gọn:

  • Một hạn chót, không phải một số lần thử lại. for (let i = 0; i < 30; i++) là ba mươi lần thử với bất kỳ tốc độ nào mà vòng lặp tình cờ chạy ra — ngắn hơn khi API nhanh lên, dài hơn khi bên gửi của bạn chậm đi. Một hạn chót theo đồng hồ thực thì luôn mang cùng một ý nghĩa trên mọi máy.
  • Một lần đọc mỗi giây. Đó là nhịp độ đã được ghi trong tài liệu và không bao giờ bị siết thêm. Đọc nhanh hơn sẽ bị từ chối với 429 kèm tiêu đề Retry-After, và thăm dò nhanh hơn cũng không khiến thư đến sớm hơn.
  • Không dùng waitForTimeout. Một khoảng sleep cố định thì hoặc quá ngắn vào ngày mọi thứ chậm chạp, hoặc quá dài vào mọi ngày còn lại. Vòng lặp dừng lại đúng vào khoảnh khắc thư xuất hiện.
  • Hãy lọc; đừng lấy đại thư mới nhất một cách mù quáng. Truyền vào subjectContains hoặc fromContains. Khi một luồng gửi đi hai thư — một thư chào mừng và một mã — thư mới nhất không phải lúc nào cũng là thư bạn cần.

Các mã trạng thái mà vòng lặp sẽ gặp phải, và việc cần làm với từng mã:

Ý nghĩaVòng lặp làm gì
200Hộp thư đã được đọc thành công. count có thể là 0 — một hộp thư trống không bao giờ là một lỗi 404.Tìm một kết quả khớp; nếu không có, chờ một giây rồi thử lại.
400Địa chỉ sai định dạng.Ném lỗi. Thử lại một lỗi gõ sai thì cũng chẳng sửa được gì.
404Tên miền này không được lưu trữ ở đây.Ném lỗi, và kiểm tra bản ghi MX nếu đó là tên miền riêng của bạn.
429Nhiều hơn một lần đọc mỗi giây cho địa chỉ đó, hoặc nhiều hơn 1200 yêu cầu mỗi phút từ runner này.Chờ đúng số giây ghi trong Retry-After rồi tiếp tục. Đừng bao giờ để bài kiểm thử thất bại chỉ vì một lỗi 429.

Lấy mã, hoặc liên kết, ra khỏi thư

Thư trả về có cả hai phần, và bạn cần phân tích phần nào là tùy vào việc ứng dụng của bạn gửi gì:

text
Phần văn bản thuần. Hãy phân tích phần này khi nó tồn tại — không có markup, và một mã sáu chữ số thì vẫn chỉ là một mã sáu chữ số.
html
Phần HTML, hoặc null khi bên gửi chỉ gửi văn bản thuần. Các liên kết xác nhận thường chỉ tồn tại ở đây, bên trong một thẻ <a href>, với & được viết thành &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];
}

Mẫu biểu thức này được neo có chủ đích vào đúng câu chữ trong template của chính bạn. Chỉ riêng [0-9]{6} sẽ vui vẻ khớp với một năm, một mức giá, hay một mã đơn hàng tình cờ xuất hiện trước đó; còn code is ([0-9]{6}) chỉ khớp với mã của bạn và không gì khác — và ngày nào đó có ai viết lại nội dung email, bài kiểm thử sẽ thất bại và báo cho bạn biết, thay vì cứ thế cho qua với một con số sai.

Các liên kết được đối chiếu theo một đoạn đường dẫn bạn đã biết trước — /confirm/, /reset/ — thay vì theo kiểu “URL đầu tiên tìm thấy”, vì một email giao dịch thường mang theo đến năm liên kết: logo, hủy đăng ký, trung tâm hỗ trợ, huy hiệu app store, và liên kết bạn thực sự cần.

Ba luồng, từ đầu đến cuối

Khi đã có sẵn fixture và các bộ trích xuất, mỗi bài kiểm thử đọc lên giống hệt tính năng mà nó đang kiểm tra. Việc chờ đợi, thăm dò và phân tích đều nằm ở một nơi khác, và đó chính là lý do để đặt chúng ở đó.

Đăng ký kèm mã xác nhận

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

Một magic link giúp người dùng đăng nhập

Không cần gõ gì cả: bài kiểm thử truy cập liên kết mà thư mang theo, rồi assert xem nó đưa đến đâu.

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

Đặt lại mật khẩu, rồi đăng nhập bằng mật khẩu mới

Bài kiểm thử đặt lại mật khẩu cần một người dùng đã tồn tại sẵn từ trước, và đó là việc dành cho chính cơ chế hỗ trợ kiểm thử của ứng dụng bạn — một endpoint nội bộ, một fixture cơ sở dữ liệu, một CLI — chứ không phải việc của trình duyệt. Sau đó, luồng này có hình dạng giống hệt những luồng còn lại: gửi yêu cầu, chờ, đi theo liên kết, rồi 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/);
});

Làm sao để nó sống sót qua CI

Mọi thứ ở trên đều chạy tốt trên một chiếc laptop. Đây là những thứ chỉ hỏng khi nó chạy hai mươi lần một ngày trên máy của người khác.

Triệu chứngNguyên nhânCách khắc phục
Chạy qua ở máy cục bộ, thất bại trong CIRunner không thể truy cập Internet công khai, hoặc egress bị lọc.Cho phép grabmail.io qua HTTPS. Không gì khác — không cổng SMTP, không kết nối vào.
Thất bại lần đầu, chạy qua khi thử lạiBên gửi của bạn xếp hàng đợi thư, và hạn chót lại ngắn hơn thời gian xếp hàng đó.Hãy tăng hạn chót trước khi động vào bất cứ thứ gì khác. Sáu mươi giây là một mức trần hợp lý cho một email giao dịch.
429 xuất hiện dồn dậpNhiều bài kiểm thử cùng thăm dò một địa chỉ, hoặc cả runner vượt quá 1200 yêu cầu mỗi phút.Một địa chỉ cho mỗi bài kiểm thử — fixture đã làm sẵn việc đó. Mức trần phía client là hai mươi hộp thư được thăm dò mỗi giây một lần.
Build xanh, tính năng vẫn hỏngMột địa chỉ dùng lại trả về một thư cũ.Một địa chỉ ngẫu nhiên cho mỗi bài kiểm thử. Đây chính là điều quan trọng nhất.
Chỉ chập chờn khi chạy với nhiều workerHai bài kiểm thử dùng chung một hộp thư, hoặc một assertion dựa vào thư nào là mới nhất.Một địa chỉ mới cho mỗi bài kiểm thử cùng một bộ lọc subjectContains; đừng bao giờ lấy đại thư mới nhất một cách mù quáng.
Chạy tốt suốt một tuần, rồi không bao giờ chạy được nữaMột fixture đã cache lại id của một thư; mọi thứ ở đây đều bị xóa sau 5 ngày.Các bài kiểm thử phải tự kích hoạt thư của riêng mình ở mỗi lượt chạy. Không gì tồn tại quá 5 ngày.

Không có secret nào cần lưu trữ cả. Các tên miền công khai không cần key, không cần tài khoản, không cần header nào — nếu pipeline của bạn cần một thông tin đăng nhập để chạy các bài kiểm thử này, chắc hẳn đã có sự hiểu nhầm ở đâu đó. Cài đặt duy nhất đáng ghi lại là timeout, vì đó là thứ duy nhất mà giá trị mặc định của Playwright đặt sai cho một bài kiểm thử phải chờ thư:

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

Một workflow GitHub Actions chạy trọn bộ kiểm thử này, với vấn đề hạn chót và egress đã được giải quyết sẵn, được trình bày đầy đủ trong hướng dẫn về CI.

Nếu ứng dụng của bạn từ chối các tên miền dùng một lần

Một số biểu mẫu đăng ký đối chiếu địa chỉ với các danh sách công khai về tên miền dùng một lần, rồi từ chối grabmail.io ngay khi vừa nhìn thấy. Đó là một tính năng của ứng dụng bạn, không phải lỗi của bài kiểm thử — và cách khắc phục không phải là làm yếu đi bước kiểm tra đó cho môi trường kiểm thử. Thay vào đó, hãy trỏ một tên miền bạn sở hữu vào dịch vụ này: một bản ghi MX, không cần tài khoản, và mọi địa chỉ trên tên miền đó đều trở thành một hộp thư mà chính fixture cũ có thể đọc được, chỉ bằng cách đổi một hằng số.

Bài biến một tên miền thành hộp thư catch-all là phần thiết lập; còn tài khoản kiểm thử không giới hạn trên một tên miền là hình dạng của nó khi áp dụng vào một bộ kiểm thử.

Trước khi bạn coi như đã xong

  • Một địa chỉ khác nhau cho mỗi bài kiểm thử, lấy từ fixture — không bao giờ là một hằng số.
  • Một hạn chót theo đồng hồ thực, và một thông báo lỗi nêu rõ tên địa chỉ đã chờ.
  • 429 được xử lý bằng cách chờ đúng Retry-After, chứ không phải bằng cách làm thất bại bài kiểm thử.
  • Mã hoặc liên kết được đối chiếu theo đúng câu chữ của riêng bạn, chứ không phải một mẫu biểu thức trần trụi.
  • Timeout của bài kiểm thử cao hơn hạn chót chờ thư một cách thoải mái.
  • Một bộ lọc theo tiêu đề hoặc người gửi, để thư đúng luôn thắng khi có hai thư cùng đến.
  • Không có assertion nào về việc thư đến nhanh cỡ nào — chỉ về việc nó có đến hay không.

Đó là toàn bộ nguyên tắc cần nhớ. Mọi thứ khác về việc kiểm thử email trong Playwright đều giống hệt việc kiểm thử bất cứ thứ gì bất đồng bộ khác. Cùng một helper đó dưới dạng các lệnh Cypress nằm trong hướng dẫn Cypress; còn các quy tắc trích xuất, tách riêng cho mọi runner, nằm trong mã OTP trong các bài kiểm thử tự động.

Câu hỏi

Tôi có cần API key để đọc hộp thư từ Playwright không?

Không cần. Các tên miền công khai không cần key, không cần tài khoản, không cần header nào. Chỉ có nhóm tên miền trả phí được giữ ngoài các danh sách chặn email dùng một lần mới cần đến header Authorization: Bearer, và đó là một sản phẩm riêng biệt.

Các bài kiểm thử có thể chạy trên nhiều worker song song không?

Có thể, và đó chính là lý do vì sao cần một địa chỉ ngẫu nhiên cho mỗi bài kiểm thử: hai worker sẽ không bao giờ đọc nhầm thư của nhau. Mức trần cho mỗi client là 1200 yêu cầu mỗi phút, tương đương hai mươi hộp thư được thăm dò mỗi giây một lần — quá đủ cho một bộ kiểm thử, và dù sao thì fixture cũng không bao giờ thăm dò nhanh hơn một lần mỗi giây.

Tôi có nên dùng fixture request của Playwright thay vì fetch không?

Cả hai đều dùng được. fetch được dùng ở đây vì như vậy helper có thể chạy nguyên vẹn trong một script Node thuần túy, một global setup, hay một runner khác. request của Playwright bổ sung thêm khả năng trace các lệnh gọi, điều này đáng có nếu bạn muốn các lượt thăm dò hiện ra trong trace viewer.

Nếu email đến trước khi bài kiểm thử bắt đầu thăm dò thì sao?

Không có gì thay đổi cả. Lượt thăm dò đầu tiên sẽ trả về nó. Một hộp thư giữ lại những gì đến trong 5 ngày bất kể có ai đọc hay không, nên một thư đến ngay trong lúc click chuột thì đơn giản là đã có sẵn ở đó cho yêu cầu tiếp theo.

Hộp thư có riêng tư trong lúc bài kiểm thử đang dùng nó không?

Không riêng tư. Bất kỳ ai biết địa chỉ đều đọc được nó, dù trên tên miền công khai hay trên tên miền riêng của bạn. Với một địa chỉ ngẫu nhiên chỉ tồn tại mười một giây và chỉ chứa một mã dùng-rồi-bỏ thì điều đó không quan trọng; nhưng với một môi trường staging gửi thư khách hàng thật thì điều này hoàn toàn không chấp nhận được — đừng trỏ một môi trường như vậy vào đây.

Tôi dọn dẹp sau đó bằng cách nào?

Có thể làm, bằng một lệnh DELETE trên thư đó, và thao tác này có tính idempotent. Dù sao thì mọi thứ cũng hết hạn sau 5 ngày, nên một lượt chạy bỏ qua bước dọn dẹp cũng không tốn kém gì — việc xóa chỉ giúp lần thất bại tiếp theo dễ đọc hơn mà thôi.

Hãy thử ngay khi nó còn mới

Một địa chỉ chỉ mất một cú nhấp, không tài khoản và không thẻ. Mọi thứ trong hướng dẫn này đều hoạt động ngay trên đó.

Chào mừng trở lại

Hộp thư và tên miền của bạn, ở cùng một nơi.