diff --git a/.gitignore b/.gitignore index 2503474..3f2e2ba 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ *.userosscache *.sln.docstates *.env +n# Claude Code +.claude/ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/Solution.slnx b/In-Memory Logger.slnx similarity index 52% rename from Solution.slnx rename to In-Memory Logger.slnx index 35f7f20..eaef030 100644 --- a/Solution.slnx +++ b/In-Memory Logger.slnx @@ -1,24 +1,31 @@ + + + + - + + - + + + @@ -26,6 +33,10 @@ - - + + + + + + diff --git a/README.md b/README.md index 682248c..a1da96b 100644 --- a/README.md +++ b/README.md @@ -1,181 +1,43 @@ # Wolfgang.Extensions.Logging.InMemoryLogger -Implementations of ILogger and ILogger that write log events to an in-memory list. Useful for testing +An implementation of `ILogger` from `Microsoft.Extensions.Logging` that writes log entries to an in-memory collection. Designed for use in unit and integration tests where you need to assert against logged messages. -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![.NET](https://img.shields.io/badge/.NET-Multi--Targeted-purple.svg)](https://dotnet.microsoft.com/) -[![GitHub](https://img.shields.io/badge/GitHub-Repository-181717?logo=github)](https://github.com/Chris-Wolfgang/In-memory-Logger) +## Features ---- +- Thread-safe in-memory log entry storage +- Configurable minimum log level +- Configurable initial capacity +- Supports all .NET log levels (Trace through Critical) +- Returns log entries as `IReadOnlyList>` for easy assertion -## 📦 Installation +## Installation -```bash +```shell dotnet add package Wolfgang.Extensions.Logging.InMemoryLogger ``` -**NuGet Package:** Coming soon to NuGet.org +## Usage ---- +```csharp +var logger = new InMemoryLogger(LogLevel.Information); -## 📄 License +// Use the logger in your code under test +var service = new MyService(logger); +service.DoWork(); -This project is licensed under the **MIT License**. See the [LICENSE](LICENSE) file for details. - ---- - -## 📚 Documentation - -- **GitHub Repository:** [https://github.com/Chris-Wolfgang/In-memory-Logger](https://github.com/Chris-Wolfgang/In-memory-Logger) -- **API Documentation:** https://Chris-Wolfgang.github.io/In-memory-Logger/ -- **Formatting Guide:** [README-FORMATTING.md](README-FORMATTING.md) -- **Contributing Guide:** [CONTRIBUTING.md](CONTRIBUTING.md) - ---- - -## 🚀 Quick Start - -{{QUICK_START_EXAMPLE}} - ---- - -## ✨ Features - -{{FEATURES_TABLE}} - -**Examples:** -{{FEATURE_EXAMPLES}} - ---- - -## 🎯 Target Frameworks - -| Framework | Versions | -|-----------|----------| -| .NET Framework | .NET 4.6.2, .NET 4.7.0, .NET 4.7.1, .NET 4.7.2, .NET 4.8, .NET 4.8.1 | -| .NET Core | .NET Core 3.1 | -| .NET | .NET 5.0, .NET 6.0, .NET 7.0, .NET 8.0, .NET 9.0, .NET 10.0 | - ---- - -## 🔍 Code Quality & Static Analysis - -This project enforces **strict code quality standards** through **7 specialized analyzers** and custom async-first rules: - -### Analyzers in Use - -1. **Microsoft.CodeAnalysis.NetAnalyzers** - Built-in .NET analyzers for correctness and performance -2. **Roslynator.Analyzers** - Advanced refactoring and code quality rules -3. **AsyncFixer** - Async/await best practices and anti-pattern detection -4. **Microsoft.VisualStudio.Threading.Analyzers** - Thread safety and async patterns -5. **Microsoft.CodeAnalysis.BannedApiAnalyzers** - Prevents usage of banned synchronous APIs -6. **Meziantou.Analyzer** - Comprehensive code quality rules -7. **SonarAnalyzer.CSharp** - Industry-standard code analysis - -### Async-First Enforcement - -This library uses **`BannedSymbols.txt`** to prohibit synchronous APIs and enforce async-first patterns: - -**Blocked APIs Include:** -- ❌ `Task.Wait()`, `Task.Result` - Use `await` instead -- ❌ `Thread.Sleep()` - Use `await Task.Delay()` instead -- ❌ Synchronous file I/O (`File.ReadAllText`) - Use async versions -- ❌ Synchronous stream operations - Use `ReadAsync()`, `WriteAsync()` -- ❌ `Parallel.For/ForEach` - Use `Task.WhenAll()` or `Parallel.ForEachAsync()` -- ❌ Obsolete APIs (`WebClient`, `BinaryFormatter`) - -**Why?** To ensure all code is **truly async** and **non-blocking** for optimal performance in async contexts. - ---- - -## 🛠️ Building from Source - -### Prerequisites -- [.NET 8.0 SDK](https://dotnet.microsoft.com/download) or later -- Optional: [PowerShell Core](https://github.com/PowerShell/PowerShell) for formatting scripts - -### Build Steps - -```bash -# Clone the repository -git clone https://github.com/Chris-Wolfgang/In-memory-Logger.git -cd In-memory-Logger - -# Restore dependencies -dotnet restore - -# Build the solution -dotnet build --configuration Release - -# Run tests -dotnet test --configuration Release - -# Run code formatting (PowerShell Core) -pwsh ./format.ps1 -``` - -### Code Formatting - -This project uses `.editorconfig` and `dotnet format`: - -```bash -# Format code -dotnet format - -# Verify formatting (as CI does) -dotnet format --verify-no-changes -``` - -See [README-FORMATTING.md](README-FORMATTING.md) for detailed formatting guidelines. - -### Building Documentation - -This project uses [DocFX](https://dotnet.github.io/docfx/) to generate API documentation: - -```bash -# Install DocFX (one-time setup) -dotnet tool install -g docfx - -# Generate API metadata and build documentation -cd docfx_project -docfx metadata # Extract API metadata from source code -docfx build # Build HTML documentation - -# Documentation is generated in the docs/ folder at the repository root +// Assert against captured log entries +Assert.Single(logger.LogEntries); +Assert.Equal(LogLevel.Information, logger.LogEntries[0].LogLevel); ``` -The documentation is automatically built and deployed to GitHub Pages when changes are pushed to the `main` branch. - -**Local Preview:** -```bash -# Serve documentation locally (with live reload) -cd docfx_project -docfx build --serve - -# Open http://localhost:8080 in your browser -``` - -**Documentation Structure:** -- `docfx_project/` - DocFX configuration and source files -- `docs/` - Generated HTML documentation (published to GitHub Pages) -- `docfx_project/index.md` - Main landing page content -- `docfx_project/docs/` - Additional documentation articles -- `docfx_project/api/` - Auto-generated API reference YAML files - ---- - -## 🤝 Contributing - -Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for: -- Code quality standards -- Build and test instructions -- Pull request guidelines -- Analyzer configuration details - ---- - +## Target Frameworks -## 🙏 Acknowledgments +- .NET Framework 4.6.2 +- .NET Standard 2.0 +- .NET Standard 2.1 +- .NET 8.0 +- .NET 10.0 -{{ACKNOWLEDGMENTS}} +## License +[MIT](LICENSE) diff --git a/src/Wolfgang.Extensions.Logging.InMemoryLogger/InMemoryLogger.cs b/src/Wolfgang.Extensions.Logging.InMemoryLogger/InMemoryLogger.cs new file mode 100644 index 0000000..ace0d22 --- /dev/null +++ b/src/Wolfgang.Extensions.Logging.InMemoryLogger/InMemoryLogger.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Wolfgang.Extensions.Logging.InMemoryLogger; + +/// +/// An implementation of that logs messages to an in-memory collection. +/// +[SuppressMessage("Style", "MA0049:Type name should not match containing namespace", + Justification = "The package, namespace, and type intentionally share a name; the namespace is named after the project's primary type.")] +public class InMemoryLogger : ILogger +{ + private readonly List> _logEntries; +#if NET9_0_OR_GREATER + private readonly System.Threading.Lock _lock = new(); +#else + private readonly object _lock = new object(); +#endif + private readonly AsyncLocal _scopeStack = new AsyncLocal(); + + + + /// + /// Initializes a new instance of the class. + /// + /// The category name for messages produced by the logger. + /// The minimum log level to log. + /// The initial capacity of the log entries collection. + /// Thrown when is . + /// Thrown when capacity is less than 1. + public InMemoryLogger + ( + string category, + LogLevel minLogLevel = LogLevel.Trace, + int capacity = 16 + ) + { + if (category == null) + { + throw new ArgumentNullException(nameof(category)); + } + + if (capacity < 1) + { + throw new ArgumentOutOfRangeException(nameof(capacity), "Value cannot be less than 1"); + } + + Category = category; + MinimumLogLevel = minLogLevel; + _logEntries = new List>(capacity); + } + + + + /// + /// Logs the specified data. + /// + /// The log level. + /// The event ID associated with the log entry. + /// The state associated with the log entry. + /// The exception associated with the log entry, if any. + /// A function to create a string message from the state and exception. + /// The type of the state object. + public void Log + ( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var entry = new LogEntry + ( + logLevel, + Category, + eventId, + state!, + exception, + (o, ex) => formatter((TState)o!, ex) + ); + + lock (_lock) + { + _logEntries.Add(entry); + } + } + + + + /// + /// Gets a value indicating if the specified log level is enabled. + /// + /// The log level to check. + /// if the log level is enabled; otherwise, . + public bool IsEnabled(LogLevel logLevel) + { + if (logLevel == LogLevel.None || MinimumLogLevel == LogLevel.None) + { + return false; + } + + return (int)logLevel >= (int)MinimumLogLevel; + } + + + + /// + /// Begins a logical operation scope. + /// + /// The identifier for the scope. + /// The type of the state. + /// A disposable object that ends the logical operation scope on dispose. + public IDisposable BeginScope(TState state) where TState : notnull + { + var parent = _scopeStack.Value; + var scope = new ScopeStack(state, parent); + _scopeStack.Value = scope; + + return new ScopeDisposable(this, scope); + } + + + + /// + /// Returns a readonly list of log entries that have been logged. + /// + public IReadOnlyList> LogEntries + { + get + { + lock (_lock) + { + return _logEntries.ToArray(); + } + } + } + + + + /// + /// Gets the category name for this logger. + /// + public string Category { get; } + + + + /// + /// Gets the minimum for this instance. + /// + public LogLevel MinimumLogLevel { get; } + + + + /// + /// The capacity of the internal log entries collection. + /// + public int Capacity + { + get + { + lock (_lock) + { + return _logEntries.Capacity; + } + } + } + + + + /// + /// Gets the currently active scopes as an array, from outermost to innermost. + /// + public object[] Scopes + { + get + { + var current = _scopeStack.Value; + if (current == null) + { + return Array.Empty(); + } + + var scopes = new List(); + while (current != null) + { + scopes.Add(current.State); + current = current.Parent; + } + + scopes.Reverse(); + return scopes.ToArray(); + } + } + + + + private sealed class ScopeStack + { + public ScopeStack(object state, ScopeStack? parent) + { + State = state; + Parent = parent; + } + + + + public object State { get; } + + + + public ScopeStack? Parent { get; } + } + + + + private sealed class ScopeDisposable : IDisposable + { + private readonly InMemoryLogger _logger; + private readonly ScopeStack _scope; + private int _disposed; + + + + public ScopeDisposable(InMemoryLogger logger, ScopeStack scope) + { + _logger = logger; + _scope = scope; + } + + + + public void Dispose() + { + // Only pop if this is the first dispose AND this scope is still the + // current top-of-stack. Out-of-order disposal is silently ignored to + // avoid corrupting the stack. + if (Interlocked.CompareExchange(ref _disposed, 1, 0) == 0 + && ReferenceEquals(_logger._scopeStack.Value, _scope)) + { + _logger._scopeStack.Value = _scope.Parent; + } + } + } +} diff --git a/src/Wolfgang.Extensions.Logging.InMemoryLogger/InMemoryLoggerBuilderExtensions.cs b/src/Wolfgang.Extensions.Logging.InMemoryLogger/InMemoryLoggerBuilderExtensions.cs new file mode 100644 index 0000000..84a757c --- /dev/null +++ b/src/Wolfgang.Extensions.Logging.InMemoryLogger/InMemoryLoggerBuilderExtensions.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Wolfgang.Extensions.Logging.InMemoryLogger; + +/// +/// Extension methods for adding to an . +/// +public static class InMemoryLoggerBuilderExtensions +{ + /// + /// Adds an in-memory logger to the logging builder. + /// + /// The to add the provider to. + /// The instance to register. + /// The so that additional calls can be chained. + /// + /// Thrown when or is . + /// + /// + /// + /// var provider = new InMemoryLoggerProvider(); + /// var serviceProvider = new ServiceCollection() + /// .AddLogging(builder => builder.AddInMemoryLogger(provider)) + /// .BuildServiceProvider(); + /// + /// + public static ILoggingBuilder AddInMemoryLogger + ( + this ILoggingBuilder builder, + InMemoryLoggerProvider provider + ) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (provider == null) + { + throw new ArgumentNullException(nameof(provider)); + } + + builder.AddProvider(provider); + + return builder; + } +} diff --git a/src/Wolfgang.Extensions.Logging.InMemoryLogger/InMemoryLoggerOfT.cs b/src/Wolfgang.Extensions.Logging.InMemoryLogger/InMemoryLoggerOfT.cs new file mode 100644 index 0000000..0056eaf --- /dev/null +++ b/src/Wolfgang.Extensions.Logging.InMemoryLogger/InMemoryLoggerOfT.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Wolfgang.Extensions.Logging.InMemoryLogger; + +/// +/// An implementation of that logs messages to an in-memory collection. +/// +/// The type whose name is used for the logger category name. +[SuppressMessage("Style", "MA0049:Type name should not match containing namespace", + Justification = "The package, namespace, and type intentionally share a name; the namespace is named after the project's primary type.")] +public class InMemoryLogger : ILogger +{ + private readonly InMemoryLogger _innerLogger; + + + + /// + /// Initializes a new instance of the class. + /// + /// The minimum log level to log. + /// The initial capacity of the log entries collection. + /// Thrown when capacity is less than 1. + public InMemoryLogger + ( + LogLevel minLogLevel = LogLevel.Trace, + int capacity = 16 + ) + { + _innerLogger = new InMemoryLogger + ( + typeof(T).FullName ?? typeof(T).Name, + minLogLevel, + capacity + ); + } + + + + /// + public void Log + ( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) + { + _innerLogger.Log(logLevel, eventId, state, exception, formatter); + } + + + + /// + public bool IsEnabled(LogLevel logLevel) => _innerLogger.IsEnabled(logLevel); + + + + /// + public IDisposable BeginScope(TState state) where TState : notnull => _innerLogger.BeginScope(state); + + + + /// + /// Returns a readonly list of log entries that have been logged. + /// + public IReadOnlyList> LogEntries => _innerLogger.LogEntries; + + + + /// + /// Gets the minimum for this instance. + /// + public LogLevel MinimumLogLevel => _innerLogger.MinimumLogLevel; + + + + /// + /// The capacity of the internal log entries collection. + /// + public int Capacity => _innerLogger.Capacity; + + + + /// + /// Gets the currently active scopes as an array, from outermost to innermost. + /// + public object[] Scopes => _innerLogger.Scopes; +} diff --git a/src/Wolfgang.Extensions.Logging.InMemoryLogger/InMemoryLoggerProvider.cs b/src/Wolfgang.Extensions.Logging.InMemoryLogger/InMemoryLoggerProvider.cs new file mode 100644 index 0000000..f0f0670 --- /dev/null +++ b/src/Wolfgang.Extensions.Logging.InMemoryLogger/InMemoryLoggerProvider.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Wolfgang.Extensions.Logging.InMemoryLogger; + +/// +/// An that creates instances +/// and provides access to all log entries across all loggers. +/// +public sealed class InMemoryLoggerProvider : ILoggerProvider +{ + private readonly ConcurrentDictionary _loggers = + new ConcurrentDictionary(StringComparer.Ordinal); + + private readonly LogLevel _minLogLevel; + + + + /// + /// Initializes a new instance of the class. + /// + /// The minimum log level for all loggers created by this provider. + public InMemoryLoggerProvider(LogLevel minLogLevel = LogLevel.Trace) + { + _minLogLevel = minLogLevel; + } + + + + /// + /// Creates a new with the given category name. + /// + /// The category name for messages produced by the logger. + /// An instance. + /// + /// Thrown when is . + /// + public ILogger CreateLogger(string categoryName) + { + if (categoryName == null) + { + throw new ArgumentNullException(nameof(categoryName)); + } + + return _loggers.GetOrAdd + ( + categoryName, + name => new InMemoryLogger(name, _minLogLevel) + ); + } + + + + /// + /// Returns all log entries from all loggers created by this provider. + /// Entries are grouped by logger category; ordering across categories is not guaranteed. + /// + /// + /// Each access materializes a snapshot of the entries across all loggers; the cost is + /// O(n) in the total entry count. Test code typically reads this once per assertion. + /// + [SuppressMessage("Major Code Smell", "S2365:Properties should not make collection or array copies", + Justification = "This is a test-helper property whose entire purpose is to return a snapshot view; the copy semantics are intentional and idiomatic for assertion code.")] + public IReadOnlyList> LogEntries + { + get + { + return _loggers.Values + .SelectMany(logger => logger.LogEntries) + .ToArray(); + } + } + + + + /// + /// Returns all logger instances created by this provider. + /// + /// + /// Each access materializes a snapshot copy of the underlying logger map; cost is O(n) + /// in the number of distinct categories logged through this provider. + /// + [SuppressMessage("Major Code Smell", "S2365:Properties should not make collection or array copies", + Justification = "This is a test-helper property whose entire purpose is to return a snapshot view; the copy semantics are intentional and idiomatic for assertion code.")] + public IReadOnlyDictionary Loggers => + new Dictionary(_loggers, StringComparer.Ordinal); + + + + /// + public void Dispose() + { + } +} diff --git a/src/Wolfgang.Extensions.Logging.InMemoryLogger/Properties/AssemblyInfo.cs b/src/Wolfgang.Extensions.Logging.InMemoryLogger/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..da38ac8 --- /dev/null +++ b/src/Wolfgang.Extensions.Logging.InMemoryLogger/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit")] diff --git a/src/Wolfgang.Extensions.Logging.InMemoryLogger/Wolfgang.Extensions.Logging.InMemoryLogger.csproj b/src/Wolfgang.Extensions.Logging.InMemoryLogger/Wolfgang.Extensions.Logging.InMemoryLogger.csproj new file mode 100644 index 0000000..6f081ef --- /dev/null +++ b/src/Wolfgang.Extensions.Logging.InMemoryLogger/Wolfgang.Extensions.Logging.InMemoryLogger.csproj @@ -0,0 +1,40 @@ + + + net462;netstandard2.0;netstandard2.1;net8.0;net10.0 + latest + enable + true + 0.1.0 + $(AssemblyName) + Chris Wolfgang + Implementations of ILogger and ILogger<T> that write log entries to an in-memory collection + Copyright 2026 Chris Wolfgang + https://github.com/Chris-Wolfgang/In-Memory-Logger + README.md + 1.0.0 + MIT + True + True + False + + + + enable + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/InMemoryLoggerBuilderExtensionsTests.cs b/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/InMemoryLoggerBuilderExtensionsTests.cs new file mode 100644 index 0000000..dac6b0e --- /dev/null +++ b/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/InMemoryLoggerBuilderExtensionsTests.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit; + +public class InMemoryLoggerBuilderExtensionsTests +{ + [Fact] + public void AddInMemoryLogger_when_called_registers_provider() + { + var provider = new InMemoryLoggerProvider(); + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddInMemoryLogger(provider)); + + using var serviceProvider = services.BuildServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + logger.LogInformation("Test message"); + + Assert.Single(provider.LogEntries); + } + + + [Fact] + public void AddInMemoryLogger_when_builder_is_null_throws_ArgumentNullException() + { + var provider = new InMemoryLoggerProvider(); + + var ex = Assert.Throws + ( + () => InMemoryLoggerBuilderExtensions.AddInMemoryLogger(null!, provider) + ); + Assert.Equal("builder", ex.ParamName); + } + + + [Fact] + public void AddInMemoryLogger_when_provider_is_null_throws_ArgumentNullException() + { + var services = new ServiceCollection(); + + var ex = Assert.Throws + ( + () => services.AddLogging(builder => builder.AddInMemoryLogger(null!)) + ); + Assert.Equal("provider", ex.ParamName); + } +} diff --git a/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/InMemoryLoggerOfTTests.cs b/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/InMemoryLoggerOfTTests.cs new file mode 100644 index 0000000..7ea112a --- /dev/null +++ b/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/InMemoryLoggerOfTTests.cs @@ -0,0 +1,194 @@ +using Microsoft.Extensions.Logging; + +namespace Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit; + +public class InMemoryLoggerOfTTests +{ + [Fact] + public void Ctor_when_default_parameters_creates_instance() + { + var sut = new InMemoryLogger(); + + Assert.NotNull(sut); + } + + + [Fact] + public void LogEntries_when_nothing_is_logged_returns_empty_list() + { + var sut = new InMemoryLogger(); + + Assert.Empty(sut.LogEntries); + } + + + [Fact] + public void Log_when_called_adds_entry_to_LogEntries() + { + var sut = new InMemoryLogger(); + sut.Log + ( + LogLevel.Information, + new EventId(1, "TestEvent"), + "Test message", + exception: null, + (state, _) => state + ); + + Assert.Single(sut.LogEntries); + var logEntry = sut.LogEntries[0]; + Assert.Equal(LogLevel.Information, logEntry.LogLevel); + Assert.Equal(1, logEntry.EventId.Id); + Assert.Equal("TestEvent", logEntry.EventId.Name); + + // Produce the formatted message the same way the logger would + var formatted = logEntry.Formatter.Invoke(logEntry.State!, logEntry.Exception); + Assert.Equal("Test message", formatted); + + Assert.Null(logEntry.Exception); + } + + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + [InlineData(LogLevel.Warning)] + [InlineData(LogLevel.Error)] + [InlineData(LogLevel.Critical)] + [InlineData(LogLevel.None)] + public void Ctor_when_setting_minimumLogLevel_sets_for_the_instance(LogLevel minLogLevel) + { + var sut = new InMemoryLogger(minLogLevel); + + Assert.Equal(minLogLevel, sut.MinimumLogLevel); + } + + + + [Fact] + public void Ctor_when_default_parameters_MinimumLogLevel_is_Trace() + { + var sut = new InMemoryLogger(); + + Assert.Equal(LogLevel.Trace, sut.MinimumLogLevel); + } + + + + [Fact] + public void Log_when_MinimumLogLevel_is_None_does_not_add_entry() + { + var sut = new InMemoryLogger(LogLevel.None); + + sut.LogTrace("Test message"); + + Assert.Empty(sut.LogEntries); + } + + + [Fact] + public void Log_when_MinimumLogLevel_is_Trace_adds_entry() + { + var sut = new InMemoryLogger(LogLevel.Trace); + + sut.LogTrace("Test message"); + + Assert.NotEmpty(sut.LogEntries); + } + + + [Fact] + public void IsEnabled_when_logLevel_is_None_returns_false() + { + var sut = new InMemoryLogger(LogLevel.Trace); + + Assert.False(sut.IsEnabled(LogLevel.None)); + } + + + + [Fact] + public void Ctor_when_capacity_is_less_than_1_throws_ArgumentOutOfRangeException() + { + var ex = Assert.Throws + ( + () => new InMemoryLogger(capacity: 0) + ); + Assert.Equal("capacity", ex.ParamName); + } + + + [Theory] + [InlineData(10)] + [InlineData(100)] + public void Ctor_when_capacity_specified_initializes_to_specified_value(int capacity) + { + var sut = new InMemoryLogger(capacity: capacity); + + Assert.Equal(capacity, sut.Capacity); + } + + + [Fact] + public void Ctor_when_capacity_not_specified_initializes_to_16() + { + var sut = new InMemoryLogger(); + + Assert.Equal(16, sut.Capacity); + } + + + [Fact] + public void BeginScope_returns_non_null_disposable() + { + var sut = new InMemoryLogger(); + + var scope = sut.BeginScope("test scope"); + + Assert.NotNull(scope); + scope.Dispose(); + } + + + [Fact] + public void BeginScope_when_active_Scopes_contains_scope_state() + { + var sut = new InMemoryLogger(); + + using (sut.BeginScope("outer")) + { + Assert.Single(sut.Scopes); + Assert.Equal("outer", sut.Scopes[0]); + } + } + + + [Fact] + public void BeginScope_when_nested_Scopes_contains_outermost_to_innermost() + { + var sut = new InMemoryLogger(); + + using (sut.BeginScope("outer")) + using (sut.BeginScope("inner")) + { + Assert.Equal(2, sut.Scopes.Length); + Assert.Equal("outer", sut.Scopes[0]); + Assert.Equal("inner", sut.Scopes[1]); + } + } + + + [Fact] + public void BeginScope_when_disposed_Scopes_is_empty() + { + var sut = new InMemoryLogger(); + + using (sut.BeginScope("scope")) + { + Assert.Single(sut.Scopes); + } + + Assert.Empty(sut.Scopes); + } +} diff --git a/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/InMemoryLoggerProviderTests.cs b/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/InMemoryLoggerProviderTests.cs new file mode 100644 index 0000000..7e04bb1 --- /dev/null +++ b/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/InMemoryLoggerProviderTests.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.Logging; + +namespace Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit; + +public class InMemoryLoggerProviderTests +{ + [Fact] + public void CreateLogger_when_called_returns_ILogger_instance() + { + using var sut = new InMemoryLoggerProvider(); + + var logger = sut.CreateLogger("TestCategory"); + + Assert.NotNull(logger); + Assert.IsType(logger); + } + + + [Fact] + public void CreateLogger_when_called_twice_with_same_category_returns_same_instance() + { + using var sut = new InMemoryLoggerProvider(); + + var logger1 = sut.CreateLogger("TestCategory"); + var logger2 = sut.CreateLogger("TestCategory"); + + Assert.Same(logger1, logger2); + } + + + [Fact] + public void CreateLogger_when_called_with_different_categories_returns_different_instances() + { + using var sut = new InMemoryLoggerProvider(); + + var logger1 = sut.CreateLogger("Category1"); + var logger2 = sut.CreateLogger("Category2"); + + Assert.NotSame(logger1, logger2); + } + + + [Fact] + public void LogEntries_when_multiple_loggers_log_contains_all_entries() + { + using var sut = new InMemoryLoggerProvider(); + + var logger1 = sut.CreateLogger("Category1"); + var logger2 = sut.CreateLogger("Category2"); + logger1.LogInformation("Message 1"); + logger2.LogWarning("Message 2"); + + Assert.Equal(2, sut.LogEntries.Count); + } + + + [Fact] + public void Loggers_returns_all_created_loggers() + { + using var sut = new InMemoryLoggerProvider(); + + sut.CreateLogger("Category1"); + sut.CreateLogger("Category2"); + + Assert.Equal(2, sut.Loggers.Count); + Assert.True(sut.Loggers.ContainsKey("Category1")); + Assert.True(sut.Loggers.ContainsKey("Category2")); + } + + + [Fact] + public void Ctor_when_minLogLevel_specified_loggers_use_that_level() + { + using var sut = new InMemoryLoggerProvider(LogLevel.Warning); + + var logger = sut.CreateLogger("TestCategory"); + logger.LogInformation("Should be filtered"); + logger.LogWarning("Should be logged"); + + Assert.Single(sut.LogEntries); + } + + + [Fact] + public void Dispose_does_not_throw() + { + var sut = new InMemoryLoggerProvider(); + + var ex = Record.Exception(() => sut.Dispose()); + + Assert.Null(ex); + } +} diff --git a/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/InMemoryLoggerTests.cs b/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/InMemoryLoggerTests.cs new file mode 100644 index 0000000..e1e0f72 --- /dev/null +++ b/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/InMemoryLoggerTests.cs @@ -0,0 +1,164 @@ +using Microsoft.Extensions.Logging; + +namespace Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit; + +public class InMemoryLoggerTests +{ + [Fact] + public void Ctor_when_default_parameters_creates_instance() + { + var sut = new InMemoryLogger("TestCategory"); + + Assert.Equal("TestCategory", sut.Category); + } + + + [Fact] + public void Ctor_when_category_is_null_throws_ArgumentNullException() + { + var ex = Assert.Throws + ( + () => new InMemoryLogger(null!) + ); + Assert.Equal("category", ex.ParamName); + } + + + [Fact] + public void Ctor_when_capacity_is_less_than_1_throws_ArgumentOutOfRangeException() + { + var ex = Assert.Throws + ( + () => new InMemoryLogger("TestCategory", capacity: 0) + ); + Assert.Equal("capacity", ex.ParamName); + } + + + [Fact] + public void Ctor_when_default_parameters_MinimumLogLevel_is_Trace() + { + var sut = new InMemoryLogger("TestCategory"); + + Assert.Equal(LogLevel.Trace, sut.MinimumLogLevel); + } + + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + [InlineData(LogLevel.Warning)] + [InlineData(LogLevel.Error)] + [InlineData(LogLevel.Critical)] + [InlineData(LogLevel.None)] + public void Ctor_when_setting_minimumLogLevel_sets_for_the_instance(LogLevel minLogLevel) + { + var sut = new InMemoryLogger("TestCategory", minLogLevel); + + Assert.Equal(minLogLevel, sut.MinimumLogLevel); + } + + + [Fact] + public void LogEntries_when_nothing_is_logged_returns_empty_list() + { + var sut = new InMemoryLogger("TestCategory"); + + Assert.Empty(sut.LogEntries); + } + + + [Fact] + public void Log_when_called_adds_entry_to_LogEntries() + { + var sut = new InMemoryLogger("TestCategory"); + sut.Log + ( + LogLevel.Information, + new EventId(1, "TestEvent"), + "Test message", + exception: null, + (state, _) => state + ); + + Assert.Single(sut.LogEntries); + var logEntry = sut.LogEntries[0]; + Assert.Equal(LogLevel.Information, logEntry.LogLevel); + Assert.Equal("TestCategory", logEntry.Category); + Assert.Equal(1, logEntry.EventId.Id); + } + + + [Fact] + public void Log_when_MinimumLogLevel_is_None_does_not_add_entry() + { + var sut = new InMemoryLogger("TestCategory", LogLevel.None); + + sut.LogTrace("Test message"); + + Assert.Empty(sut.LogEntries); + } + + + [Fact] + public void IsEnabled_when_logLevel_is_None_returns_false() + { + var sut = new InMemoryLogger("TestCategory"); + + Assert.False(sut.IsEnabled(LogLevel.None)); + } + + + [Theory] + [InlineData(10)] + [InlineData(100)] + public void Ctor_when_capacity_specified_initializes_to_specified_value(int capacity) + { + var sut = new InMemoryLogger("TestCategory", capacity: capacity); + + Assert.Equal(capacity, sut.Capacity); + } + + + [Fact] + public void BeginScope_when_active_Scopes_contains_scope_state() + { + var sut = new InMemoryLogger("TestCategory"); + + using (sut.BeginScope("outer")) + { + Assert.Single(sut.Scopes); + Assert.Equal("outer", sut.Scopes[0]); + } + } + + + [Fact] + public void BeginScope_when_nested_Scopes_contains_outermost_to_innermost() + { + var sut = new InMemoryLogger("TestCategory"); + + using (sut.BeginScope("outer")) + using (sut.BeginScope("inner")) + { + Assert.Equal(2, sut.Scopes.Length); + Assert.Equal("outer", sut.Scopes[0]); + Assert.Equal("inner", sut.Scopes[1]); + } + } + + + [Fact] + public void BeginScope_when_disposed_Scopes_is_empty() + { + var sut = new InMemoryLogger("TestCategory"); + + using (sut.BeginScope("scope")) + { + Assert.Single(sut.Scopes); + } + + Assert.Empty(sut.Scopes); + } +} diff --git a/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit.csproj b/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit.csproj new file mode 100644 index 0000000..ba0af73 --- /dev/null +++ b/tests/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit/Wolfgang.Extensions.Logging.InMemoryLogger.Tests.Unit.csproj @@ -0,0 +1,32 @@ + + + + net462;net47;net471;net472;net48;net481;net5.0;net6.0;net7.0;net8.0;net9.0;net10.0 + latest + enable + enable + true + + NU1701 + false + true + + + + + + + + + + + + + + + + + + + +