TimeProvider của .NET 8 và một tinh chỉnh nhỏ với SynchronizationContext có thể cắt giảm hàng giây trong các bài kiểm tra đơn vị (unit test) cho logic thử lại (retry-logic) vốn thường phải chờ đợi vô ích do các lệnh sleep thực tế. Một bài kiểm tra từng mất bảy giây giờ đây chỉ mất vài chục mili giây, và thay đổi này không tốn thêm bất kỳ chi phí nào khi chạy trong môi trường production.
Tại sao sự chậm trễ này lại quan trọng
Nhiều trình hỗ trợ thử lại (retry helpers) sử dụng cơ chế exponential backoff: đợi 1 giây, sau đó 2 giây, rồi 4 giây, v.v. Trong môi trường production, điều này giúp bảo vệ các dịch vụ khỏi việc gửi dồn dập yêu cầu đến một endpoint đang bị lỗi. Trong một bộ kiểm thử (test suite), cùng đoạn mã đó sẽ gọi Task.Delay trên đồng hồ thực, khiến luồng kiểm thử (test thread) thực sự phải ngủ. Nhân con số đó với hàng chục bài kiểm tra và quy trình tích hợp liên tục (CI) sẽ bị kéo dài thêm nhiều phút mà không mang lại lợi ích chức năng nào. "Thuế ngủ" (sleep tax) này hoàn toàn là thời gian lãng phí.
Cách TimeProvider hoạt động
.NET 8 đã giới thiệu TimeProvider, một lớp trừu tượng hóa (abstraction) trên đồng hồ hệ thống. Một lớp cần thời gian hiện tại hoặc cần trì hoãn có thể nhận một thực thể (instance) TimeProvider thay vì gọi trực tiếp DateTime.UtcNow hoặc Task.Delay. Trong môi trường production, bạn truyền vào TimeProvider.System, cái sẽ chuyển tiếp đến đồng hồ thực. Trong một bài kiểm tra, bạn cung cấp một FakeTimeProvider. Bản giả lập (fake) này cho phép bạn tiến thời gian một cách thủ công bằng Advance(TimeSpan). Về mặt nội bộ, Task.Delay sẽ đọc từ provider này, vì vậy việc đẩy đồng hồ giả lên phía trước sẽ ngay lập tức thỏa mãn bất kỳ lệnh trì hoãn nào đang chờ xử lý.
Ý tưởng rất đơn giản: thay thế đồng hồ thực bằng một đồng hồ có thể kiểm soát được, sau đó nhảy vọt đến thời điểm mà mã đang được kiểm thử sẽ tiếp tục chạy.
Cái bẫy SynchronizationContext của xUnit
Khái niệm này hoạt động tốt trong một ứng dụng console, nhưng trong xUnit, nó lại gây ra tình trạng deadlock (khóa chết). Bài kiểm tra gọi Advance() trong khi trình hỗ trợ thử lại đang chờ (awaiting) một lệnh trì hoãn. xUnit cài đặt SynchronizationContext riêng của nó để nắm bắt các phần tiếp nối (continuations) và chạy chúng trên luồng kiểm thử. Khi Advance() đẩy đồng hồ về phía trước, phần tiếp nối đã được đưa vào hàng đợi của thread pool thay vì luồng kiểm thử. Luồng kiểm thử tiếp tục đẩy thời gian lên, khiến đồng hồ giả vượt quá thời điểm mà lần thử lại tiếp theo đáng lẽ phải kích hoạt. Bộ hẹn giờ mới được thiết lập cho một thời điểm trong tương lai mà sẽ không bao giờ tới được, vì không còn luồng nào để đẩy đồng hồ đi tiếp nữa. Bài kiểm tra bị treo vô thời hạn.
Cách khắc phục chỉ với một dòng mã
Thêm một dòng duy nhất vào đầu bài kiểm tra sẽ khôi phục lại hành vi mong đợi:
SynchronizationContext.SetSynchronizationContext(null);
Việc xóa bỏ context tùy chỉnh sẽ buộc các phần tiếp nối của await phải chạy trên thread pool, nơi các callback của bộ hẹn giờ giả có thể thực thi. Với thay đổi đó, cùng một bài kiểm tra sẽ giảm từ bảy giây xuống còn khoảng 36 mili giây.
Những lợi ích rộng hơn
Ngoài việc loại bỏ các lệnh sleep chờ đợi vô ích, một đồng hồ giả còn giúp việc kiểm thử các logic nhạy cảm với múi giờ trở nên dễ dàng. Bạn có thể xác minh rằng hạn mức hàng ngày được đặt lại vào đúng nửa đêm giờ địa phương mà không cần đợi đồng hồ thực điểm mười hai giờ. Cách tiếp cận tương tự cũng áp dụng cho bất kỳ mã nào có rẽ nhánh dựa trên thời gian hiện tại: hãy inject provider, tránh các lời gọi ẩn đến Thread.Sleep hoặc Task.Delay, và bạn sẽ có được các bài kiểm tra nhanh chóng và có tính xác định (deterministic).
Những điều cần lưu ý
- Mọi lệnh trì hoãn phải được điều hướng thông qua
TimeProviderđã được inject. Một lệnhThread.Sleeplạc lõng hoặc một lệnhTask.Delaytrực tiếp vẫn sẽ gọi đồng hồ thực và gây ra độ trễ trở lại. - Nếu một thư viện mà bạn phụ thuộc vào gọi
Task.Delaybên trong mà không cung cấp một provider, bạn không thể kiểm soát thời gian của nó nếu không sử dụng một lớp shim can thiệp sâu hơn. Trong những trường hợp như vậy, lợi ích có thể bị hạn chế. - Hãy dùng lệnh grep trong thư mục kiểm thử để tìm
Task.DelayvàThread.Sleepnhằm phát hiện các lệnh chờ ẩn. Việc tái cấu trúc (refactoring) các lời gọi đó để sử dụng provider là cách duy nhất để giữ cho đồng hồ giả hoạt động hiệu quả.
Bài học rút ra
Thay thế đồng hồ hệ thống bằng TimeProvider và vô hiệu hóa SynchronizationContext của xUnit sẽ biến các bài kiểm tra thử lại chậm chạp thành các bước kiểm tra gần như tức thời, giải phóng tài nguyên CI và giúp mã nguồn phụ thuộc vào thời gian dễ dàng được xác minh hơn. Công sức bỏ ra chỉ là vài dòng mã; nhưng thành quả thu lại được tính bằng số giây tiết kiệm được trong mỗi lần chạy kiểm thử.
