From d28976d2ee4e9176d32579f59b693549293d424c Mon Sep 17 00:00:00 2001 From: "Frank R. Haugen" Date: Sun, 3 Mar 2024 15:38:26 +0100 Subject: [PATCH] Major changes to hosted base tests, upgraded nugets and a new in-memory logger to make asserting logging possible --- .../Frank.Testing.EntityFrameworkCore.csproj | 2 +- .../Frank.Testing.Logging.csproj | 4 +- Frank.Testing.Logging/InMemoryLogEntry.cs | 76 ++++++++++++++ Frank.Testing.Logging/InMemoryLogger.cs | 28 ++++++ .../InMemoryLoggerProvider.cs | 17 ++++ Frank.Testing.Logging/InMemoryLoggerScope.cs | 11 +++ Frank.Testing.Logging/JsonFormatter.cs | 11 +++ Frank.Testing.Logging/JsonTestLogger.cs | 48 +++++++++ .../JsonTestLoggerProvider.cs | 23 +++++ .../LoggingBuilderExtensions.cs | 23 ++++- .../SimpleTestLoggerProvider.cs | 9 +- .../Frank.Testing.TestBases.csproj | 2 +- .../HostApplicationTestBase.cs | 36 +++++-- .../WebApplicationExtensions.cs | 12 +++ .../WebApplicationTestBase.cs | 99 +++++++++++++++++++ .../WebHostApplicationTestBase.cs | 46 --------- Frank.Testing.TestBases/WebHostExtensions.cs | 45 --------- .../DbContextBuilderTests.cs | 20 +--- .../Frank.Testing.Tests.csproj | 10 +- .../TestBases/HostApplicationTestBaseTests.cs | 7 +- .../WebHostApplicationTestBaseTests.cs | 60 +++++------ ...HostApplicationTestBaseWithStartupTests.cs | 36 ++++--- 22 files changed, 447 insertions(+), 178 deletions(-) create mode 100644 Frank.Testing.Logging/InMemoryLogEntry.cs create mode 100644 Frank.Testing.Logging/InMemoryLogger.cs create mode 100644 Frank.Testing.Logging/InMemoryLoggerProvider.cs create mode 100644 Frank.Testing.Logging/InMemoryLoggerScope.cs create mode 100644 Frank.Testing.Logging/JsonFormatter.cs create mode 100644 Frank.Testing.Logging/JsonTestLogger.cs create mode 100644 Frank.Testing.Logging/JsonTestLoggerProvider.cs create mode 100644 Frank.Testing.TestBases/WebApplicationExtensions.cs create mode 100644 Frank.Testing.TestBases/WebApplicationTestBase.cs delete mode 100644 Frank.Testing.TestBases/WebHostApplicationTestBase.cs delete mode 100644 Frank.Testing.TestBases/WebHostExtensions.cs diff --git a/Frank.Testing.EntityFrameworkCore/Frank.Testing.EntityFrameworkCore.csproj b/Frank.Testing.EntityFrameworkCore/Frank.Testing.EntityFrameworkCore.csproj index f8ea016..a6b7e1f 100644 --- a/Frank.Testing.EntityFrameworkCore/Frank.Testing.EntityFrameworkCore.csproj +++ b/Frank.Testing.EntityFrameworkCore/Frank.Testing.EntityFrameworkCore.csproj @@ -6,6 +6,6 @@ - + diff --git a/Frank.Testing.Logging/Frank.Testing.Logging.csproj b/Frank.Testing.Logging/Frank.Testing.Logging.csproj index f2bcd41..7107a49 100644 --- a/Frank.Testing.Logging/Frank.Testing.Logging.csproj +++ b/Frank.Testing.Logging/Frank.Testing.Logging.csproj @@ -6,11 +6,11 @@ - + - + diff --git a/Frank.Testing.Logging/InMemoryLogEntry.cs b/Frank.Testing.Logging/InMemoryLogEntry.cs new file mode 100644 index 0000000..d94871d --- /dev/null +++ b/Frank.Testing.Logging/InMemoryLogEntry.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Logging; + +namespace Frank.Testing.Logging; + +public class InMemoryLogEntry +{ + /// + /// Gets the log level of the application. + /// + /// The log level. + public LogLevel LogLevel { get; } + + /// + /// Gets the unique identifier of an event. + /// + public EventId EventId { get; } + + /// + /// Gets the exception associated with the property. + /// + /// + /// The exception associated with the property, or null if no exception occurred. + /// + public Exception? Exception { get; } + + /// + /// Gets the name of the category. + /// + /// The name of the category. + public string CategoryName { get; } + + /// + /// Gets the message associated with this property. + /// + public string Message { get; } + + /// + /// Gets the state of the object. + /// + /// + /// 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. + /// + /// A read-only list of key-value pairs representing the state of the object. + public IReadOnlyList>? State { get; } + + /// + /// Represents a log pulse, which encapsulates information about a log event. + /// + /// The level of the log event. + /// The identifier of the log event. + /// The exception associated with the log event, if any. + /// The name of the log category. + /// The log message. + /// + public InMemoryLogEntry(LogLevel logLevel, EventId eventId, Exception? exception, string categoryName, string message, IReadOnlyList>? state) + { + LogLevel = logLevel; + EventId = eventId; + Exception = exception; + CategoryName = categoryName; + Message = message; + State = state; + } + + /// + /// Returns a string representation of the object. + /// + /// + /// 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 + /// + public override string ToString() => $"[{LogLevel}] ({EventId}) {CategoryName}: '{Message}'\n\t{Exception}"; +} \ No newline at end of file diff --git a/Frank.Testing.Logging/InMemoryLogger.cs b/Frank.Testing.Logging/InMemoryLogger.cs new file mode 100644 index 0000000..c76684a --- /dev/null +++ b/Frank.Testing.Logging/InMemoryLogger.cs @@ -0,0 +1,28 @@ +using Frank.Reflection; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Frank.Testing.Logging; + +public class InMemoryLogger(IOptions options, string category) : ILogger +{ + private readonly List _logEntries = new(); + + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => _logEntries.Add(new InMemoryLogEntry(logLevel, eventId, exception, category, formatter(state, exception), state as IReadOnlyList>)); + + /// + public bool IsEnabled(LogLevel logLevel) => options.Value.Rules.Any(rule => rule.ProviderName == "InMemoryLogger" && rule.LogLevel <= logLevel); + + /// + public IDisposable? BeginScope(TState state) where TState : notnull => new InMemoryLoggerScope(state); + + public IReadOnlyList GetLogEntries() => _logEntries; +} + +public class InMemoryLogger : InMemoryLogger, ILogger +{ + public InMemoryLogger(IOptions options) : base(options, typeof(T).GetFullFriendlyName()) { } +} \ No newline at end of file diff --git a/Frank.Testing.Logging/InMemoryLoggerProvider.cs b/Frank.Testing.Logging/InMemoryLoggerProvider.cs new file mode 100644 index 0000000..2c92e6e --- /dev/null +++ b/Frank.Testing.Logging/InMemoryLoggerProvider.cs @@ -0,0 +1,17 @@ +using System.Collections.Concurrent; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Frank.Testing.Logging; + +public class InMemoryLoggerProvider(IOptions options) : ILoggerProvider +{ + private readonly ConcurrentDictionary _loggers = new(); + + /// + public ILogger CreateLogger(string categoryName) => _loggers.GetOrAdd(categoryName, new InMemoryLogger(options, categoryName)); + + /// + public void Dispose() => _loggers.Clear(); +} \ No newline at end of file diff --git a/Frank.Testing.Logging/InMemoryLoggerScope.cs b/Frank.Testing.Logging/InMemoryLoggerScope.cs new file mode 100644 index 0000000..48cd23e --- /dev/null +++ b/Frank.Testing.Logging/InMemoryLoggerScope.cs @@ -0,0 +1,11 @@ +namespace Frank.Testing.Logging; + +public class InMemoryLoggerScope : 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}"); + + /// + public void Dispose() => State = default; +} \ No newline at end of file diff --git a/Frank.Testing.Logging/JsonFormatter.cs b/Frank.Testing.Logging/JsonFormatter.cs new file mode 100644 index 0000000..cd3cdff --- /dev/null +++ b/Frank.Testing.Logging/JsonFormatter.cs @@ -0,0 +1,11 @@ +using System.Text.Json; + +namespace Frank.Testing.Logging; + +public class JsonFormatter +{ + public static string Format(TState state, Exception? exception) + { + return JsonSerializer.Serialize(state); + } +} \ No newline at end of file diff --git a/Frank.Testing.Logging/JsonTestLogger.cs b/Frank.Testing.Logging/JsonTestLogger.cs new file mode 100644 index 0000000..b50160e --- /dev/null +++ b/Frank.Testing.Logging/JsonTestLogger.cs @@ -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; + } + + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func 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>).ToString()); + } + + /// + public bool IsEnabled(LogLevel logLevel) + { + return _logLevel <= logLevel; + } + + /// + public IDisposable? BeginScope(TState state) where TState : notnull + { + return null; + } +} \ No newline at end of file diff --git a/Frank.Testing.Logging/JsonTestLoggerProvider.cs b/Frank.Testing.Logging/JsonTestLoggerProvider.cs new file mode 100644 index 0000000..2ea7157 --- /dev/null +++ b/Frank.Testing.Logging/JsonTestLoggerProvider.cs @@ -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() + { + } +} \ No newline at end of file diff --git a/Frank.Testing.Logging/LoggingBuilderExtensions.cs b/Frank.Testing.Logging/LoggingBuilderExtensions.cs index 52f0c24..771fed6 100644 --- a/Frank.Testing.Logging/LoggingBuilderExtensions.cs +++ b/Frank.Testing.Logging/LoggingBuilderExtensions.cs @@ -39,10 +39,31 @@ public static ILoggingBuilder AddSimpleTestLoggingProvider(this ILoggingBuilder builder.AddProvider(); return builder; } + + public static ILoggingBuilder AddInMemoryLoggingProvider(this ILoggingBuilder builder, LogLevel logLevel = LogLevel.Debug) + { + builder.Services.Configure(options => + { + options.MinLevel = logLevel; + }); + builder.AddProvider(); + return builder; + } + + public static ILoggingBuilder AddJsonTestLoggingProvider(this ILoggingBuilder builder, ITestOutputHelper outputHelper, LogLevel logLevel = LogLevel.Debug) + { + builder.Services.AddSingleton(outputHelper); + builder.Services.Configure(options => + { + options.MinLevel = logLevel; + }); + builder.AddProvider(); + return builder; + } public static ILoggingBuilder AddProvider(this ILoggingBuilder builder) where T : class, ILoggerProvider { builder.Services.AddSingleton(); return builder; } -} +} \ No newline at end of file diff --git a/Frank.Testing.Logging/SimpleTestLoggerProvider.cs b/Frank.Testing.Logging/SimpleTestLoggerProvider.cs index 3e5b0bb..df60baa 100644 --- a/Frank.Testing.Logging/SimpleTestLoggerProvider.cs +++ b/Frank.Testing.Logging/SimpleTestLoggerProvider.cs @@ -7,16 +7,13 @@ namespace Frank.Testing.Logging; -public class SimpleTestLoggerProvider(ITestOutputHelper outputHelper, IOptionsMonitor options) : ILoggerProvider +public class SimpleTestLoggerProvider(ITestOutputHelper outputHelper, IOptions options) : ILoggerProvider { private readonly ConcurrentDictionary _loggers = new(); /// - public void Dispose() - { - _loggers.Clear(); - } + public void Dispose() => _loggers.Clear(); /// - 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)); } \ No newline at end of file diff --git a/Frank.Testing.TestBases/Frank.Testing.TestBases.csproj b/Frank.Testing.TestBases/Frank.Testing.TestBases.csproj index 76ed464..699ad4d 100644 --- a/Frank.Testing.TestBases/Frank.Testing.TestBases.csproj +++ b/Frank.Testing.TestBases/Frank.Testing.TestBases.csproj @@ -8,7 +8,7 @@ - + diff --git a/Frank.Testing.TestBases/HostApplicationTestBase.cs b/Frank.Testing.TestBases/HostApplicationTestBase.cs index a9bb85f..9b47900 100644 --- a/Frank.Testing.TestBases/HostApplicationTestBase.cs +++ b/Frank.Testing.TestBases/HostApplicationTestBase.cs @@ -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; +/// +/// 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 +/// 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; + + /// + /// Creates a new instance of with the specified logger provider and log level + /// + /// + /// + 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("!!!"); + /// + /// The services of the host application after it starts + /// + /// + protected IServiceProvider GetServices => (_initialized ? _scope?.ServiceProvider : throw new InvalidOperationException("Not initialized yet.")) ?? throw new InvalidOperationException("Unreachable situation."); + /// + /// Setup the host application before it starts + /// + /// protected virtual async Task SetupAsync(HostApplicationBuilder builder) => await Task.CompletedTask; + /// public async Task InitializeAsync() { await SetupAsync(_hostApplicationBuilder); _host = _hostApplicationBuilder.Build(); await _host.StartAsync(_cancellationTokenSource.Token); + _scope = _host.Services.CreateScope(); _initialized = true; } - + + /// public async Task DisposeAsync() { await _cancellationTokenSource.CancelAsync(); diff --git a/Frank.Testing.TestBases/WebApplicationExtensions.cs b/Frank.Testing.TestBases/WebApplicationExtensions.cs new file mode 100644 index 0000000..f578520 --- /dev/null +++ b/Frank.Testing.TestBases/WebApplicationExtensions.cs @@ -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) + }; +} \ No newline at end of file diff --git a/Frank.Testing.TestBases/WebApplicationTestBase.cs b/Frank.Testing.TestBases/WebApplicationTestBase.cs new file mode 100644 index 0000000..2c9a022 --- /dev/null +++ b/Frank.Testing.TestBases/WebApplicationTestBase.cs @@ -0,0 +1,99 @@ +using System.Net; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using Xunit; + +namespace Frank.Testing.TestBases; + +/// +/// The base class for web application tests that uses the and to setup and run the web application for testing, and provides the to make requests to the web application using HttpClient +/// +public abstract class WebApplicationTestBase : IAsyncLifetime +{ + private readonly WebApplicationBuilder _hostApplicationBuilder; + private WebApplication? _application; + private readonly CancellationTokenSource _cancellationTokenSource = new(); + private bool _initialized = false; + private IServiceScope? _scope; + + /// + /// Creates a new instance of with the specified logger provider and log level + /// + /// + /// + protected WebApplicationTestBase(ILoggerProvider loggerProvider, LogLevel logLevel = LogLevel.Error) + { + _hostApplicationBuilder = WebApplication.CreateBuilder(); + _hostApplicationBuilder.Logging.AddDebug().AddProvider(loggerProvider).SetMinimumLevel(logLevel); + } + + /// + /// The services of the host application after it starts + /// + /// + protected IServiceProvider GetServices => (_initialized ? _scope?.ServiceProvider : throw new InvalidOperationException("The host has not been initialized yet.")) ?? throw new InvalidOperationException("!!!"); + + /// + /// Setup the web application before it starts + /// + /// The web application builder to setup + protected virtual async Task SetupAsync(WebApplicationBuilder builder) => await Task.CompletedTask; + + /// + /// Setup the host application + /// + /// The web application to setup + protected virtual async Task SetupApplicationAsync(WebApplication application) => await Task.CompletedTask; + + /// + /// Returns the port to run the application on + /// + /// + protected virtual int GetPort() => Random.Shared.Next(5000, 6000 + 1); + + /// + /// The test client to make requests to the web application using HttpClient + /// + /// + protected HttpClient GetTestClient => (_initialized ? _application?.CreateTestClient() : throw new InvalidOperationException("The host has not been initialized yet.")) ?? throw new InvalidOperationException("!!!"); + + /// + /// The endpoints of the web application after it starts. Useful for ensuring that the endpoints are registered correctly + /// + protected IEnumerable GetEndpoints => GetServices.GetRequiredService().Endpoints; + + /// + /// The routes of the web application after it starts. Useful for ensuring that the routes are registered correctly and for making requests to the web application using HttpClient + /// + protected IEnumerable GetEndpointRoutes => GetEndpoints.Select(e => e).Cast().Select(e => e.RoutePattern.RawText)!; + + /// + public async Task InitializeAsync() + { + await SetupAsync(_hostApplicationBuilder); + _hostApplicationBuilder.WebHost.UseKestrel(kestrelOptions => + { + kestrelOptions.Listen(IPAddress.Parse("127.0.0.1"), GetPort()); + }); + _application = _hostApplicationBuilder.Build(); + await SetupApplicationAsync(_application); + await _application.StartAsync(_cancellationTokenSource.Token); + _scope = _application.Services.CreateScope(); + _initialized = true; + } + + /// + public async Task DisposeAsync() + { + await _cancellationTokenSource.CancelAsync(); + await _application?.StopAsync()!; + await _application.WaitForShutdownAsync(); + } +} \ No newline at end of file diff --git a/Frank.Testing.TestBases/WebHostApplicationTestBase.cs b/Frank.Testing.TestBases/WebHostApplicationTestBase.cs deleted file mode 100644 index 926d1d7..0000000 --- a/Frank.Testing.TestBases/WebHostApplicationTestBase.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Frank.Testing.Logging; - -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Logging; - -using Xunit; -using Xunit.Abstractions; - -namespace Frank.Testing.TestBases; - -public abstract class WebHostApplicationTestBase : IAsyncLifetime -{ - private readonly IWebHostBuilder _hostApplicationBuilder; - private IWebHost? _host; - private readonly CancellationTokenSource _cancellationTokenSource = new(); - private bool _initialized = false; - - protected WebHostApplicationTestBase(ITestOutputHelper outputHelper, LogLevel logLevel = LogLevel.Information) - { - _hostApplicationBuilder = WebHost.CreateDefaultBuilder(); - _hostApplicationBuilder.ConfigureLogging(logging => logging.AddSimpleTestLoggingProvider(outputHelper, logLevel)); - } - - public IServiceProvider Services => (_initialized ? _host?.Services : throw new InvalidOperationException("The host has not been initialized yet.")) ?? throw new InvalidOperationException("!!!"); - - protected virtual async Task SetupAsync(IWebHostBuilder builder) => await Task.CompletedTask; - - protected HttpClient TestClient => (_initialized ? _host?.CreateTestClient() : throw new InvalidOperationException("The host has not been initialized yet.")) ?? throw new InvalidOperationException("!!!"); - protected IEnumerable GetServerEndpoints() => (_initialized ? _host?.GetServerEndpoints() : throw new InvalidOperationException("The host has not been initialized yet.")) ?? throw new InvalidOperationException("!!!"); - public async Task InitializeAsync() - { - await SetupAsync(_hostApplicationBuilder); - _host = _hostApplicationBuilder.Build(); - await _host.StartAsync(_cancellationTokenSource.Token); - _initialized = true; - } - - public async Task DisposeAsync() - { - await _cancellationTokenSource.CancelAsync(); - await _host?.StopAsync()!; - await _host.WaitForShutdownAsync(); - _host.Dispose(); - } -} \ No newline at end of file diff --git a/Frank.Testing.TestBases/WebHostExtensions.cs b/Frank.Testing.TestBases/WebHostExtensions.cs deleted file mode 100644 index 6024f3e..0000000 --- a/Frank.Testing.TestBases/WebHostExtensions.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; - -namespace Frank.Testing.TestBases; - -public static class ServiceCollectionExtensions -{ - public static IServiceCollection Replace(this IServiceCollection services) - where TService : class - where TImplementation : class, TService - { - var descriptors = services.Where(d => d.ServiceType == typeof(TService)).ToList(); - if (!descriptors.Any()) - return services;// throw new InvalidOperationException($"No service of type {typeof(TService)} has been registered."); - - foreach (var descriptor in descriptors) - { - services.Remove(descriptor); - var clone = new ServiceDescriptor(typeof(TService), typeof(TImplementation), descriptor.Lifetime); - services.Add(clone); - } - - return services; - } -} - -public static class WebHostExtensions -{ - public static HttpClient CreateTestClient(this IWebHost host) - { - var baseAddress = host.ServerFeatures.Get()?.Addresses.First(); - - return new HttpClient - { - BaseAddress = new Uri(baseAddress ?? throw new InvalidOperationException("The host has not been initialized yet."), UriKind.Absolute) - }; - } - - public static IEnumerable GetServerEndpoints(this IWebHost host) - { - return host.Services.GetServices().SelectMany(x => x.Endpoints).Select(x => x.DisplayName); - } -} \ No newline at end of file diff --git a/Frank.Testing.Tests/EntityFramworkCoreTests/DbContextBuilderTests.cs b/Frank.Testing.Tests/EntityFramworkCoreTests/DbContextBuilderTests.cs index 73bc729..bfb12ba 100644 --- a/Frank.Testing.Tests/EntityFramworkCoreTests/DbContextBuilderTests.cs +++ b/Frank.Testing.Tests/EntityFramworkCoreTests/DbContextBuilderTests.cs @@ -19,10 +19,8 @@ public class DbContextBuilderTests(ITestOutputHelper outputHelper) [Fact] public void Build_WithLoggerProvider_UsesLoggerProvider() { - var options = Substitute.For>(); - options.CurrentValue.Returns(new LoggerFilterOptions() { MinLevel = LogLevel.Debug }); var dbContext = new DbContextBuilder() - .WithLoggerProvider(new SimpleTestLoggerProvider(outputHelper, options)) + .WithLoggerProvider(new SimpleTestLoggerProvider(outputHelper, Options.Create(new LoggerFilterOptions() { MinLevel = LogLevel.Debug }))) .Build(); dbContext.Database.EnsureCreated(); dbContext.Database.ExecuteSqlRaw("SELECT 1"); @@ -32,10 +30,8 @@ public void Build_WithLoggerProvider_UsesLoggerProvider() [Fact] public void Build_WithService_UsesService() { - var options = Substitute.For>(); - options.CurrentValue.Returns(new LoggerFilterOptions() { MinLevel = LogLevel.Debug }); var dbContext = new DbContextBuilder() - .WithLoggerProvider(new SimpleTestLoggerProvider(outputHelper, options)) + .WithLoggerProvider(new SimpleTestLoggerProvider(outputHelper, Options.Create(new LoggerFilterOptions() { MinLevel = LogLevel.Debug }))) .WithService(services => services.AddSingleton()) .Build(); Assert.NotNull(dbContext.GetService()); @@ -44,10 +40,8 @@ public void Build_WithService_UsesService() [Fact] public void Build_WithOptions_UsesOptions() { - var options = Substitute.For>(); - options.CurrentValue.Returns(new LoggerFilterOptions() { MinLevel = LogLevel.Debug }); var dbContext = new DbContextBuilder() - .WithLoggerProvider(new SimpleTestLoggerProvider(outputHelper, options)) + .WithLoggerProvider(new SimpleTestLoggerProvider(outputHelper, Options.Create(new LoggerFilterOptions() { MinLevel = LogLevel.Debug }))) .WithOptions(options => options.UseSqlite("Data Source=:memory:")) .Build(); dbContext.Database.EnsureCreated(); @@ -58,10 +52,8 @@ public void Build_WithOptions_UsesOptions() [Fact] public void Build_WithLoggerProviderAndService_UsesLoggerProviderAndService() { - var options = Substitute.For>(); - options.CurrentValue.Returns(new LoggerFilterOptions() { MinLevel = LogLevel.Debug }); var dbContext = new DbContextBuilder() - .WithLoggerProvider(new SimpleTestLoggerProvider(outputHelper, options)) + .WithLoggerProvider(new SimpleTestLoggerProvider(outputHelper, Options.Create(new LoggerFilterOptions() { MinLevel = LogLevel.Debug }))) .WithSqliteConnectionString("Data Source=MyTestDatabase.db") .WithService(services => services.AddSingleton()) .Build(); @@ -74,10 +66,8 @@ public void Build_WithLoggerProviderAndService_UsesLoggerProviderAndService() [Fact] public void Build_WithLoggerProviderAndOptions_UsesLoggerProviderAndOptions() { - var options = Substitute.For>(); - options.CurrentValue.Returns(new LoggerFilterOptions() { MinLevel = LogLevel.Debug }); var dbContext = new DbContextBuilder() - .WithLoggerProvider(new SimpleTestLoggerProvider(outputHelper, options)) + .WithLoggerProvider(new SimpleTestLoggerProvider(outputHelper, Options.Create(new LoggerFilterOptions() { MinLevel = LogLevel.Debug }))) .WithSqliteConnectionString("Data Source=MyTestDatabase.db") .Build(); dbContext.Database.EnsureCreated(); diff --git a/Frank.Testing.Tests/Frank.Testing.Tests.csproj b/Frank.Testing.Tests/Frank.Testing.Tests.csproj index 9e82f46..0162ec6 100644 --- a/Frank.Testing.Tests/Frank.Testing.Tests.csproj +++ b/Frank.Testing.Tests/Frank.Testing.Tests.csproj @@ -9,18 +9,18 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Frank.Testing.Tests/TestBases/HostApplicationTestBaseTests.cs b/Frank.Testing.Tests/TestBases/HostApplicationTestBaseTests.cs index 08431a3..825c57b 100644 --- a/Frank.Testing.Tests/TestBases/HostApplicationTestBaseTests.cs +++ b/Frank.Testing.Tests/TestBases/HostApplicationTestBaseTests.cs @@ -1,16 +1,19 @@ using FluentAssertions; +using FluentAssertions.Common; +using Frank.Testing.Logging; using Frank.Testing.TestBases; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Xunit.Abstractions; namespace Frank.Testing.Tests.TestBases; -public class HostApplicationTestBaseTests(ITestOutputHelper outputHelper) : HostApplicationTestBase(outputHelper) +public class HostApplicationTestBaseTests(ITestOutputHelper outputHelper) : HostApplicationTestBase(new SimpleTestLoggerProvider(outputHelper, Options.Create(new LoggerFilterOptions { MinLevel = LogLevel.Debug }))) { protected override Task SetupAsync(HostApplicationBuilder builder) { @@ -28,7 +31,7 @@ public async Task Test() [Fact] public void Test2() { - var service = Services.GetService(); + var service = GetServices.GetService(); service.Should().NotBeNull(); } diff --git a/Frank.Testing.Tests/TestBases/WebHostApplicationTestBaseTests.cs b/Frank.Testing.Tests/TestBases/WebHostApplicationTestBaseTests.cs index 07aa9a1..2f38889 100644 --- a/Frank.Testing.Tests/TestBases/WebHostApplicationTestBaseTests.cs +++ b/Frank.Testing.Tests/TestBases/WebHostApplicationTestBaseTests.cs @@ -1,48 +1,45 @@ -using System.Net; - -using FluentAssertions; +using FluentAssertions; +using Frank.Testing.Logging; using Frank.Testing.TestBases; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Xunit.Abstractions; namespace Frank.Testing.Tests.TestBases; -public class WebHostApplicationTestBaseTests(ITestOutputHelper outputHelper) : WebHostApplicationTestBase(outputHelper) +public class WebHostApplicationTestBaseTests(ITestOutputHelper outputHelper) : WebApplicationTestBase(new InMemoryLoggerProvider(Options.Create(new LoggerFilterOptions() {MinLevel = LogLevel.Debug}))) { - protected override Task SetupAsync(IWebHostBuilder builder) + /// + protected override Task SetupAsync(WebApplicationBuilder builder) + { + builder.Services.AddControllers().AddApplicationPart(typeof(MyController).Assembly); // Add controllers from the assembly where MyController is defined + builder.Services.AddHealthChecks(); + return Task.CompletedTask; + } + + /// + protected override Task SetupApplicationAsync(WebApplication application) { - builder.ConfigureServices((context, services) => - { - services.AddControllersWithViews(); - }); - builder.Configure((context, app) => - { - app.UseRouting(); - app.UseEndpoints(endpoints => + application.MapControllers(); + application.MapHealthChecks("/health"); + application.MapGet("/test1", async httpContext => { - // endpoints.MapHealthChecks("/health"); - endpoints.MapControllers(); - endpoints.MapGet("/test1", async httpContext => - { - await httpContext.Response.WriteAsync("Test1 endpoint"); - }); - + await httpContext.Response.WriteAsync("Test1 endpoint"); }); - }); return Task.CompletedTask; } [Fact] public async Task Test() { - var response = await TestClient.GetAsync("/test1"); + var response = await GetTestClient.GetAsync("/test1"); var content = await response.Content.ReadAsStringAsync(); content.Should().Be("Test1 endpoint"); } @@ -50,23 +47,20 @@ public async Task Test() [Fact] public async Task Test2() { - var response = await TestClient.GetAsync("/test2"); + outputHelper.WriteLine("Endpoints:"); + foreach (var endpointRoute in GetEndpointRoutes) outputHelper.WriteLine(endpointRoute); + + var response = await GetTestClient.GetAsync("/test2"); var content = await response.Content.ReadAsStringAsync(); + // outputHelper.WriteLine(response); content.Should().Be("Test2 endpoint"); } - - [Fact] - public void Test3() - { - var endpoints = GetServerEndpoints(); - // endpoints = Enumerable.Empty(); - outputHelper.WriteLine(string.Join(Environment.NewLine, endpoints)); - } } +[ApiController] public class MyController : ControllerBase { - [HttpGet("/test2")] + [HttpGet("test2")] public IActionResult Test2() { return Ok("Test2 endpoint"); diff --git a/Frank.Testing.Tests/TestBases/WebHostApplicationTestBaseWithStartupTests.cs b/Frank.Testing.Tests/TestBases/WebHostApplicationTestBaseWithStartupTests.cs index 5d388e7..98d6bf2 100644 --- a/Frank.Testing.Tests/TestBases/WebHostApplicationTestBaseWithStartupTests.cs +++ b/Frank.Testing.Tests/TestBases/WebHostApplicationTestBaseWithStartupTests.cs @@ -1,29 +1,37 @@ -using System.Net; - -using FluentAssertions; +using FluentAssertions; +using Frank.Testing.Logging; using Frank.Testing.TestBases; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Xunit.Abstractions; namespace Frank.Testing.Tests.TestBases; -public class WebHostApplicationTestBaseWithStartupTests(ITestOutputHelper outputHelper) : WebHostApplicationTestBase(outputHelper) +public class WebHostApplicationTestBaseWithStartupTests(ITestOutputHelper outputHelper) : WebApplicationTestBase(new InMemoryLoggerProvider(Options.Create(new LoggerFilterOptions() {MinLevel = LogLevel.Debug}))) { - protected override Task SetupAsync(IWebHostBuilder builder) + /// + protected override Task SetupAsync(WebApplicationBuilder builder) { - builder.UseStartup(); - builder.ConfigureServices(services => + builder.Services.AddControllers(); + builder.Services.AddSingleton(); + + return Task.CompletedTask; + } + + /// + protected override Task SetupApplicationAsync(WebApplication application) + { + application.UseRouting(); + application.MapControllers(); + application.MapGet("/test1", async httpContext => { - services.Replace(); + await httpContext.Response.WriteAsync("Test1 endpoint"); }); return Task.CompletedTask; } @@ -31,9 +39,13 @@ protected override Task SetupAsync(IWebHostBuilder builder) [Fact] public async Task Test() { - var service = Services.GetRequiredService(); + var service = GetServices.GetRequiredService(); service.DoSomething(); + + var myServiceLogger = GetServices.GetRequiredService>(); + var inMemoryLogger = myServiceLogger as InMemoryLogger; + inMemoryLogger?.GetLogEntries().Should().Contain(log => log.Message == "DoSomething"); } }