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

https://duongnt.com/fakeiteasy-ignored

FakeItEasy Ignored considered harmful

Tôi thường dùng FakeItEasy để tạo mock object khi viết unit test bằng C#. Thư viện này có một property gọi là Ignored, nó giúp ta đơn giản hóa test code. Nhưng việc lạm dụng nó có thể khiến unit test của ta trở thành vô nghĩa.

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

Ta định nghĩa một vài lớp dưới đây để dùng trong các ví dụ.

public class ConfigKeys
{
    public string Connection { get; set; }
    public int TimeoutInSecs { get; set; }
}

public interface IRetriever
{
    Task<byte[]> GetData(ConfigKeys configKeys);
}

public interface IDispatcher
{
    Task SendData(byte[] data);
}

public class MyAwesomeClass
{
    private readonly IRetriever _retriever;
    private readonly IDispatcher _dispatcher;
    
    public AwesomeClass(IRetriever retriever, IDispatcher dispatcher)
    {
        _retriever = retriever;
        _dispatcher = dispatcher
    }

    public async Task<byte[]> Process(string conn, int timeout)
    {
        var configKeys = new ConfigKeys
        {
            Connection = conn,
            TimeoutInMilisecs = timeout
        }
        var data = await _retriever.GetData(configKeys);
        await _dispatcher.SendData(data);
    }
}

Dùng property Ignored rất tiện

Viết test đầu tiên

Vì tôi thường xuyên sử dụng Test-Driven-Development (TDD) nên tôi hay phải tạo mock cho các dependency khi viết unit test. Trong ví dụ hôm nay, chúng ta sẽ dùng FakeItEasy để tạo mock cho IRetrieverIDispatcher.

Nhờ FakeItEasy, ta dễ dàng khiến mock object trả về dữ liệu test.

var testData = <code để sinh dữ liệu test>;
var retriever = A.Fake<IRetriever>();
A.CallTo(() => retriever.GetData(A<ConfigKeys>.Ignored))
    .Returns(testData);

Trong đoạn code trên, chúng ta thấy rằng GetData nhận tham số A<ConfigKeys>.Ignored. Có nghĩa là GetData luôn trả về testData bất kể giá trị của configKeys ra sao.

Tương tự trên, ta tạo mock cho IDispatcher. Nhưng ta không cần trả về giá trị nào cả. Thay vào đó, ta muốn kiểm tra là hàm SendData được gọi đúng 1 lần (gọi với giá trị nào thì không quan tâm).

var dispatcher = A.Fake<IDispatcher>();
A.CallTo(() => dispatcher.SendData(A<byte[]>._)) // _ là viết tắt cho Ignored
    .MustHaveHappenedOnceExactly();

Khi kết hợp các đoạn code trên, test của ta sẽ chạy thông. Có vẻ là hàm Process đã chạy đúng.

Nếu dùng Ignored thì việc thay đổi hàm sẽ đơn giản hơn

Bây giờ giả sử ta muốn thêm một trường mới vào ConfigKeys. Trường này lưu thời gian GetData sẽ đợi trước khi trả về dữ liệu.

public class ConfigKeys
{
    public string Connection { get; set; }
    public int TimeoutInSecs { get; set; }
    public int DelayInSecs { get; set; } // trường mới 
}

Hàm Process cũng cần được cập nhật. Nó sẽ nhận thêm tham số delay, và nó sẽ gắn giá trị của delay vào đầu mảng data trước khi gửi đi.

public async Task<byte[]> Process(string conn, int timeout, int delay)
{
    var configKeys = new ConfigKeys
    {
        Connection = conn,
        TimeoutInMilisecs = timeout,
        DelayInSecs = delay
    }
    var data = await _retriever.GetData(configKeys);
    var prefix = <code để chuyển convert sang byte array>
    var dataWithPrefix = <code để gắn giá trị của delay vào đầu mảng data>
    await _dispatcher.SendData(dataWithPrefix);
}

Vì ta dùng Ignored, ta không cần cập nhật unit test. Nếu thử chạy test này, các bạn sẽ thấy nó vẫn chạy thông. Nhưng đây có thực sự là điều tốt hay không? Chúng ta sẽ tìm hiểu trong phần tiếp theo.

Lợi ích trong ngắn hạn và lợi ích trong dài hạn

Vấn đề khi dùng Ignored

Có lẽ các bạn cũng đã nhận ra vấn đề trong ví dụ trên. Khi thực hiện TDD, khi muốn cập nhật code ta cần thay đổi test trước để cho nó chạy lỗi. Sau đó ta mới cập nhật code để test lại chạy đúng. Nhưng trong ví dụ trên, ta không cần thay đổi code test một chút nào. Giả sử là ta viết sai hàm Process như sau.

var configKeys = new ConfigKeys
{
    Connection = conn,
    TimeoutInMilisecs = timeout,
    DelayInSecs = timeout // truyền nhầm timeout một lần nữa
}
var data = await _retriever.GetData(configKeys);

Thông thường, giá trị chờ chỉ vào khoảng vài giây, còn giá trị timeout là khoảng vài phút. Nghĩa là đoạn code trên sẽ thực hiện chờ dài hơn 60 lần so với mong muốn của ta. Lẽ ra unit test cần phải phát hiện ra lỗi này. Nhưng vì ta đã dùng Ignored nên test vẫn chạy thông.

// Chỉ cần ta truyền vào configKeys, cho dù giá trị đó có sai thì retriever vẫn trả về testData
A.CallTo(() => retriever.GetData(A<ConfigKeys>.Ignored))
    .Returns(testData);

// Dòng này chỉ kiểm tra xem SendData có được gọi cùng với một byte array không. Nó không kiểm tra nội dung array đó.
A.CallTo(() => dispatcher.SendData(A<byte[]>._))
    .MustHaveHappenedOnceExactly();

Trong trường hợp này, tôi nghĩ rằng unit test không những không có ích mà còn có hại. Vì nó khiến ta chủ quan cho rằng code đã đúng.

Chỉ rõ giá trị cần có của các tham số

Thay vì dùng Ignored, ta nên thiết lập FakeItEasy để kiểm tra cả giá trị của argument. FakeItEasy cung cấp hàm That.Matches để thực hiện điều này. Với hàm Process ở trên, ta có thể viết code như sau.

// code khởi tạo conn/timeout/delay

A.CallTo(() => retriever.GetData(A<ConfigKeys>.That.Matches(config =>
    config.Connection == conn &&
    config.TimeoutInMilisecs == timeout &&
    config.DelayInSecs == delay)))
        .Returns(testData);

var testDataWithPrefix = <code để gắn giá trị delay vào đầu testData>
A.CallTo(() => dispatcher.SendData(A<byte[]>.That.Matches(b =>
    b.SequenceEquals(testDataWithPrefix))))
        .MustHaveHappenedOnceExactly();

Khi chạy phiên bản test mới, bạn sẽ thấy nó báo lỗi. Lúc này GetData sẽ không trả về testData nữa vì giá trị config.DelayInSecs là sai. Và dòng kiểm tra SendData cũng sẽ trả lại lỗi vì ta có kiểm tra nội dung byte array truyền vào SendData. Điều này cũng có nghĩa là ta sẽ phải cập nhật test mỗi khi ta thay đổi byte array đó hoặc thay đổi ConfigKeys. Nhưng tôi tin rằng như thế vẫn tốt hơn là để bug lọt vào code production.

Nên dùng Ignored trong trường hợp nào?

Mặc dù cần phải chú ý các điểm trên, tôi vẫn thường xuyên sử dụng Ignored khi viết unit test. Tình huống thích hợp nhất để sử dụng nó là khi ta cần xác thực rằng một điều gì đó KHÔNG xảy ra, như trong ví dụ dưới đây.

public async Task SendOrNot(byte[] data, bool flag)
{
    if (flag)
    {
        _dispatcher.SendData(data);
    }
}

Ta có thể viết test cho trường hợp flag == false như sau.

var data = <code để tạo byte array>
var sut = <code để khởi tạo MyAwesomeClass>

await sut.SendOrNot(data, false);

A.CallTo(() => dispatcher.SendData(A<byte[]>._))
    .MustNotHaveHappened();

Lúc này, ta có thể đảm bảo rằng hàm SendData không được gọi khi flag == false mà không cần quan tâm tới giá trị của data.

Kết thúc

Ta có thể coi unit test là bảo hiểm cho code. Nhưng nếu bất cẩn thì ta có thể khiến test trở thành vô nghĩa mà không hề hay biết. Bởi vậy, khi viết test code ta cũng phải cẩn thận như khi viết code thông thường vậy.

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

One Thought on “Nhược điểm của Ignored trong FakeItEasy”

Leave a Reply