การใช้ TimeProvider ของ .NET 8 ร่วมกับการปรับแต่ง SynchronizationContext เพียงจุดเดียว สามารถช่วยลดเวลาในการรัน unit test ของ retry-logic ที่ปกติจะต้องเสียเวลารอ (sleep) ไปได้หลายวินาที จากเดิมที่เคยใช้เวลาถึง 7 วินาที ตอนนี้เหลือเพียงไม่กี่สิบมิลลิวินาทีเท่านั้น และการเปลี่ยนแปลงนี้ไม่มีค่าใช้จ่ายเพิ่มเติมเมื่อนำไปรันใน production

ทำไมความล่าช้าถึงเป็นเรื่องสำคัญ

ตัวช่วยในการ retry หลายตัวใช้กลไก exponential backoff เช่น รอ 1 วินาที ตามด้วย 2 วินาที, 4 วินาที และต่อๆ ไป ใน production วิธีนี้จะช่วยปกป้อง service ไม่ให้ยิง request ใส่ endpoint ที่กำลังมีปัญหาหนักเกินไป แต่ในชุดการทดสอบ (test suite) โค้ดชุดเดียวกันนี้จะเรียกใช้ Task.Delay บนนาฬิกาจริง ทำให้ thread ของการทดสอบต้องหยุดรอ (sleep) จริงๆ เมื่อคูณจำนวนการทดสอบนับสิบๆ ครั้งเข้าด้วยกัน จะทำให้ pipeline ของ continuous-integration (CI) ใช้เวลานานขึ้นหลายนาทีโดยไม่ได้ประโยชน์ในเชิงฟังก์ชันเลย "ภาษีการรอ" (sleep tax) นี้คือเวลาที่เสียไปโดยเปล่าประโยชน์

TimeProvider ทำงานอย่างไร

.NET 8 ได้แนะนำ TimeProvider ซึ่งเป็น abstraction ของ system clock โดย class ที่ต้องการเวลาปัจจุบันหรือต้องการหน่วงเวลาสามารถรับ instance ของ TimeProvider แทนการเรียกใช้ DateTime.UtcNow หรือ Task.Delay โดยตรง ใน production คุณจะส่ง TimeProvider.System ซึ่งจะส่งต่อไปยังนาฬิกาจริง แต่ในการทดสอบ คุณสามารถส่ง FakeTimeProvider เข้าไปแทน ซึ่งตัว fake นี้จะช่วยให้คุณขยับเวลาไปข้างหน้าได้ด้วยตัวเองผ่าน Advance(TimeSpan) เนื่องจากภายใน Task.Delay จะอ่านค่าจาก provider ดังนั้นการขยับนาฬิกาจำลองไปข้างหน้าจะทำให้การหน่วงเวลาที่ค้างอยู่เสร็จสิ้นลงทันที

แนวคิดนั้นเรียบง่าย: แทนที่นาฬิกาจริงด้วยนาฬิกาที่ควบคุมได้ จากนั้นก็กระโดดข้ามไปยังจุดที่โค้ดที่กำลังทดสอบควรจะทำงานต่อ

กับดัก SynchronizationContext ใน xUnit

แนวคิดนี้ใช้ได้ผลใน console app แต่ใน xUnit มันกลับทำให้เกิด deadlock โดยการทดสอบเรียก Advance() ในขณะที่ retry helper กำลังรอ (awaiting) การหน่วงเวลาอยู่ xUnit จะติดตั้ง SynchronizationContext ของตัวเองเพื่อดักจับ continuations และรันพวกมันบน test thread เมื่อ Advance() ขยับนาฬิกาไปข้างหน้า continuation กลับถูกส่งไปเข้าคิวใน thread pool แทนที่จะเป็น test thread ทำให้ test thread ยังคงขยับเวลาต่อไปเรื่อยๆ จนนาฬิกาจำลองเลยจุดที่ควรจะเกิดการ retry ครั้งถัดไป ตัวจับเวลาใหม่ถูกตั้งไว้ในอนาคตที่ไม่มีวันมาถึง เพราะไม่มี thread เหลืออยู่เพื่อขยับนาฬิกาอีกครั้ง ส่งผลให้การทดสอบค้างอยู่อย่างนั้นไม่สิ้นสุด

วิธีแก้ไขด้วยโค้ดเพียงบรรทัดเดียว

การเพิ่มโค้ดเพียงบรรทัดเดียวที่จุดเริ่มต้นของการทดสอบจะช่วยให้กลับมาทำงานได้ตามปกติ:

SynchronizationContext.SetSynchronizationContext(null);

การล้าง custom context จะบังคับให้ await continuations ไปรันบน thread pool ซึ่งเป็นที่ที่ callback ของ fake timer สามารถทำงานได้ และด้วยการเปลี่ยนแปลงนี้ การทดสอบเดิมที่เคยใช้เวลา 7 วินาที จะลดลงเหลือเพียงประมาณ 36 มิลลิวินาที

ประโยชน์ในด้านอื่นๆ

นอกเหนือจากการกำจัดช่วงเวลาที่ต้องรอ (idle sleeps) แล้ว นาฬิกาจำลองยังช่วยให้การทดสอบ logic ที่เกี่ยวข้องกับ time zone ทำได้ง่ายขึ้น คุณสามารถตรวจสอบได้ว่าโควตารายวันถูกรีเซ็ตที่เวลาเที่ยงคืนตามเวลาท้องถิ่นที่ถูกต้อง โดยไม่ต้องรอให้นาฬิกาจริงตีบอกเวลาเที่ยงคืน แนวทางเดียวกันนี้ยังใช้ได้กับโค้ดใดๆ ก็ตามที่มีการแยกเงื่อนไขตามเวลาปัจจุบัน: เพียงแค่ฉีด (inject) provider เข้าไป หลีกเลี่ยงการเรียกใช้ Thread.Sleep หรือ Task.Delay แบบแอบแฝง คุณก็จะได้รับผลการทดสอบที่รวดเร็วและคาดเดาผลได้ (deterministic)

ข้อควรระวัง

  • การหน่วงเวลาทุกครั้งต้องผ่าน TimeProvider ที่ฉีดเข้าไป หากมี Thread.Sleep หรือ Task.Delay ที่หลุดรอดไป มันจะยังคงเรียกใช้นาฬิกาจริงและทำให้เกิดความล่าช้าขึ้นอีกครั้ง
  • หาก library ที่คุณใช้งานอยู่มีการเรียก Task.Delay ภายในโดยไม่มีการเปิดให้ใส่ provider คุณจะไม่สามารถควบคุมเวลาของมันได้ เว้นแต่จะใช้ shim ที่ต้องเข้าไปแทรกแซงมากกว่า ในกรณีเช่นนี้ ประโยชน์ที่ได้รับอาจมีจำกัด
  • ลองใช้ grep ค้นหา Task.Delay และ Thread.Sleep ในโฟลเดอร์การทดสอบเพื่อหาจุดที่มีการรอแบบแอบแฝง การ refactor การเรียกใช้งานเหล่านั้นให้มาใช้ provider คือวิธีเดียวที่จะทำให้การใช้นาฬิกาจำลองมีประสิทธิภาพ

บทสรุป

การแทนที่ system clock ด้วย TimeProvider และการปิดการใช้งาน SynchronizationContext ของ xUnit จะเปลี่ยนการทดสอบ retry ที่เชื่องช้าให้กลายเป็นการตรวจสอบที่เกือบจะทันที ช่วยประหยัดทรัพยากร CI และทำให้โค้ดที่ขึ้นกับเวลาตรวจสอบได้ง่ายขึ้น ความพยายามที่ใช้มีเพียงโค้ดไม่กี่บรรทัด แต่ผลตอบแทนที่ได้คือเวลาหลายวินาทีที่ประหยัดได้ในการรันการทดสอบแต่ละครั้ง