Điều gì thay đổi trên một runner
Bản thân bài kiểm thử không hề thay đổi. Thứ thay đổi là mọi thứ xung quanh nó, và mỗi điều dưới đây đều có một câu trả lời cụ thể, chứ không phải một cái nhún vai:
- Không có secret nào cần gắn vào
- Đọc một hộp thư 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 phía hộp thư không thêm gì vào
secretscả. Thông tin đăng nhập duy nhất trong job là thứ ứng dụng của bạn vốn đã cần để gửi thư — SendGrid, Postmark, SES, hay bất cứ dịch vụ nào — và nó thuộc về ứng dụng của bạn, chứ không phải của bài kiểm thử. - Runner phải kết nối được với Internet
- HTTPS hướng ra ngoài đến
grabmail.io, và hướng ra ngoài đến bất cứ dịch vụ nào mà trình gửi thư của bạn đang dùng. Các runner do GitHub host mặc định cho phép cả hai; một runner tự host đứng sau một bộ lọc egress cần được thêm vào một quy tắc. - Các lượt chạy chồng lấn lên nhau
- Hai pull request, bốn shard, một lượt thử lại của một job chập chờn — nhiều bản sao của cùng một bài kiểm thử đọc thư cùng một lúc. Một địa chỉ dùng chung sẽ khiến chúng đọc nhầm mã của nhau; một địa chỉ mới cho mỗi bài kiểm thử khiến cả nhóm vấn đề này trở nên bất khả thi.
- Thời gian được tính theo mức sử dụng
- Một bài kiểm thử chờ thư sáu mươi giây thì không sao cả. Nhưng một job chờ sáu mươi giây trong từng bài trong số bốn mươi bài kiểm thử thì lại là bốn mươi phút runner bị tính phí. Các lượt chờ phải có giới hạn, và bộ kiểm thử phải được chia shard một khi nó lớn dần lên.
Đoạn code kiểm thử mà các workflow này chạy chính là đoạn code từ hướng dẫn Playwright hoặc hướng dẫn Python: một địa chỉ mới, một lượt chờ có hạn chót, một bộ trích xuất được neo vào template của bạn. Không có gì trong đó là đặc thù riêng cho CI cả, và đó chính là điểm mấu chốt — mọi phần đặc thù cho runner đều nằm trong tệp workflow.
Workflow, dành cho Playwright
Chỉ một job. Nó khởi động ứng dụng của bạn với một trình gửi thư đi thật, chờ ứng dụng phản hồi, chạy bộ kiểm thử, và chỉ giữ lại báo cáo khi có gì đó thất bại.
name: e2e
on:
push:
branches: [main]
pull_request:
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20 # the whole job, comfortably above every wait inside it
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
# Your application, started the way it runs in staging: a REAL outbound
# mailer. Its credentials are YOUR secret; the mailbox side needs none.
- name: Start the application
run: npm run start:test &
env:
MAILER_API_KEY: ${{ secrets.MAILER_API_KEY }}
APP_URL: http://localhost:3000
- name: Wait for the application
run: npx wait-on --timeout 60000 http://localhost:3000/health
- name: Run the suite
run: npx playwright test
env:
BASE_URL: http://localhost:3000
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7Ba dòng gánh phần lớn trọng lượng ở đây. timeout-minutes: 20 là giới hạn ngoài cùng mà mọi thứ khác lồng vào bên trong. Ứng dụng được khởi động với thông tin đăng nhập trình gửi thư thật, vì một bài kiểm thử đọc thư thật thì cần thư thật được gửi đi. Và báo cáo chỉ được tải lên khi thất bại, với thời gian lưu ngắn — một lượt chạy thành công thì chẳng có gì đáng giữ lại cả.
Workflow, dành cho pytest
Cùng một hình dạng, nhưng với bộ công cụ Python: khởi động ứng dụng, chờ health endpoint của nó phản hồi, chạy bộ kiểm thử với một timeout riêng cho từng bài cao hơn hạn chót chờ thư, giữ lại báo cáo JUnit khi thất bại.
name: e2e
on:
push:
branches: [main]
pull_request:
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- run: pip install -r requirements.txt -r requirements-test.txt
- name: Start the application
run: python -m app.server &
env:
MAILER_API_KEY: ${{ secrets.MAILER_API_KEY }}
APP_URL: http://localhost:8000
- name: Wait for the application
run: |
for i in $(seq 1 60); do
curl -sf http://localhost:8000/health && exit 0
sleep 1
done
echo "application did not come up" >&2; exit 1
- name: Run the suite
run: pytest tests/e2e -q --timeout=120 --junitxml=report.xml
env:
BASE_URL: http://localhost:8000
- uses: actions/upload-artifact@v4
if: failure()
with:
name: pytest-report
path: report.xml--timeout=120 đến từ plugin pytest-timeout và là mức trần cho từng bài kiểm thử; hạn chót chờ thư bên trong helper là sáu mươi giây, nên một bài kiểm thử chờ một thư rồi làm thêm vài thao tác trình duyệt vẫn vừa khít trong đó. Vòng lặp health-check được viết trực tiếp ra thay vì kéo vào dưới dạng một action, vì nó chỉ có tám dòng và chẳng có gì trong đó dễ viết sai cả.
Quy tắc mạng duy nhất
Đọc một hộp thư chỉ là một yêu cầu HTTPS hướng ra ngoài, từ runner đến grabmail.io. Đó là toàn bộ dấu chân mạng của việc này:
- Không có inbound. Không có gì kết nối đến runner cả. Không có webhook nào cần nhận, không có máy chủ SMTP nào cần chạy, không có cổng nào cần mở ra.
- Không có SMTP từ phía runner. Thư được gửi bởi ứng dụng của bạn thông qua nhà cung cấp của nó, qua API hoặc endpoint SMTP của nhà cung cấp đó — giống hệt cách nó vẫn làm trong production. Bản thân runner không bao giờ nói chuyện bằng giao thức SMTP.
- Chỉ cần thêm
grabmail.io:443trên một runner có allow-list cho egress — cộng với nhà cung cấp thư của bạn và registry gói phần mềm, những thứ mà job vốn đã cần đến từ trước.
- uses: step-security/harden-runner@v2
with:
egress-policy: block
allowed-endpoints: >
grabmail.io:443
api.your-mail-provider.example:443
registry.npmjs.org:443Nếu bộ kiểm thử chạy qua ở máy cục bộ nhưng thất bại trong CI với một lỗi kết nối từ helper, quy tắc này là điều đầu tiên cần kiểm tra, và gần như lúc nào cũng là toàn bộ câu trả lời. Một runner tự host trong mạng nội bộ doanh nghiệp thường sẽ lọc egress HTTPS theo hostname; hostname cần cho phép chính là hostname của API, và yêu cầu đó chỉ là HTTPS thuần túy trên cổng 443.
Shard, matrix job và các lượt thử lại
Một khi bộ kiểm thử đã đủ chậm để cần chia shard, hãy chia nó ra. Playwright chia một lượt chạy ra nhiều job bằng --shard, và vì mỗi bài kiểm thử đều tự mở hộp thư riêng của mình, các shard không cần gì từ nhau cả:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
# ... the same steps as above, then:
- run: npx playwright test --shard=${{ matrix.shard }}/4Cùng một đặc tính đó cũng bao trùm luôn hai cách còn lại khiến một bài kiểm thử cuối cùng chạy hai lần cùng một lúc:
- Hai pull request cùng lúc
- Hai job, hai tập địa chỉ ngẫu nhiên, không hề chồng lấn. Mức trần cho mỗi client trên API 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 job chỉ thăm dò một hộp thư tại một thời điểm thì còn cách con số đó rất xa.
- Một job được thử lại
- Một lượt thử lại chạy lại phần thân bài kiểm thử, và việc đó lại tạo ra một địa chỉ mới. Hộp thư cũ vẫn giữ thư cũ trong 5 ngày, nhưng không có gì đọc nó cả — lượt thử lại không bao giờ nhìn thấy nó.
retriescủa chính Playwright- Cũng là chuyện tương tự, chỉ ở một cấp thấp hơn: mỗi lượt thử đều chạy lại fixture. Đừng chuyển địa chỉ vào một
beforeAllđể tiết kiệm thời gian; đó chính xác là kiểu dùng chung khiến một lượt thử có thể đọc được mã của lượt thử trước đó.
Các timeout vừa khít bên trong job
Có bốn chiếc đồng hồ, và chúng phải lồng vào nhau, đồng hồ trong cùng phải ngắn nhất. Khi điều đó không đúng, lỗi sẽ được báo cáo bởi chiếc đồng hồ sai và chỉ ra một nguyên nhân sai.
| Đồng hồ | Đặt ở đâu | Một giá trị hợp lý |
|---|---|---|
| Hạn chót chờ thư | Bên trong helper (timeoutMs, timeout=) | 60 giây. Thư giao dịch thường đến trong vài giây; một phút đủ để bao trùm cả một hàng đợi chậm từ phía nhà cung cấp. |
| Timeout của bài kiểm thử | playwright.config.ts / --timeout | 120 giây. Cao hơn hạn chót cộng thêm phần thao tác trình duyệt xung quanh nó. |
| Timeout của bước | timeout-minutes trên bước đó, nếu có | Thường không cần đặt; giới hạn của job là đủ rồi. |
| Timeout của job | timeout-minutes trên job | 20 phút. Đủ cho việc cài đặt, khởi động, chạy bộ kiểm thử và tải báo cáo lên; đủ thấp để một ứng dụng bị treo không khiến bạn bị tính phí cả một giờ đồng hồ. |
Triệu chứng của việc lồng sai rất cụ thể: một bài kiểm thử chờ sáu mươi giây bên trong timeout kiểm thử mặc định ba mươi giây của Playwright sẽ chết ở giây thứ ba mươi kèm một thông báo về bài kiểm thử, lần nào cũng vậy, và chẳng nói được gì về việc thư có đến hay không. Hãy đặt timeout của bài kiểm thử trước, rồi mới đến mọi thứ lồng ra bên ngoài nó.
Đọc hiểu một lượt thất bại
Một lượt chạy thất bại nên cho bạn biết một trong ba điều gì đã xảy ra — thư không bao giờ đến, thư sai đã đến, hoặc mã trong đó bị sai — mà không cần chạy lại bất cứ thứ gì. Bốn thói quen sau giúp điều đó thành hiện thực:
- Ghi log địa chỉ. Thông báo lỗi của helper nêu rõ tên hộp thư mà nó đã chờ. Hãy in nó thêm một lần nữa ở đầu bài kiểm thử để nó luôn có mặt trong log của job, ngay cả khi assertion nằm ở một chỗ khác.
- Tự tay mở hộp thư. Thư được giữ lại trong 5 ngày, nên
/inbox/<address>trên trang này sẽ cho thấy chính xác những gì runner đã thấy — hoặc chưa từng thấy — trong suốt phần còn lại của tuần đó. Đây chính là điều hữu ích nhất của một hộp thư thật so với một hộp thư được mock. - Giữ lại báo cáo khi thất bại. Trace của Playwright cho thấy cú click lẽ ra đã phải gửi thư đi; tệp JUnit cho thấy bài kiểm thử nào và nó đã chờ bao lâu.
- Kiểm tra trạng thái dịch vụ trước khi đổ lỗi cho bài kiểm thử. Trang trạng thái được thăm dò từ bên ngoài mỗi hai phút một lần; nếu luồng thư đến bị gián đoạn đúng vào lúc lượt chạy diễn ra, thì thất bại đó là có thật, và không phải lỗi của bạn.
Dọn dẹp, nếu muốn
Mọi thứ đều hết hạn sau 5 ngày bất kể có ai xóa hay không, 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ì. Nhưng xóa những gì lượt chạy đã đọc vẫn đáng để làm thành một bước riêng, vì lần thất bại tiếp theo khi đó sẽ được đọc trên một hộp thư thực sự trống rỗng. Thao tác này có tính idempotent — xóa hai lần vẫn trả về 200 — nên tự bản thân nó không bao giờ có thể khiến một build thất bại:
- name: Delete what the run read
if: always()
run: |
for addr in $(cat .e2e-addresses 2>/dev/null); do
curl -sG https://grabmail.io/api/v1/mailbox --data-urlencode "address=$addr" \
| jq -r '.messages[].id' \
| xargs -r -I{} curl -sX DELETE -G "https://grabmail.io/api/v1/message/{}" --data-urlencode "mailbox=$addr" -o /dev/null
doneHãy đặt nó là if: always() và đừng bao giờ biến nó thành bắt buộc: một bước dọn dẹp thất bại nên chỉ là một cảnh báo trong log, chứ không phải một build báo đỏ.
Trước khi bạn coi như đã xong
- Không có thông tin đăng nhập hộp thư nào trong
secrets; chỉ có key trình gửi thư của chính ứng dụng bạn. - HTTPS hướng ra ngoài đến
grabmail.iođược cho phép, và không có gì inbound cả. - Một địa chỉ mới cho mỗi bài kiểm thử, được tạo ngay trong phần thân bài kiểm thử — an toàn dưới cả shard lẫn các lượt thử lại.
- Bốn timeout được lồng vào nhau: hạn chót < bài kiểm thử < bước < job.
- Báo cáo được tải lên khi thất bại, kèm địa chỉ trong log.
- Dọn dẹp như một bước luôn luôn chạy, nhưng không bao giờ bắt buộc.
Đó là tất cả những gì runner cộng thêm vào. Kỷ luật kiểm thử nằm bên dưới — hạn chót, địa chỉ mới, mẫu biểu thức được neo sẵn — nằm trong bài kiểm thử một luồng xác minh từ đầu đến cuối, còn các quy tắc trích xuất, tách riêng, nằm trong mã OTP trong các bài kiểm thử tự động.
Câu hỏi
Tôi có cần thêm một secret GrabMail vào repository 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ì để thêm vào secrets cả. Thông tin đăng nhập duy nhất trong workflow là thứ ứng dụng của bạn dùng để gửi thư, thứ mà nó cần có để chạy được ngay từ đầu.
Điều này có hoạt động trong một repository riêng tư hay trên một runner tự host không?
Có. Runner chỉ gửi các yêu cầu HTTPS hướng ra ngoài đến grabmail.io và không gì khác; việc bạn host runner ở đâu không hề quan trọng. Trên một runner tự host đứng sau một bộ lọc egress, hãy cho phép hostname đó trên cổng 443.
Các job chạy đồng thời có chạm phải rate limit không?
Trên thực tế thì không. Giới hạn cho mỗi địa chỉ là một lần đọc mỗi giây, điều mà helper luôn tuân thủ, còn 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, từ một runner. Nhiều runner thì tương đương nhiều client. Một lỗi 429 sẽ đi kèm Retry-After, và helper sẽ chờ đúng khoảng đó thay vì báo thất bại.
Tôi có thể chạy việc này theo lịch, như một lượt kiểm tra tổng hợp cho luồng đăng ký production không?
Có thể, và đây là một cách dùng rất tốt: một workflow on: schedule đăng ký bằng một địa chỉ mới mỗi giờ rồi đọc mã sẽ chứng minh toàn bộ đường đi của thư trong production, kể cả nhà cung cấp. Hãy giữ tiền tố của địa chỉ dễ nhận diện, để các lượt đăng ký đó dễ dọn dẹp ở phía bạn.
Nếu ứng dụng của tôi từ chối các tên miền dùng một lần thì sao?
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 — rồi dùng tên miền đó trong fixture. Phần thiết lập chỉ mất vài phút, và bài tài khoản kiểm thử không giới hạn trên một tên miền cho thấy hình mẫu đó khi áp dụng vào một bộ kiểm thử.
Có gì trong hộp thư mang tính riêng tư không?
Không. Bất kỳ ai biết mộ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ỉ 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 dữ liệu 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.


