Note: phiên bản Tiếng Việt của bài này ở link dưới.
https://duongnt.com/fakeiteasy-ignored-vie
The FakeItEasy library is my go-to choice whenever I need to create mock objects for my unit tests in C#. One of its features, the Ignored
property, can help us simplify our setup code. But misusing it can easily cripple our tests.
The sample classes
Below are some simple classes for demonstration purposes.
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);
}
}
The temptation of the Ignored property
Write the first test
Because I use Test-Driven-Development (TDD), I often need to mock out dependencies to write unit tests. In this sample, I use FakeItEasy
to mock IRetriver
and IDispatcher
.
It’s easy to configure the mock IRetriever
to return some test data.
var testData = <code to generate test data>;
var retriever = A.Fake<IRetriever>();
A.CallTo(() => retriever.GetData(A<ConfigKeys>.Ignored))
.Returns(testData);
In the configuration above, we can see that the GetData
method takes an A<ConfigKeys>.Ignored
argument. This means that regardless of the configKeys
it receives, GetData
will always return testData
.
Similarly, we can create a mock for IDispatcher
. But we don’t want it to return anything. Instead, we want to verify that it is called exactly once (the actual content of data
is ignored).
var dispatcher = A.Fake<IDispatcher>();
A.CallTo(() => dispatcher.SendData(A<byte[]>._)) // _ is a shorthand for Ignored
.MustHaveHappenedOnceExactly();
After combining everything together, we can verify that the test passed. Our code seems to be correct.
It’s easy to modify our methods if we use Ignored
Let’s say we want to add a new key to ConfigKeys
. It indicates how long IRetriever.GetData
should wait before returning data.
public class ConfigKeys
{
public string Connection { get; set; }
public int TimeoutInSecs { get; set; }
public int DelayInSecs { get; set; } // the new key
}
The Process
method has also changed. Now it accepts a delay
argument and prepends that delay to the data
array before dispatching it.
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 to convert delay value into byte array>
var dataWithPrefix = <code to prepend prefix to data>
await _dispatcher.SendData(dataWithPrefix);
}
Thanks to the Ignored
property, we don’t need to modify our unit test. If you try to run it, you will realize that it will pass as is. But is this really a good thing? Let’s find out in the next sections.
Short-term gain and long-term gain
The problem with matching everything
Attentive readers might already realize a problem with the example above. When using TDD, we are supposed to modify the test to make it fail, then update the code to make it pass again. Yet in the previous section, we didn’t update our test code at all. But the issue runs deeper. Let’s say I make a mistake in Process
.
var configKeys = new ConfigKeys
{
Connection = conn,
TimeoutInMilisecs = timeout,
DelayInSecs = timeout // pass timeout again by mistake
}
var data = await _retriever.GetData(configKeys);
Normally, a delay is only a few seconds, and a timeout is a few minutes. This means our code will wait 60 times longer than we intended. This error should be caught by our unit test, but due to the match-all clause with Ignored
, the test still passes.
// If we pass a configKeys, even if its value is incorrect, retriever still returns testData
A.CallTo(() => retriever.GetData(A<ConfigKeys>.Ignored))
.Returns(testData);
// This line only checks if SendData is called with a byte array. It doesn't verify the content
A.CallTo(() => dispatcher.SendData(A<byte[]>._))
.MustHaveHappenedOnceExactly();
In this case, I consider the unit test to be a liability because it gives us a false sense of safety.
Only match expected arguments
Instead of using Ignored
, we should configure FakeItEasy to match only the arguments we expect. FakeItEasy supports the That
and Matches
method to do this. For our Process
method, the code looks like this.
// code to define 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 to prepend delay value to testData>
A.CallTo(() => dispatcher.SendData(A<byte[]>.That.Matches(b =>
b.SequenceEquals(testDataWithPrefix))))
.MustHaveHappenedOnceExactly();
If you try to run this new test, you will see that it fails. With the new configuration, GetData
won’t return testData
because config.DelayInSecs
does not have the correct attributes. And the check for SendData
will also fail because we verify the content of the byte array we pass to SendData
. This does mean we need to update the test every time we modify that byte array or ConfigKeys
. But considering the cost of letting a bug into production, I think this is a fair trade-off.
Is there any valid application for Ignored?
Having said all that, I still frequently use Ignored
in my unit tests. A prime candidate to use it is when we need to verify that something DID NOT happen. Take the method below, for example.
public async Task SendOrNot(byte[] data, bool flag)
{
if (flag)
{
_dispatcher.SendData(data);
}
}
We can write the test below for the case flag == false
.
var data = <code to create a byte array>
var sut = <code to initialize MyAwesomeClass>
await sut.SendOrNot(data, false);
A.CallTo(() => dispatcher.SendData(A<byte[]>._))
.MustNotHaveHappened();
In this case, no matter what change we make to data
inside SendData
, we can still verify that SendData
is not called if flag == false
.
Conclusion
Unit testing is an excellent safety net for our code. But if we are not careful, it can be crippled without us knowing. Because of that, test code also requires just as much attention as normal code.