From 52d035ee5ce4b07368ab0711ef6c28f94a47ff8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Sat, 21 Dec 2024 09:57:58 -0500 Subject: [PATCH] Add Meziantou.Extensions.Logging.Xunit.v3 --- Meziantou.Framework.slnx | 4 +- README.md | 1 + ...ziantou.Extensions.Logging.Xunit.v3.csproj | 17 +++ .../XUnitLogger.cs | 110 ++++++++++++++++++ .../XUnitLoggerOptions.cs | 30 +++++ .../XUnitLoggerProvider.cs | 36 ++++++ .../readme.md | 33 ++++++ tests/Directory.Build.props | 6 +- .../InMemoryTestOutputHelper.cs | 2 +- ...tou.Extensions.Logging.Xunit.Tests.csproj} | 0 .../XunitLoggerTests.cs | 2 +- .../InMemoryTestOutputHelper.cs | 45 +++++++ ...u.Extensions.Logging.Xunit.v3.Tests.csproj | 18 +++ .../XunitLoggerTests.cs | 31 +++++ 14 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 src/Meziantou.Extensions.Logging.Xunit.v3/Meziantou.Extensions.Logging.Xunit.v3.csproj create mode 100644 src/Meziantou.Extensions.Logging.Xunit.v3/XUnitLogger.cs create mode 100644 src/Meziantou.Extensions.Logging.Xunit.v3/XUnitLoggerOptions.cs create mode 100644 src/Meziantou.Extensions.Logging.Xunit.v3/XUnitLoggerProvider.cs create mode 100644 src/Meziantou.Extensions.Logging.Xunit.v3/readme.md rename tests/{Meziantou.Extensions.Logging.Tests.Tests => Meziantou.Extensions.Logging.Xunit.Tests}/InMemoryTestOutputHelper.cs (91%) rename tests/{Meziantou.Extensions.Logging.Tests.Tests/Meziantou.Extensions.Logging.Tests.Tests.csproj => Meziantou.Extensions.Logging.Xunit.Tests/Meziantou.Extensions.Logging.Xunit.Tests.csproj} (100%) rename tests/{Meziantou.Extensions.Logging.Tests.Tests => Meziantou.Extensions.Logging.Xunit.Tests}/XunitLoggerTests.cs (94%) create mode 100644 tests/Meziantou.Extensions.Logging.Xunit.v3.Tests/InMemoryTestOutputHelper.cs create mode 100644 tests/Meziantou.Extensions.Logging.Xunit.v3.Tests/Meziantou.Extensions.Logging.Xunit.v3.Tests.csproj create mode 100644 tests/Meziantou.Extensions.Logging.Xunit.v3.Tests/XunitLoggerTests.cs diff --git a/Meziantou.Framework.slnx b/Meziantou.Framework.slnx index 38aba7f94..0e2a9e20f 100644 --- a/Meziantou.Framework.slnx +++ b/Meziantou.Framework.slnx @@ -38,6 +38,7 @@ + @@ -106,7 +107,8 @@ - + + diff --git a/README.md b/README.md index 1a46c7011..4a3c63256 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ | Meziantou.AspNetCore.Mvc | [![NuGet](https://img.shields.io/nuget/v/Meziantou.AspNetCore.Mvc.svg)](https://www.nuget.org/packages/Meziantou.AspNetCore.Mvc/) | | | Meziantou.Extensions.Logging.InMemory | [![NuGet](https://img.shields.io/nuget/v/Meziantou.Extensions.Logging.InMemory.svg)](https://www.nuget.org/packages/Meziantou.Extensions.Logging.InMemory/) | [readme](src/Meziantou.Extensions.Logging.InMemory/readme.md) | | Meziantou.Extensions.Logging.Xunit | [![NuGet](https://img.shields.io/nuget/v/Meziantou.Extensions.Logging.Xunit.svg)](https://www.nuget.org/packages/Meziantou.Extensions.Logging.Xunit/) | [readme](src/Meziantou.Extensions.Logging.Xunit/readme.md) | +| Meziantou.Extensions.Logging.Xunit.v3 | [![NuGet](https://img.shields.io/nuget/v/Meziantou.Extensions.Logging.Xunit.v3.svg)](https://www.nuget.org/packages/Meziantou.Extensions.Logging.Xunit.v3/) | [readme](src/Meziantou.Extensions.Logging.Xunit.v3/readme.md) | | Meziantou.Framework | [![NuGet](https://img.shields.io/nuget/v/Meziantou.Framework.svg)](https://www.nuget.org/packages/Meziantou.Framework/) | | | Meziantou.Framework.ByteSize | [![NuGet](https://img.shields.io/nuget/v/Meziantou.Framework.ByteSize.svg)](https://www.nuget.org/packages/Meziantou.Framework.ByteSize/) | [readme](src/Meziantou.Framework.ByteSize/readme.md) | | Meziantou.Framework.ChromiumTracing | [![NuGet](https://img.shields.io/nuget/v/Meziantou.Framework.ChromiumTracing.svg)](https://www.nuget.org/packages/Meziantou.Framework.ChromiumTracing/) | | diff --git a/src/Meziantou.Extensions.Logging.Xunit.v3/Meziantou.Extensions.Logging.Xunit.v3.csproj b/src/Meziantou.Extensions.Logging.Xunit.v3/Meziantou.Extensions.Logging.Xunit.v3.csproj new file mode 100644 index 000000000..609340318 --- /dev/null +++ b/src/Meziantou.Extensions.Logging.Xunit.v3/Meziantou.Extensions.Logging.Xunit.v3.csproj @@ -0,0 +1,17 @@ + + + + $(LatestTargetFrameworks);netstandard2.0 + false + + Microsoft.Extension.Logging.ILogger implementation for xunit.v3 + xunit, xunit.v3, logger + 1.0.0 + + + + + + + + diff --git a/src/Meziantou.Extensions.Logging.Xunit.v3/XUnitLogger.cs b/src/Meziantou.Extensions.Logging.Xunit.v3/XUnitLogger.cs new file mode 100644 index 000000000..86228f135 --- /dev/null +++ b/src/Meziantou.Extensions.Logging.Xunit.v3/XUnitLogger.cs @@ -0,0 +1,110 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Meziantou.Extensions.Logging.Xunit.v3; + +public sealed class XUnitLogger : XUnitLogger, ILogger +{ + public XUnitLogger(ITestOutputHelper testOutputHelper, LoggerExternalScopeProvider scopeProvider) + : base(testOutputHelper, scopeProvider, typeof(T).FullName) + { + } +} + +public class XUnitLogger : ILogger +{ + private readonly ITestOutputHelper _testOutputHelper; + private readonly string? _categoryName; + private readonly XUnitLoggerOptions _options; + private readonly LoggerExternalScopeProvider _scopeProvider; + + public static ILogger CreateLogger(ITestOutputHelper testOutputHelper) => new XUnitLogger(testOutputHelper, new LoggerExternalScopeProvider(), ""); + public static ILogger CreateLogger(ITestOutputHelper testOutputHelper) => new XUnitLogger(testOutputHelper, new LoggerExternalScopeProvider()); + + public XUnitLogger(ITestOutputHelper testOutputHelper, LoggerExternalScopeProvider scopeProvider, string? categoryName) + : this(testOutputHelper, scopeProvider, categoryName, appendScope: true) + { + } + + public XUnitLogger(ITestOutputHelper testOutputHelper, LoggerExternalScopeProvider scopeProvider, string? categoryName, bool appendScope) + : this(testOutputHelper, scopeProvider, categoryName, options: new XUnitLoggerOptions { IncludeScopes = appendScope }) + { + } + + public XUnitLogger(ITestOutputHelper testOutputHelper, LoggerExternalScopeProvider scopeProvider, string? categoryName, XUnitLoggerOptions? options) + { + _testOutputHelper = testOutputHelper; + _scopeProvider = scopeProvider; + _categoryName = categoryName; + _options = options ?? new(); + } + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + public IDisposable? BeginScope(TState state) where TState : notnull => _scopeProvider.Push(state); + + [SuppressMessage("ApiDesign", "RS0030:Do not use banned APIs")] + [SuppressMessage("Usage", "MA0011:IFormatProvider is missing")] + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var sb = new StringBuilder(); + + if (_options.TimestampFormat is not null) + { + var now = _options.UseUtcTimestamp ? DateTimeOffset.UtcNow : DateTimeOffset.Now; + var timestamp = now.ToString(_options.TimestampFormat); + sb.Append(timestamp).Append(' '); + } + + if (_options.IncludeLogLevel) + { + sb.Append(GetLogLevelString(logLevel)).Append(' '); + } + + if (_options.IncludeCategory) + { + sb.Append('[').Append(_categoryName).Append("] "); + } + + sb.Append(formatter(state, exception)); + + if (exception is not null) + { + sb.Append('\n').Append(exception); + } + + // Append scopes + if (_options.IncludeScopes) + { + _scopeProvider.ForEachScope((scope, state) => + { + state.Append("\n => "); + state.Append(scope); + }, sb); + } + + try + { + _testOutputHelper.WriteLine(sb.ToString()); + } + catch + { + // This can happen when the test is not active + } + } + + private static string GetLogLevelString(LogLevel logLevel) + { + return logLevel switch + { + LogLevel.Trace => "trce", + LogLevel.Debug => "dbug", + LogLevel.Information => "info", + LogLevel.Warning => "warn", + LogLevel.Error => "fail", + LogLevel.Critical => "crit", + _ => throw new ArgumentOutOfRangeException(nameof(logLevel)) + }; + } +} diff --git a/src/Meziantou.Extensions.Logging.Xunit.v3/XUnitLoggerOptions.cs b/src/Meziantou.Extensions.Logging.Xunit.v3/XUnitLoggerOptions.cs new file mode 100644 index 000000000..638b2d058 --- /dev/null +++ b/src/Meziantou.Extensions.Logging.Xunit.v3/XUnitLoggerOptions.cs @@ -0,0 +1,30 @@ +namespace Meziantou.Extensions.Logging.Xunit.v3; + +public sealed class XUnitLoggerOptions +{ + /// + /// Includes scopes when . + /// + public bool IncludeScopes { get; set; } + + /// + /// Includes category when . + /// + public bool IncludeCategory { get; set; } + + /// + /// Includes log level when . + /// + public bool IncludeLogLevel { get; set; } + + /// + /// Gets or sets format string used to format timestamp in logging messages. Defaults to . + /// + [StringSyntax(StringSyntaxAttribute.DateTimeFormat)] + public string? TimestampFormat { get; set; } + + /// + /// Gets or sets indication whether or not UTC timezone should be used to format timestamps in logging messages. Defaults to . + /// + public bool UseUtcTimestamp { get; set; } +} diff --git a/src/Meziantou.Extensions.Logging.Xunit.v3/XUnitLoggerProvider.cs b/src/Meziantou.Extensions.Logging.Xunit.v3/XUnitLoggerProvider.cs new file mode 100644 index 000000000..ac8312477 --- /dev/null +++ b/src/Meziantou.Extensions.Logging.Xunit.v3/XUnitLoggerProvider.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Meziantou.Extensions.Logging.Xunit.v3; + +public sealed class XUnitLoggerProvider : ILoggerProvider +{ + private readonly ITestOutputHelper _testOutputHelper; + private readonly XUnitLoggerOptions _options; + private readonly LoggerExternalScopeProvider _scopeProvider = new(); + + public XUnitLoggerProvider(ITestOutputHelper testOutputHelper) + : this(testOutputHelper, options: null) + { + } + + public XUnitLoggerProvider(ITestOutputHelper testOutputHelper, bool appendScope) + : this(testOutputHelper, new XUnitLoggerOptions { IncludeScopes = appendScope }) + { + } + + public XUnitLoggerProvider(ITestOutputHelper testOutputHelper, XUnitLoggerOptions? options) + { + _testOutputHelper = testOutputHelper; + _options = options ?? new XUnitLoggerOptions(); + } + + public ILogger CreateLogger(string categoryName) + { + return new XUnitLogger(_testOutputHelper, _scopeProvider, categoryName, _options); + } + + public void Dispose() + { + } +} diff --git a/src/Meziantou.Extensions.Logging.Xunit.v3/readme.md b/src/Meziantou.Extensions.Logging.Xunit.v3/readme.md new file mode 100644 index 000000000..00d16bab3 --- /dev/null +++ b/src/Meziantou.Extensions.Logging.Xunit.v3/readme.md @@ -0,0 +1,33 @@ +# Meziantou.Extensions.Logging.Xunit.v3 + +```c# +ILogger logger = XUnitLogger.CreateLogger(); +ILogger logger = XUnitLogger.CreateLogger(); +``` + +If you are using a `WebApplicationFactory`: + +```c# +public class UnitTest1(ITestOutputHelper testOutputHelper) +{ + [Fact] + public async Task Test1() + { + using var factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureLogging(builder => + { + // You can override the logging configuration if needed + //builder.SetMinimumLevel(LogLevel.Trace); + //builder.AddFilter(_ => true); + + // Register the xUnit logger provider + builder.Services.AddSingleton(new XUnitLoggerProvider(testOutputHelper, appendScope: false)); + }); + }); + } +} +``` + +Blog post about this package: [How to write logs from ILogger to xUnit.net ITestOutputHelper](https://www.meziantou.net/how-to-view-logs-from-ilogger-in-xunitdotnet.htm) diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index aeb221137..07ac3caea 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -1,11 +1,15 @@ + + true + + - + diff --git a/tests/Meziantou.Extensions.Logging.Tests.Tests/InMemoryTestOutputHelper.cs b/tests/Meziantou.Extensions.Logging.Xunit.Tests/InMemoryTestOutputHelper.cs similarity index 91% rename from tests/Meziantou.Extensions.Logging.Tests.Tests/InMemoryTestOutputHelper.cs rename to tests/Meziantou.Extensions.Logging.Xunit.Tests/InMemoryTestOutputHelper.cs index 96c4469f8..f6c95887e 100644 --- a/tests/Meziantou.Extensions.Logging.Tests.Tests/InMemoryTestOutputHelper.cs +++ b/tests/Meziantou.Extensions.Logging.Xunit.Tests/InMemoryTestOutputHelper.cs @@ -1,7 +1,7 @@ using Xunit.Abstractions; using System.Globalization; -namespace Meziantou.Extensions.Logging.Tests.Tests; +namespace Meziantou.Extensions.Logging.Xunit.Tests; internal sealed class InMemoryTestOutputHelper : ITestOutputHelper { diff --git a/tests/Meziantou.Extensions.Logging.Tests.Tests/Meziantou.Extensions.Logging.Tests.Tests.csproj b/tests/Meziantou.Extensions.Logging.Xunit.Tests/Meziantou.Extensions.Logging.Xunit.Tests.csproj similarity index 100% rename from tests/Meziantou.Extensions.Logging.Tests.Tests/Meziantou.Extensions.Logging.Tests.Tests.csproj rename to tests/Meziantou.Extensions.Logging.Xunit.Tests/Meziantou.Extensions.Logging.Xunit.Tests.csproj diff --git a/tests/Meziantou.Extensions.Logging.Tests.Tests/XunitLoggerTests.cs b/tests/Meziantou.Extensions.Logging.Xunit.Tests/XunitLoggerTests.cs similarity index 94% rename from tests/Meziantou.Extensions.Logging.Tests.Tests/XunitLoggerTests.cs rename to tests/Meziantou.Extensions.Logging.Xunit.Tests/XunitLoggerTests.cs index 727f71650..385faf222 100644 --- a/tests/Meziantou.Extensions.Logging.Tests.Tests/XunitLoggerTests.cs +++ b/tests/Meziantou.Extensions.Logging.Xunit.Tests/XunitLoggerTests.cs @@ -5,7 +5,7 @@ using Meziantou.Extensions.Logging.Xunit; using Microsoft.Extensions.DependencyInjection; -namespace Meziantou.Extensions.Logging.Tests.Tests; +namespace Meziantou.Extensions.Logging.Xunit.Tests; public sealed class XunitLoggerTests { diff --git a/tests/Meziantou.Extensions.Logging.Xunit.v3.Tests/InMemoryTestOutputHelper.cs b/tests/Meziantou.Extensions.Logging.Xunit.v3.Tests/InMemoryTestOutputHelper.cs new file mode 100644 index 000000000..714e4634a --- /dev/null +++ b/tests/Meziantou.Extensions.Logging.Xunit.v3.Tests/InMemoryTestOutputHelper.cs @@ -0,0 +1,45 @@ +using System.Globalization; +using Xunit; + +namespace Meziantou.Extensions.Logging.Xunit.v3.Tests; + +internal sealed class InMemoryTestOutputHelper : ITestOutputHelper +{ + private readonly List _logs = new(); + + public IEnumerable Logs => _logs; + + public string Output { get; } + + public void Write(string message) + { + lock (_logs) + { + _logs.Add(message); + } + } + + public void Write(string format, params object[] args) + { + lock (_logs) + { + _logs.Add(string.Format(CultureInfo.InvariantCulture, format, args)); + } + } + + public void WriteLine(string message) + { + lock (_logs) + { + _logs.Add(message + Environment.NewLine); + } + } + + public void WriteLine(string format, params object[] args) + { + lock (_logs) + { + _logs.Add(string.Format(CultureInfo.InvariantCulture, format, args) + Environment.NewLine); + } + } +} diff --git a/tests/Meziantou.Extensions.Logging.Xunit.v3.Tests/Meziantou.Extensions.Logging.Xunit.v3.Tests.csproj b/tests/Meziantou.Extensions.Logging.Xunit.v3.Tests/Meziantou.Extensions.Logging.Xunit.v3.Tests.csproj new file mode 100644 index 000000000..c30b2b181 --- /dev/null +++ b/tests/Meziantou.Extensions.Logging.Xunit.v3.Tests/Meziantou.Extensions.Logging.Xunit.v3.Tests.csproj @@ -0,0 +1,18 @@ + + + + $(LatestTargetFrameworks) + false + + + + + + + + + + + + + diff --git a/tests/Meziantou.Extensions.Logging.Xunit.v3.Tests/XunitLoggerTests.cs b/tests/Meziantou.Extensions.Logging.Xunit.v3.Tests/XunitLoggerTests.cs new file mode 100644 index 000000000..737557274 --- /dev/null +++ b/tests/Meziantou.Extensions.Logging.Xunit.v3.Tests/XunitLoggerTests.cs @@ -0,0 +1,31 @@ +#pragma warning disable CA1848 // Use the LoggerMessage delegates +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; +using Meziantou.Extensions.Logging.Xunit; +using Microsoft.Extensions.DependencyInjection; + +namespace Meziantou.Extensions.Logging.Xunit.v3.Tests; + +public sealed class XunitLoggerTests +{ + [Fact] + public void XUnitLoggerProviderTest() + { + var output = new InMemoryTestOutputHelper(); + using var provider = new XUnitLoggerProvider(output); + var host = new HostBuilder() + .ConfigureLogging(builder => + { + builder.Services.AddSingleton(provider); + + }) + .Build(); + + var logger = host.Services.GetRequiredService>(); + logger.LogInformation("Test"); + logger.LogInformation("Test {Sample}", "value"); + + Assert.Equal(["Test" + Environment.NewLine, "Test value" + Environment.NewLine], output.Logs); + } +} \ No newline at end of file