Note: phiên bản Tiếng Việt của bài này ở link dưới.

https://duongnt.com/fakeiteasy-ignored-vie

FakeItEasy Ignored might be harmful

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.

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

Leave a Reply