diff --git a/Directory.Packages.props b/Directory.Packages.props
index 11f44f281e..e8437addf0 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -51,6 +51,7 @@
+
@@ -63,6 +64,7 @@
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/TUnit.Analyzers.Tests/ClassDataSourceConstructorAnalyzerTests.cs b/TUnit.Analyzers.Tests/ClassDataSourceConstructorAnalyzerTests.cs
new file mode 100644
index 0000000000..9b536bb7e1
--- /dev/null
+++ b/TUnit.Analyzers.Tests/ClassDataSourceConstructorAnalyzerTests.cs
@@ -0,0 +1,410 @@
+using Verifier = TUnit.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier;
+
+namespace TUnit.Analyzers.Tests;
+
+public class ClassDataSourceConstructorAnalyzerTests
+{
+ [Test]
+ public async Task No_Error_When_Type_Has_Parameterless_Constructor()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ public MyData() { }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Type_Has_Internal_Parameterless_Constructor()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ internal MyData() { }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Type_Is_Struct()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public struct MyData
+ {
+ public int Value { get; set; }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Type_Is_Record_With_Parameterless_Constructor()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public record MyData();
+ """
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Type_Has_Implicit_Parameterless_Constructor()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ public string Value { get; set; } = "";
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Type_Has_Only_Parameterized_Constructor()
+ {
+ var expected = Verifier.Diagnostic(Rules.NoAccessibleConstructor)
+ .WithLocation(0)
+ .WithArguments("MyData");
+
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [{|#0:ClassDataSource|}]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ public MyData(string value) { }
+ }
+ """,
+ expected
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Type_Has_Private_Parameterless_Constructor()
+ {
+ var expected = Verifier.Diagnostic(Rules.NoAccessibleConstructor)
+ .WithLocation(0)
+ .WithArguments("MyData");
+
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [{|#0:ClassDataSource|}]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ private MyData() { }
+ public MyData(string value) { }
+ }
+ """,
+ expected
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Record_Has_Required_Parameters()
+ {
+ var expected = Verifier.Diagnostic(Rules.NoAccessibleConstructor)
+ .WithLocation(0)
+ .WithArguments("MyData");
+
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [{|#0:ClassDataSource|}]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public record MyData(string Value);
+ """,
+ expected
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Used_On_Method_Parameter()
+ {
+ var expected = Verifier.Diagnostic(Rules.NoAccessibleConstructor)
+ .WithLocation(0)
+ .WithArguments("MyData");
+
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [{|#0:ClassDataSource|}]
+ [Test]
+ public void MyTest(MyData data)
+ {
+ }
+ }
+
+ public class MyData
+ {
+ public MyData(string value) { }
+ }
+ """,
+ expected
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Used_On_Class()
+ {
+ var expected = Verifier.Diagnostic(Rules.NoAccessibleConstructor)
+ .WithLocation(0)
+ .WithArguments("MyData");
+
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ [{|#0:ClassDataSource|}]
+ public class MyClass
+ {
+ public MyClass(MyData data) { }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ public MyData(string value) { }
+ }
+ """,
+ expected
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Type_Is_Abstract()
+ {
+ // Abstract types can't be instantiated anyway, so we don't report an error
+ // The user likely intends to use a derived type
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public abstract class MyData
+ {
+ protected MyData(string value) { }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Protected_Internal_Constructor()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ protected internal MyData() { }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Protected_Constructor_Only()
+ {
+ var expected = Verifier.Diagnostic(Rules.NoAccessibleConstructor)
+ .WithLocation(0)
+ .WithArguments("MyData");
+
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [{|#0:ClassDataSource|}]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ protected MyData() { }
+ }
+ """,
+ expected
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Type_Has_Both_Parameterless_And_Parameterized_Constructors()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ public MyData() { }
+ public MyData(string value) { }
+ }
+ """
+ );
+ }
+}
diff --git a/TUnit.Analyzers/AnalyzerReleases.Unshipped.md b/TUnit.Analyzers/AnalyzerReleases.Unshipped.md
index c52332fd49..31a6ee8fe2 100644
--- a/TUnit.Analyzers/AnalyzerReleases.Unshipped.md
+++ b/TUnit.Analyzers/AnalyzerReleases.Unshipped.md
@@ -1,4 +1,11 @@
### New Rules
Rule ID | Category | Severity | Notes
---------|----------|----------|-------
\ No newline at end of file
+--------|----------|----------|-------
+TUnit0061 | Usage | Error | ClassDataSource type requires parameterless constructor
+
+### Removed Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
+TUnit0043 | Usage | Error | Changed to Info severity (now a suggestion instead of error)
\ No newline at end of file
diff --git a/TUnit.Analyzers/ClassDataSourceConstructorAnalyzer.cs b/TUnit.Analyzers/ClassDataSourceConstructorAnalyzer.cs
new file mode 100644
index 0000000000..07c03b20bd
--- /dev/null
+++ b/TUnit.Analyzers/ClassDataSourceConstructorAnalyzer.cs
@@ -0,0 +1,148 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using TUnit.Analyzers.Extensions;
+using TUnit.Analyzers.Helpers;
+
+namespace TUnit.Analyzers;
+
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class ClassDataSourceConstructorAnalyzer : ConcurrentDiagnosticAnalyzer
+{
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Rules.NoAccessibleConstructor);
+
+ protected override void InitializeInternal(AnalysisContext context)
+ {
+ context.RegisterSymbolAction(AnalyzeProperty, SymbolKind.Property);
+ context.RegisterSymbolAction(AnalyzeMethod, SymbolKind.Method);
+ context.RegisterSymbolAction(AnalyzeClass, SymbolKind.NamedType);
+ }
+
+ private void AnalyzeProperty(SymbolAnalysisContext context)
+ {
+ if (context.Symbol is not IPropertySymbol propertySymbol)
+ {
+ return;
+ }
+
+ foreach (var attribute in propertySymbol.GetAttributes())
+ {
+ CheckClassDataSourceAttribute(context, attribute);
+ }
+ }
+
+ private void AnalyzeMethod(SymbolAnalysisContext context)
+ {
+ if (context.Symbol is not IMethodSymbol methodSymbol)
+ {
+ return;
+ }
+
+ // Check method-level attributes
+ foreach (var attribute in methodSymbol.GetAttributes())
+ {
+ CheckClassDataSourceAttribute(context, attribute);
+ }
+
+ // Check parameter-level attributes
+ foreach (var parameter in methodSymbol.Parameters)
+ {
+ foreach (var attribute in parameter.GetAttributes())
+ {
+ CheckClassDataSourceAttribute(context, attribute);
+ }
+ }
+ }
+
+ private void AnalyzeClass(SymbolAnalysisContext context)
+ {
+ if (context.Symbol is not INamedTypeSymbol namedTypeSymbol)
+ {
+ return;
+ }
+
+ foreach (var attribute in namedTypeSymbol.GetAttributes())
+ {
+ CheckClassDataSourceAttribute(context, attribute);
+ }
+ }
+
+ private void CheckClassDataSourceAttribute(SymbolAnalysisContext context, AttributeData attribute)
+ {
+ if (attribute.AttributeClass is null)
+ {
+ return;
+ }
+
+ // Check if this is ClassDataSourceAttribute
+ var attributeClassName = attribute.AttributeClass.Name;
+ var attributeFullName = attribute.AttributeClass.ToDisplayString();
+
+ if (!attributeClassName.StartsWith("ClassDataSourceAttribute") ||
+ !attributeFullName.StartsWith("TUnit.Core.ClassDataSourceAttribute<"))
+ {
+ return;
+ }
+
+ // Get the type argument T from ClassDataSource
+ if (attribute.AttributeClass is not INamedTypeSymbol { IsGenericType: true, TypeArguments.Length: > 0 } genericAttribute)
+ {
+ return;
+ }
+
+ var dataSourceType = genericAttribute.TypeArguments[0];
+
+ // Skip if the type is abstract - it can't be instantiated directly anyway
+ if (dataSourceType is INamedTypeSymbol { IsAbstract: true })
+ {
+ return;
+ }
+
+ // Skip type parameters - they can't be validated at compile time
+ if (dataSourceType is ITypeParameterSymbol)
+ {
+ return;
+ }
+
+ if (dataSourceType is not INamedTypeSymbol namedType)
+ {
+ return;
+ }
+
+ // Check if there's an accessible parameterless constructor
+ if (!HasAccessibleParameterlessConstructor(namedType, context.Compilation))
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ Rules.NoAccessibleConstructor,
+ attribute.GetLocation() ?? context.Symbol.Locations.FirstOrDefault(),
+ namedType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
+ }
+ }
+
+ private static bool HasAccessibleParameterlessConstructor(INamedTypeSymbol type, Compilation compilation)
+ {
+ // For structs, there's always an implicit parameterless constructor
+ if (type.IsValueType)
+ {
+ return true;
+ }
+
+ // If there are no explicit constructors, the compiler generates a public parameterless constructor
+ var hasAnyExplicitConstructor = type.InstanceConstructors
+ .Any(c => !c.IsImplicitlyDeclared);
+
+ if (!hasAnyExplicitConstructor)
+ {
+ return true;
+ }
+
+ // Check for an explicit accessible parameterless constructor
+ return type.InstanceConstructors
+ .Where(c => c.Parameters.Length == 0)
+ .Any(c => c.DeclaredAccessibility is Accessibility.Public
+ or Accessibility.Internal
+ or Accessibility.ProtectedOrInternal);
+ }
+}
diff --git a/TUnit.Analyzers/Resources.resx b/TUnit.Analyzers/Resources.resx
index 4482dd6a1e..ba705c6075 100644
--- a/TUnit.Analyzers/Resources.resx
+++ b/TUnit.Analyzers/Resources.resx
@@ -462,6 +462,15 @@
Data source may produce no tests
+
+ ClassDataSource<T> requires that type T has an accessible parameterless constructor. Add a public or internal parameterless constructor to the type, or use IAsyncInitializer for initialization logic.
+
+
+ Type '{0}' does not have an accessible parameterless constructor required by ClassDataSource<T>
+
+
+ ClassDataSource type requires parameterless constructor
+
When parameters have data source attributes, the method or class must be marked with [CombinedDataSources] to combine the parameter data sources.
diff --git a/TUnit.Analyzers/Rules.cs b/TUnit.Analyzers/Rules.cs
index c95fa52291..ae19286889 100644
--- a/TUnit.Analyzers/Rules.cs
+++ b/TUnit.Analyzers/Rules.cs
@@ -97,7 +97,7 @@ public static class Rules
CreateDescriptor("TUnit0042", UsageCategory, DiagnosticSeverity.Warning);
public static readonly DiagnosticDescriptor PropertyRequiredNotSet =
- CreateDescriptor("TUnit0043", UsageCategory, DiagnosticSeverity.Error);
+ CreateDescriptor("TUnit0043", UsageCategory, DiagnosticSeverity.Info);
public static readonly DiagnosticDescriptor MustHavePropertySetter =
CreateDescriptor("TUnit0044", UsageCategory, DiagnosticSeverity.Error);
@@ -162,6 +162,9 @@ public static class Rules
public static readonly DiagnosticDescriptor PotentialEmptyDataSource =
CreateDescriptor("TUnit0060", UsageCategory, DiagnosticSeverity.Info);
+ public static readonly DiagnosticDescriptor NoAccessibleConstructor =
+ CreateDescriptor("TUnit0061", UsageCategory, DiagnosticSeverity.Error);
+
public static readonly DiagnosticDescriptor GenericTypeNotAotCompatible =
CreateDescriptor("TUnit0300", UsageCategory, DiagnosticSeverity.Warning);
diff --git a/TUnit.AspNetCore/Extensions/LoggingExtensions.cs b/TUnit.AspNetCore/Extensions/LoggingExtensions.cs
new file mode 100644
index 0000000000..d0ecfac1cf
--- /dev/null
+++ b/TUnit.AspNetCore/Extensions/LoggingExtensions.cs
@@ -0,0 +1,28 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using TUnit.AspNetCore.Logging;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore.Extensions;
+
+///
+/// Extension methods for to simplify service replacement in tests.
+///
+public static class LoggingExtensions
+{
+ ///
+ /// Adds the TUnit logger provider to the logging builder with a specific context provider.
+ ///
+ /// The logging builder.
+ /// The test context.
+ /// The minimum log level to capture. Defaults to Information.
+ /// The logging builder for chaining.
+ public static ILoggingBuilder AddTUnit(
+ this ILoggingBuilder builder,
+ TestContext context,
+ LogLevel minLogLevel = LogLevel.Information)
+ {
+ builder.AddProvider(new TUnitLoggerProvider(context, minLogLevel));
+ return builder;
+ }
+}
diff --git a/TUnit.AspNetCore/Extensions/ServiceCollectionExtensions.cs b/TUnit.AspNetCore/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..b163fbbc9e
--- /dev/null
+++ b/TUnit.AspNetCore/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,120 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore.Extensions;
+
+///
+/// Extension methods for to simplify service replacement in tests.
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Replaces all registrations of with the specified instance.
+ ///
+ /// The service type to replace.
+ /// The service collection.
+ /// The instance to use for the service.
+ /// The service collection for chaining.
+ ///
+ ///
+ /// services.ReplaceService<IEmailService>(new FakeEmailService());
+ ///
+ ///
+ public static IServiceCollection ReplaceService(
+ this IServiceCollection services,
+ TService instance)
+ where TService : class
+ {
+ services.RemoveAll();
+ services.AddSingleton(instance);
+ return services;
+ }
+
+ ///
+ /// Replaces all registrations of with the specified factory.
+ ///
+ /// The service type to replace.
+ /// The service collection.
+ /// The factory to create the service instance.
+ /// The lifetime of the service. Defaults to .
+ /// The service collection for chaining.
+ ///
+ ///
+ /// services.ReplaceService<IEmailService>(
+ /// sp => new FakeEmailService(sp.GetRequiredService<ILogger>()));
+ ///
+ ///
+ public static IServiceCollection ReplaceService(
+ this IServiceCollection services,
+ Func factory,
+ ServiceLifetime lifetime = ServiceLifetime.Singleton)
+ where TService : class
+ {
+ services.RemoveAll();
+
+ var descriptor = new ServiceDescriptor(typeof(TService), factory, lifetime);
+ services.Add(descriptor);
+
+ return services;
+ }
+
+ ///
+ /// Replaces all registrations of with .
+ ///
+ /// The service type to replace.
+ /// The implementation type to use.
+ /// The service collection.
+ /// The lifetime of the service. Defaults to .
+ /// The service collection for chaining.
+ ///
+ ///
+ /// services.ReplaceService<IEmailService, FakeEmailService>();
+ ///
+ ///
+ public static IServiceCollection ReplaceService(
+ this IServiceCollection services,
+ ServiceLifetime lifetime = ServiceLifetime.Singleton)
+ where TService : class
+ where TImplementation : class, TService
+ {
+ services.RemoveAll();
+
+ var descriptor = new ServiceDescriptor(typeof(TService), typeof(TImplementation), lifetime);
+ services.Add(descriptor);
+
+ return services;
+ }
+
+ ///
+ /// Removes all registrations of .
+ ///
+ /// The service type to remove.
+ /// The service collection.
+ /// The service collection for chaining.
+ public static IServiceCollection RemoveService(this IServiceCollection services)
+ where TService : class
+ {
+ services.RemoveAll();
+ return services;
+ }
+
+ ///
+ /// Adds TUnit logging to the service collection with a specific test context.
+ /// Use this overload when you need to capture logs for a specific test context.
+ ///
+ /// The service collection.
+ /// A function that returns the test context.
+ /// The minimum log level to capture. Defaults to Information.
+ /// The service collection for chaining.
+ public static IServiceCollection AddTUnitLogging(
+ this IServiceCollection services,
+ TestContext context,
+ LogLevel minLogLevel = LogLevel.Information)
+ {
+ services.AddLogging(builder => LoggingExtensions.AddTUnit(builder, context, minLogLevel));
+ return services;
+ }
+}
diff --git a/TUnit.AspNetCore/Interception/CapturedHttpExchange.cs b/TUnit.AspNetCore/Interception/CapturedHttpExchange.cs
new file mode 100644
index 0000000000..b83817c4b2
--- /dev/null
+++ b/TUnit.AspNetCore/Interception/CapturedHttpExchange.cs
@@ -0,0 +1,116 @@
+using System.Net;
+
+namespace TUnit.AspNetCore.Interception;
+
+///
+/// Represents a captured HTTP request/response exchange.
+///
+public sealed class CapturedHttpExchange
+{
+ ///
+ /// Gets the unique identifier for this exchange.
+ ///
+ public Guid Id { get; } = Guid.NewGuid();
+
+ ///
+ /// Gets the timestamp when the request was received.
+ ///
+ public DateTimeOffset Timestamp { get; init; }
+
+ ///
+ /// Gets the duration of the request processing.
+ ///
+ public TimeSpan Duration { get; init; }
+
+ ///
+ /// Gets the captured request details.
+ ///
+ public required CapturedRequest Request { get; init; }
+
+ ///
+ /// Gets the captured response details.
+ ///
+ public required CapturedResponse Response { get; init; }
+}
+
+///
+/// Represents a captured HTTP request.
+///
+public sealed class CapturedRequest
+{
+ ///
+ /// Gets the HTTP method (GET, POST, etc.).
+ ///
+ public required string Method { get; init; }
+
+ ///
+ /// Gets the request path.
+ ///
+ public required string Path { get; init; }
+
+ ///
+ /// Gets the query string (without the leading '?').
+ ///
+ public string? QueryString { get; init; }
+
+ ///
+ /// Gets the full URL (path + query string).
+ ///
+ public string Url => string.IsNullOrEmpty(QueryString) ? Path : $"{Path}?{QueryString}";
+
+ ///
+ /// Gets the request headers.
+ ///
+ public IReadOnlyDictionary Headers { get; init; } = new Dictionary();
+
+ ///
+ /// Gets the Content-Type header value, if present.
+ ///
+ public string? ContentType { get; init; }
+
+ ///
+ /// Gets the request body as a string, if captured.
+ ///
+ public string? Body { get; init; }
+
+ ///
+ /// Gets the request body length in bytes.
+ ///
+ public long? ContentLength { get; init; }
+}
+
+///
+/// Represents a captured HTTP response.
+///
+public sealed class CapturedResponse
+{
+ ///
+ /// Gets the HTTP status code.
+ ///
+ public required HttpStatusCode StatusCode { get; init; }
+
+ ///
+ /// Gets the status code as an integer.
+ ///
+ public int StatusCodeValue => (int)StatusCode;
+
+ ///
+ /// Gets the response headers.
+ ///
+ public IReadOnlyDictionary Headers { get; init; } = new Dictionary();
+
+ ///
+ /// Gets the Content-Type header value, if present.
+ ///
+ public string? ContentType { get; init; }
+
+ ///
+ /// Gets the response body as a string, if captured.
+ ///
+ public string? Body { get; init; }
+
+ ///
+ /// Gets the response body length in bytes.
+ ///
+ public long? ContentLength { get; init; }
+}
diff --git a/TUnit.AspNetCore/Interception/HttpExchangeCapture.cs b/TUnit.AspNetCore/Interception/HttpExchangeCapture.cs
new file mode 100644
index 0000000000..01e977bcaf
--- /dev/null
+++ b/TUnit.AspNetCore/Interception/HttpExchangeCapture.cs
@@ -0,0 +1,123 @@
+using System.Collections.Concurrent;
+using System.Net;
+
+namespace TUnit.AspNetCore.Interception;
+
+///
+/// Stores captured HTTP exchanges for test assertions.
+/// Register as a singleton in the test service collection.
+///
+public sealed class HttpExchangeCapture
+{
+ private readonly ConcurrentQueue _exchanges = new();
+
+ ///
+ /// Gets or sets whether to capture request bodies. Default is true.
+ /// Disable for large payloads or binary content.
+ ///
+ public bool CaptureRequestBody { get; set; } = true;
+
+ ///
+ /// Gets or sets whether to capture response bodies. Default is true.
+ /// Disable for large payloads or binary content.
+ ///
+ public bool CaptureResponseBody { get; set; } = true;
+
+ ///
+ /// Gets or sets the maximum body size to capture in bytes. Default is 1MB.
+ /// Bodies larger than this will be truncated.
+ ///
+ public int MaxBodySize { get; set; } = 1024 * 1024;
+
+ ///
+ /// Gets all captured exchanges in order.
+ ///
+ public IReadOnlyList Exchanges => [.. _exchanges];
+
+ ///
+ /// Gets the number of captured exchanges.
+ ///
+ public int Count => _exchanges.Count;
+
+ ///
+ /// Gets the most recent exchange, or null if none captured.
+ ///
+ public CapturedHttpExchange? Last => _exchanges.LastOrDefault();
+
+ ///
+ /// Gets the first exchange, or null if none captured.
+ ///
+ public CapturedHttpExchange? First => _exchanges.FirstOrDefault();
+
+ ///
+ /// Adds a captured exchange to the store.
+ ///
+ internal void Add(CapturedHttpExchange exchange)
+ {
+ _exchanges.Enqueue(exchange);
+ }
+
+ ///
+ /// Clears all captured exchanges.
+ ///
+ public void Clear()
+ {
+ while (_exchanges.TryDequeue(out _)) { }
+ }
+
+ ///
+ /// Gets exchanges matching the specified HTTP method.
+ ///
+ public IEnumerable ForMethod(string method) =>
+ _exchanges.Where(e => e.Request.Method.Equals(method, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// Gets exchanges matching the specified path (exact match).
+ ///
+ public IEnumerable ForPath(string path) =>
+ _exchanges.Where(e => e.Request.Path.Equals(path, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// Gets exchanges where the path starts with the specified prefix.
+ ///
+ public IEnumerable ForPathStartingWith(string prefix) =>
+ _exchanges.Where(e => e.Request.Path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// Gets exchanges matching the specified status code.
+ ///
+ public IEnumerable ForStatusCode(HttpStatusCode statusCode) =>
+ _exchanges.Where(e => e.Response.StatusCode == statusCode);
+
+ ///
+ /// Gets exchanges matching the specified status code.
+ ///
+ public IEnumerable ForStatusCode(int statusCode) =>
+ _exchanges.Where(e => e.Response.StatusCodeValue == statusCode);
+
+ ///
+ /// Gets exchanges matching the specified method and path.
+ ///
+ public IEnumerable For(string method, string path) =>
+ _exchanges.Where(e =>
+ e.Request.Method.Equals(method, StringComparison.OrdinalIgnoreCase) &&
+ e.Request.Path.Equals(path, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// Gets exchanges matching the predicate.
+ ///
+ public IEnumerable Where(Func predicate) =>
+ _exchanges.Where(predicate);
+
+ ///
+ /// Returns true if any exchange matches the predicate.
+ ///
+ public bool Any(Func predicate) =>
+ _exchanges.Any(predicate);
+
+ ///
+ /// Returns true if any exchange was captured for the given method and path.
+ ///
+ public bool Any(string method, string path) =>
+ For(method, path).Any();
+}
diff --git a/TUnit.AspNetCore/Interception/HttpExchangeCaptureExtensions.cs b/TUnit.AspNetCore/Interception/HttpExchangeCaptureExtensions.cs
new file mode 100644
index 0000000000..3b07da7029
--- /dev/null
+++ b/TUnit.AspNetCore/Interception/HttpExchangeCaptureExtensions.cs
@@ -0,0 +1,76 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace TUnit.AspNetCore.Interception;
+
+///
+/// Extension methods for adding HTTP exchange capture to tests.
+///
+public static class HttpExchangeCaptureExtensions
+{
+ ///
+ /// Adds HTTP exchange capture to the service collection.
+ /// This registers both the capture store and a startup filter that adds the middleware.
+ ///
+ /// The service collection.
+ /// Optional configuration for capture settings.
+ /// The service collection for chaining.
+ ///
+ ///
+ /// protected override void ConfigureTestServices(IServiceCollection services)
+ /// {
+ /// services.AddHttpExchangeCapture();
+ /// }
+ ///
+ /// [Test]
+ /// public async Task Test()
+ /// {
+ /// var client = Factory.CreateClient();
+ /// await client.GetAsync("/api/todos");
+ ///
+ /// var capture = Services.GetRequiredService<HttpExchangeCapture>();
+ /// await Assert.That(capture.Last!.Response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ /// }
+ ///
+ ///
+ public static IServiceCollection AddHttpExchangeCapture(
+ this IServiceCollection services,
+ Action? configure = null)
+ {
+ var capture = new HttpExchangeCapture();
+ configure?.Invoke(capture);
+
+ services.AddSingleton(capture);
+ services.AddSingleton(new HttpExchangeCaptureStartupFilter());
+
+ return services;
+ }
+
+ ///
+ /// Adds the HTTP exchange capture middleware to the pipeline.
+ /// Prefer using which handles this automatically.
+ ///
+ /// The application builder.
+ /// The application builder for chaining.
+ public static IApplicationBuilder UseHttpExchangeCapture(this IApplicationBuilder app)
+ {
+ return app.UseMiddleware();
+ }
+}
+
+///
+/// Startup filter that adds the HTTP exchange capture middleware early in the pipeline.
+///
+internal sealed class HttpExchangeCaptureStartupFilter : IStartupFilter
+{
+ public Action Configure(Action next)
+ {
+ return app =>
+ {
+ // Add capture middleware first so it captures everything
+ app.UseMiddleware();
+ next(app);
+ };
+ }
+}
diff --git a/TUnit.AspNetCore/Interception/HttpExchangeCaptureMiddleware.cs b/TUnit.AspNetCore/Interception/HttpExchangeCaptureMiddleware.cs
new file mode 100644
index 0000000000..e0a9c1e268
--- /dev/null
+++ b/TUnit.AspNetCore/Interception/HttpExchangeCaptureMiddleware.cs
@@ -0,0 +1,156 @@
+using System.Buffers;
+using System.Diagnostics;
+using System.Net;
+using System.Text;
+using Microsoft.AspNetCore.Http;
+
+namespace TUnit.AspNetCore.Interception;
+
+///
+/// Middleware that captures HTTP request/response exchanges for test assertions.
+///
+public sealed class HttpExchangeCaptureMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly HttpExchangeCapture _capture;
+
+ public HttpExchangeCaptureMiddleware(RequestDelegate next, HttpExchangeCapture capture)
+ {
+ _next = next;
+ _capture = capture;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var timestamp = DateTimeOffset.UtcNow;
+ var stopwatch = Stopwatch.StartNew();
+
+ // Capture request
+ var capturedRequest = await CaptureRequestAsync(context.Request);
+
+ // Buffer response body if capturing
+ var originalBodyStream = context.Response.Body;
+ using var responseBodyStream = new MemoryStream();
+
+ if (_capture.CaptureResponseBody)
+ {
+ context.Response.Body = responseBodyStream;
+ }
+
+ try
+ {
+ await _next(context);
+ }
+ finally
+ {
+ // Ensure response body stream is restored even on exception
+ if (_capture.CaptureResponseBody)
+ {
+ context.Response.Body = originalBodyStream;
+ }
+ }
+
+ stopwatch.Stop();
+
+ // Capture response
+ string? responseBody = null;
+ if (_capture.CaptureResponseBody)
+ {
+ responseBodyStream.Position = 0;
+ responseBody = await ReadBodyAsync(responseBodyStream, _capture.MaxBodySize);
+
+ // Copy back to original stream
+ responseBodyStream.Position = 0;
+ await responseBodyStream.CopyToAsync(originalBodyStream);
+ }
+
+ var capturedResponse = CaptureResponse(context.Response, responseBody);
+
+ var exchange = new CapturedHttpExchange
+ {
+ Timestamp = timestamp,
+ Duration = stopwatch.Elapsed,
+ Request = capturedRequest,
+ Response = capturedResponse
+ };
+
+ _capture.Add(exchange);
+ }
+
+ private async Task CaptureRequestAsync(HttpRequest request)
+ {
+ string? body = null;
+
+ if (_capture.CaptureRequestBody && request.ContentLength > 0)
+ {
+ request.EnableBuffering();
+ body = await ReadBodyAsync(request.Body, _capture.MaxBodySize);
+ request.Body.Position = 0;
+ }
+
+ return new CapturedRequest
+ {
+ Method = request.Method,
+ Path = request.Path.Value ?? "/",
+ QueryString = request.QueryString.HasValue ? request.QueryString.Value?.TrimStart('?') : null,
+ Headers = CaptureHeaders(request.Headers),
+ ContentType = request.ContentType,
+ ContentLength = request.ContentLength,
+ Body = body
+ };
+ }
+
+ private static CapturedResponse CaptureResponse(HttpResponse response, string? body)
+ {
+ return new CapturedResponse
+ {
+ StatusCode = (HttpStatusCode)response.StatusCode,
+ Headers = CaptureHeaders(response.Headers),
+ ContentType = response.ContentType,
+ ContentLength = response.ContentLength,
+ Body = body
+ };
+ }
+
+ private static Dictionary CaptureHeaders(IHeaderDictionary headers)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var header in headers)
+ {
+ result[header.Key] = header.Value.ToArray();
+ }
+
+ return result;
+ }
+
+ private static async Task ReadBodyAsync(Stream stream, int maxSize)
+ {
+ var bufferSize = Math.Min(maxSize, 81920); // 80KB chunks
+ var buffer = ArrayPool.Shared.Rent(bufferSize);
+ try
+ {
+ var builder = new StringBuilder();
+ int totalRead = 0;
+
+ int bytesRead;
+ while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, Math.Min(bufferSize, maxSize - totalRead)))) > 0)
+ {
+ builder.Append(Encoding.UTF8.GetString(buffer, 0, bytesRead));
+ totalRead += bytesRead;
+
+ if (totalRead >= maxSize)
+ {
+ builder.Append("... [truncated]");
+ break;
+ }
+ }
+
+ return builder.ToString();
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+}
diff --git a/TUnit.AspNetCore/Logging/TUnitAspNetLogger.cs b/TUnit.AspNetCore/Logging/TUnitAspNetLogger.cs
new file mode 100644
index 0000000000..10d6b05394
--- /dev/null
+++ b/TUnit.AspNetCore/Logging/TUnitAspNetLogger.cs
@@ -0,0 +1,54 @@
+using Microsoft.Extensions.Logging;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore.Logging;
+
+///
+/// A logger that writes log messages to TUnit's test output.
+/// Messages are associated with the current test context for proper output capture.
+///
+public sealed class TUnitAspNetLogger : ILogger
+{
+ private readonly string _categoryName;
+ private readonly TestContext _context;
+ private readonly LogLevel _minLogLevel;
+
+ internal TUnitAspNetLogger(string categoryName, TestContext context, LogLevel minLogLevel)
+ {
+ _categoryName = categoryName;
+ _context = context;
+ _minLogLevel = minLogLevel;
+ }
+
+ public IDisposable? BeginScope(TState state) where TState : notnull
+ {
+ return TUnitLoggerScope.Push(state);
+ }
+
+ public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLogLevel;
+
+ public void Log(
+ LogLevel logLevel,
+ EventId eventId,
+ TState state,
+ Exception? exception,
+ Func formatter)
+ {
+ if (!IsEnabled(logLevel))
+ {
+ return;
+ }
+
+ // Set the current test context for proper output association
+ TestContext.Current = _context;
+
+ var message = formatter(state, exception);
+
+ if (exception is not null)
+ {
+ message = $"{message}{Environment.NewLine}{exception}";
+ }
+
+ Console.WriteLine($"[{logLevel}] {_categoryName}: {message}");
+ }
+}
diff --git a/TUnit.AspNetCore/Logging/TUnitLoggerProvider.cs b/TUnit.AspNetCore/Logging/TUnitLoggerProvider.cs
new file mode 100644
index 0000000000..5535a86819
--- /dev/null
+++ b/TUnit.AspNetCore/Logging/TUnitLoggerProvider.cs
@@ -0,0 +1,48 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore.Logging;
+
+///
+/// A logger provider that creates instances.
+/// Logs are written to the current test's output.
+///
+public sealed class TUnitLoggerProvider : ILoggerProvider
+{
+ private readonly ConcurrentDictionary _loggers = new();
+ private readonly TestContext _testContext;
+ private readonly LogLevel _minLogLevel;
+ private bool _disposed;
+
+ ///
+ /// Creates a new TUnitLoggerProvider that uses the provided context provider
+ /// to get the current test context.
+ ///
+ /// A function that returns the current test context, or null if not in a test.
+ /// The minimum log level to capture. Defaults to Information.
+ public TUnitLoggerProvider(TestContext testContext, LogLevel minLogLevel = LogLevel.Information)
+ {
+ _testContext = testContext;
+ _minLogLevel = minLogLevel;
+ }
+
+ public ILogger CreateLogger(string categoryName)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ return _loggers.GetOrAdd(categoryName,
+ name => new TUnitAspNetLogger(name, _testContext, _minLogLevel));
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _loggers.Clear();
+ }
+}
diff --git a/TUnit.AspNetCore/Logging/TUnitLoggerScope.cs b/TUnit.AspNetCore/Logging/TUnitLoggerScope.cs
new file mode 100644
index 0000000000..fd8406e524
--- /dev/null
+++ b/TUnit.AspNetCore/Logging/TUnitLoggerScope.cs
@@ -0,0 +1,54 @@
+namespace TUnit.AspNetCore.Logging;
+
+///
+/// Manages logging scope state using AsyncLocal for proper async flow.
+///
+internal sealed class TUnitLoggerScope : IDisposable
+{
+ private static readonly AsyncLocal CurrentScope = new();
+
+ private readonly object _state;
+ private readonly TUnitLoggerScope? _parent;
+ private bool _disposed;
+
+ private TUnitLoggerScope(object state, TUnitLoggerScope? parent)
+ {
+ _state = state;
+ _parent = parent;
+ }
+
+ public static TUnitLoggerScope? Current => CurrentScope.Value;
+
+ public static IDisposable Push(object state)
+ {
+ var scope = new TUnitLoggerScope(state, CurrentScope.Value);
+ CurrentScope.Value = scope;
+ return scope;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ CurrentScope.Value = _parent;
+ }
+
+ public override string ToString()
+ {
+ var current = this;
+ var scopes = new List();
+
+ while (current != null)
+ {
+ scopes.Add(current._state.ToString() ?? string.Empty);
+ current = current._parent;
+ }
+
+ scopes.Reverse();
+ return string.Join(" => ", scopes);
+ }
+}
\ No newline at end of file
diff --git a/TUnit.AspNetCore/TUnit.AspNetCore.csproj b/TUnit.AspNetCore/TUnit.AspNetCore.csproj
new file mode 100644
index 0000000000..7c8d190a1d
--- /dev/null
+++ b/TUnit.AspNetCore/TUnit.AspNetCore.csproj
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+ net8.0;net9.0;net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TUnit.AspNetCore/TestWebApplicationFactory.cs b/TUnit.AspNetCore/TestWebApplicationFactory.cs
new file mode 100644
index 0000000000..39ce8380f0
--- /dev/null
+++ b/TUnit.AspNetCore/TestWebApplicationFactory.cs
@@ -0,0 +1,39 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using TUnit.AspNetCore.Extensions;
+using TUnit.AspNetCore.Interception;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore;
+
+///
+/// Internal factory wrapper that allows configuration via a delegate.
+///
+public abstract class TestWebApplicationFactory : WebApplicationFactory where TEntryPoint : class
+{
+ public WebApplicationFactory GetIsolatedFactory(
+ TestContext testContext,
+ WebApplicationTestOptions options,
+ Action configureServices,
+ Action configureConfiguration,
+ Action? configureWebHostBuilder = null)
+ {
+ return WithWebHostBuilder(builder =>
+ {
+ // Apply user's escape hatch configuration first
+ configureWebHostBuilder?.Invoke(builder);
+
+ // Then apply standard configuration
+ builder.ConfigureTestServices(configureServices)
+ .ConfigureAppConfiguration(configureConfiguration);
+
+ if (options.EnableHttpExchangeCapture)
+ {
+ builder.ConfigureTestServices(services => services.AddHttpExchangeCapture());
+ }
+ });
+ }
+}
diff --git a/TUnit.AspNetCore/WebApplicationTest.cs b/TUnit.AspNetCore/WebApplicationTest.cs
new file mode 100644
index 0000000000..1de04a34d5
--- /dev/null
+++ b/TUnit.AspNetCore/WebApplicationTest.cs
@@ -0,0 +1,251 @@
+using System.ComponentModel;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using TUnit.AspNetCore.Interception;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore;
+
+public abstract class WebApplicationTest
+{
+ internal static int _idCounter;
+
+ ///
+ /// Gets a unique identifier for this test instance.
+ /// Useful for creating isolated resources (tables, topics, keys) per test.
+ ///
+ public int UniqueId { get; }
+
+ internal WebApplicationTest()
+ {
+ UniqueId = Interlocked.Increment(ref _idCounter);
+ }
+
+ ///
+ /// Creates an isolated name by combining a base name with the test's unique identifier.
+ /// Use for database tables, Redis keys, Kafka topics, etc.
+ ///
+ /// The base name for the resource.
+ /// A unique name in the format "Test_{UniqueId}_{baseName}".
+ ///
+ ///
+ /// // In a test with UniqueId = 42:
+ /// var tableName = GetIsolatedName("todos"); // Returns "Test_42_todos"
+ /// var topicName = GetIsolatedName("orders"); // Returns "Test_42_orders"
+ ///
+ ///
+ protected string GetIsolatedName(string baseName) => $"Test_{UniqueId}_{baseName}";
+
+ ///
+ /// Creates an isolated prefix using the test's unique identifier.
+ /// Use for key prefixes in Redis, Kafka topic prefixes, etc.
+ ///
+ /// The separator character. Defaults to "_".
+ /// A unique prefix in the format "test{separator}{UniqueId}{separator}".
+ ///
+ ///
+ /// // In a test with UniqueId = 42:
+ /// var prefix = GetIsolatedPrefix(); // Returns "test_42_"
+ /// var dotPrefix = GetIsolatedPrefix("."); // Returns "test.42."
+ ///
+ ///
+ protected string GetIsolatedPrefix(string separator = "_") => $"test{separator}{UniqueId}{separator}";
+}
+
+///
+/// Base class for ASP.NET Core integration tests with TUnit.
+/// Provides per-test isolated web application factories via the delegating factory pattern.
+///
+/// The factory type derived from TestWebApplicationFactory.
+/// The entry point class (typically 'Program') of the application under test.
+///
+///
+/// This class creates a global once per test class type,
+/// then creates a per-test delegating factory using
+/// for each test. This enables parallel test execution with complete isolation.
+///
+///
+/// The factory is created in a BeforeTest hook, ensuring all other injected properties
+/// (such as database containers) are available before the factory is configured.
+///
+///
+public abstract class WebApplicationTest : WebApplicationTest
+ where TFactory : TestWebApplicationFactory, new()
+ where TEntryPoint : class
+{
+ [ClassDataSource(Shared = [SharedType.PerTestSession])]
+ public TFactory GlobalFactory { get; set; } = null!;
+
+ private WebApplicationFactory? _factory;
+
+ private readonly WebApplicationTestOptions _options = new();
+
+ ///
+ /// Gets the per-test delegating factory. This factory is isolated to the current test.
+ ///
+ /// Thrown if accessed before test setup.
+ public WebApplicationFactory Factory => _factory ?? throw new InvalidOperationException(
+ "Factory is not initialized. Ensure the test has started and the BeforeTest hook has run. " +
+ "Do not access Factory during test discovery or in data source methods.");
+
+ ///
+ /// Gets the service provider from the per-test factory.
+ /// Use this to resolve services for verification or setup.
+ ///
+ public IServiceProvider Services => Factory.Services;
+
+ ///
+ /// Initializes the isolated web application factory before each test.
+ /// This hook runs after all property injection is complete, ensuring
+ /// that dependencies like database containers are available.
+ ///
+ [Before(HookType.Test)]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public async Task InitializeFactoryAsync(TestContext testContext)
+ {
+ ConfigureTestOptions(_options);
+
+ // Run async setup first - use this for database/container initialization
+ await SetupAsync();
+
+ // Then create factory with sync configuration (required by ASP.NET Core hosting)
+ _factory = GlobalFactory.GetIsolatedFactory(
+ testContext,
+ _options,
+ ConfigureTestServices,
+ (_, config) => ConfigureTestConfiguration(config),
+ ConfigureWebHostBuilder);
+
+ // Eagerly start the test server to catch configuration errors early
+ _ = _factory.Server;
+ }
+
+ [After(HookType.Test)]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public async Task DisposeFactoryAsync()
+ {
+ if (_factory != null)
+ {
+ await _factory.DisposeAsync();
+ }
+ }
+
+ ///
+ /// Override to perform async setup before the factory is created.
+ /// Use this for operations that require async (database table creation,
+ /// container health checks, external service initialization, etc.).
+ ///
+ ///
+ /// This method runs BEFORE and
+ /// . Store any results in
+ /// instance fields for use in the configuration methods.
+ ///
+ ///
+ ///
+ /// protected override async Task SetupAsync()
+ /// {
+ /// TableName = GetIsolatedName("todos");
+ /// await CreateTableAsync(TableName);
+ /// }
+ ///
+ /// protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
+ /// {
+ /// config.AddInMemoryCollection(new Dictionary<string, string?>
+ /// {
+ /// { "Database:TableName", TableName }
+ /// });
+ /// }
+ ///
+ ///
+ protected virtual Task SetupAsync()
+ {
+ return Task.CompletedTask;
+ }
+
+ protected virtual void ConfigureTestOptions(WebApplicationTestOptions options)
+ {
+ }
+
+ ///
+ /// Override to configure additional services for the test.
+ /// Called synchronously during factory creation (ASP.NET Core requirement).
+ /// For async setup, use instead.
+ ///
+ /// The service collection to configure.
+ ///
+ ///
+ /// protected override void ConfigureTestServices(IServiceCollection services)
+ /// {
+ /// services.ReplaceService<IEmailService>(new FakeEmailService());
+ /// }
+ ///
+ ///
+ protected virtual void ConfigureTestServices(IServiceCollection services)
+ {
+ }
+
+ ///
+ /// Override to configure the application for the test.
+ /// Called synchronously during factory creation (ASP.NET Core requirement).
+ /// For async setup, use instead.
+ ///
+ /// The configuration builder to configure.
+ ///
+ ///
+ /// protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
+ /// {
+ /// config.AddInMemoryCollection(new Dictionary<string, string?>
+ /// {
+ /// { "Database:TableName", GetIsolatedName("todos") }
+ /// });
+ /// }
+ ///
+ ///
+ protected virtual void ConfigureTestConfiguration(IConfigurationBuilder config)
+ {
+ }
+
+ ///
+ /// Override to configure the web host builder directly.
+ /// This is an escape hatch for advanced scenarios not covered by other configuration methods.
+ /// Called first, before and .
+ ///
+ /// The web host builder to configure.
+ ///
+ ///
+ /// protected override void ConfigureWebHostBuilder(IWebHostBuilder builder)
+ /// {
+ /// builder.UseEnvironment("Staging");
+ /// builder.UseSetting("MyFeature:Enabled", "true");
+ /// builder.ConfigureKestrel(options => options.AddServerHeader = false);
+ /// }
+ ///
+ ///
+ protected virtual void ConfigureWebHostBuilder(IWebHostBuilder builder)
+ {
+ }
+
+ ///
+ /// Gets the HTTP exchange capture store, if enabled via .
+ /// Returns null if HTTP exchange capture is not enabled.
+ ///
+ ///
+ ///
+ /// protected override WebApplicationTestOptions Options => new() { EnableHttpExchangeCapture = true };
+ ///
+ /// [Test]
+ /// public async Task CapturesRequests()
+ /// {
+ /// var client = Factory.CreateClient();
+ /// await client.GetAsync("/api/todos");
+ ///
+ /// await Assert.That(HttpCapture).IsNotNull();
+ /// await Assert.That(HttpCapture!.Last!.Response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ /// }
+ ///
+ ///
+ public HttpExchangeCapture? HttpCapture =>
+ _options.EnableHttpExchangeCapture ? (field ??= new()) : null;
+}
diff --git a/TUnit.AspNetCore/WebApplicationTestOptions.cs b/TUnit.AspNetCore/WebApplicationTestOptions.cs
new file mode 100644
index 0000000000..bfeff23e64
--- /dev/null
+++ b/TUnit.AspNetCore/WebApplicationTestOptions.cs
@@ -0,0 +1,11 @@
+namespace TUnit.AspNetCore;
+
+public record WebApplicationTestOptions
+{
+ ///
+ /// Gets or sets a value indicating whether HTTP exchange capture is enabled for the test.
+ /// When enabled, all HTTP requests and responses are recorded and can be inspected via .
+ /// Default is false.
+ ///
+ public bool EnableHttpExchangeCapture { get; set; } = false;
+}
diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/StaticPropertyInitializationGenerator.cs b/TUnit.Core.SourceGenerator/CodeGenerators/StaticPropertyInitializationGenerator.cs
index 7f4cf2e9d6..157915e409 100644
--- a/TUnit.Core.SourceGenerator/CodeGenerators/StaticPropertyInitializationGenerator.cs
+++ b/TUnit.Core.SourceGenerator/CodeGenerators/StaticPropertyInitializationGenerator.cs
@@ -49,9 +49,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
return null;
}
+ // Skip open generic types - we can't generate code for types with unbound type parameters
+ // The initialization will happen in the consuming assembly that provides concrete type arguments
+ if (typeSymbol.IsGenericType && typeSymbol.TypeArguments.Any(t => t.TypeKind == TypeKind.TypeParameter))
+ {
+ return null;
+ }
+
// Check if this type has any static properties with data source attributes
var hasStaticPropertiesWithDataSources = GetStaticPropertyDataSources(typeSymbol).Any();
-
+
return hasStaticPropertiesWithDataSources ? typeSymbol : null;
}
diff --git a/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs b/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs
index 726185ffa0..d3c10a781c 100644
--- a/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs
+++ b/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs
@@ -237,10 +237,14 @@ public static string GloballyQualified(this ISymbol typeSymbol)
if (typeSymbol is INamedTypeSymbol { IsGenericType: true } namedTypeSymbol)
{
// Check if this is an unbound generic type or has type parameter arguments
+ // Use multiple detection methods for robustness across Roslyn versions
var hasTypeParameters = namedTypeSymbol.TypeArguments.Any(t => t.TypeKind == TypeKind.TypeParameter);
+ var hasTypeParameterSymbols = namedTypeSymbol.TypeArguments.OfType().Any();
var isUnboundGeneric = namedTypeSymbol.IsUnboundGenericType;
-
- if (hasTypeParameters || isUnboundGeneric)
+ // Also detect generic type definitions by checking if type equals its OriginalDefinition
+ var isGenericTypeDefinition = SymbolEqualityComparer.Default.Equals(namedTypeSymbol, namedTypeSymbol.OriginalDefinition);
+
+ if (hasTypeParameters || hasTypeParameterSymbols || isUnboundGeneric || isGenericTypeDefinition)
{
// Special case for System.Nullable<> - Roslyn displays it as "T?" even for open generic
if (namedTypeSymbol.SpecialType == SpecialType.System_Nullable_T ||
diff --git a/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs b/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs
index 9c3c88b637..36a3c2f3d6 100644
--- a/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs
+++ b/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs
@@ -605,11 +605,26 @@ private static string FormatTypedConstant(TypedConstant constant)
TypedConstantKind.Primitive => constant.Value?.ToString() ?? "null",
TypedConstantKind.Enum => FormatEnumConstant(constant),
TypedConstantKind.Type => FormatTypeConstant(constant),
- TypedConstantKind.Array => $"new object[] {{ {string.Join(", ", constant.Values.Select(FormatTypedConstant))} }}",
+ TypedConstantKind.Array => FormatArrayConstant(constant),
_ => constant.Value?.ToString() ?? "null"
};
}
+ private static string FormatArrayConstant(TypedConstant constant)
+ {
+ // Get the element type from the array type (e.g., SharedType[] -> SharedType)
+ if (constant.Type is IArrayTypeSymbol arrayType)
+ {
+ var elementTypeName = GetNonNullableTypeString(arrayType.ElementType);
+ var elements = string.Join(", ", constant.Values.Select(FormatTypedConstant));
+ return $"new {elementTypeName}[] {{ {elements} }}";
+ }
+
+ // Fallback to object[] if type information is not available
+ var fallbackElements = string.Join(", ", constant.Values.Select(FormatTypedConstant));
+ return $"new object[] {{ {fallbackElements} }}";
+ }
+
private static string FormatEnumConstant(TypedConstant constant)
{
if (constant is { Type: not null, Value: not null })
diff --git a/TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs b/TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs
index 786d91f382..91cde113c6 100644
--- a/TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs
+++ b/TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs
@@ -426,12 +426,18 @@ private static string GetPropertyAccessor(INamedTypeSymbol namedTypeSymbol, IPro
// For generic types with unresolved type parameters, we can't cast to the open generic type
// We need to use dynamic or reflection
var hasUnresolvedTypeParameters = namedTypeSymbol.IsGenericType &&
- namedTypeSymbol.TypeArguments.Any(t => t.TypeKind == TypeKind.TypeParameter);
+ (namedTypeSymbol.TypeArguments.Any(t => t.TypeKind == TypeKind.TypeParameter) ||
+ namedTypeSymbol.TypeArguments.OfType().Any() ||
+ SymbolEqualityComparer.Default.Equals(namedTypeSymbol, namedTypeSymbol.OriginalDefinition));
- if (hasUnresolvedTypeParameters && !property.IsStatic)
+ if (hasUnresolvedTypeParameters)
{
- // Use dynamic to avoid invalid cast to open generic type
- return $"o => ((dynamic)o).{property.Name}";
+ return property.IsStatic
+ // Can't access static members on an unbound generic type like WebApplicationTest<,>
+ // Use reflection to get the value at runtime
+ ? $"_ => typeof({namedTypeSymbol.GloballyQualified()}).GetProperty(\"{property.Name}\")?.GetValue(null)"
+ // Use dynamic to avoid invalid cast to open generic type
+ : $"o => ((dynamic)o).{property.Name}";
}
var safeTypeName = namedTypeSymbol.GloballyQualified();
diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs
index d75e405f7d..9dbfc8ff2c 100644
--- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs
+++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs
@@ -4,31 +4,15 @@
namespace TUnit.Core;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)]
-public sealed class ClassDataSourceAttribute<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T>
- : DataSourceGeneratorAttribute
+public sealed class ClassDataSourceAttribute : UntypedDataSourceGeneratorAttribute
{
- public SharedType Shared { get; set; } = SharedType.None;
- public string Key { get; set; } = string.Empty;
- public Type ClassType => typeof(T);
+ private Type[] _types;
- protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata)
+ public ClassDataSourceAttribute()
{
- var testClassType = TestClassTypeHelper.GetTestClassType(dataGeneratorMetadata);
- yield return () => ClassDataSources.Get(dataGeneratorMetadata.TestSessionId)
- .Get(Shared, testClassType, Key, dataGeneratorMetadata);
+ _types = [];
}
-
- public IEnumerable GetSharedTypes() => [Shared];
-
- public IEnumerable GetKeys() => string.IsNullOrEmpty(Key) ? [] : [Key];
-}
-
-[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)]
-public sealed class ClassDataSourceAttribute : UntypedDataSourceGeneratorAttribute
-{
- private readonly Type[] _types;
-
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Non-params constructor calls params one with proper annotations.")]
public ClassDataSourceAttribute(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)]
@@ -99,6 +83,25 @@ public ClassDataSourceAttribute(params Type[] types)
{
yield return () =>
{
+ if (_types.Length == 0)
+ {
+ _types = dataGeneratorMetadata.MembersToGenerate.Select(x =>
+ {
+ if (x is ParameterMetadata parameterMetadata)
+ {
+ return parameterMetadata.Type;
+ }
+
+ if (x is PropertyMetadata propertyMetadata)
+ {
+ return propertyMetadata.Type;
+ }
+
+ throw new ArgumentOutOfRangeException(nameof(dataGeneratorMetadata),
+ "Member to generate must be either a parameter or a property.");
+ }).ToArray();
+ }
+
var items = new object?[_types.Length];
for (var i = 0; i < _types.Length; i++)
@@ -117,3 +120,24 @@ public ClassDataSourceAttribute(params Type[] types)
public IEnumerable GetKeys() => Keys;
}
+
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)]
+public sealed class ClassDataSourceAttribute<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T>
+ : DataSourceGeneratorAttribute
+{
+ public SharedType Shared { get; set; } = SharedType.None;
+ public string Key { get; set; } = string.Empty;
+ public Type ClassType => typeof(T);
+
+ protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata)
+ {
+ var testClassType = TestClassTypeHelper.GetTestClassType(dataGeneratorMetadata);
+ yield return () => ClassDataSources.Get(dataGeneratorMetadata.TestSessionId)
+ .Get(Shared, testClassType, Key, dataGeneratorMetadata);
+ }
+
+
+ public IEnumerable GetSharedTypes() => [Shared];
+
+ public IEnumerable GetKeys() => string.IsNullOrEmpty(Key) ? [] : [Key];
+}
diff --git a/TUnit.Core/Attributes/TestData/InstanceMethodDataSourceSourceAttribute.cs b/TUnit.Core/Attributes/TestData/InstanceMethodDataSourceSourceAttribute.cs
index 9413df05a1..1e5b4ea2eb 100644
--- a/TUnit.Core/Attributes/TestData/InstanceMethodDataSourceSourceAttribute.cs
+++ b/TUnit.Core/Attributes/TestData/InstanceMethodDataSourceSourceAttribute.cs
@@ -7,7 +7,7 @@ namespace TUnit.Core;
/// This implements IAccessesInstanceData which tells the engine to create a properly-initialized
/// instance before evaluating the data source.
///
-[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = true)]
public class InstanceMethodDataSourceAttribute : MethodDataSourceAttribute, IAccessesInstanceData
{
public InstanceMethodDataSourceAttribute(string methodNameProvidingDataSource)
diff --git a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs
index c6bd260fe0..1597914248 100644
--- a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs
+++ b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs
@@ -89,6 +89,17 @@ public MethodDataSourceAttribute(
{
targetType = dataGeneratorMetadata.TestClassInstance.GetType();
}
+
+ // If the target type is abstract or interface, we can't create an instance of it.
+ // Fall back to the test class type which should be concrete.
+ if (targetType != null && (targetType.IsAbstract || targetType.IsInterface))
+ {
+ var testClassType = TestClassTypeHelper.GetTestClassType(dataGeneratorMetadata);
+ if (testClassType != null && !testClassType.IsAbstract && !testClassType.IsInterface)
+ {
+ targetType = testClassType;
+ }
+ }
if (targetType == null)
{
@@ -108,7 +119,13 @@ public MethodDataSourceAttribute(
object? instance = null;
if (!methodInfo.IsStatic)
{
- instance = dataGeneratorMetadata.TestClassInstance ?? Activator.CreateInstance(targetType);
+ // Skip PlaceholderInstance as it's a sentinel value, not a real instance
+ var testClassInstance = dataGeneratorMetadata.TestClassInstance;
+ if (testClassInstance is PlaceholderInstance)
+ {
+ testClassInstance = null;
+ }
+ instance = testClassInstance ?? Activator.CreateInstance(targetType);
}
methodResult = methodInfo.Invoke(instance, Arguments);
@@ -125,7 +142,13 @@ public MethodDataSourceAttribute(
object? instance = null;
if (propertyInfo.GetMethod?.IsStatic != true)
{
- instance = dataGeneratorMetadata.TestClassInstance ?? Activator.CreateInstance(targetType);
+ // Skip PlaceholderInstance as it's a sentinel value, not a real instance
+ var testClassInstance = dataGeneratorMetadata.TestClassInstance;
+ if (testClassInstance is PlaceholderInstance)
+ {
+ testClassInstance = null;
+ }
+ instance = testClassInstance ?? Activator.CreateInstance(targetType);
}
methodResult = propertyInfo.GetValue(instance);
@@ -136,7 +159,13 @@ public MethodDataSourceAttribute(
object? instance = null;
if (!fieldInfo.IsStatic)
{
- instance = dataGeneratorMetadata.TestClassInstance ?? Activator.CreateInstance(targetType);
+ // Skip PlaceholderInstance as it's a sentinel value, not a real instance
+ var testClassInstance = dataGeneratorMetadata.TestClassInstance;
+ if (testClassInstance is PlaceholderInstance)
+ {
+ testClassInstance = null;
+ }
+ instance = testClassInstance ?? Activator.CreateInstance(targetType);
}
methodResult = fieldInfo.GetValue(instance);
diff --git a/TUnit.Core/ObjectInitializer.cs b/TUnit.Core/ObjectInitializer.cs
index 46c52923e8..a8ef5f3378 100644
--- a/TUnit.Core/ObjectInitializer.cs
+++ b/TUnit.Core/ObjectInitializer.cs
@@ -107,7 +107,7 @@ private static async ValueTask InitializeCoreAsync(
// called multiple times, but Lazy ensures only one initialization runs.
var lazyTask = InitializationTasks.GetOrAdd(obj,
_ => new Lazy(
- () => asyncInitializer.InitializeAsync(),
+ asyncInitializer.InitializeAsync,
LazyThreadSafetyMode.ExecutionAndPublication));
try
diff --git a/TUnit.Core/TUnit.Core.csproj b/TUnit.Core/TUnit.Core.csproj
index 56f619768d..f895e73378 100644
--- a/TUnit.Core/TUnit.Core.csproj
+++ b/TUnit.Core/TUnit.Core.csproj
@@ -5,6 +5,7 @@
+
diff --git a/TUnit.Core/TestBuilderContext.cs b/TUnit.Core/TestBuilderContext.cs
index f6ec4b0eef..712f7303f4 100644
--- a/TUnit.Core/TestBuilderContext.cs
+++ b/TUnit.Core/TestBuilderContext.cs
@@ -70,7 +70,23 @@ internal static TestBuilderContext FromTestContext(TestContext testContext, IDat
///
/// Provides access to the current .
///
-public class TestBuilderContextAccessor(TestBuilderContext context)
+public class TestBuilderContextAccessor
{
- public TestBuilderContext Current { get; set; } = context;
+ private TestBuilderContext _current;
+
+ public TestBuilderContextAccessor(TestBuilderContext context)
+ {
+ _current = context;
+ TestBuilderContext.Current = context;
+ }
+
+ public TestBuilderContext Current
+ {
+ get => _current;
+ set
+ {
+ _current = value;
+ TestBuilderContext.Current = value;
+ }
+ }
}
diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs
index 937d913d6f..e4d82110a9 100644
--- a/TUnit.Engine/Building/TestBuilder.cs
+++ b/TUnit.Engine/Building/TestBuilder.cs
@@ -170,6 +170,10 @@ public async Task> BuildTestsFromMetadataAsy
InitializedAttributes = attributes // Store the initialized attributes
};
+ // Set the static AsyncLocal immediately so it's available for property data sources
+ // This must be set BEFORE any operations that might invoke data source methods
+ TestBuilderContext.Current = testBuilderContext;
+
// Check for ClassConstructor attribute and set it early if present (reuse already created attributes)
var classConstructorAttribute = attributes.OfType().FirstOrDefault();
if (classConstructorAttribute != null)
@@ -391,7 +395,7 @@ await _objectLifecycleService.RegisterObjectAsync(
var basicSkipReason = GetBasicSkipReason(metadata, attributes);
Func> instanceFactory;
- bool isReusingDiscoveryInstance = false;
+ var isReusingDiscoveryInstance = false;
if (basicSkipReason is { Length: > 0 })
{
@@ -1392,6 +1396,10 @@ public async IAsyncEnumerable BuildTestsStreamingAsync(
InitializedAttributes = attributes // Store the initialized attributes
};
+ // Set the static AsyncLocal immediately so it's available for property data sources
+ // This must be set BEFORE any operations that might invoke data source methods
+ TestBuilderContext.Current = baseContext;
+
// Check for ClassConstructor attribute and set it early if present
// Look for any attribute that inherits from ClassConstructorAttribute
// This handles both ClassConstructorAttribute and ClassConstructorAttribute
diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs
index 9a31e5eb99..d74193b0cc 100644
--- a/TUnit.Engine/Framework/TUnitServiceProvider.cs
+++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs
@@ -230,7 +230,7 @@ public TUnitServiceProvider(IExtension extension,
Logger,
ParallelLimitLockProvider));
- var staticPropertyHandler = Register(new StaticPropertyHandler(Logger, objectTracker, trackableObjectGraphProvider, disposer));
+ var staticPropertyHandler = Register(new StaticPropertyHandler(Logger, objectTracker, trackableObjectGraphProvider, disposer, lazyPropertyInjector, objectGraphDiscoveryService));
var dynamicTestQueue = Register(new DynamicTestQueue(MessageBus));
diff --git a/TUnit.Engine/Services/PropertyInjector.cs b/TUnit.Engine/Services/PropertyInjector.cs
index e0008d17ac..92fd913b25 100644
--- a/TUnit.Engine/Services/PropertyInjector.cs
+++ b/TUnit.Engine/Services/PropertyInjector.cs
@@ -508,6 +508,7 @@ private async Task ResolveAndCacheReflectionPropertyAsync(
}
var dataGeneratorMetadata = CreateDataGeneratorMetadata(context, dataSource);
+
var dataRows = dataSource.GetDataRowsAsync(dataGeneratorMetadata);
await foreach (var factory in dataRows)
diff --git a/TUnit.Engine/Services/StaticPropertyHandler.cs b/TUnit.Engine/Services/StaticPropertyHandler.cs
index c860b0524a..a1881de0d9 100644
--- a/TUnit.Engine/Services/StaticPropertyHandler.cs
+++ b/TUnit.Engine/Services/StaticPropertyHandler.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using TUnit.Core;
using TUnit.Core.Helpers;
using TUnit.Core.StaticProperties;
@@ -15,17 +16,24 @@ internal sealed class StaticPropertyHandler
private readonly ObjectTracker _objectTracker;
private readonly TrackableObjectGraphProvider _trackableObjectGraphProvider;
private readonly Disposer _disposer;
+ private readonly Lazy _propertyInjector;
+ private readonly ObjectGraphDiscoveryService _objectGraphDiscoveryService;
+ private readonly ConcurrentDictionary _sessionObjectBag = new();
private bool _initialized;
public StaticPropertyHandler(TUnitFrameworkLogger logger,
ObjectTracker objectTracker,
TrackableObjectGraphProvider trackableObjectGraphProvider,
- Disposer disposer)
+ Disposer disposer,
+ Lazy propertyInjector,
+ ObjectGraphDiscoveryService objectGraphDiscoveryService)
{
_logger = logger;
_objectTracker = objectTracker;
_trackableObjectGraphProvider = trackableObjectGraphProvider;
_disposer = disposer;
+ _propertyInjector = propertyInjector;
+ _objectGraphDiscoveryService = objectGraphDiscoveryService;
}
///
@@ -50,6 +58,20 @@ public async Task InitializeStaticPropertiesAsync(CancellationToken cancellation
if (value != null)
{
+ // Inject instance properties on the value before initialization
+ // This handles cases where the static property's type has instance properties
+ // with data source attributes (e.g., [ClassDataSource] with Shared = PerTestSession)
+ await _propertyInjector.Value.InjectPropertiesAsync(
+ value,
+ _sessionObjectBag,
+ methodMetadata: null,
+ new TestContextEvents());
+
+ // Initialize nested objects depth-first BEFORE initializing the main value
+ // This ensures containers (IAsyncInitializer) are started before the factory
+ // that depends on them calls methods like GetConnectionString()
+ await InitializeNestedObjectsAsync(value, cancellationToken);
+
// Initialize the value (IAsyncInitializer, etc.)
await ObjectInitializer.InitializeAsync(value, cancellationToken);
@@ -97,4 +119,37 @@ public async Task DisposeStaticPropertiesAsync(List cleanupExceptions
throw new AggregateException("Errors occurred while disposing static properties", cleanupExceptions);
}
}
+
+ ///
+ /// Initializes nested objects depth-first (deepest first) before the parent object.
+ /// This ensures that containers/resources are fully initialized before the parent
+ /// object (like a WebApplicationFactory) tries to use them.
+ ///
+ private async Task InitializeNestedObjectsAsync(object rootObject, CancellationToken cancellationToken)
+ {
+ var graph = _objectGraphDiscoveryService.DiscoverNestedObjectGraph(rootObject, cancellationToken);
+
+ // Initialize from deepest to shallowest (skip depth 0 which is the root itself)
+ foreach (var depth in graph.GetDepthsDescending())
+ {
+ if (depth == 0)
+ {
+ continue; // Root handled separately by caller
+ }
+
+ var objectsAtDepth = graph.GetObjectsAtDepth(depth);
+
+ // Pre-allocate task list without LINQ Select
+ var tasks = new List();
+ foreach (var obj in objectsAtDepth)
+ {
+ tasks.Add(ObjectInitializer.InitializeAsync(obj, cancellationToken).AsTask());
+ }
+
+ if (tasks.Count > 0)
+ {
+ await Task.WhenAll(tasks);
+ }
+ }
+ }
}
diff --git a/TUnit.Engine/TUnit.Engine.csproj b/TUnit.Engine/TUnit.Engine.csproj
index 427392cdec..4eb26f20e9 100644
--- a/TUnit.Engine/TUnit.Engine.csproj
+++ b/TUnit.Engine/TUnit.Engine.csproj
@@ -7,8 +7,8 @@
-
+
diff --git a/TUnit.Example.Asp.Net.TestProject/KafkaIsolationTests.cs b/TUnit.Example.Asp.Net.TestProject/KafkaIsolationTests.cs
new file mode 100644
index 0000000000..b7dfb2cf3d
--- /dev/null
+++ b/TUnit.Example.Asp.Net.TestProject/KafkaIsolationTests.cs
@@ -0,0 +1,69 @@
+namespace TUnit.Example.Asp.Net.TestProject;
+
+///
+/// These tests demonstrate that parallel tests have isolated Kafka topic namespaces.
+/// Each test has its own topic prefix, so messages from one test cannot leak into another.
+/// The [Repeat(3)] attribute creates multiple iterations that run in parallel,
+/// and each gets its own topic prefix based on the unique TestContext.Id.
+///
+public class KafkaIsolationTests : KafkaTestBase
+{
+ ///
+ /// Verifies each test has a unique topic prefix.
+ ///
+ [Test, Repeat(3)]
+ public async Task TopicPrefix_IsUnique()
+ {
+ // Each test instance should have a non-empty prefix
+ await Assert.That(TopicPrefix).IsNotNullOrEmpty();
+
+ // The prefix should follow our naming convention
+ await Assert.That(TopicPrefix).StartsWith("test_");
+ await Assert.That(TopicPrefix).EndsWith("_");
+ }
+
+ ///
+ /// Verifies topic names are properly prefixed.
+ ///
+ [Test, Repeat(3)]
+ public async Task GetTopicName_AppliesPrefix()
+ {
+ var topicName = GetTopicName("orders");
+
+ await Assert.That(topicName).StartsWith(TopicPrefix);
+ await Assert.That(topicName).EndsWith("orders");
+ }
+
+ ///
+ /// Verifies the Kafka bootstrap address is available.
+ ///
+ [Test, Repeat(3)]
+ public async Task BootstrapAddress_IsConfigured()
+ {
+ var bootstrapAddress = GetBootstrapAddress();
+
+ await Assert.That(bootstrapAddress).IsNotNullOrEmpty();
+ // Testcontainers uses 127.0.0.1 (or localhost) with a random port
+ await Assert.That(bootstrapAddress).Contains("127.0.0.1");
+ }
+
+ ///
+ /// Verifies multiple topics can be created with the prefix.
+ ///
+ [Test, Repeat(3)]
+ public async Task MultipleTopic_AllHaveSamePrefix()
+ {
+ var ordersTopic = GetTopicName("orders");
+ var eventsTopic = GetTopicName("events");
+ var notificationsTopic = GetTopicName("notifications");
+
+ // All topics should share the same prefix
+ await Assert.That(ordersTopic).StartsWith(TopicPrefix);
+ await Assert.That(eventsTopic).StartsWith(TopicPrefix);
+ await Assert.That(notificationsTopic).StartsWith(TopicPrefix);
+
+ // But have different suffixes
+ await Assert.That(ordersTopic).IsNotEqualTo(eventsTopic);
+ await Assert.That(eventsTopic).IsNotEqualTo(notificationsTopic);
+ }
+}
diff --git a/TUnit.Example.Asp.Net.TestProject/KafkaTestBase.cs b/TUnit.Example.Asp.Net.TestProject/KafkaTestBase.cs
new file mode 100644
index 0000000000..9d34d9067d
--- /dev/null
+++ b/TUnit.Example.Asp.Net.TestProject/KafkaTestBase.cs
@@ -0,0 +1,53 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.Configuration;
+
+namespace TUnit.Example.Asp.Net.TestProject;
+
+///
+/// Base class for Kafka tests with per-test topic prefix isolation.
+/// Extends TestsBase (which provides container injection) and adds:
+/// - Unique topic prefix per test (using GetIsolatedPrefix helper)
+/// - Helper methods for topic management
+///
+///
+/// This class demonstrates the new simpler configuration API:
+/// - is auto-additive (no need to call base)
+/// - provides consistent isolation naming
+///
+[SuppressMessage("Usage", "TUnit0043:Property must use `required` keyword")]
+public abstract class KafkaTestBase : TestsBase
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public InMemoryKafka Kafka { get; init; } = null!;
+
+ ///
+ /// The unique topic prefix for this test.
+ /// All Kafka topics will be prefixed with this value.
+ ///
+ protected string TopicPrefix { get; private set; } = null!;
+
+ ///
+ /// Configures the application with a unique topic prefix for this test.
+ /// Uses the new simpler API - no need to call base or chain delegates.
+ ///
+ protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
+ {
+ // Generate unique topic prefix using the built-in helper
+ TopicPrefix = GetIsolatedPrefix();
+
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "Kafka:TopicPrefix", TopicPrefix }
+ });
+ }
+
+ ///
+ /// Gets the fully qualified topic name with prefix.
+ ///
+ protected string GetTopicName(string baseName) => $"{TopicPrefix}{baseName}";
+
+ ///
+ /// Gets the bootstrap address for the Kafka container.
+ ///
+ protected string GetBootstrapAddress() => Kafka.Container.GetBootstrapAddress();
+}
diff --git a/TUnit.Example.Asp.Net.TestProject/ParallelIsolationTests.cs b/TUnit.Example.Asp.Net.TestProject/ParallelIsolationTests.cs
new file mode 100644
index 0000000000..4452c1beae
--- /dev/null
+++ b/TUnit.Example.Asp.Net.TestProject/ParallelIsolationTests.cs
@@ -0,0 +1,87 @@
+using System.Net.Http.Json;
+using TUnit.Example.Asp.Net.Models;
+
+namespace TUnit.Example.Asp.Net.TestProject;
+
+///
+/// These tests demonstrate that parallel tests are completely isolated.
+/// Each test has its own table, so data from one test cannot leak into another.
+/// The [Repeat(3)] attribute creates multiple iterations that run in parallel,
+/// and each gets its own table based on the unique TestContext.Id.
+/// TUnit runs tests in parallel by default, so no attribute is needed.
+///
+public class ParallelIsolationTests : TodoTestBase
+{
+ ///
+ /// Creates 5 todos. Each of the 3 repetitions should see exactly 5.
+ /// If isolation failed, we'd see 10 or 15.
+ ///
+ [Test, Repeat(3)]
+ public async Task Test1_CreateFiveTodos_ReturnsExactlyFive()
+ {
+ var client = Factory.CreateClient();
+
+ // Create 5 todos
+ for (var i = 0; i < 5; i++)
+ {
+ await client.PostAsJsonAsync("/todos", new { Title = $"Parallel Todo {i}" });
+ }
+
+ // Verify exactly 5 (not 10 or 15 - isolation works!)
+ var todos = await client.GetFromJsonAsync>("/todos");
+ await Assert.That(todos!.Count).IsEqualTo(5);
+ }
+
+ ///
+ /// Creates 3 todos with different titles. Each repetition should see exactly 3.
+ ///
+ [Test, Repeat(3)]
+ public async Task Test2_CreateThreeTodos_ReturnsExactlyThree()
+ {
+ var client = Factory.CreateClient();
+
+ // Create 3 todos
+ for (var i = 0; i < 3; i++)
+ {
+ await client.PostAsJsonAsync("/todos", new { Title = $"Different {i}" });
+ }
+
+ // Verify exactly 3 (not 6, not 9 - isolation works!)
+ var todos = await client.GetFromJsonAsync>("/todos");
+ await Assert.That(todos!.Count).IsEqualTo(3);
+ }
+
+ ///
+ /// Each repetition starts with an empty table. If isolation failed,
+ /// we'd see data from previous repetitions.
+ ///
+ [Test, Repeat(3)]
+ public async Task Test3_StartsEmpty_ReturnZeroTodos()
+ {
+ var client = Factory.CreateClient();
+
+ // Fresh table should be empty
+ var todos = await client.GetFromJsonAsync>("/todos");
+ await Assert.That(todos!.Count).IsEqualTo(0);
+ }
+
+ ///
+ /// Creates a todo and verifies it can be retrieved. Each repetition
+ /// should have its own isolated todo.
+ ///
+ [Test, Repeat(3)]
+ public async Task Test4_CreateAndRetrieve_Works()
+ {
+ var client = Factory.CreateClient();
+
+ // Create
+ var createResponse = await client.PostAsJsonAsync("/todos", new { Title = "Isolated Todo" });
+ var created = await createResponse.Content.ReadFromJsonAsync();
+
+ // Retrieve
+ var retrieved = await client.GetFromJsonAsync($"/todos/{created!.Id}");
+
+ await Assert.That(retrieved!.Title).IsEqualTo("Isolated Todo");
+ await Assert.That(retrieved.Id).IsEqualTo(created.Id);
+ }
+}
diff --git a/TUnit.Example.Asp.Net.TestProject/RedisIsolationTests.cs b/TUnit.Example.Asp.Net.TestProject/RedisIsolationTests.cs
new file mode 100644
index 0000000000..d67838dc8f
--- /dev/null
+++ b/TUnit.Example.Asp.Net.TestProject/RedisIsolationTests.cs
@@ -0,0 +1,97 @@
+using System.Net;
+using System.Net.Http.Json;
+
+namespace TUnit.Example.Asp.Net.TestProject;
+
+///
+/// These tests demonstrate that parallel tests have isolated Redis key spaces.
+/// Each test has its own key prefix, so data from one test cannot leak into another.
+/// The [Repeat(3)] attribute creates multiple iterations that run in parallel,
+/// and each gets its own key prefix based on the unique TestContext.Id.
+///
+public class RedisIsolationTests : RedisTestBase
+{
+ ///
+ /// Sets a value and retrieves it. Each repetition should see only its own value.
+ /// If isolation failed, we might see values from other tests.
+ ///
+ [Test, Repeat(3)]
+ public async Task SetAndGet_ReturnsOwnValue()
+ {
+ var client = Factory.CreateClient();
+
+ // Set a value
+ await client.PostAsJsonAsync("/cache/mykey", new { Value = "my-isolated-value" });
+
+ // Get the value back
+ var response = await client.GetAsync("/cache/mykey");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var value = await response.Content.ReadAsStringAsync();
+ await Assert.That(value).IsEqualTo("\"my-isolated-value\"");
+ }
+
+ ///
+ /// Gets a key that was never set. Each test starts with an empty key space.
+ ///
+ [Test, Repeat(3)]
+ public async Task Get_WhenKeyDoesNotExist_Returns404()
+ {
+ var client = Factory.CreateClient();
+
+ var response = await client.GetAsync("/cache/nonexistent");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ ///
+ /// Deletes a key after setting it.
+ ///
+ [Test, Repeat(3)]
+ public async Task Delete_RemovesKey()
+ {
+ var client = Factory.CreateClient();
+
+ // Set a value
+ await client.PostAsJsonAsync("/cache/to-delete", new { Value = "delete-me" });
+
+ // Delete it
+ var deleteResponse = await client.DeleteAsync("/cache/to-delete");
+ await Assert.That(deleteResponse.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+
+ // Verify it's gone
+ var getResponse = await client.GetAsync("/cache/to-delete");
+ await Assert.That(getResponse.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ ///
+ /// Sets multiple keys. Each repetition should have its own isolated set of keys.
+ ///
+ [Test, Repeat(3)]
+ public async Task SetMultipleKeys_AllIsolated()
+ {
+ var client = Factory.CreateClient();
+
+ // Set 3 different keys
+ await client.PostAsJsonAsync("/cache/key1", new { Value = "value1" });
+ await client.PostAsJsonAsync("/cache/key2", new { Value = "value2" });
+ await client.PostAsJsonAsync("/cache/key3", new { Value = "value3" });
+
+ // Verify all exist with correct values
+ var response1 = await client.GetAsync("/cache/key1");
+ var response2 = await client.GetAsync("/cache/key2");
+ var response3 = await client.GetAsync("/cache/key3");
+
+ await Assert.That(response1.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ await Assert.That(response2.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ await Assert.That(response3.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var value1 = await response1.Content.ReadAsStringAsync();
+ var value2 = await response2.Content.ReadAsStringAsync();
+ var value3 = await response3.Content.ReadAsStringAsync();
+
+ await Assert.That(value1).IsEqualTo("\"value1\"");
+ await Assert.That(value2).IsEqualTo("\"value2\"");
+ await Assert.That(value3).IsEqualTo("\"value3\"");
+ }
+}
diff --git a/TUnit.Example.Asp.Net.TestProject/RedisTestBase.cs b/TUnit.Example.Asp.Net.TestProject/RedisTestBase.cs
new file mode 100644
index 0000000000..09f7cbc4af
--- /dev/null
+++ b/TUnit.Example.Asp.Net.TestProject/RedisTestBase.cs
@@ -0,0 +1,93 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.Configuration;
+using StackExchange.Redis;
+
+namespace TUnit.Example.Asp.Net.TestProject;
+
+///
+/// Base class for Redis tests with per-test key prefix isolation.
+/// Extends TestsBase (which provides container injection) and adds:
+/// - Unique key prefix per test (using GetIsolatedPrefix helper)
+/// - Key cleanup after tests
+///
+///
+/// This class demonstrates the new simpler configuration API:
+/// - is auto-additive (no need to call base)
+/// - provides consistent isolation naming
+///
+public abstract class RedisTestBase : TestsBase
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public InMemoryRedis Redis { get; init; } = null!;
+
+ ///
+ /// The unique key prefix for this test.
+ /// All cache operations will use keys prefixed with this value.
+ ///
+ protected string KeyPrefix { get; private set; } = null!;
+
+ ///
+ /// Configures the application with a unique key prefix for this test.
+ /// Uses the new simpler API - no need to call base or chain delegates.
+ ///
+ protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
+ {
+ // Generate unique key prefix using the built-in helper
+ KeyPrefix = GetIsolatedPrefix();
+
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "Redis:KeyPrefix", KeyPrefix }
+ });
+ }
+
+ [After(HookType.Test)]
+ public async Task CleanupRedisKeys()
+ {
+ if (string.IsNullOrEmpty(KeyPrefix))
+ {
+ return;
+ }
+
+ try
+ {
+ await using var connection = await ConnectionMultiplexer.ConnectAsync(
+ Redis.Container.GetConnectionString());
+ var server = connection.GetServer(connection.GetEndPoints()[0]);
+ var db = connection.GetDatabase();
+
+ // Find and delete all keys with our prefix
+ await foreach (var key in server.KeysAsync(pattern: $"{KeyPrefix}*"))
+ {
+ await db.KeyDeleteAsync(key);
+ }
+ }
+ catch
+ {
+ // Cleanup failures shouldn't fail the test
+ }
+ }
+
+ ///
+ /// Gets a value directly from Redis (bypassing the API).
+ ///
+ protected async Task GetRawValueAsync(string key)
+ {
+ await using var connection = await ConnectionMultiplexer.ConnectAsync(
+ Redis.Container.GetConnectionString());
+ var db = connection.GetDatabase();
+ var value = await db.StringGetAsync($"{KeyPrefix}{key}");
+ return value.HasValue ? value.ToString() : null;
+ }
+
+ ///
+ /// Sets a value directly in Redis (bypassing the API).
+ ///
+ protected async Task SetRawValueAsync(string key, string value)
+ {
+ await using var connection = await ConnectionMultiplexer.ConnectAsync(
+ Redis.Container.GetConnectionString());
+ var db = connection.GetDatabase();
+ await db.StringSetAsync($"{KeyPrefix}{key}", value);
+ }
+}
diff --git a/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj b/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj
index abc7a1390f..7b3f975755 100644
--- a/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj
+++ b/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj
@@ -7,12 +7,14 @@
+
+
diff --git a/TUnit.Example.Asp.Net.TestProject/Tests.cs b/TUnit.Example.Asp.Net.TestProject/Tests.cs
index ef15d29ad5..a8ce59594f 100644
--- a/TUnit.Example.Asp.Net.TestProject/Tests.cs
+++ b/TUnit.Example.Asp.Net.TestProject/Tests.cs
@@ -1,11 +1,15 @@
-namespace TUnit.Example.Asp.Net.TestProject;
+namespace TUnit.Example.Asp.Net.TestProject;
+///
+/// Simple integration tests using the WebApplicationTest pattern via TestsBase.
+/// These tests don't need per-test isolation (no database writes).
+///
public class Tests : TestsBase
{
[Test]
- public async Task Test()
+ public async Task Ping_ReturnsHelloWorld()
{
- var client = WebApplicationFactory.CreateClient();
+ var client = Factory.CreateClient();
var response = await client.GetAsync("/ping");
diff --git a/TUnit.Example.Asp.Net.TestProject/TestsBase.cs b/TUnit.Example.Asp.Net.TestProject/TestsBase.cs
index ca660495c0..2682e10ed8 100644
--- a/TUnit.Example.Asp.Net.TestProject/TestsBase.cs
+++ b/TUnit.Example.Asp.Net.TestProject/TestsBase.cs
@@ -1,7 +1,13 @@
-namespace TUnit.Example.Asp.Net.TestProject;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Configuration;
+using TUnit.AspNetCore;
-public abstract class TestsBase
+namespace TUnit.Example.Asp.Net.TestProject;
+
+///
+/// Base class for ASP.NET Core integration tests using the WebApplicationTest pattern.
+/// Provides shared container injection and configuration for all test classes.
+///
+public abstract class TestsBase : WebApplicationTest
{
- [ClassDataSource(Shared = SharedType.PerTestSession)]
- public required WebApplicationFactory WebApplicationFactory { get; init; }
}
diff --git a/TUnit.Example.Asp.Net.TestProject/TodoApiTests.cs b/TUnit.Example.Asp.Net.TestProject/TodoApiTests.cs
new file mode 100644
index 0000000000..6f2f6669ea
--- /dev/null
+++ b/TUnit.Example.Asp.Net.TestProject/TodoApiTests.cs
@@ -0,0 +1,122 @@
+using System.Net;
+using System.Net.Http.Json;
+using TUnit.AspNetCore;
+using TUnit.Example.Asp.Net.Models;
+
+namespace TUnit.Example.Asp.Net.TestProject;
+
+///
+/// Integration tests for the Todo API demonstrating per-test table isolation.
+/// Each test gets its own table within the shared PostgreSQL container.
+///
+public class TodoApiTests : TodoTestBase
+{
+ [Test]
+ public async Task GetTodos_WhenEmpty_ReturnsEmptyList()
+ {
+ var client = Factory.CreateClient();
+
+ var response = await client.GetAsync("/todos");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ var todos = await response.Content.ReadFromJsonAsync>();
+ await Assert.That(todos).IsNotNull();
+ await Assert.That(todos!.Count).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task CreateTodo_ReturnsCreatedTodo()
+ {
+ var client = Factory.CreateClient();
+
+ var response = await client.PostAsJsonAsync("/todos", new { Title = "Test Todo" });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
+ var todo = await response.Content.ReadFromJsonAsync();
+ await Assert.That(todo).IsNotNull();
+ await Assert.That(todo!.Title).IsEqualTo("Test Todo");
+ await Assert.That(todo.IsComplete).IsFalse();
+ }
+
+ [Test]
+ public async Task GetTodo_AfterCreate_ReturnsTodo()
+ {
+ var client = Factory.CreateClient();
+
+ // Create
+ var createResponse = await client.PostAsJsonAsync("/todos", new { Title = "Get Me" });
+ var created = await createResponse.Content.ReadFromJsonAsync();
+
+ // Get
+ var getResponse = await client.GetAsync($"/todos/{created!.Id}");
+
+ await Assert.That(getResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ var todo = await getResponse.Content.ReadFromJsonAsync();
+ await Assert.That(todo!.Title).IsEqualTo("Get Me");
+ }
+
+ [Test]
+ public async Task UpdateTodo_ChangesIsComplete()
+ {
+ var client = Factory.CreateClient();
+
+ // Create
+ var createResponse = await client.PostAsJsonAsync("/todos", new { Title = "Update Me" });
+ var created = await createResponse.Content.ReadFromJsonAsync();
+
+ // Update
+ var updateResponse = await client.PutAsJsonAsync(
+ $"/todos/{created!.Id}",
+ new { Title = "Updated", IsComplete = true });
+
+ await Assert.That(updateResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ var updated = await updateResponse.Content.ReadFromJsonAsync();
+ await Assert.That(updated!.IsComplete).IsTrue();
+ await Assert.That(updated.Title).IsEqualTo("Updated");
+ }
+
+ [Test]
+ public async Task DeleteTodo_RemovesTodo()
+ {
+ var client = Factory.CreateClient();
+
+ // Create
+ var createResponse = await client.PostAsJsonAsync("/todos", new { Title = "Delete Me" });
+ var created = await createResponse.Content.ReadFromJsonAsync();
+
+ // Delete
+ var deleteResponse = await client.DeleteAsync($"/todos/{created!.Id}");
+ await Assert.That(deleteResponse.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+
+ // Verify gone
+ var getResponse = await client.GetAsync($"/todos/{created.Id}");
+ await Assert.That(getResponse.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task GetTodo_WhenNotFound_Returns404()
+ {
+ var client = Factory.CreateClient();
+
+ var response = await client.GetAsync("/todos/99999");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
+ }
+
+ [Test]
+ public async Task CreateMultipleTodos_GetAllReturnsThem()
+ {
+ var client = Factory.CreateClient();
+
+ // Create multiple todos
+ await client.PostAsJsonAsync("/todos", new { Title = "Todo 1" });
+ await client.PostAsJsonAsync("/todos", new { Title = "Todo 2" });
+ await client.PostAsJsonAsync("/todos", new { Title = "Todo 3" });
+
+ // Get all
+ var response = await client.GetAsync("/todos");
+ var todos = await response.Content.ReadFromJsonAsync>();
+
+ await Assert.That(todos!.Count).IsEqualTo(3);
+ }
+}
diff --git a/TUnit.Example.Asp.Net.TestProject/TodoSeededTests.cs b/TUnit.Example.Asp.Net.TestProject/TodoSeededTests.cs
new file mode 100644
index 0000000000..c4b36d5b96
--- /dev/null
+++ b/TUnit.Example.Asp.Net.TestProject/TodoSeededTests.cs
@@ -0,0 +1,80 @@
+using System.Net;
+using System.Net.Http.Json;
+using TUnit.Example.Asp.Net.Models;
+
+namespace TUnit.Example.Asp.Net.TestProject;
+
+///
+/// Example tests using the TodoTestBase for seeded data scenarios.
+/// Demonstrates using SeedTodosAsync to set up test preconditions.
+///
+public class TodoSeededTests : TodoTestBase
+{
+ [Test]
+ public async Task GetAll_WithSeededData_ReturnsAll()
+ {
+ // Seed data directly to database (bypassing API)
+ await SeedTodosAsync("Todo 1", "Todo 2", "Todo 3");
+
+ var client = Factory.CreateClient();
+ var todos = await client.GetFromJsonAsync>("/todos");
+
+ await Assert.That(todos!.Count).IsEqualTo(3);
+ }
+
+ [Test]
+ public async Task GetAll_WithMixedCompletionStatus_ReturnsAll()
+ {
+ // Seed mixed data
+ await SeedTodosAsync("Incomplete 1", "Incomplete 2");
+ await SeedCompletedTodoAsync("Completed 1");
+
+ var client = Factory.CreateClient();
+ var todos = await client.GetFromJsonAsync>("/todos");
+
+ await Assert.That(todos!.Count).IsEqualTo(3);
+ await Assert.That(todos.Count(t => t.IsComplete)).IsEqualTo(1);
+ await Assert.That(todos.Count(t => !t.IsComplete)).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task Delete_FromSeededData_RemovesOne()
+ {
+ // Seed data
+ await SeedTodosAsync("Keep 1", "Delete Me", "Keep 2");
+
+ var client = Factory.CreateClient();
+
+ // Get all to find the one to delete
+ var todos = await client.GetFromJsonAsync>("/todos");
+ var toDelete = todos!.First(t => t.Title == "Delete Me");
+
+ // Delete
+ var deleteResponse = await client.DeleteAsync($"/todos/{toDelete.Id}");
+ await Assert.That(deleteResponse.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
+
+ // Verify count via direct database access
+ var count = await GetTodoCountAsync();
+ await Assert.That(count).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task DatabaseAndApiInSync()
+ {
+ var client = Factory.CreateClient();
+
+ // Create via API
+ await client.PostAsJsonAsync("/todos", new { Title = "API Created" });
+ await client.PostAsJsonAsync("/todos", new { Title = "API Created 2" });
+
+ // Seed directly
+ await SeedTodosAsync("DB Seeded");
+
+ // Both API and database should see all 3
+ var apiCount = (await client.GetFromJsonAsync>("/todos"))!.Count;
+ var dbCount = await GetTodoCountAsync();
+
+ await Assert.That(apiCount).IsEqualTo(3);
+ await Assert.That(dbCount).IsEqualTo(3);
+ }
+}
diff --git a/TUnit.Example.Asp.Net.TestProject/TodoTestBase.cs b/TUnit.Example.Asp.Net.TestProject/TodoTestBase.cs
new file mode 100644
index 0000000000..bf8eb27817
--- /dev/null
+++ b/TUnit.Example.Asp.Net.TestProject/TodoTestBase.cs
@@ -0,0 +1,142 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.Configuration;
+using Npgsql;
+
+namespace TUnit.Example.Asp.Net.TestProject;
+
+///
+/// Base class for Todo API tests with per-test table isolation.
+/// Extends TestsBase (which provides container injection) and adds:
+/// - Unique table name per test (using GetIsolatedName helper)
+/// - Table creation/cleanup
+/// - Seeding helpers
+///
+///
+/// This class demonstrates the async setup pattern:
+/// - runs before factory creation for async operations
+/// - uses results from SetupAsync
+/// - provides consistent isolation naming
+///
+[SuppressMessage("Usage", "TUnit0043:Property must use `required` keyword")]
+public abstract class TodoTestBase : TestsBase
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public InMemoryPostgreSqlDatabase PostgreSql { get; init; } = null!;
+
+ ///
+ /// The unique table name for this test.
+ ///
+ protected string TableName { get; private set; } = null!;
+
+ ///
+ /// Performs async setup: generates unique table name and creates the table.
+ /// Runs BEFORE ConfigureTestConfiguration, so TableName is ready for use.
+ ///
+ protected override async Task SetupAsync()
+ {
+ // Generate unique table name using the built-in helper
+ TableName = GetIsolatedName("todos");
+
+ // Create the table - this is async!
+ await CreateTableAsync(TableName);
+ }
+
+ ///
+ /// Configures the application with the unique table name.
+ /// TableName is already set by SetupAsync.
+ ///
+ protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "Database:TableName", TableName }
+ });
+ }
+
+ [After(HookType.Test)]
+ public async Task CleanupTable()
+ {
+ if (!string.IsNullOrEmpty(TableName))
+ {
+ await DropTableAsync(TableName);
+ }
+ }
+
+ ///
+ /// Creates the table for this test.
+ ///
+ protected async Task CreateTableAsync(string tableName)
+ {
+ await using var connection = new NpgsqlConnection(PostgreSql.Container.GetConnectionString());
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"""
+ CREATE TABLE IF NOT EXISTS "{tableName}" (
+ id SERIAL PRIMARY KEY,
+ title TEXT NOT NULL,
+ is_complete BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP DEFAULT NOW()
+ )
+ """;
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ ///
+ /// Drops the table after the test.
+ ///
+ protected async Task DropTableAsync(string tableName)
+ {
+ await using var connection = new NpgsqlConnection(PostgreSql.Container.GetConnectionString());
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"DROP TABLE IF EXISTS \"{tableName}\"";
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ ///
+ /// Seeds the database with todos directly (bypassing the API).
+ ///
+ protected async Task SeedTodosAsync(params string[] titles)
+ {
+ await using var connection = new NpgsqlConnection(PostgreSql.Container.GetConnectionString());
+ await connection.OpenAsync();
+
+ foreach (var title in titles)
+ {
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"INSERT INTO \"{TableName}\" (title) VALUES (@title)";
+ cmd.Parameters.AddWithValue("title", title);
+ await cmd.ExecuteNonQueryAsync();
+ }
+ }
+
+ ///
+ /// Seeds a completed todo directly to the database.
+ ///
+ protected async Task SeedCompletedTodoAsync(string title)
+ {
+ await using var connection = new NpgsqlConnection(PostgreSql.Container.GetConnectionString());
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"INSERT INTO \"{TableName}\" (title, is_complete) VALUES (@title, TRUE)";
+ cmd.Parameters.AddWithValue("title", title);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ ///
+ /// Gets the count of todos directly from the database.
+ ///
+ protected async Task GetTodoCountAsync()
+ {
+ await using var connection = new NpgsqlConnection(PostgreSql.Container.GetConnectionString());
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"SELECT COUNT(*) FROM \"{TableName}\"";
+ var result = await cmd.ExecuteScalarAsync();
+ return Convert.ToInt32(result);
+ }
+}
diff --git a/TUnit.Example.Asp.Net.TestProject/WebApplicationFactory.cs b/TUnit.Example.Asp.Net.TestProject/WebApplicationFactory.cs
index b6b67e9f7a..35bb1e4a85 100644
--- a/TUnit.Example.Asp.Net.TestProject/WebApplicationFactory.cs
+++ b/TUnit.Example.Asp.Net.TestProject/WebApplicationFactory.cs
@@ -1,32 +1,47 @@
-using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.Mvc.Testing;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
+using TUnit.AspNetCore;
using TUnit.Core.Interfaces;
namespace TUnit.Example.Asp.Net.TestProject;
-public class WebApplicationFactory : WebApplicationFactory, IAsyncInitializer
+///
+/// Factory for integration tests. Extends TestWebApplicationFactory to support
+/// per-test isolation via the WebApplicationTest pattern.
+///
+/// Note: This factory intentionally does NOT have required container properties.
+/// Instead, test classes inject containers and provide configuration overrides
+/// via OverrideConfigurationAsync. This allows the factory to be created with new().
+///
+public class WebApplicationFactory : TestWebApplicationFactory, IAsyncInitializer
{
private int _configuredWebHostCalled;
- [ClassDataSource(Shared = SharedType.PerTestSession)]
- public required InMemoryKafka Kafka { get; init; }
+ public int ConfiguredWebHostCalled => _configuredWebHostCalled;
- [ClassDataSource(Shared = SharedType.PerTestSession)]
- public required KafkaUI KafkaUI { get; init; }
+ ///
+ /// PostgreSQL container - shared across all tests in the session.
+ ///
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public InMemoryPostgreSqlDatabase PostgreSql { get; init; } = null!;
+ ///
+ /// Redis container - shared across all tests in the session.
+ ///
[ClassDataSource(Shared = SharedType.PerTestSession)]
- public required InMemoryRedis Redis { get; init; }
+ public InMemoryRedis Redis { get; init; } = null!;
- [ClassDataSource(Shared = SharedType.PerTestSession)]
- public required InMemoryPostgreSqlDatabase PostgreSql { get; init; }
+ ///
+ /// Kafka container - shared across all tests in the session.
+ ///
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public InMemoryKafka Kafka { get; init; } = null!;
- public int ConfiguredWebHostCalled => _configuredWebHostCalled;
public Task InitializeAsync()
{
_ = Server;
-
return Task.CompletedTask;
}
@@ -34,13 +49,16 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
{
Interlocked.Increment(ref _configuredWebHostCalled);
+ // Base configuration - tests will override with actual container connection strings
+ // via OverrideConfigurationAsync
builder.ConfigureAppConfiguration((context, configBuilder) =>
{
+ // Defaults that will be overridden by tests
configBuilder.AddInMemoryCollection(new Dictionary
{
+ { "Database:ConnectionString", PostgreSql.Container.GetConnectionString() },
{ "Redis:ConnectionString", Redis.Container.GetConnectionString() },
- { "PostgreSql:ConnectionString", PostgreSql.Container.GetConnectionString() },
- { "Kafka:ConnectionString", Kafka.Container.GetBootstrapAddress() },
+ { "Kafka:ConnectionString", Kafka.Container.GetBootstrapAddress() }
});
});
}
diff --git a/TUnit.Example.Asp.Net/Configuration/DatabaseOptions.cs b/TUnit.Example.Asp.Net/Configuration/DatabaseOptions.cs
new file mode 100644
index 0000000000..1626eb23ed
--- /dev/null
+++ b/TUnit.Example.Asp.Net/Configuration/DatabaseOptions.cs
@@ -0,0 +1,9 @@
+namespace TUnit.Example.Asp.Net.Configuration;
+
+public class DatabaseOptions
+{
+ public const string SectionName = "Database";
+
+ public string ConnectionString { get; set; } = string.Empty;
+ public string TableName { get; set; } = "todos";
+}
diff --git a/TUnit.Example.Asp.Net/Configuration/KafkaOptions.cs b/TUnit.Example.Asp.Net/Configuration/KafkaOptions.cs
new file mode 100644
index 0000000000..e0b5dbcae8
--- /dev/null
+++ b/TUnit.Example.Asp.Net/Configuration/KafkaOptions.cs
@@ -0,0 +1,9 @@
+namespace TUnit.Example.Asp.Net.Configuration;
+
+public class KafkaOptions
+{
+ public const string SectionName = "Kafka";
+
+ public string ConnectionString { get; set; } = string.Empty;
+ public string TopicPrefix { get; set; } = "";
+}
diff --git a/TUnit.Example.Asp.Net/Configuration/RedisOptions.cs b/TUnit.Example.Asp.Net/Configuration/RedisOptions.cs
new file mode 100644
index 0000000000..6170f8ad45
--- /dev/null
+++ b/TUnit.Example.Asp.Net/Configuration/RedisOptions.cs
@@ -0,0 +1,9 @@
+namespace TUnit.Example.Asp.Net.Configuration;
+
+public class RedisOptions
+{
+ public const string SectionName = "Redis";
+
+ public string ConnectionString { get; set; } = string.Empty;
+ public string KeyPrefix { get; set; } = "";
+}
diff --git a/TUnit.Example.Asp.Net/Models/Todo.cs b/TUnit.Example.Asp.Net/Models/Todo.cs
new file mode 100644
index 0000000000..8f0266bf73
--- /dev/null
+++ b/TUnit.Example.Asp.Net/Models/Todo.cs
@@ -0,0 +1,9 @@
+namespace TUnit.Example.Asp.Net.Models;
+
+public class Todo
+{
+ public int Id { get; set; }
+ public required string Title { get; set; }
+ public bool IsComplete { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/TUnit.Example.Asp.Net/Program.cs b/TUnit.Example.Asp.Net/Program.cs
index fc25e82519..d4663de0bb 100644
--- a/TUnit.Example.Asp.Net/Program.cs
+++ b/TUnit.Example.Asp.Net/Program.cs
@@ -1,9 +1,21 @@
+using TUnit.Example.Asp.Net.Configuration;
+using TUnit.Example.Asp.Net.Models;
+using TUnit.Example.Asp.Net.Repositories;
+using TUnit.Example.Asp.Net.Services;
+
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
+builder.Services.Configure(builder.Configuration.GetSection(DatabaseOptions.SectionName));
+builder.Services.Configure(builder.Configuration.GetSection(RedisOptions.SectionName));
+builder.Services.Configure(builder.Configuration.GetSection(KafkaOptions.SectionName));
+builder.Services.AddScoped();
+builder.Services.AddScoped();
var app = builder.Build();
+var logger = app.Services.GetRequiredService().CreateLogger("Endpoints");
+
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
@@ -11,8 +23,58 @@
app.UseHttpsRedirection();
-app.MapGet("/ping", () => "Hello, World!");
+app.MapGet("/ping", () =>
+{
+ logger.LogInformation("Ping endpoint called");
+ return "Hello, World!";
+});
+
+// Todo CRUD endpoints
+app.MapGet("/todos", async (ITodoRepository repo) =>
+ await repo.GetAllAsync());
+
+app.MapGet("/todos/{id:int}", async (int id, ITodoRepository repo) =>
+ await repo.GetByIdAsync(id) is { } todo
+ ? Results.Ok(todo)
+ : Results.NotFound());
+
+app.MapPost("/todos", async (CreateTodoRequest request, ITodoRepository repo) =>
+{
+ var todo = await repo.CreateAsync(new Todo { Title = request.Title });
+ return Results.Created($"/todos/{todo.Id}", todo);
+});
+
+app.MapPut("/todos/{id:int}", async (int id, UpdateTodoRequest request, ITodoRepository repo) =>
+ await repo.UpdateAsync(id, new Todo { Title = request.Title, IsComplete = request.IsComplete }) is { } todo
+ ? Results.Ok(todo)
+ : Results.NotFound());
+
+app.MapDelete("/todos/{id:int}", async (int id, ITodoRepository repo) =>
+ await repo.DeleteAsync(id)
+ ? Results.NoContent()
+ : Results.NotFound());
+
+// Cache endpoints (Redis)
+app.MapGet("/cache/{key}", async (string key, ICacheService cache) =>
+ await cache.GetAsync(key) is { } value
+ ? Results.Ok(value)
+ : Results.NotFound());
+
+app.MapPost("/cache/{key}", async (string key, CacheValueRequest request, ICacheService cache) =>
+{
+ await cache.SetAsync(key, request.Value);
+ return Results.Created($"/cache/{key}", request.Value);
+});
+
+app.MapDelete("/cache/{key}", async (string key, ICacheService cache) =>
+ await cache.DeleteAsync(key)
+ ? Results.NoContent()
+ : Results.NotFound());
app.Run();
public partial class Program;
+
+public record CreateTodoRequest(string Title);
+public record UpdateTodoRequest(string Title, bool IsComplete);
+public record CacheValueRequest(string Value);
diff --git a/TUnit.Example.Asp.Net/Repositories/ITodoRepository.cs b/TUnit.Example.Asp.Net/Repositories/ITodoRepository.cs
new file mode 100644
index 0000000000..62be1bdfe2
--- /dev/null
+++ b/TUnit.Example.Asp.Net/Repositories/ITodoRepository.cs
@@ -0,0 +1,12 @@
+using TUnit.Example.Asp.Net.Models;
+
+namespace TUnit.Example.Asp.Net.Repositories;
+
+public interface ITodoRepository
+{
+ Task> GetAllAsync();
+ Task GetByIdAsync(int id);
+ Task CreateAsync(Todo todo);
+ Task UpdateAsync(int id, Todo todo);
+ Task DeleteAsync(int id);
+}
diff --git a/TUnit.Example.Asp.Net/Repositories/TodoRepository.cs b/TUnit.Example.Asp.Net/Repositories/TodoRepository.cs
new file mode 100644
index 0000000000..85bf999539
--- /dev/null
+++ b/TUnit.Example.Asp.Net/Repositories/TodoRepository.cs
@@ -0,0 +1,169 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Npgsql;
+using TUnit.Example.Asp.Net.Configuration;
+using TUnit.Example.Asp.Net.Models;
+
+namespace TUnit.Example.Asp.Net.Repositories;
+
+public class TodoRepository : ITodoRepository
+{
+ private readonly string _connectionString;
+ private readonly string _tableName;
+ private readonly ILogger _logger;
+
+ public TodoRepository(IOptions options, ILogger logger)
+ {
+ _connectionString = options.Value.ConnectionString;
+ _tableName = options.Value.TableName;
+ _logger = logger;
+ }
+
+ public async Task> GetAllAsync()
+ {
+ _logger.LogDebug("Fetching all todos from table {TableName}", _tableName);
+
+ var todos = new List();
+
+ await using var connection = new NpgsqlConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"SELECT id, title, is_complete, created_at FROM \"{_tableName}\" ORDER BY created_at DESC";
+
+ await using var reader = await cmd.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ todos.Add(new Todo
+ {
+ Id = reader.GetInt32(0),
+ Title = reader.GetString(1),
+ IsComplete = reader.GetBoolean(2),
+ CreatedAt = reader.GetDateTime(3)
+ });
+ }
+
+ _logger.LogInformation("Retrieved {Count} todos from table {TableName}", todos.Count, _tableName);
+ return todos;
+ }
+
+ public async Task GetByIdAsync(int id)
+ {
+ _logger.LogDebug("Fetching todo {TodoId} from table {TableName}", id, _tableName);
+
+ await using var connection = new NpgsqlConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"SELECT id, title, is_complete, created_at FROM \"{_tableName}\" WHERE id = @id";
+ cmd.Parameters.AddWithValue("id", id);
+
+ await using var reader = await cmd.ExecuteReaderAsync();
+ if (await reader.ReadAsync())
+ {
+ var todo = new Todo
+ {
+ Id = reader.GetInt32(0),
+ Title = reader.GetString(1),
+ IsComplete = reader.GetBoolean(2),
+ CreatedAt = reader.GetDateTime(3)
+ };
+ _logger.LogInformation("Found todo {TodoId}: {Title}", todo.Id, todo.Title);
+ return todo;
+ }
+
+ _logger.LogWarning("Todo {TodoId} not found in table {TableName}", id, _tableName);
+ return null;
+ }
+
+ public async Task CreateAsync(Todo todo)
+ {
+ _logger.LogDebug("Creating todo with title {Title} in table {TableName}", todo.Title, _tableName);
+
+ await using var connection = new NpgsqlConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"""
+ INSERT INTO "{_tableName}" (title, is_complete, created_at)
+ VALUES (@title, @is_complete, @created_at)
+ RETURNING id, title, is_complete, created_at
+ """;
+ cmd.Parameters.AddWithValue("title", todo.Title);
+ cmd.Parameters.AddWithValue("is_complete", todo.IsComplete);
+ cmd.Parameters.AddWithValue("created_at", todo.CreatedAt);
+
+ await using var reader = await cmd.ExecuteReaderAsync();
+ await reader.ReadAsync();
+
+ var created = new Todo
+ {
+ Id = reader.GetInt32(0),
+ Title = reader.GetString(1),
+ IsComplete = reader.GetBoolean(2),
+ CreatedAt = reader.GetDateTime(3)
+ };
+
+ _logger.LogInformation("Created todo {TodoId}: {Title}", created.Id, created.Title);
+ return created;
+ }
+
+ public async Task UpdateAsync(int id, Todo todo)
+ {
+ _logger.LogDebug("Updating todo {TodoId} in table {TableName}", id, _tableName);
+
+ await using var connection = new NpgsqlConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"""
+ UPDATE "{_tableName}"
+ SET title = @title, is_complete = @is_complete
+ WHERE id = @id
+ RETURNING id, title, is_complete, created_at
+ """;
+ cmd.Parameters.AddWithValue("id", id);
+ cmd.Parameters.AddWithValue("title", todo.Title);
+ cmd.Parameters.AddWithValue("is_complete", todo.IsComplete);
+
+ await using var reader = await cmd.ExecuteReaderAsync();
+ if (await reader.ReadAsync())
+ {
+ var updated = new Todo
+ {
+ Id = reader.GetInt32(0),
+ Title = reader.GetString(1),
+ IsComplete = reader.GetBoolean(2),
+ CreatedAt = reader.GetDateTime(3)
+ };
+ _logger.LogInformation("Updated todo {TodoId}: {Title}, IsComplete={IsComplete}", updated.Id, updated.Title, updated.IsComplete);
+ return updated;
+ }
+
+ _logger.LogWarning("Failed to update todo {TodoId} - not found in table {TableName}", id, _tableName);
+ return null;
+ }
+
+ public async Task DeleteAsync(int id)
+ {
+ _logger.LogDebug("Deleting todo {TodoId} from table {TableName}", id, _tableName);
+
+ await using var connection = new NpgsqlConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"DELETE FROM \"{_tableName}\" WHERE id = @id";
+ cmd.Parameters.AddWithValue("id", id);
+
+ var rowsAffected = await cmd.ExecuteNonQueryAsync();
+
+ if (rowsAffected > 0)
+ {
+ _logger.LogInformation("Deleted todo {TodoId} from table {TableName}", id, _tableName);
+ return true;
+ }
+
+ _logger.LogWarning("Failed to delete todo {TodoId} - not found in table {TableName}", id, _tableName);
+ return false;
+ }
+}
diff --git a/TUnit.Example.Asp.Net/Services/ICacheService.cs b/TUnit.Example.Asp.Net/Services/ICacheService.cs
new file mode 100644
index 0000000000..6f99d64731
--- /dev/null
+++ b/TUnit.Example.Asp.Net/Services/ICacheService.cs
@@ -0,0 +1,9 @@
+namespace TUnit.Example.Asp.Net.Services;
+
+public interface ICacheService
+{
+ Task GetAsync(string key);
+ Task SetAsync(string key, string value, TimeSpan? expiry = null);
+ Task DeleteAsync(string key);
+ Task ExistsAsync(string key);
+}
diff --git a/TUnit.Example.Asp.Net/Services/RedisCacheService.cs b/TUnit.Example.Asp.Net/Services/RedisCacheService.cs
new file mode 100644
index 0000000000..4feb2d6eda
--- /dev/null
+++ b/TUnit.Example.Asp.Net/Services/RedisCacheService.cs
@@ -0,0 +1,101 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StackExchange.Redis;
+using TUnit.Example.Asp.Net.Configuration;
+
+namespace TUnit.Example.Asp.Net.Services;
+
+public class RedisCacheService : ICacheService, IAsyncDisposable
+{
+ private readonly RedisOptions _options;
+ private readonly ILogger _logger;
+ private ConnectionMultiplexer? _connection;
+ private IDatabase? _database;
+
+ public RedisCacheService(IOptions options, ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ private async Task GetDatabaseAsync()
+ {
+ if (_database is not null)
+ {
+ return _database;
+ }
+
+ _connection = await ConnectionMultiplexer.ConnectAsync(_options.ConnectionString);
+ _database = _connection.GetDatabase();
+ return _database;
+ }
+
+ private string GetPrefixedKey(string key) =>
+ string.IsNullOrEmpty(_options.KeyPrefix) ? key : $"{_options.KeyPrefix}{key}";
+
+ public async Task GetAsync(string key)
+ {
+ var prefixedKey = GetPrefixedKey(key);
+ _logger.LogDebug("Getting cache key {Key}", prefixedKey);
+
+ var db = await GetDatabaseAsync();
+ var value = await db.StringGetAsync(prefixedKey);
+
+ if (value.HasValue)
+ {
+ _logger.LogInformation("Cache hit for key {Key}", prefixedKey);
+ return value.ToString();
+ }
+
+ _logger.LogDebug("Cache miss for key {Key}", prefixedKey);
+ return null;
+ }
+
+ public async Task SetAsync(string key, string value, TimeSpan? expiry = null)
+ {
+ var prefixedKey = GetPrefixedKey(key);
+ _logger.LogDebug("Setting cache key {Key}", prefixedKey);
+
+ var db = await GetDatabaseAsync();
+ await db.StringSetAsync(prefixedKey, value, expiry);
+
+ _logger.LogInformation("Cached value for key {Key}, expiry={Expiry}", prefixedKey, expiry?.ToString() ?? "none");
+ }
+
+ public async Task DeleteAsync(string key)
+ {
+ var prefixedKey = GetPrefixedKey(key);
+ _logger.LogDebug("Deleting cache key {Key}", prefixedKey);
+
+ var db = await GetDatabaseAsync();
+ var deleted = await db.KeyDeleteAsync(prefixedKey);
+
+ if (deleted)
+ {
+ _logger.LogInformation("Deleted cache key {Key}", prefixedKey);
+ }
+ else
+ {
+ _logger.LogDebug("Cache key {Key} not found for deletion", prefixedKey);
+ }
+
+ return deleted;
+ }
+
+ public async Task ExistsAsync(string key)
+ {
+ var prefixedKey = GetPrefixedKey(key);
+ var db = await GetDatabaseAsync();
+ var exists = await db.KeyExistsAsync(prefixedKey);
+ _logger.LogDebug("Cache key {Key} exists: {Exists}", prefixedKey, exists);
+ return exists;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_connection is not null)
+ {
+ await _connection.DisposeAsync();
+ }
+ }
+}
diff --git a/TUnit.Example.Asp.Net/TUnit.Example.Asp.Net.csproj b/TUnit.Example.Asp.Net/TUnit.Example.Asp.Net.csproj
index 549f17e876..adf9850290 100644
--- a/TUnit.Example.Asp.Net/TUnit.Example.Asp.Net.csproj
+++ b/TUnit.Example.Asp.Net/TUnit.Example.Asp.Net.csproj
@@ -10,6 +10,8 @@
+
+
diff --git a/TUnit.Pipeline/Modules/RunAspNetTestsModule.cs b/TUnit.Pipeline/Modules/RunAspNetTestsModule.cs
index e6c974434a..19affa0735 100644
--- a/TUnit.Pipeline/Modules/RunAspNetTestsModule.cs
+++ b/TUnit.Pipeline/Modules/RunAspNetTestsModule.cs
@@ -24,7 +24,7 @@ public class RunAspNetTestsModule : Module
Configuration = Configuration.Release,
Framework = "net10.0",
WorkingDirectory = project.Folder!,
- Arguments = ["--ignore-exit-code", "8", "--hangdump", "--hangdump-filename", "hangdump.aspnet-tests.dmp", "--hangdump-timeout", "5m"],
+ Arguments = ["--hangdump", "--hangdump-filename", "hangdump.aspnet-tests.dmp", "--hangdump-timeout", "5m"],
EnvironmentVariables = new Dictionary
{
["DISABLE_GITHUB_REPORTER"] = "true",
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
index 822e18b671..316cd35c04 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
@@ -1,5 +1,6 @@
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")]
namespace
{
@@ -244,6 +245,7 @@ namespace
[(.Class | .Method | .Property | .Parameter, AllowMultiple=true)]
public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute
{
+ public ClassDataSourceAttribute() { }
[.("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic" +
" access otherwise can break functionality when trimming application code", Justification="Non-params constructor calls params one with proper annotations.")]
public ClassDataSourceAttribute([.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] type) { }
@@ -895,7 +897,7 @@ namespace
{
public InheritsTestsAttribute() { }
}
- [(.Method, AllowMultiple=true)]
+ [(.Method | .Property, AllowMultiple=true)]
public class InstanceMethodDataSourceAttribute : .MethodDataSourceAttribute, .IAccessesInstanceData
{
public InstanceMethodDataSourceAttribute(string methodNameProvidingDataSource) { }
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
index fbdb6afb31..6935605b9a 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
@@ -1,5 +1,6 @@
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(".NETCoreApp,Version=v8.0", FrameworkDisplayName=".NET 8.0")]
namespace
{
@@ -244,6 +245,7 @@ namespace
[(.Class | .Method | .Property | .Parameter, AllowMultiple=true)]
public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute
{
+ public ClassDataSourceAttribute() { }
[.("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic" +
" access otherwise can break functionality when trimming application code", Justification="Non-params constructor calls params one with proper annotations.")]
public ClassDataSourceAttribute([.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] type) { }
@@ -895,7 +897,7 @@ namespace
{
public InheritsTestsAttribute() { }
}
- [(.Method, AllowMultiple=true)]
+ [(.Method | .Property, AllowMultiple=true)]
public class InstanceMethodDataSourceAttribute : .MethodDataSourceAttribute, .IAccessesInstanceData
{
public InstanceMethodDataSourceAttribute(string methodNameProvidingDataSource) { }
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
index 870981ebd9..a0aa39ff4d 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
@@ -1,5 +1,6 @@
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(".NETCoreApp,Version=v9.0", FrameworkDisplayName=".NET 9.0")]
namespace
{
@@ -244,6 +245,7 @@ namespace
[(.Class | .Method | .Property | .Parameter, AllowMultiple=true)]
public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute
{
+ public ClassDataSourceAttribute() { }
[.("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic" +
" access otherwise can break functionality when trimming application code", Justification="Non-params constructor calls params one with proper annotations.")]
public ClassDataSourceAttribute([.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] type) { }
@@ -895,7 +897,7 @@ namespace
{
public InheritsTestsAttribute() { }
}
- [(.Method, AllowMultiple=true)]
+ [(.Method | .Property, AllowMultiple=true)]
public class InstanceMethodDataSourceAttribute : .MethodDataSourceAttribute, .IAccessesInstanceData
{
public InstanceMethodDataSourceAttribute(string methodNameProvidingDataSource) { }
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
index 22c1bd53e1..520c6e603f 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
@@ -1,5 +1,6 @@
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(".NETStandard,Version=v2.0", FrameworkDisplayName=".NET Standard 2.0")]
namespace
{
@@ -240,6 +241,7 @@ namespace
[(.Class | .Method | .Property | .Parameter, AllowMultiple=true)]
public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute
{
+ public ClassDataSourceAttribute() { }
public ClassDataSourceAttribute( type) { }
public ClassDataSourceAttribute(params [] types) { }
public ClassDataSourceAttribute( type, type2) { }
@@ -872,7 +874,7 @@ namespace
{
public InheritsTestsAttribute() { }
}
- [(.Method, AllowMultiple=true)]
+ [(.Method | .Property, AllowMultiple=true)]
public class InstanceMethodDataSourceAttribute : .MethodDataSourceAttribute, .IAccessesInstanceData
{
public InstanceMethodDataSourceAttribute(string methodNameProvidingDataSource) { }
diff --git a/TUnit.sln b/TUnit.sln
index 9e6c120b6a..12b327bdf5 100644
--- a/TUnit.sln
+++ b/TUnit.sln
@@ -139,6 +139,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Assertions.SourceGene
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Profile", "TUnit.Profile\TUnit.Profile.csproj", "{FEB5F3F3-A7DD-4DE0-A9C7-B9AD489E9C52}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore", "TUnit.AspNetCore\TUnit.AspNetCore.csproj", "{A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -785,6 +787,18 @@ Global
{FEB5F3F3-A7DD-4DE0-A9C7-B9AD489E9C52}.Release|x64.Build.0 = Release|Any CPU
{FEB5F3F3-A7DD-4DE0-A9C7-B9AD489E9C52}.Release|x86.ActiveCfg = Release|Any CPU
{FEB5F3F3-A7DD-4DE0-A9C7-B9AD489E9C52}.Release|x86.Build.0 = Release|Any CPU
+ {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Debug|x64.Build.0 = Debug|Any CPU
+ {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Debug|x86.Build.0 = Debug|Any CPU
+ {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Release|x64.ActiveCfg = Release|Any CPU
+ {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Release|x64.Build.0 = Release|Any CPU
+ {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Release|x86.ActiveCfg = Release|Any CPU
+ {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -798,7 +812,6 @@ Global
{230A0FFC-0EE8-475C-BED7-A4508C510EA7} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850}
{BECB04A9-C731-4AC0-B76B-36382BFE77AA} = {6F7B9D8E-4539-42BC-BE0B-E5BE70ED9473}
{2DE2A1F9-2A87-4FA8-8D62-0B60093DA604} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850}
- {A7B8C9D0-1234-4567-8901-23456789ABCD} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850}
{227AFF42-1812-43CF-AF1D-B702029FA6AB} = {0BA988BF-ADCE-4343-9098-B4EF65C43709}
{5BEC27F0-C33B-4BD9-A2D1-75E6158D35DE} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850}
{D5342747-7A9C-480E-ACA9-D9D6DDBA14A9} = {503DA9FA-045D-4910-8AF6-905E6048B1F1}
@@ -844,7 +857,9 @@ Global
{2B950D9B-C0FB-20E2-05FF-C70F639E9E28} = {98CF3A63-B408-4711-974F-FD894FB905F6}
{C6EEC750-06EE-46E2-9468-0CBD202D1BE9} = {503DA9FA-045D-4910-8AF6-905E6048B1F1}
{9E5E9515-109F-4E01-A307-F3B28CE5F3CB} = {6F7B9D8E-4539-42BC-BE0B-E5BE70ED9473}
+ {A7B8C9D0-1234-4567-8901-23456789ABCD} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850}
{FEB5F3F3-A7DD-4DE0-A9C7-B9AD489E9C52} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850}
+ {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75} = {1B56B580-4D59-4E83-9F80-467D58DADAC1}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {109D285A-36B3-4503-BCDF-8E26FB0E2C5B}
diff --git a/docs/docs/examples/aspnet.md b/docs/docs/examples/aspnet.md
index 26ed0bcfca..d10dd7d972 100644
--- a/docs/docs/examples/aspnet.md
+++ b/docs/docs/examples/aspnet.md
@@ -1,45 +1,507 @@
-# ASP.NET Core Web App/Api
+# ASP.NET Core Integration Testing
-If you want to test a web app, you can utilise the Microsoft.Mvc.Testing packages to wrap your web app within an in-memory test server.
+TUnit provides first-class support for ASP.NET Core integration testing through the `TUnit.AspNetCore` package. This package enables per-test isolation with shared infrastructure, making it easy to write fast, parallel integration tests.
+
+## Installation
+
+```bash
+dotnet add package TUnit.AspNetCore
+```
+
+## Quick Start
+
+### 1. Create a Test Factory
+
+Create a factory that extends `TestWebApplicationFactory`:
+
+```csharp
+using TUnit.AspNetCore;
+using TUnit.Core.Interfaces;
+
+public class WebApplicationFactory : TestWebApplicationFactory
+{
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ // Configure shared services and settings
+ builder.ConfigureAppConfiguration((context, config) =>
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "ConnectionStrings:Default", "..." }
+ });
+ });
+ }
+}
+```
+
+### 2. Create a Test Base Class
+
+Create a base class that extends `WebApplicationTest`:
+
+```csharp
+using TUnit.AspNetCore;
+
+public abstract class TestsBase : WebApplicationTest
+{
+}
+```
+
+### 3. Write Tests
+
+```csharp
+public class TodoApiTests : TestsBase
+{
+ [Test]
+ public async Task GetTodos_ReturnsOk()
+ {
+ var client = Factory.CreateClient();
+
+ var response = await client.GetAsync("/todos");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ }
+}
+```
+
+## Core Concepts
+
+### WebApplicationTest Pattern
+
+The `WebApplicationTest` base class provides:
+
+- **Per-test isolation**: Each test gets its own delegating factory via `WithWebHostBuilder`
+- **Shared infrastructure**: The global factory (containers, connections) is shared across tests
+- **Parallel execution**: Tests run in parallel with complete isolation
+- **Lifecycle hooks**: Async setup runs before sync configuration
+
+### Lifecycle Order
+
+1. `GlobalFactory` is injected (shared per test session)
+2. `SetupAsync()` runs (for async operations like creating database tables)
+3. `ConfigureTestServices()` runs (sync, for DI configuration)
+4. `ConfigureTestConfiguration()` runs (sync, for app configuration)
+5. `ConfigureWebHostBuilder()` runs (sync, escape hatch for advanced scenarios)
+6. Test runs with isolated `Factory`
+7. Factory is disposed
+
+## Override Methods
+
+### SetupAsync
+
+Use for async operations that must complete before the factory is created:
+
+```csharp
+public class TodoTests : TestsBase
+{
+ protected string TableName { get; private set; } = null!;
+
+ protected override async Task SetupAsync()
+ {
+ TableName = GetIsolatedName("todos");
+ await CreateTableAsync(TableName);
+ }
+
+ protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
+ {
+ // TableName is already set from SetupAsync
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "Database:TableName", TableName }
+ });
+ }
+}
+```
+
+### ConfigureTestServices
+
+Use for DI configuration:
+
+```csharp
+protected override void ConfigureTestServices(IServiceCollection services)
+{
+ // Replace a service with a mock
+ services.ReplaceService(new FakeEmailService());
+
+ // Add test-specific services
+ services.AddSingleton();
+}
+```
+
+### ConfigureTestConfiguration
+
+Use for app configuration:
+
+```csharp
+protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
+{
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "Feature:Enabled", "true" },
+ { "Api:BaseUrl", "https://test.example.com" }
+ });
+}
+```
+
+### ConfigureWebHostBuilder
+
+Escape hatch for advanced scenarios:
+
+```csharp
+protected override void ConfigureWebHostBuilder(IWebHostBuilder builder)
+{
+ builder.UseEnvironment("Staging");
+ builder.UseSetting("MyFeature:Enabled", "true");
+ builder.ConfigureKestrel(options => options.AddServerHeader = false);
+}
+```
+
+## Test Isolation Helpers
+
+### GetIsolatedName
+
+Creates a unique name for resources like database tables:
+
+```csharp
+// In a test with UniqueId = 42:
+var tableName = GetIsolatedName("todos"); // Returns "Test_42_todos"
+var topicName = GetIsolatedName("orders"); // Returns "Test_42_orders"
+```
+
+### GetIsolatedPrefix
+
+Creates a unique prefix for key-based resources:
+
+```csharp
+// In a test with UniqueId = 42:
+var prefix = GetIsolatedPrefix(); // Returns "test_42_"
+var dotPrefix = GetIsolatedPrefix("."); // Returns "test.42."
+```
+
+## Container Integration
+
+### With Testcontainers
+
+```csharp
+public class InMemoryDatabase : IAsyncInitializer, IAsyncDisposable
+{
+ public PostgreSqlContainer Container { get; } = new PostgreSqlBuilder()
+ .WithImage("postgres:16-alpine")
+ .Build();
+
+ public async Task InitializeAsync() => await Container.StartAsync();
+ public async ValueTask DisposeAsync() => await Container.DisposeAsync();
+}
+
+public class WebApplicationFactory : TestWebApplicationFactory
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public InMemoryDatabase Database { get; init; } = null!;
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.ConfigureAppConfiguration((_, config) =>
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "Database:ConnectionString", Database.Container.GetConnectionString() }
+ });
+ });
+ }
+}
+```
+
+### Per-Test Table Isolation
+
+```csharp
+public abstract class TodoTestBase : TestsBase
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public InMemoryDatabase Database { get; init; } = null!;
+
+ protected string TableName { get; private set; } = null!;
+
+ protected override async Task SetupAsync()
+ {
+ TableName = GetIsolatedName("todos");
+ await CreateTableAsync(TableName);
+ }
+
+ protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "Database:TableName", TableName }
+ });
+ }
+
+ [After(HookType.Test)]
+ public async Task CleanupTable()
+ {
+ await DropTableAsync(TableName);
+ }
+
+ private async Task CreateTableAsync(string name) { /* ... */ }
+ private async Task DropTableAsync(string name) { /* ... */ }
+}
+```
+
+## HTTP Exchange Capture
+
+Capture and inspect HTTP requests/responses for assertions:
+
+```csharp
+public class CaptureTests : TestsBase
+{
+ protected override WebApplicationTestOptions Options => new()
+ {
+ EnableHttpExchangeCapture = true
+ };
+
+ [Test]
+ public async Task RequestIsCaptured()
+ {
+ var client = Factory.CreateClient();
+
+ await client.GetAsync("/api/todos");
+
+ await Assert.That(HttpCapture).IsNotNull();
+ await Assert.That(HttpCapture!.Last!.Response.StatusCode)
+ .IsEqualTo(HttpStatusCode.OK);
+ }
+}
+```
+
+### Capture Options
+
+```csharp
+protected override WebApplicationTestOptions Options => new()
+{
+ EnableHttpExchangeCapture = true,
+ CaptureRequestBody = true,
+ CaptureResponseBody = true,
+ MaxBodySize = 1024 * 1024 // 1MB limit
+};
+```
+
+### Inspecting Captured Exchanges
+
+```csharp
+// Get the last exchange
+var last = HttpCapture!.Last;
+
+// Get all exchanges
+var all = HttpCapture.All;
+
+// Inspect request
+await Assert.That(last!.Request.Method).IsEqualTo("POST");
+await Assert.That(last.Request.Path).IsEqualTo("/api/todos");
+await Assert.That(last.Request.Body).Contains("\"title\"");
+
+// Inspect response
+await Assert.That(last.Response.StatusCode).IsEqualTo(HttpStatusCode.Created);
+await Assert.That(last.Response.Body).Contains("\"id\"");
+```
+
+## TUnit Logging Integration
+
+Server logs are automatically correlated with TUnit test output:
+
+```csharp
+protected override WebApplicationTestOptions Options => new()
+{
+ AddTUnitLogging = true // Default is true
+};
+```
+
+Logs from your ASP.NET Core app will appear in the test output, making debugging easier.
+
+## Best Practices
+
+### 1. Use Base Classes for Common Setup
+
+```csharp
+// Shared base for all tests
+public abstract class TestsBase : WebApplicationTest
+{
+}
+
+// Specialized base for database tests
+public abstract class DatabaseTestBase : TestsBase
+{
+ protected override async Task SetupAsync()
+ {
+ await CreateSchemaAsync();
+ }
+}
+
+// Actual tests
+public class UserTests : DatabaseTestBase
+{
+ [Test]
+ public async Task CreateUser_Works() { /* ... */ }
+}
+```
+
+### 2. Clean Up Resources
+
+```csharp
+[After(HookType.Test)]
+public async Task Cleanup()
+{
+ await CleanupTestDataAsync();
+}
+```
+
+### 3. Use Isolated Names for Shared Resources
```csharp
-public class WebAppFactory : WebApplicationFactory, IAsyncInitializer
+protected override async Task SetupAsync()
{
- public Task InitializeAsync()
+ // Each test gets unique resources
+ var tableName = GetIsolatedName("users");
+ var cachePrefix = GetIsolatedPrefix();
+ var topicName = GetIsolatedName("events");
+}
+```
+
+### 4. Inject Containers at Factory Level
+
+```csharp
+public class WebApplicationFactory : TestWebApplicationFactory
+{
+ // Shared across all tests
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public PostgresContainer Postgres { get; init; } = null!;
+
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public RedisContainer Redis { get; init; } = null!;
+}
+```
+
+## Complete Example
+
+```csharp
+// Container wrapper
+public class InMemoryPostgres : IAsyncInitializer, IAsyncDisposable
+{
+ public PostgreSqlContainer Container { get; } = new PostgreSqlBuilder().Build();
+ public async Task InitializeAsync() => await Container.StartAsync();
+ public async ValueTask DisposeAsync() => await Container.DisposeAsync();
+}
+
+// Factory with shared container
+public class WebApplicationFactory : TestWebApplicationFactory
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public InMemoryPostgres Postgres { get; init; } = null!;
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
{
- // You can also override certain services here to mock things out
+ builder.ConfigureAppConfiguration((_, config) =>
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "Database:ConnectionString", Postgres.Container.GetConnectionString() }
+ });
+ });
+ }
+}
- // Grab a reference to the server
- // This forces it to initialize.
- // By doing it within this method, it's thread safe.
- // And avoids multiple initialisations from different tests if parallelisation is switched on
- _ = Server;
+// Base class
+public abstract class TestsBase : WebApplicationTest
+{
+}
- return Task.CompletedTask;
+// Test base with table isolation
+public abstract class TodoTestBase : TestsBase
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public InMemoryPostgres Postgres { get; init; } = null!;
+
+ protected string TableName { get; private set; } = null!;
+
+ protected override async Task SetupAsync()
+ {
+ TableName = GetIsolatedName("todos");
+ await CreateTableAsync();
+ }
+
+ protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "Database:TableName", TableName }
+ });
+ }
+
+ [After(HookType.Test)]
+ public async Task Cleanup() => await DropTableAsync();
+
+ private async Task CreateTableAsync() { /* ... */ }
+ private async Task DropTableAsync() { /* ... */ }
+}
+
+// Actual tests
+public class TodoApiTests : TodoTestBase
+{
+ [Test]
+ public async Task CreateTodo_ReturnsCreated()
+ {
+ var client = Factory.CreateClient();
+
+ var response = await client.PostAsJsonAsync("/todos", new { Title = "Test" });
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
+ }
+
+ [Test, Repeat(5)]
+ public async Task ParallelTests_AreIsolated()
+ {
+ var client = Factory.CreateClient();
+
+ // Each repetition has its own table
+ await client.PostAsJsonAsync("/todos", new { Title = "Isolated" });
+
+ var todos = await client.GetFromJsonAsync>("/todos");
+ await Assert.That(todos!.Count).IsEqualTo(1); // Always 1, not 5
}
}
```
-This factory can then be injected into your tests, and whether you have one shared instance, or shared per class/assembly, or a new instance each time, is up to you!
+## Migrating from Basic WebApplicationFactory
-The `IAsyncInitializer.InitializeAsync` method that you can see above will be called before your tests are invoked, so you know all the initialisation has already been done for you.
+If you're currently using `WebApplicationFactory` directly:
+**Before:**
```csharp
public class MyTests
{
[ClassDataSource(Shared = SharedType.PerTestSession)]
- public required WebAppFactory WebAppFactory { get; init; }
-
+ public required WebAppFactory Factory { get; init; }
+
[Test]
public async Task Test1()
{
- var client = WebAppFactory.CreateClient();
+ var client = Factory.CreateClient();
+ // Tests share state - not isolated!
+ }
+}
+```
- var response = await client.GetAsync("/my/endpoint");
-
- await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+**After:**
+```csharp
+public class MyTests : WebApplicationTest
+{
+ [Test]
+ public async Task Test1()
+ {
+ var client = Factory.CreateClient(); // Isolated per test!
}
}
```
-Alternatively, you can use the `[NotInParallel]` attribute to avoid parallelism and multi-initialisation. But you'll most likely be sacrificing test speeds if tests can't run in parallel.
+The key benefits:
+- Each test gets its own isolated factory via `WithWebHostBuilder`
+- `SetupAsync` enables async initialization before factory creation
+- `ConfigureTestServices` and `ConfigureTestConfiguration` are per-test
+- Built-in isolation helpers (`GetIsolatedName`, `GetIsolatedPrefix`)