Jak przeprowadzić testy jednostkowe za pomocą ILogger w ASP.NET Core

129

To jest mój kontroler:

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);
    }
}

Jak widać, mam 2 zależności, a IDAOi aILogger

I to jest moja klasa testowa, używam xUnit do testowania i Moq do tworzenia mock i stub, mogę DAOłatwo kpić , ale z ILoggernie wiem co zrobić, więc po prostu przekazuję null i komentuję wywołanie, aby zalogować się do kontrolera po uruchomieniu testu. Czy jest sposób, aby przetestować, ale nadal jakoś zachować rejestrator?

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
źródło
1
Możesz użyć makiety jako kodu pośredniczącego, jak sugeruje Ilya, jeśli tak naprawdę nie próbujesz sprawdzić, czy została wywołana sama metoda logowania. W takim przypadku kpienie z rejestratora nie działa i możesz wypróbować kilka różnych podejść. Napisałem krótki artykuł pokazujący różne podejścia. Artykuł zawiera pełne repozytorium GitHub z każdą z różnych opcji . Ostatecznie, radzę używać własnego adaptera zamiast pracować bezpośrednio z typem ILogger <T>, jeśli musisz być w stanie
ssmith
Jak wspomniał @ssmith, istnieją pewne problemy z weryfikacją rzeczywistych zgłoszeń ILogger. Na swoim blogu ma kilka dobrych sugestii, a ja przedstawiłem moje rozwiązanie, które wydaje się rozwiązywać większość problemów opisanych poniżej .
Ilya Chernomordik

Odpowiedzi:

141

Po prostu kpij z tego, jak również z każdej innej zależności:

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);

Prawdopodobnie będziesz musiał zainstalować Microsoft.Extensions.Logging.Abstractionspakiet, aby go używać ILogger<T>.

Ponadto możesz stworzyć prawdziwy rejestrator:

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

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

var logger = factory.CreateLogger<BlogController>();
Ilya Chumakov
źródło
5
aby zalogować się do okna wyjściowego debugowania, wywołaj AddDebug () w fabryce: var factory = serviceProvider.GetService <ILoggerFactory> () .AddDebug ();
spottedmahn
3
Uważam, że podejście „prawdziwego rejestratora” jest bardziej skuteczne!
DanielV,
1
Prawdziwa część rejestrująca działa również świetnie do testowania LogConfiguration i LogLevel w określonych scenariuszach.
Martin Lottering
Takie podejście pozwoli tylko na kodowanie, ale nie na weryfikację połączeń. Przyszedłem z moim rozwiązaniem, które wydaje się rozwiązać większość problemów z weryfikacją w odpowiedzi poniżej .
Ilya Chernomordik
102

Właściwie znalazłem, Microsoft.Extensions.Logging.Abstractions.NullLogger<>które wygląda na idealne rozwiązanie. Zainstaluj pakiet Microsoft.Extensions.Logging.Abstractions, a następnie postępuj zgodnie z przykładem, aby go skonfigurować i używać:

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>();
    }
}

i test jednostkowy

//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
źródło
Wydaje się, że działa to tylko w przypadku .NET Core 2.0, a nie .NET Core 1.1.
Thorkil Værge
3
@adospace, twój komentarz jest o wiele bardziej przydatny niż odpowiedź
johnny 5
Czy możesz podać przykład, jak to by działało? Podczas testów jednostkowych chciałbym, aby dzienniki pojawiały się w oknie danych wyjściowych, nie jestem pewien, czy to spowoduje.
J86
@adospace Czy to ma trafić do startup.cs?
raklos
1
@raklos buczenie, nie, powinno być używane w metodzie uruchamiania w teście, w którym
tworzona
32

Użyj niestandardowego programu rejestrującego, który używa ITestOutputHelper(z xunit) do przechwytywania danych wyjściowych i dzienników. Poniżej znajduje się mały przykład, który zapisuje tylko statedane wyjściowe.

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()
    {
    }
}

Użyj go w swoich publikacjach, takich jak

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
źródło
1
cześć. to działa dobrze dla mnie. teraz, jak mogę sprawdzić lub wyświetlić informacje z dziennika
malik saifullah
uruchamiam testy jednostkowe bezpośrednio z VS. nie mam do tego konsoli
malik saifullah
1
@maliksaifullah im using resharper. sprawdzę to z vs
Jehof,
1
@maliksaifullah, TestExplorer z VS udostępnia link do otwierania wyników testu. wybierz swój test w TestExplorer, a na dole znajduje się link
Jehof
1
To świetnie, dzięki! Kilka sugestii: 1) nie musi to być ogólne, ponieważ parametr typu nie jest używany. Wdrożenie po prostu ILoggerzwiększy jego użyteczność. 2) Nie BeginScopepowinien zwracać się sam, ponieważ oznacza to, że każda przetestowana metoda, która rozpoczyna i kończy zakres podczas wykonywania, pozbędzie się rejestratora. Zamiast tego utwórz prywatną „fałszywą” klasę zagnieżdżoną, która implementuje IDisposablei zwraca jej instancję (a następnie usuwa IDisposablez XunitLogger).
Tobias J
27

Dla odpowiedzi .net core 3, które używają Moq

Na szczęście firma stakx zapewniła dobre obejście . Dlatego publikuję to z nadzieją, że pozwoli to zaoszczędzić czas innym (zajęło to trochę czasu, zanim to zrozumiał):

 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
źródło
Uratowałeś mi dzień… Dziękuję.
KiddoDeveloper
15

Dodając moje 2 centy, jest to metoda rozszerzenia pomocnika, zwykle umieszczana w statycznej klasie pomocniczej:

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);
    }
}

Następnie używasz tego w ten sposób:

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

//Act

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

Oczywiście możesz go łatwo rozszerzyć, aby udawać wszelkie oczekiwania (np. Oczekiwanie, wiadomość itp.)

Mahmoud Hanafy
źródło
To bardzo eleganckie rozwiązanie.
MichaelDotKnox
Zgadzam się, ta odpowiedź była bardzo dobra. Nie rozumiem, dlaczego nie ma tak wielu głosów
Farzad
Czy byłoby możliwe utworzenie makiety, aby sprawdzić ciąg, który przekazaliśmy w LogWarning? Na przykład:It.Is<string>(s => s.Equals("A parameter is empty!"))
Serhat
To bardzo pomaga. Jedynym brakującym elementem dla mnie jest to, jak mogę ustawić wywołanie zwrotne w makiecie, która zapisuje na wyjściu XUnit? Nigdy nie oddzwoni do mnie.
flipdoubt
6

Jest to łatwe, jak sugerują inne odpowiedzi, aby przejść próbę ILogger, ale nagle znacznie bardziej problematyczne staje się zweryfikowanie, czy rzeczywiście wykonano połączenia z rejestratorem. Powodem jest to, że większość wywołań w rzeczywistości nie należy do samego ILoggerinterfejsu.

Dlatego większość wywołań to metody rozszerzające, które wywołują jedyną Logmetodę interfejsu. Wydaje się, że łatwiej jest wykonać implementację interfejsu, jeśli masz tylko jedno i niewiele przeciążeń, które sprowadzają się do tej samej metody.

Wadą jest oczywiście to, że nagle znacznie trudniej jest sprawdzić, czy połączenie zostało wykonane, ponieważ połączenie, które należy zweryfikować, bardzo różni się od połączenia, które wykonałeś. Istnieje kilka różnych podejść do obejścia tego problemu i odkryłem, że niestandardowe metody rozszerzeń do mockowania frameworka ułatwiają pisanie.

Oto przykład metody, którą stworzyłem do pracy 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>>());
    }

}

A tak można to wykorzystać:

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

Wygląda dokładnie tak, jakbyś użył tej metody bezpośrednio. Sztuczka polega na tym, że nasza metoda rozszerzająca ma priorytet, ponieważ jest „bliżej” przestrzeni nazw niż oryginalna, więc zostanie użyta zamiast niej.

Nie daje niestety 100% tego, czego chcemy, a mianowicie komunikaty o błędach nie będą tak dobre, ponieważ nie sprawdzamy bezpośrednio na łańcuchu, a raczej na lambdzie, która zawiera ciąg, ale 95% jest lepsze niż nic :) Dodatkowo takie podejście stworzy kod testowy

PS W przypadku Moq można zastosować podejście polegające na napisaniu metody rozszerzającej Mock<ILogger<T>>, Verifyaby osiągnąć podobne wyniki.

PPS To już nie działa w .Net Core 3, sprawdź ten wątek, aby uzyskać więcej informacji: https://github.com/nsubstitute/NSubstitute/issues/597#issuecomment-573742574

Ilya Chernomordik
źródło
Dlaczego weryfikujesz połączenia rejestratora? Nie są częścią logiki biznesowej. Jeśli wydarzy się coś złego, wolałbym raczej zweryfikować rzeczywiste zachowanie programu (takie jak wywołanie programu obsługi błędów lub zgłoszenie wyjątku) niż rejestrowanie komunikatu.
Ilya Chumakov
1
Myślę, że to również jest bardzo ważne, przynajmniej w niektórych przypadkach. Zbyt wiele razy widziałem, że program nie działa po cichu, więc myślę, że warto sprawdzić, czy rejestrowanie miało miejsce, gdy wystąpił wyjątek, np. Nie jest to „albo” albo „albo”, ale raczej testowanie zarówno rzeczywistego zachowania programu, jak i rejestrowania.
Ilya Chernomordik
5

Już wspomniałem, że możesz go kpić jak każdy inny interfejs.

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

Na razie w porządku.

Fajną rzeczą jest to, że możesz użyć Moqdo sprawdzenia, czy niektóre połączenia zostały wykonane . Na przykład tutaj sprawdzam, czy dziennik został wywołany z określonym 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>>()));

Przy używaniu Verifychodzi o zrobienie tego w stosunku do rzeczywistej Logmetody z ILoogerinterfejsu, a nie metod rozszerzających.

guillem
źródło
5

Opierając się jeszcze bardziej na pracy @ ivan-samygin i @stakx, oto metody rozszerzające, które mogą również pasować do wyjątku i wszystkich wartości dziennika (KeyValuePairs).

Działają (na moim komputerze;)) z .Net Core 3, Moq 4.13.0 i 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
źródło
1

Samo utworzenie atrapy ILoggernie jest zbyt wartościowe w przypadku testów jednostkowych. Powinieneś także sprawdzić, czy wykonano wywołania logowania. Możesz wstrzyknąć próbę za ILoggerpomocą Moq, ale weryfikacja połączenia może być trochę trudna. Ten artykuł zawiera szczegółowe informacje na temat weryfikacji za pomocą Moq.

Oto bardzo prosty przykład z artykułu:

_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));

Sprawdza, czy została zarejestrowana wiadomość informacyjna. Ale jeśli chcemy zweryfikować bardziej złożone informacje o wiadomości, takie jak szablon wiadomości i nazwane właściwości, staje się to trudniejsze:

_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)
);

Jestem pewien, że możesz zrobić to samo z innymi frameworkami do mockowania, ale ILoggerinterfejs zapewnia, że ​​jest to trudne.

Christian Findlay
źródło
1
Zgadzam się z sentymentem i jak mówisz, budowanie wyrażenia może być trochę trudne. Często miałem ten sam problem, więc niedawno złożyłem Moq.Contrib.ExpressionBuilders.Logging, aby zapewnić płynny interfejs, który sprawia, że ​​jest on o wiele przyjemniejszy.
rgvlee
1

Jeśli nadal aktualny. Prosty sposób na logowanie do wyników testów dla .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();
}
fotoscar
źródło
0

Użyj Telerik Just Mock, aby utworzyć fałszywą instancję programu rejestrującego:

using Telerik.JustMock;
...
context = new XDbContext(Mock.Create<ILogger<XDbContext>>());
bigpony
źródło
0

Próbowałem kpić z tego interfejsu Loggera za pomocą NSubstitute (i nie powiodło się, ponieważ Arg.Any<T>()wymaga parametru typu, którego nie mogę podać), ale skończyło się na utworzeniu rejestratora testu (podobnie do odpowiedzi @ jehof) w następujący sposób:

    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;
            }
        }
    }

Możesz łatwo uzyskać dostęp do wszystkich zarejestrowanych wiadomości i potwierdzić wszystkie istotne parametry, które są z nimi dostarczane.

Igor Kustov
źródło