Shared lib for aspire/playwright test infra

This commit is contained in:
Joe DeCock 2025-08-29 15:08:52 -05:00
parent ead4f09669
commit a9bcbeaae4
29 changed files with 171 additions and 89 deletions

View file

@ -67,7 +67,7 @@
<PackageVersion Include="Microsoft.Extensions.Http" Condition="'$(TargetFramework)' == 'net9.0'" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Condition="'$(TargetFramework)' == 'net8.0'" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Condition="'$(TargetFramework)' == 'net9.0'" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.1.0" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.4.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Condition="'$(TargetFramework)' == 'net8.0'" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging" Condition="'$(TargetFramework)' == 'net9.0'" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Condition="'$(TargetFramework)' == 'net8.0'" Version="8.0.1" />

View file

@ -32,7 +32,9 @@
"bff\\templates\\src\\BffLocalApi\\BffLocalApi.csproj",
"bff\\templates\\src\\BffRemoteApi\\BffRemoteApi.csproj",
"bff\\test\\Bff.Tests\\Bff.Tests.csproj",
"bff\\test\\Hosts.Tests\\Hosts.Tests.csproj"
"bff\\test\\Hosts.Tests\\Hosts.Tests.csproj",
"shared\\Xunit.Playwright\\Duende.Xunit.Playwright.csproj",
"shared\\ShouldlyExtensions\\ShouldlyExtensions.csproj"
]
}
}
}

View file

@ -0,0 +1,25 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.Xunit.Playwright;
namespace Hosts.ServiceDefaults;
public class BffAppHostRoutes : IAppHostServiceRoutes
{
public string[] ServiceNames => AppHostServices.All;
public Uri UrlTo(string clientName)
{
var url = clientName switch
{
AppHostServices.Bff => "https://localhost:5002",
AppHostServices.BffBlazorPerComponent => "https://localhost:5105",
AppHostServices.BffMultiFrontend => "https://localhost:5005",
AppHostServices.BffBlazorWebassembly => "https://localhost:5006",
AppHostServices.TemplateBffBlazor => "https://localhost:7035",
_ => throw new InvalidOperationException("client not configured")
};
return new Uri(url);
}
}

View file

@ -19,6 +19,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\shared\Xunit.Playwright\Duende.Xunit.Playwright.csproj" />
<ProjectReference Include="..\..\src\Bff\Bff.csproj" />
</ItemGroup>

View file

@ -1,7 +1,8 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.Hosts.Tests.TestInfra.Retries;
using Duende.Xunit.Playwright;
using Duende.Xunit.Playwright.Retries;
using Hosts.ServiceDefaults;
using Hosts.Tests.PageModels;
using Hosts.Tests.TestInfra;
@ -9,8 +10,8 @@ using Xunit.Abstractions;
namespace Hosts.Tests;
public class BffBlazorWebAssemblyTests(ITestOutputHelper output, AppHostFixture fixture)
: PlaywrightTestBase(output, fixture)
public class BffBlazorWebAssemblyTests(ITestOutputHelper output, BffHostTestFixture fixture)
: BffPlaywrightTestBase(output, fixture)
{
public async Task<WebAssemblyPageModel> GoToHome()
{

View file

@ -1,18 +1,21 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.Xunit.Playwright;
using Hosts.ServiceDefaults;
using Hosts.Tests.TestInfra;
using Projects;
using Xunit.Abstractions;
namespace Hosts.Tests;
public class BffTests : IntegrationTestBase
[Collection(BffAppHostCollection.CollectionName)]
public class BffTests : IntegrationTestBase<Hosts_AppHost>
{
private readonly HttpClient _httpClient;
private readonly BffClient _bffClient;
public BffTests(ITestOutputHelper output, AppHostFixture fixture) : base(output: output, fixture: fixture)
public BffTests(ITestOutputHelper output, BffHostTestFixture fixture) : base(output: output, fixture: fixture)
{
_httpClient = CreateHttpClient(AppHostServices.Bff);
_bffClient = new BffClient(CreateHttpClient(AppHostServices.Bff));
@ -28,7 +31,6 @@ public class BffTests : IntegrationTestBase
[SkippableFact]
public async Task Can_initiate_login()
{
var response = await _httpClient.GetAsync("/");
response.StatusCode.ShouldBe(HttpStatusCode.OK);

View file

@ -1,7 +1,8 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.Hosts.Tests.TestInfra.Retries;
using Duende.Xunit.Playwright;
using Duende.Xunit.Playwright.Retries;
using Hosts.ServiceDefaults;
using Hosts.Tests.PageModels;
using Hosts.Tests.TestInfra;
@ -9,8 +10,8 @@ using Xunit.Abstractions;
namespace Hosts.Tests;
public class BlazorPerComponentTests(ITestOutputHelper output, AppHostFixture fixture)
: PlaywrightTestBase(output, fixture)
public class BlazorPerComponentTests(ITestOutputHelper output, BffHostTestFixture fixture)
: BffPlaywrightTestBase(output, fixture)
{
public async Task<PerComponentPageModel> GoToHome()

View file

@ -5,6 +5,7 @@
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<Configurations>Debug;Release;Debug_ncrunch</Configurations>
<RootNamespace>Hosts.Tests</RootNamespace>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug_ncrunch|AnyCPU'">
@ -12,7 +13,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />
@ -22,11 +22,9 @@
<PackageReference Include="Aspire.Hosting.Testing" />
<PackageReference Include="Xunit.SkippableFact" />
<PackageReference Include="Microsoft.Playwright.Xunit" />
</ItemGroup>
<ItemGroup Condition="'$(Configuration)' != 'Debug_NCrunch'">
<ProjectReference Include="..\..\hosts\Hosts.AppHost\Hosts.AppHost.csproj" />
<Using Include="Aspire.Hosting.ApplicationModel" />
<Using Include="Aspire.Hosting.Testing" />
@ -39,4 +37,8 @@
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\shared\Xunit.Playwright\Duende.Xunit.Playwright.csproj" />
</ItemGroup>
</Project>

View file

@ -1,6 +1,7 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.Xunit.Playwright;
using Hosts.ServiceDefaults;
using Hosts.Tests.PageModels;
using Hosts.Tests.TestInfra;
@ -8,8 +9,8 @@ using Xunit.Abstractions;
namespace Hosts.Tests.Templates;
public class BffBlazorTemplateTests(ITestOutputHelper output, AppHostFixture fixture)
: PlaywrightTestBase(output, fixture)
public class BffBlazorTemplateTests(ITestOutputHelper output, BffHostTestFixture fixture)
: BffPlaywrightTestBase(output, fixture)
{
public async Task<WebAssemblyPageModel> GoToHome()
{

View file

@ -0,0 +1,13 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Hosts.Tests.TestInfra;
[CollectionDefinition(CollectionName)]
public class BffAppHostCollection : ICollectionFixture<BffHostTestFixture>
{
public const string CollectionName = "apphost collection";
// This class has no code, and is never created. Its purpose is simply
// to be the place to apply [CollectionDefinition] and all the
// ICollectionFixture<> interfaces.
}

View file

@ -0,0 +1,12 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.Xunit.Playwright;
using Hosts.ServiceDefaults;
using Projects;
namespace Hosts.Tests.TestInfra;
public class BffHostTestFixture() : AppHostFixture<Hosts_AppHost>(new BffAppHostRoutes())
{
}

View file

@ -0,0 +1,16 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.Xunit.Playwright;
using Projects;
using Xunit.Abstractions;
namespace Hosts.Tests.TestInfra;
[Collection(BffAppHostCollection.CollectionName)]
public class BffPlaywrightTestBase : PlaywrightTestBase<Hosts_AppHost>
{
public BffPlaywrightTestBase(ITestOutputHelper output, AppHostFixture<Hosts_AppHost> fixture) : base(output, fixture)
{
}
}

View file

@ -12,9 +12,6 @@ dotnet build
pwsh bin/Debug/net9.0/playwright.ps1 install --with-deps
```
The actual tests have been written in such a way that they only need an HTTP client to work. If you don't do anything,
then the system will start an aspire test host, run the tests, then kill the aspire host again.

View file

@ -166,6 +166,7 @@
</Folder>
<Folder Name="/shared/">
<Project Path="shared/ShouldlyExtensions/ShouldlyExtensions.csproj" />
<Project Path="shared/Xunit.Playwright/Duende.Xunit.Playwright.csproj" />
</Folder>
<Folder Name="/templates/">
<Project Path="templates/build/build.csproj" />

View file

@ -2,46 +2,19 @@
// See LICENSE in the project root for license information.
using Aspire.Hosting;
using Hosts.ServiceDefaults;
using Microsoft.Extensions.Logging;
#if !DEBUG_NCRUNCH
using Microsoft.Extensions.Logging.Console;
using Projects;
#endif
using Serilog;
using Serilog.Core;
using Serilog.Extensions.Logging;
namespace Hosts.Tests.TestInfra;
namespace Duende.Xunit.Playwright;
[CollectionDefinition(AppHostCollection.CollectionName)]
public class AppHostCollection : ICollectionFixture<AppHostFixture>
{
public const string CollectionName = "apphost collection";
// This class has no code, and is never created. Its purpose is simply
// to be the place to apply [CollectionDefinition] and all the
// ICollectionFixture<> interfaces.
}
/// <summary>
/// This fixture will launch the app host, if needed.
/// It has 3 modes:
/// - Directly. Then the test fixture will launch an aspire test host. It will run all tests against the aspire test
/// host.
/// In order to make this work, there were two things that I needed to overcome (see below). Service Discovery and
/// Shared CookieContainers.
/// - With manually run aspire host.The advantage of this is that you can keep your aspire host running
/// and only iterate on your tests. This is more efficient for writing the tests.It also leaves the door open to
/// re-using these tests to run them against a deployed in stance somewhere in the future.Downside is that you cannot
/// debug both your tests and host at the same time because visual studio compiles them in the same location.
/// - With NCrunch. It turns out that NCrunch doesn't support building aspire projects.
/// However, I've always found that iterating over tests using ncrunch is the fastest way to get feedback.So, to make
/// this work, I had to add a conditional compilation.
/// </summary>
// ReSharper disable once ClassNeverInstantiated.Global
public class AppHostFixture : IAsyncLifetime
public class AppHostFixture<THost>(IAppHostServiceRoutes routes) : IAsyncLifetime where THost : class
{
private readonly TextWriter _startupLogs = new StringWriter();
private WriteTestOutput? _activeWriter;
@ -90,7 +63,7 @@ public class AppHostFixture : IAsyncLifetime
// Not running in ncrunch AND no service found running.
// So, create an AppHost that will be used for the duration of this test run.
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Hosts_AppHost>();
.CreateAsync<THost>();
appHost.Configuration["DcpPublisher:RandomizePorts"] = "false";
appHost.Services.ConfigureHttpClientDefaults(c => c.ConfigurePrimaryHttpMessageHandler(() =>
@ -115,7 +88,7 @@ public class AppHostFixture : IAsyncLifetime
// Wait for all the services so that their logs are mostly written.
foreach (var resource in AppHostServices.All)
foreach (var resource in routes.ServiceNames)
{
await resourceNotificationService.WaitForResourceAsync(
resource,
@ -251,18 +224,7 @@ public class AppHostFixture : IAsyncLifetime
{
if (UsingAlreadyRunningInstance)
{
// An aspire host is already found (likely was started manually)
// so build an http client that directly points to this host.
var url = clientName switch
{
AppHostServices.Bff => "https://localhost:5002",
AppHostServices.BffBlazorPerComponent => "https://localhost:5105",
AppHostServices.BffMultiFrontend => "https://localhost:5005",
AppHostServices.BffBlazorWebassembly => "https://localhost:5006",
AppHostServices.TemplateBffBlazor => "https://localhost:7035",
_ => throw new InvalidOperationException("client not configured")
};
return new Uri(url);
return routes.UrlTo(clientName);
}
else
{

View file

@ -3,7 +3,7 @@
using Microsoft.Extensions.Logging;
namespace Hosts.Tests.TestInfra;
namespace Duende.Xunit.Playwright;
public class AutoFollowRedirectHandler(ILogger<AutoFollowRedirectHandler> logger) : DelegatingHandler
{

View file

@ -1,7 +1,7 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Hosts.Tests.TestInfra;
namespace Duende.Xunit.Playwright;
public class CloningHttpMessageHandler(HttpClient innerHttpClient) : HttpMessageHandler
{

View file

@ -3,7 +3,7 @@
using Microsoft.Net.Http.Headers;
namespace Hosts.Tests.TestInfra;
namespace Duende.Xunit.Playwright;
public class CookieHandler : DelegatingHandler
{

View file

@ -1,7 +1,7 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Hosts.Tests.TestInfra;
namespace Duende.Xunit.Playwright;
public class DelegateDisposable(Action onDispose) : IDisposable
{

View file

@ -3,7 +3,7 @@
using System.Text;
namespace Hosts.Tests.TestInfra;
namespace Duende.Xunit.Playwright;
public class DelegateTextWriter : TextWriter
{

View file

@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Duende.Xunit.Playwright</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Serilog.Sinks.TextWriter" />
<PackageReference Include="Serilog.Sinks.XUnit" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Aspire.Hosting.Testing" />
<PackageReference Include="Xunit.SkippableFact" />
<PackageReference Include="Microsoft.Playwright.Xunit" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit.core" />
</ItemGroup>
<ItemGroup>
<Using Include="Microsoft.Extensions.DependencyInjection" />
<Using Include="System.Net" />
<Using Include="Shouldly"/>
<Using Include="Xunit" />
<Using Include="Aspire.Hosting.ApplicationModel" />
<Using Include="Aspire.Hosting.Testing" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,13 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.Xunit.Playwright;
/// <summary>
/// This interface describes all the services in an AppHost and provides Urls to services in the host for external code.
/// </summary>
public interface IAppHostServiceRoutes
{
public string[] ServiceNames { get; }
public Uri UrlTo(string clientName);
}

View file

@ -3,14 +3,13 @@
using Xunit.Abstractions;
namespace Hosts.Tests.TestInfra;
namespace Duende.Xunit.Playwright;
[Collection(AppHostCollection.CollectionName)]
public class IntegrationTestBase : IDisposable
public class IntegrationTestBase<THost> : IDisposable where THost : class
{
private readonly IDisposable _loggingScope;
public IntegrationTestBase(ITestOutputHelper output, AppHostFixture fixture)
public IntegrationTestBase(ITestOutputHelper output, AppHostFixture<THost> fixture)
{
Output = output;
Fixture = fixture;
@ -23,13 +22,13 @@ public class IntegrationTestBase : IDisposable
{
#if DEBUG_NCRUNCH
// Running in NCrunch. NCrunch cannot build the aspire project, so it needs
// to be started manually.
// to be started manually.
Skip.If(true, "When running the Host.Tests using NCrunch, you must start the Hosts.AppHost project manually. IE: dotnet run -p bff/samples/Hosts.AppHost. Or start without debugging from the UI. ");
#endif
}
}
public AppHostFixture Fixture { get; }
public AppHostFixture<THost> Fixture { get; }
public ITestOutputHelper Output { get; }

View file

@ -2,13 +2,12 @@
// See LICENSE in the project root for license information.
using System.Reflection;
using Hosts.Tests.TestInfra;
using Microsoft.Playwright;
using Microsoft.Playwright.Xunit;
using Xunit.Abstractions;
using Xunit.Sdk;
namespace Hosts.Tests;
namespace Duende.Xunit.Playwright;
public class Defaults
{
@ -17,12 +16,11 @@ public class Defaults
}
[WithTestName]
[Collection(AppHostCollection.CollectionName)]
public class PlaywrightTestBase : PageTest, IDisposable
public class PlaywrightTestBase<THost> : PageTest, IDisposable where THost : class
{
private readonly IDisposable _loggingScope;
public PlaywrightTestBase(ITestOutputHelper output, AppHostFixture fixture)
public PlaywrightTestBase(ITestOutputHelper output, AppHostFixture<THost> fixture)
{
Output = output;
Fixture = fixture;
@ -36,7 +34,7 @@ public class PlaywrightTestBase : PageTest, IDisposable
{
#if DEBUG_NCRUNCH
// Running in NCrunch. NCrunch cannot build the aspire project, so it needs
// to be started manually.
// to be started manually.
Skip.If(true, "When running the Host.Tests using NCrunch, you must start the Hosts.AppHost project manually. IE: dotnet run -p bff/samples/Hosts.AppHost. Or start without debugging from the UI. ");
#endif
}
@ -58,7 +56,7 @@ public class PlaywrightTestBase : PageTest, IDisposable
public override async Task DisposeAsync()
{
var path = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? Environment.CurrentDirectory;
// if path ends with /bin/{build configuration}/{dotnetversion}, then strip that from the path.
// if path ends with /bin/{build configuration}/{dotnetversion}, then strip that from the path.
var bin = Path.GetFullPath(Path.Combine(path, "../../"));
if (bin.EndsWith("\\bin\\") || bin.EndsWith("/bin/"))
{
@ -82,14 +80,14 @@ public class PlaywrightTestBase : PageTest, IDisposable
Locale = "en-US",
ColorScheme = ColorScheme.Light,
// We need to ignore https errors to make this work on the build server.
// We need to ignore https errors to make this work on the build server.
// Even though we use dotnet dev-certs https --trust on the build agent,
// it still claims the certs are invalid.
// it still claims the certs are invalid.
IgnoreHTTPSErrors = true,
};
public AppHostFixture Fixture { get; }
public AppHostFixture<THost> Fixture { get; }
public ITestOutputHelper Output { get; }

View file

@ -4,7 +4,7 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace Hosts.Tests.TestInfra;
namespace Duende.Xunit.Playwright;
public class RequestLoggingHandler(
ILogger<RequestLoggingHandler> log,

View file

@ -3,7 +3,7 @@
using Xunit.Sdk;
namespace Duende.Hosts.Tests.TestInfra.Retries;
namespace Duende.Xunit.Playwright.Retries;
[XunitTestCaseDiscoverer(
typeName: "Duende.Hosts.Tests.TestInfra.Retries.RetryableTestDiscoverer",

View file

@ -4,7 +4,7 @@
using Xunit.Abstractions;
using Xunit.Sdk;
namespace Duende.Hosts.Tests.TestInfra.Retries;
namespace Duende.Xunit.Playwright.Retries;
public class RetryableTestCase(
IMessageSink sink,

View file

@ -4,7 +4,7 @@
using Xunit.Abstractions;
using Xunit.Sdk;
namespace Duende.Hosts.Tests.TestInfra.Retries;
namespace Duende.Xunit.Playwright.Retries;
public class RetryableTestDiscoverer(IMessageSink messageSink) : IXunitTestCaseDiscoverer
{

View file

@ -1,6 +1,6 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Hosts.Tests.TestInfra;
namespace Duende.Xunit.Playwright;
public delegate void WriteTestOutput(string message);