Note: see the link below for the English version of this article.

https://duongnt.com/time-dependent-unit-testing

Điều khiển thời gian trong unit test C# với NodaTime

Đôi khi một hàm của ta trả lại kết quả khác nhau tùy thuộc vào từng thời điểm khác nhau. Việc viết unit test cho những hàm như vậy là tương đối phức tạp. Hai yêu cầu cơ bản của một unit test là phải chạy nhanh và phải luôn trả về cùng kết quả nếu code không thay đổi. Nhưng những biện pháp test hàm phụ thuộc thời gian thường khiến test của ta chạy chậm và thiếu ổn định.

May mắn là ta có thể cải thiện vấn đề này bằng các package NodaTimeNodaTime.Testing. Những package đó cho phép ta "điều khiển" thời gian.

Cách thông thường để test một hàm phụ thuộc vào thời gian

Lớp để làm ví dụ

Giả sử ta đang viết một trò chơi điện tử, trong đó ta có lớp dưới đây để quản lý một kỹ năng đặc biệt. Sau khi người dùng sử dụng kỹ năng này, họ phải đợi 5 giây rồi mới có thể dùng lại một lần nữa.

public class Skill
{
    private const int COOL_DOWN_IN_SECS = 5;
    private DateTime _disableUntilDt;

    public Skill() => _disableUntilDt = new DateTime();

    public string Click()
    {
        var now = DateTime.UtcNow;
        if (now > _disableUntilDt)
        {
            _disableUntilDt = now.AddSeconds(COOL_DOWN_IN_SECS);
            return "Thi triển kỹ năng đặc biệt";
        }
        else
        {
            return "Đang trong thời gian hồi kỹ năng";
        }
    }
}

Sử dụng delay để thay đổi kết quả hàm

Ta có thể dùng XUnit để viết unit test dưới đây nhằm đảm bảo rằng ta không thể gọi hàm Click 2 lần trong vòng 5 giây.

[Fact]
public void ShouldNotUseSkillTwiceWithin5Seconds()
{
    var sut = new Skill();
    // Sử dụng kỹ năng, bây giờ ta phải đợi 5 giây cho kỹ năng hồi lại
    sut.Click();

    // Thử dùng lại kỹ năng ngay lập tức
    var rs = sut.Click();

    // Xác nhận ta vẫn trong thời gian hồi kỹ năng
    Assert.Equal("Đang trong thời gian hồi kỹ năng", rs);
}

Nhưng xác nhận việc ta có thể gọi lại hàm Click sau khi hết thời gian hồi kỹ năng là tương đối khó. Thông thường, ta cần bổ sung một lệnh delay ở đây.

[Fact]
public async Task ShouldUseSkillAgainAfterCoolDown()
{
    var sut = new Skill();
    // Sử dụng kỹ năng, bây giờ ta phải đợi 5 giây cho kỹ năng hồi lại
    sut.Click();
    // Dùng lệnh delay để đợi 6 giây
    await Task.Delay(6 * 1000);

    // Thử dùng lại kỹ năng
    var rs = sut.Click();

    // Xác nhận là ta có thể thi triển kỹ năng sau khi hết thời gian hồi
    Assert.Equal("Thi triển kỹ năng đặc biệt", rs);
}

Có lẽ các bạn đã nhận ra vấn đề. Unit test cần phải nhanh, thông thường một test chỉ chạy trong vài mili giây. Nhưng test ở trên lại tốn mất những 6 giây. Thử tưởng tượng xem điều gì sẽ xảy ra nếu kỹ năng của ta có thời gian hồi lớn hơn nữa; hoặc ta cần test nhiều skill.

Code chỉ chạy tại một thời điểm nhất định

Trong một số tình huống khác, ta không thể đợi được đến thời điểm test của ta chạy đúng. Giả sử kỹ năng của ta có 2 phiên bản khác nhau; một bản thông thường và một bản đặc biệt cho đêm Giáng Sinh. Ta thêm hàm dưới đây vào lớp Skill để tải bản phù hợp dựa vào thời điểm kỹ năng được sử dụng.

public string GetAnimation()
{
    var now = DateTime.UtcNow;
    if (now.Month == 12 && now.Day == 24)
    {
        return "Phiên bản đặc biệt";
    }
    else
    {
        return "Phiên bản bình thường";
    }
}

Lúc này ta chỉ có 2 phương án để test hàm. Ta phải đợi đến đúng đêm Giáng Sinh, hoặc ta phải chỉnh đồng hồ của máy. Cả 2 phương án này đều không phù hợp cho việc viết unit test.

Điều khiển thời gian với NodaTime

Giới thiệu package NodaTime

NodaTime là một package được viết bằng .NET. Nó được tạo ra để giải quyết một số nhược điểm của kiểu dữ liệu DateTime.

Các bạn có thể cài nó bằng lệnh sau.

dotnet add package NodaTime

Và các bạn có thể cài NodaTime.Testing bằng lệnh sau.

dotnet add package NodaTime.Testing

Chuyển khái niệm thời gian thành dependency

Bước đầu tiên ta cần làm là thay đổi hàm khởi tạo của lớp Skill để bổ sung interface IClock. Tất cả code liên quan tới thời gian sẽ sử dụng interface này.

private readonly IClock _clock; // IClock được định nghĩa trong NodaTime

public Skill(IClock clock)
{
    _clock = clock;
    _disableUntilDt = new DateTime();
}

Sau đó, thay vì gọi trực tiếp DateTime.UtcNow để lấy giá trị thời gian hiện tại, ta sẽ sử dụng _clock. Tất cả những code sử dụng biến now có thể được giữ nguyên.

var now = _clock.GetCurrentInstant().ToDateTimeUtc();

Sau khi bổ sung dependency này, ta có thể "du hành thời gian" bằng cách truyền một object fake vào hàm khởi tạo của Skill. Thông thường, ta sẽ sử dụng library khác để tạo fake cho IClock (tôi thường dùng package FakeItEasy). Nhưng NodaTime có sẵn một fake đặc biệt với tên gọi là FakeClock. Nó được định nghĩa trong package NodaTime.Testing

Du hành thời gian tới đêm Giáng Sinh

Trong hàm khởi tạo của FakeClock có một argument gọi là Instance. Argument này được dùng để thiết lập giá trị trả về khi ta gọi hàm GetCurrentInstant. Nếu ta truyền thời điểm đêm Giáng Sinh vào argument này, ta sẽ chuyển được thời gian trong các unit test thành đêm Giáng Sinh.

var clock = new FakeClock(Instant.FromUtc(2022, 12, 24, 0, 0, 0)); // Dùng đêm Giáng Sinh năm 2022

Lúc này ta có thể viết unit test cho hàm GetAnimation như sau.

[Fact]
public void ShouldUseSpecialAnimationOnChristmasEve()
{
    // Tạo một FakeClock luôn trả về giá trị 2022/12/24 00:00:00
    var clock = new FakeClock(Instant.FromUtc(2022, 12, 24, 0, 0, 0));
    var sut = new Skill(clock);

    // Gọi hàm GetAnimation
    var rs = sut.GetAnimation();

    // Vì FakeClock luôn trả về thời điểm đêm Giáng Sinh, kết quả khi gọi GetAnimation sẽ là "Phiên bản đặc biệt"
    Assert.Equal("Phiên bản đặc biệt", rs);
}

Rút ngắn thời gian đợi hồi kỹ năng

Nếu như FakeClock luôn trả về một giá trị cố định thì ta không thể test hàm Click. Đó là vì sau lần sử dụng đầu tiên, kỹ năng của ta sẽ luôn nằm trong trạng thái đợi hồi lại. Thật may là FakeClock cho phép ta thay đổi giá trị mà nó trả về. Ta cũng có thể tự quyết định tốc độ chạy của FakeClock.

Trong ví dụ dưới đây, ta cho FakeClock chạy 10 giây. Sau khi gọi hàm AdvanceSeconds, hàm GetCurrentInstant sẽ trả lại thời điểm 10 giây sau thời điểm ta thiết lập trong FakeClock.

clock.AdvanceSeconds(10);

Bằng cách này, ta có thể viết lại ShouldUseSkillAgainAfterCoolDown mà không cần sử dụng delay. Test của ta sẽ chạy xong trong vài mili giây thay vì tốn những 6 giây.

[Fact]
public void ShouldUseSkillAgainAfterCoolDown()
{
    var startTime = DateTime.UtcNow; // Ta không cần thiết lập để FakeClock trả về giá trị đêm Giáng Sinh nữa
    var clock = new FakeClock(Instant.FromDateTimeUtc(startTime));
    var sut = new Skill(clock);
    // Sử dụng kỹ năng lần đầu tiên
    sut.Click();
    // Cho đồng hồ chạy 6 giây
    clock.AdvanceSeconds(6);

    // Thử sử dụng lại kỹ năng
    var rs = sut.Click();

    // Xác nhận là có thể sử dụng kỹ năng
    Assert.Equal("Thi triển kỹ năng đặc biệt", rs);
}

Lớp FakeClock có nhiều hàm để kiểm soát tốc độ chạy với những mức độ chi tiết khác nhau. Ta có thể cho đồng hồ chạy vài giây, vài phút, vài ngày,… Các bạn có thể tham khảo link này để biết thêm chi tiết.

Thiết lập FakeClock để tự động chạy một khoảng thời gian cố định sau mỗi lần gọi

Nếu ta muốn FakeClock tự động chạy một khoảng thời gian cố định sau mỗi lần gọi thì việc gọi hàm AdvanceSeconds lặp đi lặp lại là không cần thiết. Khi đó, ta có thể sử dụng argument autoAdvance trong hàm khởi tạo của FakeClock. Trong ví dụ dưới, FakeClock sẽ tự động chạy 10 giây sau mỗi lần ta sử dụng nó.

var startTime = DateTime.UtcNow;
var advanceTenSecs = Duration.FromSeconds(10);
var clock = new FakeClock(Instant.FromDateTimeUtc(startTime), advanceTenSecs);

Ta có thể rút gọn ShouldUseSkillAgainAfterCoolDown hơn nữa.

[Fact]
public void ShouldUseSkillAgainAfterCoolDown()
{
    var startTime = DateTime.UtcNow;
    var advanceTenSecs = Duration.FromSeconds(10);
    var clock = new FakeClock(Instant.FromDateTimeUtc(startTime), advanceTenSecs);
    var sut = new Skill(clock);
    // Sử dụng kỹ năng
    sut.Click();

    // Thử sử dụng lại kỹ năng (đồng hồ đã tự động chạy 10 giây)
    var rs = sut.Click();

    // Xác nhận là có thể sử dụng kỹ năng
    Assert.Equal("Thi triển kỹ năng đặc biệt", rs);
}

Đảm bảo test luôn trả về một kết quả

Khi viết ShouldNotUseSkillTwiceWithin5Seconds, ta ngầm định là thời gian giữa 2 lần gọi hàm Click là ngắn hơn thời gian hồi kỹ năng. Trong phần lớn các trường hợp, điều này là đúng. Đó là vì thời gian hồi kỹ năng của ta là 5 giây, đối với CPU đây là quãng thời gian dài. Nhưng nếu ta rút ngắn thời gian hồi kỹ năng còn 100ms hay 10ms thì sao? Lúc này test của ta sẽ chạy lúc đúng lúc sai, vì có trường hợp thời gian hồi kỹ năng sẽ kết thúc trước khi ta kịp gọi hàm Click lần 2.

Ta có thể đảm bảo rằng test trên luôn luôn chạy đúng bằng cách quy định rõ khoảng cách giữa 2 lệnh Click.

public void ShouldNotUseSkillTwiceWithin5Seconds()
{
    var startTime = DateTime.UtcNow;
    var clock = new FakeClock(Instant.FromDateTimeUtc(startTime));
    var sut = new Skill(clock);
    // Gọi kỹ năng lần đầu tiên
    sut.Click();

    // Cho đồng hồ chạy 5ms (ta giả định thời gian hồi kỹ năng là 10ms)
    clock.AdvanceMilliseconds(5);
    // Thử gọi kỹ năng một lần nữa
    var rs = sut.Click();

    // Xác nhận là kỹ năng vẫn trong thời gian chờ
    Assert.Equal("Đang trong thời gian hồi kỹ năng", rs);
}

Kết thúc

NodaTime và lớp FakeClock là lựa chọn hàng đầu của tôi mỗi khi tôi cần thực hiện thao tác trên ngày tháng hay thời gian. Nhờ đó, tôi không phải suy nghĩ nhiều khi viết các test phụ thuộc vào thời điểm chạy.

A software developer from Vietnam and is currently living in Japan.

One Thought on “Điều khiển thời gian trong unit test C#”

Leave a Reply