Kiểm thử & CI

Mã OTP từ email trong test tự động, không còn test chập chờn

Một mã dùng một lần là sáu chữ số nằm bên trong một thư còn chứa cả một năm, một mức giá, một mã đơn hàng và một số điện thoại. Lấy đúng sáu chữ số đó ra — mọi lần, trên mọi runner — là một kỷ luật nhỏ: neo mẫu biểu thức, loại bỏ markup, bỏ qua thư bạn đã từng thấy, tôn trọng thời hạn hết hiệu lực. Đây chính là kỷ luật đó, kèm theo đoạn code dùng ngay được cho shell, Python và TypeScript.

  • Trung cấp
  • 14 phút đọc
Một phong bì xanh mở, phía trước xếp hàng sáu khối xám nhỏ, mỗi khối đánh dấu một chấm xanh, dưới một kính lúp xám

Mã thực sự nằm ở đâu

Một email xác minh có tối đa ba chỗ mà mã có thể nằm ở đó, và việc bạn nên đọc chỗ nào sẽ quyết định mọi thứ diễn ra sau đó. JSON của thư mà API trả về cho bạn cả ba cùng lúc: subject, text (phần văn bản thuần, hoặc null) và html (phần HTML, hoặc null).

Ở đâuTrông như thế nàoCách đọc nó
Phần văn bản thuần (text)Your code is 481920. It expires in 10 minutes.Hãy phân tích phần này trước, khi nó tồn tại. Không có markup, không gì cần giải mã, và câu chữ luôn ổn định.
Phần HTML (html)Cùng một câu đó nhưng nằm trong một bảng, thường có các chữ số được tạo kiểu mỗi chữ số một ô, và mọi ký tự & đều được viết dưới dạng một thực thể.Thay thẻ bằng khoảng trắng, giải mã các thực thể, gộp khoảng trắng, rồi mới áp dụng mẫu biểu thức. Đừng bao giờ chạy regex trực tiếp trên HTML thô.
Dòng tiêu đề481920 is your verification codeMột món quà khi bên gửi làm vậy: chẳng cần phân tích phần nội dung nào cả. Hãy đối chiếu trên tiêu đề trước, rồi mới quay về phần nội dung nếu cần.
Một hình ảnhMã được vẽ thành một bức hình, với đúng mục đích đánh bại kiểu script như thế này.Hiếm gặp, và là dấu hiệu cho thấy bên gửi không muốn bị tự động hóa. Hãy đổi template của bên gửi nếu đó là của bạn; nếu không phải, sẽ chẳng có cách nào lách qua một cách đường hoàng cả.

Phần văn bản thuần là phần nên ưu tiên, và đây cũng là phần mà hầu hết các hệ thống template tự động sinh ra từ HTML — nên nó thường luôn có mặt. Khi nó là null, phần HTML sẽ là nội dung duy nhất, và hai phần tiếp theo nói về cách đọc nó một cách an toàn.

Neo mẫu biểu thức vào đúng câu chữ của riêng bạn

Bản năng đầu tiên là dùng \d{6}. Nó khớp với mã, nhưng cũng khớp với năm ở phần footer, mã bưu điện trong khối địa chỉ, sáu chữ số cuối của một số điện thoại, và mã đơn hàng xuất hiện ngay hai dòng phía trên mã thật. Cái nào xuất hiện trước sẽ thắng, và bài kiểm thử sẽ gõ nó vào biểu mẫu với sự tự tin tuyệt đối.

MẫuCũng khớp vớiKết luận
\d{6}Năm, mã bưu điện, mức giá không có dấu phân cách, mã đơn hàng, số điện thoại, mã vận đơn.Không bao giờ nên dùng. Đây không phải một mẫu biểu thức, đây là một lần tung đồng xu.
\b\d{6}\bMọi thứ nêu ở trên mà tình cờ có đúng sáu chữ số với khoảng trắng ở hai bên — tức là vẫn gần như toàn bộ.Chỉ tốt hơn một chút. Ranh giới từ không hề biết đâu là một mã.
code is\D{0,12}(\d{6})Chỉ sáu chữ số theo ngay sau những từ mà template của chính bạn đặt trước mã, có chừa chỗ cho một dấu hai chấm, một khoảng trắng, hoặc phần khoảng trắng còn sót lại từ một thẻ.Đúng vậy, nên dùng cách này. Nó chỉ khớp với mã và không gì khác, và nó sẽ thất bại vào ngày có ai đó viết lại nội dung email — mà đó chính là một thất bại bạn muốn được biết đến.

\D{0,12} chính là chi tiết mang tính thực dụng: sau khi các thẻ đã được thay bằng khoảng trắng, phần chữ và phần số có thể bị ngăn cách bởi một dấu hai chấm, một chuỗi khoảng trắng, hoặc phần còn sót lại của một thẻ <strong> từng nằm giữa chúng. Cho phép tối đa mười hai ký tự không phải chữ số sẽ bao trùm hết tất cả những trường hợp đó, mà không để mẫu biểu thức nhảy sang một con số khác.

Các template tách rời từng chữ số

Một kiểu thiết kế phổ biến đặt mỗi chữ số của mã vào một ô riêng, để đọc dễ hơn trên điện thoại. Trong HTML, đó là sáu ô bảng, hoặc sáu thẻ <span>, và con số đó không bao giờ xuất hiện dưới dạng sáu ký tự liên tiếp ở bất cứ đâu trong mã nguồn:

phần HTML thực sự chứa những gì
<p>Your code is</p>
<table><tr>
  <td class="digit">4</td><td class="digit">8</td><td class="digit">1</td>
  <td class="digit">9</td><td class="digit">2</td><td class="digit">0</td>
</tr></table>

Một regex chạy trên HTML thô sẽ chẳng tìm thấy gì cả. Cách khắc phục không phải là một regex khôn khéo hơn; mà là chuyển HTML thành văn bản trước, theo một trình tự cố định:

  1. Thay mọi thẻ bằng một khoảng trắng. Một khoảng trắng, chứ không phải không gì cả — <td>4</td><td>8</td> phải trở thành 4 8, chứ không phải 48 dính liền với bất cứ thứ gì theo sau.
  2. Giải mã các thực thể. &amp;, &nbsp;, &#39;. Một khoảng trắng không ngắt dòng nằm giữa hai chữ số sẽ không được một regex coi là khoảng trắng cho đến khi nó được giải mã.
  3. Gộp khoảng trắng lại, rồi đối chiếu với các chữ số được phép cách nhau bởi khoảng trắng. Với kiểu thiết kế chia ô, dùng code is\D{0,12}(\d)\s*(\d)\s*(\d)\s*(\d)\s*(\d)\s*(\d) rồi nối các nhóm lại; còn với một template thông thường, mẫu biểu thức đơn giản ở phần trước là đủ.

Các helper bên dưới đã làm sẵn bước một và bước hai cho bạn, đồng thời tìm kiếm trên cả hai phần cùng lúc, nên một bài kiểm thử không cần phải biết template tháng này đang dùng kiểu thiết kế nào.

Thư mới nhất không phải lúc nào cũng là thư đúng

Mọi lượt liệt kê hộp thư ở đây đều trả về theo thứ tự mới nhất trước, và messages[0] là thứ mà phần lớn các bản nháp đầu tiên hay đọc lấy. Ba tình huống sau khiến đó trở thành thư sai:

Hai thư từ một hành động
Đăng ký gửi đi một thư chào mừng và một thư chứa mã, theo bất kỳ thứ tự nào mà hàng đợi của bên gửi xử lý xong trước. Một nửa số lần, thư chào mừng lại là thư mới nhất. Hãy lọc theo tiêu đề, hoặc theo người gửi, trước khi lấy bất cứ thư nào.
Một lượt gửi lại
Bài kiểm thử đã yêu cầu mã hai lần — một lần do nhầm lẫn, một lần có chủ đích — và máy chủ chỉ chấp nhận mã mới nhất. Thư cũ hơn vẫn còn nằm trong hộp thư, vẫn khớp với mẫu biểu thức, và vẫn phân tích ra được sáu chữ số, nhưng giờ đã không còn hợp lệ.
Một lượt chạy kiểm thử trước đó
Chỉ xảy ra nếu địa chỉ bị dùng lại, điều mà lẽ ra không bao giờ nên xảy ra. Một địa chỉ mới cho mỗi lượt chạy sẽ khiến trường hợp này không thể xảy ra; nếu bạn không thể có được điều đó, cách chụp lại trạng thái bên dưới sẽ là phương án dự phòng.

Hình dạng vững chắc của giải pháp này giống nhau ở mọi runner: nhìn xem hộp thư đang chứa gì trước khi bạn kích hoạt việc gửi thư, rồi chỉ chấp nhận một thư chưa từng có ở đó và khớp với tiêu đề bạn đang mong đợi.

chờ một thư chưa từng tồn tại trước lượt gửi lại
// Remember what is already there, THEN trigger the resend, THEN wait for something new.
const before = new Set((await listMailbox(address)).messages.map(m => m.id));

await page.getByRole('button', { name: 'Resend code' }).click();

const fresh = await waitFor(address, m => !before.has(m.id) && /code/i.test(m.subject));

Những mã hết hạn ngay trong lúc chạy

Phần lớn mã dùng một lần có hiệu lực từ năm đến mười lăm phút. Nghe có vẻ rộng rãi, cho đến khi một bộ kiểm thử xếp hàng hai mươi spec, mỗi spec đều yêu cầu mã của mình ngay từ đầu rồi mới gõ nó vào ở cuối. Ba quy tắc sau giữ cho mã còn sống:

  • Yêu cầu mã càng muộn càng tốt. Kích hoạt việc gửi ngay trước lúc chờ, chứ không phải trong một bước thiết lập chạy trong khi các bài kiểm thử khác vẫn đang xếp hàng.
  • Giữ hạn chót chờ đợi thấp hơn hẳn thời gian sống của mã. Một hạn chót sáu mươi giây cho một mã có hiệu lực mười phút sẽ để lại chín phút để gõ nó vào. Một hạn chót mười phút thì chẳng để lại gì cả.
  • Đừng bao giờ lưu một mã để dùng cho bài kiểm thử khác. Mã vừa chỉ dùng được một lần vừa có tuổi thọ ngắn; một fixture dùng chung phát ra một mã như vậy chẳng khác nào một cuộc chạy đua giữa hai bài kiểm thử để giành lấy cùng một con số.

Các bộ trích xuất sẵn sàng dùng ngay

Ba phiên bản của cùng bốn dòng lệnh: nối cả hai phần lại, đổi thẻ thành khoảng trắng, giải mã thực thể, gộp khoảng trắng, rồi áp dụng mẫu biểu thức đã được neo sẵn. Chỉ cần đổi mẫu biểu thức cho khớp với câu chữ trong template của bạn, không cần động vào gì khác.

Từ shell, dùng jq

shell
curl -sG "https://grabmail.io/api/v1/message/$ID" --data-urlencode "mailbox=$ADDR" \
| jq -r '[.text, .html] | map(select(. != null)) | join(" ") | gsub("<[^>]*>"; " ")' \
| grep -oiE 'code is[^0-9]{0,12}[0-9]{6}' | grep -oE '[0-9]{6}' | head -1

Python

extract.py
import html
import re

TAGS = re.compile(r"<[^>]+>")
CODE = re.compile(r"code is\D{0,12}(\d{6})", re.I)      # anchored on YOUR template's wording


def text_of(message: dict) -> str:
    """Both parts as plain text: tags out, entities decoded, whitespace folded."""
    raw = f"{message.get('text') or ''}\n{message.get('html') or ''}"
    return re.sub(r"\s+", " ", html.unescape(TAGS.sub(" ", raw)))


def code_from(message: dict, pattern: re.Pattern = CODE) -> str:
    hit = pattern.search(text_of(message))
    if not hit:
        raise AssertionError(f"no code in message {message['id']!r} ({message['subject']!r})")
    return hit.group(1)

TypeScript

extract.ts
export type Message = { id: string; subject: string; text: string | null; html: string | null };

const TAGS = /<[^>]+>/g;
const ENTITIES: Record<string, string> = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&nbsp;': ' ' };

/** Both parts as plain text: tags out, the common entities decoded, whitespace folded. */
export const textOf = (m: Message): string =>
  `${m.text ?? ''}\n${m.html ?? ''}`
    .replace(TAGS, ' ')
    .replace(/&(amp|lt|gt|quot|#39|nbsp);/g, e => ENTITIES[e])
    .replace(/\s+/g, ' ');

/** Anchored on your own wording. A reworded template fails loudly. */
export function codeFrom(m: Message, pattern = /code is\D{0,12}(\d{6})/i): string {
  const hit = textOf(m).match(pattern);
  if (!hit) throw new Error(`no code in message ${m.id} ("${m.subject}")`);
  return hit[1];
}

JSON của thư mà các đoạn code này đọc đến từ GET /api/v1/message/{id}, được ghi chép trong tài liệu tham chiếu API; còn việc chờ để có được id đó ngay từ đầu nằm trong hướng dẫn kiểm thử từ đầu đến cuối, cùng với các helper dựng sẵn cho Playwright, Cypress, PythonNode.js.

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

  • Mẫu biểu thức được neo vào đúng câu chữ trong template của bạn, và được đặt ngay bên cạnh template đó.
  • Cả hai phần đều được tìm kiếm, dưới dạng văn bản: thẻ chuyển thành khoảng trắng, thực thể được giải mã, khoảng trắng được gộp lại.
  • Một bộ lọc theo tiêu đề hoặc người gửi, để một thư chào mừng không bao giờ thắng thế trước một thư chứa mã.
  • Một lần chụp trạng thái trước bất kỳ lượt gửi lại nào, và chỉ những thư mới xuất hiện sau đó mới được chấp nhận.
  • Hạn chót chờ đợi thấp hơn hẳn thời gian sống của mã, và việc gửi được kích hoạt ngay trước lúc chờ.
  • Một thông báo lỗi nêu rõ id của thư và tiêu đề mà nó đã tìm trong đó.

Điều đó bao trùm mọi cách mà một bộ trích xuất sáu chữ số từng bị bắt gặp là cho qua một con số sai. Một agent đọc cùng loại thư đó cũng gặp đúng những vấn đề như vậy, nhưng lại có ít công cụ hơn để xử lý chúng, và đó chính là lý do máy chủ MCP đưa cho nó cả thư đầy đủ thay vì một phỏng đoán — bài một hộp thư mà AI agent có thể đọc được đi sâu vào điều đó.

Câu hỏi

Tôi nên đọc phần text hay phần HTML?

Phần text khi nó tồn tại: nó ổn định và không có gì cần giải mã. Nhưng dù sao cũng nên tìm trên cả hai, giống như cách các helper vẫn làm, để một template chỉ gửi HTML vẫn hoạt động, và một template chỉ gửi text thì không bao giờ vấp phải một phần HTML trống.

Mã của tôi có cả chữ cái trong đó. Mẫu biểu thức có cần thay đổi không?

Chỉ cần đổi lớp ký tự: ([A-Z0-9]{6}), hoặc bất kỳ bảng ký tự nào mà bên gửi dùng, vẫn được neo vào đúng câu chữ đứng trước nó. Hãy thêm cờ i nếu chữ hoa/thường không được đảm bảo, và cẩn thận để lớp ký tự đó không vô tình khớp luôn với một từ tiếng Anh đứng ngay sau điểm neo.

Còn magic link thay vì mã thì sao?

Cùng một kỷ luật, chỉ khác mẫu biểu thức: hãy đối chiếu URL theo một đoạn đường dẫn bạn đã biết trước — /confirm/, /auth/magic/ — thay vì theo kiểu “liên kết đầu tiên”, vì một email giao dịch thường mang theo đến năm liên kết và liên kết bạn cần hiếm khi đứng đầu tiên. Hãy giải mã &amp; trước khi truy cập nó.

Một thư có thể đọc được trong bao lâu?

5 ngày sau khi nó đến, bất kể đã được đọc hay chưa. Khoảng thời gian đó dài hơn nhiều so với thời gian hiệu lực của bất kỳ mã nào, nên một bài kiểm thử không bao giờ phải vội vàng ở bước đọc — chỉ cần vội ở bước gõ vào mà thôi.

Tôi có thể lấy mã mà không cần thăm dò hộp thư không?

Qua REST thì không: bạn vẫn phải thăm dò mỗi giây một lần với một hạn chót, đó là nhịp độ đã được ghi trong tài liệu và không bao giờ bị siết thêm. Qua MCP thì có một công cụ wait_for_message giữ lệnh gọi mở cho đến khi thư đến, đúng là hình dạng mà một AI agent cần.

Tôi có cần API key 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 dùng đến bearer token, và đoạn code trích xuất thì giống hệt nhau trong cả hai trường hợp.

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.