Skip to content

Commit 538cbf9

Browse files
authored
Replace WebApplicationFactory in tests with WebApplicationBuilder (#2014)
1 parent dbda7be commit 538cbf9

13 files changed

Lines changed: 235 additions & 155 deletions

File tree

.github/workflows/build.yml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ jobs:
4343
with:
4444
username: postgres
4545
password: postgres
46+
- name: Configure PostgreSQL
47+
shell: bash
48+
run: |
49+
PGDATA="$RUNNER_TEMP/pgdata"
50+
echo "max_connections = 500" >> "$PGDATA/postgresql.conf"
51+
pg_ctl restart --pgdata="$PGDATA" --wait
4652
- name: Setup .NET
4753
uses: actions/setup-dotnet@v5
4854
with:
@@ -97,13 +103,6 @@ jobs:
97103
- name: Build
98104
run: dotnet build --no-restore --configuration Release /p:VersionSuffix=${{ env.PACKAGE_VERSION_SUFFIX }}
99105
- name: Test
100-
env:
101-
# Override log levels, to reduce logging output when running tests in ci-build.
102-
Logging__LogLevel__Microsoft.Hosting.Lifetime: 'None'
103-
Logging__LogLevel__Microsoft.AspNetCore.Hosting.Diagnostics: 'None'
104-
Logging__LogLevel__Microsoft.Extensions.Hosting.Internal.Host: 'None'
105-
Logging__LogLevel__Microsoft.EntityFrameworkCore.Database.Command: 'None'
106-
Logging__LogLevel__JsonApiDotNetCore: 'None'
107106
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"
108107
- name: Upload coverage to codecov.io
109108
if: ${{ matrix.os == 'ubuntu-latest' }}

Build.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ Remove-Item -Recurse -Force * -Include coverage.cobertura.xml
1010

1111
dotnet tool restore
1212
dotnet build --configuration Release
13-
dotnet test --no-build --configuration Release --verbosity quiet --collect:"XPlat Code Coverage"
13+
dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage"
1414
dotnet reportgenerator -reports:**\coverage.cobertura.xml -targetdir:artifacts\coverage -filefilters:-*.g.cs
1515
dotnet pack --no-build --configuration Release --output artifacts/packages

src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ private bool ShouldIncludeTopLevelLink(LinkTypes linkType, ResourceType? resourc
128128

129129
private string GetLinkForTopLevelSelf()
130130
{
131-
// Note: in tests, this does not properly escape special characters due to WebApplicationFactory short-circuiting.
131+
// Note: in tests, this does not properly escape special characters due to WebApplicationFactory/TestServer short-circuiting.
132132
return _options.UseRelativeLinks ? HttpContext.Request.GetEncodedPathAndQuery() : HttpContext.Request.GetEncodedUrl();
133133
}
134134

test/DapperTests/IntegrationTests/DapperTestContext.cs

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics;
12
using System.Text.Json;
23
using DapperExample;
34
using DapperExample.Data;
@@ -14,12 +15,13 @@
1415
using Microsoft.Extensions.DependencyInjection.Extensions;
1516
using Microsoft.Extensions.Logging;
1617
using TestBuildingBlocks;
18+
using Xunit;
1719
using Xunit.Abstractions;
1820

1921
namespace DapperTests.IntegrationTests;
2022

2123
[PublicAPI]
22-
public sealed class DapperTestContext : IntegrationTest
24+
public sealed class DapperTestContext : IntegrationTest, IAsyncLifetime
2325
{
2426
private const string SqlServerClearAllTablesScript = """
2527
EXEC sp_MSForEachTable 'ALTER TABLE ? NOCHECK CONSTRAINT ALL';
@@ -31,6 +33,7 @@ public sealed class DapperTestContext : IntegrationTest
3133

3234
private readonly Lazy<WebApplicationFactory<TodoItem>> _lazyFactory;
3335
private ITestOutputHelper? _testOutputHelper;
36+
private bool _throttleAcquired;
3437

3538
protected override JsonSerializerOptions SerializerOptions
3639
{
@@ -48,6 +51,12 @@ public DapperTestContext()
4851
_lazyFactory = new Lazy<WebApplicationFactory<TodoItem>>(CreateFactory);
4952
}
5053

54+
public async Task InitializeAsync()
55+
{
56+
await AcquireDatabaseThrottleAsync();
57+
_throttleAcquired = true;
58+
}
59+
5160
private WebApplicationFactory<TodoItem> CreateFactory()
5261
{
5362
#pragma warning disable CA2000 // Dispose objects before losing scope
@@ -56,7 +65,7 @@ private WebApplicationFactory<TodoItem> CreateFactory()
5665
#pragma warning restore CA2000 // Dispose objects before losing scope
5766
{
5867
builder.UseSetting("ConnectionStrings:DapperExamplePostgreSql",
59-
$"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true");
68+
$"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=600");
6069

6170
builder.UseSetting("ConnectionStrings:DapperExampleMySql",
6271
$"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=root;Password=mysql;SSL Mode=None;AllowPublicKeyRetrieval=True");
@@ -68,16 +77,16 @@ private WebApplicationFactory<TodoItem> CreateFactory()
6877

6978
builder.ConfigureLogging(loggingBuilder =>
7079
{
80+
ClearLoggingProvidersInReleaseBuild(loggingBuilder);
81+
7182
if (_testOutputHelper != null)
7283
{
73-
#if !DEBUG
74-
// Reduce logging output when running tests in ci-build.
75-
loggingBuilder.ClearProviders();
76-
#endif
7784
loggingBuilder.Services.AddSingleton<ILoggerProvider>(_ => new XUnitLoggerProvider(_testOutputHelper, "DapperExample."));
7885
}
7986
});
8087

88+
builder.UseDefaultServiceProvider(ConfigureServiceProvider);
89+
8190
builder.ConfigureServices(services =>
8291
{
8392
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(new FrozenTimeProvider(FrozenTime)));
@@ -86,6 +95,12 @@ private WebApplicationFactory<TodoItem> CreateFactory()
8695
});
8796
}
8897

98+
[Conditional("RELEASE")]
99+
private static void ClearLoggingProvidersInReleaseBuild(ILoggingBuilder loggingBuilder)
100+
{
101+
loggingBuilder.ClearProviders();
102+
}
103+
89104
public void SetTestOutputHelper(ITestOutputHelper testOutputHelper)
90105
{
91106
_testOutputHelper = testOutputHelper;
@@ -123,12 +138,22 @@ public async Task ClearAllTablesAsync(DbContext dbContext)
123138

124139
public async Task RunOnDatabaseAsync(Func<AppDbContext, Task> asyncAction)
125140
{
141+
AssertThrottleAcquired();
142+
126143
await using AsyncServiceScope scope = Factory.Services.CreateAsyncScope();
127144
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
128145

129146
await asyncAction(dbContext);
130147
}
131148

149+
private void AssertThrottleAcquired()
150+
{
151+
if (!_throttleAcquired)
152+
{
153+
throw new InvalidOperationException("Database throttle not acquired.");
154+
}
155+
}
156+
132157
public string AdaptSql(string text, bool hasClientGeneratedId = false)
133158
{
134159
var dataModelService = Factory.Services.GetRequiredService<IDataModelService>();
@@ -141,25 +166,25 @@ protected override HttpClient CreateClient()
141166
return Factory.CreateClient();
142167
}
143168

144-
public override async Task DisposeAsync()
169+
public async Task DisposeAsync()
145170
{
146171
try
147172
{
148173
if (_lazyFactory.IsValueCreated)
149174
{
150175
try
151176
{
152-
await RunOnDatabaseAsync(async dbContext => await dbContext.Database.EnsureDeletedAsync());
177+
await RunOnDatabaseAsync(static async dbContext => await dbContext.Database.EnsureDeletedAsync());
153178
}
154179
finally
155180
{
156-
await _lazyFactory.Value.DisposeAsync();
181+
await Factory.DisposeAsync();
157182
}
158183
}
159184
}
160185
finally
161186
{
162-
await base.DisposeAsync();
187+
ReleaseDatabaseThrottle();
163188
}
164189
}
165190
}

test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public AtomicTransactionConsistencyTests(IntegrationTestContext<TestableStartup<
2828
services.AddResourceRepository<LyricRepository>();
2929

3030
string dbConnectionString =
31-
$"Host=localhost;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true";
31+
$"Host=localhost;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=600";
3232

3333
services.AddDbContext<ExtraDbContext>(options => options.UseNpgsql(dbConnectionString));
3434
});

test/OpenApiKiotaEndToEndTests/TestableHttpClientRequestAdapterFactory.cs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using JsonApiDotNetCore.OpenApi.Client.Kiota;
2-
using Microsoft.AspNetCore.Mvc.Testing;
32
using Microsoft.Kiota.Abstractions.Authentication;
43
using Microsoft.Kiota.Http.HttpClientLibrary;
54
using Microsoft.Kiota.Http.HttpClientLibrary.Middleware;
@@ -21,10 +20,9 @@ public TestableHttpClientRequestAdapterFactory(ITestOutputHelper testOutputHelpe
2120
_logHttpMessageHandler = new XUnitLogHttpMessageHandler(testOutputHelper);
2221
}
2322

24-
public HttpClientRequestAdapter CreateAdapter<TStartup>(WebApplicationFactory<TStartup> webApplicationFactory)
25-
where TStartup : class
23+
public HttpClientRequestAdapter CreateAdapter(FactoryBridge bridge)
2624
{
27-
ArgumentNullException.ThrowIfNull(webApplicationFactory);
25+
ArgumentNullException.ThrowIfNull(bridge);
2826

2927
DelegatingHandler[] handlers =
3028
[
@@ -33,7 +31,7 @@ public HttpClientRequestAdapter CreateAdapter<TStartup>(WebApplicationFactory<TS
3331
_logHttpMessageHandler
3432
];
3533

36-
HttpClient httpClient = webApplicationFactory.CreateDefaultClient(handlers);
34+
HttpClient httpClient = bridge.CreateDefaultClient(handlers);
3735
return new HttpClientRequestAdapter(new AnonymousAuthenticationProvider(), httpClient: httpClient);
3836
}
3937

test/OpenApiTests/OpenApiTestContext.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace OpenApiTests;
99

1010
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
1111
public class OpenApiTestContext<TStartup, TDbContext> : IntegrationTestContext<TStartup, TDbContext>
12-
where TStartup : class
12+
where TStartup : IStartup, new()
1313
where TDbContext : TestableDbContext
1414
{
1515
private readonly Lazy<Task<JsonElement>> _lazyDocument;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.TestHost;
3+
4+
namespace TestBuildingBlocks;
5+
6+
/// <summary>
7+
/// A temporary bridge to avoid changing all existing tests.
8+
/// </summary>
9+
public sealed class FactoryBridge
10+
{
11+
private readonly WebApplication _app;
12+
13+
public IServiceProvider Services => _app.Services;
14+
15+
internal FactoryBridge(WebApplication app)
16+
{
17+
ArgumentNullException.ThrowIfNull(app);
18+
19+
_app = app;
20+
}
21+
22+
public HttpClient CreateClient()
23+
{
24+
return CreateDefaultClient();
25+
}
26+
27+
public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
28+
{
29+
if (handlers.Length == 0)
30+
{
31+
return _app.GetTestClient();
32+
}
33+
34+
TestServer testServer = _app.GetTestServer();
35+
HttpMessageHandler serverHandler = testServer.CreateHandler();
36+
HttpClient httpClient = CreateHttpClient(serverHandler, handlers);
37+
38+
httpClient.BaseAddress ??= new Uri("http://localhost");
39+
return httpClient;
40+
}
41+
42+
private static HttpClient CreateHttpClient(HttpMessageHandler serverHandler, params DelegatingHandler[] handlers)
43+
{
44+
for (int index = handlers.Length - 1; index > 0; index--)
45+
{
46+
handlers[index - 1].InnerHandler = handlers[index];
47+
}
48+
49+
handlers[^1].InnerHandler = serverHandler;
50+
return new HttpClient(handlers[0]);
51+
}
52+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.Extensions.DependencyInjection;
3+
4+
namespace TestBuildingBlocks;
5+
6+
public interface IStartup
7+
{
8+
void ConfigureServices(IServiceCollection services);
9+
10+
void Configure(IApplicationBuilder app);
11+
}

test/TestBuildingBlocks/IntegrationTest.cs

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,47 @@
33
using System.Text.Json;
44
using FluentAssertions.Extensions;
55
using JsonApiDotNetCore.Middleware;
6-
using Xunit;
6+
using Microsoft.Extensions.DependencyInjection;
77

88
namespace TestBuildingBlocks;
99

1010
/// <summary>
11-
/// A base class for tests that conveniently enables executing HTTP requests against JSON:API endpoints. It throttles tests that are running in parallel
12-
/// to avoid exceeding the maximum active database connections.
11+
/// A base class for tests that conveniently enables executing HTTP requests against JSON:API endpoints.
1312
/// </summary>
14-
public abstract class IntegrationTest : IAsyncLifetime
13+
/// <remarks>
14+
/// Tests that use a database should call <see cref="AcquireDatabaseThrottleAsync" /> and <see cref="ReleaseDatabaseThrottle" /> to avoid exceeding the
15+
/// maximum active database connections.
16+
/// </remarks>
17+
public abstract class IntegrationTest
1518
{
1619
private static readonly MediaTypeHeaderValue DefaultMediaType = MediaTypeHeaderValue.Parse(JsonApiMediaType.Default.ToString());
1720

1821
private static readonly MediaTypeWithQualityHeaderValue OperationsMediaType =
1922
MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.AtomicOperations.ToString());
2023

21-
private static readonly SemaphoreSlim ThrottleSemaphore = GetDefaultThrottleSemaphore();
24+
private static readonly SemaphoreSlim DatabaseThrottleSemaphore = CreateDatabaseThrottleSemaphore();
25+
protected static readonly Action<ServiceProviderOptions> ConfigureServiceProvider = static options => options.ValidateScopes = true;
2226

2327
public static DateTimeOffset DefaultDateTimeUtc { get; } = 1.January(2020).At(1, 2, 3).AsUtc();
2428

2529
protected abstract JsonSerializerOptions SerializerOptions { get; }
2630

27-
private static SemaphoreSlim GetDefaultThrottleSemaphore()
31+
private static SemaphoreSlim CreateDatabaseThrottleSemaphore()
2832
{
2933
int maxConcurrentTestRuns = OperatingSystem.IsWindows() && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VSAPPIDDIR")) ? 32 : 64;
3034
return new SemaphoreSlim(maxConcurrentTestRuns);
3135
}
3236

37+
protected async Task AcquireDatabaseThrottleAsync()
38+
{
39+
await DatabaseThrottleSemaphore.WaitAsync();
40+
}
41+
42+
protected void ReleaseDatabaseThrottle()
43+
{
44+
DatabaseThrottleSemaphore.Release();
45+
}
46+
3347
public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteHeadAsync<TResponseDocument>(string requestUrl,
3448
Action<HttpRequestHeaders>? setRequestHeaders = null)
3549
{
@@ -104,8 +118,6 @@ private static SemaphoreSlim GetDefaultThrottleSemaphore()
104118
return requestBody == null ? null : requestBody as string ?? JsonSerializer.Serialize(requestBody, SerializerOptions);
105119
}
106120

107-
protected abstract HttpClient CreateClient();
108-
109121
private TResponseDocument? DeserializeResponse<TResponseDocument>(string responseText)
110122
{
111123
if (typeof(TResponseDocument) == typeof(string))
@@ -128,14 +140,5 @@ private static SemaphoreSlim GetDefaultThrottleSemaphore()
128140
}
129141
}
130142

131-
public async Task InitializeAsync()
132-
{
133-
await ThrottleSemaphore.WaitAsync();
134-
}
135-
136-
public virtual Task DisposeAsync()
137-
{
138-
_ = ThrottleSemaphore.Release();
139-
return Task.CompletedTask;
140-
}
143+
protected abstract HttpClient CreateClient();
141144
}

0 commit comments

Comments
 (0)