Skip to content

Commit 80585d8

Browse files
tests: migrate integration tests to TUnit.AspNetCore WebApplicationTest (#1100)
## Summary Migrates `EssentialCSharp.Web.Tests` to `TUnit.AspNetCore` using `WebApplicationTest<WebApplicationFactory, Program>` and `TestWebApplicationFactory<Program>`. ## What changed - Added `TUnit.AspNetCore` package. - Introduced `IntegrationTestBase` with shared `InServiceScope` helpers. - Migrated integration-style test classes off `[ClassDataSource]` / constructor factory injection to `IntegrationTestBase` inheritance. - Consolidated MCP rate-limiting tests into one class while keeping per-test host isolation. - `McpTestHelper` now accepts `TracedWebApplicationFactory<Program>` and creates clients with explicit `AllowAutoRedirect = false` where redirect assertions require it. ## Current implementation details - `WebApplicationFactory` stores a per-instance GUID-based SQLite connection string. A `_keepAliveConnection` is held open for the factory lifetime to keep the shared-cache in-memory database alive. `DbConnection` is registered as **scoped** so each request scope gets its own `SqliteConnection`, avoiding concurrent-access locking from sharing a single connection instance. - Test schema creation runs through `EnsureCreatedHostedService`, registered at index `0` in the service collection so hosted-service startup runs schema creation first. - Schema creation is serialized per factory instance to prevent concurrent `EnsureCreatedAsync()` races in CI. - Functional tests use `GetFollowingGetRedirectsAsync` (GET-only redirect helper) and non-auto-redirect clients where status/location assertions depend on raw redirect responses. ## Notes - This PR no longer uses `ConcurrentBag<SqliteConnection>`, `Interlocked` disposal guards, or `CreateRedirectFollowingClient()`. - Build compiles cleanly after these updates.
1 parent 283640f commit 80585d8

14 files changed

Lines changed: 274 additions & 216 deletions

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
<!-- TODO: update to stable release when Azure.Monitor.OpenTelemetry.Profiler reaches GA -->
2323
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Profiler" Version="1.0.1-beta.1" />
2424
<PackageVersion Include="TUnit" Version="1.40.5" />
25+
<PackageVersion Include="TUnit.AspNetCore" Version="1.40.5" />
2526
<PackageVersion Include="EssentialCSharp.Shared.Models" Version="$(ToolingPackagesVersion)" />
2627
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
2728
<PackageVersion Include="IntelliTect.Multitool" Version="2.1.0" />

EssentialCSharp.Web.Tests/ContentRateLimitingTests.cs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,18 @@
11
using System.Net;
2-
using Microsoft.AspNetCore.Mvc.Testing;
32

43
namespace EssentialCSharp.Web.Tests;
54

65
/// <summary>
76
/// HTTP integration tests for the "content" rate limit policy.
8-
/// Uses its own factory (PerClass) to get a fresh in-memory rate limiter for each run.
7+
/// Each test gets its own factory (fresh IHost) so the rate limiter starts from a clean state.
98
/// Anonymous users are limited to 10 requests per minute on chapter content pages.
109
/// </summary>
11-
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerClass)]
12-
public class ContentRateLimitingTests(WebApplicationFactory factory)
10+
public class ContentRateLimitingTests : IntegrationTestBase
1311
{
1412
[Test]
1513
public async Task ContentEndpoint_ExceedingPerMinuteLimit_Returns429()
1614
{
17-
// AllowAutoRedirect = false prevents redirect-following from consuming extra permits.
18-
HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions
19-
{
20-
AllowAutoRedirect = false
21-
});
15+
using HttpClient client = CreateClientWithoutAutoRedirect();
2216

2317
// Anonymous limit is 10/min. First 10 requests should not be rate-limited.
2418
for (int i = 0; i < 10; i++)

EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<PackageReference Include="Moq" />
1414
<PackageReference Include="Moq.AutoMock" />
1515
<PackageReference Include="TUnit" />
16+
<PackageReference Include="TUnit.AspNetCore" />
1617
<PackageReference Include="Newtonsoft.Json" />
1718
</ItemGroup>
1819

EssentialCSharp.Web.Tests/FunctionalTests.cs

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22

33
namespace EssentialCSharp.Web.Tests;
44

5-
[NotInParallel("FunctionalTests")]
6-
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerClass)]
7-
public class FunctionalTests(WebApplicationFactory factory)
5+
public class FunctionalTests : IntegrationTestBase
86
{
97
[Test]
108
[Arguments("/")]
@@ -15,8 +13,8 @@ public class FunctionalTests(WebApplicationFactory factory)
1513
[Arguments("/alive")]
1614
public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl)
1715
{
18-
HttpClient client = factory.CreateClient();
19-
using HttpResponseMessage response = await client.GetAsync(relativeUrl);
16+
using HttpClient client = CreateClientWithoutAutoRedirect();
17+
using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, relativeUrl);
2018

2119
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
2220
}
@@ -31,8 +29,8 @@ public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl
3129
[Arguments("/about?someOtherParam=value")]
3230
public async Task WhenPagesAreAccessed_TheyReturnHtml(string relativeUrl)
3331
{
34-
HttpClient client = factory.CreateClient();
35-
using HttpResponseMessage response = await client.GetAsync(relativeUrl);
32+
using HttpClient client = CreateClientWithoutAutoRedirect();
33+
using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, relativeUrl);
3634

3735
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
3836

@@ -47,8 +45,8 @@ public async Task WhenPagesAreAccessed_TheyReturnHtml(string relativeUrl)
4745
[Test]
4846
public async Task WhenTheApplicationStarts_NonExistingPage_GivesCorrectStatusCode()
4947
{
50-
HttpClient client = factory.CreateClient();
51-
using HttpResponseMessage response = await client.GetAsync("/non-existing-page1234");
48+
using HttpClient client = CreateClientWithoutAutoRedirect();
49+
using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, "/non-existing-page1234");
5250

5351
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
5452

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using System.Net;
2+
using Microsoft.AspNetCore.Mvc.Testing;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using TUnit.AspNetCore;
5+
6+
namespace EssentialCSharp.Web.Tests;
7+
8+
public abstract class IntegrationTestBase : WebApplicationTest<WebApplicationFactory, Program>
9+
{
10+
/// <summary>
11+
/// Creates an <see cref="HttpClient"/> with <c>AllowAutoRedirect = false</c> so callers can
12+
/// assert exact redirect status codes and <c>Location</c> headers without the client
13+
/// silently following them.
14+
/// </summary>
15+
protected HttpClient CreateClientWithoutAutoRedirect() =>
16+
Factory.Inner.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
17+
18+
/// <summary>
19+
/// Executes an initial GET and follows redirect responses with subsequent GET requests.
20+
/// This helper is intentionally GET-only.
21+
/// </summary>
22+
protected static async Task<HttpResponseMessage> GetFollowingGetRedirectsAsync(
23+
HttpClient client,
24+
string relativeUrl,
25+
int maxRedirects = 10)
26+
{
27+
HttpResponseMessage response = await client.GetAsync(relativeUrl);
28+
29+
for (int redirectCount = 0;
30+
redirectCount < maxRedirects && IsRedirectStatusCode(response.StatusCode);
31+
redirectCount++)
32+
{
33+
Uri? location = response.Headers.Location;
34+
if (location is null)
35+
{
36+
return response;
37+
}
38+
39+
response.Dispose();
40+
41+
response = await client.GetAsync(location);
42+
}
43+
44+
if (IsRedirectStatusCode(response.StatusCode))
45+
{
46+
response.Dispose();
47+
throw new InvalidOperationException(
48+
$"Exceeded redirect limit ({maxRedirects}) for '{relativeUrl}'. Last status: {(int)response.StatusCode} {response.StatusCode}.");
49+
}
50+
51+
return response;
52+
}
53+
54+
private static bool IsRedirectStatusCode(HttpStatusCode statusCode) =>
55+
statusCode == HttpStatusCode.Moved ||
56+
statusCode == HttpStatusCode.Found ||
57+
statusCode == HttpStatusCode.RedirectMethod ||
58+
statusCode == HttpStatusCode.TemporaryRedirect ||
59+
statusCode == HttpStatusCode.PermanentRedirect;
60+
61+
protected T InServiceScope<T>(Func<IServiceProvider, T> action)
62+
{
63+
using IServiceScope scope = Factory.Services.GetRequiredService<IServiceScopeFactory>().CreateScope();
64+
return action(scope.ServiceProvider);
65+
}
66+
67+
protected void InServiceScope(Action<IServiceProvider> action)
68+
{
69+
using IServiceScope scope = Factory.Services.GetRequiredService<IServiceScopeFactory>().CreateScope();
70+
action(scope.ServiceProvider);
71+
}
72+
73+
protected async Task<T> InServiceScopeAsync<T>(Func<IServiceProvider, Task<T>> action)
74+
{
75+
using IServiceScope scope = Factory.Services.GetRequiredService<IServiceScopeFactory>().CreateScope();
76+
return await action(scope.ServiceProvider);
77+
}
78+
79+
protected async Task InServiceScopeAsync(Func<IServiceProvider, Task> action)
80+
{
81+
using IServiceScope scope = Factory.Services.GetRequiredService<IServiceScopeFactory>().CreateScope();
82+
await action(scope.ServiceProvider);
83+
}
84+
}

EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@
44

55
namespace EssentialCSharp.Web.Tests;
66

7-
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerClass)]
8-
public class ListingSourceCodeControllerTests(WebApplicationFactory factory)
7+
public class ListingSourceCodeControllerTests : IntegrationTestBase
98
{
109
[Test]
1110
public async Task GetListing_WithValidChapterAndListing_Returns200WithContent()
1211
{
1312
// Arrange
14-
HttpClient client = factory.CreateClient();
13+
HttpClient client = Factory.CreateClient();
1514

1615
// Act
1716
using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1/listing/1");
@@ -35,7 +34,7 @@ public async Task GetListing_WithValidChapterAndListing_Returns200WithContent()
3534
public async Task GetListing_WithInvalidChapter_Returns404()
3635
{
3736
// Arrange
38-
HttpClient client = factory.CreateClient();
37+
HttpClient client = Factory.CreateClient();
3938

4039
// Act
4140
using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/999/listing/1");
@@ -48,7 +47,7 @@ public async Task GetListing_WithInvalidChapter_Returns404()
4847
public async Task GetListing_WithInvalidListing_Returns404()
4948
{
5049
// Arrange
51-
HttpClient client = factory.CreateClient();
50+
HttpClient client = Factory.CreateClient();
5251

5352
// Act
5453
using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1/listing/999");
@@ -61,7 +60,7 @@ public async Task GetListing_WithInvalidListing_Returns404()
6160
public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings()
6261
{
6362
// Arrange
64-
HttpClient client = factory.CreateClient();
63+
HttpClient client = Factory.CreateClient();
6564

6665
// Act
6766
using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1");
@@ -92,7 +91,7 @@ public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings(
9291
public async Task GetListingsByChapter_WithInvalidChapter_ReturnsEmptyList()
9392
{
9493
// Arrange
95-
HttpClient client = factory.CreateClient();
94+
HttpClient client = Factory.CreateClient();
9695

9796
// Act
9897
using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/999");

EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
using EssentialCSharp.Web.Models;
22
using EssentialCSharp.Web.Services;
3-
using Microsoft.AspNetCore.Mvc.Testing;
43
using Microsoft.Extensions.DependencyInjection;
54

65
namespace EssentialCSharp.Web.Tests;
76

8-
[NotInParallel("McpTests")]
9-
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerClass)]
10-
public class McpApiTokenServiceTests(WebApplicationFactory factory)
7+
public class McpApiTokenServiceTests : IntegrationTestBase
118
{
129
private readonly List<IServiceScope> _scopes = [];
1310

@@ -21,16 +18,16 @@ public void DisposeScopes()
2118

2219
private async Task<(string UserId, McpApiTokenService TokenService)> ArrangeAsync(string prefix)
2320
{
24-
string userId = await McpTestHelper.CreateUserAsync(factory, prefix);
25-
var scope = factory.Services.CreateScope();
21+
string userId = await McpTestHelper.CreateUserAsync(Factory, prefix);
22+
var scope = Factory.Services.CreateScope();
2623
_scopes.Add(scope);
2724
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
2825
return (userId, tokenService);
2926
}
3027

3128
private async Task<McpApiTokenService> FillToLimitAsync(string userId)
3229
{
33-
var scope = factory.Services.CreateScope();
30+
var scope = Factory.Services.CreateScope();
3431
_scopes.Add(scope);
3532
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
3633
for (int i = 0; i < McpApiTokenService.MaxTokensPerUser; i++)

0 commit comments

Comments
 (0)