หากการทดสอบอีเมลของคุณทำงานได้อย่างสมบูรณ์แบบบนแล็ปท็อป แต่กลับพังทันทีเมื่อรันบน CI คุณไม่ใช่คนเดียวที่เจอเรื่องนี้ วิธีการตอบโต้แบบเดิมๆ คือการใส่ sleep เข้าไปในโค้ดทดสอบ หรือเพิ่มจำนวนการลองใหม่ (retry count) จนกว่า build จะผ่าน วิธีนั้นอาจช่วยลดสัญญาณรบกวนได้เพียงชั่วคราว แต่มันไม่ได้แก้บั๊ก มันเพียงแค่ซ่อนมันไว้เท่านั้น

ปัญหาที่แท้จริงคือ วิธีที่เทสต์ของคุณระบุว่าต้องเปิดอีเมลฉบับไหน

ปัญหาของ Shared Inbox

บนเครื่องส่วนตัวของคุณ คุณรันเทสต์ทีละหนึ่งรายการ มีอีเมลส่งมาหนึ่งฉบับ คุณก็หยิบมันมาใช้งาน ง่ายๆ แค่นั้น

แต่ CI เป็นสภาพแวดล้อมที่ต่างออกไปโดยสิ้นเชิง Pull request เพียงอันเดียวอาจกระตุ้นให้เกิดงานแบบขนาน (parallel jobs) ตั้งสี่, แปด หรือสิบหกงาน หากงานทั้งหมดใช้ test inbox ร่วมกัน ไม่ว่าจะเป็นเซิร์ฟเวอร์ Mailosaur, กล่องจดหมาย Mailtrap หรือบัญชีจริงบน staging domain ทุกงานกำลังเขียนข้อมูลลงในถังเดียวกันในเวลาเดียวกัน งาน A ส่งการรีเซ็ตรหัสผ่าน งาน B ส่งคำเชิญ งาน C พยายามส่ง flow การต้อนรับที่ล้มเหลวซ้ำอีกครั้ง ในขณะเดียวกัน background workers และคิวการส่งอีเมลก็สร้าง jitter ที่คุณไม่สามารถควบคุมได้

เมื่อทุกงานพยายามเข้าไปใน shared inbox นั้นแล้วถามหาข้อความล่าสุดที่มีหัวข้อว่า "Reset your password" มันจะกลายเป็นการแข่งขัน (race) เทสต์ที่ชนะจะได้อีเมลที่ถูกต้อง ส่วนเทสต์ที่แพ้จะไปคลิกลิงก์ที่ตั้งใจไว้สำหรับงานอื่น ตรวจสอบเนื้อหาผิดฉบับ และล้มเหลวด้วย error ที่ดูเหมือนเป็นปัญหาเรื่องจังหวะเวลา (timing problem) แต่มันไม่ใช่ปัญหาเรื่องจังหวะเวลา มันคือปัญหาเรื่องการระบุตัวตน (identity problem)

ทำไมการหา "ข้อความล่าสุด" ถึงล้มเหลว

รูปแบบที่เปราะบางนี้เกิดขึ้นได้ง่ายเพราะมันดูสมเหตุสมผล:

  1. เริ่มต้น user flow
  2. คอยตรวจสอบ (poll) กล่องจดหมายทุกๆ ไม่กี่วินาที
  3. เปิดข้อความล่าสุดที่มีหัวข้อตรงกัน
  4. คลิกลิงก์แรกและทำการตรวจสอบ (assertions)

รูปแบบนี้จะพังด้วยเหตุผลหลายประการที่นอกเหนือไปจากเรื่องการทำงานแบบขนาน การลองใหม่ (retry) จากการรันที่ล้มเหลวก่อนหน้าอาจมาถึงล่าช้า และกลายเป็นข้อความล่าสุดในจังหวะที่คุณกำลัง poll พอดี Background workers ภายในแอปพลิเคชันของคุณอาจจัดคิวอีเมลสองฉบับและส่งฉบับที่สองออกไปก่อนฉบับแรก หัวข้ออีเมล (subject lines) เพียงอย่างเดียวก็เป็นตัวระบุที่อ่อนแอ เพราะแอปพลิเคชันบน staging ของคุณอาจส่งอีเมลที่คล้ายกันจากเส้นทางที่ต่างกัน การเรียงลำดับตาม timestamp นั้นแย่กว่าที่คิด เพราะความคลาดเคลื่อนของเวลา (clock skew) ระหว่าง CI runner และผู้ให้บริการอีเมลนั้นเกิดขึ้นจริง และ Mail API มักจะทำ caching หรือจัดกลุ่มดัชนี (batch indexes) ไว้

Timestamp จะเริ่มไม่แม่นยำในสภาพแวดล้อมที่วุ่นวาย คุณจึงต้องการสิ่งที่ตรงไปตรงมามากกว่านั้น

Run Token คืออะไรกันแน่

Run token คือสตริงที่ไม่ซ้ำกัน (unique string) ที่ถูกสร้างขึ้นตอนเริ่มเทสต์และถูกฉีด (inject) เข้าไปในอีเมลที่แอปพลิเคชันของคุณส่งออกไป มันไม่จำเป็นต้องแสดงให้ผู้ใช้เห็น และไม่จำเป็นต้องดูสวยงาม มันเพียงแค่ต้องรับประกันว่าคุณสามารถพิสูจน์ได้ว่าข้อความเฉพาะฉบับนี้เป็นของชุดการทดสอบนี้โดยเฉพาะ

ตัวอย่างที่เป็นรูปธรรมจะช่วยให้เห็นภาพชัดที่สุด ก่อนเริ่มเทสต์ ให้สร้าง token เช่น:

  • UUID: 550e8400-e29b-41d4-a716-446655440001
  • Build-scoped request ID: req_ci_build_4821_a7f3
  • Invite slug หรือ metadata suffix: signup-token-8k2m9n
  • สตริง hex แบบสุ่มที่สร้างโดย test runner: test-run-a4f9c2d1

หากคุณควบคุมโค้ดฝั่ง backend ได้ ให้ส่ง token เข้าไปใน context ของอีเมลและแสดงผลไว้ที่ส่วนใดส่วนหนึ่งในเนื้อหา (body) หากคุณกำลังทดสอบแอปพลิเคชันแบบ black-box ให้ดูว่าแอปนั้นยอมรับฟิลด์อ้างอิงที่คุณสามารถนำมาใช้ได้หรือไม่ หากไม่มี คุณสามารถฝัง token ไว้ในส่วน local-part ของผู้รับโดยใช้ plus addressing เช่น testuser+a4f9c2d1@example.com แม้ว่าวิธีนี้จะใช้ได้ก็ต่อเมื่อแอปพลิเคชันของคุณรักษาค่านี้ไว้และส่งกลับมาในอีเมลด้วยก็ตาม

ประเด็นสำคัญคือ เลิกจับคู่ด้วย metadata ที่ระบบอีเมลเป็นเจ้าของ แต่ให้จับคู่ด้วยข้อมูลที่เทสต์ของคุณเป็นเจ้าของ

รูปแบบที่เชื่อถือได้

แทนที่อัลกอริทึม "ข้อความล่าสุด" ด้วยการค้นหาที่แคบลงโดยใช้ token:

  1. สร้าง run token ก่อนที่คุณจะเริ่ม flow ใดๆ
  2. เริ่มต้นการกระทำของผู้ใช้ โดยตรวจสอบให้แน่ใจว่าแอปพลิเคชันจะรวม token เข้าไปในอีเมลที่ส่งออก
  3. คอยตรวจสอบ (poll) ผู้ให้บริการอีเมลด้วยตัวกรองที่จำกัดเฉพาะ token นั้น หาก API รองรับการค้นหาจากเนื้อหา (body search) ให้ใช้ฟีเจอร์นั้น หากไม่รองรับ ให้ดึงข้อความที่เข้าข่ายมาแล้วใช้ grep ตรวจสอบเนื้อหาในฝั่ง client
  4. ตรวจสอบ (assert) ว่ามี token อยู่ในเนื้อหาข้อความก่อนที่คุณจะแตะลิงก์ ปุ่ม หรือรหัสยืนยันใดๆ
  5. เมื่อผ่านแล้ว จึงค่อยดึง URL ยืนยันหรือรหัสออกมาเพื่อดำเนินการต่อ

ลำดับขั้นตอนเหล่านี้สำคัญมาก หากคุณดึงลิงก์ออกมาก่อนแล้วค่อยตรวจสอบ token ทีหลัง คุณก็ได้คลิกอีเมลผิดฉบับไปเรียบร้อยแล้ว การทำ assertion คือด่านตรวจของคุณ

ในทางปฏิบัติ ตัวช่วย (helper) ของคุณควรค้นหา Subject:"Welcome to AppName" AND Body:"a4f9c2d1" แทนที่จะเป็น Subject:"Welcome to AppName" sort:-received บริการทดสอบอีเมลหลายแห่งมี search API ที่รองรับการกรองเนื้อหาใน Body จงใช้สิ่งนั้น หากคุณกำลังทำงานกับผู้ให้บริการที่เรียบง่ายกว่า ให้เก็บตรรกะการ polling ไว้ในที่เดียว เพื่อที่คุณจะได้เพิ่มการกรองฝั่ง client ได้อย่างสม่ำเสมอในทุกๆ การทดสอบ

กฎ 3 ข้อเพื่อให้ระบบมีความแม่นยำและเชื่อถือได้

run token ช่วยกำหนดการเลือกข้อมูลให้แน่นอน แต่คุณยังคงต้องมีวินัยในเรื่องวิธีการ polling และสิ่งที่คุณต้องทำเมื่อเกิดข้อผิดพลาด

บันทึกสถานะของ inbox เมื่อเกิดความล้มเหลว เมื่อการทดสอบล้มเหลว ให้แสดง inbox identifier, หัวข้ออีเมล (subject line) ที่คุณค้นหา, ช่วงเวลา (timestamp window) ที่แน่นอน และจำนวนข้อความที่ตรงตามเงื่อนไขของคุณ สิ่งนี้จะเปลี่ยนข้อผิดพลาดที่คลุมเครืออย่าง "email not found" ให้กลายเป็นเรื่องราวที่ชัดเจน หาก job 7823 ไปดึงข้อความ retry จาก job 7821 มาเพราะมันมาถึงช้ากว่า 3 วินาที Log ของคุณควรจะแสดงให้เห็นอย่างชัดเจน หากไม่มีบริบทนี้ คุณจะโทษเรื่องจังหวะเวลา (timing) และเพิ่มคำสั่ง sleep เข้าไปอีก

เก็บการ polling อีเมลทั้งหมดไว้ใน helper file เดียวกัน อย่ากระจายการเรียกใช้ setTimeout และ cy.task ไปตามไฟล์ทดสอบยี่สิบไฟล์ ให้รวมตรรกะที่รอข้อความ, การเรียก API ซ้ำ (retry), และการทำ backoff ไว้ที่ศูนย์กลาง หากทุกการทดสอบใช้ helper ตัวเดียวกัน กฎการกรองของคุณก็จะสม่ำเสมอ และเมื่อคุณปรับปรุงตรรกะการค้นหา ทุกการทดสอบก็จะได้รับประโยชน์ด้วย นอกจากนี้ยังช่วยให้การบังคับใช้การตรวจสอบ token ทำได้ง่ายขึ้น หาก helper กำหนดว่าต้องมี argument เป็น token ก็จะไม่มีใครเผลอกลับไปใช้ "ที่พึ่งแบบง่ายๆ" อย่างการเลือก "ข้อความล่าสุด" (latest message)

ระวังเรื่องการ retry การ retry การทดสอบเป็นเรื่องปกติใน CI แต่การ retry แต่ละครั้งจะสร้างอีเมลเพิ่มขึ้นใน inbox หากการทดสอบของคุณผ่านในการพยายามครั้งที่สาม คุณอาจจะดีใจและผ่านมันไป แต่สิ่งที่คุณพลาดไปคือการพยายามครั้งที่หนึ่งและสองอาจเผยให้เห็นบั๊กที่แท้จริง เช่น race condition, การส่งซ้ำ หรือ index ที่หายไป ซึ่งข้อความส่วนเกินเหล่านั้นได้บดบังไว้ หากคุณจำเป็นต้องใช้การ retry ให้ตรวจสอบว่า inbox มีข้อความซ้ำที่ไม่คาดคิดหลังจากเกิดความล้มเหลวหรือไม่ หรือจะให้ดีกว่านั้น ให้พิจารณาการล้าง inbox หรือใช้ที่อยู่อีเมลที่ไม่ซ้ำกันต่อหนึ่ง job หากผู้ให้บริการของคุณรองรับ dynamic inboxes การ retry ไม่ควรกลายเป็นกลยุทธ์ในการกลบเกลื่อนตรรกะการเลือกข้อมูลที่ไม่น่าเชื่อถือ

บทสรุปที่สำคัญ

การเรียงลำดับ inbox ตามวันที่แล้วหยิบผลลัพธ์แรกมานั้นไม่ใช่การทดสอบ แต่มันคือการเดาที่เขียนอยู่ในรูปแบบของโค้ด การใช้ run token แทบไม่มีต้นทุนเลย—แค่ตัวแปร string หนึ่งตัว, พารามิเตอร์การกรองเพิ่มอีกหนึ่งตัว, หรืออาจจะแค่การเปลี่ยน template เล็กน้อย—แต่มันช่วยให้การทดสอบของคุณมีตัวตนที่แน่นอน (deterministic identity) มันพิสูจน์ว่าข้อความที่อยู่ตรงหน้าคุณเป็นของรอบการทำงาน (run) ที่คุณกำลังรันอยู่ในขณะนี้จริงๆ

เลิกเพิ่มคำสั่ง sleep แล้วหวังว่าเครือข่ายจะทำงานได้ตามปกติเสียที จงสร้าง token ใส่ลงในอีเมล แล้วค้นหามันโดยตรง การรัน CI ของคุณจะเร็วขึ้น Log ของคุณจะอ่านง่ายขึ้น และในที่สุดคุณจะเชื่อมั่นในสิ่งที่ชุดการทดสอบอีเมลกำลังบอกคุณ