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 @@
-