Pengujian & CI

GitHub Actions: job end-to-end yang membaca email sungguhan

Pengujian pendaftaran yang membaca kode konfirmasinya dari sebuah kotak surat berfungsi di laptop, lalu bertemu dengan runner CI: tidak ada secret yang perlu dipasang, ada urusan egress, ada batas waktu yang harus muat di dalam job, dan ada kotak surat yang harus selalu baru di setiap run dan setiap retry. Berikut workflow-nya, untuk Playwright dan untuk pytest, lengkap dengan bagian-bagian yang hanya penting di sebuah runner.

  • Menengah
  • 15 menit baca
Tiga roda gigi abu-abu menggerakkan sabuk yang membawa amplop biru menuju gerbang abu-abu berlampu biru di atasnya

Apa yang berubah pada sebuah runner

Pengujiannya sendiri tidak berubah. Yang berubah adalah semua yang ada di sekelilingnya, dan masing-masing punya jawaban yang spesifik, bukan sekadar angkat bahu:

Tidak ada secret yang perlu dipasang
Membaca kotak surat publik tidak butuh kunci, tidak butuh akun, dan tidak butuh header apa pun, jadi sisi kotak suratnya tidak menambahkan apa pun ke secrets. Satu-satunya kredensial di dalam job-nya adalah yang sudah dibutuhkan aplikasi Anda untuk mengirim email — SendGrid, Postmark, SES, apa pun itu — dan itu milik aplikasi Anda, bukan milik pengujiannya.
Runner-nya harus bisa menjangkau internet
HTTPS keluar ke grabmail.io, dan koneksi keluar ke apa pun yang dipakai mailer Anda. Runner yang di-hosting GitHub mengizinkan keduanya secara default; runner self-hosted di balik filter egress butuh satu aturan tambahan.
Run yang saling tumpang tindih
Dua pull request, empat shard, sebuah retry dari job yang flaky — beberapa salinan pengujian yang sama membaca email pada waktu yang bersamaan. Alamat yang dibagikan akan membuat mereka bisa saling membaca kode satu sama lain; alamat baru di setiap pengujian membuat seluruh kategori masalah ini mustahil terjadi.
Waktu itu berbayar
Pengujian yang menunggu email selama enam puluh detik itu baik-baik saja. Tapi sebuah job yang menunggu enam puluh detik di setiap satu dari empat puluh pengujiannya adalah empat puluh menit runner yang harus dibayar. Penantiannya harus dibatasi, dan suite-nya harus di-shard begitu ukurannya membesar.

Kode pengujian yang dijalankan workflow-workflow ini adalah kode yang sama dari panduan Playwright atau panduan Python: alamat baru, penantian dengan batas waktu, extractor yang dipatok pada template Anda. Tidak ada satu pun di dalamnya yang spesifik untuk CI, dan memang itulah maksudnya — bagian-bagian yang spesifik untuk runner semuanya berada di dalam file workflow-nya.

Workflow-nya, untuk Playwright

Satu job. Job ini menjalankan aplikasi Anda dengan mailer keluar yang sungguhan, menunggu sampai aplikasinya merespons, menjalankan suite-nya, dan hanya menyimpan laporannya saat ada yang gagal.

.github/workflows/e2e.yml
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: 7

Ada tiga baris yang memikul bobot paling besar. timeout-minutes: 20 adalah batas terluar tempat semua yang lain bersarang. Aplikasinya dijalankan dengan kredensial mailer yang sungguhan, karena pengujian yang membaca email sungguhan butuh email sungguhan yang dikirim. Dan laporannya hanya diunggah saat gagal, dengan masa simpan yang singkat — run yang lolos tidak punya apa pun yang layak disimpan.

Workflow-nya, untuk pytest

Bentuk yang sama dengan toolchain Python: jalankan aplikasinya, tunggu endpoint health check-nya, jalankan suite-nya dengan timeout per-pengujian di atas batas waktu emailnya, simpan laporan JUnit-nya saat gagal.

.github/workflows/e2e.yml
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 berasal dari plugin pytest-timeout dan menjadi batas atas per-pengujian; batas waktu email di dalam helper-nya adalah enam puluh detik, jadi pengujian yang menunggu satu pesan lalu melakukan beberapa pekerjaan browser masih tetap muat. Loop health check-nya ditulis langsung alih-alih diambil sebagai sebuah action karena panjangnya cuma delapan baris dan tidak ada yang bisa salah di situ.

Satu-satunya aturan jaringan

Membaca sebuah kotak surat adalah sebuah request HTTPS keluar dari runner ke grabmail.io. Itulah keseluruhan jejak jaringannya:

  • Tidak ada koneksi masuk. Tidak ada apa pun yang terhubung ke runner-nya. Tidak ada webhook yang perlu diterima, tidak ada server SMTP yang perlu dijalankan, tidak ada port yang perlu diekspos.
  • Tidak ada SMTP dari runner-nya. Emailnya dikirim oleh aplikasi Anda lewat provider-nya, melalui API atau endpoint SMTP provider itu — dengan cara yang sama seperti di production. Runner-nya sendiri tidak pernah berbicara SMTP.
  • Hanya grabmail.io:443 yang perlu ditambahkan pada runner dengan daftar izin egress — ditambah provider email Anda dan package registry Anda, yang toh sudah dibutuhkan job-nya.
pada runner yang diperketat, izinkan persis itu
      - uses: step-security/harden-runner@v2
        with:
          egress-policy: block
          allowed-endpoints: >
            grabmail.io:443
            api.your-mail-provider.example:443
            registry.npmjs.org:443

Jika suite-nya lolos secara lokal tapi gagal di CI dengan error koneksi dari helper-nya, aturan inilah hal pertama yang perlu diperiksa, dan hampir selalu itulah keseluruhan jawabannya. Runner self-hosted di jaringan korporat biasanya memfilter HTTPS egress-nya berdasarkan hostname; hostname yang perlu diizinkan adalah milik API-nya, dan request-nya adalah HTTPS biasa di port 443.

Shard, job matriks, dan retry

Begitu suite-nya sudah cukup lambat, bagi jadi beberapa shard. Playwright membagi sebuah run ke beberapa job dengan --shard, dan karena setiap pengujian membuka kotak suratnya sendiri, antar-shard tidak butuh apa pun satu sama lain:

empat shard, masing-masing job yang terpisah
  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 }}/4

Sifat yang sama ini juga mencakup dua cara lain sebuah pengujian bisa berakhir berjalan dua kali sekaligus:

Dua pull request pada waktu yang sama
Dua job, dua kumpulan alamat acak, tidak ada yang tumpang tindih. Batas atas per klien pada API-nya adalah 1200 request per menit, setara dua puluh kotak surat yang di-polling sekali per detik — sebuah job yang melakukan polling satu kotak surat pada satu waktu jauh dari mendekati angka itu.
Sebuah job yang di-retry
Sebuah retry menjalankan badan pengujiannya lagi, yang membuat alamat baru lagi. Kotak surat yang lama masih menyimpan pesan lamanya selama 5 hari, dan tidak ada yang membacanya — retry-nya tidak akan pernah melihatnya.
retries milik Playwright sendiri
Hal yang sama, selapis lebih dalam: setiap percobaan menjalankan fixture-nya lagi. Jangan pindahkan alamatnya ke dalam sebuah beforeAll untuk menghemat waktu; itu justru persis jenis berbagi yang memungkinkan sebuah percobaan membaca kode dari percobaan sebelumnya.

Timeout-timeout yang muat di dalam job

Ada empat hitungan waktu dan semuanya harus bersarang, yang paling dalam paling singkat. Kalau tidak, kegagalannya akan dilaporkan oleh yang salah dan menunjuk ke penyebab yang salah pula.

Hitungan waktuDiatur di manaNilai yang wajar
Batas waktu emailDi dalam helper (timeoutMs, timeout=)60 dtk. Email transaksional sampai dalam hitungan detik; satu menit sudah menutupi antrean provider yang lambat.
Batas waktu pengujianplaywright.config.ts / --timeout120 dtk. Di atas batas waktunya ditambah pekerjaan browser di sekelilingnya.
Batas waktu steptimeout-minutes pada step-nya, jika adaBiasanya tidak diatur; batas dari job-nya sudah cukup.
Batas waktu jobtimeout-minutes pada job-nya20 mnt. Cukup untuk instalasi, start, suite-nya, dan unggahannya; cukup rendah agar aplikasi yang hang tidak sampai dibayar satu jam penuh.

Gejala dari susunan bersarang yang salah itu spesifik: pengujian yang menunggu enam puluh detik di dalam batas waktu pengujian default Playwright yang tiga puluh detik akan mati di detik ketiga puluh dengan pesan soal pengujiannya, setiap saat, dan tidak memberi tahu apa pun soal emailnya. Atur batas waktu pengujiannya dulu, baru semua yang di luarnya.

Membaca sebuah kegagalan

Sebuah run yang gagal seharusnya memberi tahu Anda mana dari tiga hal ini yang terjadi — emailnya tidak pernah sampai, email yang salah yang sampai, atau kode di dalamnya yang salah — tanpa perlu menjalankan ulang apa pun. Empat kebiasaan berikut mewujudkan itu:

  1. Log alamatnya. Pesan kegagalan dari helper-nya menyebutkan kotak surat yang ditunggunya. Cetak sekali lagi di bagian atas pengujiannya supaya tetap ada di log job-nya bahkan saat assertion-nya ada di tempat lain.
  2. Buka kotak suratnya secara manual. Pesan-pesan bertahan selama 5 hari, jadi /inbox/<address> di situs ini menunjukkan persis apa yang dilihat — atau tidak dilihat — runner-nya, untuk sisa minggu itu. Inilah satu hal paling berguna dari kotak surat sungguhan dibanding yang di-mock.
  3. Simpan laporannya saat gagal. Trace milik Playwright menunjukkan klik yang seharusnya mengirim emailnya; file JUnit menunjukkan pengujian mana dan berapa lama ia menunggu.
  4. Periksa status layanannya sebelum menyalahkan pengujiannya. Halaman status diperiksa dari luar setiap dua menit; jika email masuk sedang bermasalah saat run itu berlangsung, kegagalannya memang nyata dan bukan kesalahan Anda.

Membersihkan, secara opsional

Apa pun akan kedaluwarsa setelah 5 hari, dihapus siapa pun ataupun tidak, jadi run yang melewatkan pembersihan tidak menimbulkan biaya apa pun. Menghapus apa yang sudah dibaca run itu tetap layak jadi sebuah step, karena kegagalan berikutnya jadi dibaca terhadap kotak surat yang benar-benar kosong. Sifatnya idempoten — menghapus dua kali tetap menjawab 200 — jadi ini tidak akan pernah bisa menggagalkan sebuah build dengan sendirinya:

sebuah step pembersihan yang selalu berjalan
      - 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
          done

Jadikan itu if: always() dan jangan pernah jadikan itu wajib: sebuah pembersihan yang gagal seharusnya menjadi peringatan di dalam log, bukan build yang merah.

Sebelum Anda menganggapnya selesai

  • Tidak ada kredensial kotak surat di dalam secrets; hanya kunci mailer milik aplikasi Anda sendiri.
  • HTTPS keluar ke grabmail.io diizinkan, dan tidak ada apa pun yang masuk.
  • Alamat baru di setiap pengujian, dibuat langsung di dalam badan pengujian — aman di bawah shard dan retry.
  • Keempat timeout-nya bersarang: deadline < pengujian < step < job.
  • Laporannya diunggah saat gagal, dengan alamatnya ada di dalam log.
  • Pembersihan sebagai step yang selalu berjalan, tapi tidak pernah wajib.

Itulah keseluruhan tambahan dari sisi runner-nya. Disiplin pengujian yang mendasarinya — batas waktu, alamat baru, pola yang dipatok — ada di menguji alur verifikasi dari ujung ke ujung, dan aturan ekstraksinya sendiri ada di kode OTP dalam pengujian otomatis.

Pertanyaan

Apakah saya perlu menambahkan secret GrabMail ke repository?

Tidak. Domain publiknya tidak butuh kunci, tidak butuh akun, dan tidak butuh header apa pun, jadi tidak ada apa pun yang perlu ditambahkan ke secrets. Satu-satunya kredensial di dalam workflow-nya adalah yang dipakai aplikasi Anda untuk mengirim email, yang memang sudah dibutuhkan agar aplikasinya bisa berjalan sama sekali.

Apakah ini berfungsi di repository privat atau di runner self-hosted?

Ya. Runner-nya membuat request HTTPS keluar ke grabmail.io dan tidak ada yang lain; di mana Anda meng-hosting runner-nya tidak relevan. Pada runner self-hosted di balik filter egress, izinkan hostname itu di port 443.

Apakah job-job yang berjalan bersamaan akan menabrak rate limit?

Secara praktis, tidak. Batas per alamat adalah satu kali baca per detik, yang selalu dipatuhi helper-nya, dan batas atas per klien adalah 1200 request per menit — dua puluh kotak surat yang di-polling sekali per detik, dari satu runner. Beberapa runner berarti beberapa klien. Sebuah 429 dijawab dengan Retry-After, dan helper-nya akan sleep sesuai itu, bukan gagal.

Bisakah saya menjalankan ini secara terjadwal, sebagai pemeriksaan sintetis untuk pendaftaran production?

Bisa, dan itu penggunaan yang bagus: sebuah workflow on: schedule yang mendaftar dengan alamat baru setiap jam dan membaca kodenya akan membuktikan keseluruhan jalur email production, termasuk provider-nya. Jaga agar awalan alamatnya tetap dikenali supaya pendaftaran-pendaftaran itu mudah dibersihkan dari sisi Anda.

Bagaimana jika aplikasi saya menolak domain sekali pakai?

Arahkan domain milik Anda sendiri ke layanan ini — satu record MX, tanpa akun — lalu pakai domain itu di dalam fixture-nya. Pengaturannya hanya butuh beberapa menit dan akun pengujian tanpa batas pada satu domain menunjukkan polanya di dalam sebuah suite.

Apakah ada sesuatu di kotak surat itu yang bersifat privat?

Tidak. Siapa pun yang tahu sebuah alamat bisa membacanya, baik di domain publik maupun di domain Anda sendiri. Untuk alamat acak yang menyimpan satu kode sekali pakai, itu tidak relevan; untuk lingkungan staging yang mengirim data pelanggan sungguhan, ini jadi alasan diskualifikasi — jangan arahkan yang itu ke sini.

Coba selagi masih segar

Alamat hanya perlu satu klik, tanpa akun dan tanpa kartu. Semua yang ada di panduan ini langsung berfungsi dengannya.

Selamat datang kembali

Kotak surat dan domain Anda, di satu tempat.