Skip to content

Commit

Permalink
Major changes to hosted base tests, upgraded nugets and a new in-memo…
Browse files Browse the repository at this point in the history
…ry logger to make asserting logging possible
  • Loading branch information
frankhaugen committed Mar 3, 2024
1 parent a82c5f8 commit d28976d
Show file tree
Hide file tree
Showing 22 changed files with 447 additions and 178 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.2" />
</ItemGroup>
</Project>
4 changes: 2 additions & 2 deletions Frank.Testing.Logging/Frank.Testing.Logging.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Frank.PulseFlow.Logging" Version="1.7.0" />
<PackageReference Include="Frank.PulseFlow.Logging" Version="2.0.0" />
<PackageReference Include="Frank.Reflection" Version="1.3.0" />
<PackageReference Include="JetBrains.Annotations" Version="2023.3.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="xunit.extensibility.core" Version="2.6.6" />
<PackageReference Include="xunit.extensibility.core" Version="2.7.0" />
</ItemGroup>

<ItemGroup>
Expand Down
76 changes: 76 additions & 0 deletions Frank.Testing.Logging/InMemoryLogEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using Microsoft.Extensions.Logging;

namespace Frank.Testing.Logging;

public class InMemoryLogEntry
{
/// <summary>
/// Gets the log level of the application.
/// </summary>
/// <value>The log level.</value>
public LogLevel LogLevel { get; }

/// <summary>
/// Gets the unique identifier of an event.
/// </summary>
public EventId EventId { get; }

/// <summary>
/// Gets the exception associated with the property.
/// </summary>
/// <value>
/// The exception associated with the property, or null if no exception occurred.
/// </value>
public Exception? Exception { get; }

/// <summary>
/// Gets the name of the category.
/// </summary>
/// <value>The name of the category.</value>
public string CategoryName { get; }

/// <summary>
/// Gets the message associated with this property.
/// </summary>
public string Message { get; }

/// <summary>
/// Gets the state of the object.
/// </summary>
/// <remarks>
/// The state is represented as a collection of key-value pairs, where the key is a string and the value is an object.
/// The state is read-only and can be null if there is no state available.
/// </remarks>
/// <returns>A read-only list of key-value pairs representing the state of the object.</returns>
public IReadOnlyList<KeyValuePair<string, object?>>? State { get; }

/// <summary>
/// Represents a log pulse, which encapsulates information about a log event.
/// </summary>
/// <param name="logLevel">The level of the log event.</param>
/// <param name="eventId">The identifier of the log event.</param>
/// <param name="exception">The exception associated with the log event, if any.</param>
/// <param name="categoryName">The name of the log category.</param>
/// <param name="message">The log message.</param>
/// <param name="state"></param>
public InMemoryLogEntry(LogLevel logLevel, EventId eventId, Exception? exception, string categoryName, string message, IReadOnlyList<KeyValuePair<string, object?>>? state)
{
LogLevel = logLevel;
EventId = eventId;
Exception = exception;
CategoryName = categoryName;
Message = message;
State = state;
}

/// <summary>
/// Returns a string representation of the object.
/// </summary>
/// <returns>
/// A string representing the object. The string consists of the log level,
/// event ID, category name, message, and exception, formatted in the following way:
/// [LogLevel] (EventId) CategoryName: 'Message'
/// Exception
/// </returns>
public override string ToString() => $"[{LogLevel}] ({EventId}) {CategoryName}: '{Message}'\n\t{Exception}";
}
28 changes: 28 additions & 0 deletions Frank.Testing.Logging/InMemoryLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Frank.Reflection;

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Frank.Testing.Logging;

public class InMemoryLogger(IOptions<LoggerFilterOptions> options, string category) : ILogger
{
private readonly List<InMemoryLogEntry> _logEntries = new();

/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> _logEntries.Add(new InMemoryLogEntry(logLevel, eventId, exception, category, formatter(state, exception), state as IReadOnlyList<KeyValuePair<string, object?>>));

/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => options.Value.Rules.Any(rule => rule.ProviderName == "InMemoryLogger" && rule.LogLevel <= logLevel);

/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => new InMemoryLoggerScope<TState>(state);

public IReadOnlyList<InMemoryLogEntry> GetLogEntries() => _logEntries;
}

public class InMemoryLogger<T> : InMemoryLogger, ILogger<T>
{
public InMemoryLogger(IOptions<LoggerFilterOptions> options) : base(options, typeof(T).GetFullFriendlyName()) { }
}
17 changes: 17 additions & 0 deletions Frank.Testing.Logging/InMemoryLoggerProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Collections.Concurrent;

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Frank.Testing.Logging;

public class InMemoryLoggerProvider(IOptions<LoggerFilterOptions> options) : ILoggerProvider
{
private readonly ConcurrentDictionary<string, InMemoryLogger> _loggers = new();

/// <inheritdoc />
public ILogger CreateLogger(string categoryName) => _loggers.GetOrAdd(categoryName, new InMemoryLogger(options, categoryName));

/// <inheritdoc />
public void Dispose() => _loggers.Clear();
}
11 changes: 11 additions & 0 deletions Frank.Testing.Logging/InMemoryLoggerScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Frank.Testing.Logging;

public class InMemoryLoggerScope<T> : IDisposable
{
public T? State { get; private set; }

public InMemoryLoggerScope(object state) => State = state is T t ? t : throw new ArgumentException($"The state must be of type {typeof(T).Name}");

/// <inheritdoc />
public void Dispose() => State = default;
}
11 changes: 11 additions & 0 deletions Frank.Testing.Logging/JsonFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Text.Json;

namespace Frank.Testing.Logging;

public class JsonFormatter
{
public static string Format<TState>(TState state, Exception? exception)
{
return JsonSerializer.Serialize(state);
}
}
48 changes: 48 additions & 0 deletions Frank.Testing.Logging/JsonTestLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Text.Json;

using Frank.PulseFlow.Logging;

using Microsoft.Extensions.Logging;

using Xunit.Abstractions;

namespace Frank.Testing.Logging;

public class JsonTestLogger : ILogger
{
private readonly ITestOutputHelper _outputHelper;
private readonly LogLevel _logLevel;
private readonly string _categoryName;

public JsonTestLogger(ITestOutputHelper outputHelper, LogLevel logLevel, string categoryName)
{
_outputHelper = outputHelper;
_logLevel = logLevel;
_categoryName = categoryName;
}

/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
return;

var json = JsonFormatter.Format(state, exception);

JsonDocument document = JsonDocument.Parse(formatter.Invoke(state, exception));

_outputHelper.WriteLine(new LogPulse(logLevel, eventId, exception, _categoryName, formatter.Invoke(state, exception), state as IReadOnlyList<KeyValuePair<string, object?>>).ToString());
}

/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel)
{
return _logLevel <= logLevel;
}

/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
{
return null;
}
}
23 changes: 23 additions & 0 deletions Frank.Testing.Logging/JsonTestLoggerProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.Extensions.Logging;

using Xunit.Abstractions;

namespace Frank.Testing.Logging;

public class JsonTestLoggerProvider : ILoggerProvider
{
private readonly ITestOutputHelper _outputHelper;
private readonly LogLevel _logLevel;

public JsonTestLoggerProvider(ITestOutputHelper outputHelper, LogLevel logLevel)
{
_outputHelper = outputHelper;
_logLevel = logLevel;
}

public ILogger CreateLogger(string categoryName) => new JsonTestLogger(_outputHelper, _logLevel, categoryName);

public void Dispose()
{
}
}
23 changes: 22 additions & 1 deletion Frank.Testing.Logging/LoggingBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,31 @@ public static ILoggingBuilder AddSimpleTestLoggingProvider(this ILoggingBuilder
builder.AddProvider<SimpleTestLoggerProvider>();
return builder;
}

public static ILoggingBuilder AddInMemoryLoggingProvider(this ILoggingBuilder builder, LogLevel logLevel = LogLevel.Debug)
{
builder.Services.Configure<LoggerFilterOptions>(options =>
{
options.MinLevel = logLevel;
});
builder.AddProvider<InMemoryLoggerProvider>();
return builder;
}

public static ILoggingBuilder AddJsonTestLoggingProvider(this ILoggingBuilder builder, ITestOutputHelper outputHelper, LogLevel logLevel = LogLevel.Debug)
{
builder.Services.AddSingleton(outputHelper);
builder.Services.Configure<LoggerFilterOptions>(options =>
{
options.MinLevel = logLevel;
});
builder.AddProvider<JsonTestLoggerProvider>();
return builder;
}

public static ILoggingBuilder AddProvider<T>(this ILoggingBuilder builder) where T : class, ILoggerProvider
{
builder.Services.AddSingleton<ILoggerProvider, T>();
return builder;
}
}
}
9 changes: 3 additions & 6 deletions Frank.Testing.Logging/SimpleTestLoggerProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,13 @@

namespace Frank.Testing.Logging;

public class SimpleTestLoggerProvider(ITestOutputHelper outputHelper, IOptionsMonitor<LoggerFilterOptions> options) : ILoggerProvider
public class SimpleTestLoggerProvider(ITestOutputHelper outputHelper, IOptions<LoggerFilterOptions> options) : ILoggerProvider
{
private readonly ConcurrentDictionary<string, SimpleTestLogger> _loggers = new();

/// <inheritdoc />
public void Dispose()
{
_loggers.Clear();
}
public void Dispose() => _loggers.Clear();

/// <inheritdoc />
public ILogger CreateLogger(string categoryName) => _loggers.GetOrAdd(categoryName, new SimpleTestLogger(outputHelper, options.CurrentValue.MinLevel, categoryName));
public ILogger CreateLogger(string categoryName) => _loggers.GetOrAdd(categoryName, new SimpleTestLogger(outputHelper, options.Value.MinLevel, categoryName));
}
2 changes: 1 addition & 1 deletion Frank.Testing.TestBases/Frank.Testing.TestBases.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="xunit.extensibility.core" Version="2.6.6" />
<PackageReference Include="xunit.extensibility.core" Version="2.7.0" />
</ItemGroup>

<ItemGroup>
Expand Down
36 changes: 27 additions & 9 deletions Frank.Testing.TestBases/HostApplicationTestBase.cs
Original file line number Diff line number Diff line change
@@ -1,38 +1,56 @@
using Frank.Testing.Logging;

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

using Xunit;
using Xunit.Abstractions;

namespace Frank.Testing.TestBases;

/// <summary>
/// Base class for tests that require a host application to be started and stopped for testing like integration tests using HostedServices or background services in the host application
/// </summary>
public abstract class HostApplicationTestBase : IAsyncLifetime
{
private readonly HostApplicationBuilder _hostApplicationBuilder;
private IHost? _host;
private readonly CancellationTokenSource _cancellationTokenSource = new();
private bool _initialized = false;

protected HostApplicationTestBase(ITestOutputHelper outputHelper, LogLevel logLevel = LogLevel.Information)
private IServiceScope? _scope;

/// <summary>
/// Creates a new instance of <see cref="HostApplicationTestBase"/> with the specified logger provider and log level
/// </summary>
/// <param name="loggerProvider"></param>
/// <param name="logLevel"></param>
protected HostApplicationTestBase(ILoggerProvider loggerProvider, LogLevel logLevel = LogLevel.Error)
{
_hostApplicationBuilder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings());
_hostApplicationBuilder.Logging.AddSimpleTestLoggingProvider(outputHelper, logLevel);
_hostApplicationBuilder = Host.CreateApplicationBuilder();
_hostApplicationBuilder.Logging.AddDebug().AddProvider(loggerProvider).SetMinimumLevel(logLevel);
}

public IServiceProvider Services => (_initialized ? _host?.Services : throw new InvalidOperationException("The host has not been initialized yet.")) ?? throw new InvalidOperationException("!!!");
/// <summary>
/// The services of the host application after it starts
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
protected IServiceProvider GetServices => (_initialized ? _scope?.ServiceProvider : throw new InvalidOperationException("Not initialized yet.")) ?? throw new InvalidOperationException("Unreachable situation.");

/// <summary>
/// Setup the host application before it starts
/// </summary>
/// <param name="builder"></param>
protected virtual async Task SetupAsync(HostApplicationBuilder builder) => await Task.CompletedTask;

/// <inheritdoc />
public async Task InitializeAsync()
{
await SetupAsync(_hostApplicationBuilder);
_host = _hostApplicationBuilder.Build();
await _host.StartAsync(_cancellationTokenSource.Token);
_scope = _host.Services.CreateScope();
_initialized = true;
}


/// <inheritdoc />
public async Task DisposeAsync()
{
await _cancellationTokenSource.CancelAsync();
Expand Down
12 changes: 12 additions & 0 deletions Frank.Testing.TestBases/WebApplicationExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Builder;

namespace Frank.Testing.TestBases;

internal static class WebApplicationExtensions
{
public static HttpClient CreateTestClient(this WebApplication application) =>
new()
{
BaseAddress = new Uri(application.Urls.FirstOrDefault() ?? throw new InvalidOperationException("Base address for TestClient has not been initialized yet."), UriKind.Absolute)
};
}
Loading

0 comments on commit d28976d

Please sign in to comment.