diff --git a/docs/preview/03-Features/03-logging.mdx b/docs/preview/03-Features/03-logging.mdx index 493e6f4f..c2d9c946 100644 --- a/docs/preview/03-Features/03-logging.mdx +++ b/docs/preview/03-Features/03-logging.mdx @@ -10,7 +10,7 @@ This page describes functionality related to logging in tests. - The `Arcus.Testing.Logging.Xunit` library provides a `XunitTestLogger` type that's an implementation of the abstracted Microsoft [`Ilogger`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging) + The `Arcus.Testing.Logging.Xunit` library provides a `XunitTestLogger` type that's an implementation of the abstracted Microsoft [`ILogger`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging) inside the [xUnit](https://xunit.net/) test framework. **Installation** @@ -63,7 +63,7 @@ This page describes functionality related to logging in tests. ``` - The `Arcus.Testing.Logging.NUnit` library provides a `NUnitTestLogger` type that's an implementation of the abstracted Microsoft [`Ilogger`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging) + The `Arcus.Testing.Logging.NUnit` library provides a `NUnitTestLogger` type that's an implementation of the abstracted Microsoft [`ILogger`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging) inside the [NUnit](https://nunit.org/) test framework. **Installation** @@ -115,7 +115,7 @@ This page describes functionality related to logging in tests. ``` - The `Arcus.Testing.Logging.MSTest` library provides a `MSTestLogger` type that's an implementation of the abstracted Microsoft [`Ilogger`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging) + The `Arcus.Testing.Logging.MSTest` library provides a `MSTestLogger` type that's an implementation of the abstracted Microsoft [`ILogger`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging) inside the [MSTest](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-with-mstest) test framework. **Installation** @@ -162,4 +162,38 @@ This page describes functionality related to logging in tests. ILogger logger = factory.CreateLogger(); ``` + + The `Arcus.Testing.Logging.TUnit` library provides a `TUnitTestLogger` type that's an implementation of the abstracted Microsoft [`ILogger`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging) inside the [TUnit](https://tunit.dev/) test framework. + + **Installation** + + The following functionality is available when installing this package: + + ```powershell + PM> Install-Package -Name Arcus.Testing.Logging.TUnit + ``` + + **Example** + + Log messages written to the `ILogger` instance will be written to the `TUnit`'s test logger. + + ```csharp + using Arcus.Testing; + using Microsoft.Extensions.Logging; + using TUnit.Core.Logging; + + public class TestClass + { + [Test] + public async Task TestMethod() + { + var tunitTestLogger = TestContext.Current.GetDefaultLogger(); + ILogger logger = new TUnitTestLogger(tunitTestLogger); + } + } + ``` + + In the same fashion there is a: + * [`AddTUnitTestLogging`] extension to add a `ILoggerProvider` to a [Microsoft Logging](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/logging/?view=aspnetcore-3.1) setup. + \ No newline at end of file diff --git a/src/Arcus.Testing.Logging.TUnit/Arcus.Testing.Logging.TUnit.csproj b/src/Arcus.Testing.Logging.TUnit/Arcus.Testing.Logging.TUnit.csproj new file mode 100644 index 00000000..6dbad72f --- /dev/null +++ b/src/Arcus.Testing.Logging.TUnit/Arcus.Testing.Logging.TUnit.csproj @@ -0,0 +1,41 @@ + + + + net10.0 + Arcus.Testing + Arcus + Arcus + Provides logging capabilities during testing within the TUnit framework + Copyright (c) Arcus + https://github.com/arcus-azure/arcus.testing + https://github.com/arcus-azure/arcus.testing + LICENSE + icon.png + README.md + Git + Azure;Testing + Arcus.Testing.Logging.TUnit + true + true + true + $(WarningsNotAsErrors);NU1901;NU1902;NU1903;NU1904 + S1133 + All + + + + + + + + + + + + + + + + + + diff --git a/src/Arcus.Testing.Logging.TUnit/Extensions/ILoggerBuilderExtensions.cs b/src/Arcus.Testing.Logging.TUnit/Extensions/ILoggerBuilderExtensions.cs new file mode 100644 index 00000000..70c2ea8e --- /dev/null +++ b/src/Arcus.Testing.Logging.TUnit/Extensions/ILoggerBuilderExtensions.cs @@ -0,0 +1,72 @@ +using System; +using Arcus.Testing; +using ITUnitLogger = TUnit.Core.Logging.ILogger; + +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.Logging +{ + /// + /// Extensions on the related to logging. + /// + // ReSharper disable once InconsistentNaming + public static class ILoggerBuilderExtensions + { + /// + /// Adds the logging messages from the given TUnit as a provider to the . + /// + /// The logging builder to add the NUnit logging test messages to. + /// The TUnit test writer to write custom test output. + /// Thrown when the or the is null. + public static ILoggingBuilder AddTUnitTestLogging(this ILoggingBuilder builder, ITUnitLogger outputWriter) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(outputWriter); + +#pragma warning disable CA2000 // Responsibility of disposing the created object is transferred to the caller + var provider = new TUnitLoggerProvider(outputWriter); +#pragma warning restore CA2000 + return builder.AddProvider(provider); + } + + [ProviderAlias("TUnit")] + private sealed class TUnitLoggerProvider : ILoggerProvider, ISupportExternalScope + { + private readonly ITUnitLogger _testLogger; + private IExternalScopeProvider _scopeProvider; + + /// + /// Initializes a new instance of the class. + /// + internal TUnitLoggerProvider(ITUnitLogger testLogger) + { + _testLogger = testLogger; + } + + /// + /// Creates a new instance. + /// + /// The category name for messages produced by the logger. + /// The instance of that was created. + public ILogger CreateLogger(string categoryName) + { + return new TUnitTestLogger(_testLogger, _scopeProvider, categoryName); + } + + /// + /// Sets external scope information source for logger provider. + /// + /// The provider of scope data. + public void SetScopeProvider(IExternalScopeProvider scopeProvider) + { + _scopeProvider = scopeProvider; + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + } + } + } +} diff --git a/src/Arcus.Testing.Logging.TUnit/TUnitTestLogger.cs b/src/Arcus.Testing.Logging.TUnit/TUnitTestLogger.cs new file mode 100644 index 00000000..6341ddfb --- /dev/null +++ b/src/Arcus.Testing.Logging.TUnit/TUnitTestLogger.cs @@ -0,0 +1,120 @@ +using System; +using Microsoft.Extensions.Logging; +using ITUnitLogger = TUnit.Core.Logging.ILogger; +using TUnitLogLevel = TUnit.Core.Logging.LogLevel; + +namespace Arcus.Testing +{ + /// + /// representation of a TUnit logger. + /// + public class TUnitTestLogger : ILogger + { + private readonly ITUnitLogger _outputWriter; + private readonly IExternalScopeProvider _scopeProvider; + private readonly string _categoryName; + + /// + /// Initializes a new instance of the class. + /// + /// The TUnit test writer to write custom test output. + /// Thrown when the is null. + public TUnitTestLogger(ITUnitLogger outputWriter) + : this(outputWriter, scopeProvider: null, categoryName: null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The TUnit test writer to write custom test output. + /// The instance to provide logging scopes. + /// The category name for messages produced by the logger. + /// Thrown when the is null. + internal TUnitTestLogger(ITUnitLogger outputWriter, IExternalScopeProvider scopeProvider, string categoryName) + { + ArgumentNullException.ThrowIfNull(outputWriter); + _outputWriter = outputWriter; + _scopeProvider = scopeProvider; + _categoryName = categoryName; + } + + /// + /// Writes a log entry. + /// + /// Entry will be written on this level. + /// Id of the event. + /// The entry to be written. Can be also an object. + /// The exception related to this entry. + /// Function to create a message of the and . + /// The type of the object to be written. + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + TUnitLogLevel level = ConvertToTUnitLogLevel(logLevel); + + ArgumentNullException.ThrowIfNull(formatter); + string message = formatter(state, exception); + + var builder = new LogMessageBuilder(logLevel); + builder.AddCategory(_categoryName) + .AddUserMessage(message) + .AddException(exception); + + _scopeProvider?.ForEachScope((st, lb) => lb.AddScope(st), builder); + + string result = builder.ToString(); + _outputWriter.Log(level, result, exception, formatter: (st, _) => st); + } + + /// + /// Checks if the given is enabled. + /// + /// Level to be checked. + /// true if enabled. + public bool IsEnabled(LogLevel logLevel) + { + TUnitLogLevel level = ConvertToTUnitLogLevel(logLevel); + return _outputWriter.IsEnabled(level); + } + + private static TUnitLogLevel ConvertToTUnitLogLevel(LogLevel logLevel) + { + return logLevel switch + { + LogLevel.Critical => TUnitLogLevel.Critical, + LogLevel.Error => TUnitLogLevel.Error, + LogLevel.Warning => TUnitLogLevel.Warning, + LogLevel.Information => TUnitLogLevel.Information, + LogLevel.Debug => TUnitLogLevel.Debug, + LogLevel.Trace => TUnitLogLevel.Trace, + _ => TUnitLogLevel.None + }; + } + + /// + /// Begins a logical operation scope. + /// + /// The identifier for the scope. + /// The type of the state to begin scope for. + /// An that ends the logical operation scope on dispose. + public IDisposable BeginScope(TState state) where TState : notnull + { + return _scopeProvider?.Push(state); + } + } + + /// + /// representation of a TUnit logger. + /// + /// The type whose name is used for the logger category name. + public class TUnitTestLogger : TUnitTestLogger, ILogger + { + /// + /// Initializes a new instance of the class. + /// + public TUnitTestLogger(ITUnitLogger outputWriter) + : base(outputWriter, scopeProvider: null, categoryName: typeof(TCategoryName).FullName) + { + } + } +} diff --git a/src/Arcus.Testing.Tests.Unit/Arcus.Testing.Tests.Unit.csproj b/src/Arcus.Testing.Tests.Unit/Arcus.Testing.Tests.Unit.csproj index e08fe121..04396103 100644 --- a/src/Arcus.Testing.Tests.Unit/Arcus.Testing.Tests.Unit.csproj +++ b/src/Arcus.Testing.Tests.Unit/Arcus.Testing.Tests.Unit.csproj @@ -29,6 +29,7 @@ + diff --git a/src/Arcus.Testing.Tests.Unit/Logging/Fixture/InMemoryTUnitTestLogger.cs b/src/Arcus.Testing.Tests.Unit/Logging/Fixture/InMemoryTUnitTestLogger.cs new file mode 100644 index 00000000..95337d78 --- /dev/null +++ b/src/Arcus.Testing.Tests.Unit/Logging/Fixture/InMemoryTUnitTestLogger.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using TUnit.Core.Logging; +using Xunit; + +namespace Arcus.Testing.Tests.Unit.Logging.Fixture +{ + public class MockTUnitTestLogger : ILogger + { + internal Collection<(LogLevel level, string message)> Logs { get; } = []; + + public ValueTask LogAsync(LogLevel logLevel, TState state, Exception exception, Func formatter) + { + throw new NotImplementedException(); + } + + public void Log(LogLevel logLevel, TState state, Exception exception, Func formatter) + { + Logs.Add((logLevel, formatter(state, exception))); + } + + public bool IsEnabled(LogLevel logLevel) + { + throw new NotImplementedException(); + } + + /// + /// Verifies that there was a written for the given to this logger. + /// + public void VerifyWritten(Microsoft.Extensions.Logging.LogLevel level, string message, Exception exception = null, string state = null) + { + Assert.Contains(Logs, log => + { + bool hasException = exception is null || log.message.Contains(exception.Message); + bool hasState = state is null || log.message.Contains(state); + + return (int) level == (int) log.level + && log.message.Contains(message) + && hasState + && hasException; + }); + } + } +} diff --git a/src/Arcus.Testing.Tests.Unit/Logging/Fixture/MockTestContext.cs b/src/Arcus.Testing.Tests.Unit/Logging/Fixture/MockTestContext.cs index 00595d46..f43af84b 100644 --- a/src/Arcus.Testing.Tests.Unit/Logging/Fixture/MockTestContext.cs +++ b/src/Arcus.Testing.Tests.Unit/Logging/Fixture/MockTestContext.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.ObjectModel; using Microsoft.VisualStudio.TestTools.UnitTesting; +using TestContext = Microsoft.VisualStudio.TestTools.UnitTesting.TestContext; namespace Arcus.Testing.Tests.Unit.Logging.Fixture { diff --git a/src/Arcus.Testing.Tests.Unit/Logging/ILoggerBuilderExtensionsTests.cs b/src/Arcus.Testing.Tests.Unit/Logging/ILoggerBuilderExtensionsTests.cs index 66420d71..9cd853c1 100644 --- a/src/Arcus.Testing.Tests.Unit/Logging/ILoggerBuilderExtensionsTests.cs +++ b/src/Arcus.Testing.Tests.Unit/Logging/ILoggerBuilderExtensionsTests.cs @@ -170,5 +170,40 @@ public void AddXunitTestLogging_WithoutXunitTestLogger_Throws() // Assert Assert.ThrowsAny(() => builder.Build()); } + + [Fact] + public void AddTUnitTestLogging_WithTestLogger_LogsMessage() + { + // Arrange + var mockLogger = new MockTUnitTestLogger(); + var builder = new HostBuilder(); + + // Act + builder.ConfigureLogging(logging => logging.AddTUnitTestLogging(mockLogger)); + + // Assert + using IHost host = builder.Build(); + var logger = host.Services.GetRequiredService>(); + + string state = Bogus.Lorem.Word(); + using var _ = logger.BeginScope(state); + + string expected = Bogus.Lorem.Sentence(); + logger.LogInformation(expected); + mockLogger.VerifyWritten(LogLevel.Information, expected, state: state); + } + + [Fact] + public void AddTUnitTestLogging_WithoutTestLogger_Throws() + { + // Arrange + var builder = new HostBuilder(); + + // Act + builder.ConfigureLogging(logging => logging.AddTUnitTestLogging(outputWriter: null)); + + // Assert + Assert.ThrowsAny(() => builder.Build()); + } } } diff --git a/src/Arcus.Testing.Tests.Unit/Logging/TUnitTestLoggerTests.cs b/src/Arcus.Testing.Tests.Unit/Logging/TUnitTestLoggerTests.cs new file mode 100644 index 00000000..d8f6e88a --- /dev/null +++ b/src/Arcus.Testing.Tests.Unit/Logging/TUnitTestLoggerTests.cs @@ -0,0 +1,30 @@ +using Arcus.Testing.Tests.Unit.Logging.Fixture; +using Bogus; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Arcus.Testing.Tests.Unit.Logging +{ + public class TUnitTestLoggerTests + { + private static readonly Faker Bogus = new(); + + [Fact] + public void Log_WithLevel_SucceedsWithMicrosoftLevel() + { + // Arrange + string expectedMessage = Bogus.Lorem.Sentence(); + var expectedLevel = Bogus.PickRandom(); + var exception = Bogus.System.Exception().OrNull(Bogus); + + var mockLogger = new MockTUnitTestLogger(); + var logger = new TUnitTestLogger(mockLogger); + + // Act + logger.Log(expectedLevel, exception, expectedMessage); + + // Assert + mockLogger.VerifyWritten(expectedLevel, expectedMessage, exception); + } + } +} diff --git a/src/Arcus.Testing.sln b/src/Arcus.Testing.sln index 3bb125e2..7012ba12 100644 --- a/src/Arcus.Testing.sln +++ b/src/Arcus.Testing.sln @@ -52,6 +52,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Arcus.Testing.Storage.File. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Arcus.Testing.Logging.Xunit.v3", "Arcus.Testing.Logging.Xunit.v3\Arcus.Testing.Logging.Xunit.v3.csproj", "{60883A81-B453-4E5C-B53B-B3E357E3AFB3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Arcus.Testing.Logging.TUnit", "Arcus.Testing.Logging.TUnit\Arcus.Testing.Logging.TUnit.csproj", "{2A0C03CC-3D26-4AC4-A4B8-3B7F4DB418EF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -120,6 +122,10 @@ Global {60883A81-B453-4E5C-B53B-B3E357E3AFB3}.Debug|Any CPU.Build.0 = Debug|Any CPU {60883A81-B453-4E5C-B53B-B3E357E3AFB3}.Release|Any CPU.ActiveCfg = Release|Any CPU {60883A81-B453-4E5C-B53B-B3E357E3AFB3}.Release|Any CPU.Build.0 = Release|Any CPU + {2A0C03CC-3D26-4AC4-A4B8-3B7F4DB418EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A0C03CC-3D26-4AC4-A4B8-3B7F4DB418EF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A0C03CC-3D26-4AC4-A4B8-3B7F4DB418EF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A0C03CC-3D26-4AC4-A4B8-3B7F4DB418EF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -139,6 +145,7 @@ Global {74AB9F6E-791F-4609-96BD-15420C25AA72} = {FA2E21E0-953E-4B84-9C47-C4F0A3833E4E} {4988575F-A1BA-4990-A7AD-F48303B51C62} = {FA2E21E0-953E-4B84-9C47-C4F0A3833E4E} {60883A81-B453-4E5C-B53B-B3E357E3AFB3} = {45B1870D-C19A-4680-BDD8-2F670C793BC2} + {2A0C03CC-3D26-4AC4-A4B8-3B7F4DB418EF} = {45B1870D-C19A-4680-BDD8-2F670C793BC2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E5382820-51FF-4B00-92BE-C78E80EA0841}