From f8e43a6236dfef5a587e1fc6a8744430cbbb4283 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 24 May 2026 09:09:50 +0200 Subject: [PATCH 01/14] Extract sending HTTP requests into separate class --- test/TestBuildingBlocks/HttpClientWrapper.cs | 124 +++++++++++++++++++ test/TestBuildingBlocks/IntegrationTest.cs | 91 +++----------- 2 files changed, 142 insertions(+), 73 deletions(-) create mode 100644 test/TestBuildingBlocks/HttpClientWrapper.cs diff --git a/test/TestBuildingBlocks/HttpClientWrapper.cs b/test/TestBuildingBlocks/HttpClientWrapper.cs new file mode 100644 index 0000000000..df83326867 --- /dev/null +++ b/test/TestBuildingBlocks/HttpClientWrapper.cs @@ -0,0 +1,124 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using JsonApiDotNetCore.Middleware; + +namespace TestBuildingBlocks; + +/// +/// A wrapper for that conveniently enables executing HTTP requests against JSON:API endpoints. +/// +public sealed class HttpClientWrapper +{ + private static readonly MediaTypeHeaderValue DefaultMediaType = MediaTypeHeaderValue.Parse(JsonApiMediaType.Default.ToString()); + + private static readonly MediaTypeWithQualityHeaderValue OperationsMediaType = + MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.AtomicOperations.ToString()); + + private readonly HttpClient _httpClient; + private readonly JsonSerializerOptions _serializerOptions; + + public HttpClientWrapper(HttpClient httpClient, JsonSerializerOptions serializerOptions) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(serializerOptions); + + _httpClient = httpClient; + _serializerOptions = serializerOptions; + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteHeadAsync(string requestUrl, + Action? setRequestHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Head, requestUrl, null, null, setRequestHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteGetAsync(string requestUrl, + Action? setRequestHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Get, requestUrl, null, null, setRequestHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAsync(string requestUrl, + object requestBody, string? contentType = null, Action? setRequestHeaders = null) + { + MediaTypeHeaderValue mediaType = contentType == null ? DefaultMediaType : MediaTypeHeaderValue.Parse(contentType); + + return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, mediaType, setRequestHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAtomicAsync(string requestUrl, + object requestBody) + { + Action setRequestHeaders = headers => headers.Accept.Add(OperationsMediaType); + + return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, OperationsMediaType, setRequestHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePatchAsync(string requestUrl, + object requestBody, Action? setRequestHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, DefaultMediaType, setRequestHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteDeleteAsync(string requestUrl, + object? requestBody = null, Action? setRequestHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, DefaultMediaType, setRequestHeaders); + } + + private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteRequestAsync(HttpMethod method, + string requestUrl, object? requestBody, MediaTypeHeaderValue? contentType, Action? setRequestHeaders) + { + using var request = new HttpRequestMessage(method, requestUrl); + string? requestText = SerializeRequest(requestBody); + + if (!string.IsNullOrEmpty(requestText)) + { + requestText = requestText.Replace("atomic__", "atomic:"); + request.Content = new StringContent(requestText); + request.Content.Headers.ContentLength = Encoding.UTF8.GetByteCount(requestText); + + if (contentType != null) + { + request.Content.Headers.ContentType = contentType; + } + } + + setRequestHeaders?.Invoke(request.Headers); + + HttpResponseMessage responseMessage = await _httpClient.SendAsync(request); + + string responseText = await responseMessage.Content.ReadAsStringAsync(); + var responseDocument = DeserializeResponse(responseText); + + return (responseMessage, responseDocument!); + } + + private string? SerializeRequest(object? requestBody) + { + return requestBody == null ? null : requestBody as string ?? JsonSerializer.Serialize(requestBody, _serializerOptions); + } + + private TResponseDocument? DeserializeResponse(string responseText) + { + if (typeof(TResponseDocument) == typeof(string)) + { + return (TResponseDocument)(object)responseText; + } + + if (string.IsNullOrEmpty(responseText)) + { + return default; + } + + try + { + return JsonSerializer.Deserialize(responseText, _serializerOptions); + } + catch (JsonException exception) + { + throw new FormatException($"Failed to deserialize response body to JSON:\n{responseText}", exception); + } + } +} diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index b67b1e1e29..a1d9cf349f 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -1,8 +1,6 @@ using System.Net.Http.Headers; -using System.Text; using System.Text.Json; using FluentAssertions.Extensions; -using JsonApiDotNetCore.Middleware; using Xunit; namespace TestBuildingBlocks; @@ -13,11 +11,6 @@ namespace TestBuildingBlocks; /// public abstract class IntegrationTest : IAsyncLifetime { - private static readonly MediaTypeHeaderValue DefaultMediaType = MediaTypeHeaderValue.Parse(JsonApiMediaType.Default.ToString()); - - private static readonly MediaTypeWithQualityHeaderValue OperationsMediaType = - MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.AtomicOperations.ToString()); - private static readonly SemaphoreSlim ThrottleSemaphore = GetDefaultThrottleSemaphore(); public static DateTimeOffset DefaultDateTimeUtc { get; } = 1.January(2020).At(1, 2, 3).AsUtc(); @@ -33,101 +26,53 @@ private static SemaphoreSlim GetDefaultThrottleSemaphore() public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteHeadAsync(string requestUrl, Action? setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Head, requestUrl, null, null, setRequestHeaders); + using HttpClient httpClient = CreateClient(); + var wrapper = new HttpClientWrapper(httpClient, SerializerOptions); + return await wrapper.ExecuteHeadAsync(requestUrl, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteGetAsync(string requestUrl, Action? setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Get, requestUrl, null, null, setRequestHeaders); + using HttpClient httpClient = CreateClient(); + var wrapper = new HttpClientWrapper(httpClient, SerializerOptions); + return await wrapper.ExecuteGetAsync(requestUrl, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAsync(string requestUrl, object requestBody, string? contentType = null, Action? setRequestHeaders = null) { - MediaTypeHeaderValue mediaType = contentType == null ? DefaultMediaType : MediaTypeHeaderValue.Parse(contentType); - - return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, mediaType, setRequestHeaders); + using HttpClient httpClient = CreateClient(); + var wrapper = new HttpClientWrapper(httpClient, SerializerOptions); + return await wrapper.ExecutePostAsync(requestUrl, requestBody, contentType, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAtomicAsync(string requestUrl, object requestBody) { - Action setRequestHeaders = headers => headers.Accept.Add(OperationsMediaType); - - return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, OperationsMediaType, setRequestHeaders); + using HttpClient httpClient = CreateClient(); + var wrapper = new HttpClientWrapper(httpClient, SerializerOptions); + return await wrapper.ExecutePostAtomicAsync(requestUrl, requestBody); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePatchAsync(string requestUrl, object requestBody, Action? setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, DefaultMediaType, setRequestHeaders); + using HttpClient httpClient = CreateClient(); + var wrapper = new HttpClientWrapper(httpClient, SerializerOptions); + return await wrapper.ExecutePatchAsync(requestUrl, requestBody, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteDeleteAsync(string requestUrl, object? requestBody = null, Action? setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, DefaultMediaType, setRequestHeaders); - } - - private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteRequestAsync(HttpMethod method, - string requestUrl, object? requestBody, MediaTypeHeaderValue? contentType, Action? setRequestHeaders) - { - using var request = new HttpRequestMessage(method, requestUrl); - string? requestText = SerializeRequest(requestBody); - - if (!string.IsNullOrEmpty(requestText)) - { - requestText = requestText.Replace("atomic__", "atomic:"); - request.Content = new StringContent(requestText); - request.Content.Headers.ContentLength = Encoding.UTF8.GetByteCount(requestText); - - if (contentType != null) - { - request.Content.Headers.ContentType = contentType; - } - } - - setRequestHeaders?.Invoke(request.Headers); - - using HttpClient client = CreateClient(); - HttpResponseMessage responseMessage = await client.SendAsync(request); - - string responseText = await responseMessage.Content.ReadAsStringAsync(); - var responseDocument = DeserializeResponse(responseText); - - return (responseMessage, responseDocument!); - } - - private string? SerializeRequest(object? requestBody) - { - return requestBody == null ? null : requestBody as string ?? JsonSerializer.Serialize(requestBody, SerializerOptions); + using HttpClient httpClient = CreateClient(); + var wrapper = new HttpClientWrapper(httpClient, SerializerOptions); + return await wrapper.ExecuteDeleteAsync(requestUrl, requestBody, setRequestHeaders); } protected abstract HttpClient CreateClient(); - private TResponseDocument? DeserializeResponse(string responseText) - { - if (typeof(TResponseDocument) == typeof(string)) - { - return (TResponseDocument)(object)responseText; - } - - if (string.IsNullOrEmpty(responseText)) - { - return default; - } - - try - { - return JsonSerializer.Deserialize(responseText, SerializerOptions); - } - catch (JsonException exception) - { - throw new FormatException($"Failed to deserialize response body to JSON:\n{responseText}", exception); - } - } - public async Task InitializeAsync() { await ThrottleSemaphore.WaitAsync(); From 38acf6f5ce0df0e7af13936b3e8f7f403a898a5f Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 24 May 2026 22:05:06 +0200 Subject: [PATCH 02/14] --wip --- package-versions.props | 1 + .../ContentTypeHeaderTests.cs | 2 +- .../ApiControllerAttributeLogTests.cs | 4 +- .../DuplicateResourceControllerTests.cs | 4 +- .../UnknownResourceControllerTests.cs | 4 +- .../SerializerIgnoreConditionTests.cs | 4 +- ...TestableHttpClientRequestAdapterFactory.cs | 8 +- .../OpenApiTests/LegacyOpenApi/LegacyTests.cs | 7 +- .../MissingFromBodyOnPatchMethodTests.cs | 7 +- .../MissingFromBodyOnPostMethodTests.cs | 9 +- test/OpenApiTests/OpenApiTestContext.cs | 9 +- test/TestBuildingBlocks/FactoryBridge.cs | 79 +++++++ test/TestBuildingBlocks/HttpClientWrapper.cs | 2 +- test/TestBuildingBlocks/IStartup.cs | 11 + test/TestBuildingBlocks/IntegrationTest.cs | 7 + .../IntegrationTestContext.cs | 217 ++++++++---------- .../TestBuildingBlocks.csproj | 3 +- test/TestBuildingBlocks/TestableStartup.cs | 2 +- test/TestBuildingBlocks/appsettings.json | 15 -- 19 files changed, 229 insertions(+), 166 deletions(-) create mode 100644 test/TestBuildingBlocks/FactoryBridge.cs create mode 100644 test/TestBuildingBlocks/IStartup.cs delete mode 100644 test/TestBuildingBlocks/appsettings.json diff --git a/package-versions.props b/package-versions.props index 57f55ff647..6d51c8b603 100644 --- a/package-versions.props +++ b/package-versions.props @@ -30,6 +30,7 @@ 10.*-* 10.0.* 18.5.* + 9.9.* 2.9.* 3.1.* diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index 9c6b5c6a28..810438bf90 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -432,7 +432,7 @@ public async Task Denies_JsonApi_ContentType_header_with_CharSet() }; const string route = "/policies"; - string contentType = $"{JsonApiMediaType.Default}; charset=ISO-8859-4"; + string contentType = $"{JsonApiMediaType.Default}; charset=utf-8"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs index 160bc3bc92..9dddb255fc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using TestBuildingBlocks; using Xunit; +using Xunit.DependencyInjection; namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; @@ -10,7 +11,8 @@ public sealed class ApiControllerAttributeLogTests : IntegrationTestContext(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs index 0f1b1178f4..163ab487c0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs @@ -2,12 +2,14 @@ using JsonApiDotNetCore.Errors; using TestBuildingBlocks; using Xunit; +using Xunit.DependencyInjection; namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; public sealed class DuplicateResourceControllerTests : IntegrationTestContext, KnownDbContext> { - public DuplicateResourceControllerTests() + public DuplicateResourceControllerTests(ITestOutputHelperAccessor accessor) + : base(accessor) { UseController(); UseController(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs index 55d09dc2fe..6eab15be63 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs @@ -2,12 +2,14 @@ using JsonApiDotNetCore.Errors; using TestBuildingBlocks; using Xunit; +using Xunit.DependencyInjection; namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; public sealed class UnknownResourceControllerTests : IntegrationTestContext, EmptyDbContext> { - public UnknownResourceControllerTests() + public UnknownResourceControllerTests(ITestOutputHelperAccessor accessor) + : base(accessor) { UseController(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs index 7fa5aa7fcb..bf5e3f21e5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; +using Xunit.DependencyInjection; namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; @@ -14,7 +15,8 @@ public sealed class SerializerIgnoreConditionTests : IntegrationTestContext(); } diff --git a/test/OpenApiKiotaEndToEndTests/TestableHttpClientRequestAdapterFactory.cs b/test/OpenApiKiotaEndToEndTests/TestableHttpClientRequestAdapterFactory.cs index 40082ea7ee..9436f4ceb4 100644 --- a/test/OpenApiKiotaEndToEndTests/TestableHttpClientRequestAdapterFactory.cs +++ b/test/OpenApiKiotaEndToEndTests/TestableHttpClientRequestAdapterFactory.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCore.OpenApi.Client.Kiota; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Kiota.Abstractions.Authentication; using Microsoft.Kiota.Http.HttpClientLibrary; using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; @@ -21,10 +20,9 @@ public TestableHttpClientRequestAdapterFactory(ITestOutputHelper testOutputHelpe _logHttpMessageHandler = new XUnitLogHttpMessageHandler(testOutputHelper); } - public HttpClientRequestAdapter CreateAdapter(WebApplicationFactory webApplicationFactory) - where TStartup : class + public HttpClientRequestAdapter CreateAdapter(FactoryBridge bridge) { - ArgumentNullException.ThrowIfNull(webApplicationFactory); + ArgumentNullException.ThrowIfNull(bridge); DelegatingHandler[] handlers = [ @@ -33,7 +31,7 @@ public HttpClientRequestAdapter CreateAdapter(WebApplicationFactory, LegacyIntegrationDbContext> { - public LegacyTests(ITestOutputHelper testOutputHelper) + public LegacyTests(ITestOutputHelperAccessor accessor) + : base(accessor) { UseController(); UseController(); UseController(); UseController(); - SetTestOutputHelper(testOutputHelper); + SetTestOutputHelper(accessor.Output); OpenApiDocumentOutputDirectory = $"{GetType().Namespace!.Replace('.', '/')}/GeneratedSwagger"; } diff --git a/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPatchMethodTests.cs b/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPatchMethodTests.cs index ed9168a2a9..e525f7ea25 100644 --- a/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPatchMethodTests.cs +++ b/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPatchMethodTests.cs @@ -1,17 +1,18 @@ using FluentAssertions; using JsonApiDotNetCore.Errors; using Xunit; -using Xunit.Abstractions; +using Xunit.DependencyInjection; namespace OpenApiTests.OpenApiGenerationFailures.MissingFromBody; public sealed class MissingFromBodyOnPatchMethodTests : OpenApiTestContext, MissingFromBodyDbContext> { - public MissingFromBodyOnPatchMethodTests(ITestOutputHelper testOutputHelper) + public MissingFromBodyOnPatchMethodTests(ITestOutputHelperAccessor accessor) + : base(accessor) { UseController(); - SetTestOutputHelper(testOutputHelper); + SetTestOutputHelper(accessor.Output); } [Fact] diff --git a/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPostMethodTests.cs b/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPostMethodTests.cs index 637d0361ff..72811b785e 100644 --- a/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPostMethodTests.cs +++ b/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPostMethodTests.cs @@ -1,17 +1,18 @@ -using FluentAssertions; +using FluentAssertions; using JsonApiDotNetCore.Errors; using Xunit; -using Xunit.Abstractions; +using Xunit.DependencyInjection; namespace OpenApiTests.OpenApiGenerationFailures.MissingFromBody; public sealed class MissingFromBodyOnPostMethodTests : OpenApiTestContext, MissingFromBodyDbContext> { - public MissingFromBodyOnPostMethodTests(ITestOutputHelper testOutputHelper) + public MissingFromBodyOnPostMethodTests(ITestOutputHelperAccessor accessor) + : base(accessor) { UseController(); - SetTestOutputHelper(testOutputHelper); + SetTestOutputHelper(accessor.Output); } [Fact] diff --git a/test/OpenApiTests/OpenApiTestContext.cs b/test/OpenApiTests/OpenApiTestContext.cs index 81101a8032..72d152f0fa 100644 --- a/test/OpenApiTests/OpenApiTestContext.cs +++ b/test/OpenApiTests/OpenApiTestContext.cs @@ -4,12 +4,13 @@ using Microsoft.Extensions.Logging; using TestBuildingBlocks; using Xunit.Abstractions; +using Xunit.DependencyInjection; namespace OpenApiTests; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public class OpenApiTestContext : IntegrationTestContext - where TStartup : class + where TStartup : IStartup, new() where TDbContext : TestableDbContext { private readonly Lazy> _lazyDocument; @@ -17,8 +18,10 @@ public class OpenApiTestContext : IntegrationTestContext>(CreateOpenApiDocumentAsync, LazyThreadSafetyMode.ExecutionAndPublication); } @@ -39,7 +42,7 @@ internal async Task CreateOpenApiDocumentAsync() return rootElement; } - internal void SetTestOutputHelper(ITestOutputHelper testOutputHelper) + internal void SetTestOutputHelper(ITestOutputHelper? testOutputHelper) { ArgumentNullException.ThrowIfNull(testOutputHelper); diff --git a/test/TestBuildingBlocks/FactoryBridge.cs b/test/TestBuildingBlocks/FactoryBridge.cs new file mode 100644 index 0000000000..f3ba572982 --- /dev/null +++ b/test/TestBuildingBlocks/FactoryBridge.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Xunit.DependencyInjection; + +namespace TestBuildingBlocks; + +/// +/// A temporary bridge to prevent adapting all existing tests. +/// +public sealed class FactoryBridge : IDisposable +{ + private readonly WebApplication _app; + private readonly ITestOutputHelperAccessor _accessor; + private readonly bool _captureHttpTraffic; + private XUnitLogHttpMessageHandler? _handler; + + public IServiceProvider Services => _app.Services; + + internal FactoryBridge(WebApplication app, ITestOutputHelperAccessor accessor, bool captureHttpTraffic) + { + ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(accessor); + + _app = app; + _accessor = accessor; + _captureHttpTraffic = captureHttpTraffic; + } + + public HttpClient CreateClient() + { + return GetTestClient(); + } + + public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers) + { + return GetTestClient(handlers); + } + + public HttpClient GetTestClient(params DelegatingHandler[] handlers) + { + if (handlers.Length == 0) + { + if (_captureHttpTraffic) + { + _handler ??= new XUnitLogHttpMessageHandler(_accessor.Output!); + handlers = [_handler]; + } + } + + if (handlers.Length == 0) + { + return _app.GetTestClient(); + } + + TestServer testServer = _app.GetTestServer(); + HttpMessageHandler serverHandler = testServer.CreateHandler(); + HttpClient httpClient = CreateHttpClient(serverHandler, handlers); + + httpClient.BaseAddress ??= new Uri("http://localhost"); + + return httpClient; + } + + private static HttpClient CreateHttpClient(HttpMessageHandler serverHandler, params DelegatingHandler[] handlers) + { + for (int i = handlers.Length - 1; i > 0; i--) + { + handlers[i - 1].InnerHandler = handlers[i]; + } + + handlers[^1].InnerHandler = serverHandler; + return new HttpClient(handlers[0]); + } + + public void Dispose() + { + _handler?.Dispose(); + } +} diff --git a/test/TestBuildingBlocks/HttpClientWrapper.cs b/test/TestBuildingBlocks/HttpClientWrapper.cs index df83326867..0fb8778f43 100644 --- a/test/TestBuildingBlocks/HttpClientWrapper.cs +++ b/test/TestBuildingBlocks/HttpClientWrapper.cs @@ -8,7 +8,7 @@ namespace TestBuildingBlocks; /// /// A wrapper for that conveniently enables executing HTTP requests against JSON:API endpoints. /// -public sealed class HttpClientWrapper +internal sealed class HttpClientWrapper { private static readonly MediaTypeHeaderValue DefaultMediaType = MediaTypeHeaderValue.Parse(JsonApiMediaType.Default.ToString()); diff --git a/test/TestBuildingBlocks/IStartup.cs b/test/TestBuildingBlocks/IStartup.cs new file mode 100644 index 0000000000..bcadde4a16 --- /dev/null +++ b/test/TestBuildingBlocks/IStartup.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace TestBuildingBlocks; + +public interface IStartup +{ + void ConfigureServices(IServiceCollection services); + + void Configure(IApplicationBuilder app); +} diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index a1d9cf349f..475723332d 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -1,6 +1,7 @@ using System.Net.Http.Headers; using System.Text.Json; using FluentAssertions.Extensions; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace TestBuildingBlocks; @@ -13,6 +14,12 @@ public abstract class IntegrationTest : IAsyncLifetime { private static readonly SemaphoreSlim ThrottleSemaphore = GetDefaultThrottleSemaphore(); + protected static readonly Action ConfigureServiceProvider = static options => + { + options.ValidateScopes = true; + options.ValidateOnBuild = true; + }; + public static DateTimeOffset DefaultDateTimeUtc { get; } = 1.January(2020).At(1, 2, 3).AsUtc(); protected abstract JsonSerializerOptions SerializerOptions { get; } diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index 36672cb3b3..4d8aa1ca39 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -2,9 +2,9 @@ using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -12,11 +12,12 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Xunit.DependencyInjection; namespace TestBuildingBlocks; /// -/// Base class for a test context that creates a new database and server instance before running tests and cleans up afterwards. You can either use this +/// Base class for a test context that creates a new database and server instance before running tests and cleans up afterward. You can either use this /// as a fixture on your tests class (init/cleanup runs once before/after all tests) or have your tests class inherit from it (init/cleanup runs once /// before/after each test). See for details on shared context usage. /// @@ -28,72 +29,111 @@ namespace TestBuildingBlocks; /// [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public class IntegrationTestContext : IntegrationTest - where TStartup : class + where TStartup : IStartup, new() where TDbContext : TestableDbContext { - private readonly Lazy> _lazyFactory; + private readonly ITestOutputHelperAccessor _accessor; private readonly TestControllerProvider _testControllerProvider = new(); - private Action? _loggingConfiguration; + private readonly Lazy _lazyApp; private Action? _configureServices; private Action? _postConfigureServices; + private Action? _configureLogging; + + private WebApplication App => _lazyApp.Value; + + protected bool CaptureHttpTraffic { get; init; } = true; protected override JsonSerializerOptions SerializerOptions { get { - var options = Factory.Services.GetRequiredService(); + var options = App.Services.GetRequiredService(); return options.SerializerOptions; } } - public WebApplicationFactory Factory => _lazyFactory.Value; + public FactoryBridge Factory => new(App, _accessor, CaptureHttpTraffic); - public IntegrationTestContext() + public IntegrationTestContext(ITestOutputHelperAccessor accessor) { - _lazyFactory = new Lazy>(CreateFactory); + _accessor = accessor; + _lazyApp = new Lazy(BuildApp, LazyThreadSafetyMode.ExecutionAndPublication); } - public void UseController() - where TController : ControllerBase + public void ConfigureServices(Action configureServices) { - _testControllerProvider.AddController(typeof(TController)); + if (_configureServices != null && _configureServices != configureServices) + { + throw new InvalidOperationException($"Do not call {nameof(ConfigureServices)} multiple times."); + } + + _configureServices = configureServices; } - protected override HttpClient CreateClient() + public void PostConfigureServices(Action postConfigureServices) { - return Factory.CreateClient(); + if (_postConfigureServices != null && _postConfigureServices != postConfigureServices) + { + throw new InvalidOperationException($"Do not call {nameof(PostConfigureServices)} multiple times."); + } + + _postConfigureServices = postConfigureServices; } - private WebApplicationFactory CreateFactory() + public void ConfigureLogging(Action configureLogging) { - string dbConnectionString = $"Host=localhost;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"; + if (_configureLogging != null && _configureLogging != configureLogging) + { + throw new InvalidOperationException($"Do not call {nameof(ConfigureLogging)} multiple times."); + } + + _configureLogging = configureLogging; + } - var factory = new IntegrationTestWebApplicationFactory(); + public void UseController() + where TController : ControllerBase + { + _testControllerProvider.AddController(typeof(TController)); + } - factory.ConfigureLogging(_loggingConfiguration); + private WebApplication BuildApp() + { + var startup = new TStartup(); - factory.ConfigureServices(services => + WebApplicationBuilder builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions { - _configureServices?.Invoke(services); + ApplicationName = startup.GetType().Assembly.GetName().Name + }); - services.TryAddSingleton(new FrozenTimeProvider(DefaultDateTimeUtc)); + _configureServices?.Invoke(builder.Services); + startup.ConfigureServices(builder.Services); + _postConfigureServices?.Invoke(builder.Services); - services.ReplaceControllers(_testControllerProvider); + builder.Services.TryAddSingleton(new FrozenTimeProvider(DefaultDateTimeUtc)); + builder.Services.ReplaceControllers(_testControllerProvider); - services.AddDbContext(options => - { - options.UseNpgsql(dbConnectionString, builder => builder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); - SetDbContextDebugOptions(options); - }); + string dbConnectionString = $"Host=localhost;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"; + + builder.Services.AddDbContext(options => + { + options.UseNpgsql(dbConnectionString, static optionsBuilder => optionsBuilder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); + SetDbContextDebugOptions(options); }); - factory.PostConfigureServices(_postConfigureServices); + _configureLogging?.Invoke(builder.Logging); - using IServiceScope scope = factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Database.EnsureCreated(); + builder.Host.UseDefaultServiceProvider(ConfigureServiceProvider); + builder.WebHost.UseDefaultServiceProvider(ConfigureServiceProvider); + builder.WebHost.UseTestServer(); + + WebApplication app = builder.Build(); + startup.Configure(app); + + RunOnDatabase(app, static dbContext => dbContext.Database.EnsureCreated()); + + app.Start(); - return factory; + return app; } [Conditional("DEBUG")] @@ -101,117 +141,44 @@ private static void SetDbContextDebugOptions(DbContextOptionsBuilder options) { options.EnableDetailedErrors(); options.EnableSensitiveDataLogging(); - options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); + options.ConfigureWarnings(static builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); } - public void ConfigureLogging(Action loggingConfiguration) + private static void RunOnDatabase(WebApplication app, Action action) { - if (_loggingConfiguration != null && _loggingConfiguration != loggingConfiguration) - { - throw new InvalidOperationException($"Do not call {nameof(ConfigureLogging)} multiple times."); - } - - _loggingConfiguration = loggingConfiguration; - } - - public void ConfigureServices(Action configureServices) - { - if (_configureServices != null && _configureServices != configureServices) - { - throw new InvalidOperationException($"Do not call {nameof(ConfigureServices)} multiple times."); - } - - _configureServices = configureServices; - } - - public void PostConfigureServices(Action postConfigureServices) - { - if (_postConfigureServices != null && _postConfigureServices != postConfigureServices) - { - throw new InvalidOperationException($"Do not call {nameof(PostConfigureServices)} multiple times."); - } + using IServiceScope scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); - _postConfigureServices = postConfigureServices; + action(dbContext); } public async Task RunOnDatabaseAsync(Func asyncAction) { - await using AsyncServiceScope scope = Factory.Services.CreateAsyncScope(); + await using AsyncServiceScope scope = App.Services.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); await asyncAction(dbContext); } - public override async Task DisposeAsync() + protected override HttpClient CreateClient() { - try - { - if (_lazyFactory.IsValueCreated) - { - await RunOnDatabaseAsync(async dbContext => await dbContext.Database.EnsureDeletedAsync()); - await _lazyFactory.Value.DisposeAsync(); - } - } - finally - { - await base.DisposeAsync(); - } + return Factory.CreateClient(); } - private sealed class IntegrationTestWebApplicationFactory : WebApplicationFactory + public override async Task DisposeAsync() { - private Action? _loggingConfiguration; - private Action? _configureServices; - private Action? _postConfigureServices; - - public void ConfigureLogging(Action? loggingConfiguration) - { - _loggingConfiguration = loggingConfiguration; - } - - public void ConfigureServices(Action? configureServices) + if (_lazyApp.IsValueCreated) { - _configureServices = configureServices; - } - - public void PostConfigureServices(Action? configureServices) - { - _postConfigureServices = configureServices; - } - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - // We have placed an appsettings.json in the TestBuildingBlocks project directory and set the content root to there. Note that - // controllers are not discovered in the content root, but are registered manually using IntegrationTestContext.UseController. - builder.UseSolutionRelativeContentRoot($"test/{nameof(TestBuildingBlocks)}"); - } - - protected override IHostBuilder CreateHostBuilder() - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:wrap_before_first_method_call true - - return Host - .CreateDefaultBuilder(null) - .ConfigureAppConfiguration(builder => - { - // For tests asserting on log output, we discard the log levels from appsettings.json and environment variables. - // But using appsettings.json for all other tests makes it easy to quickly toggle when debugging tests. - if (_loggingConfiguration != null) - { - builder.Sources.Clear(); - } - }) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.ConfigureServices(services => _configureServices?.Invoke(services)); - webBuilder.UseStartup(); - webBuilder.ConfigureServices(services => _postConfigureServices?.Invoke(services)); - }) - .ConfigureLogging(options => _loggingConfiguration?.Invoke(options)); - - // @formatter:wrap_before_first_method_call restore - // @formatter:wrap_chained_method_calls restore + try + { + await RunOnDatabaseAsync(static async dbContext => await dbContext.Database.EnsureDeletedAsync()); + Factory.Dispose(); + await App.DisposeAsync(); + } + catch (Exception) + { + // Ignore. Any exception thrown here (app fails to start) masks the original error. + } } } } diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj index 3174e64374..052330474a 100644 --- a/test/TestBuildingBlocks/TestBuildingBlocks.csproj +++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj @@ -1,4 +1,4 @@ - + net10.0;net9.0;net8.0 @@ -21,6 +21,7 @@ + diff --git a/test/TestBuildingBlocks/TestableStartup.cs b/test/TestBuildingBlocks/TestableStartup.cs index 7a4f750477..34d7420a92 100644 --- a/test/TestBuildingBlocks/TestableStartup.cs +++ b/test/TestBuildingBlocks/TestableStartup.cs @@ -4,7 +4,7 @@ namespace TestBuildingBlocks; -public class TestableStartup +public class TestableStartup : IStartup where TDbContext : TestableDbContext { public virtual void ConfigureServices(IServiceCollection services) diff --git a/test/TestBuildingBlocks/appsettings.json b/test/TestBuildingBlocks/appsettings.json deleted file mode 100644 index c60110712a..0000000000 --- a/test/TestBuildingBlocks/appsettings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Warning", - // Disable logging to keep the output from C/I build clean. Errors are expected to occur while testing failure handling. - "Microsoft.AspNetCore.Hosting.Diagnostics": "None", - "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore": "Warning", - "Microsoft.EntityFrameworkCore.Model.Validation": "Critical", - "Microsoft.EntityFrameworkCore.Update": "Critical", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical", - "JsonApiDotNetCore": "Critical" - } - } -} From 21414adcc708db86189a040aff72fe006efbb8bb Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 26 May 2026 00:52:26 +0200 Subject: [PATCH 03/14] --perf --- src/Examples/DapperExample/appsettings.json | 2 +- .../DatabasePerTenantExample/appsettings.json | 6 +- .../JsonApiDotNetCoreExample/appsettings.json | 2 +- .../Serialization/Response/LinkBuilder.cs | 2 +- .../IntegrationTests/DapperTestContext.cs | 14 ++- .../AtomicTransactionConsistencyTests.cs | 2 +- test/TestBuildingBlocks/FactoryBridge.cs | 25 ++-- test/TestBuildingBlocks/IntegrationTest.cs | 109 ++++++++++++++---- .../IntegrationTestContext.cs | 74 +++++++++--- 9 files changed, 176 insertions(+), 60 deletions(-) diff --git a/src/Examples/DapperExample/appsettings.json b/src/Examples/DapperExample/appsettings.json index 7854646e7f..6856e6481d 100644 --- a/src/Examples/DapperExample/appsettings.json +++ b/src/Examples/DapperExample/appsettings.json @@ -3,7 +3,7 @@ "ConnectionStrings": { // docker run --rm --detach --name dapper-example-postgresql-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:latest // docker run --rm --detach --name dapper-example-postgresql-management --link dapper-example-postgresql-db:db -e PGADMIN_DEFAULT_EMAIL=admin@admin.com -e PGADMIN_DEFAULT_PASSWORD=postgres -p 5050:80 dpage/pgadmin4:latest - "DapperExamplePostgreSql": "Host=localhost;Database=DapperExample;User ID=postgres;Password=postgres;Include Error Detail=true", + "DapperExamplePostgreSql": "Host=localhost;Database=DapperExample;User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120", // docker run --rm --detach --name dapper-example-mysql-db -e MYSQL_ROOT_PASSWORD=mysql -e MYSQL_DATABASE=DapperExample -e MYSQL_USER=mysql -e MYSQL_PASSWORD=mysql -p 3306:3306 mysql:latest // docker run --rm --detach --name dapper-example-mysql-management --link dapper-example-mysql-db:db -p 8081:80 phpmyadmin/phpmyadmin "DapperExampleMySql": "Host=localhost;Database=DapperExample;User ID=mysql;Password=mysql;SSL Mode=None;AllowPublicKeyRetrieval=True", diff --git a/src/Examples/DatabasePerTenantExample/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json index 1b5a40da62..1389ed0e8d 100644 --- a/src/Examples/DatabasePerTenantExample/appsettings.json +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -1,8 +1,8 @@ { "ConnectionStrings": { - "Default": "Host=localhost;Database=DefaultTenantDb;User ID=postgres;Password=postgres;Include Error Detail=true", - "AdventureWorks": "Host=localhost;Database=AdventureWorks;User ID=postgres;Password=postgres;Include Error Detail=true", - "Contoso": "Host=localhost;Database=Contoso;User ID=postgres;Password=postgres;Include Error Detail=true" + "Default": "Host=localhost;Database=DefaultTenantDb;User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120", + "AdventureWorks": "Host=localhost;Database=AdventureWorks;User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120", + "Contoso": "Host=localhost;Database=Contoso;User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120" }, "Logging": { "LogLevel": { diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index 418fcb7812..ed3d154dfc 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "Default": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=postgres;Include Error Detail=true" + "Default": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120" }, "Logging": { "LogLevel": { diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index 1fec12996f..54787e4b8f 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -128,7 +128,7 @@ private bool ShouldIncludeTopLevelLink(LinkTypes linkType, ResourceType? resourc private string GetLinkForTopLevelSelf() { - // Note: in tests, this does not properly escape special characters due to WebApplicationFactory short-circuiting. + // Note: in tests, this does not properly escape special characters due to TestServer short-circuiting. return _options.UseRelativeLinks ? HttpContext.Request.GetEncodedPathAndQuery() : HttpContext.Request.GetEncodedUrl(); } diff --git a/test/DapperTests/IntegrationTests/DapperTestContext.cs b/test/DapperTests/IntegrationTests/DapperTestContext.cs index ea59dc895e..22c6ff7928 100644 --- a/test/DapperTests/IntegrationTests/DapperTestContext.cs +++ b/test/DapperTests/IntegrationTests/DapperTestContext.cs @@ -14,12 +14,13 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using TestBuildingBlocks; +using Xunit; using Xunit.Abstractions; namespace DapperTests.IntegrationTests; [PublicAPI] -public sealed class DapperTestContext : IntegrationTest +public sealed class DapperTestContext : IntegrationTest, IAsyncLifetime { private const string SqlServerClearAllTablesScript = """ EXEC sp_MSForEachTable 'ALTER TABLE ? NOCHECK CONSTRAINT ALL'; @@ -48,6 +49,11 @@ public DapperTestContext() _lazyFactory = new Lazy>(CreateFactory); } + public async Task InitializeAsync() + { + await AcquireDbThrottleAsync(); + } + private WebApplicationFactory CreateFactory() { #pragma warning disable CA2000 // Dispose objects before losing scope @@ -56,7 +62,7 @@ private WebApplicationFactory CreateFactory() #pragma warning restore CA2000 // Dispose objects before losing scope { builder.UseSetting("ConnectionStrings:DapperExamplePostgreSql", - $"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"); + $"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120"); builder.UseSetting("ConnectionStrings:DapperExampleMySql", $"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=root;Password=mysql;SSL Mode=None;AllowPublicKeyRetrieval=True"); @@ -141,7 +147,7 @@ protected override HttpClient CreateClient() return Factory.CreateClient(); } - public override async Task DisposeAsync() + public async Task DisposeAsync() { try { @@ -159,7 +165,7 @@ public override async Task DisposeAsync() } finally { - await base.DisposeAsync(); + ReleaseDbThrottle(); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs index 14cfc466a0..f4b5478c32 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -28,7 +28,7 @@ public AtomicTransactionConsistencyTests(IntegrationTestContext(); string dbConnectionString = - $"Host=localhost;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"; + $"Host=localhost;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120"; services.AddDbContext(options => options.UseNpgsql(dbConnectionString)); }); diff --git a/test/TestBuildingBlocks/FactoryBridge.cs b/test/TestBuildingBlocks/FactoryBridge.cs index f3ba572982..af7fb610ba 100644 --- a/test/TestBuildingBlocks/FactoryBridge.cs +++ b/test/TestBuildingBlocks/FactoryBridge.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Hosting; using Xunit.DependencyInjection; namespace TestBuildingBlocks; @@ -7,12 +8,12 @@ namespace TestBuildingBlocks; /// /// A temporary bridge to prevent adapting all existing tests. /// -public sealed class FactoryBridge : IDisposable +public sealed class FactoryBridge { private readonly WebApplication _app; private readonly ITestOutputHelperAccessor _accessor; private readonly bool _captureHttpTraffic; - private XUnitLogHttpMessageHandler? _handler; + private bool _hasStartedApp; public IServiceProvider Services => _app.Services; @@ -38,14 +39,25 @@ public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers) public HttpClient GetTestClient(params DelegatingHandler[] handlers) { + if (!_hasStartedApp) + { + _hasStartedApp = true; + _app.Start(); + } + +#if DEBUG if (handlers.Length == 0) { if (_captureHttpTraffic) { - _handler ??= new XUnitLogHttpMessageHandler(_accessor.Output!); - handlers = [_handler]; + var captureHandler = new XUnitLogHttpMessageHandler(_accessor.Output!); + handlers = [captureHandler]; } } +#else + _ = _captureHttpTraffic; + _ = _accessor; +#endif if (handlers.Length == 0) { @@ -71,9 +83,4 @@ private static HttpClient CreateHttpClient(HttpMessageHandler serverHandler, par handlers[^1].InnerHandler = serverHandler; return new HttpClient(handlers[0]); } - - public void Dispose() - { - _handler?.Dispose(); - } } diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index 475723332d..98f693439f 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -2,32 +2,104 @@ using System.Text.Json; using FluentAssertions.Extensions; using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace TestBuildingBlocks; /// -/// A base class for tests that conveniently enables executing HTTP requests against JSON:API endpoints. It throttles tests that are running in parallel -/// to avoid exceeding the maximum active database connections. +/// A base class for tests that conveniently enables executing HTTP requests against JSON:API endpoints. Tests that use a database should call +/// and to avoid exceeding the maximum active database connections. /// -public abstract class IntegrationTest : IAsyncLifetime +public abstract class IntegrationTest { - private static readonly SemaphoreSlim ThrottleSemaphore = GetDefaultThrottleSemaphore(); + private const string ThrottleSemaphoreName = "JADNC-DbThrottle"; + private const string ThrottleEnvironmentVariableName = "JADNC_DB_THROTTLE"; - protected static readonly Action ConfigureServiceProvider = static options => - { - options.ValidateScopes = true; - options.ValidateOnBuild = true; - }; + // Named OS-level semaphore shared across all test processes (projects × target frameworks) on this machine. + // Limits the total number of concurrently live test databases to avoid exhausting the PostgreSQL connection pool. + private static readonly Semaphore DatabaseThrottleSemaphore = CreateThrottleSemaphore(); + + protected static readonly Action ConfigureServiceProvider = static options => options.ValidateScopes = true; + + // Tracks how many slots this process has acquired, so they can be released on abnormal exit. + private static int _acquiredSlotCount; + + // Tracks whether this instance has acquired a slot, so DisposeAsync cannot double-release on error paths. + private bool _throttleAcquired; public static DateTimeOffset DefaultDateTimeUtc { get; } = 1.January(2020).At(1, 2, 3).AsUtc(); protected abstract JsonSerializerOptions SerializerOptions { get; } - private static SemaphoreSlim GetDefaultThrottleSemaphore() + static IntegrationTest() + { + // Fires on normal exit, Environment.Exit(), VS Stop (graceful), and CLI Ctrl+C. + // Releases all slots this process holds so the next test run is not blocked. + // Forceful process kills cannot be caught here; the OS closes the handle but does not adjust the semaphore count. + // In that scenario the 5-minute timeout in AcquireDbThrottle() produces a clear error message. + AppDomain.CurrentDomain.ProcessExit += static (_, _) => ReleaseAllAcquiredSlots(); + } + + private static Semaphore CreateThrottleSemaphore() + { + bool isRunningLocally = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")); + int defaultMaxConnections = isRunningLocally ? 400 : 40; + + string? overrideMaxConnections = Environment.GetEnvironmentVariable(ThrottleEnvironmentVariableName); + int maxConnections = int.TryParse(overrideMaxConnections, out int parsed) && parsed > 0 ? parsed : defaultMaxConnections; + + return new Semaphore(maxConnections, maxConnections, ThrottleSemaphoreName); + } + + private static void ReleaseAllAcquiredSlots() + { + int slots = Interlocked.Exchange(ref _acquiredSlotCount, 0); + + if (slots > 0) + { + DatabaseThrottleSemaphore.Release(slots); + } + } + + protected async Task AcquireDbThrottleAsync() + { + if (DatabaseThrottleSemaphore.WaitOne(0)) + { + Interlocked.Increment(ref _acquiredSlotCount); + } + else + { + // Slow path: all slots are taken; offload the blocking WaitOne to a thread-pool thread to keep + // the xUnit scheduler thread free for other work. + await Task.Run(AcquireDbThrottleBlocking); + } + + _throttleAcquired = true; + } + + private static void AcquireDbThrottleBlocking() { - int maxConcurrentTestRuns = OperatingSystem.IsWindows() && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VSAPPIDDIR")) ? 32 : 64; - return new SemaphoreSlim(maxConcurrentTestRuns); + // The 5-minute timeout is a safety net: if a previous test run was forcibly killed, some slots may have leaked. + // Without a timeout, the next run would hang indefinitely. + if (!DatabaseThrottleSemaphore.WaitOne(TimeSpan.FromMinutes(5))) + { + throw new TimeoutException("Timed out waiting for a database test slot after 5 minutes. If a test run was recently killed forcibly, " + + $"the named semaphore '{ThrottleSemaphoreName}' may hold leaked slots. Restart all running test processes to clear it."); + } + + Interlocked.Increment(ref _acquiredSlotCount); + } + + /// + /// Releases the database slot acquired by . + /// + protected void ReleaseDbThrottle() + { + if (_throttleAcquired) + { + _throttleAcquired = false; + Interlocked.Decrement(ref _acquiredSlotCount); + DatabaseThrottleSemaphore.Release(); + } } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteHeadAsync(string requestUrl, @@ -79,15 +151,4 @@ private static SemaphoreSlim GetDefaultThrottleSemaphore() } protected abstract HttpClient CreateClient(); - - public async Task InitializeAsync() - { - await ThrottleSemaphore.WaitAsync(); - } - - public virtual Task DisposeAsync() - { - _ = ThrottleSemaphore.Release(); - return Task.CompletedTask; - } } diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index 4d8aa1ca39..66ee77d270 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -3,7 +3,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; @@ -12,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Xunit; using Xunit.DependencyInjection; namespace TestBuildingBlocks; @@ -28,7 +28,7 @@ namespace TestBuildingBlocks; /// The Entity Framework Core database context, which can be defined in the test project or API project. /// [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public class IntegrationTestContext : IntegrationTest +public class IntegrationTestContext : IntegrationTest, IAsyncLifetime where TStartup : IStartup, new() where TDbContext : TestableDbContext { @@ -52,7 +52,14 @@ protected override JsonSerializerOptions SerializerOptions } } - public FactoryBridge Factory => new(App, _accessor, CaptureHttpTraffic); + public FactoryBridge Factory + { + get + { + field ??= new FactoryBridge(App, _accessor, CaptureHttpTraffic); + return field; + } + } public IntegrationTestContext(ITestOutputHelperAccessor accessor) { @@ -112,7 +119,8 @@ private WebApplication BuildApp() builder.Services.TryAddSingleton(new FrozenTimeProvider(DefaultDateTimeUtc)); builder.Services.ReplaceControllers(_testControllerProvider); - string dbConnectionString = $"Host=localhost;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"; + string dbConnectionString = + $"Host=localhost;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120"; builder.Services.AddDbContext(options => { @@ -120,10 +128,16 @@ private WebApplication BuildApp() SetDbContextDebugOptions(options); }); - _configureLogging?.Invoke(builder.Logging); + if (_configureLogging == null) + { + ConfigureMinLevels(builder); + } + else + { + _configureLogging.Invoke(builder.Logging); + } builder.Host.UseDefaultServiceProvider(ConfigureServiceProvider); - builder.WebHost.UseDefaultServiceProvider(ConfigureServiceProvider); builder.WebHost.UseTestServer(); WebApplication app = builder.Build(); @@ -131,11 +145,35 @@ private WebApplication BuildApp() RunOnDatabase(app, static dbContext => dbContext.Database.EnsureCreated()); - app.Start(); - return app; } + [Conditional("RELEASE")] + private static void ConfigureMinLevels(WebApplicationBuilder builder) + { + /* + var appSettings = new Dictionary + { + ["Logging:LogLevel:Default"] = "Warning", + ["Logging:LogLevel:Microsoft.AspNetCore.Hosting.Diagnostics"] = "None", + ["Logging:LogLevel:Microsoft.Hosting.Lifetime"] = "Warning", + ["Logging:LogLevel:Microsoft.EntityFrameworkCore"] = "Warning", + ["Logging:LogLevel:Microsoft.EntityFrameworkCore.Model.Validation"] = "Critical", + ["Logging:LogLevel:Microsoft.EntityFrameworkCore.Update"] = "Critical", + ["Logging:LogLevel:Microsoft.EntityFrameworkCore.Database.Command"] = "Critical", + ["Logging:LogLevel:JsonApiDotNetCore"] = "Critical" + }; + + var configurationBuilder = new ConfigurationBuilder(); + configurationBuilder.AddInMemoryCollection(appSettings); + IConfigurationRoot configuration = configurationBuilder.Build(); + + builder.Logging.AddConfiguration(configuration); + */ + + builder.Logging.ClearProviders(); + } + [Conditional("DEBUG")] private static void SetDbContextDebugOptions(DbContextOptionsBuilder options) { @@ -165,20 +203,24 @@ protected override HttpClient CreateClient() return Factory.CreateClient(); } - public override async Task DisposeAsync() + public async Task InitializeAsync() + { + await AcquireDbThrottleAsync(); + } + + public virtual async Task DisposeAsync() { - if (_lazyApp.IsValueCreated) + try { - try + if (_lazyApp.IsValueCreated) { await RunOnDatabaseAsync(static async dbContext => await dbContext.Database.EnsureDeletedAsync()); - Factory.Dispose(); await App.DisposeAsync(); } - catch (Exception) - { - // Ignore. Any exception thrown here (app fails to start) masks the original error. - } + } + finally + { + ReleaseDbThrottle(); } } } From 613ab969fa08e7fd31acf5973ee93d39f38878b7 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 26 May 2026 02:32:37 +0200 Subject: [PATCH 04/14] tweak cibuild --- .github/workflows/build.yml | 6 ++++++ test/TestBuildingBlocks/IntegrationTest.cs | 5 +---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 100fc1022f..f7632612bd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,6 +43,12 @@ jobs: with: username: postgres password: postgres + - name: Configure PostgreSQL + shell: bash + run: | + PGDATA="$RUNNER_TEMP/pgdata" + echo "max_connections = 500" >> "$PGDATA/postgresql.conf" + pg_ctl restart --pgdata="$PGDATA" --wait - name: Setup .NET uses: actions/setup-dotnet@v5 with: diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index 98f693439f..f1748b9406 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -41,11 +41,8 @@ static IntegrationTest() private static Semaphore CreateThrottleSemaphore() { - bool isRunningLocally = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")); - int defaultMaxConnections = isRunningLocally ? 400 : 40; - string? overrideMaxConnections = Environment.GetEnvironmentVariable(ThrottleEnvironmentVariableName); - int maxConnections = int.TryParse(overrideMaxConnections, out int parsed) && parsed > 0 ? parsed : defaultMaxConnections; + int maxConnections = int.TryParse(overrideMaxConnections, out int parsed) && parsed > 0 ? parsed : 400; return new Semaphore(maxConnections, maxConnections, ThrottleSemaphoreName); } From c7f5032e3fa1ed31c973dd46c9743b3c95d6225a Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 26 May 2026 03:32:02 +0200 Subject: [PATCH 05/14] try-fix --- test/TestBuildingBlocks/IntegrationTest.cs | 67 ++++++++++++++++++---- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index f1748b9406..673a07a75a 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -14,9 +14,10 @@ public abstract class IntegrationTest private const string ThrottleSemaphoreName = "JADNC-DbThrottle"; private const string ThrottleEnvironmentVariableName = "JADNC_DB_THROTTLE"; - // Named OS-level semaphore shared across all test processes (projects × target frameworks) on this machine. + // On Windows: named OS-level semaphore shared across all test processes (projects × target frameworks) on this machine. + // On Linux/macOS: process-local semaphore (named semaphores are not supported by the .NET runtime on those platforms). // Limits the total number of concurrently live test databases to avoid exhausting the PostgreSQL connection pool. - private static readonly Semaphore DatabaseThrottleSemaphore = CreateThrottleSemaphore(); + private static readonly CrossPlatformSemaphore DatabaseThrottleSemaphore = CreateThrottleSemaphore(); protected static readonly Action ConfigureServiceProvider = static options => options.ValidateScopes = true; @@ -34,17 +35,25 @@ static IntegrationTest() { // Fires on normal exit, Environment.Exit(), VS Stop (graceful), and CLI Ctrl+C. // Releases all slots this process holds so the next test run is not blocked. - // Forceful process kills cannot be caught here; the OS closes the handle but does not adjust the semaphore count. - // In that scenario the 5-minute timeout in AcquireDbThrottle() produces a clear error message. + // On Windows, forceful process kills cannot be caught here; the OS closes the handle but does not adjust the named + // semaphore count. In that scenario the 5-minute timeout in AcquireDbThrottle() produces a clear error message. AppDomain.CurrentDomain.ProcessExit += static (_, _) => ReleaseAllAcquiredSlots(); } - private static Semaphore CreateThrottleSemaphore() + private static CrossPlatformSemaphore CreateThrottleSemaphore() { string? overrideMaxConnections = Environment.GetEnvironmentVariable(ThrottleEnvironmentVariableName); - int maxConnections = int.TryParse(overrideMaxConnections, out int parsed) && parsed > 0 ? parsed : 400; - return new Semaphore(maxConnections, maxConnections, ThrottleSemaphoreName); + if (int.TryParse(overrideMaxConnections, out int parsed) && parsed > 0) + { + return new CrossPlatformSemaphore(ThrottleSemaphoreName, parsed); + } + + // On Windows, the named semaphore is shared across all concurrent test processes, so 400 is the global cap. + // On Linux/macOS, each process gets its own SemaphoreSlim, so divide by ProcessorCount to approximate the same + // global cap: dotnet test default parallelism equals ProcessorCount, so ProcessorCount × perProcessLimit ≈ 400. + int maxConnections = OperatingSystem.IsWindows() ? 400 : Math.Max(1, 400 / Environment.ProcessorCount); + return new CrossPlatformSemaphore(ThrottleSemaphoreName, maxConnections); } private static void ReleaseAllAcquiredSlots() @@ -75,12 +84,13 @@ protected async Task AcquireDbThrottleAsync() private static void AcquireDbThrottleBlocking() { - // The 5-minute timeout is a safety net: if a previous test run was forcibly killed, some slots may have leaked. - // Without a timeout, the next run would hang indefinitely. + // The 5-minute timeout is a safety net: if a previous test run was forcibly killed on Windows, some slots in the + // named semaphore may have leaked. Without a timeout, the next run would hang indefinitely. if (!DatabaseThrottleSemaphore.WaitOne(TimeSpan.FromMinutes(5))) { - throw new TimeoutException("Timed out waiting for a database test slot after 5 minutes. If a test run was recently killed forcibly, " + - $"the named semaphore '{ThrottleSemaphoreName}' may hold leaked slots. Restart all running test processes to clear it."); + throw new TimeoutException("Timed out waiting for a database test slot after 5 minutes. " + + "On Windows, if a test run was recently killed forcibly, the named semaphore may hold leaked slots; " + + "restarting all running test processes will clear it."); } Interlocked.Increment(ref _acquiredSlotCount); @@ -148,4 +158,39 @@ protected void ReleaseDbThrottle() } protected abstract HttpClient CreateClient(); + + /// + /// Provides a cross-platform counting semaphore. Uses a named OS-level semaphore on Windows (shared across processes) and a + /// process-local semaphore on other platforms (named semaphores are not supported by the .NET runtime on Linux and macOS). + /// +#pragma warning disable CA1001 // Types that own disposable fields should be disposable + // False positive: this instance is intentionally held for the process lifetime as a static field. + private sealed class CrossPlatformSemaphore +#pragma warning restore CA1001 + { + private readonly Semaphore? _named; + private readonly SemaphoreSlim? _local; + + public CrossPlatformSemaphore(string name, int count) + { + if (OperatingSystem.IsWindows()) + { + _named = new Semaphore(count, count, name); + } + else + { + _local = new SemaphoreSlim(count, count); + } + } + + public bool WaitOne(int millisecondsTimeout) => + _named != null ? _named.WaitOne(millisecondsTimeout) : _local!.Wait(millisecondsTimeout); + + public bool WaitOne(TimeSpan timeout) => + _named != null ? _named.WaitOne(timeout) : _local!.Wait(timeout); + + public void Release() { _named?.Release(); _local?.Release(); } + + public void Release(int releaseCount) { _named?.Release(releaseCount); _local?.Release(releaseCount); } + } } From 12f93d4675715586afb32f0347783e11d8a63da9 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 26 May 2026 04:04:55 +0200 Subject: [PATCH 06/14] try-polling --- test/TestBuildingBlocks/IntegrationTest.cs | 63 +++++++++++++--------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index 673a07a75a..1435c6e5d0 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -68,32 +68,20 @@ private static void ReleaseAllAcquiredSlots() protected async Task AcquireDbThrottleAsync() { - if (DatabaseThrottleSemaphore.WaitOne(0)) - { - Interlocked.Increment(ref _acquiredSlotCount); - } - else - { - // Slow path: all slots are taken; offload the blocking WaitOne to a thread-pool thread to keep - // the xUnit scheduler thread free for other work. - await Task.Run(AcquireDbThrottleBlocking); - } - - _throttleAcquired = true; - } - - private static void AcquireDbThrottleBlocking() - { - // The 5-minute timeout is a safety net: if a previous test run was forcibly killed on Windows, some slots in the - // named semaphore may have leaked. Without a timeout, the next run would hang indefinitely. - if (!DatabaseThrottleSemaphore.WaitOne(TimeSpan.FromMinutes(5))) + // The 5-minute timeout is a safety net against indefinite hangs. + // On Windows, it fires when a previous run was forcibly killed, leaving leaked slots in the named semaphore; + // restarting all running test processes clears it. On other platforms, it indicates the per-process slot limit + // is too low for the number of concurrently active test databases; set the JADNC_DB_THROTTLE environment + // variable to a higher value to increase it. + if (!await DatabaseThrottleSemaphore.WaitAsync(TimeSpan.FromMinutes(5))) { throw new TimeoutException("Timed out waiting for a database test slot after 5 minutes. " + - "On Windows, if a test run was recently killed forcibly, the named semaphore may hold leaked slots; " + - "restarting all running test processes will clear it."); + $"Set the '{ThrottleEnvironmentVariableName}' environment variable to override the slot count. " + + "On Windows, restarting all running test processes clears leaked slots from a previously killed run."); } Interlocked.Increment(ref _acquiredSlotCount); + _throttleAcquired = true; } /// @@ -183,11 +171,36 @@ public CrossPlatformSemaphore(string name, int count) } } - public bool WaitOne(int millisecondsTimeout) => - _named != null ? _named.WaitOne(millisecondsTimeout) : _local!.Wait(millisecondsTimeout); + public async Task WaitAsync(TimeSpan timeout) + { + if (_local != null) + { + return await _local.WaitAsync(timeout); + } + + // Named Semaphore has no async API. Poll with exponential backoff to avoid blocking thread-pool threads, + // which would cause thread-pool starvation when many fixtures compete for slots simultaneously. + if (_named!.WaitOne(0)) + { + return true; + } + + var deadline = DateTime.UtcNow + timeout; + var delay = TimeSpan.FromMilliseconds(5); - public bool WaitOne(TimeSpan timeout) => - _named != null ? _named.WaitOne(timeout) : _local!.Wait(timeout); + while (DateTime.UtcNow < deadline) + { + await Task.Delay(delay); + delay = TimeSpan.FromMilliseconds(Math.Min(delay.TotalMilliseconds * 2, 100)); + + if (_named.WaitOne(0)) + { + return true; + } + } + + return false; + } public void Release() { _named?.Release(); _local?.Release(); } From 4deea61c9fdebdbbfe92bfba1ed2759552260f3a Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 27 May 2026 02:43:32 +0200 Subject: [PATCH 07/14] try something else --- .../IntegrationTestContext.cs | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index 66ee77d270..58ec200086 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -38,6 +38,7 @@ public class IntegrationTestContext : IntegrationTest, IAs private Action? _configureServices; private Action? _postConfigureServices; private Action? _configureLogging; + private Task? _dbReadyTask; private WebApplication App => _lazyApp.Value; @@ -141,9 +142,20 @@ private WebApplication BuildApp() builder.WebHost.UseTestServer(); WebApplication app = builder.Build(); - startup.Configure(app); - RunOnDatabase(app, static dbContext => dbContext.Database.EnsureCreated()); + // Start DB schema creation asynchronously. With many fixtures starting concurrently, + // a synchronous EnsureCreated() here would block thread-pool threads and starve the + // SemaphoreSlim used by the DB throttle, causing timeouts. + Task dbReadyTask = _dbReadyTask = EnsureDbCreatedAsync(app); + + // Runs before startup middleware, ensuring the schema exists when the first request arrives. + app.Use(async (context, next) => + { + await dbReadyTask; + await next(context); + }); + + startup.Configure(app); return app; } @@ -182,17 +194,22 @@ private static void SetDbContextDebugOptions(DbContextOptionsBuilder options) options.ConfigureWarnings(static builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); } - private static void RunOnDatabase(WebApplication app, Action action) + private static async Task EnsureDbCreatedAsync(WebApplication app) { - using IServiceScope scope = app.Services.CreateScope(); + await using AsyncServiceScope scope = app.Services.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - action(dbContext); + await dbContext.Database.EnsureCreatedAsync(); } public async Task RunOnDatabaseAsync(Func asyncAction) { await using AsyncServiceScope scope = App.Services.CreateAsyncScope(); + + // App access above triggers BuildApp() if needed, which sets _dbReadyTask. + // Wait for DB schema creation before accessing the database. + await _dbReadyTask!; + var dbContext = scope.ServiceProvider.GetRequiredService(); await asyncAction(dbContext); @@ -214,7 +231,11 @@ public virtual async Task DisposeAsync() { if (_lazyApp.IsValueCreated) { - await RunOnDatabaseAsync(static async dbContext => await dbContext.Database.EnsureDeletedAsync()); + if (_dbReadyTask?.IsCompletedSuccessfully == true) + { + await RunOnDatabaseAsync(static async dbContext => await dbContext.Database.EnsureDeletedAsync()); + } + await App.DisposeAsync(); } } From e14a2c56ef153feaaaeafd5f5bb1295d60d0ad43 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 27 May 2026 03:51:05 +0200 Subject: [PATCH 08/14] add diagnostics --- .github/workflows/build.yml | 1 + test/TestBuildingBlocks/IntegrationTest.cs | 117 +++++++++++++++++++-- 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f7632612bd..963879bc93 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -110,6 +110,7 @@ jobs: Logging__LogLevel__Microsoft.Extensions.Hosting.Internal.Host: 'None' Logging__LogLevel__Microsoft.EntityFrameworkCore.Database.Command: 'None' Logging__LogLevel__JsonApiDotNetCore: 'None' + JADNC_THROTTLE_DIAG: '1' run: dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" --logger "GitHubActions;annotations-title=@test (@framework);annotations-message=@error\n@trace;summary-include-passed=false" - name: Upload coverage to codecov.io if: ${{ matrix.os == 'ubuntu-latest' }} diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index 1435c6e5d0..c029ea31c9 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net.Http.Headers; using System.Text.Json; using FluentAssertions.Extensions; @@ -13,6 +14,7 @@ public abstract class IntegrationTest { private const string ThrottleSemaphoreName = "JADNC-DbThrottle"; private const string ThrottleEnvironmentVariableName = "JADNC_DB_THROTTLE"; + private const string DiagnosticsEnvironmentVariableName = "JADNC_THROTTLE_DIAG"; // On Windows: named OS-level semaphore shared across all test processes (projects × target frameworks) on this machine. // On Linux/macOS: process-local semaphore (named semaphores are not supported by the .NET runtime on those platforms). @@ -24,8 +26,17 @@ public abstract class IntegrationTest // Tracks how many slots this process has acquired, so they can be released on abnormal exit. private static int _acquiredSlotCount; + // Diagnostics counters (only updated when diagnostics are enabled). + private static readonly bool DiagnosticsEnabled = Environment.GetEnvironmentVariable(DiagnosticsEnvironmentVariableName) == "1"; + private static readonly Stopwatch DiagStopwatch = Stopwatch.StartNew(); + private static int _diagCurrentActive; + private static int _diagPeakActive; + private static int _diagWaitedCount; + private static int _diagAcquiredTotal; + // Tracks whether this instance has acquired a slot, so DisposeAsync cannot double-release on error paths. private bool _throttleAcquired; + private long _slotAcquiredTimestamp; public static DateTimeOffset DefaultDateTimeUtc { get; } = 1.January(2020).At(1, 2, 3).AsUtc(); @@ -37,7 +48,34 @@ static IntegrationTest() // Releases all slots this process holds so the next test run is not blocked. // On Windows, forceful process kills cannot be caught here; the OS closes the handle but does not adjust the named // semaphore count. In that scenario the 5-minute timeout in AcquireDbThrottle() produces a clear error message. - AppDomain.CurrentDomain.ProcessExit += static (_, _) => ReleaseAllAcquiredSlots(); + AppDomain.CurrentDomain.ProcessExit += static (_, _) => + { + ReleaseAllAcquiredSlots(); + + if (DiagnosticsEnabled) + { + Console.Error.WriteLine( + $"[THROTTLE] EXIT summary: peak={_diagPeakActive}, waited={_diagWaitedCount}/{_diagAcquiredTotal}, elapsed={DiagStopwatch.Elapsed:mm\\:ss\\.f}"); + } + }; + + if (DiagnosticsEnabled) + { + Console.Error.WriteLine( + $"[THROTTLE] INIT pid={Environment.ProcessId}, os={Environment.OSVersion.Platform}, cpus={Environment.ProcessorCount}, limit={GetThrottleLimit()}"); + } + } + + private static int GetThrottleLimit() + { + string? overrideValue = Environment.GetEnvironmentVariable(ThrottleEnvironmentVariableName); + + if (int.TryParse(overrideValue, out int parsed) && parsed > 0) + { + return parsed; + } + + return OperatingSystem.IsWindows() ? 400 : Math.Max(1, 400 / Environment.ProcessorCount); } private static CrossPlatformSemaphore CreateThrottleSemaphore() @@ -68,20 +106,74 @@ private static void ReleaseAllAcquiredSlots() protected async Task AcquireDbThrottleAsync() { - // The 5-minute timeout is a safety net against indefinite hangs. - // On Windows, it fires when a previous run was forcibly killed, leaving leaked slots in the named semaphore; - // restarting all running test processes clears it. On other platforms, it indicates the per-process slot limit - // is too low for the number of concurrently active test databases; set the JADNC_DB_THROTTLE environment - // variable to a higher value to increase it. - if (!await DatabaseThrottleSemaphore.WaitAsync(TimeSpan.FromMinutes(5))) + bool hadToWait = false; + bool acquired; + + if (DiagnosticsEnabled) + { + // Try a non-blocking acquire first to detect whether the semaphore is actually saturated. + acquired = await DatabaseThrottleSemaphore.WaitAsync(TimeSpan.Zero); + + if (!acquired) + { + hadToWait = true; + Interlocked.Increment(ref _diagWaitedCount); + + await Console.Error.WriteLineAsync( + $"[THROTTLE] WAITING at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={_diagCurrentActive}, peak={_diagPeakActive}"); + + // The 5-minute timeout is a safety net against indefinite hangs. + // On Windows, it fires when a previous run was forcibly killed, leaving leaked slots in the named semaphore; + // restarting all running test processes clears it. On other platforms, it indicates the per-process slot limit + // is too low for the number of concurrently active test databases; set the JADNC_DB_THROTTLE environment + // variable to a higher value to increase it. + acquired = await DatabaseThrottleSemaphore.WaitAsync(TimeSpan.FromMinutes(5)); + } + } + else { - throw new TimeoutException("Timed out waiting for a database test slot after 5 minutes. " + + // The 5-minute timeout is a safety net against indefinite hangs. + // On Windows, it fires when a previous run was forcibly killed, leaving leaked slots in the named semaphore; + // restarting all running test processes clears it. On other platforms, it indicates the per-process slot limit + // is too low for the number of concurrently active test databases; set the JADNC_DB_THROTTLE environment + // variable to a higher value to increase it. + acquired = await DatabaseThrottleSemaphore.WaitAsync(TimeSpan.FromMinutes(5)); + } + + if (!acquired) + { + throw new TimeoutException($"Timed out waiting for a database test slot after 5 minutes " + + $"[diag: active={_diagCurrentActive}, peak={_diagPeakActive}, waited={_diagWaitedCount}]. " + $"Set the '{ThrottleEnvironmentVariableName}' environment variable to override the slot count. " + "On Windows, restarting all running test processes clears leaked slots from a previously killed run."); } Interlocked.Increment(ref _acquiredSlotCount); _throttleAcquired = true; + + if (DiagnosticsEnabled) + { + int active = Interlocked.Increment(ref _diagCurrentActive); + Interlocked.Increment(ref _diagAcquiredTotal); + + int peak; + + do + { + peak = _diagPeakActive; + + if (active <= peak) + { + break; + } + } + while (Interlocked.CompareExchange(ref _diagPeakActive, active, peak) != peak); + + _slotAcquiredTimestamp = Stopwatch.GetTimestamp(); + + await Console.Error.WriteLineAsync( + $"[THROTTLE] ACQUIRED at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={active}, peak={Math.Max(active, peak)}, waited={hadToWait}"); + } } /// @@ -94,6 +186,15 @@ protected void ReleaseDbThrottle() _throttleAcquired = false; Interlocked.Decrement(ref _acquiredSlotCount); DatabaseThrottleSemaphore.Release(); + + if (DiagnosticsEnabled) + { + int active = Interlocked.Decrement(ref _diagCurrentActive); + double heldMs = Stopwatch.GetElapsedTime(_slotAcquiredTimestamp).TotalMilliseconds; + + Console.Error.WriteLine( + $"[THROTTLE] RELEASED at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={active}, held={heldMs:F0}ms"); + } } } From c9a675d78cf4da6e99a4da2464d5fc148951c098 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 27 May 2026 04:29:42 +0200 Subject: [PATCH 09/14] fixup --- .github/workflows/build.yml | 14 +++++++++ test/TestBuildingBlocks/IntegrationTest.cs | 34 +++++++++++++++------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 963879bc93..88fe457aa9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -112,6 +112,20 @@ jobs: Logging__LogLevel__JsonApiDotNetCore: 'None' JADNC_THROTTLE_DIAG: '1' run: dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" --logger "GitHubActions;annotations-title=@test (@framework);annotations-message=@error\n@trace;summary-include-passed=false" + - name: Print throttle diagnostics + if: always() + shell: bash + run: | + shopt -s nullglob + files=("$RUNNER_TEMP"/jadnc-throttle-*.log) + if [[ ${#files[@]} -eq 0 ]]; then + echo "No throttle diagnostic files found." + else + for f in "${files[@]}"; do + echo "=== $f ===" + cat "$f" + done + fi - name: Upload coverage to codecov.io if: ${{ matrix.os == 'ubuntu-latest' }} env: diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index c029ea31c9..50af8fcf8a 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -29,6 +29,10 @@ public abstract class IntegrationTest // Diagnostics counters (only updated when diagnostics are enabled). private static readonly bool DiagnosticsEnabled = Environment.GetEnvironmentVariable(DiagnosticsEnvironmentVariableName) == "1"; private static readonly Stopwatch DiagStopwatch = Stopwatch.StartNew(); + private static readonly string DiagFilePath = Path.Combine(Path.GetTempPath(), $"jadnc-throttle-{Environment.ProcessId}.log"); +#pragma warning disable IDE0330 // Use 'System.Threading.Lock' — not available on net8.0 + private static readonly object DiagFileLock = new(); +#pragma warning restore IDE0330 private static int _diagCurrentActive; private static int _diagPeakActive; private static int _diagWaitedCount; @@ -54,15 +58,23 @@ static IntegrationTest() if (DiagnosticsEnabled) { - Console.Error.WriteLine( + WriteDiagLine( $"[THROTTLE] EXIT summary: peak={_diagPeakActive}, waited={_diagWaitedCount}/{_diagAcquiredTotal}, elapsed={DiagStopwatch.Elapsed:mm\\:ss\\.f}"); } }; if (DiagnosticsEnabled) { - Console.Error.WriteLine( - $"[THROTTLE] INIT pid={Environment.ProcessId}, os={Environment.OSVersion.Platform}, cpus={Environment.ProcessorCount}, limit={GetThrottleLimit()}"); + WriteDiagLine( + $"[THROTTLE] INIT pid={Environment.ProcessId}, os={Environment.OSVersion.Platform}, cpus={Environment.ProcessorCount}, limit={GetThrottleLimit()}, file={DiagFilePath}"); + } + } + + private static void WriteDiagLine(string message) + { + lock (DiagFileLock) + { + File.AppendAllText(DiagFilePath, message + Environment.NewLine); } } @@ -75,7 +87,7 @@ private static int GetThrottleLimit() return parsed; } - return OperatingSystem.IsWindows() ? 400 : Math.Max(1, 400 / Environment.ProcessorCount); + return OperatingSystem.IsWindows() ? 400 : Math.Max(1, 256 / Environment.ProcessorCount); } private static CrossPlatformSemaphore CreateThrottleSemaphore() @@ -88,9 +100,11 @@ private static CrossPlatformSemaphore CreateThrottleSemaphore() } // On Windows, the named semaphore is shared across all concurrent test processes, so 400 is the global cap. - // On Linux/macOS, each process gets its own SemaphoreSlim, so divide by ProcessorCount to approximate the same - // global cap: dotnet test default parallelism equals ProcessorCount, so ProcessorCount × perProcessLimit ≈ 400. - int maxConnections = OperatingSystem.IsWindows() ? 400 : Math.Max(1, 400 / Environment.ProcessorCount); + // On Linux/macOS, each process gets its own SemaphoreSlim. Divide by ProcessorCount because dotnet test default + // parallelism equals ProcessorCount, so ProcessorCount × perProcessLimit ≈ global cap. Use 256 (not 400) so that + // on a 4-CPU CI runner we get 64 per process—the same level that was validated on master—avoiding resource + // exhaustion when multiple target frameworks run concurrently on slower x86 hardware. + int maxConnections = OperatingSystem.IsWindows() ? 400 : Math.Max(1, 256 / Environment.ProcessorCount); return new CrossPlatformSemaphore(ThrottleSemaphoreName, maxConnections); } @@ -119,7 +133,7 @@ protected async Task AcquireDbThrottleAsync() hadToWait = true; Interlocked.Increment(ref _diagWaitedCount); - await Console.Error.WriteLineAsync( + WriteDiagLine( $"[THROTTLE] WAITING at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={_diagCurrentActive}, peak={_diagPeakActive}"); // The 5-minute timeout is a safety net against indefinite hangs. @@ -171,7 +185,7 @@ await Console.Error.WriteLineAsync( _slotAcquiredTimestamp = Stopwatch.GetTimestamp(); - await Console.Error.WriteLineAsync( + WriteDiagLine( $"[THROTTLE] ACQUIRED at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={active}, peak={Math.Max(active, peak)}, waited={hadToWait}"); } } @@ -192,7 +206,7 @@ protected void ReleaseDbThrottle() int active = Interlocked.Decrement(ref _diagCurrentActive); double heldMs = Stopwatch.GetElapsedTime(_slotAcquiredTimestamp).TotalMilliseconds; - Console.Error.WriteLine( + WriteDiagLine( $"[THROTTLE] RELEASED at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={active}, held={heldMs:F0}ms"); } } From 67129fbb88038a594125b49d40f4977ef14d6293 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 31 May 2026 14:05:39 +0200 Subject: [PATCH 10/14] try more --- test/TestBuildingBlocks/IntegrationTest.cs | 27 +++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index 50af8fcf8a..5d8d719746 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -29,7 +29,10 @@ public abstract class IntegrationTest // Diagnostics counters (only updated when diagnostics are enabled). private static readonly bool DiagnosticsEnabled = Environment.GetEnvironmentVariable(DiagnosticsEnvironmentVariableName) == "1"; private static readonly Stopwatch DiagStopwatch = Stopwatch.StartNew(); - private static readonly string DiagFilePath = Path.Combine(Path.GetTempPath(), $"jadnc-throttle-{Environment.ProcessId}.log"); + // When running in GitHub Actions, RUNNER_TEMP is a well-known writable directory that the workflow step can read back. + // Locally, fall back to the system temp directory. + private static readonly string DiagFilePath = Path.Combine( + Environment.GetEnvironmentVariable("RUNNER_TEMP") ?? Path.GetTempPath(), $"jadnc-throttle-{Environment.ProcessId}.log"); #pragma warning disable IDE0330 // Use 'System.Threading.Lock' — not available on net8.0 private static readonly object DiagFileLock = new(); #pragma warning restore IDE0330 @@ -136,27 +139,23 @@ protected async Task AcquireDbThrottleAsync() WriteDiagLine( $"[THROTTLE] WAITING at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={_diagCurrentActive}, peak={_diagPeakActive}"); - // The 5-minute timeout is a safety net against indefinite hangs. - // On Windows, it fires when a previous run was forcibly killed, leaving leaked slots in the named semaphore; - // restarting all running test processes clears it. On other platforms, it indicates the per-process slot limit - // is too low for the number of concurrently active test databases; set the JADNC_DB_THROTTLE environment - // variable to a higher value to increase it. - acquired = await DatabaseThrottleSemaphore.WaitAsync(TimeSpan.FromMinutes(5)); + // The 10-minute timeout is a safety net against indefinite hangs caused by leaked slots (e.g. after a + // forcibly killed run on Windows). Under normal conditions fixtures queue up and are served as earlier + // ones complete, which happens well within this window. + acquired = await DatabaseThrottleSemaphore.WaitAsync(TimeSpan.FromMinutes(10)); } } else { - // The 5-minute timeout is a safety net against indefinite hangs. - // On Windows, it fires when a previous run was forcibly killed, leaving leaked slots in the named semaphore; - // restarting all running test processes clears it. On other platforms, it indicates the per-process slot limit - // is too low for the number of concurrently active test databases; set the JADNC_DB_THROTTLE environment - // variable to a higher value to increase it. - acquired = await DatabaseThrottleSemaphore.WaitAsync(TimeSpan.FromMinutes(5)); + // The 10-minute timeout is a safety net against indefinite hangs caused by leaked slots (e.g. after a + // forcibly killed run on Windows). Under normal conditions fixtures queue up and are served as earlier + // ones complete, which happens well within this window. + acquired = await DatabaseThrottleSemaphore.WaitAsync(TimeSpan.FromMinutes(10)); } if (!acquired) { - throw new TimeoutException($"Timed out waiting for a database test slot after 5 minutes " + + throw new TimeoutException($"Timed out waiting for a database test slot after 10 minutes " + $"[diag: active={_diagCurrentActive}, peak={_diagPeakActive}, waited={_diagWaitedCount}]. " + $"Set the '{ThrottleEnvironmentVariableName}' environment variable to override the slot count. " + "On Windows, restarting all running test processes clears leaked slots from a previously killed run."); From d988504bc0fd59e746ab5f4b114c7ac49efa60e2 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 31 May 2026 15:15:51 +0200 Subject: [PATCH 11/14] another attempt --- test/TestBuildingBlocks/IntegrationTest.cs | 56 ++++++++++++---------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index 5d8d719746..f3c3b801dd 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -20,22 +20,24 @@ public abstract class IntegrationTest // On Linux/macOS: process-local semaphore (named semaphores are not supported by the .NET runtime on those platforms). // Limits the total number of concurrently live test databases to avoid exhausting the PostgreSQL connection pool. private static readonly CrossPlatformSemaphore DatabaseThrottleSemaphore = CreateThrottleSemaphore(); - protected static readonly Action ConfigureServiceProvider = static options => options.ValidateScopes = true; - - // Tracks how many slots this process has acquired, so they can be released on abnormal exit. private static int _acquiredSlotCount; - - // Diagnostics counters (only updated when diagnostics are enabled). private static readonly bool DiagnosticsEnabled = Environment.GetEnvironmentVariable(DiagnosticsEnvironmentVariableName) == "1"; + private static readonly Stopwatch DiagStopwatch = Stopwatch.StartNew(); + // When running in GitHub Actions, RUNNER_TEMP is a well-known writable directory that the workflow step can read back. // Locally, fall back to the system temp directory. - private static readonly string DiagFilePath = Path.Combine( - Environment.GetEnvironmentVariable("RUNNER_TEMP") ?? Path.GetTempPath(), $"jadnc-throttle-{Environment.ProcessId}.log"); + private static readonly string DiagFilePath = Path.Combine(Environment.GetEnvironmentVariable("RUNNER_TEMP") ?? Path.GetTempPath(), + $"jadnc-throttle-{Environment.ProcessId}.log"); #pragma warning disable IDE0330 // Use 'System.Threading.Lock' — not available on net8.0 private static readonly object DiagFileLock = new(); #pragma warning restore IDE0330 + + // Diagnostics counters (only updated when diagnostics are enabled). + + // Tracks how many slots this process has acquired, so they can be released on abnormal exit. + private static int _diagCurrentActive; private static int _diagPeakActive; private static int _diagWaitedCount; @@ -90,7 +92,7 @@ private static int GetThrottleLimit() return parsed; } - return OperatingSystem.IsWindows() ? 400 : Math.Max(1, 256 / Environment.ProcessorCount); + return OperatingSystem.IsWindows() ? 400 : 160; } private static CrossPlatformSemaphore CreateThrottleSemaphore() @@ -103,11 +105,10 @@ private static CrossPlatformSemaphore CreateThrottleSemaphore() } // On Windows, the named semaphore is shared across all concurrent test processes, so 400 is the global cap. - // On Linux/macOS, each process gets its own SemaphoreSlim. Divide by ProcessorCount because dotnet test default - // parallelism equals ProcessorCount, so ProcessorCount × perProcessLimit ≈ global cap. Use 256 (not 400) so that - // on a 4-CPU CI runner we get 64 per process—the same level that was validated on master—avoiding resource - // exhaustion when multiple target frameworks run concurrently on slower x86 hardware. - int maxConnections = OperatingSystem.IsWindows() ? 400 : Math.Max(1, 256 / Environment.ProcessorCount); + // On Linux/macOS, each process gets its own SemaphoreSlim. Use 160 per process so that all test fixtures can + // start without queuing (there are ~132), while keeping total connections across 3 concurrent TFM processes + // well within the PostgreSQL max_connections=500 configured in CI (3 × 132 active = 396). + int maxConnections = OperatingSystem.IsWindows() ? 400 : 160; return new CrossPlatformSemaphore(ThrottleSemaphoreName, maxConnections); } @@ -136,8 +137,7 @@ protected async Task AcquireDbThrottleAsync() hadToWait = true; Interlocked.Increment(ref _diagWaitedCount); - WriteDiagLine( - $"[THROTTLE] WAITING at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={_diagCurrentActive}, peak={_diagPeakActive}"); + WriteDiagLine($"[THROTTLE] WAITING at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={_diagCurrentActive}, peak={_diagPeakActive}"); // The 10-minute timeout is a safety net against indefinite hangs caused by leaked slots (e.g. after a // forcibly killed run on Windows). Under normal conditions fixtures queue up and are served as earlier @@ -184,8 +184,7 @@ protected async Task AcquireDbThrottleAsync() _slotAcquiredTimestamp = Stopwatch.GetTimestamp(); - WriteDiagLine( - $"[THROTTLE] ACQUIRED at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={active}, peak={Math.Max(active, peak)}, waited={hadToWait}"); + WriteDiagLine($"[THROTTLE] ACQUIRED at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={active}, peak={Math.Max(active, peak)}, waited={hadToWait}"); } } @@ -205,8 +204,7 @@ protected void ReleaseDbThrottle() int active = Interlocked.Decrement(ref _diagCurrentActive); double heldMs = Stopwatch.GetElapsedTime(_slotAcquiredTimestamp).TotalMilliseconds; - WriteDiagLine( - $"[THROTTLE] RELEASED at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={active}, held={heldMs:F0}ms"); + WriteDiagLine($"[THROTTLE] RELEASED at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={active}, held={heldMs:F0}ms"); } } } @@ -262,8 +260,8 @@ protected void ReleaseDbThrottle() protected abstract HttpClient CreateClient(); /// - /// Provides a cross-platform counting semaphore. Uses a named OS-level semaphore on Windows (shared across processes) and a - /// process-local semaphore on other platforms (named semaphores are not supported by the .NET runtime on Linux and macOS). + /// Provides a cross-platform counting semaphore. Uses a named OS-level semaphore on Windows (shared across processes) and a process-local semaphore on + /// other platforms (named semaphores are not supported by the .NET runtime on Linux and macOS). /// #pragma warning disable CA1001 // Types that own disposable fields should be disposable // False positive: this instance is intentionally held for the process lifetime as a static field. @@ -299,8 +297,8 @@ public async Task WaitAsync(TimeSpan timeout) return true; } - var deadline = DateTime.UtcNow + timeout; - var delay = TimeSpan.FromMilliseconds(5); + DateTime deadline = DateTime.UtcNow + timeout; + TimeSpan delay = TimeSpan.FromMilliseconds(5); while (DateTime.UtcNow < deadline) { @@ -316,8 +314,16 @@ public async Task WaitAsync(TimeSpan timeout) return false; } - public void Release() { _named?.Release(); _local?.Release(); } + public void Release() + { + _named?.Release(); + _local?.Release(); + } - public void Release(int releaseCount) { _named?.Release(releaseCount); _local?.Release(releaseCount); } + public void Release(int releaseCount) + { + _named?.Release(releaseCount); + _local?.Release(releaseCount); + } } } From 0263c9c748cafcaacb457090d6a38b048240e7c4 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 31 May 2026 17:05:25 +0200 Subject: [PATCH 12/14] workaround test failure --- .../Serialization/Extensions/SourcePointerInExceptionTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Extensions/SourcePointerInExceptionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Extensions/SourcePointerInExceptionTests.cs index 089a01d21e..42ac59c58d 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Extensions/SourcePointerInExceptionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Extensions/SourcePointerInExceptionTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Runtime.CompilerServices; using System.Text.Json; using FluentAssertions; using JetBrains.Annotations; @@ -91,6 +92,7 @@ private sealed class ThrowingResourceObjectConverter(IResourceGraph resourceGrap { private readonly string? _relativeSourcePointer = relativeSourcePointer; + [MethodImpl(MethodImplOptions.NoInlining)] private protected override void ValidateExtensionInAttributes(string extensionNamespace, string extensionName, ResourceType resourceType, Utf8JsonReader reader) { From 4fe017e15c4b4cab442c12e6985cbc3c8f074e4b Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 31 May 2026 17:51:57 +0200 Subject: [PATCH 13/14] try more --- test/TestBuildingBlocks/IntegrationTest.cs | 94 ++-------------------- 1 file changed, 6 insertions(+), 88 deletions(-) diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index f3c3b801dd..968e367571 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -12,14 +12,11 @@ namespace TestBuildingBlocks; /// public abstract class IntegrationTest { - private const string ThrottleSemaphoreName = "JADNC-DbThrottle"; private const string ThrottleEnvironmentVariableName = "JADNC_DB_THROTTLE"; private const string DiagnosticsEnvironmentVariableName = "JADNC_THROTTLE_DIAG"; - // On Windows: named OS-level semaphore shared across all test processes (projects × target frameworks) on this machine. - // On Linux/macOS: process-local semaphore (named semaphores are not supported by the .NET runtime on those platforms). - // Limits the total number of concurrently live test databases to avoid exhausting the PostgreSQL connection pool. - private static readonly CrossPlatformSemaphore DatabaseThrottleSemaphore = CreateThrottleSemaphore(); + // Process-local semaphore that limits the number of concurrently live test databases to avoid exhausting the PostgreSQL connection pool. + private static readonly SemaphoreSlim DatabaseThrottleSemaphore = CreateThrottleSemaphore(); protected static readonly Action ConfigureServiceProvider = static options => options.ValidateScopes = true; private static int _acquiredSlotCount; private static readonly bool DiagnosticsEnabled = Environment.GetEnvironmentVariable(DiagnosticsEnvironmentVariableName) == "1"; @@ -92,24 +89,13 @@ private static int GetThrottleLimit() return parsed; } - return OperatingSystem.IsWindows() ? 400 : 160; + // On Windows outside Visual Studio, use a lower limit to avoid overwhelming PostgreSQL with too many simultaneous connections. + return OperatingSystem.IsWindows() && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VSAPPIDDIR")) ? 32 : 64; } - private static CrossPlatformSemaphore CreateThrottleSemaphore() + private static SemaphoreSlim CreateThrottleSemaphore() { - string? overrideMaxConnections = Environment.GetEnvironmentVariable(ThrottleEnvironmentVariableName); - - if (int.TryParse(overrideMaxConnections, out int parsed) && parsed > 0) - { - return new CrossPlatformSemaphore(ThrottleSemaphoreName, parsed); - } - - // On Windows, the named semaphore is shared across all concurrent test processes, so 400 is the global cap. - // On Linux/macOS, each process gets its own SemaphoreSlim. Use 160 per process so that all test fixtures can - // start without queuing (there are ~132), while keeping total connections across 3 concurrent TFM processes - // well within the PostgreSQL max_connections=500 configured in CI (3 × 132 active = 396). - int maxConnections = OperatingSystem.IsWindows() ? 400 : 160; - return new CrossPlatformSemaphore(ThrottleSemaphoreName, maxConnections); + return new SemaphoreSlim(GetThrottleLimit()); } private static void ReleaseAllAcquiredSlots() @@ -258,72 +244,4 @@ protected void ReleaseDbThrottle() } protected abstract HttpClient CreateClient(); - - /// - /// Provides a cross-platform counting semaphore. Uses a named OS-level semaphore on Windows (shared across processes) and a process-local semaphore on - /// other platforms (named semaphores are not supported by the .NET runtime on Linux and macOS). - /// -#pragma warning disable CA1001 // Types that own disposable fields should be disposable - // False positive: this instance is intentionally held for the process lifetime as a static field. - private sealed class CrossPlatformSemaphore -#pragma warning restore CA1001 - { - private readonly Semaphore? _named; - private readonly SemaphoreSlim? _local; - - public CrossPlatformSemaphore(string name, int count) - { - if (OperatingSystem.IsWindows()) - { - _named = new Semaphore(count, count, name); - } - else - { - _local = new SemaphoreSlim(count, count); - } - } - - public async Task WaitAsync(TimeSpan timeout) - { - if (_local != null) - { - return await _local.WaitAsync(timeout); - } - - // Named Semaphore has no async API. Poll with exponential backoff to avoid blocking thread-pool threads, - // which would cause thread-pool starvation when many fixtures compete for slots simultaneously. - if (_named!.WaitOne(0)) - { - return true; - } - - DateTime deadline = DateTime.UtcNow + timeout; - TimeSpan delay = TimeSpan.FromMilliseconds(5); - - while (DateTime.UtcNow < deadline) - { - await Task.Delay(delay); - delay = TimeSpan.FromMilliseconds(Math.Min(delay.TotalMilliseconds * 2, 100)); - - if (_named.WaitOne(0)) - { - return true; - } - } - - return false; - } - - public void Release() - { - _named?.Release(); - _local?.Release(); - } - - public void Release(int releaseCount) - { - _named?.Release(releaseCount); - _local?.Release(releaseCount); - } - } } From fde5f7f122b05a88c18ecd686bdf2face1b3c5db Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:02:51 +0200 Subject: [PATCH 14/14] try remove xUnit DI package --- package-versions.props | 1 - .../ApiControllerAttributeLogTests.cs | 4 +--- .../DuplicateResourceControllerTests.cs | 4 +--- .../UnknownResourceControllerTests.cs | 4 +--- .../SerializerIgnoreConditionTests.cs | 4 +--- test/OpenApiTests/LegacyOpenApi/LegacyTests.cs | 7 +++---- .../MissingFromBodyOnPatchMethodTests.cs | 7 +++---- .../MissingFromBodyOnPostMethodTests.cs | 7 +++---- test/OpenApiTests/OpenApiTestContext.cs | 4 +--- test/TestBuildingBlocks/FactoryBridge.cs | 18 +----------------- .../IntegrationTestContext.cs | 7 ++----- .../TestBuildingBlocks.csproj | 1 - 12 files changed, 17 insertions(+), 51 deletions(-) diff --git a/package-versions.props b/package-versions.props index 6d51c8b603..57f55ff647 100644 --- a/package-versions.props +++ b/package-versions.props @@ -30,7 +30,6 @@ 10.*-* 10.0.* 18.5.* - 9.9.* 2.9.* 3.1.* diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs index 9dddb255fc..160bc3bc92 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.Logging; using TestBuildingBlocks; using Xunit; -using Xunit.DependencyInjection; namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; @@ -11,8 +10,7 @@ public sealed class ApiControllerAttributeLogTests : IntegrationTestContext(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs index 163ab487c0..0f1b1178f4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs @@ -2,14 +2,12 @@ using JsonApiDotNetCore.Errors; using TestBuildingBlocks; using Xunit; -using Xunit.DependencyInjection; namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; public sealed class DuplicateResourceControllerTests : IntegrationTestContext, KnownDbContext> { - public DuplicateResourceControllerTests(ITestOutputHelperAccessor accessor) - : base(accessor) + public DuplicateResourceControllerTests() { UseController(); UseController(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs index 6eab15be63..55d09dc2fe 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs @@ -2,14 +2,12 @@ using JsonApiDotNetCore.Errors; using TestBuildingBlocks; using Xunit; -using Xunit.DependencyInjection; namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; public sealed class UnknownResourceControllerTests : IntegrationTestContext, EmptyDbContext> { - public UnknownResourceControllerTests(ITestOutputHelperAccessor accessor) - : base(accessor) + public UnknownResourceControllerTests() { UseController(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs index bf5e3f21e5..7fa5aa7fcb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; -using Xunit.DependencyInjection; namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; @@ -15,8 +14,7 @@ public sealed class SerializerIgnoreConditionTests : IntegrationTestContext(); } diff --git a/test/OpenApiTests/LegacyOpenApi/LegacyTests.cs b/test/OpenApiTests/LegacyOpenApi/LegacyTests.cs index 55d782712a..3e7ebd00f1 100644 --- a/test/OpenApiTests/LegacyOpenApi/LegacyTests.cs +++ b/test/OpenApiTests/LegacyOpenApi/LegacyTests.cs @@ -4,21 +4,20 @@ using FluentAssertions; using TestBuildingBlocks; using Xunit; -using Xunit.DependencyInjection; +using Xunit.Abstractions; namespace OpenApiTests.LegacyOpenApi; public sealed class LegacyTests : OpenApiTestContext, LegacyIntegrationDbContext> { - public LegacyTests(ITestOutputHelperAccessor accessor) - : base(accessor) + public LegacyTests(ITestOutputHelper testOutputHelper) { UseController(); UseController(); UseController(); UseController(); - SetTestOutputHelper(accessor.Output); + SetTestOutputHelper(testOutputHelper); OpenApiDocumentOutputDirectory = $"{GetType().Namespace!.Replace('.', '/')}/GeneratedSwagger"; } diff --git a/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPatchMethodTests.cs b/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPatchMethodTests.cs index e525f7ea25..ed9168a2a9 100644 --- a/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPatchMethodTests.cs +++ b/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPatchMethodTests.cs @@ -1,18 +1,17 @@ using FluentAssertions; using JsonApiDotNetCore.Errors; using Xunit; -using Xunit.DependencyInjection; +using Xunit.Abstractions; namespace OpenApiTests.OpenApiGenerationFailures.MissingFromBody; public sealed class MissingFromBodyOnPatchMethodTests : OpenApiTestContext, MissingFromBodyDbContext> { - public MissingFromBodyOnPatchMethodTests(ITestOutputHelperAccessor accessor) - : base(accessor) + public MissingFromBodyOnPatchMethodTests(ITestOutputHelper testOutputHelper) { UseController(); - SetTestOutputHelper(accessor.Output); + SetTestOutputHelper(testOutputHelper); } [Fact] diff --git a/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPostMethodTests.cs b/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPostMethodTests.cs index 72811b785e..ddc321504e 100644 --- a/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPostMethodTests.cs +++ b/test/OpenApiTests/OpenApiGenerationFailures/MissingFromBody/MissingFromBodyOnPostMethodTests.cs @@ -1,18 +1,17 @@ using FluentAssertions; using JsonApiDotNetCore.Errors; using Xunit; -using Xunit.DependencyInjection; +using Xunit.Abstractions; namespace OpenApiTests.OpenApiGenerationFailures.MissingFromBody; public sealed class MissingFromBodyOnPostMethodTests : OpenApiTestContext, MissingFromBodyDbContext> { - public MissingFromBodyOnPostMethodTests(ITestOutputHelperAccessor accessor) - : base(accessor) + public MissingFromBodyOnPostMethodTests(ITestOutputHelper testOutputHelper) { UseController(); - SetTestOutputHelper(accessor.Output); + SetTestOutputHelper(testOutputHelper); } [Fact] diff --git a/test/OpenApiTests/OpenApiTestContext.cs b/test/OpenApiTests/OpenApiTestContext.cs index 72d152f0fa..d5ef6cf7e6 100644 --- a/test/OpenApiTests/OpenApiTestContext.cs +++ b/test/OpenApiTests/OpenApiTestContext.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Logging; using TestBuildingBlocks; using Xunit.Abstractions; -using Xunit.DependencyInjection; namespace OpenApiTests; @@ -18,8 +17,7 @@ public class OpenApiTestContext : IntegrationTestContext>(CreateOpenApiDocumentAsync, LazyThreadSafetyMode.ExecutionAndPublication); diff --git a/test/TestBuildingBlocks/FactoryBridge.cs b/test/TestBuildingBlocks/FactoryBridge.cs index af7fb610ba..6778e019d2 100644 --- a/test/TestBuildingBlocks/FactoryBridge.cs +++ b/test/TestBuildingBlocks/FactoryBridge.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Hosting; -using Xunit.DependencyInjection; namespace TestBuildingBlocks; @@ -11,19 +10,16 @@ namespace TestBuildingBlocks; public sealed class FactoryBridge { private readonly WebApplication _app; - private readonly ITestOutputHelperAccessor _accessor; private readonly bool _captureHttpTraffic; private bool _hasStartedApp; public IServiceProvider Services => _app.Services; - internal FactoryBridge(WebApplication app, ITestOutputHelperAccessor accessor, bool captureHttpTraffic) + internal FactoryBridge(WebApplication app, bool captureHttpTraffic) { ArgumentNullException.ThrowIfNull(app); - ArgumentNullException.ThrowIfNull(accessor); _app = app; - _accessor = accessor; _captureHttpTraffic = captureHttpTraffic; } @@ -45,19 +41,7 @@ public HttpClient GetTestClient(params DelegatingHandler[] handlers) _app.Start(); } -#if DEBUG - if (handlers.Length == 0) - { - if (_captureHttpTraffic) - { - var captureHandler = new XUnitLogHttpMessageHandler(_accessor.Output!); - handlers = [captureHandler]; - } - } -#else _ = _captureHttpTraffic; - _ = _accessor; -#endif if (handlers.Length == 0) { diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index 58ec200086..7460ea05ad 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -12,7 +12,6 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Xunit; -using Xunit.DependencyInjection; namespace TestBuildingBlocks; @@ -32,7 +31,6 @@ public class IntegrationTestContext : IntegrationTest, IAs where TStartup : IStartup, new() where TDbContext : TestableDbContext { - private readonly ITestOutputHelperAccessor _accessor; private readonly TestControllerProvider _testControllerProvider = new(); private readonly Lazy _lazyApp; private Action? _configureServices; @@ -57,14 +55,13 @@ public FactoryBridge Factory { get { - field ??= new FactoryBridge(App, _accessor, CaptureHttpTraffic); + field ??= new FactoryBridge(App, CaptureHttpTraffic); return field; } } - public IntegrationTestContext(ITestOutputHelperAccessor accessor) + public IntegrationTestContext() { - _accessor = accessor; _lazyApp = new Lazy(BuildApp, LazyThreadSafetyMode.ExecutionAndPublication); } diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj index 052330474a..ec28b715d3 100644 --- a/test/TestBuildingBlocks/TestBuildingBlocks.csproj +++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj @@ -21,7 +21,6 @@ -