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`)