Note: see the link below for the English version of this article.
https://duongnt.com/fakeiteasy-ignored
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 IRetriever
và IDispatcher
.
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.
One Thought on “Nhược điểm của Ignored trong FakeItEasy”