Kiểm thử & CI

Test email với Cypress: đăng ký, OTP và đặt lại mật khẩu

Cypress chạy bài kiểm thử của bạn ngay trong trình duyệt, mà trình duyệt thì không thể đọc một hộp thư. Cách khắc phục thông thường là một task ở phía Node để thăm dò hộp thư đó — đây chính là nó, thăm dò một hộp thư dùng một lần có thật, không cần API key, cùng hai command tùy chỉnh giúp một spec đăng ký đọc lên giống hệt tính năng mà nó kiểm tra.

  • Trung cấp
  • 19 phút đọc
Một cánh tay máy màu xám đang hạ phong bì xanh xuống băng ghế có ba khối lập phương xám đánh dấu tích xanh

Vì sao Cypress cần một task cho việc này

Một spec Cypress chạy bên trong trình duyệt, trong cùng một cửa sổ với trang đang được kiểm thử. Đó chính là điều khiến cy.getcy.contains trực tiếp đến vậy, và đó cũng là lý do vì sao spec không thể chỉ đơn giản lặp qua một API HTTP suốt một phút: hàng đợi lệnh không phải là chỗ dành cho một vòng lặp while có chứa sleep bên trong, và một chuỗi các lệnh gọi cy.request được thử lại liên tục thì vừa khó đọc vừa khó dừng lại hơn.

Ba cách thường dùng để kiểm thử nửa phần email của một luồng, mỗi cách chứng minh một điều khác nhau, và chỉ một trong số đó chứng minh đúng thứ bạn đã triển khai:

Stub trình gửi thư
Chỉ chứng minh được rằng send() đã được gọi với đúng tham số. Không nói đượ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 container nữa 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á phải trả là bài kiểm thử phải biết chờ đúng cách, và trong Cypress, nơi phù hợp để chờ chính là một task.

API mà task này gọi tới chỉ gồm ba endpoint, không cần key — tài liệu tham chiếu rất ngắn gọn. Phiên bản không phụ thuộc runner cụ thể của nguyên tắc này nằm trong bài kiểm thử một luồng xác minh từ đầu đến cuối; còn phiên bản dành cho Playwright, dùng fixture thay vì task, nằm trong hướng dẫn Playwright.

Task: một vòng lặp thăm dò ở phía Node

Mọi thứ cần phải chờ đều nằm ở đây, trong setupNodeEvents. Đây là Node thuần túy: fetch, một hạn chót, một lần đọc mỗi giây, và một nhánh xử lý 429 bằng cách chờ thay vì thất bại. Spec không bao giờ nhìn thấy bất cứ điều gì trong số đó.

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

Có hai quyết định trong tệp đó đáng được nói rõ. Task trả về thư đầy đủ, chứ không phải bản tóm tắt, vì điều tiếp theo mọi spec đều cần là phần nội dung, và một lệnh gọi task thứ hai chỉ để lấy nó là thừa thãi. Và nó trả về null khi đến hạn chót thay vì ném lỗi: “chưa có thư” là một câu trả lời hợp lệ để một task đưa ra, và command bên dưới chính là nơi biến điều đó thành một lỗi kèm thông báo hữu ích.

Hai command tùy chỉnh và hai bộ trích xuất

Các command này được thiết kế mỏng một cách có chủ đích. freshAddress tạo ra một hộp thư; waitForMail gọi task với một timeout cao hơn hạn chót một cách thoải mái, rồi assert vào câu trả lời. Các bộ trích xuất chỉ là những hàm thuần túy, vì chúng chỉ là công việc xử lý chuỗi ký tự đơn thuần, và biến chúng thành một command Cypress sẽ chỉ khiến việc unit-test chúng khó hơn.

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

Hãy để ý đến timeout riêng của command: nó bằng hạn chót của task cộng thêm mười giây, để task luôn có cơ hội đưa ra câu trả lời của mình. Nếu thiếu nó, timeout mặc định sáu mươi giây của task trong Cypress sẽ chạy đua với hạn chót sáu mươi giây của thư và thắng trước vài mili-giây, khiến lỗi bị đổ oan cho task.

Ba spec, từ đầu đến cuối

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

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

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

Một lượt đăng nhập yêu cầu mã dùng một lần gửi qua email

Người dùng phải đã tồn tại 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 — được gọi tới bằng cy.request, chứ không phải qua trình duyệt.

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

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

Liên kết đặt lại được truy cập bằng một lệnh cy.visit thông thường khi nó trỏ đến cùng origin với baseUrl. Nếu ứng dụng của bạn đưa người dùng sang một origin khác cho trang đặt lại — chẳng hạn một subdomain xác thực riêng — hãy bọc các bước trên trang đó trong cy.origin(); việc trích xuất liên kết thì không đổi.

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

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 từ phía Node. Không gì khác — không cổng SMTP, không kết nối vào.
“cy.task timed out” mà chẳng nói gì về việc thư có đến hay khôngtaskTimeout (mặc định là 60 giây) thấp hơn hạn chót chờ thư.Đặt taskTimeout cao hơn hạn chót ngay trong file config, và truyền vào timeout riêng cho từng lệnh gọi mà command đã tự tính sẵn.
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 spec cùng thăm dò một địa chỉ, hoặc runner vượt quá 1200 yêu cầu mỗi phút.Một địa chỉ cho mỗi spec. 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ũ.freshAddress trong phần thân của mọi bài kiểm thử. Đây chính là điều quan trọng nhất.
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 spec 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 spec này, chắc hẳn đã có sự hiểu nhầm ở đâu đó. Một workflow GitHub Actions chạy một bộ kiểm thử như thế này, với vấn đề egress đã được giải quyết sẵn, nằm 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 spec — 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 task 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 spec, được tạo ngay trong phần thân bài kiểm thử — không bao giờ là một hằng số.
  • Việc chờ nằm trong một task với hạn chót theo đồng hồ thực; trả về null khi đến hạn chót, không bao giờ là undefined.
  • Cả taskTimeout lẫn timeout của command đều cao hơn hạn chót chờ thư.
  • 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.
  • 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ớ. 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ó thể dùng cy.request trong một vòng lặp thay vì dùng task không?

Được: cy.request cũng chạy từ phía Node, nên nó không bị ràng buộc bởi CORS, và một hàm đệ quy cứ gửi lại yêu cầu cho đến khi khớp hoặc đến hạn chót vẫn hoạt động bình thường. Chỉ là nó khó đọc hơn và khó dừng lại hơn so với một task có vòng lặp while bên trong, và việc dùng task giúp spec không phải chứa logic thử lại.

Tôi có cần API key hay một biến môi trường Cypress 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, nên chẳng có gì để đặt vào cypress.env.json hay vào các secret của CI cả. 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 dùng đến bearer token, và đó là một sản phẩm riêng biệt.

Điều này có hoạt động cùng cơ chế thử lại bài kiểm thử và chạy song song của Cypress không?

Có, chính xác là vì địa chỉ được tạo ngay trong phần thân bài kiểm thử: mỗi lượt thử lại và mỗi máy chạy song song đều nhận được hộp thư riêng của mình. 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 — một con số mà một lượt chạy Cypress không bao giờ chạm tới.

Nếu email đến trước khi task 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 spec đ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ư đó ngay từ trong task, 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.