diff --git a/Directory.Build.props b/Directory.Build.props index be0d163..77f1df3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.1.3 + 1.1.4 MIT diff --git a/README.md b/README.md index 8abbd74..85c5430 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ Simplified, structured logging for modern .NET apps โ€” overloads, conditionals, - ๐Ÿ”ง **Easy Integration** - Drop-in replacement for standard ILogger calls - ๐ŸŽฏ **Scope Management** - Comprehensive scope tracking with automatic disposal - โšก **Performance Monitoring** - Built-in timing and performance tracking -- ๐Ÿงช **Testing Support** - Complete testing framework with assertions and mocking -- ๐Ÿ“ฆ **Multi-Target** - Supports .NET 8.0, .NET 9.0, and .NET Standard 2.1 +- ๐Ÿงช **Testing Support** - In-memory `TestLogger` with assertions and search helpers +- ๐Ÿ“ฆ **Multi-Target** - Supports .NET 8.0, .NET 9.0, .NET 10.0, .NET Standard 2.1, and .NET Standard 2.0 ## Installation @@ -107,6 +107,12 @@ using (logger.BeginScopeWith(new { UserId = userId, SessionId = sessionId })) // Session logic } +// Caller-aware scopes (adds MemberName/FilePath/LineNumber) +using (logger.BeginCallerScope()) +{ + logger.Debug("Tracing caller details"); +} + // Timed scopes for performance monitoring using (logger.TimeOperation("DatabaseQuery")) { @@ -170,10 +176,10 @@ using (logger.TimeMethod()) ## Testing Support -Comprehensive testing framework for verifying logging behavior: +`TestLogger` implements `ILogger` and captures entries in memory so you can assert against what was written. Extension methods help you inspect entries, check for the presence of messages, and perform simple assertions that throw `InvalidOperationException` when they fail. ```csharp -[Test] +[Fact] public void Should_Log_User_Registration() { // Arrange @@ -184,17 +190,14 @@ public void Should_Log_User_Registration() userService.RegisterUser("john@example.com"); // Assert - testLogger.Should().HaveLoggedInformation() - .WithMessage("User registered successfully") - .WithProperty("Email", "john@example.com"); - - // Alternative assertion syntax - testLogger.AssertLogEntry(LogLevel.Information, "User registered"); testLogger.AssertLogCount(1); - testLogger.Should().HaveExactly(1).LogEntries(); + testLogger.AssertLogEntry(LogLevel.Information, "User registered"); + + var entry = testLogger.GetLastLogEntry(); + entry!.FormattedMessage.Should().Contain("john@example.com"); } -[Test] +[Fact] public void Should_Handle_Registration_Errors() { // Arrange @@ -202,12 +205,11 @@ public void Should_Handle_Registration_Errors() var userService = new UserService(testLogger); // Act & Assert - Assert.Throws(() => + Assert.Throws(() => userService.RegisterUser("invalid-email")); - testLogger.Should().HaveLoggedError() - .WithException() - .WithMessage("Invalid email format"); + testLogger.AssertLogEntry(LogLevel.Error, "Invalid email format"); + testLogger.HasLogEntryWithException().Should().BeTrue(); } ``` @@ -216,20 +218,27 @@ public void Should_Handle_Registration_Errors() ```csharp // Get specific log entries var lastEntry = testLogger.GetLastLogEntry(); +var secondEntry = testLogger.GetLogEntry(1); var errorEntries = testLogger.GetLogEntries(LogLevel.Error); var entriesWithException = testLogger.GetLogEntriesWithException(); // Search log entries var userEntries = testLogger.GetLogEntriesContaining("user"); var hasError = testLogger.HasLogEntry(LogLevel.Error, "failed"); +var hasArgumentError = testLogger.HasLogEntryWithException(LogLevel.Error); // Assertions +testLogger.AssertLogEntry(LogLevel.Warning, "threshold"); +testLogger.AssertLogEntryAt(0, LogLevel.Information, "started"); testLogger.AssertLogCount(5); -testLogger.AssertLogEntry(LogLevel.Information, "expected message"); -testLogger.AssertNoLogEntries(LogLevel.Error); +testLogger.AssertLogCount(LogLevel.Error, 1); +testLogger.AssertNoLogEntries(); // Clear logs between tests testLogger.Clear(); + +// Optional: only record entries at or above this level (defaults to Trace) +testLogger.MinimumLogLevel = LogLevel.Information; ``` ## Advanced Usage diff --git a/src/LayeredCraft.StructuredLogging/LayeredCraft.StructuredLogging.csproj b/src/LayeredCraft.StructuredLogging/LayeredCraft.StructuredLogging.csproj index 039c2a8..b7db07c 100644 --- a/src/LayeredCraft.StructuredLogging/LayeredCraft.StructuredLogging.csproj +++ b/src/LayeredCraft.StructuredLogging/LayeredCraft.StructuredLogging.csproj @@ -41,7 +41,7 @@ - + diff --git a/src/LayeredCraft.StructuredLogging/Testing/TestingExtensions.cs b/src/LayeredCraft.StructuredLogging/Testing/TestingExtensions.cs index 1a06c84..174a0bf 100644 --- a/src/LayeredCraft.StructuredLogging/Testing/TestingExtensions.cs +++ b/src/LayeredCraft.StructuredLogging/Testing/TestingExtensions.cs @@ -9,286 +9,275 @@ namespace LayeredCraft.StructuredLogging.Testing; /// public static class TestingExtensions { - /// - /// Gets the most recent log entry from the test logger, or null if no entries exist. - /// /// The test logger instance. - /// The last log entry, or null if no entries exist. - /// - /// - /// var lastEntry = testLogger.GetLastLogEntry(); - /// Assert.NotNull(lastEntry); - /// Assert.Equal(LogLevel.Information, lastEntry.LogLevel); - /// - /// - public static LogEntry? GetLastLogEntry(this TestLogger logger) + extension(TestLogger logger) { - return logger.LogEntries.LastOrDefault(); - } + /// + /// Gets the most recent log entry from the test logger, or null if no entries exist. + /// + /// The last log entry, or null if no entries exist. + /// + /// + /// var lastEntry = testLogger.GetLastLogEntry(); + /// Assert.NotNull(lastEntry); + /// Assert.Equal(LogLevel.Information, lastEntry.LogLevel); + /// + /// + public LogEntry? GetLastLogEntry() + { + return logger.LogEntries.LastOrDefault(); + } - /// - /// Gets a specific log entry by index, or null if the index is out of range. - /// - /// The test logger instance. - /// The zero-based index of the log entry to retrieve. - /// The log entry at the specified index, or null if the index is out of range. - /// - /// - /// var firstEntry = testLogger.GetLogEntry(0); - /// var secondEntry = testLogger.GetLogEntry(1); - /// - /// - public static LogEntry? GetLogEntry(this TestLogger logger, int index) - { - return index >= 0 && index < logger.LogEntries.Count ? logger.LogEntries[index] : null; - } + /// + /// Gets a specific log entry by index, or null if the index is out of range. + /// + /// The zero-based index of the log entry to retrieve. + /// The log entry at the specified index, or null if the index is out of range. + /// + /// + /// var firstEntry = testLogger.GetLogEntry(0); + /// var secondEntry = testLogger.GetLogEntry(1); + /// + /// + public LogEntry? GetLogEntry(int index) + { + return index >= 0 && index < logger.LogEntries.Count ? logger.LogEntries[index] : null; + } - /// - /// Gets all log entries that match the specified log level. - /// - /// The test logger instance. - /// The log level to filter by. - /// An enumerable of log entries with the specified log level. - /// - /// - /// var errorEntries = testLogger.GetLogEntries(LogLevel.Error); - /// var warningEntries = testLogger.GetLogEntries(LogLevel.Warning); - /// - /// - public static IEnumerable GetLogEntries(this TestLogger logger, LogLevel logLevel) - { - return logger.LogEntries.Where(e => e.LogLevel == logLevel); - } + /// + /// Gets all log entries that match the specified log level. + /// + /// The log level to filter by. + /// An enumerable of log entries with the specified log level. + /// + /// + /// var errorEntries = testLogger.GetLogEntries(LogLevel.Error); + /// var warningEntries = testLogger.GetLogEntries(LogLevel.Warning); + /// + /// + public IEnumerable GetLogEntries(LogLevel logLevel) + { + return logger.LogEntries.Where(e => e.LogLevel == logLevel); + } - /// - /// Gets all log entries whose formatted message contains the specified text. - /// - /// The test logger instance. - /// The text to search for in log messages. - /// An enumerable of log entries containing the specified text. - /// - /// - /// var userEntries = testLogger.GetLogEntriesContaining("user"); - /// var errorEntries = testLogger.GetLogEntriesContaining("failed"); - /// - /// - public static IEnumerable GetLogEntriesContaining(this TestLogger logger, string message) - { - return logger.LogEntries.Where(e => e.FormattedMessage?.Contains(message) == true); - } + /// + /// Gets all log entries whose formatted message contains the specified text. + /// + /// The text to search for in log messages. + /// An enumerable of log entries containing the specified text. + /// + /// + /// var userEntries = testLogger.GetLogEntriesContaining("user"); + /// var errorEntries = testLogger.GetLogEntriesContaining("failed"); + /// + /// + public IEnumerable GetLogEntriesContaining(string message) + { + return logger.LogEntries.Where(e => e.FormattedMessage?.Contains(message) == true); + } - /// - /// Gets all log entries that have an associated exception. - /// - /// The test logger instance. - /// An enumerable of log entries that have exceptions. - /// - /// - /// var entriesWithExceptions = testLogger.GetLogEntriesWithException(); - /// Assert.True(entriesWithExceptions.Any()); - /// - /// - public static IEnumerable GetLogEntriesWithException(this TestLogger logger) - { - return logger.LogEntries.Where(e => e.Exception != null); - } + /// + /// Gets all log entries that have an associated exception. + /// + /// An enumerable of log entries that have exceptions. + /// + /// + /// var entriesWithExceptions = testLogger.GetLogEntriesWithException(); + /// Assert.True(entriesWithExceptions.Any()); + /// + /// + public IEnumerable GetLogEntriesWithException() + { + return logger.LogEntries.Where(e => e.Exception != null); + } - /// - /// Gets all log entries that have an exception of the specified type. - /// - /// The type of exception to filter by. - /// The test logger instance. - /// An enumerable of log entries that have exceptions of the specified type. - /// - /// - /// var argumentExceptions = testLogger.GetLogEntriesWithException<ArgumentException>(); - /// var invalidOperationExceptions = testLogger.GetLogEntriesWithException<InvalidOperationException>(); - /// - /// - public static IEnumerable GetLogEntriesWithException(this TestLogger logger) where TException : Exception - { - return logger.LogEntries.Where(e => e.Exception is TException); - } + /// + /// Gets all log entries that have an exception of the specified type. + /// + /// The type of exception to filter by. + /// An enumerable of log entries that have exceptions of the specified type. + /// + /// + /// var argumentExceptions = testLogger.GetLogEntriesWithException<ArgumentException>(); + /// var invalidOperationExceptions = testLogger.GetLogEntriesWithException<InvalidOperationException>(); + /// + /// + public IEnumerable GetLogEntriesWithException() where TException : Exception + { + return logger.LogEntries.Where(e => e.Exception is TException); + } - /// - /// Checks if the logger has any log entry with the specified log level and optionally containing the specified message. - /// - /// The test logger instance. - /// The log level to search for. - /// Optional message text to search for within log entries. - /// True if a matching log entry is found, false otherwise. - /// - /// - /// Assert.True(testLogger.HasLogEntry(LogLevel.Error)); - /// Assert.True(testLogger.HasLogEntry(LogLevel.Information, "completed")); - /// - /// - public static bool HasLogEntry(this TestLogger logger, LogLevel logLevel, string? message = null) - { - return logger.LogEntries.Any(e => e.LogLevel == logLevel && - (message == null || e.FormattedMessage?.Contains(message) == true)); - } + /// + /// Checks if the logger has any log entry with the specified log level and optionally containing the specified message. + /// + /// The log level to search for. + /// Optional message text to search for within log entries. + /// True if a matching log entry is found, false otherwise. + /// + /// + /// Assert.True(testLogger.HasLogEntry(LogLevel.Error)); + /// Assert.True(testLogger.HasLogEntry(LogLevel.Information, "completed")); + /// + /// + public bool HasLogEntry(LogLevel logLevel, string? message = null) + { + return logger.LogEntries.Any(e => e.LogLevel == logLevel && + (message == null || e.FormattedMessage?.Contains(message) == true)); + } - /// - /// Checks if the logger has any log entry with an exception of the specified type and optionally with the specified log level. - /// - /// The type of exception to search for. - /// The test logger instance. - /// Optional log level to filter by. - /// True if a matching log entry with the specified exception type is found, false otherwise. - /// - /// - /// Assert.True(testLogger.HasLogEntryWithException<ArgumentException>()); - /// Assert.True(testLogger.HasLogEntryWithException<InvalidOperationException>(LogLevel.Error)); - /// - /// - public static bool HasLogEntryWithException(this TestLogger logger, LogLevel? logLevel = null) where TException : Exception - { - return logger.LogEntries.Any(e => e.Exception is TException && - (logLevel == null || e.LogLevel == logLevel)); - } + /// + /// Checks if the logger has any log entry with an exception of the specified type and optionally with the specified log level. + /// + /// The type of exception to search for. + /// Optional log level to filter by. + /// True if a matching log entry with the specified exception type is found, false otherwise. + /// + /// + /// Assert.True(testLogger.HasLogEntryWithException<ArgumentException>()); + /// Assert.True(testLogger.HasLogEntryWithException<InvalidOperationException>(LogLevel.Error)); + /// + /// + public bool HasLogEntryWithException(LogLevel? logLevel = null) where TException : Exception + { + return logger.LogEntries.Any(e => e.Exception is TException && + (logLevel == null || e.LogLevel == logLevel)); + } - /// - /// Asserts that the most recent log entry has the expected log level and optionally contains the expected message. - /// Throws an InvalidOperationException if the assertion fails. - /// - /// The test logger instance. - /// The expected log level of the last entry. - /// Optional expected message text that should be contained in the log entry. - /// Thrown when no log entries exist or the assertion fails. - /// - /// - /// testLogger.AssertLogEntry(LogLevel.Information); - /// testLogger.AssertLogEntry(LogLevel.Error, "failed to process"); - /// - /// - public static void AssertLogEntry(this TestLogger logger, LogLevel expectedLogLevel, string? expectedMessage = null) - { - var entry = logger.GetLastLogEntry(); - if (entry == null) - throw new InvalidOperationException("No log entries found"); + /// + /// Asserts that the most recent log entry has the expected log level and optionally contains the expected message. + /// Throws an InvalidOperationException if the assertion fails. + /// + /// The expected log level of the last entry. + /// Optional expected message text that should be contained in the log entry. + /// Thrown when no log entries exist or the assertion fails. + /// + /// + /// testLogger.AssertLogEntry(LogLevel.Information); + /// testLogger.AssertLogEntry(LogLevel.Error, "failed to process"); + /// + /// + public void AssertLogEntry(LogLevel expectedLogLevel, string? expectedMessage = null) + { + var entry = logger.GetLastLogEntry(); + if (entry == null) + throw new InvalidOperationException("No log entries found"); - if (entry.LogLevel != expectedLogLevel) - throw new InvalidOperationException($"Expected log level {expectedLogLevel}, but was {entry.LogLevel}"); + if (entry.LogLevel != expectedLogLevel) + throw new InvalidOperationException($"Expected log level {expectedLogLevel}, but was {entry.LogLevel}"); - if (expectedMessage != null && entry.FormattedMessage?.Contains(expectedMessage) != true) - throw new InvalidOperationException($"Expected message to contain '{expectedMessage}', but was '{entry.FormattedMessage}'"); - } + if (expectedMessage != null && entry.FormattedMessage?.Contains(expectedMessage) != true) + throw new InvalidOperationException($"Expected message to contain '{expectedMessage}', but was '{entry.FormattedMessage}'"); + } - /// - /// Asserts that the log entry at the specified index has the expected log level and optionally contains the expected message. - /// Throws an InvalidOperationException if the assertion fails. - /// - /// The test logger instance. - /// The zero-based index of the log entry to check. - /// The expected log level of the entry at the specified index. - /// Optional expected message text that should be contained in the log entry. - /// Thrown when no log entry exists at the index or the assertion fails. - /// - /// - /// testLogger.AssertLogEntryAt(0, LogLevel.Information); - /// testLogger.AssertLogEntryAt(1, LogLevel.Warning, "rate limit exceeded"); - /// - /// - public static void AssertLogEntryAt(this TestLogger logger, int index, LogLevel expectedLogLevel, string? expectedMessage = null) - { - var entry = logger.GetLogEntry(index); - if (entry == null) - throw new InvalidOperationException($"No log entry found at index {index}"); + /// + /// Asserts that the log entry at the specified index has the expected log level and optionally contains the expected message. + /// Throws an InvalidOperationException if the assertion fails. + /// + /// The zero-based index of the log entry to check. + /// The expected log level of the entry at the specified index. + /// Optional expected message text that should be contained in the log entry. + /// Thrown when no log entry exists at the index or the assertion fails. + /// + /// + /// testLogger.AssertLogEntryAt(0, LogLevel.Information); + /// testLogger.AssertLogEntryAt(1, LogLevel.Warning, "rate limit exceeded"); + /// + /// + public void AssertLogEntryAt(int index, LogLevel expectedLogLevel, string? expectedMessage = null) + { + var entry = logger.GetLogEntry(index); + if (entry == null) + throw new InvalidOperationException($"No log entry found at index {index}"); - if (entry.LogLevel != expectedLogLevel) - throw new InvalidOperationException($"Expected log level {expectedLogLevel} at index {index}, but was {entry.LogLevel}"); + if (entry.LogLevel != expectedLogLevel) + throw new InvalidOperationException($"Expected log level {expectedLogLevel} at index {index}, but was {entry.LogLevel}"); - if (expectedMessage != null && entry.FormattedMessage?.Contains(expectedMessage) != true) - throw new InvalidOperationException($"Expected message to contain '{expectedMessage}' at index {index}, but was '{entry.FormattedMessage}'"); - } + if (expectedMessage != null && entry.FormattedMessage?.Contains(expectedMessage) != true) + throw new InvalidOperationException($"Expected message to contain '{expectedMessage}' at index {index}, but was '{entry.FormattedMessage}'"); + } - /// - /// Asserts that the total number of log entries matches the expected count. - /// Throws an InvalidOperationException if the assertion fails. - /// - /// The test logger instance. - /// The expected total number of log entries. - /// Thrown when the actual count doesn't match the expected count. - /// - /// - /// testLogger.AssertLogCount(3); // Expects exactly 3 log entries - /// testLogger.AssertLogCount(0); // Expects no log entries - /// - /// - public static void AssertLogCount(this TestLogger logger, int expectedCount) - { - if (logger.LogEntries.Count != expectedCount) - throw new InvalidOperationException($"Expected {expectedCount} log entries, but found {logger.LogEntries.Count}"); - } + /// + /// Asserts that the total number of log entries matches the expected count. + /// Throws an InvalidOperationException if the assertion fails. + /// + /// The expected total number of log entries. + /// Thrown when the actual count doesn't match the expected count. + /// + /// + /// testLogger.AssertLogCount(3); // Expects exactly 3 log entries + /// testLogger.AssertLogCount(0); // Expects no log entries + /// + /// + public void AssertLogCount(int expectedCount) + { + if (logger.LogEntries.Count != expectedCount) + throw new InvalidOperationException($"Expected {expectedCount} log entries, but found {logger.LogEntries.Count}"); + } - /// - /// Asserts that the number of log entries with the specified log level matches the expected count. - /// Throws an InvalidOperationException if the assertion fails. - /// - /// The test logger instance. - /// The log level to filter by. - /// The expected number of log entries with the specified log level. - /// Thrown when the actual count doesn't match the expected count. - /// - /// - /// testLogger.AssertLogCount(LogLevel.Error, 2); // Expects exactly 2 error entries - /// testLogger.AssertLogCount(LogLevel.Warning, 0); // Expects no warning entries - /// - /// - public static void AssertLogCount(this TestLogger logger, LogLevel logLevel, int expectedCount) - { - var count = logger.GetLogEntries(logLevel).Count(); - if (count != expectedCount) - throw new InvalidOperationException($"Expected {expectedCount} log entries with level {logLevel}, but found {count}"); - } + /// + /// Asserts that the number of log entries with the specified log level matches the expected count. + /// Throws an InvalidOperationException if the assertion fails. + /// + /// The log level to filter by. + /// The expected number of log entries with the specified log level. + /// Thrown when the actual count doesn't match the expected count. + /// + /// + /// testLogger.AssertLogCount(LogLevel.Error, 2); // Expects exactly 2 error entries + /// testLogger.AssertLogCount(LogLevel.Warning, 0); // Expects no warning entries + /// + /// + public void AssertLogCount(LogLevel logLevel, int expectedCount) + { + var count = logger.GetLogEntries(logLevel).Count(); + if (count != expectedCount) + throw new InvalidOperationException($"Expected {expectedCount} log entries with level {logLevel}, but found {count}"); + } - /// - /// Asserts that the logger has no log entries. - /// Throws an InvalidOperationException if any log entries exist. - /// - /// The test logger instance. - /// Thrown when log entries exist. - /// - /// - /// testLogger.AssertNoLogEntries(); // Expects the logger to be empty - /// - /// - public static void AssertNoLogEntries(this TestLogger logger) - { - logger.AssertLogCount(0); - } + /// + /// Asserts that the logger has no log entries. + /// Throws an InvalidOperationException if any log entries exist. + /// + /// Thrown when log entries exist. + /// + /// + /// testLogger.AssertNoLogEntries(); // Expects the logger to be empty + /// + /// + public void AssertNoLogEntries() + { + logger.AssertLogCount(0); + } - /// - /// Asserts that the logger has no log entries with the specified log level. - /// Throws an InvalidOperationException if any log entries with the specified level exist. - /// - /// The test logger instance. - /// The log level to check for absence. - /// Thrown when log entries with the specified level exist. - /// - /// - /// testLogger.AssertNoLogEntries(LogLevel.Error); // Expects no error entries - /// - /// - public static void AssertNoLogEntries(this TestLogger logger, LogLevel logLevel) - { - logger.AssertLogCount(logLevel, 0); - } + /// + /// Asserts that the logger has no log entries with the specified log level. + /// Throws an InvalidOperationException if any log entries with the specified level exist. + /// + /// The log level to check for absence. + /// Thrown when log entries with the specified level exist. + /// + /// + /// testLogger.AssertNoLogEntries(LogLevel.Error); // Expects no error entries + /// + /// + public void AssertNoLogEntries(LogLevel logLevel) + { + logger.AssertLogCount(logLevel, 0); + } - /// - /// Clears all log entries from the test logger, resetting it to an empty state. - /// - /// The test logger instance. - /// - /// - /// testLogger.Clear(); // Removes all log entries - /// testLogger.AssertNoLogEntries(); // Now passes - /// - /// - public static void Clear(this TestLogger logger) - { - logger.LogEntries.Clear(); + /// + /// Clears all log entries from the test logger, resetting it to an empty state. + /// + /// + /// + /// testLogger.Clear(); // Removes all log entries + /// testLogger.AssertNoLogEntries(); // Now passes + /// + /// + public void Clear() + { + logger.LogEntries.Clear(); + } } } diff --git a/test/LayeredCraft.StructuredLogging.Tests/LayeredCraft.StructuredLogging.Tests.csproj b/test/LayeredCraft.StructuredLogging.Tests/LayeredCraft.StructuredLogging.Tests.csproj index aa2eae8..07d2171 100644 --- a/test/LayeredCraft.StructuredLogging.Tests/LayeredCraft.StructuredLogging.Tests.csproj +++ b/test/LayeredCraft.StructuredLogging.Tests/LayeredCraft.StructuredLogging.Tests.csproj @@ -16,6 +16,8 @@ For more information on Microsoft Testing Platform support in xUnit.net, please visit: https://xunit.net/docs/getting-started/v3/microsoft-testing-platform --> + true + true @@ -33,7 +35,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - +