diff --git a/src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs b/src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs
index b98c6ee75f0..61ff8e88ac7 100644
--- a/src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs
+++ b/src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.Mvc.Testing.Handlers;
using Microsoft.Extensions.Http;
namespace TUnit.AspNetCore.Http;
@@ -40,4 +42,55 @@ internal static DelegatingHandler[] PrependPropagationHandlers(DelegatingHandler
Array.Copy(handlers, 0, all, 2, handlers.Length);
return all;
}
+
+ ///
+ /// Creates a client the way WebApplicationFactory.CreateClient(WebApplicationFactoryClientOptions)
+ /// does, with the TUnit propagation handlers added. must be the
+ /// factory's base CreateDefaultClient(DelegatingHandler[]), which also runs the factory's
+ /// ConfigureClient override.
+ ///
+ internal static HttpClient CreateClient(
+ Func createDefaultClient,
+ WebApplicationFactoryClientOptions options)
+ {
+ var client = createDefaultClient(CreateClientOptionsHandlers(options));
+ // Read BaseAddress after the client is created: starting a Kestrel-backed factory
+ // replaces ClientOptions.BaseAddress with the server's real address.
+ client.BaseAddress = options.BaseAddress;
+ return client;
+ }
+
+ ///
+ /// Returns the handlers that requests, followed by the
+ /// TUnit propagation handlers: a when
+ /// is set, then a
+ /// when
+ /// is set. Mirrors the internal WebApplicationFactoryClientOptions.CreateHandlers in
+ /// Microsoft.AspNetCore.Mvc.Testing 8.0 to 10.0; update this if a later version adds a handler.
+ ///
+ ///
+ /// The propagation handlers sit inside so that every redirect hop
+ /// gets freshly injected headers, not only the first request.
+ ///
+ internal static DelegatingHandler[] CreateClientOptionsHandlers(WebApplicationFactoryClientOptions options)
+ {
+ var count = 2 + (options.AllowAutoRedirect ? 1 : 0) + (options.HandleCookies ? 1 : 0);
+ var all = new DelegatingHandler[count];
+ var index = 0;
+
+ if (options.AllowAutoRedirect)
+ {
+ all[index++] = new RedirectHandler(options.MaxAutomaticRedirections);
+ }
+
+ if (options.HandleCookies)
+ {
+ all[index++] = new CookieContainerHandler();
+ }
+
+ all[index++] = new ActivityPropagationHandler();
+ all[index] = new TUnitTestIdHandler();
+
+ return all;
+ }
}
diff --git a/src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs b/src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
index aa20d3ae609..01d8aaf0a44 100644
--- a/src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
+++ b/src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
@@ -233,14 +233,20 @@ private static ServiceDescriptor WrapHostedServiceDescriptor(ServiceDescriptor d
}
///
- /// Creates an with automatic Activity tracing and test context propagation.
- /// Equivalent to calling with no additional handlers.
+ /// Creates an configured by ,
+ /// with automatic Activity tracing and test context propagation.
///
- public new HttpClient CreateClient()
- {
- var client = CreateDefaultClient();
- ConfigureClient(client);
- return client;
- }
+ public new HttpClient CreateClient() => CreateClient(ClientOptions);
+
+ ///
+ /// Creates an configured by , with automatic
+ /// Activity tracing and test context propagation. Honors
+ /// ,
+ /// ,
+ /// and
+ /// .
+ ///
+ public new HttpClient CreateClient(WebApplicationFactoryClientOptions options) =>
+ TUnitHttpClientFilter.CreateClient(base.CreateDefaultClient, options);
}
diff --git a/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs b/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs
index 6d549a3b393..782d23c13d9 100644
--- a/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs
+++ b/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs
@@ -39,10 +39,19 @@ public TracedWebApplicationFactory(WebApplicationFactory inner)
public IServiceProvider Services => _inner.Services;
///
- /// Creates an with activity tracing and test context propagation.
+ /// Creates an configured by the inner factory's
+ /// , with activity tracing and
+ /// test context propagation.
///
- public HttpClient CreateClient() =>
- _inner.CreateDefaultClient(TUnitHttpClientFilter.PrependPropagationHandlers([]));
+ public HttpClient CreateClient() => CreateClient(_inner.ClientOptions);
+
+ ///
+ /// Creates an configured by , with activity
+ /// tracing and test context propagation. Honors cookie handling, auto-redirect and base address
+ /// settings the same way as .
+ ///
+ public HttpClient CreateClient(WebApplicationFactoryClientOptions options) =>
+ TUnitHttpClientFilter.CreateClient(_inner.CreateDefaultClient, options);
///
/// Creates an with the specified delegating handlers, plus
diff --git a/tests/TUnit.AspNetCore.Tests.WebApp/Program.cs b/tests/TUnit.AspNetCore.Tests.WebApp/Program.cs
index 3e2f4e2fa7f..bdde333fe13 100644
--- a/tests/TUnit.AspNetCore.Tests.WebApp/Program.cs
+++ b/tests/TUnit.AspNetCore.Tests.WebApp/Program.cs
@@ -18,6 +18,25 @@
app.MapGet("/ping", () => "pong");
+// Cookie round-trip endpoints (thomhurst/TUnit#6921): the client must store the cookie
+// set by /cookie/set and send it back on /cookie/get when ClientOptions.HandleCookies is on.
+app.MapGet("/cookie/set/{value}", (string value, HttpContext context) =>
+{
+ context.Response.Cookies.Append("tunit-cookie", value);
+ return Results.Ok();
+});
+
+app.MapGet("/cookie/get", (HttpContext context) =>
+ Results.Text(context.Request.Cookies.TryGetValue("tunit-cookie", out var value) ? value : ""));
+
+app.MapGet("/redirect", () => Results.Redirect("/ping"));
+app.MapGet("/redirect-to-echo-headers", () => Results.Redirect("/echo-headers"));
+
+// Echoes the request headers the test client sent, so tests can assert that TUnit's
+// propagation headers are still emitted by option-configured clients.
+app.MapGet("/echo-headers", (HttpContext context) =>
+ Results.Text(string.Join("\n", context.Request.Headers.Select(h => $"{h.Key}: {h.Value}"))));
+
// Outbound call through IHttpClientFactory. The downstream pipeline's primary
// handler echoes request headers back in the response body so tests can assert
// which headers the SUT-side HttpClient actually emitted.
diff --git a/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs b/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs
new file mode 100644
index 00000000000..96e4bdea194
--- /dev/null
+++ b/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs
@@ -0,0 +1,198 @@
+using System.Net;
+using Microsoft.AspNetCore.Mvc.Testing;
+using TUnit.AspNetCore;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore.Tests;
+
+///
+/// Regression coverage for thomhurst/TUnit#6921: clients created by
+/// must honor
+/// (cookies, redirects, base address)
+/// while still carrying TUnit's propagation headers.
+///
+public class ClientOptionsTests
+{
+ [ClassDataSource(Shared = [SharedType.PerTestSession])]
+ public TestWebAppFactory Factory { get; set; } = null!;
+
+ [Test]
+ public async Task CreateClient_HandlesCookies_ByDefault()
+ {
+ using var client = Factory.CreateClient();
+
+ await AssertCookieRoundTrip(client, expected: "abc");
+ }
+
+ [Test]
+ public async Task CreateClient_FollowsRedirects_ByDefault()
+ {
+ using var client = Factory.CreateClient();
+
+ var response = await client.GetAsync("/redirect");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ await Assert.That(await response.Content.ReadAsStringAsync()).IsEqualTo("pong");
+ }
+
+ [Test]
+ public async Task CreateClient_WithOptions_CanDisableCookiesAndRedirects()
+ {
+ using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ HandleCookies = false,
+ AllowAutoRedirect = false,
+ });
+
+ await AssertCookieRoundTrip(client, expected: "");
+
+ var response = await client.GetAsync("/redirect");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Redirect);
+ }
+
+ [Test]
+ public async Task CreateClient_WithOptions_UsesBaseAddress()
+ {
+ var baseAddress = new Uri("http://tunit.test/");
+
+ using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = baseAddress });
+
+ await Assert.That(client.BaseAddress).IsEqualTo(baseAddress);
+ }
+
+ [Test]
+ public async Task CreateClient_WithOptions_KeepsPropagationHeaders()
+ {
+ using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions());
+
+ var echoed = await client.GetStringAsync("/echo-headers");
+
+ await Assert.That(echoed).Contains(TUnitTestIdHandler.HeaderName + ": " + TestContext.Current!.Id);
+ }
+
+ [Test]
+ public async Task CreateClient_Redirect_KeepsPropagationHeadersOnRedirectedRequest()
+ {
+ using var client = Factory.CreateClient();
+
+ await AssertRedirectedRequestHasTestIdHeader(client);
+ }
+
+ internal static async Task AssertRedirectedRequestHasTestIdHeader(HttpClient client)
+ {
+ var response = await client.GetAsync("/redirect-to-echo-headers");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var lines = (await response.Content.ReadAsStringAsync()).Split('\n');
+
+ // Exact line match proves a single value: /echo-headers joins repeated values with
+ // commas, so a header duplicated across redirect hops would not match this line.
+ await Assert.That(lines).Contains(TUnitTestIdHandler.HeaderName + ": " + TestContext.Current!.Id);
+ }
+
+ internal static async Task AssertCookieRoundTrip(HttpClient client, string expected)
+ {
+ var set = await client.GetAsync("/cookie/set/abc");
+ set.EnsureSuccessStatusCode();
+
+ var value = await client.GetStringAsync("/cookie/get");
+
+ await Assert.That(value).IsEqualTo(expected);
+ }
+}
+
+public class WebApplicationTestClientOptionsTests : WebApplicationTest
+{
+ [Test]
+ public async Task CreateClient_HandlesCookies_ByDefault()
+ {
+ using var client = Factory.CreateClient();
+
+ await ClientOptionsTests.AssertCookieRoundTrip(client, expected: "abc");
+ }
+
+ [Test]
+ public async Task CreateClient_FollowsRedirects_ByDefault()
+ {
+ using var client = Factory.CreateClient();
+
+ var response = await client.GetAsync("/redirect");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ await Assert.That(await response.Content.ReadAsStringAsync()).IsEqualTo("pong");
+ }
+
+ [Test]
+ public async Task CreateClient_Redirect_KeepsPropagationHeadersOnRedirectedRequest()
+ {
+ using var client = Factory.CreateClient();
+
+ await ClientOptionsTests.AssertRedirectedRequestHasTestIdHeader(client);
+ }
+
+ [Test]
+ public async Task CreateClient_WithOptions_CanDisableCookiesAndRedirects()
+ {
+ using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ HandleCookies = false,
+ AllowAutoRedirect = false,
+ });
+
+ await ClientOptionsTests.AssertCookieRoundTrip(client, expected: "");
+
+ var response = await client.GetAsync("/redirect");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Redirect);
+ }
+
+ [Test]
+ public async Task CreateClient_WithOptions_UsesBaseAddress()
+ {
+ var baseAddress = new Uri("http://tunit.test/");
+
+ using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = baseAddress });
+
+ await Assert.That(client.BaseAddress).IsEqualTo(baseAddress);
+ }
+
+ [Test]
+ public async Task CreateClient_WithOptions_KeepsPropagationHeaders()
+ {
+ using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions());
+
+ var echoed = await client.GetStringAsync("/echo-headers");
+
+ await Assert.That(echoed).Contains(TUnitTestIdHandler.HeaderName + ": " + TestContext.Current!.Id);
+ }
+}
+
+///
+/// A ConfigureClient override must still apply to clients created through the
+/// option-aware CreateClient overloads.
+///
+public class ConfigureClientOverrideTests
+{
+ [ClassDataSource(Shared = [SharedType.PerTestSession])]
+ public ConfigureClientWebAppFactory Factory { get; set; } = null!;
+
+ [Test]
+ public async Task CreateClient_RunsConfigureClientOverride()
+ {
+ using var client = Factory.CreateClient();
+
+ var echoed = await client.GetStringAsync("/echo-headers");
+
+ await Assert.That(echoed).Contains(ConfigureClientWebAppFactory.HeaderName + ": yes");
+ }
+}
+
+public class ConfigureClientWebAppFactory : TestWebAppFactory
+{
+ public const string HeaderName = "X-Configured-By-Override";
+
+ protected override void ConfigureClient(HttpClient client)
+ {
+ base.ConfigureClient(client);
+ client.DefaultRequestHeaders.Add(HeaderName, "yes");
+ }
+}