Cara menguji unit dengan ILogger di ASP.NET Core

129

Ini pengontrol saya:

public class BlogController : Controller
{
    private IDAO<Blog> _blogDAO;
    private readonly ILogger<BlogController> _logger;

    public BlogController(ILogger<BlogController> logger, IDAO<Blog> blogDAO)
    {
        this._blogDAO = blogDAO;
        this._logger = logger;
    }
    public IActionResult Index()
    {
        var blogs = this._blogDAO.GetMany();
        this._logger.LogInformation("Index page say hello", new object[0]);
        return View(blogs);
    }
}

Seperti yang Anda lihat, saya memiliki 2 dependensi, a IDAOdan aILogger

Dan ini adalah kelas pengujian saya, saya menggunakan xUnit untuk menguji dan Moq untuk membuat tiruan dan rintisan, saya dapat mengejek dengan DAOmudah, tetapi dengan ILoggersaya tidak tahu apa yang harus dilakukan jadi saya hanya memberikan null dan mengomentari panggilan untuk masuk pengontrol saat uji coba. Apakah ada cara untuk menguji tetapi tetap mempertahankan logger?

public class BlogControllerTest
{
    [Fact]
    public void Index_ReturnAViewResult_WithAListOfBlog()
    {
        var mockRepo = new Mock<IDAO<Blog>>();
        mockRepo.Setup(repo => repo.GetMany(null)).Returns(GetListBlog());
        var controller = new BlogController(null,mockRepo.Object);

        var result = controller.Index();

        var viewResult = Assert.IsType<ViewResult>(result);
        var model = Assert.IsAssignableFrom<IEnumerable<Blog>>(viewResult.ViewData.Model);
        Assert.Equal(2, model.Count());
    }
}
duc
sumber
1
Anda dapat menggunakan tiruan sebagai rintisan, seperti yang disarankan Ilya, jika Anda tidak benar-benar mencoba menguji apakah metode logging itu sendiri dipanggil. Jika itu masalahnya, mengejek logger tidak berhasil, dan Anda dapat mencoba beberapa pendekatan berbeda. Saya telah menulis artikel pendek yang menunjukkan berbagai pendekatan. Artikel ini menyertakan repo GitHub lengkap dengan masing-masing opsi berbeda . Pada akhirnya, rekomendasi saya adalah menggunakan adaptor Anda sendiri daripada bekerja langsung dengan tipe ILogger <T>, jika Anda perlu untuk bisa
ssmith
Seperti yang disebutkan @ssmith, ada beberapa masalah dengan memverifikasi panggilan sebenarnya untuk ILogger. Dia memiliki beberapa saran bagus di posting blognya dan saya telah datang dengan solusi saya yang tampaknya menyelesaikan sebagian besar masalah dalam jawaban di bawah ini .
Ilya Chernomordik

Jawaban:

141

Hanya mengejeknya serta ketergantungan lainnya:

var mock = new Mock<ILogger<BlogController>>();
ILogger<BlogController> logger = mock.Object;

//or use this short equivalent 
logger = Mock.Of<ILogger<BlogController>>()

var controller = new BlogController(logger);

Anda mungkin perlu menginstal Microsoft.Extensions.Logging.Abstractionspaket untuk digunakan ILogger<T>.

Selain itu, Anda dapat membuat logger nyata:

var serviceProvider = new ServiceCollection()
    .AddLogging()
    .BuildServiceProvider();

var factory = serviceProvider.GetService<ILoggerFactory>();

var logger = factory.CreateLogger<BlogController>();
Ilya Chumakov
sumber
5
untuk masuk ke jendela keluaran debug, panggil AddDebug () di pabrik: var factory = serviceProvider.GetService <ILoggerFactory> () .AddDebug ();
spottedmahn
3
Saya menemukan pendekatan "penebang nyata" lebih efektif!
DanielV
1
Bagian logger yang sebenarnya juga berfungsi dengan baik untuk menguji LogConfiguration dan LogLevel dalam skenario tertentu.
Martin Lottering
Pendekatan ini hanya akan mengizinkan stub, tetapi tidak untuk verifikasi panggilan. Saya datang dengan solusi saya yang tampaknya menyelesaikan sebagian besar masalah dengan verifikasi dalam jawaban di bawah ini .
Ilya Chernomordik
102

Sebenarnya, saya telah menemukan Microsoft.Extensions.Logging.Abstractions.NullLogger<>solusi yang tampaknya sempurna. Instal paketnya Microsoft.Extensions.Logging.Abstractions, lalu ikuti contoh untuk mengkonfigurasi dan menggunakannya:

using Microsoft.Extensions.Logging;

public void ConfigureServices(IServiceCollection services)
{
    ...

    services.AddSingleton<ILoggerFactory, NullLoggerFactory>();

    ...
}
using Microsoft.Extensions.Logging;

public class MyClass : IMyClass
{
    public const string ErrorMessageILoggerFactoryIsNull = "ILoggerFactory is null";

    private readonly ILogger<MyClass> logger;

    public MyClass(ILoggerFactory loggerFactory)
    {
        if (null == loggerFactory)
        {
            throw new ArgumentNullException(ErrorMessageILoggerFactoryIsNull, (Exception)null);
        }

        this.logger = loggerFactory.CreateLogger<MyClass>();
    }
}

dan uji unit

//using Microsoft.VisualStudio.TestTools.UnitTesting;
//using Microsoft.Extensions.Logging;

[TestMethod]
public void SampleTest()
{
    ILoggerFactory doesntDoMuch = new Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory();
    IMyClass testItem = new MyClass(doesntDoMuch);
    Assert.IsNotNull(testItem);
}   
Amir Shitrit
sumber
Ini tampaknya hanya berfungsi untuk .NET Core 2.0, bukan .NET Core 1.1.
Thorkil Værge
3
@adospace, komentar Anda jauh lebih berguna daripada jawabannya
johnny 5
Dapatkah Anda memberikan contoh bagaimana ini akan berhasil? Saat pengujian unit, saya ingin log muncul di jendela keluaran, saya tidak yakin apakah ini melakukannya.
J86
@adospace Apakah ini seharusnya masuk ke startup.cs?
raklos
1
@raklos hum, tidak, itu seharusnya digunakan dalam metode startup dalam pengujian di mana ServiceCollection dibuat
adospace
32

Gunakan logger khusus yang menggunakan ITestOutputHelper(dari xunit) untuk menangkap keluaran dan log. Berikut ini adalah contoh kecil yang hanya menulis stateke output.

public class XunitLogger<T> : ILogger<T>, IDisposable
{
    private ITestOutputHelper _output;

    public XunitLogger(ITestOutputHelper output)
    {
        _output = output;
    }
    public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
    {
        _output.WriteLine(state.ToString());
    }

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

    public IDisposable BeginScope<TState>(TState state)
    {
        return this;
    }

    public void Dispose()
    {
    }
}

Gunakan di unittests Anda seperti

public class BlogControllerTest
{
  private XunitLogger<BlogController> _logger;

  public BlogControllerTest(ITestOutputHelper output){
    _logger = new XunitLogger<BlogController>(output);
  }

  [Fact]
  public void Index_ReturnAViewResult_WithAListOfBlog()
  {
    var mockRepo = new Mock<IDAO<Blog>>();
    mockRepo.Setup(repo => repo.GetMany(null)).Returns(GetListBlog());
    var controller = new BlogController(_logger,mockRepo.Object);
    // rest
  }
}
Jehof
sumber
1
Hai. ini bekerja dengan baik untuk saya. sekarang bagaimana saya dapat memeriksa atau melihat informasi log saya
malik saifullah
saya menjalankan kasus uji unit langsung dari VS. saya tidak memiliki konsol untuk itu
malik saifullah
1
@maliksaifullah im using resharper. izinkan saya memeriksanya dengan vs
Jehof
1
@maliksaifullah, TestExplorer VS menyediakan link untuk membuka keluaran pengujian. pilih pengujian Anda di TestExplorer dan di bagian bawah ada tautan
Jehof
1
Ini bagus, terima kasih! Beberapa saran: 1) ini tidak perlu generik, karena parameter type tidak digunakan. Menerapkan hanya ILoggerakan membuatnya dapat digunakan secara lebih luas. 2) BeginScopeSeharusnya tidak kembali sendiri, karena itu berarti setiap metode yang diuji yang memulai dan mengakhiri cakupan selama proses akan membuang pencatat. Sebagai gantinya, buat kelas bertingkat "dummy" pribadi yang mengimplementasikan IDisposabledan mengembalikan instance dari itu (lalu hapus IDisposabledari XunitLogger).
Tobias J
27

Untuk jawaban .net core 3 yang menggunakan Moq

Untungnya stakx memberikan solusi yang bagus . Jadi saya mempostingnya dengan harapan dapat menghemat waktu untuk orang lain (butuh beberapa saat untuk mencari tahu):

 loggerMock.Verify(
                x => x.Log(
                    LogLevel.Information,
                    It.IsAny<EventId>(),
                    It.Is<It.IsAnyType>((o, t) => string.Equals("Index page say hello", o.ToString(), StringComparison.InvariantCultureIgnoreCase)),
                    It.IsAny<Exception>(),
                    (Func<It.IsAnyType, Exception, string>) It.IsAny<object>()),
                Times.Once);
Ivan Samygin
sumber
Anda menyelamatkan hari saya .. Terima kasih.
KiddoDeveloper
15

Menambahkan 2 sen saya, Ini adalah metode ekstensi pembantu yang biasanya dimasukkan ke dalam kelas pembantu statis:

static class MockHelper
{
    public static ISetup<ILogger<T>> MockLog<T>(this Mock<ILogger<T>> logger, LogLevel level)
    {
        return logger.Setup(x => x.Log(level, It.IsAny<EventId>(), It.IsAny<object>(), It.IsAny<Exception>(), It.IsAny<Func<object, Exception, string>>()));
    }

    private static Expression<Action<ILogger<T>>> Verify<T>(LogLevel level)
    {
        return x => x.Log(level, 0, It.IsAny<object>(), It.IsAny<Exception>(), It.IsAny<Func<object, Exception, string>>());
    }

    public static void Verify<T>(this Mock<ILogger<T>> mock, LogLevel level, Times times)
    {
        mock.Verify(Verify<T>(level), times);
    }
}

Kemudian, Anda menggunakannya seperti ini:

//Arrange
var logger = new Mock<ILogger<YourClass>>();
logger.MockLog(LogLevel.Warning)

//Act

//Assert
logger.Verify(LogLevel.Warning, Times.Once());

Dan tentu saja Anda dapat dengan mudah memperluasnya untuk mengejek ekspektasi apa pun (mis. Ekspektasi, pesan, dll…)

Mahmoud Hanafy
sumber
Ini adalah solusi yang sangat elegan.
MichaelDotKnox
Saya setuju, jawaban ini sangat bagus. Saya tidak mengerti mengapa tidak memiliki banyak suara
Farzad
1
Luar biasa. Ini versi untuk non-generik ILogger: gist.github.com/timabell/d71ae82c6f3eaa5df26b147f9d3842eb
Tim Abell
Apakah mungkin membuat tiruan untuk memeriksa string yang kami berikan di LogWarning? Misalnya:It.Is<string>(s => s.Equals("A parameter is empty!"))
Serhat
Ini sangat membantu. Bagian yang hilang bagi saya adalah bagaimana saya bisa mengatur callback pada tiruan yang menulis ke output XUnit? Tidak pernah menelepon balik untuk saya.
flipdoubt
6

Sangat mudah karena jawaban lain menyarankan untuk melewatkan tiruan ILogger, tetapi tiba-tiba menjadi jauh lebih bermasalah untuk memverifikasi bahwa panggilan benar-benar dibuat ke logger. Alasannya adalah bahwa sebagian besar panggilan sebenarnya bukan milik ILoggerantarmuka itu sendiri.

Jadi, sebagian besar panggilan adalah metode ekstensi yang memanggil satu-satunya Logmetode antarmuka. Alasannya tampaknya adalah jauh lebih mudah untuk membuat implementasi antarmuka jika Anda hanya memiliki satu dan tidak banyak kelebihan beban yang bermuara pada metode yang sama.

Kekurangannya adalah tiba-tiba lebih sulit untuk memverifikasi bahwa panggilan telah dibuat karena panggilan yang harus Anda verifikasi sangat berbeda dari panggilan yang Anda buat. Ada beberapa pendekatan berbeda untuk mengatasi ini, dan saya telah menemukan bahwa metode ekstensi khusus untuk kerangka kerja tiruan akan membuatnya paling mudah untuk ditulis.

Berikut adalah contoh metode yang telah saya buat untuk dikerjakan NSubstitute:

public static class LoggerTestingExtensions
{
    public static void LogError(this ILogger logger, string message)
    {
        logger.Log(
            LogLevel.Error,
            0,
            Arg.Is<FormattedLogValues>(v => v.ToString() == message),
            Arg.Any<Exception>(),
            Arg.Any<Func<object, Exception, string>>());
    }

}

Dan inilah cara penggunaannya:

_logger.Received(1).LogError("Something bad happened");   

Tampaknya persis seperti jika Anda menggunakan metode secara langsung, triknya di sini adalah metode ekstensi kami mendapat prioritas karena "lebih dekat" dalam ruang nama daripada yang asli, jadi metode ekstensi akan digunakan sebagai gantinya.

Sayangnya itu tidak memberikan 100% apa yang kita inginkan, yaitu pesan kesalahan tidak akan sebaik, karena kita tidak memeriksa langsung pada string melainkan pada lambda yang melibatkan string, tetapi 95% lebih baik daripada tidak sama sekali :) Selain itu pendekatan ini akan membuat kode uji

PS Untuk MOQ satu dapat menggunakan pendekatan menulis metode ekstensi untuk Mock<ILogger<T>>yang melakukan Verifyuntuk mencapai hasil yang sama.

PPS Ini tidak berfungsi di .Net Core 3 lagi, periksa utas ini untuk detail lebih lanjut: https://github.com/nsubstitute/NSubstitute/issues/597#issuecomment-573742574

Ilya Chernomordik
sumber
Mengapa Anda memverifikasi panggilan logger? Mereka bukanlah bagian dari logika bisnis. Jika sesuatu yang buruk terjadi, saya lebih suka memverifikasi perilaku program yang sebenarnya (seperti memanggil penangan kesalahan atau membuat pengecualian) daripada mencatat pesan.
Ilya Chumakov
1
Saya rasa itu cukup penting untuk mengujinya juga, setidaknya dalam beberapa kasus. Saya sudah terlalu sering melihat program gagal diam-diam, jadi menurut saya masuk akal untuk memverifikasi bahwa logging terjadi ketika pengecualian terjadi misalnya. Dan ini tidak seperti "salah satu atau", melainkan menguji perilaku program dan logging yang sebenarnya.
Ilya Chernomordik
5

Sudah disebutkan, Anda dapat mengejeknya seperti antarmuka lainnya.

var logger = new Mock<ILogger<QueuedHostedService>>();

Sejauh ini baik.

Hal yang menyenangkan adalah Anda dapat menggunakan Moquntuk memverifikasi bahwa panggilan tertentu telah dilakukan . Misalnya di sini saya memeriksa bahwa log telah dipanggil dengan tertentu Exception.

logger.Verify(m => m.Log(It.Is<LogLevel>(l => l == LogLevel.Information), 0,
            It.IsAny<object>(), It.IsAny<TaskCanceledException>(), It.IsAny<Func<object, Exception, string>>()));

Saat menggunakan Verifyintinya adalah melakukannya melawan Logmetode nyata dari ILoogerantarmuka dan bukan metode ekstensi.

guillem
sumber
5

Membangun lebih jauh dari pekerjaan @ ivan-samygin dan @stakx, berikut adalah metode ekstensi yang juga bisa cocok dengan Exception dan semua nilai log (KeyValuePairs).

Ini bekerja (pada mesin saya;)) dengan .Net Core 3, Moq 4.13.0 dan Microsoft.Extensions.Logging.Abstractions 3.1.0.

/// <summary>
/// Verifies that a Log call has been made, with the given LogLevel, Message and optional KeyValuePairs.
/// </summary>
/// <typeparam name="T">Type of the class for the logger.</typeparam>
/// <param name="loggerMock">The mocked logger class.</param>
/// <param name="expectedLogLevel">The LogLevel to verify.</param>
/// <param name="expectedMessage">The Message to verify.</param>
/// <param name="expectedValues">Zero or more KeyValuePairs to verify.</param>
public static void VerifyLog<T>(this Mock<ILogger<T>> loggerMock, LogLevel expectedLogLevel, string expectedMessage, params KeyValuePair<string, object>[] expectedValues)
{
    loggerMock.Verify(mock => mock.Log(
        expectedLogLevel,
        It.IsAny<EventId>(),
        It.Is<It.IsAnyType>((o, t) => MatchesLogValues(o, expectedMessage, expectedValues)),
        It.IsAny<Exception>(),
        It.IsAny<Func<object, Exception, string>>()
        )
    );
}

/// <summary>
/// Verifies that a Log call has been made, with LogLevel.Error, Message, given Exception and optional KeyValuePairs.
/// </summary>
/// <typeparam name="T">Type of the class for the logger.</typeparam>
/// <param name="loggerMock">The mocked logger class.</param>
/// <param name="expectedMessage">The Message to verify.</param>
/// <param name="expectedException">The Exception to verify.</param>
/// <param name="expectedValues">Zero or more KeyValuePairs to verify.</param>
public static void VerifyLog<T>(this Mock<ILogger<T>> loggerMock, string expectedMessage, Exception expectedException, params KeyValuePair<string, object>[] expectedValues)
{
    loggerMock.Verify(logger => logger.Log(
        LogLevel.Error,
        It.IsAny<EventId>(),
        It.Is<It.IsAnyType>((o, t) => MatchesLogValues(o, expectedMessage, expectedValues)),
        It.Is<Exception>(e => e == expectedException),
        It.Is<Func<It.IsAnyType, Exception, string>>((o, t) => true)
    ));
}

private static bool MatchesLogValues(object state, string expectedMessage, params KeyValuePair<string, object>[] expectedValues)
{
    const string messageKeyName = "{OriginalFormat}";

    var loggedValues = (IReadOnlyList<KeyValuePair<string, object>>)state;

    return loggedValues.Any(loggedValue => loggedValue.Key == messageKeyName && loggedValue.Value.ToString() == expectedMessage) &&
           expectedValues.All(expectedValue => loggedValues.Any(loggedValue => loggedValue.Key == expectedValue.Key && loggedValue.Value == expectedValue.Value));
}
Mathias R
sumber
1

Hanya membuat boneka ILoggertidak terlalu berharga untuk pengujian unit. Anda juga harus memverifikasi bahwa panggilan logging telah dilakukan. Anda dapat menyuntikkan ejekan ILoggerdengan Moq tetapi memverifikasi panggilan itu bisa sedikit rumit. Artikel ini membahas lebih dalam tentang memverifikasi dengan Moq.

Berikut adalah contoh yang sangat sederhana dari artikel tersebut:

_loggerMock.Verify(l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
(Func<It.IsAnyType, Exception, string>)It.IsAny<object>()), Times.Exactly(1));

Ini memverifikasi bahwa pesan informasi telah dicatat. Tetapi, jika kita ingin memverifikasi informasi yang lebih kompleks tentang pesan seperti template pesan dan properti bernama, itu menjadi lebih rumit:

_loggerMock.Verify
(
    l => l.Log
    (
        //Check the severity level
        LogLevel.Error,
        //This may or may not be relevant to your scenario
        It.IsAny<EventId>(),
        //This is the magical Moq code that exposes internal log processing from the extension methods
        It.Is<It.IsAnyType>((state, t) =>
            //This confirms that the correct log message was sent to the logger. {OriginalFormat} should match the value passed to the logger
            //Note: messages should be retrieved from a service that will probably store the strings in a resource file
            CheckValue(state, LogTest.ErrorMessage, "{OriginalFormat}") &&
            //This confirms that an argument with a key of "recordId" was sent with the correct value
            //In Application Insights, this will turn up in Custom Dimensions
            CheckValue(state, recordId, nameof(recordId))
    ),
    //Confirm the exception type
    It.IsAny<NotImplementedException>(),
    //Accept any valid Func here. The Func is specified by the extension methods
    (Func<It.IsAnyType, Exception, string>)It.IsAny<object>()),
    //Make sure the message was logged the correct number of times
    Times.Exactly(1)
);

Saya yakin Anda dapat melakukan hal yang sama dengan kerangka kerja tiruan lainnya, tetapi ILoggerantarmuka memastikan bahwa itu sulit.

Christian Findlay
sumber
1
Saya setuju dengan sentimennya, dan seperti yang Anda katakan, bisa jadi agak sulit untuk membangun ekspresi. Saya sering mengalami masalah yang sama, jadi baru-baru ini saya mengumpulkan Moq.Contrib.ExpressionBuilders.Logging untuk menyediakan antarmuka yang lancar yang membuatnya jauh lebih enak.
rgvlee
1

Jika masih aktual. Cara sederhana lakukan log ke output dalam pengujian untuk .net core> = 3

[Fact]
public void SomeTest()
{
    using var logFactory = LoggerFactory.Create(builder => builder.AddConsole());
    var logger = logFactory.CreateLogger<AccountController>();
    
    var controller = new SomeController(logger);

    var result = controller.SomeActionAsync(new Dto{ ... }).GetAwaiter().GetResult();
}
photoscar
sumber
0

Gunakan Telerik Just Mock untuk membuat contoh logger yang diejek:

using Telerik.JustMock;
...
context = new XDbContext(Mock.Create<ILogger<XDbContext>>());
bigpony
sumber
0

Saya telah mencoba untuk mengejek antarmuka Logger menggunakan NSubstitute (dan gagal karena Arg.Any<T>()memerlukan parameter tipe, yang tidak dapat saya berikan), tetapi akhirnya membuat logger percobaan (mirip dengan jawaban @ jehof) dengan cara berikut:

    internal sealed class TestLogger<T> : ILogger<T>, IDisposable
    {
        private readonly List<LoggedMessage> _messages = new List<LoggedMessage>();

        public IReadOnlyList<LoggedMessage> Messages => _messages;

        public void Dispose()
        {
        }

        public IDisposable BeginScope<TState>(TState state)
        {
            return this;
        }

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

        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
        {
            var message = formatter(state, exception);
            _messages.Add(new LoggedMessage(logLevel, eventId, exception, message));
        }

        public sealed class LoggedMessage
        {
            public LogLevel LogLevel { get; }
            public EventId EventId { get; }
            public Exception Exception { get; }
            public string Message { get; }

            public LoggedMessage(LogLevel logLevel, EventId eventId, Exception exception, string message)
            {
                LogLevel = logLevel;
                EventId = eventId;
                Exception = exception;
                Message = message;
            }
        }
    }

Anda dapat dengan mudah mengakses semua pesan yang dicatat dan menegaskan semua parameter penting yang disediakan dengannya.

Igor Kustov
sumber