From dbe53142e1a3f6d486bd9517260f17c2ed10292b Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Mon, 3 Aug 2026 23:22:06 +0200 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20add=20managed=20application?= =?UTF-8?q?=20fixtures=20for=20entrypoint-owned=20startup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces ManagedApplicationFixture and ManagedWebApplicationFixture for opt-in entrypoint-owned startup, enabling the application's Main method to own host configuration and lifecycle. Adds internal deferred host infrastructure to capture and lazily start hosts when the test host is consumed. Updates framework abstractions (HostTest, HostFixture) to support both legacy and managed startup paths for backward compatibility during this minor release. Refactors ApplicationHostFactory and WebApplicationTestFactory to route managed fixture requests through the deferred path. Marks BlockingManagedApplicationFixture and BlockingManagedWebApplicationFixture as obsolete. Updates bootstrap sample applications to demonstrate all startup patterns. --- .../Program.cs | 10 ++- .../BootstrapperMinimalConsoleState.cs | 8 ++ .../Program.cs | 13 +++- .../Program.cs | 6 ++ .../Program.cs | 6 ++ .../Program.cs | 10 ++- .../Program.cs | 10 ++- .../ClassicProgramState.cs | 8 ++ .../Program.cs | 12 ++- .../Program.cs | 6 ++ .../BlockingManagedWebApplicationFixture.cs | 4 +- .../ManagedWebApplicationFixture.cs | 75 +++++++++++++++++++ .../WebApplicationTest.cs | 13 +++- .../WebApplicationTestFactory.cs | 5 ++ .../ApplicationHostFactory.cs | 61 ++++----------- .../ApplicationTestFactory.cs | 5 ++ .../BlockingManagedApplicationFixture.cs | 3 + .../HostFixture.cs | 31 +++++++- .../HostTest.cs | 63 +++++++++++++--- .../IHostTest.cs | 13 ++-- .../Internal/DeferredHostBuilder.cs | 57 ++++++++++++-- .../Internal/DeferredHostFactory.cs | 33 ++++++++ .../Internal/HostBuilderFactory.cs | 28 +++++++ .../Internal/IDeferredHost.cs | 8 ++ .../Internal/ProgramHostFactoryResolver.cs | 33 +++++++- .../ManagedApplicationFixture.cs | 67 +++++++++++++++++ 26 files changed, 504 insertions(+), 84 deletions(-) create mode 100644 app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App/BootstrapperMinimalConsoleState.cs create mode 100644 app/Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App/ClassicProgramState.cs create mode 100644 src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/ManagedWebApplicationFixture.cs create mode 100644 src/Codebelt.Extensions.Xunit.Hosting/Internal/DeferredHostFactory.cs create mode 100644 src/Codebelt.Extensions.Xunit.Hosting/Internal/HostBuilderFactory.cs create mode 100644 src/Codebelt.Extensions.Xunit.Hosting/Internal/IDeferredHost.cs create mode 100644 src/Codebelt.Extensions.Xunit.Hosting/ManagedApplicationFixture.cs diff --git a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperConsole.App/Program.cs b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperConsole.App/Program.cs index f982687..0312133 100644 --- a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperConsole.App/Program.cs +++ b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperConsole.App/Program.cs @@ -1,13 +1,21 @@ using System.Threading.Tasks; using Codebelt.Bootstrapper.Console; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace Codebelt.Extensions.Xunit.Hosting.BootstrapperConsole.App; public sealed class Program : ConsoleProgram { + public static bool MainInvoked { get; private set; } + + public static bool EntrypointStarted { get; private set; } + public static Task Main(string[] args) { - return CreateHostBuilder(args).Build().RunAsync(); + MainInvoked = true; + var host = CreateHostBuilder(args).Build(); + host.Services.GetRequiredService().ApplicationStarted.Register(() => EntrypointStarted = true); + return host.RunAsync(); } } diff --git a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App/BootstrapperMinimalConsoleState.cs b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App/BootstrapperMinimalConsoleState.cs new file mode 100644 index 0000000..2a3ff7b --- /dev/null +++ b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App/BootstrapperMinimalConsoleState.cs @@ -0,0 +1,8 @@ +namespace Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App; + +public sealed class BootstrapperMinimalConsoleState +{ + public bool MainInvoked { get; internal set; } + + public bool EntrypointStarted { get; internal set; } +} diff --git a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App/Program.cs b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App/Program.cs index ab69e38..af8889e 100644 --- a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App/Program.cs +++ b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App/Program.cs @@ -12,14 +12,23 @@ public sealed class Program : MinimalConsoleProgram public static Task Main(string[] args) { var builder = CreateHostBuilder(args); + var state = new BootstrapperMinimalConsoleState { MainInvoked = true }; + builder.Services.AddSingleton(state); builder.Services.AddSingleton(new BootstrapperMinimalConsoleMarker("Bootstrapper Minimal Console")); var host = builder.Build(); + host.Services.GetRequiredService().ApplicationStarted.Register(() => state.EntrypointStarted = true); return host.RunAsync(); } - public override Task RunAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + public override async Task RunAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) { - return Task.CompletedTask; + try + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } } } diff --git a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalWeb.App/Program.cs b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalWeb.App/Program.cs index 3736b6a..cd8ee0f 100644 --- a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalWeb.App/Program.cs +++ b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalWeb.App/Program.cs @@ -7,12 +7,18 @@ namespace Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalWeb.App; public sealed class Program : MinimalWebProgram { + public static bool MainInvoked { get; private set; } + + public static bool EntrypointStarted { get; private set; } + public static Task Main(string[] args) { + MainInvoked = true; var builder = CreateHostBuilder(args); builder.Services.AddSingleton(new BootstrapperMinimalWebMarker("Bootstrapper Minimal Web")); var app = builder.Build(); + app.Lifetime.ApplicationStarted.Register(() => EntrypointStarted = true); app.MapGet("/", (BootstrapperMinimalWebMarker marker) => marker.Value); diff --git a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalWorker.App/Program.cs b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalWorker.App/Program.cs index e36ed7e..e66eadf 100644 --- a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalWorker.App/Program.cs +++ b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalWorker.App/Program.cs @@ -7,13 +7,19 @@ namespace Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalWorker.App; public sealed class Program : MinimalWorkerProgram { + public static bool MainInvoked { get; private set; } + + public static bool EntrypointStarted { get; private set; } + public static Task Main(string[] args) { + MainInvoked = true; var builder = CreateHostBuilder(args); builder.Services.AddSingleton(new BootstrapperMinimalWorkerMarker("Bootstrapper Minimal Worker")); builder.Services.AddHostedService(); var host = builder.Build(); + host.Services.GetRequiredService().ApplicationStarted.Register(() => EntrypointStarted = true); return host.RunAsync(); } } diff --git a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperWeb.App/Program.cs b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperWeb.App/Program.cs index 8e6f6ac..0b4b5e2 100644 --- a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperWeb.App/Program.cs +++ b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperWeb.App/Program.cs @@ -1,12 +1,20 @@ using Codebelt.Bootstrapper.Web; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace Codebelt.Extensions.Xunit.Hosting.BootstrapperWeb.App; public sealed class Program : WebProgram { + public static bool MainInvoked { get; private set; } + + public static bool EntrypointStarted { get; private set; } + public static void Main(string[] args) { - CreateHostBuilder(args).Build().Run(); + MainInvoked = true; + var host = CreateHostBuilder(args).Build(); + host.Services.GetRequiredService().ApplicationStarted.Register(() => EntrypointStarted = true); + host.Run(); } } diff --git a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperWorker.App/Program.cs b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperWorker.App/Program.cs index 95abd9e..2cc3513 100644 --- a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperWorker.App/Program.cs +++ b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperWorker.App/Program.cs @@ -1,13 +1,21 @@ using System.Threading.Tasks; using Codebelt.Bootstrapper.Worker; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace Codebelt.Extensions.Xunit.Hosting.BootstrapperWorker.App; public sealed class Program : WorkerProgram { + public static bool MainInvoked { get; private set; } + + public static bool EntrypointStarted { get; private set; } + public static async Task Main(string[] args) { - await CreateHostBuilder(args).Build().RunAsync().ConfigureAwait(false); + MainInvoked = true; + var host = CreateHostBuilder(args).Build(); + host.Services.GetRequiredService().ApplicationStarted.Register(() => EntrypointStarted = true); + await host.RunAsync().ConfigureAwait(false); } } diff --git a/app/Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App/ClassicProgramState.cs b/app/Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App/ClassicProgramState.cs new file mode 100644 index 0000000..c54efa4 --- /dev/null +++ b/app/Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App/ClassicProgramState.cs @@ -0,0 +1,8 @@ +namespace Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App; + +public sealed class ClassicProgramState +{ + public bool MainInvoked { get; internal set; } + + public bool EntrypointStarted { get; internal set; } +} diff --git a/app/Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App/Program.cs b/app/Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App/Program.cs index 619f8b2..b98f383 100644 --- a/app/Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App/Program.cs +++ b/app/Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App/Program.cs @@ -10,7 +10,11 @@ public sealed class Program { public static void Main(string[] args) { - CreateHostBuilder(args).Build().Run(); + var host = CreateHostBuilder(args).Build(); + var state = host.Services.GetRequiredService(); + state.MainInvoked = true; + host.Services.GetRequiredService().ApplicationStarted.Register(() => state.EntrypointStarted = true); + host.Run(); } public static IHostBuilder CreateHostBuilder(string[] args) @@ -18,7 +22,11 @@ public static IHostBuilder CreateHostBuilder(string[] args) return Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(builder => { - builder.ConfigureServices(services => services.AddSingleton(new ClassicProgramMarker("Classic Program"))); + builder.ConfigureServices(services => + { + services.AddSingleton(); + services.AddSingleton(new ClassicProgramMarker("Classic Program")); + }); builder.Configure(app => app.Run(async context => { var marker = context.RequestServices.GetRequiredService(); diff --git a/app/Codebelt.Extensions.Xunit.Hosting.Program.App/Program.cs b/app/Codebelt.Extensions.Xunit.Hosting.Program.App/Program.cs index feda131..478824b 100644 --- a/app/Codebelt.Extensions.Xunit.Hosting.Program.App/Program.cs +++ b/app/Codebelt.Extensions.Xunit.Hosting.Program.App/Program.cs @@ -8,13 +8,19 @@ namespace Codebelt.Extensions.Xunit.Hosting.Program.App; public sealed class Program { + public static bool MainInvoked { get; private set; } + + public static bool EntrypointStarted { get; private set; } + public static void Main(string[] args) { + MainInvoked = true; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(new ProgramMarker("Modern Program")); var app = builder.Build(); + app.Lifetime.ApplicationStarted.Register(() => EntrypointStarted = true); app.MapGet("/", (ProgramMarker marker, IHostEnvironment environment) => $"{marker.Value}|{environment.EnvironmentName}"); app.MapGet("/configuration", (IConfiguration configuration) => configuration["ProgramLane:Message"] ?? "Missing"); diff --git a/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/BlockingManagedWebApplicationFixture.cs b/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/BlockingManagedWebApplicationFixture.cs index 64f2886..cc425af 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/BlockingManagedWebApplicationFixture.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/BlockingManagedWebApplicationFixture.cs @@ -19,8 +19,10 @@ namespace Codebelt.Extensions.Xunit.Hosting.AspNetCore; /// /// Unlike the base managed web host fixtures, this fixture starts the resolved application host synchronously. /// ASP.NET Core application entry point testing must expose a started after fixture initialization. -/// There is no separate non-blocking managed web application fixture because this application-entry-point API is new and unreleased. +/// Use for entrypoint-owned startup in new tests. +/// This compatibility fixture is retained for the current minor release and should be removed or changed in the next major release. /// +[Obsolete("Use ManagedWebApplicationFixture so the application entry point owns host startup. This compatibility fixture will be removed or changed in the next major release.")] public class BlockingManagedWebApplicationFixture : HostFixture, IWebApplicationFixture where TEntryPoint : class { /// diff --git a/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/ManagedWebApplicationFixture.cs b/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/ManagedWebApplicationFixture.cs new file mode 100644 index 0000000..3f984e6 --- /dev/null +++ b/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/ManagedWebApplicationFixture.cs @@ -0,0 +1,75 @@ +using System; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Codebelt.Extensions.Xunit.Hosting.AspNetCore; + +/// +/// Provides an entrypoint-owned implementation of the interface. +/// +/// A type in the entry point assembly of the application. +/// +/// +/// +/// The ASP.NET Core application's Main method owns host startup. The fixture captures the host after it has been built without starting it during fixture setup; the test host starts the deferred host when it is consumed. +/// +public class ManagedWebApplicationFixture : HostFixture, IWebApplicationFixture where TEntryPoint : class +{ + /// + /// Initializes a new instance of the class. + /// + public ManagedWebApplicationFixture() + { + } + + /// + /// Creates and configures the of this instance. + /// + /// The object that inherits from . + /// was added to support those cases where the caller is required in the host configuration. + /// + /// is null. + /// + /// + /// is not assignable from . + /// + public virtual void ConfigureHost(Test hostTest) + { + ArgumentNullException.ThrowIfNull(hostTest); + if (!HasTypes(hostTest.GetType(), typeof(WebApplicationTest<,>))) { throw new ArgumentOutOfRangeException(nameof(hostTest), typeof(WebApplicationTest<,>), $"{nameof(hostTest)} is not assignable from WebApplicationTest."); } + if (this.HasValidState()) { return; } + + Host = CreateEntrypointOwnedHost(hostBuilder => hostBuilder.ConfigureWebHost(webHostBuilder => + { + webHostBuilder.UseTestServer(o => o.PreserveExecutionContext = true); + ConfigureWebHostCallback?.Invoke(webHostBuilder); + })); + try + { + Server = Host.GetTestServer(); + Configuration = Host.Services.GetRequiredService(); + Environment = Host.Services.GetRequiredService(); + + ConfigureCallback(Configuration, Environment); + } + finally + { + ReleaseEntrypoint(Host); + } + } + + /// + /// Gets or sets the delegate that provides a way to override the before the application is built. + /// + /// The delegate that provides a way to override the . + public Action ConfigureWebHostCallback { get; set; } + + /// + /// Gets the initialized by this instance. + /// + /// The initialized by this instance. + public TestServer Server { get; protected set; } +} diff --git a/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/WebApplicationTest.cs b/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/WebApplicationTest.cs index 623cbce..ebee567 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/WebApplicationTest.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/WebApplicationTest.cs @@ -14,6 +14,8 @@ namespace Codebelt.Extensions.Xunit.Hosting.AspNetCore; /// public abstract class WebApplicationTest : HostTest, IClassFixture where TEntryPoint : class where T : class, IWebApplicationFixture { + private TestServer _server; + /// /// Initializes a new instance of the class. /// @@ -53,7 +55,16 @@ protected WebApplicationTest(bool skipHostFixtureInitialization, T hostFixture, /// Gets the initialized by the . /// /// The initialized by the . - public TestServer Server { get; protected set; } + /// Accessing the server starts an entry-point-owned deferred host when necessary. + public TestServer Server + { + get + { + _ = Host; + return _server; + } + protected set => _server = value; + } /// /// Provides a way to override the defaults before the application is built. diff --git a/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/WebApplicationTestFactory.cs b/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/WebApplicationTestFactory.cs index 84c19fe..1dbe47c 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/WebApplicationTestFactory.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/WebApplicationTestFactory.cs @@ -19,8 +19,13 @@ public static class WebApplicationTestFactory /// The which may be configured. /// An optional implementation to use instead of the default instance. /// An instance of an implementation. + /// + /// Passing a opts this call into entrypoint-owned deferred startup. Omitting preserves the blocking compatibility path for the current minor release; that default should be removed or changed in the next major release. + /// public static IHostTest Create(Action webHostSetup = null, IWebApplicationFixture hostFixture = null) where TEntryPoint : class { + // Minor-release compatibility: keep the historical blocking default while allowing callers to opt in by passing ManagedWebApplicationFixture. + // Major release: remove or change this default when the compatibility fixture is retired. Hint: ManagedWebApplicationFixture return new Internal.WebApplicationTest(webHostSetup, hostFixture ?? new BlockingManagedWebApplicationFixture()); } diff --git a/src/Codebelt.Extensions.Xunit.Hosting/ApplicationHostFactory.cs b/src/Codebelt.Extensions.Xunit.Hosting/ApplicationHostFactory.cs index 15236cb..d77e2a5 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting/ApplicationHostFactory.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting/ApplicationHostFactory.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; using Codebelt.Extensions.Xunit.Hosting.Internal; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; namespace Codebelt.Extensions.Xunit.Hosting; @@ -16,7 +14,11 @@ public static class ApplicationHostFactory /// /// A type in the entry point assembly of the application. /// The delegate that provides a way to override the before the application is built. - /// A started instance. + /// A built instance. + /// + /// For compatibility, applications that expose CreateHostBuilder(string[]) are built through that factory. Applications that do not expose the legacy factory use the deferred entry-point path. + /// The legacy path and its wrapper behavior are retained for the current minor release; only the managed application fixtures opt into entrypoint-owned deferred startup. The legacy path should be removed or changed in the next major release. + /// /// /// The entry point assembly does not expose a supported application host. /// @@ -31,60 +33,27 @@ public static IHost Create(Action configureHost) wher /// A type in the entry point assembly of the application. /// The delegate that provides a way to override the before the application is built. /// A value indicating whether the entry point should be stopped after the host is built. - /// A started instance. + /// A built instance. + /// + /// For compatibility, applications that expose CreateHostBuilder(string[]) are built through that factory and the value is ignored for that path. + /// The legacy path and its wrapper behavior are retained for the current minor release; only the managed application fixtures opt into entrypoint-owned deferred startup. The legacy path should be removed or changed in the next major release. + /// /// /// The entry point assembly does not expose a supported application host. /// public static IHost Create(Action configureHost, bool stopApplication) where TEntryPoint : class { var assembly = typeof(TEntryPoint).Assembly; - var hostBuilder = ProgramHostFactoryResolver.ResolveHostBuilderFactory(assembly)?.Invoke(Array.Empty()); + // Compatibility behavior for the current minor release: preserve CreateHostBuilder-first resolution. + // Major release: remove this branch and make the deferred entry-point path the default. + var hostBuilder = ProgramHostFactoryResolver.ResolveHostBuilderFactory(assembly)?.Invoke(Array.Empty()); if (hostBuilder != null) { hostBuilder.UseEnvironment(Environments.Development); - return BuildHost(hostBuilder, configureHost); - } - - var deferredHostBuilder = new DeferredHostBuilder(); - - deferredHostBuilder.UseEnvironment(Environments.Development); - deferredHostBuilder.ConfigureHostConfiguration(config => - { - config.AddInMemoryCollection(new Dictionary - { - [HostDefaults.ApplicationKey] = assembly.GetName().Name - }); - }); - - var hostFactory = ProgramHostFactoryResolver.ResolveHostFactory(assembly, stopApplication, deferredHostBuilder.ConfigureHostBuilder, deferredHostBuilder.EntryPointCompleted); - if (hostFactory == null) - { - throw new InvalidOperationException($"The entry point assembly '{assembly.GetName().Name}' does not expose a supported application host."); - } - - deferredHostBuilder.SetHostFactory(hostFactory); - return BuildHost(deferredHostBuilder, configureHost); - } - - private static IHost BuildHost(IHostBuilder hostBuilder, Action configureHost) - { - configureHost?.Invoke(hostBuilder); - -#if NET9_0_OR_GREATER - hostBuilder.UseDefaultServiceProvider(o => - { - o.ValidateOnBuild = true; - o.ValidateScopes = true; - }); -#endif - - var host = hostBuilder.Build(); - if (hostBuilder is IDisposable disposable) - { - disposable.Dispose(); + return HostBuilderFactory.Build(hostBuilder, configureHost); } - return host; + return DeferredHostFactory.Create(assembly, configureHost, stopApplication, false); } } diff --git a/src/Codebelt.Extensions.Xunit.Hosting/ApplicationTestFactory.cs b/src/Codebelt.Extensions.Xunit.Hosting/ApplicationTestFactory.cs index d2cef04..31894f5 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting/ApplicationTestFactory.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting/ApplicationTestFactory.cs @@ -15,8 +15,13 @@ public static class ApplicationTestFactory /// The which may be configured. /// An optional implementation to use instead of the default instance. /// An instance of an implementation. + /// + /// Passing a opts this call into entrypoint-owned deferred startup. Omitting preserves the blocking compatibility path for the current minor release; that default should be removed or changed in the next major release. + /// public static IHostTest Create(Action hostSetup = null, IApplicationFixture hostFixture = null) where TEntryPoint : class { + // Minor-release compatibility: keep the historical blocking default while allowing callers to opt in by passing ManagedApplicationFixture. + // Major release: remove or change this default when the compatibility fixture is retired. Hint: ManagedApplicationFixture return new Internal.ApplicationTest(hostSetup, hostFixture ?? new BlockingManagedApplicationFixture()); } } diff --git a/src/Codebelt.Extensions.Xunit.Hosting/BlockingManagedApplicationFixture.cs b/src/Codebelt.Extensions.Xunit.Hosting/BlockingManagedApplicationFixture.cs index 40fd04d..df6124b 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting/BlockingManagedApplicationFixture.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting/BlockingManagedApplicationFixture.cs @@ -16,7 +16,10 @@ namespace Codebelt.Extensions.Xunit.Hosting; /// /// Unlike the base managed host fixtures, this fixture starts the resolved application host synchronously. /// Application entry point testing must expose a fully started host after fixture initialization. +/// Use for entrypoint-owned startup in new tests. +/// This compatibility fixture is retained for the current minor release and should be removed or changed in the next major release. /// +[Obsolete("Use ManagedApplicationFixture so the application entry point owns host startup. This compatibility fixture will be removed or changed in the next major release.")] public class BlockingManagedApplicationFixture : HostFixture, IApplicationFixture where TEntryPoint : class { /// diff --git a/src/Codebelt.Extensions.Xunit.Hosting/HostFixture.cs b/src/Codebelt.Extensions.Xunit.Hosting/HostFixture.cs index 06f78e3..ac3ac2e 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting/HostFixture.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting/HostFixture.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Codebelt.Extensions.Xunit.Hosting.Internal; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Xunit; @@ -43,7 +44,35 @@ protected HostFixture() { } - /// + /// + /// Creates an entrypoint-owned host for a derived application fixture. + /// + /// A type in the entry point assembly of the application. + /// The delegate that provides a way to override the before the application is built. + /// An unstarted wrapper that captures the host built by the application entry point. + /// + /// The entry point assembly does not expose a supported application host. + /// + /// + /// This protected hook is used by the opt-in ManagedApplicationFixture<TEntryPoint> and ManagedWebApplicationFixture<TEntryPoint> implementations. It keeps the deferred path out of the existing public contract while allowing both fixture packages to share the same implementation. + /// + protected static IHost CreateEntrypointOwnedHost(Action configureHost) where TEntryPoint : class + { + // Minor-release compatibility: only the new managed application fixtures opt into IDeferredHost and HostTest lazy startup. + // Major release: remove or change this split when the legacy application factory and blocking fixtures are removed or changed. + return DeferredHostFactory.Create(typeof(TEntryPoint).Assembly, configureHost, false, true); + } + + /// + /// Releases an entrypoint-owned application after the fixture has captured its host metadata. + /// + /// The host captured from the application entry point. + protected static void ReleaseEntrypoint(IHost host) + { + (host as IDeferredHost)?.ReleaseEntrypoint(); + } + + /// /// Determines whether the specified contains one or more of the specified target . /// /// The to validate. diff --git a/src/Codebelt.Extensions.Xunit.Hosting/HostTest.cs b/src/Codebelt.Extensions.Xunit.Hosting/HostTest.cs index 18b7f19..802cd48 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting/HostTest.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting/HostTest.cs @@ -1,8 +1,9 @@ -using System; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Xunit; +using System; +using Codebelt.Extensions.Xunit.Hosting.Internal; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; namespace Codebelt.Extensions.Xunit.Hosting; @@ -12,8 +13,12 @@ namespace Codebelt.Extensions.Xunit.Hosting; /// /// /// -public abstract class HostTest : Test, IHostTest -{ +public abstract class HostTest : Test, IHostTest +{ + private readonly object _hostLock = new(); + private IHost _host; + private bool _hostStartAttempted; + /// /// Initializes a new instance of the class. /// @@ -34,11 +39,45 @@ public virtual void Configure(IConfiguration configuration, IHostEnvironment env Environment = environment; } - /// - /// Gets the initialized by the . - /// - /// The initialized by the . - public IHost Host { get; protected set; } + /// + /// Gets the initialized by the . + /// + /// The initialized by the . + /// + /// For an entry-point-owned host captured by the managed application fixture path, accessing the host starts it when the + /// application entry point has not started it yet. This keeps the opt-in entrypoint-owned applications lazy while matching + /// the startup behavior of WebApplicationFactory when its server is consumed. + /// + public IHost Host + { + get + { + var host = _host; + if (host == null) { return null; } + + lock (_hostLock) + { + if (!_hostStartAttempted) + { + _hostStartAttempted = true; + if (host is IDeferredHost) + { + host.Start(); + } + } + } + + return host; + } + protected set + { + lock (_hostLock) + { + _host = value; + _hostStartAttempted = false; + } + } + } /// /// Gets the initialized by the . diff --git a/src/Codebelt.Extensions.Xunit.Hosting/IHostTest.cs b/src/Codebelt.Extensions.Xunit.Hosting/IHostTest.cs index 64e48f9..5e242bf 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting/IHostTest.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting/IHostTest.cs @@ -10,9 +10,10 @@ namespace Codebelt.Extensions.Xunit.Hosting; /// public interface IHostTest : IConfigurationTest, IEnvironmentTest, ITest { - /// - /// Gets the initialized by the . - /// - /// The initialized by the . - IHost Host { get; } -} + /// + /// Gets the initialized by the . + /// + /// The initialized by the . + /// For hosts captured by the opt-in managed application fixture path, accessing this property starts the deferred host when necessary. + IHost Host { get; } +} diff --git a/src/Codebelt.Extensions.Xunit.Hosting/Internal/DeferredHostBuilder.cs b/src/Codebelt.Extensions.Xunit.Hosting/Internal/DeferredHostBuilder.cs index eccb6c1..ac26e8f 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting/Internal/DeferredHostBuilder.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting/Internal/DeferredHostBuilder.cs @@ -8,17 +8,17 @@ namespace Codebelt.Extensions.Xunit.Hosting.Internal; -// Adapted from the ASP.NET Core testing infrastructure. -// Licensed to the .NET Foundation under one or more agreements under the MIT license. internal sealed class DeferredHostBuilder : IHostBuilder, IDisposable { private readonly ConfigurationManager _hostConfiguration = new(); private readonly TaskCompletionSource _hostStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly bool _entrypointOwned; private Action _configure; private Func _hostFactory; - public DeferredHostBuilder() + public DeferredHostBuilder(bool entrypointOwned) { + _entrypointOwned = entrypointOwned; _configure = builder => { foreach (var pair in Properties) @@ -39,8 +39,27 @@ public IHost Build() args.Add($"--{pair.Key}={pair.Value}"); } - var host = (IHost)_hostFactory(args.ToArray()); - return new DeferredHost(host, _hostStarted); + var capture = (ProgramHostFactoryResolver.HostCapture)_hostFactory(args.ToArray()); + try + { + var host = capture.Host; + // Preserve the legacy wrapper for ApplicationHostFactory fallback callers. Only managed application fixtures opt into the marker that HostTest uses for lazy startup. + var deferredHost = _entrypointOwned + ? new EntrypointOwnedDeferredHost(host, _hostStarted, capture) + : new DeferredHost(host, _hostStarted, capture); + + if (!_entrypointOwned) + { + capture.Release(); + } + + return deferredHost; + } + catch + { + capture.Release(); + throw; + } } public IHostBuilder ConfigureAppConfiguration(Action configureDelegate) @@ -105,14 +124,18 @@ public void Dispose() _hostConfiguration.Dispose(); } - private sealed class DeferredHost : IHost, IAsyncDisposable + private class DeferredHost : IHost, IAsyncDisposable { private readonly IHost _host; + private readonly IHostApplicationLifetime _applicationLifetime; + private readonly ProgramHostFactoryResolver.HostCapture _capture; private readonly TaskCompletionSource _hostStarted; - public DeferredHost(IHost host, TaskCompletionSource hostStarted) + public DeferredHost(IHost host, TaskCompletionSource hostStarted, ProgramHostFactoryResolver.HostCapture capture) { _host = host; + _applicationLifetime = capture.ApplicationLifetime; + _capture = capture; _hostStarted = hostStarted; } @@ -136,8 +159,14 @@ public async ValueTask DisposeAsync() public async Task StartAsync(CancellationToken cancellationToken = default) { + if (_hostStarted.Task.IsCompleted) + { + await _hostStarted.Task.ConfigureAwait(false); + return; + } + using var registration = cancellationToken.Register(() => _hostStarted.TrySetCanceled()); - using var startedRegistration = _host.Services.GetRequiredService().ApplicationStarted.Register(() => _hostStarted.TrySetResult(null)); + using var startedRegistration = _applicationLifetime.ApplicationStarted.Register(() => _hostStarted.TrySetResult(null)); await _hostStarted.Task.ConfigureAwait(false); } @@ -146,5 +175,17 @@ public Task StopAsync(CancellationToken cancellationToken = default) { return _host.StopAsync(cancellationToken); } + + public void ReleaseEntrypoint() + { + _capture.Release(); + } + } + + private sealed class EntrypointOwnedDeferredHost : DeferredHost, IDeferredHost + { + public EntrypointOwnedDeferredHost(IHost host, TaskCompletionSource hostStarted, ProgramHostFactoryResolver.HostCapture capture) : base(host, hostStarted, capture) + { + } } } diff --git a/src/Codebelt.Extensions.Xunit.Hosting/Internal/DeferredHostFactory.cs b/src/Codebelt.Extensions.Xunit.Hosting/Internal/DeferredHostFactory.cs new file mode 100644 index 0000000..6e88043 --- /dev/null +++ b/src/Codebelt.Extensions.Xunit.Hosting/Internal/DeferredHostFactory.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace Codebelt.Extensions.Xunit.Hosting.Internal; + +internal static class DeferredHostFactory +{ + public static IHost Create(Assembly assembly, Action configureHost, bool stopApplication, bool entrypointOwned) + { + var deferredHostBuilder = new DeferredHostBuilder(entrypointOwned); + + deferredHostBuilder.UseEnvironment(Environments.Development); + deferredHostBuilder.ConfigureHostConfiguration(config => + { + config.AddInMemoryCollection(new Dictionary + { + [HostDefaults.ApplicationKey] = assembly.GetName().Name + }); + }); + + var hostFactory = ProgramHostFactoryResolver.ResolveHostFactory(assembly, stopApplication, deferredHostBuilder.ConfigureHostBuilder, deferredHostBuilder.EntryPointCompleted); + if (hostFactory == null) + { + throw new InvalidOperationException($"The entry point assembly '{assembly.GetName().Name}' does not expose a supported application host."); + } + + deferredHostBuilder.SetHostFactory(hostFactory); + return HostBuilderFactory.Build(deferredHostBuilder, configureHost); + } +} diff --git a/src/Codebelt.Extensions.Xunit.Hosting/Internal/HostBuilderFactory.cs b/src/Codebelt.Extensions.Xunit.Hosting/Internal/HostBuilderFactory.cs new file mode 100644 index 0000000..3ce773d --- /dev/null +++ b/src/Codebelt.Extensions.Xunit.Hosting/Internal/HostBuilderFactory.cs @@ -0,0 +1,28 @@ +using System; +using Microsoft.Extensions.Hosting; + +namespace Codebelt.Extensions.Xunit.Hosting.Internal; + +internal static class HostBuilderFactory +{ + public static IHost Build(IHostBuilder hostBuilder, Action configureHost) + { + configureHost?.Invoke(hostBuilder); + +#if NET9_0_OR_GREATER + hostBuilder.UseDefaultServiceProvider(o => + { + o.ValidateOnBuild = true; + o.ValidateScopes = true; + }); +#endif + + var host = hostBuilder.Build(); + if (hostBuilder is IDisposable disposable) + { + disposable.Dispose(); + } + + return host; + } +} diff --git a/src/Codebelt.Extensions.Xunit.Hosting/Internal/IDeferredHost.cs b/src/Codebelt.Extensions.Xunit.Hosting/Internal/IDeferredHost.cs new file mode 100644 index 0000000..aad174c --- /dev/null +++ b/src/Codebelt.Extensions.Xunit.Hosting/Internal/IDeferredHost.cs @@ -0,0 +1,8 @@ +namespace Codebelt.Extensions.Xunit.Hosting.Internal; + +// Adapted from the ASP.NET Core testing infrastructure. +// Licensed to the .NET Foundation under one or more agreements under the MIT license. +internal interface IDeferredHost +{ + void ReleaseEntrypoint(); +} diff --git a/src/Codebelt.Extensions.Xunit.Hosting/Internal/ProgramHostFactoryResolver.cs b/src/Codebelt.Extensions.Xunit.Hosting/Internal/ProgramHostFactoryResolver.cs index 8d47fb7..7ab286b 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting/Internal/ProgramHostFactoryResolver.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting/Internal/ProgramHostFactoryResolver.cs @@ -4,6 +4,7 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace Codebelt.Extensions.Xunit.Hosting.Internal; @@ -64,7 +65,7 @@ private sealed class HostingListener : IObserver, IObserver< private readonly Action _configure; private readonly Action _entryPointCompleted; private readonly MethodInfo _entryPoint; - private readonly TaskCompletionSource _host = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _host = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly bool _stopApplication; private readonly TimeSpan _waitTimeout; private IDisposable _disposable; @@ -126,11 +127,14 @@ public void OnNext(KeyValuePair value) if (value.Key == "HostBuilt") { - _host.TrySetResult(value.Value); + var capture = new HostCapture((IHost)value.Value); + _host.TrySetResult(capture); if (_stopApplication) { throw new HostAbortedException(); } + + capture.WaitForRelease(); } } @@ -179,4 +183,29 @@ private sealed class HostAbortedException : Exception { } } + + internal sealed class HostCapture + { + private readonly TaskCompletionSource _released = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public HostCapture(IHost host) + { + Host = host; + ApplicationLifetime = host.Services.GetRequiredService(); + } + + public IHost Host { get; } + + public IHostApplicationLifetime ApplicationLifetime { get; } + + public void Release() + { + _released.TrySetResult(null); + } + + public void WaitForRelease() + { + _released.Task.GetAwaiter().GetResult(); + } + } } diff --git a/src/Codebelt.Extensions.Xunit.Hosting/ManagedApplicationFixture.cs b/src/Codebelt.Extensions.Xunit.Hosting/ManagedApplicationFixture.cs new file mode 100644 index 0000000..2c8efa5 --- /dev/null +++ b/src/Codebelt.Extensions.Xunit.Hosting/ManagedApplicationFixture.cs @@ -0,0 +1,67 @@ +using System; +using Codebelt.Extensions.Xunit.Hosting.Internal; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Codebelt.Extensions.Xunit.Hosting; + +/// +/// Provides an entrypoint-owned implementation of the interface. +/// +/// A type in the entry point assembly of the application. +/// +/// +/// +/// The application's Main method owns host startup. The fixture captures the host after it has been built without starting it during fixture setup; the test host starts the deferred host when it is consumed. +/// +public class ManagedApplicationFixture : HostFixture, IApplicationFixture where TEntryPoint : class +{ + /// + /// Initializes a new instance of the class. + /// + public ManagedApplicationFixture() + { + } + + /// + /// Creates and configures the of this instance. + /// + /// The object that inherits from . + /// was added to support those cases where the caller is required in the host configuration. + /// + /// is null. + /// + /// + /// is not assignable from . + /// + public virtual void ConfigureHost(Test hostTest) + { +#if NETSTANDARD2_0 + if (hostTest == null) { throw new ArgumentNullException(nameof(hostTest)); } +#else + ArgumentNullException.ThrowIfNull(hostTest); +#endif + if (!HasTypes(hostTest.GetType(), typeof(ApplicationTest<,>))) { throw new ArgumentOutOfRangeException(nameof(hostTest), typeof(ApplicationTest<,>), $"{nameof(hostTest)} is not assignable from ApplicationTest."); } + if (this.HasValidState()) { return; } + + Host = CreateEntrypointOwnedHost(ConfigureHostCallback); + try + { + Configuration = Host.Services.GetRequiredService(); + Environment = Host.Services.GetRequiredService(); + + ConfigureCallback(Configuration, Environment); + } + finally + { + ReleaseEntrypoint(Host); + } + } + + /// + /// Gets or sets the delegate that provides a way to override the before the application is built. + /// + /// The delegate that provides a way to override the . + public Action ConfigureHostCallback { get; set; } +} From cebe2b8c419989db33983356a4571cab6e4e512c Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Mon, 3 Aug 2026 23:22:12 +0200 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=85=20add=20tests=20for=20managed?= =?UTF-8?q?=20application=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds comprehensive regression test coverage for the new managed startup patterns across Generic Host and ASP.NET Core scenarios. Includes new functional tests validating entrypoint-owned startup for classic, minimal, Bootstrapper, worker, and modern entry points. Verifies that explicit fixture selection (managed vs blocking) produces the expected startup behavior. Updates existing tests to confirm backward compatibility of legacy factory paths. --- ...otstrapperMinimalWebApplicationTestTest.cs | 6 +- .../BootstrapperWebApplicationTestTest.cs | 6 +- .../ClassicWebApplicationTestTest.cs | 49 +++++----- .../DeferredModernWebApplicationTest.cs | 6 +- ...StartupValidationWebApplicationTestTest.cs | 91 +++++++++++++++++++ .../WebApplicationTestFactoryTest.cs | 20 +++- .../WebApplicationTestTest.cs | 7 +- .../ApplicationTestFactoryTest.cs | 12 +++ .../BootstrapperConsoleApplicationTestTest.cs | 6 +- ...rapperMinimalConsoleApplicationTestTest.cs | 32 +++---- ...trapperMinimalWorkerApplicationTestTest.cs | 6 +- .../BootstrapperWorkerApplicationTestTest.cs | 6 +- .../StartupValidationApplicationTestTest.cs | 83 +++++++++++++++++ 13 files changed, 268 insertions(+), 62 deletions(-) create mode 100644 test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/StartupValidationWebApplicationTestTest.cs create mode 100644 test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/StartupValidationApplicationTestTest.cs diff --git a/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/BootstrapperMinimalWebApplicationTestTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/BootstrapperMinimalWebApplicationTestTest.cs index c561553..32cb2a6 100644 --- a/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/BootstrapperMinimalWebApplicationTestTest.cs +++ b/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/BootstrapperMinimalWebApplicationTestTest.cs @@ -6,9 +6,9 @@ namespace Codebelt.Extensions.Xunit.Hosting.AspNetCore; -public class BootstrapperMinimalWebApplicationTestTest : WebApplicationTest> -{ - public BootstrapperMinimalWebApplicationTestTest(BlockingManagedWebApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) +public class BootstrapperMinimalWebApplicationTestTest : WebApplicationTest> +{ + public BootstrapperMinimalWebApplicationTestTest(ManagedWebApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) { } diff --git a/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/BootstrapperWebApplicationTestTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/BootstrapperWebApplicationTestTest.cs index 7dd7bc7..aaf3003 100644 --- a/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/BootstrapperWebApplicationTestTest.cs +++ b/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/BootstrapperWebApplicationTestTest.cs @@ -7,9 +7,9 @@ namespace Codebelt.Extensions.Xunit.Hosting.AspNetCore; -public class BootstrapperWebApplicationTestTest : WebApplicationTest> -{ - public BootstrapperWebApplicationTestTest(BlockingManagedWebApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) +public class BootstrapperWebApplicationTestTest : WebApplicationTest> +{ + public BootstrapperWebApplicationTestTest(ManagedWebApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) { } diff --git a/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/ClassicWebApplicationTestTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/ClassicWebApplicationTestTest.cs index f3d5de8..924738f 100644 --- a/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/ClassicWebApplicationTestTest.cs +++ b/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/ClassicWebApplicationTestTest.cs @@ -1,25 +1,28 @@ -using System.Threading.Tasks; -using Microsoft.AspNetCore.TestHost; +using System.Threading.Tasks; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; using Xunit; using Classic = Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App.Program; - -namespace Codebelt.Extensions.Xunit.Hosting.AspNetCore; - -public class ClassicWebApplicationTestTest : WebApplicationTest> -{ - public ClassicWebApplicationTestTest(BlockingManagedWebApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) - { - } - - [Fact] - public async Task ShouldBootstrapApplication_WhenEntryPointExposesCreateHostBuilder() - { - using var client = Host.GetTestClient(); - - var response = await client.GetAsync("/").ConfigureAwait(false); - var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - - Assert.True(response.IsSuccessStatusCode); - Assert.Equal("Classic Program", body); - } -} +using ClassicProgramState = Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App.ClassicProgramState; + +namespace Codebelt.Extensions.Xunit.Hosting.AspNetCore; + +public class ClassicWebApplicationTestTest : WebApplicationTest> +{ + public ClassicWebApplicationTestTest(ManagedWebApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) + { + } + + [Fact] + public async Task ShouldBootstrapApplication_WhenEntryPointExposesCreateHostBuilder() + { + using var client = Host.GetTestClient(); + + var response = await client.GetAsync("/").ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + Assert.True(response.IsSuccessStatusCode); + Assert.Equal("Classic Program", body); + Assert.True(Host.Services.GetRequiredService().MainInvoked); + } +} diff --git a/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/DeferredModernWebApplicationTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/DeferredModernWebApplicationTest.cs index de2174a..c680e44 100644 --- a/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/DeferredModernWebApplicationTest.cs +++ b/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/DeferredModernWebApplicationTest.cs @@ -3,9 +3,9 @@ namespace Codebelt.Extensions.Xunit.Hosting.AspNetCore; -internal sealed class DeferredModernWebApplicationTest : WebApplicationTest> -{ - public DeferredModernWebApplicationTest(BlockingManagedWebApplicationFixture hostFixture) : base(true, hostFixture) +internal sealed class DeferredModernWebApplicationTest : WebApplicationTest> +{ + public DeferredModernWebApplicationTest(ManagedWebApplicationFixture hostFixture) : base(true, hostFixture) { } diff --git a/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/StartupValidationWebApplicationTestTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/StartupValidationWebApplicationTestTest.cs new file mode 100644 index 0000000..d1a8f8a --- /dev/null +++ b/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/StartupValidationWebApplicationTestTest.cs @@ -0,0 +1,91 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; +using ClassicProgramState = Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App.ClassicProgramState; +using Classic = Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App.Program; + +namespace Codebelt.Extensions.Xunit.Hosting.AspNetCore; + +public class StartupValidationWebApplicationTestTest : Test +{ + public StartupValidationWebApplicationTestTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ShouldPropagateStartupValidationFailure_WhenUsingManagedWebApplicationFixture() + { + var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var state = new ClassicProgramState(); + var validation = new StartupValidationService(state, missing); + using var fixture = new ManagedWebApplicationFixture(); + using var application = WebApplicationTestFactory.Create( + builder => ConfigureStartupValidation(builder, state, validation), + fixture); + + var exception = Assert.ThrowsAny(() => _ = application.Host.Services); + + Assert.Contains("content root", exception.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.True(state.MainInvoked); + Assert.True(validation.Started); + } + + [Fact] + public void ShouldNotInvokeEntrypoint_WhenUsingBlockingManagedWebApplicationFixture() + { + var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var state = new ClassicProgramState(); + var validation = new StartupValidationService(state, missing); + using var fixture = new BlockingManagedWebApplicationFixture(); + using var application = WebApplicationTestFactory.Create( + builder => ConfigureStartupValidation(builder, state, validation), + fixture); + + Assert.NotNull(application.Host.Services); + Assert.False(state.MainInvoked); + Assert.True(validation.Started); + } + + private static void ConfigureStartupValidation(IWebHostBuilder builder, ClassicProgramState state, StartupValidationService validation) + { + builder.ConfigureLogging(logging => logging.ClearProviders()); + builder.ConfigureServices(services => + { + services.AddSingleton(state); + services.AddSingleton(validation); + }); + } + + private sealed class StartupValidationService : IHostedService + { + private readonly string _contentRoot; + private readonly ClassicProgramState _state; + + public StartupValidationService(ClassicProgramState state, string contentRoot) + { + _state = state; + _contentRoot = contentRoot; + } + + public bool Started { get; private set; } + + public Task StartAsync(CancellationToken cancellationToken) + { + Started = true; + if (_state.MainInvoked && !Directory.Exists(_contentRoot)) + { + throw new InvalidOperationException($"Invalid startup configuration. The content root '{_contentRoot}' does not exist."); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/WebApplicationTestFactoryTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/WebApplicationTestFactoryTest.cs index 56bb5be..2e8a653 100644 --- a/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/WebApplicationTestFactoryTest.cs +++ b/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/WebApplicationTestFactoryTest.cs @@ -9,6 +9,7 @@ using BootstrapperMinimalWebProgram = Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalWeb.App.Program; using BootstrapperWebProgram = Codebelt.Extensions.Xunit.Hosting.BootstrapperWeb.App.Program; using Classic = Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App.Program; +using ClassicProgramState = Codebelt.Extensions.Xunit.Hosting.ClassicProgram.App.ClassicProgramState; using ModernProgram = Codebelt.Extensions.Xunit.Hosting.Program.App.Program; namespace Codebelt.Extensions.Xunit.Hosting.AspNetCore; @@ -48,7 +49,7 @@ public async Task Create_ShouldBootstrapApplication_WhenEntryPointUsesBootstrapp [Fact] public async Task Create_ShouldBootstrapApplication_WhenEntryPointUsesClassicProgram() { - using var application = WebApplicationTestFactory.Create(); + using var application = WebApplicationTestFactory.Create(hostFixture: new ManagedWebApplicationFixture()); using var client = application.Host.GetTestClient(); var response = await client.GetAsync("/").ConfigureAwait(false); @@ -56,6 +57,9 @@ public async Task Create_ShouldBootstrapApplication_WhenEntryPointUsesClassicPro Assert.True(response.IsSuccessStatusCode); Assert.Equal("Classic Program", body); + var state = application.Host.Services.GetRequiredService(); + Assert.True(state.MainInvoked); + Assert.True(state.EntrypointStarted); } [Fact] @@ -102,6 +106,20 @@ public async Task Create_ShouldApplyWebHostConfiguration_WhenWebHostSetupIsProvi Assert.Equal("Custom service from WebApplicationTestFactory", serviceBody); } + [Fact] + public async Task Create_ShouldSupportExplicitBlockingFixture() + { + using var application = WebApplicationTestFactory.Create(hostFixture: new BlockingManagedWebApplicationFixture()); + using var client = application.Host.GetTestClient(); + + using var response = await client.GetAsync("/").ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + Assert.True(response.IsSuccessStatusCode); + Assert.Equal("Classic Program", body); + Assert.False(application.Host.Services.GetRequiredService().MainInvoked); + } + [Fact] public async Task RunAsync_ShouldReturnResponse_WhenEntryPointUsesModernProgramPattern() { diff --git a/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/WebApplicationTestTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/WebApplicationTestTest.cs index 7d2fb9d..a5e0ba4 100644 --- a/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/WebApplicationTestTest.cs +++ b/test/Codebelt.Extensions.Xunit.Hosting.AspNetCore.FunctionalTests/WebApplicationTestTest.cs @@ -11,9 +11,9 @@ namespace Codebelt.Extensions.Xunit.Hosting.AspNetCore; -public class WebApplicationTestTest : WebApplicationTest> +public class WebApplicationTestTest : WebApplicationTest> { - public WebApplicationTestTest(BlockingManagedWebApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) + public WebApplicationTestTest(ManagedWebApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) { } @@ -67,7 +67,7 @@ public void ShouldExposeHostConfigurationEnvironmentAndServer() [Fact] public void ShouldHaveValidFixtureState_WhenApplicationIsBootstrapped() { - var fixture = new BlockingManagedWebApplicationFixture(); + var fixture = new ManagedWebApplicationFixture(); var test = new DeferredModernWebApplicationTest(fixture); fixture.ConfigureCallback = test.Configure; @@ -104,4 +104,3 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) }); } } - diff --git a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/ApplicationTestFactoryTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/ApplicationTestFactoryTest.cs index d9eab74..8a9fc4a 100644 --- a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/ApplicationTestFactoryTest.cs +++ b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/ApplicationTestFactoryTest.cs @@ -8,6 +8,7 @@ using BootstrapperConsoleMarker = Codebelt.Extensions.Xunit.Hosting.BootstrapperConsole.App.BootstrapperConsoleMarker; using BootstrapperConsoleProgram = Codebelt.Extensions.Xunit.Hosting.BootstrapperConsole.App.Program; using BootstrapperMinimalConsoleProgram = Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App.Program; +using BootstrapperMinimalConsoleState = Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App.BootstrapperMinimalConsoleState; using BootstrapperMinimalWorkerMarker = Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalWorker.App.BootstrapperMinimalWorkerMarker; using BootstrapperMinimalWorkerProgram = Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalWorker.App.Program; using BootstrapperWorkerMarker = Codebelt.Extensions.Xunit.Hosting.BootstrapperWorker.App.BootstrapperWorkerMarker; @@ -83,6 +84,17 @@ public void Create_ShouldApplyHostConfiguration_WhenHostSetupIsProvided() Assert.Equal("Configured from ApplicationTestFactory", application.Configuration["Factory:Message"]); } + [Fact] + public void Create_ShouldSupportExplicitBlockingFixture() + { + using var application = ApplicationTestFactory.Create(hostFixture: new BlockingManagedApplicationFixture()); + var state = application.Host.Services.GetRequiredService(); + + Assert.NotNull(application.Host); + Assert.True(state.MainInvoked); + Assert.False(state.EntrypointStarted); + } + [Fact] public void Create_CallerTypeShouldHaveDeclaringTypeOfApplicationTestFactoryTest() { diff --git a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperConsoleApplicationTestTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperConsoleApplicationTestTest.cs index b7558ca..61d0f2d 100644 --- a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperConsoleApplicationTestTest.cs +++ b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperConsoleApplicationTestTest.cs @@ -5,9 +5,9 @@ namespace Codebelt.Extensions.Xunit.Hosting; -public class BootstrapperConsoleApplicationTestTest : ApplicationTest> -{ - public BootstrapperConsoleApplicationTestTest(BlockingManagedApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) +public class BootstrapperConsoleApplicationTestTest : ApplicationTest> +{ + public BootstrapperConsoleApplicationTestTest(BlockingManagedApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) { } diff --git a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperMinimalConsoleApplicationTestTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperMinimalConsoleApplicationTestTest.cs index 4e7322c..ce1365d 100644 --- a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperMinimalConsoleApplicationTestTest.cs +++ b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperMinimalConsoleApplicationTestTest.cs @@ -3,21 +3,21 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; using BootstrapperMinimalConsoleProgram = Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App.Program; - -namespace Codebelt.Extensions.Xunit.Hosting; - -public class BootstrapperMinimalConsoleApplicationTestTest : ApplicationTest> -{ - public BootstrapperMinimalConsoleApplicationTestTest(BlockingManagedApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) + +namespace Codebelt.Extensions.Xunit.Hosting; + +public class BootstrapperMinimalConsoleApplicationTestTest : ApplicationTest> +{ + public BootstrapperMinimalConsoleApplicationTestTest(ManagedApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) { - } - - [Fact] - public void ShouldBootstrapMinimalConsoleProgram() + } + + [Fact] + public void ShouldBootstrapMinimalConsoleProgram() { - var marker = Host.Services.GetRequiredService(); - Assert.Equal("Bootstrapper Minimal Console", marker.Value); - Assert.Equal("Development", Environment.EnvironmentName); - Assert.NotNull(Host); - } -} + var marker = Host.Services.GetRequiredService(); + Assert.Equal("Bootstrapper Minimal Console", marker.Value); + Assert.Equal("Development", Environment.EnvironmentName); + Assert.NotNull(Host); + } +} diff --git a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperMinimalWorkerApplicationTestTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperMinimalWorkerApplicationTestTest.cs index c566167..37e36fb 100644 --- a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperMinimalWorkerApplicationTestTest.cs +++ b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperMinimalWorkerApplicationTestTest.cs @@ -6,9 +6,9 @@ namespace Codebelt.Extensions.Xunit.Hosting; -public class BootstrapperMinimalWorkerApplicationTestTest : ApplicationTest> -{ - public BootstrapperMinimalWorkerApplicationTestTest(BlockingManagedApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) +public class BootstrapperMinimalWorkerApplicationTestTest : ApplicationTest> +{ + public BootstrapperMinimalWorkerApplicationTestTest(ManagedApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) { } diff --git a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperWorkerApplicationTestTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperWorkerApplicationTestTest.cs index 349e9f7..5cd74e2 100644 --- a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperWorkerApplicationTestTest.cs +++ b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperWorkerApplicationTestTest.cs @@ -6,9 +6,9 @@ namespace Codebelt.Extensions.Xunit.Hosting; -public class BootstrapperWorkerApplicationTestTest : ApplicationTest> -{ - public BootstrapperWorkerApplicationTestTest(BlockingManagedApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) +public class BootstrapperWorkerApplicationTestTest : ApplicationTest> +{ + public BootstrapperWorkerApplicationTestTest(ManagedApplicationFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output) { } diff --git a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/StartupValidationApplicationTestTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/StartupValidationApplicationTestTest.cs new file mode 100644 index 0000000..42705d7 --- /dev/null +++ b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/StartupValidationApplicationTestTest.cs @@ -0,0 +1,83 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; +using BootstrapperMinimalConsoleProgram = Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App.Program; +using BootstrapperMinimalConsoleState = Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App.BootstrapperMinimalConsoleState; + +namespace Codebelt.Extensions.Xunit.Hosting; + +public class StartupValidationApplicationTestTest : Test +{ + public StartupValidationApplicationTestTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ShouldPropagateStartupValidationFailure_WhenUsingManagedApplicationFixture() + { + var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var validation = new StartupValidationService(missing); + using var fixture = new ManagedApplicationFixture(); + using var application = ApplicationTestFactory.Create( + builder => ConfigureStartupValidation(builder, validation), + fixture); + + var exception = Assert.ThrowsAny(() => _ = application.Host.Services); + + Assert.Contains("content root", exception.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.True(validation.Started); + } + + [Fact] + public void ShouldNotStartHostedServices_WhenUsingBlockingManagedApplicationFixture() + { + var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var validation = new StartupValidationService(missing); + using var fixture = new BlockingManagedApplicationFixture(); + using var application = ApplicationTestFactory.Create( + builder => ConfigureStartupValidation(builder, validation), + fixture); + + var state = application.Host.Services.GetRequiredService(); + + Assert.True(state.MainInvoked); + Assert.False(state.EntrypointStarted); + Assert.False(validation.Started); + } + + private static void ConfigureStartupValidation(IHostBuilder builder, StartupValidationService validation) + { + builder.ConfigureLogging(logging => logging.ClearProviders()); + builder.ConfigureServices((_, services) => services.AddSingleton(validation)); + } + + private sealed class StartupValidationService : IHostedService + { + private readonly string _contentRoot; + + public StartupValidationService(string contentRoot) + { + _contentRoot = contentRoot; + } + + public bool Started { get; private set; } + + public Task StartAsync(CancellationToken cancellationToken) + { + Started = true; + if (!Directory.Exists(_contentRoot)) + { + throw new InvalidOperationException($"Invalid startup configuration. The content root '{_contentRoot}' does not exist."); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} From 141c4c675a8738562adb98d3d8e4786dc5cb2278 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Mon, 3 Aug 2026 23:22:19 +0200 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=93=9D=20update=20api=20documentati?= =?UTF-8?q?on=20for=20managed=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates DocFX namespace pages and type-level API documentation to reflect the new managed application and web application fixtures. Clarifies the distinction between the new managed startup path (where the application entry point owns host initialization) and the legacy factory/blocking paths (which preserve startup behavior for this minor release). Adds usage guidance and deprecation notices to the API documentation. --- ...elt.Extensions.Xunit.Hosting.AspNetCore.md | 8 +-- .../Codebelt.Extensions.Xunit.Hosting.md | 6 +-- ...ns.Xunit.Hosting.ApplicationHostFactory.md | 18 +++---- ...lockingManagedWebApplicationFixture%601.md | 2 +- ...etCore.ManagedWebApplicationFixture%601.md | 52 +++++++++++++++++++ ...g.BlockingManagedApplicationFixture%601.md | 2 +- ...t.Hosting.ManagedApplicationFixture%601.md | 48 +++++++++++++++++ 7 files changed, 117 insertions(+), 19 deletions(-) create mode 100644 .docfx/api/types/Codebelt.Extensions.Xunit.Hosting.AspNetCore.ManagedWebApplicationFixture%601.md create mode 100644 .docfx/api/types/Codebelt.Extensions.Xunit.Hosting.ManagedApplicationFixture%601.md diff --git a/.docfx/api/namespaces/Codebelt.Extensions.Xunit.Hosting.AspNetCore.md b/.docfx/api/namespaces/Codebelt.Extensions.Xunit.Hosting.AspNetCore.md index 58a1024..9a8a14f 100644 --- a/.docfx/api/namespaces/Codebelt.Extensions.Xunit.Hosting.AspNetCore.md +++ b/.docfx/api/namespaces/Codebelt.Extensions.Xunit.Hosting.AspNetCore.md @@ -5,7 +5,7 @@ summary: *content Exercise an ASP.NET Core application's real entry point, dependency-injection graph, middleware pipeline, and endpoints through an in-memory `TestServer`. The `Codebelt.Extensions.Xunit.Hosting.AspNetCore` namespace can bootstrap modern minimal hosting and conventional `Startup` applications, apply test-only web-host configuration, and return either an owned test context or a reusable xUnit fixture. -For a focused endpoint or service-override test, start with `WebApplicationTestFactory.Create` or its one-request `RunAsync` convenience. Use `WebApplicationTest` with `BlockingManagedWebApplicationFixture` when several tests should share the bootstrapped application. Reach for `WebHostTestFactory` or `MinimalWebHostTestFactory` when the test defines its own pipeline instead of loading an existing application. +For a focused endpoint or service-override test, start with `WebApplicationTestFactory.Create` or its one-request `RunAsync` convenience. Use `WebApplicationTest` with `ManagedWebApplicationFixture` when several tests should share the bootstrapped application. Reach for `WebHostTestFactory` or `MinimalWebHostTestFactory` when the test defines its own pipeline instead of loading an existing application. [!INCLUDE [availability-modern](../../includes/availability-modern.md)] @@ -17,7 +17,7 @@ Complements: [ASP.NET Core integration tests](https://learn.microsoft.com/en-us/ |---|---|---| |Bootstrap an existing ASP.NET Core application for one focused test|`WebApplicationTestFactory.Create`|Returns an owned `IHostTest` whose host exposes the application's `TestServer`, services, configuration, and environment.| |Send one request to an existing application|`WebApplicationTestFactory.RunAsync`|Combines application startup, `HttpClient` creation, request execution, and cleanup in one call.| -|Share an existing application across an xUnit test class|`WebApplicationTest` with `BlockingManagedWebApplicationFixture`|Uses xUnit fixture lifetime while keeping the real application entry point and `TestServer`.| +|Share an existing application across an xUnit test class|`WebApplicationTest` with `ManagedWebApplicationFixture`|Opt-in entrypoint-owned startup while the fixture exposes `TestServer`.| |Define services and middleware entirely inside the test|`WebHostTestFactory` or `MinimalWebHostTestFactory`|Builds a purpose-specific in-memory pipeline without loading an application project.| |Attach observers or change state before startup|A `SelfManaged` web fixture|Builds the host and pipeline but leaves startup to the test.| @@ -39,11 +39,11 @@ ASP.NET Core host fixtures follow the same lifecycle naming convention as the ho |Prefix|Convention| |---|---| -|`Managed`|The fixture owns host creation, configuration, startup and disposal using the default host runner.| +|`Managed`|The fixture owns host creation, configuration and disposal while the application entry point owns startup; test-host consumption starts the deferred host when needed.| |`SelfManaged`|The fixture owns host creation and configuration, but leaves host startup to the test.| |`BlockingManaged`|The fixture owns the host lifecycle and starts the host synchronously before returning control to the test.| -Application-entry-point fixtures use the `BlockingManaged` prefix by default. ASP.NET Core application tests expose a `TestServer`, and callers receive a started server after fixture initialization. Use `BlockingManagedWebApplicationFixture` when testing an existing ASP.NET Core application entry point with `TestServer`. +For the current minor release, the existing `WebApplicationTestFactory` and blocking fixture paths preserve legacy startup behavior. Use `ManagedWebApplicationFixture` explicitly when the real `Main` method should own startup; fixture setup remains lazy and test-host consumption starts the deferred host. `BlockingManagedWebApplicationFixture` remains available as an obsolete compatibility fixture and should be removed or changed in the next major release. `BlockingManagedWebHostFixture` remains the opt-in blocking variant for the lower-level web host fixture family. The application-entry-point fixture is named `BlockingManagedWebApplicationFixture` directly because this API is blocking by convention from its first release. diff --git a/.docfx/api/namespaces/Codebelt.Extensions.Xunit.Hosting.md b/.docfx/api/namespaces/Codebelt.Extensions.Xunit.Hosting.md index 2261b33..a9d1352 100644 --- a/.docfx/api/namespaces/Codebelt.Extensions.Xunit.Hosting.md +++ b/.docfx/api/namespaces/Codebelt.Extensions.Xunit.Hosting.md @@ -16,7 +16,7 @@ Complements: [xUnit: Shared Context between Tests](https://xunit.net/docs/shared |When you need to|Start with|Why| |---|---|---| |Bootstrap an existing console, worker, or Generic Host application for one test|`ApplicationTestFactory.Create`|Runs the application's entry-point setup and returns an owned `IHostTest` context that the caller disposes.| -|Share an existing application across an xUnit test class|`ApplicationTest` with `BlockingManagedApplicationFixture`|Moves application startup and disposal into xUnit's fixture lifecycle while retaining configuration and service access.| +|Share an existing application across an xUnit test class|`ApplicationTest` with `ManagedApplicationFixture`|Opt-in entrypoint-owned startup through the new managed fixture while retaining configuration and service access.| |Build a conventional Generic Host entirely inside the test|`HostTestFactory`|Configures `IServiceCollection` and `IHostBuilder` directly without requiring an application entry point.| |Build with the modern `IHostApplicationBuilder` model|`MinimalHostTestFactory`|Keeps minimal-host tests focused on services and application-builder configuration.| |Configure the host now but decide when it starts|A `SelfManaged` fixture|Leaves startup under test control so observers and pre-start assertions can be attached first.| @@ -27,11 +27,11 @@ Host fixtures follow a lifecycle naming convention: |Prefix|Convention| |---|---| -|`Managed`|The fixture owns host creation, configuration, startup and disposal using the default host runner.| +|`Managed`|The fixture owns host creation, configuration and disposal while the application entry point owns startup; test-host consumption starts the deferred host when needed.| |`SelfManaged`|The fixture owns host creation and configuration, but leaves host startup to the test.| |`BlockingManaged`|The fixture owns the host lifecycle and starts the host synchronously before returning control to the test.| -Application-entry-point fixtures use the `BlockingManaged` prefix by default. Existing application entry points are discovered and built from their `Program` assembly, so tests receive a ready host after fixture initialization. Use `BlockingManagedApplicationFixture` when testing a console, worker, or Generic Host application from an existing entry point. +For the current minor release, the existing `ApplicationTestFactory` and blocking fixture paths preserve legacy startup behavior. Use `ManagedApplicationFixture` explicitly when the real `Main` method should own startup; fixture setup remains lazy and test-host consumption starts the deferred host. `BlockingManagedApplicationFixture` remains available as an obsolete compatibility fixture and should be removed or changed in the next major release. ### Extension Members diff --git a/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.ApplicationHostFactory.md b/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.ApplicationHostFactory.md index c4e9608..9bf375d 100644 --- a/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.ApplicationHostFactory.md +++ b/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.ApplicationHostFactory.md @@ -4,10 +4,9 @@ example: - *content --- -The test project references a worker application's entry-point assembly. `ApplicationHostFactory` captures the host built by that entry point and applies a test-only service override; because this lower-level factory returns the host directly, the caller starts, stops, and disposes it explicitly. +The test project references a worker application's entry-point assembly. `ApplicationHostFactory.Create` preserves the current minor-release compatibility path, including direct use of an application's `CreateHostBuilder` when it is available. When the application entry point should own startup, pass `ManagedApplicationFixture` to `ApplicationTestFactory.Create`; the fixture opts into the deferred path without changing the existing factory method signature. The compatibility path is intentionally retained until it can be removed or changed in the next major release. Because this lower-level factory returns the host directly, the caller still owns disposal. ```csharp -using System.Threading.Tasks; using Codebelt.Extensions.Xunit.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -16,7 +15,7 @@ namespace WorkerApp.Tests; public sealed class ApplicationHostFactoryExample { - public async Task StartWithTestIdentityAsync() + public string GetTestIdentity() { using IHost host = ApplicationHostFactory.Create(builder => { @@ -24,10 +23,7 @@ public sealed class ApplicationHostFactoryExample services.AddSingleton(new WorkerIdentity("Test inventory worker"))); }); - await host.StartAsync().ConfigureAwait(false); var identity = host.Services.GetRequiredService(); - await host.StopAsync().ConfigureAwait(false); - return identity.Name; } } @@ -36,12 +32,14 @@ public sealed record WorkerIdentity(string Name); public sealed class WorkerProgram { - public static void Main(string[] args) + public static IHostBuilder CreateHostBuilder(string[] args) { - var builder = Host.CreateApplicationBuilder(args); - builder.Services.AddSingleton(new WorkerIdentity("Inventory worker")); + return Host.CreateDefaultBuilder(args); + } - using var host = builder.Build(); + public static void Main(string[] args) + { + using var host = CreateHostBuilder(args).Build(); host.Run(); } } diff --git a/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.AspNetCore.BlockingManagedWebApplicationFixture%601.md b/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.AspNetCore.BlockingManagedWebApplicationFixture%601.md index 7f149c3..59c530f 100644 --- a/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.AspNetCore.BlockingManagedWebApplicationFixture%601.md +++ b/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.AspNetCore.BlockingManagedWebApplicationFixture%601.md @@ -4,7 +4,7 @@ example: - *content --- -The test project references a minimal ASP.NET Core application and shares its in-memory server through xUnit's class-fixture lifetime. `BlockingManagedWebApplicationFixture` waits for startup before constructing the test class, so the test can create a client from `TestServer` and exercise the real request pipeline immediately. +The test project references a minimal ASP.NET Core application and shares its in-memory server through xUnit's class-fixture lifetime. `BlockingManagedWebApplicationFixture` is an obsolete compatibility fixture that preserves the legacy blocking startup path for the current minor release; new tests should use `ManagedWebApplicationFixture` so the real application entry point owns startup. This compatibility type should be removed or changed in the next major release. ```csharp using System.Threading.Tasks; diff --git a/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.AspNetCore.ManagedWebApplicationFixture%601.md b/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.AspNetCore.ManagedWebApplicationFixture%601.md new file mode 100644 index 0000000..07be4e1 --- /dev/null +++ b/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.AspNetCore.ManagedWebApplicationFixture%601.md @@ -0,0 +1,52 @@ +--- +uid: Codebelt.Extensions.Xunit.Hosting.AspNetCore.ManagedWebApplicationFixture`1 +example: +- *content +--- + +Use `ManagedWebApplicationFixture` when an xUnit class fixture should exercise an ASP.NET Core application's real entry point and let that entry point start the in-memory server. This is an opt-in path for the current minor release. Fixture setup remains lazy; consuming the test host starts the deferred host, after which the test can create a client from the exposed `TestServer` and verify the application's endpoint behavior. The legacy blocking path is retained for compatibility until it can be removed or changed in the next major release. + +```csharp +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace CatalogApi.Tests; + +public sealed class CatalogApiTest : IClassFixture> +{ + private readonly ManagedWebApplicationFixture _fixture; + + public CatalogApiTest(ManagedWebApplicationFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task HealthEndpoint_ReturnsApplicationState() + { + using var client = _fixture.Server.CreateClient(); + + var body = await client.GetStringAsync("/health").ConfigureAwait(false); + + Assert.Equal("ready", body); + } +} + +public sealed record CatalogStatus(string Value); + +public sealed class CatalogProgram +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + builder.Services.AddSingleton(new CatalogStatus("ready")); + + var app = builder.Build(); + app.MapGet("/health", (CatalogStatus status) => status.Value); + app.Run(); + } +} +``` diff --git a/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.BlockingManagedApplicationFixture%601.md b/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.BlockingManagedApplicationFixture%601.md index b0a4f08..409276a 100644 --- a/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.BlockingManagedApplicationFixture%601.md +++ b/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.BlockingManagedApplicationFixture%601.md @@ -4,7 +4,7 @@ example: - *content --- -The test project references a worker application's entry point and shares one bootstrapped host through xUnit's class-fixture lifetime. `BlockingManagedApplicationFixture` waits until the host is ready before constructing the test class, so each test can immediately resolve services registered by the real application. +The test project references a worker application's entry point and shares one bootstrapped host through xUnit's class-fixture lifetime. `BlockingManagedApplicationFixture` is an obsolete compatibility fixture that preserves the legacy blocking startup path for the current minor release; new tests should use `ManagedApplicationFixture` so the real application entry point owns startup. This compatibility type should be removed or changed in the next major release. ```csharp using Codebelt.Extensions.Xunit.Hosting; diff --git a/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.ManagedApplicationFixture%601.md b/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.ManagedApplicationFixture%601.md new file mode 100644 index 0000000..ef4e9e2 --- /dev/null +++ b/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.ManagedApplicationFixture%601.md @@ -0,0 +1,48 @@ +--- +uid: Codebelt.Extensions.Xunit.Hosting.ManagedApplicationFixture`1 +example: +- *content +--- + +Use `ManagedApplicationFixture` when an xUnit class fixture should exercise the application's real entry point and let that entry point start the host. This is an opt-in path for the current minor release. Fixture setup remains lazy; accessing the test host starts the deferred host and surfaces startup failures at the point the test consumes it. The legacy blocking path is retained for compatibility until it can be removed or changed in the next major release. + +```csharp +using Codebelt.Extensions.Xunit.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace InventoryWorker.Tests; + +public sealed class InventoryWorkerTest : IClassFixture> +{ + private readonly ManagedApplicationFixture _fixture; + + public InventoryWorkerTest(ManagedApplicationFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public void Host_ContainsApplicationService() + { + var identity = _fixture.Host.Services.GetRequiredService(); + + Assert.Equal("Inventory worker", identity.Name); + } +} + +public sealed record WorkerIdentity(string Name); + +public sealed class WorkerProgram +{ + public static void Main(string[] args) + { + var builder = Host.CreateApplicationBuilder(args); + builder.Services.AddSingleton(new WorkerIdentity("Inventory worker")); + + using var host = builder.Build(); + host.Run(); + } +} +``` From a41de10a2ffb4278f038bb80004cb21a31d4b95e Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Mon, 3 Aug 2026 23:22:26 +0200 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=93=A6=20update=20package=20release?= =?UTF-8?q?=20notes=20for=20managed=20startup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates NuGet package release notes and README documentation for Codebelt.Extensions.Xunit.Hosting and Codebelt.Extensions.Xunit.Hosting.AspNetCore packages. Adds 'Unreleased' section highlighting the new managed fixtures as the primary feature. Documents the compatibility split: ManagedApplicationFixture and ManagedWebApplicationFixture enable applications where Main owns startup configuration; legacy factory and blocking fixture APIs preserve existing behavior for backward compatibility. --- .../PackageReleaseNotes.txt | 13 +++++++++++++ .../README.md | 2 ++ .../PackageReleaseNotes.txt | 13 +++++++++++++ .nuget/Codebelt.Extensions.Xunit.Hosting/README.md | 2 +- 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.nuget/Codebelt.Extensions.Xunit.Hosting.AspNetCore/PackageReleaseNotes.txt b/.nuget/Codebelt.Extensions.Xunit.Hosting.AspNetCore/PackageReleaseNotes.txt index 272eb19..7b420d0 100644 --- a/.nuget/Codebelt.Extensions.Xunit.Hosting.AspNetCore/PackageReleaseNotes.txt +++ b/.nuget/Codebelt.Extensions.Xunit.Hosting.AspNetCore/PackageReleaseNotes.txt @@ -1,3 +1,16 @@ +Version: Unreleased +Availability: .NET 10 and .NET 9 + +# New Features +- ADDED ManagedWebApplicationFixture{TEntryPoint} as an opt-in entrypoint-owned fixture for ASP.NET Core application tests + +# Changed +- CHANGED ManagedWebApplicationFixture{TEntryPoint} to invoke supported Main methods and let the application entry point own web-host startup +- PRESERVED WebApplicationTestFactory and BlockingManagedWebApplicationFixture{TEntryPoint} startup behavior for the current minor release + +# Deprecated +- DEPRECATED BlockingManagedWebApplicationFixture{TEntryPoint}; use ManagedWebApplicationFixture{TEntryPoint} for new entrypoint-owned tests. The compatibility fixture should be removed or changed in the next major release. + Version: 11.1.2 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Codebelt.Extensions.Xunit.Hosting.AspNetCore/README.md b/.nuget/Codebelt.Extensions.Xunit.Hosting.AspNetCore/README.md index 5491bf6..9808463 100644 --- a/.nuget/Codebelt.Extensions.Xunit.Hosting.AspNetCore/README.md +++ b/.nuget/Codebelt.Extensions.Xunit.Hosting.AspNetCore/README.md @@ -14,6 +14,8 @@ It is, by heart, free, flexible and built to extend and boost your agile codebel The `Codebelt.Extensions.Xunit.Hosting.AspNetCore` namespace contains types that provides a uniform way of doing unit testing that depends on ASP.NET Core and used in conjunction with Microsoft Dependency Injection. The namespace relates to the `Microsoft.AspNetCore.TestHost` namespace. `WebApplicationTestFactory.Create` is a lightweight alternative for focused integration tests that prefer inline `IWebHostBuilder` customization and Codebelt's common `IHostTest` model. It is not a drop-in replacement for [WebApplicationFactory](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.testing.webapplicationfactory-1): use Microsoft's factory when reusable derived factories, `CreateClient` options, `WithWebHostBuilder`, or MVC content-root conventions are central to the test suite. + +For the current minor release, the existing factory and blocking fixture paths preserve their legacy startup behavior. Use `ManagedWebApplicationFixture` explicitly when the application's `Main` method should own startup and the deferred `TestServer` should start when the test host is consumed. `BlockingManagedWebApplicationFixture` remains available as an obsolete compatibility option until it can be removed or changed in the next major release. More documentation available at our documentation site: diff --git a/.nuget/Codebelt.Extensions.Xunit.Hosting/PackageReleaseNotes.txt b/.nuget/Codebelt.Extensions.Xunit.Hosting/PackageReleaseNotes.txt index 7e7ed72..dd6b47e 100644 --- a/.nuget/Codebelt.Extensions.Xunit.Hosting/PackageReleaseNotes.txt +++ b/.nuget/Codebelt.Extensions.Xunit.Hosting/PackageReleaseNotes.txt @@ -1,3 +1,16 @@ +Version: Unreleased +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# New Features +- ADDED ManagedApplicationFixture{TEntryPoint} as an opt-in entrypoint-owned fixture for application tests + +# Changed +- CHANGED ManagedApplicationFixture{TEntryPoint} to invoke supported Main methods and let the application entry point own startup +- PRESERVED ApplicationTestFactory and BlockingManagedApplicationFixture{TEntryPoint} startup behavior for the current minor release + +# Deprecated +- DEPRECATED BlockingManagedApplicationFixture{TEntryPoint}; use ManagedApplicationFixture{TEntryPoint} for new entrypoint-owned tests. The compatibility fixture should be removed or changed in the next major release. + Version: 11.1.2 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Codebelt.Extensions.Xunit.Hosting/README.md b/.nuget/Codebelt.Extensions.Xunit.Hosting/README.md index 2044c5c..02b5d12 100644 --- a/.nuget/Codebelt.Extensions.Xunit.Hosting/README.md +++ b/.nuget/Codebelt.Extensions.Xunit.Hosting/README.md @@ -13,7 +13,7 @@ It is, by heart, free, flexible and built to extend and boost your agile codebel The `Codebelt.Extensions.Xunit.Hosting` namespace contains types that provides a uniform way of doing unit testing that is used in conjunction with Microsoft Dependency Injection. The namespace relates to the `Xunit.Abstractions` namespace. -Use `ApplicationTestFactory.Create` for a focused integration test against an existing console, worker or Generic Host application's `Program` assembly. It brings the entry-point testing pattern commonly associated with ASP.NET Core to the rest of the .NET application stack, while `ApplicationTest` and `BlockingManagedApplicationFixture` provide the reusable xUnit class-fixture form. +Use `ApplicationTestFactory.Create` for a focused integration test against an existing console, worker or Generic Host application's `Program` assembly. It brings the entry-point testing pattern commonly associated with ASP.NET Core to the rest of the .NET application stack. For the current minor release, existing factory and blocking fixture paths preserve their legacy startup behavior; use `ManagedApplicationFixture` explicitly when the application's `Main` method should own startup and the deferred host should start when the test host is consumed. `BlockingManagedApplicationFixture` remains as an obsolete compatibility option until it can be removed or changed in the next major release. More documentation available at our documentation site: From bc051de88163844773d0af29f507df46a9afdcad Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Mon, 3 Aug 2026 23:22:34 +0200 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=92=AC=20release=20notes=20for=2011?= =?UTF-8?q?.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates CHANGELOG.md with 11.2.0 release entry documenting the introduction of entrypoint-owned managed fixtures as an opt-in startup path. Highlights backward compatibility split: new ManagedApplicationFixture and ManagedWebApplicationFixture allow applications where the entry point manages host initialization; existing factory and blocking fixture APIs remain unchanged for this minor release. Notes deprecation of BlockingManagedApplicationFixture and BlockingManagedWebApplicationFixture in favor of managed fixtures. --- CHANGELOG.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a7933..bbaa0bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ For more details, please refer to `PackageReleaseNotes.txt` on a per assembly ba > [!NOTE] > Changelog entries prior to version 8.4.0 was migrated from previous versions of Cuemon.Extensions.Xunit, Cuemon.Extensions.Xunit.Hosting, and Cuemon.Extensions.Xunit.Hosting.AspNetCore. +## [11.2.0] - 2026-08-03 + +This is a minor release that adds opt-in entrypoint-owned startup for application and ASP.NET Core tests, while keeping the existing factory paths compatible for the current minor release and marking the older blocking fixtures as obsolete. + +### Added + +- `ManagedApplicationFixture` and `ManagedWebApplicationFixture` for opt-in entrypoint-owned startup in application and ASP.NET Core tests, +- Regression coverage for classic, minimal, Bootstrapper, worker, and modern ASP.NET Core entry points. + +### Changed + +- `HostTest`, `WebApplicationTest`, `ApplicationHostFactory`, and `WebApplicationTestFactory` now support lazy entrypoint-owned startup when the managed fixtures are used, while the existing factory paths preserve their previous startup behavior for the current minor release, +- Package README and release-note guidance were updated to explain when to choose the managed fixtures versus the compatibility paths. + +### Deprecated + +- `BlockingManagedApplicationFixture` and `BlockingManagedWebApplicationFixture` remain available for source and binary compatibility but are obsolete for new tests; they should be removed or changed in the next major release. + ## [11.1.2] - 2026-07-18 This is a patch release that updates package dependencies across all supported target frameworks and introduces query performance benchmarks for the InMemoryTestStore implementation. All changes are non-breaking service refinements. @@ -454,7 +472,8 @@ This major release is first and foremost focused on ironing out any wrinkles tha -[Unreleased]: https://github.com/codebeltnet/xunit/compare/v11.1.2...HEAD +[Unreleased]: https://github.com/codebeltnet/xunit/compare/v11.2.0...HEAD +[11.2.0]: https://github.com/codebeltnet/xunit/compare/v11.1.2...v11.2.0 [11.1.2]: https://github.com/codebeltnet/xunit/compare/v11.1.1...v11.1.2 [11.1.1]: https://github.com/codebeltnet/xunit/compare/v11.1.0...v11.1.1 [11.1.0]: https://github.com/codebeltnet/xunit/compare/v11.0.10...v11.1.0 From 6937f7a59d39266b539ec7c47c5cf7cbd22d5a3d Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 4 Aug 2026 01:28:12 +0200 Subject: [PATCH 06/10] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20consolidate=20entryp?= =?UTF-8?q?oint=20hosting=20logic=20into=20managed=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove CreateEntrypointOwnedHost and ReleaseEntrypoint protected methods from HostFixture and inline their logic directly into ManagedApplicationFixture and ManagedWebApplicationFixture. This simplifies the public API surface of HostFixture by keeping entrypoint-owned host logic scoped to the fixtures that use it. Refactor exception handling in DeferredHostBuilder to remove unnecessary try-catch wrapping. --- .../ManagedWebApplicationFixture.cs | 31 +++++++++---------- .../HostFixture.cs | 29 ----------------- .../Internal/DeferredHostBuilder.cs | 24 +++++--------- .../ManagedApplicationFixture.cs | 10 +++--- 4 files changed, 27 insertions(+), 67 deletions(-) diff --git a/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/ManagedWebApplicationFixture.cs b/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/ManagedWebApplicationFixture.cs index 3f984e6..0e7eb80 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/ManagedWebApplicationFixture.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting.AspNetCore/ManagedWebApplicationFixture.cs @@ -1,8 +1,6 @@ using System; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace Codebelt.Extensions.Xunit.Hosting.AspNetCore; @@ -42,23 +40,22 @@ public virtual void ConfigureHost(Test hostTest) if (!HasTypes(hostTest.GetType(), typeof(WebApplicationTest<,>))) { throw new ArgumentOutOfRangeException(nameof(hostTest), typeof(WebApplicationTest<,>), $"{nameof(hostTest)} is not assignable from WebApplicationTest."); } if (this.HasValidState()) { return; } - Host = CreateEntrypointOwnedHost(hostBuilder => hostBuilder.ConfigureWebHost(webHostBuilder => + var applicationFixture = new ManagedApplicationFixture { - webHostBuilder.UseTestServer(o => o.PreserveExecutionContext = true); - ConfigureWebHostCallback?.Invoke(webHostBuilder); - })); - try - { - Server = Host.GetTestServer(); - Configuration = Host.Services.GetRequiredService(); - Environment = Host.Services.GetRequiredService(); + ConfigureCallback = ConfigureCallback, + ConfigureHostCallback = hostBuilder => hostBuilder.ConfigureWebHost(webHostBuilder => + { + webHostBuilder.UseTestServer(o => o.PreserveExecutionContext = true); + ConfigureWebHostCallback?.Invoke(webHostBuilder); + }) + }; - ConfigureCallback(Configuration, Environment); - } - finally - { - ReleaseEntrypoint(Host); - } + applicationFixture.ConfigureHost(hostTest); + + Host = applicationFixture.Host; + Server = Host.GetTestServer(); + Configuration = applicationFixture.Configuration; + Environment = applicationFixture.Environment; } /// diff --git a/src/Codebelt.Extensions.Xunit.Hosting/HostFixture.cs b/src/Codebelt.Extensions.Xunit.Hosting/HostFixture.cs index ac3ac2e..2563adb 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting/HostFixture.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting/HostFixture.cs @@ -1,7 +1,6 @@ using System; using System.Threading; using System.Threading.Tasks; -using Codebelt.Extensions.Xunit.Hosting.Internal; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Xunit; @@ -44,34 +43,6 @@ protected HostFixture() { } - /// - /// Creates an entrypoint-owned host for a derived application fixture. - /// - /// A type in the entry point assembly of the application. - /// The delegate that provides a way to override the before the application is built. - /// An unstarted wrapper that captures the host built by the application entry point. - /// - /// The entry point assembly does not expose a supported application host. - /// - /// - /// This protected hook is used by the opt-in ManagedApplicationFixture<TEntryPoint> and ManagedWebApplicationFixture<TEntryPoint> implementations. It keeps the deferred path out of the existing public contract while allowing both fixture packages to share the same implementation. - /// - protected static IHost CreateEntrypointOwnedHost(Action configureHost) where TEntryPoint : class - { - // Minor-release compatibility: only the new managed application fixtures opt into IDeferredHost and HostTest lazy startup. - // Major release: remove or change this split when the legacy application factory and blocking fixtures are removed or changed. - return DeferredHostFactory.Create(typeof(TEntryPoint).Assembly, configureHost, false, true); - } - - /// - /// Releases an entrypoint-owned application after the fixture has captured its host metadata. - /// - /// The host captured from the application entry point. - protected static void ReleaseEntrypoint(IHost host) - { - (host as IDeferredHost)?.ReleaseEntrypoint(); - } - /// /// Determines whether the specified contains one or more of the specified target . /// diff --git a/src/Codebelt.Extensions.Xunit.Hosting/Internal/DeferredHostBuilder.cs b/src/Codebelt.Extensions.Xunit.Hosting/Internal/DeferredHostBuilder.cs index ac26e8f..61db5c7 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting/Internal/DeferredHostBuilder.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting/Internal/DeferredHostBuilder.cs @@ -40,26 +40,18 @@ public IHost Build() } var capture = (ProgramHostFactoryResolver.HostCapture)_hostFactory(args.ToArray()); - try - { - var host = capture.Host; - // Preserve the legacy wrapper for ApplicationHostFactory fallback callers. Only managed application fixtures opt into the marker that HostTest uses for lazy startup. - var deferredHost = _entrypointOwned - ? new EntrypointOwnedDeferredHost(host, _hostStarted, capture) - : new DeferredHost(host, _hostStarted, capture); - - if (!_entrypointOwned) - { - capture.Release(); - } + var host = capture.Host; + // Preserve the legacy wrapper for ApplicationHostFactory fallback callers. Only managed application fixtures opt into the marker that HostTest uses for lazy startup. + var deferredHost = _entrypointOwned + ? new EntrypointOwnedDeferredHost(host, _hostStarted, capture) + : new DeferredHost(host, _hostStarted, capture); - return deferredHost; - } - catch + if (!_entrypointOwned) { capture.Release(); - throw; } + + return deferredHost; } public IHostBuilder ConfigureAppConfiguration(Action configureDelegate) diff --git a/src/Codebelt.Extensions.Xunit.Hosting/ManagedApplicationFixture.cs b/src/Codebelt.Extensions.Xunit.Hosting/ManagedApplicationFixture.cs index 2c8efa5..246d0bd 100644 --- a/src/Codebelt.Extensions.Xunit.Hosting/ManagedApplicationFixture.cs +++ b/src/Codebelt.Extensions.Xunit.Hosting/ManagedApplicationFixture.cs @@ -27,13 +27,13 @@ public ManagedApplicationFixture() /// /// Creates and configures the of this instance. /// - /// The object that inherits from . + /// The object that inherits from . /// was added to support those cases where the caller is required in the host configuration. /// /// is null. /// /// - /// is not assignable from . + /// is not assignable from . /// public virtual void ConfigureHost(Test hostTest) { @@ -42,10 +42,10 @@ public virtual void ConfigureHost(Test hostTest) #else ArgumentNullException.ThrowIfNull(hostTest); #endif - if (!HasTypes(hostTest.GetType(), typeof(ApplicationTest<,>))) { throw new ArgumentOutOfRangeException(nameof(hostTest), typeof(ApplicationTest<,>), $"{nameof(hostTest)} is not assignable from ApplicationTest."); } + if (!HasTypes(hostTest.GetType(), typeof(HostTest))) { throw new ArgumentOutOfRangeException(nameof(hostTest), typeof(HostTest), $"{nameof(hostTest)} is not assignable from HostTest."); } if (this.HasValidState()) { return; } - Host = CreateEntrypointOwnedHost(ConfigureHostCallback); + Host = DeferredHostFactory.Create(typeof(TEntryPoint).Assembly, ConfigureHostCallback, false, true); try { Configuration = Host.Services.GetRequiredService(); @@ -55,7 +55,7 @@ public virtual void ConfigureHost(Test hostTest) } finally { - ReleaseEntrypoint(Host); + (Host as IDeferredHost)?.ReleaseEntrypoint(); } } From ea8585e17c7c7c6aa74f8a431b72422acb81cec2 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 4 Aug 2026 01:28:21 +0200 Subject: [PATCH 07/10] =?UTF-8?q?=E2=9C=85=20verify=20managed=20fixtures?= =?UTF-8?q?=20start=20the=20application=20entrypoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add integration test to verify ManagedApplicationFixture correctly initializes through ConfigureHost and starts the application entrypoint. Also add test to verify BootstrapperMinimalConsoleProgram handles cancellation gracefully. --- .../ApplicationTestFactoryTest.cs | 12 ++++++++++++ .../BootstrapperMinimalConsoleApplicationTestTest.cs | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/ApplicationTestFactoryTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/ApplicationTestFactoryTest.cs index 8a9fc4a..8d89d12 100644 --- a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/ApplicationTestFactoryTest.cs +++ b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/ApplicationTestFactoryTest.cs @@ -34,6 +34,18 @@ public void Create_ShouldBootstrapApplication_WhenEntryPointUsesBootstrapperCons Assert.NotNull(application.Host); } + [Fact] + public void Create_ShouldStartEntrypoint_WhenUsingManagedApplicationFixture() + { + using var application = ApplicationTestFactory.Create(hostFixture: new ManagedApplicationFixture()); + + var marker = application.Host.Services.GetRequiredService(); + + Assert.Equal("Bootstrapper Console", marker.Value); + Assert.True(BootstrapperConsoleProgram.MainInvoked); + Assert.True(BootstrapperConsoleProgram.EntrypointStarted); + } + [Fact] public void Create_ShouldBootstrapApplication_WhenEntryPointUsesMinimalConsoleProgram() { diff --git a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperMinimalConsoleApplicationTestTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperMinimalConsoleApplicationTestTest.cs index ce1365d..c746130 100644 --- a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperMinimalConsoleApplicationTestTest.cs +++ b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/BootstrapperMinimalConsoleApplicationTestTest.cs @@ -1,4 +1,6 @@ using System; +using System.Threading; +using System.Threading.Tasks; using Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -20,4 +22,14 @@ public void ShouldBootstrapMinimalConsoleProgram() Assert.Equal("Development", Environment.EnvironmentName); Assert.NotNull(Host); } + + [Fact] + public async Task ShouldCompleteRunAsync_WhenCancellationIsRequested() + { + using var cancellation = new CancellationTokenSource(); + var run = new BootstrapperMinimalConsoleProgram().RunAsync(Host.Services, cancellation.Token); + cancellation.Cancel(); + + await run.ConfigureAwait(false); + } } From dce39917b106920f20bc6645ca366c68034c4283 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 4 Aug 2026 01:28:37 +0200 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=93=9D=20update=20fixture=20usage?= =?UTF-8?q?=20examples=20in=20api=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update ManagedApplicationFixture and ManagedWebApplicationFixture documentation examples to show the proper usage pattern: inherit from ApplicationTest or WebApplicationTest base classes and pass the fixture to the base constructor for automatic fixture initialization via ConfigureHost. --- ...ng.AspNetCore.ManagedWebApplicationFixture%601.md | 12 +++++------- ...ns.Xunit.Hosting.ManagedApplicationFixture%601.md | 12 +++++------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.AspNetCore.ManagedWebApplicationFixture%601.md b/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.AspNetCore.ManagedWebApplicationFixture%601.md index 07be4e1..4f1edf5 100644 --- a/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.AspNetCore.ManagedWebApplicationFixture%601.md +++ b/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.AspNetCore.ManagedWebApplicationFixture%601.md @@ -4,7 +4,7 @@ example: - *content --- -Use `ManagedWebApplicationFixture` when an xUnit class fixture should exercise an ASP.NET Core application's real entry point and let that entry point start the in-memory server. This is an opt-in path for the current minor release. Fixture setup remains lazy; consuming the test host starts the deferred host, after which the test can create a client from the exposed `TestServer` and verify the application's endpoint behavior. The legacy blocking path is retained for compatibility until it can be removed or changed in the next major release. +Use `ManagedWebApplicationFixture` when an xUnit class fixture should exercise an ASP.NET Core application's real entry point and let that entry point start the in-memory server. Derive the test from `WebApplicationTest` and pass the fixture to its base constructor so the base class initializes the fixture through `ConfigureHost` before the test reads `Server`. This is an opt-in path for the current minor release. Fixture setup remains lazy; consuming the test host starts the deferred host, after which the test can create a client from the exposed `TestServer` and verify the application's endpoint behavior. The legacy blocking path is retained for compatibility until it can be removed or changed in the next major release. ```csharp using System.Threading.Tasks; @@ -15,19 +15,17 @@ using Xunit; namespace CatalogApi.Tests; -public sealed class CatalogApiTest : IClassFixture> +public sealed class CatalogApiTest : WebApplicationTest> { - private readonly ManagedWebApplicationFixture _fixture; - - public CatalogApiTest(ManagedWebApplicationFixture fixture) + public CatalogApiTest(ManagedWebApplicationFixture fixture, ITestOutputHelper output) + : base(fixture, output) { - _fixture = fixture; } [Fact] public async Task HealthEndpoint_ReturnsApplicationState() { - using var client = _fixture.Server.CreateClient(); + using var client = Server.CreateClient(); var body = await client.GetStringAsync("/health").ConfigureAwait(false); diff --git a/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.ManagedApplicationFixture%601.md b/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.ManagedApplicationFixture%601.md index ef4e9e2..2fab889 100644 --- a/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.ManagedApplicationFixture%601.md +++ b/.docfx/api/types/Codebelt.Extensions.Xunit.Hosting.ManagedApplicationFixture%601.md @@ -4,7 +4,7 @@ example: - *content --- -Use `ManagedApplicationFixture` when an xUnit class fixture should exercise the application's real entry point and let that entry point start the host. This is an opt-in path for the current minor release. Fixture setup remains lazy; accessing the test host starts the deferred host and surfaces startup failures at the point the test consumes it. The legacy blocking path is retained for compatibility until it can be removed or changed in the next major release. +Use `ManagedApplicationFixture` when an xUnit class fixture should exercise the application's real entry point and let that entry point start the host. Derive the test from `ApplicationTest` and pass the fixture to its base constructor so the base class initializes the fixture through `ConfigureHost` before the test reads `Host`. This is an opt-in path for the current minor release. Fixture setup remains lazy; accessing the test host starts the deferred host and surfaces startup failures at the point the test consumes it. The legacy blocking path is retained for compatibility until it can be removed or changed in the next major release. ```csharp using Codebelt.Extensions.Xunit.Hosting; @@ -14,19 +14,17 @@ using Xunit; namespace InventoryWorker.Tests; -public sealed class InventoryWorkerTest : IClassFixture> +public sealed class InventoryWorkerTest : ApplicationTest> { - private readonly ManagedApplicationFixture _fixture; - - public InventoryWorkerTest(ManagedApplicationFixture fixture) + public InventoryWorkerTest(ManagedApplicationFixture fixture, ITestOutputHelper output) + : base(fixture, output) { - _fixture = fixture; } [Fact] public void Host_ContainsApplicationService() { - var identity = _fixture.Host.Services.GetRequiredService(); + var identity = Host.Services.GetRequiredService(); Assert.Equal("Inventory worker", identity.Name); } From a3937b1168307b6c610e4e61873b41e3baf56761 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Tue, 4 Aug 2026 01:28:47 +0200 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=8E=A8=20align=20exception=20handle?= =?UTF-8?q?r=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move catch clause to the same line as the closing brace of the try block for consistent formatting style. --- .../Program.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App/Program.cs b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App/Program.cs index af8889e..ed3d519 100644 --- a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App/Program.cs +++ b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperMinimalConsole.App/Program.cs @@ -26,8 +26,7 @@ public override async Task RunAsync(IServiceProvider serviceProvider, Cancellati try { await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } } From 712065c1953d633356352a05c5c60618ec7617f0 Mon Sep 17 00:00:00 2001 From: Michael Mortensen Date: Tue, 4 Aug 2026 10:34:24 +0200 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=90=9B=20fix=20bootstrapper=20async?= =?UTF-8?q?=20startup=20and=20cancellation=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the bootstrapper startup to properly implement async/await patterns and respect application lifetime cancellation signals. This ensures the hosted application can be gracefully shutdown by the framework rather than completing immediately. --- .../Startup.cs | 10 ++++++++-- .../ApplicationTestFactoryTest.cs | 7 ++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperConsole.App/Startup.cs b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperConsole.App/Startup.cs index ff8cb3c..9056c83 100644 --- a/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperConsole.App/Startup.cs +++ b/app/Codebelt.Extensions.Xunit.Hosting.BootstrapperConsole.App/Startup.cs @@ -23,8 +23,14 @@ public override void ConfigureConsole(IServiceProvider serviceProvider) { } - public override Task RunAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + public override async Task RunAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) { - return Task.CompletedTask; + try + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } } } diff --git a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/ApplicationTestFactoryTest.cs b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/ApplicationTestFactoryTest.cs index 8d89d12..08bb557 100644 --- a/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/ApplicationTestFactoryTest.cs +++ b/test/Codebelt.Extensions.Xunit.Hosting.FunctionalTests/ApplicationTestFactoryTest.cs @@ -38,12 +38,13 @@ public void Create_ShouldBootstrapApplication_WhenEntryPointUsesBootstrapperCons public void Create_ShouldStartEntrypoint_WhenUsingManagedApplicationFixture() { using var application = ApplicationTestFactory.Create(hostFixture: new ManagedApplicationFixture()); - - var marker = application.Host.Services.GetRequiredService(); + var services = application.Host.Services; + var lifetime = services.GetRequiredService(); + var marker = services.GetRequiredService(); Assert.Equal("Bootstrapper Console", marker.Value); Assert.True(BootstrapperConsoleProgram.MainInvoked); - Assert.True(BootstrapperConsoleProgram.EntrypointStarted); + Assert.True(lifetime.ApplicationStarted.IsCancellationRequested); } [Fact]