如何在單元測試中優雅地Mock ILogger

在撰寫單元測試時,難免會遇到需要測物件和Logger互動的情況,有些類別需要Logger才能完成他的職責。舉個情境,我們希望Printer Class可以在Log中記錄印了幾頁。

public class Printer
{
    private readonly ILogger<Printer> _logger;

    public Printer(ILogger<Printer> logger)
    {
        _logger = logger;
    }

    public void Print(IEnumerable<Page> pages)
    {
       _logger.LogInformation(pages.Count().ToString()); 
    }
}

身為一個單元測試愛好者,當然要寫測試驗證功能符合需求。我們使用NSub將ILogger的介面mock掉,並在Print(pages)結束後,檢查列印的結果。

public class Tests
{
    [Test]
    public void should_log_url()
    {
        var pages = new List<Page>()
        {
            new Page(),
            new Page()
        };
        // Mock Logger
        var logger = Substitute.For<ILogger<Printer>>();
        var printer = new Printer(logger);
        printer.Print(pages);

        // Verify, it's fine
        logger.Received(1).LogInformation("2");
    }
}

上面的測試會正常通過,但僅限於這種參數完全一樣比對的情況,如果遇到複雜一點,需要用到Arg.Is加上Lambda expression的時候,會出現問題。

public class Tests
{
    [Test]
    public void should_log_url()
    {
        var pages = new List<Page>()
        {
            new Page(),
            new Page()
        };
        var logger = Substitute.For<ILogger<Printer>>();
        var printer = new Printer(logger);
        printer.Print(pages);

        // this not works
        logger.Received(1).LogInformation(
            Arg.Is<string>(x => x.Contains("2")));
    }
}

此時測試會拋出落落長的例外訊息

NSubstitute.Exceptions.RedundantArgumentMatcherException : 
Some argument specifications (e.g. Arg.Is, Arg.Any) were 
left over after the last call.

This is often caused by using an argument spec with a call
to a member NSubstitute does not handle(such as a non-virtual
member or a call to an instance which is not a substitute),
or for a purpose other than specifying a call (such as using
an arg spec as a return value). For example:

    var sub = Substitute.For<SomeClass>();
    var realType = new MyRealType(sub);
    // INCORRECT, arg spec used on realType, not a substitute:
    realType.SomeMethod(Arg.Any<int>()).Returns(2);
    // INCORRECT, arg spec used as a return value, not to specify a call:
    sub.VirtualMethod(2).Returns(Arg.Any<int>());
    // INCORRECT, arg spec used with a non-virtual method:
    sub.NonVirtualMethod(Arg.Any<int>()).Returns(2);
    // CORRECT, arg spec used to specify virtual call on a substitute:
    sub.VirtualMethod(Arg.Any<int>()).Returns(2);

To fix this make sure you only use argument specifications with calls 
to substitutes. If your substitute is a class, make sure the member is virtual.

Another possible cause is that the argument spec type does not
match the actual argument type, but code compiles due to an implicit cast. For example, Arg.Any<int>() was used, but Arg.Any<double>() was required.

NOTE: the cause of this exception can be in a previously executed test. 
Use the diagnostics below to see the types of any redundant arg specs, then work out where they are being created.

這個錯誤訊息的意思是說,你對某個method用了Arg.Is或Arg.Any,但因為寫法有問題,所以Arg.Is或Arg.Any並沒有發生作用。

NSubstitute只能夠替換掉virtual method,而_Logger.LogInformation是個extension method,因此不能mock掉。

可是第一個測試是成功的,之前的logger.Received(1).LogInformation("2")是怎麼mock的呢?

實際上,你mock掉的是ILogger<TCategory>介面上的的Log<TState>方法。

public void Log<TState> (
    Microsoft.Extensions.Logging.LogLevel logLevel,
    Microsoft.Extensions.Logging.EventId eventId,
    TState state,
    Exception exception,
    Func<TState,Exception,string> formatter);

大概等價於

logger.Receive(1).Log<FormattedLogValues>
    (Information, 0, 2, <null>, Func<FormattedLogValues, Exception, String>)

但實際上,上面這一段的寫法是不work的。FormattedLogValues是Log Library的internal class,沒辦法也不該直接在你的測試中使用拿來用的。

logger.Received(1).Log<FormattedLogValues>(...);  // this is not work

想要優雅Mock ILogger,我們可以使用一個抽象類,實作ILogger<T>

public abstract class AbstractTestLogger<T>: ILogger<T>
{
    // 這是關鍵,我們實作了Log<TState>,讓呼叫端可以
    // 自由的使用Log<FormattedLogValues>
    // 同時,我們把該呼叫轉呼叫給另一個abstract method。
    public void Log<TState>(LogLevel logLevel, 
        EventId eventId, 
        TState state, 
        Exception exception, 
        Func<TState, Exception, string> formatter)
    {
        Log(logLevel, exception, formatter(state, exception));
    }

    public bool IsEnabled(LogLevel logLevel)
    {
        return true;
    }

    public IDisposable BeginScope<TState>(TState state)
    {
        throw new NotImplementedException();
    }

    public abstract void Log(
        LogLevel logLevel, 
        Exception exception, 
        string content);
}

之後,測試就可以優雅地使用NSub提供的Argument Matcher語法,做LogLevel和內容的判斷。

[Test]
public void should_log_url()
{
    var pages = new List<Page>()
    {
        new Page(),
        new Page()
    };
    var logger = Substitute.For<AbstractTestLogger<Printer>>();
    var printer = new Printer(logger);
    printer.Print(pages);

    // happy mock
    logger.Received(1).Log(
        LogLevel.Information, 
        null, 
        Arg.Is<string>(x => x.Contains("2")));
}

希望這篇文幫助到需要Mock ILogger介面的朋友:),一起優雅地寫測試吧

Reference:
https://medium.com/@whuysentruit/unit-testing-ilogger-in-asp-net-core-9a2d066d0fb8

2
Leave a Reply

avatar
1 Comment threads
1 Thread replies
0 Followers
 
Most reacted comment
Hottest comment thread
2 Comment authors
opassCash Recent comment authors
  Subscribe  
newest oldest most voted
Notify of
Cash
Guest
Cash

提外話,在 .NET Core 裡面,如果你不管 logger 的話,內建有一個 Nulllogger 可以用