From dfff61ae7300b29a81b0c2ca579ac0ba6446fcb7 Mon Sep 17 00:00:00 2001 From: Daniel Gee Date: Sun, 3 May 2026 22:27:58 +0100 Subject: [PATCH 1/2] test: share one AppHost across integration tests (#885) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Factory/ApiTestFixture.cs | 85 ++++++++++++++++--- .../IngredientIntegrationTests.cs | 2 +- .../RecipeIntegrationTests.cs | 2 +- .../RecipeWithIngredientsIntegrationTests.cs | 2 +- .../ValidationIntegrationTests.cs | 2 +- 5 files changed, 76 insertions(+), 17 deletions(-) diff --git a/backend/MenuApi.Integration.Tests/Factory/ApiTestFixture.cs b/backend/MenuApi.Integration.Tests/Factory/ApiTestFixture.cs index 867ccca6..6da9e5de 100644 --- a/backend/MenuApi.Integration.Tests/Factory/ApiTestFixture.cs +++ b/backend/MenuApi.Integration.Tests/Factory/ApiTestFixture.cs @@ -1,9 +1,10 @@ -using Aspire.Hosting; +using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Net.Http.Headers; +using System.Threading; using Xunit; @@ -11,23 +12,84 @@ namespace MenuApi.Integration.Tests.Factory; public class ApiTestFixture : IAsyncLifetime { + private static readonly SemaphoreSlim SharedStateLock = new(1, 1); + private static DistributedApplication sharedApp; + private static AuthenticationHeaderValue sharedAuthHeader; + private static int activeFixtureCount; + private static bool applicationCreated; + public DistributedApplication app { get; private set; } - private IDistributedApplicationTestingBuilder appHost; - private AuthenticationHeaderValue cachedAuthHeader; public async Task GetHttpClient() { var httpClient = app.CreateHttpClient("apiservice"); - cachedAuthHeader ??= await new ApiAuthentication().GetAuthenticationHeaderValue(); + sharedAuthHeader ??= await new ApiAuthentication().GetAuthenticationHeaderValue(); - httpClient.DefaultRequestHeaders.Authorization = cachedAuthHeader; + httpClient.DefaultRequestHeaders.Authorization = sharedAuthHeader; return httpClient; } async ValueTask IAsyncLifetime.InitializeAsync() { - appHost = await DistributedApplicationTestingBuilder + await SharedStateLock.WaitAsync(); + try + { + if (sharedApp is null) + { + if (applicationCreated) + { + throw new InvalidOperationException( + "ApiTestFixture should only create the distributed application once per test run."); + } + + sharedApp = await CreateSharedAppAsync(); + applicationCreated = true; + } + + activeFixtureCount++; + app = sharedApp; + } + finally + { + SharedStateLock.Release(); + } + } + + async ValueTask IAsyncDisposable.DisposeAsync() + { + DistributedApplication appToDispose = null; + + await SharedStateLock.WaitAsync(); + try + { + if (activeFixtureCount > 0) + { + activeFixtureCount--; + } + + if (activeFixtureCount == 0 && sharedApp is not null) + { + appToDispose = sharedApp; + sharedApp = null; + sharedAuthHeader = null; + } + } + finally + { + SharedStateLock.Release(); + } + + if (appToDispose is not null) + { + await appToDispose.StopAsync(); + await appToDispose.DisposeAsync(); + } + } + + private static async Task CreateSharedAppAsync() + { + var appHost = await DistributedApplicationTestingBuilder .CreateAsync(); appHost.Services.ConfigureHttpClientDefaults(clientBuilder => @@ -43,7 +105,7 @@ async ValueTask IAsyncLifetime.InitializeAsync() appHost.WithContainersLifetime(ContainerLifetime.Session); - app = await appHost.BuildAsync(); + var app = await appHost.BuildAsync(); await app.StartAsync(); @@ -60,11 +122,8 @@ await resourceNotificationService.WaitForResourceAsync( KnownResourceStates.Running ) .WaitAsync(TimeSpan.FromSeconds(30)); - } - async ValueTask IAsyncDisposable.DisposeAsync() - { - await app.StopAsync(); - await app.DisposeAsync(); + + return app; } } @@ -107,4 +166,4 @@ public static async Task ShouldHaveStatusCode(this HttpResponseMessage response, $"Expected status code {(int)expected} {expected} but received {(int)response.StatusCode} {response.StatusCode}.\n\nResponse body:\n{body}"); } } -} \ No newline at end of file +} diff --git a/backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs b/backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs index 8f08d944..be10887f 100644 --- a/backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs +++ b/backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs @@ -10,7 +10,7 @@ namespace MenuApi.Integration.Tests; [Collection("API Host Collection")] -public class IngredientIntegrationTests : IClassFixture +public class IngredientIntegrationTests { private readonly JsonSerializerOptions jsonOptions; private readonly ApiTestFixture fixture; diff --git a/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs b/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs index c2f82067..5d045906 100644 --- a/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs +++ b/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs @@ -10,7 +10,7 @@ namespace MenuApi.Integration.Tests; [Collection("API Host Collection")] -public class RecipeIntegrationTests : IClassFixture +public class RecipeIntegrationTests { private const string Grams = "Grams"; diff --git a/backend/MenuApi.Integration.Tests/RecipeWithIngredientsIntegrationTests.cs b/backend/MenuApi.Integration.Tests/RecipeWithIngredientsIntegrationTests.cs index 47e839dd..eec48418 100644 --- a/backend/MenuApi.Integration.Tests/RecipeWithIngredientsIntegrationTests.cs +++ b/backend/MenuApi.Integration.Tests/RecipeWithIngredientsIntegrationTests.cs @@ -10,7 +10,7 @@ namespace MenuApi.Integration.Tests; [Collection("API Host Collection")] -public class RecipeWithIngredientsIntegrationTests : IClassFixture +public class RecipeWithIngredientsIntegrationTests { private readonly JsonSerializerOptions jsonOptions; private readonly ApiTestFixture fixture; diff --git a/backend/MenuApi.Integration.Tests/ValidationIntegrationTests.cs b/backend/MenuApi.Integration.Tests/ValidationIntegrationTests.cs index 5ee983f4..0a4c611c 100644 --- a/backend/MenuApi.Integration.Tests/ValidationIntegrationTests.cs +++ b/backend/MenuApi.Integration.Tests/ValidationIntegrationTests.cs @@ -10,7 +10,7 @@ namespace MenuApi.Integration.Tests; [Collection("API Host Collection")] -public class ValidationIntegrationTests : IClassFixture +public class ValidationIntegrationTests { private const string ApplicationJson = "application/json"; private const string ApiRecipeRoute = "/api/recipe"; From 41ba49f2c62888c9e4334569605d1122e2c6ff5e Mon Sep 17 00:00:00 2001 From: Daniel Gee Date: Sun, 3 May 2026 23:18:56 +0100 Subject: [PATCH 2/2] test: remove static ApiTestFixture state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Factory/ApiTestFixture.cs | 81 +++---------------- 1 file changed, 11 insertions(+), 70 deletions(-) diff --git a/backend/MenuApi.Integration.Tests/Factory/ApiTestFixture.cs b/backend/MenuApi.Integration.Tests/Factory/ApiTestFixture.cs index 6da9e5de..eb6ea3b3 100644 --- a/backend/MenuApi.Integration.Tests/Factory/ApiTestFixture.cs +++ b/backend/MenuApi.Integration.Tests/Factory/ApiTestFixture.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Net.Http.Headers; -using System.Threading; using Xunit; @@ -12,84 +11,23 @@ namespace MenuApi.Integration.Tests.Factory; public class ApiTestFixture : IAsyncLifetime { - private static readonly SemaphoreSlim SharedStateLock = new(1, 1); - private static DistributedApplication sharedApp; - private static AuthenticationHeaderValue sharedAuthHeader; - private static int activeFixtureCount; - private static bool applicationCreated; - public DistributedApplication app { get; private set; } + private IDistributedApplicationTestingBuilder appHost; + private AuthenticationHeaderValue cachedAuthHeader; public async Task GetHttpClient() { var httpClient = app.CreateHttpClient("apiservice"); - sharedAuthHeader ??= await new ApiAuthentication().GetAuthenticationHeaderValue(); + cachedAuthHeader ??= await new ApiAuthentication().GetAuthenticationHeaderValue(); - httpClient.DefaultRequestHeaders.Authorization = sharedAuthHeader; + httpClient.DefaultRequestHeaders.Authorization = cachedAuthHeader; return httpClient; } async ValueTask IAsyncLifetime.InitializeAsync() { - await SharedStateLock.WaitAsync(); - try - { - if (sharedApp is null) - { - if (applicationCreated) - { - throw new InvalidOperationException( - "ApiTestFixture should only create the distributed application once per test run."); - } - - sharedApp = await CreateSharedAppAsync(); - applicationCreated = true; - } - - activeFixtureCount++; - app = sharedApp; - } - finally - { - SharedStateLock.Release(); - } - } - - async ValueTask IAsyncDisposable.DisposeAsync() - { - DistributedApplication appToDispose = null; - - await SharedStateLock.WaitAsync(); - try - { - if (activeFixtureCount > 0) - { - activeFixtureCount--; - } - - if (activeFixtureCount == 0 && sharedApp is not null) - { - appToDispose = sharedApp; - sharedApp = null; - sharedAuthHeader = null; - } - } - finally - { - SharedStateLock.Release(); - } - - if (appToDispose is not null) - { - await appToDispose.StopAsync(); - await appToDispose.DisposeAsync(); - } - } - - private static async Task CreateSharedAppAsync() - { - var appHost = await DistributedApplicationTestingBuilder + appHost = await DistributedApplicationTestingBuilder .CreateAsync(); appHost.Services.ConfigureHttpClientDefaults(clientBuilder => @@ -105,7 +43,7 @@ private static async Task CreateSharedAppAsync() appHost.WithContainersLifetime(ContainerLifetime.Session); - var app = await appHost.BuildAsync(); + app = await appHost.BuildAsync(); await app.StartAsync(); @@ -122,8 +60,11 @@ await resourceNotificationService.WaitForResourceAsync( KnownResourceStates.Running ) .WaitAsync(TimeSpan.FromSeconds(30)); - - return app; + } + async ValueTask IAsyncDisposable.DisposeAsync() + { + await app.StopAsync(); + await app.DisposeAsync(); } }