diff --git a/.gitignore b/.gitignore index 0e3c8cfe18..149ec04978 100644 --- a/.gitignore +++ b/.gitignore @@ -419,8 +419,8 @@ TUnit.TestProject/TestSession*.txt .mcp.json requirements -TESTPROJECT_AOT -TESTPROJECT_SINGLEFILE +TESTPROJECT_AOT* +TESTPROJECT_SINGLEFILE* nul diff --git a/Directory.Packages.props b/Directory.Packages.props index 24ea46faf3..11f44f281e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -83,9 +83,9 @@ - - - + + + diff --git a/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs b/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs index 29e833e178..9c3c88b637 100644 --- a/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs +++ b/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs @@ -60,6 +60,29 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } GenerateIndividualPropertyInjectionSource(context, classData); }); + + // Third pipeline: Generate InitializerPropertyRegistry metadata for IAsyncInitializer types + // that have properties returning other IAsyncInitializer types. + // This enables AOT-compatible nested initializer discovery. + var asyncInitializerTypes = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: (node, _) => node is TypeDeclarationSyntax, + transform: (ctx, _) => GetAsyncInitializerWithInitializerProperties(ctx)) + .Where(x => x != null) + .Select((x, _) => x!) + .Collect() + .SelectMany((types, _) => types.DistinctBy(t => t.TypeSymbol, SymbolEqualityComparer.Default)) + .Combine(enabledProvider); + + context.RegisterSourceOutput(asyncInitializerTypes, (ctx, data) => + { + var (typeInfo, isEnabled) = data; + if (!isEnabled) + { + return; + } + GenerateInitializerPropertySource(ctx, typeInfo); + }); } private static bool IsClassWithDataSourceProperties(SyntaxNode node) @@ -635,6 +658,130 @@ private static string GetNonNullableTypeString(ITypeSymbol typeSymbol) // Alias for consistency private static string GetNonNullableTypeName(ITypeSymbol typeSymbol) => GetNonNullableTypeString(typeSymbol); + + #region IAsyncInitializer Property Discovery (for nested initializer discovery) + + /// + /// Finds types that implement IAsyncInitializer and have properties that return other IAsyncInitializer types. + /// Used for AOT-compatible nested initializer discovery during object graph traversal. + /// + private static AsyncInitializerTypeInfo? GetAsyncInitializerWithInitializerProperties(GeneratorSyntaxContext context) + { + var typeDecl = (TypeDeclarationSyntax)context.Node; + var semanticModel = context.SemanticModel; + + if (semanticModel.GetDeclaredSymbol(typeDecl) is not INamedTypeSymbol typeSymbol) + { + return null; + } + + // Skip non-public/internal types + if (!IsPubliclyAccessible(typeSymbol)) + { + return null; + } + + // Skip open generic types + if (typeSymbol.IsUnboundGenericType || typeSymbol.TypeParameters.Length > 0) + { + return null; + } + + var asyncInitializerInterface = semanticModel.Compilation.GetTypeByMetadataName("TUnit.Core.Interfaces.IAsyncInitializer"); + if (asyncInitializerInterface == null) + { + return null; + } + + // Check if this type implements IAsyncInitializer + if (!typeSymbol.AllInterfaces.Contains(asyncInitializerInterface, SymbolEqualityComparer.Default)) + { + return null; + } + + // Find properties that return IAsyncInitializer types + var initializerProperties = new List(); + + var allProperties = typeSymbol.GetMembers() + .OfType() + .Where(p => p.GetMethod != null && !p.IsStatic && !p.IsIndexer) + .ToList(); + + foreach (var property in allProperties) + { + // Check if the property type implements IAsyncInitializer + if (property.Type is INamedTypeSymbol propertyType) + { + if (propertyType.AllInterfaces.Contains(asyncInitializerInterface, SymbolEqualityComparer.Default) || + SymbolEqualityComparer.Default.Equals(propertyType, asyncInitializerInterface)) + { + initializerProperties.Add(new InitializerPropertyMetadata + { + Property = property + }); + } + } + } + + if (initializerProperties.Count == 0) + { + return null; + } + + return new AsyncInitializerTypeInfo + { + TypeSymbol = typeSymbol, + Properties = initializerProperties.ToImmutableArray() + }; + } + + /// + /// Generates source code that registers IAsyncInitializer property metadata with InitializerPropertyRegistry. + /// + private static void GenerateInitializerPropertySource(SourceProductionContext context, AsyncInitializerTypeInfo typeInfo) + { + var typeSymbol = typeInfo.TypeSymbol; + var safeName = GetSafeClassName(typeSymbol); + var fileName = $"{safeName}_InitializerProperties.g.cs"; + + var sourceBuilder = new StringBuilder(); + + sourceBuilder.AppendLine("using System;"); + sourceBuilder.AppendLine("using TUnit.Core.Discovery;"); + sourceBuilder.AppendLine(); + sourceBuilder.AppendLine("namespace TUnit.Generated;"); + sourceBuilder.AppendLine(); + + // Generate module initializer + sourceBuilder.AppendLine($"internal static class {safeName}_InitializerPropertiesInitializer"); + sourceBuilder.AppendLine("{"); + sourceBuilder.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializer]"); + sourceBuilder.AppendLine(" public static void Initialize()"); + sourceBuilder.AppendLine(" {"); + sourceBuilder.AppendLine($" InitializerPropertyRegistry.Register(typeof({typeSymbol.GloballyQualified()}), new InitializerPropertyInfo[]"); + sourceBuilder.AppendLine(" {"); + + foreach (var propInfo in typeInfo.Properties) + { + var property = propInfo.Property; + var propertyTypeName = property.Type.GloballyQualified(); + + sourceBuilder.AppendLine(" new InitializerPropertyInfo"); + sourceBuilder.AppendLine(" {"); + sourceBuilder.AppendLine($" PropertyName = \"{property.Name}\","); + sourceBuilder.AppendLine($" PropertyType = typeof({propertyTypeName}),"); + sourceBuilder.AppendLine($" GetValue = static obj => (({typeSymbol.GloballyQualified()})obj).{property.Name}"); + sourceBuilder.AppendLine(" },"); + } + + sourceBuilder.AppendLine(" });"); + sourceBuilder.AppendLine(" }"); + sourceBuilder.AppendLine("}"); + + context.AddSource(fileName, sourceBuilder.ToString()); + } + + #endregion } internal sealed class ClassWithDataSourceProperties @@ -665,3 +812,21 @@ public int GetHashCode(ClassWithDataSourceProperties obj) return SymbolEqualityComparer.Default.GetHashCode(obj.ClassSymbol); } } + +/// +/// Model for types that implement IAsyncInitializer and have properties returning IAsyncInitializer. +/// Used for generating AOT-compatible nested initializer discovery metadata. +/// +internal sealed class AsyncInitializerTypeInfo +{ + public required INamedTypeSymbol TypeSymbol { get; init; } + public required ImmutableArray Properties { get; init; } +} + +/// +/// Metadata about a property that returns an IAsyncInitializer type. +/// +internal sealed class InitializerPropertyMetadata +{ + public required IPropertySymbol Property { get; init; } +} diff --git a/TUnit.Core/Discovery/InitializerPropertyRegistry.cs b/TUnit.Core/Discovery/InitializerPropertyRegistry.cs new file mode 100644 index 0000000000..fe7269a3db --- /dev/null +++ b/TUnit.Core/Discovery/InitializerPropertyRegistry.cs @@ -0,0 +1,60 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using TUnit.Core.Interfaces; + +namespace TUnit.Core.Discovery; + +/// +/// Registry for IAsyncInitializer property metadata generated at compile time. +/// Used for AOT-compatible nested initializer discovery. +/// +public static class InitializerPropertyRegistry +{ + private static readonly ConcurrentDictionary Registry = new(); + + /// + /// Registers property metadata for a type. Called by generated code. + /// + public static void Register(Type type, InitializerPropertyInfo[] properties) + { + Registry[type] = properties; + } + + /// + /// Gets property metadata for a type, or null if not registered. + /// + public static InitializerPropertyInfo[]? GetProperties(Type type) + { + return Registry.TryGetValue(type, out var properties) ? properties : null; + } + + /// + /// Checks if a type has registered property metadata. + /// + public static bool HasRegistration(Type type) + { + return Registry.ContainsKey(type); + } +} + +/// +/// Metadata about a property that returns an IAsyncInitializer. +/// +public sealed class InitializerPropertyInfo +{ + /// + /// The name of the property. + /// + public required string PropertyName { get; init; } + + /// + /// The property type. + /// + public required Type PropertyType { get; init; } + + /// + /// Delegate to get the property value from an instance. + /// + public required Func GetValue { get; init; } +} diff --git a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs index f904d2200d..e91cd28eb8 100644 --- a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs +++ b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs @@ -420,6 +420,12 @@ private static void TraverseReflectionProperties( /// Unified traversal for IAsyncInitializer objects (from all properties). /// Eliminates duplicate code between DiscoverNestedInitializerObjects and DiscoverNestedInitializerObjectsForTracking. /// + /// + /// Uses source-generated metadata when available (AOT-compatible), falling back to reflection otherwise. + /// Exceptions during property access are propagated to the caller with context about + /// which type/property failed. This ensures data source initialization failures are + /// properly reported as test failures rather than silently swallowed. + /// [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection fallback for nested initializers. In AOT, source-gen handles primary discovery.")] [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Reflection fallback for nested initializers. In AOT, source-gen handles primary discovery.")] [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Reflection fallback for nested initializers. In AOT, source-gen handles primary discovery.")] @@ -437,6 +443,80 @@ private static void TraverseInitializerProperties( return; } + // Try source-generated metadata first (AOT-compatible) + var registeredProperties = InitializerPropertyRegistry.GetProperties(type); + if (registeredProperties != null) + { + TraverseRegisteredInitializerProperties(obj, type, registeredProperties, tryAdd, recurse, currentDepth, cancellationToken); + return; + } + + // Fall back to reflection (non-AOT path) + TraverseInitializerPropertiesViaReflection(obj, type, tryAdd, recurse, currentDepth, cancellationToken); + } + + /// + /// Traverses IAsyncInitializer properties using source-generated metadata (AOT-compatible). + /// + private static void TraverseRegisteredInitializerProperties( + object obj, + Type type, + InitializerPropertyInfo[] properties, + TryAddObjectFunc tryAdd, + RecurseFunc recurse, + int currentDepth, + CancellationToken cancellationToken) + { + foreach (var propInfo in properties) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var value = propInfo.GetValue(obj); + if (value == null) + { + continue; + } + + // Only discover IAsyncInitializer objects + if (value is IAsyncInitializer && tryAdd(value, currentDepth)) + { + recurse(value, currentDepth + 1); + } + } + catch (OperationCanceledException) + { + throw; // Propagate cancellation + } + catch (Exception ex) + { + // Record error for diagnostics (still available via GetDiscoveryErrors()) + DiscoveryErrors.Add(new DiscoveryError(type.Name, propInfo.PropertyName, ex.Message, ex)); + + // Propagate the exception with context about which property failed + // This ensures data source failures are reported as test failures + throw DataSourceException.FromNestedFailure( + $"Failed to access property '{propInfo.PropertyName}' on type '{type.Name}' during object graph discovery. " + + $"This may indicate that a data source or its nested dependencies failed to initialize. " + + $"See inner exception for details.", + ex); + } + } + } + + /// + /// Traverses IAsyncInitializer properties using reflection (fallback for non-source-generated types). + /// + [UnconditionalSuppressMessage("Trimming", "IL2067", Justification = "Reflection fallback path. Types without source-generated metadata may not work in trimmed apps.")] + private static void TraverseInitializerPropertiesViaReflection( + object obj, + Type type, + TryAddObjectFunc tryAdd, + RecurseFunc recurse, + int currentDepth, + CancellationToken cancellationToken) + { var properties = PropertyCacheManager.GetCachedProperties(type); foreach (var property in properties) @@ -473,12 +553,16 @@ private static void TraverseInitializerProperties( } catch (Exception ex) { - // Record error for diagnostics (available via GetDiscoveryErrors()) + // Record error for diagnostics (still available via GetDiscoveryErrors()) DiscoveryErrors.Add(new DiscoveryError(type.Name, property.Name, ex.Message, ex)); -#if DEBUG - Debug.WriteLine($"[ObjectGraphDiscoverer] Failed to access property '{property.Name}' on type '{type.Name}': {ex.Message}"); -#endif - // Continue discovery despite property access failures + + // Propagate the exception with context about which property failed + // This ensures data source failures are reported as test failures + throw DataSourceException.FromNestedFailure( + $"Failed to access property '{property.Name}' on type '{type.Name}' during object graph discovery. " + + $"This may indicate that a data source or its nested dependencies failed to initialize. " + + $"See inner exception for details.", + ex); } } } diff --git a/TUnit.Core/Discovery/PropertyCacheManager.cs b/TUnit.Core/Discovery/PropertyCacheManager.cs index 12319608cc..0073c4215a 100644 --- a/TUnit.Core/Discovery/PropertyCacheManager.cs +++ b/TUnit.Core/Discovery/PropertyCacheManager.cs @@ -38,8 +38,11 @@ internal static class PropertyCacheManager /// /// The type to get properties for. /// An array of readable, non-indexed properties for the type. - [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Reflection fallback for nested initializers. In AOT, source-gen handles primary discovery.")] - public static PropertyInfo[] GetCachedProperties(Type type) + [UnconditionalSuppressMessage("Trimming", "IL2111", + Justification = "CreatePropertyArray is called with a type that has the required DynamicallyAccessedMembers annotation from the caller.")] + public static PropertyInfo[] GetCachedProperties( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] + Type type) { // Periodic cleanup if cache grows too large to prevent memory leaks // Use Interlocked to ensure only one thread performs cleanup at a time @@ -71,34 +74,38 @@ public static PropertyInfo[] GetCachedProperties(Type type) } } - return PropertyCache.GetOrAdd(type, static t => - { - // Use explicit loops instead of LINQ to avoid allocations in hot path - var allProps = t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + return PropertyCache.GetOrAdd(type, CreatePropertyArray); + } + + private static PropertyInfo[] CreatePropertyArray( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] + Type t) + { + // Use explicit loops instead of LINQ to avoid allocations in hot path + var allProps = t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - // First pass: count eligible properties - var eligibleCount = 0; - foreach (var p in allProps) + // First pass: count eligible properties + var eligibleCount = 0; + foreach (var p in allProps) + { + if (p.CanRead && p.GetIndexParameters().Length == 0) { - if (p.CanRead && p.GetIndexParameters().Length == 0) - { - eligibleCount++; - } + eligibleCount++; } + } - // Second pass: fill result array - var result = new PropertyInfo[eligibleCount]; - var i = 0; - foreach (var p in allProps) + // Second pass: fill result array + var result = new PropertyInfo[eligibleCount]; + var i = 0; + foreach (var p in allProps) + { + if (p.CanRead && p.GetIndexParameters().Length == 0) { - if (p.CanRead && p.GetIndexParameters().Length == 0) - { - result[i++] = p; - } + result[i++] = p; } + } - return result; - }); + return result; } /// diff --git a/TUnit.Core/TestBuilder/TestBuilderException.cs b/TUnit.Core/TestBuilder/TestBuilderException.cs index a74c6c3f60..348dbfa42e 100644 --- a/TUnit.Core/TestBuilder/TestBuilderException.cs +++ b/TUnit.Core/TestBuilder/TestBuilderException.cs @@ -66,6 +66,24 @@ public DataSourceException(string dataSourceName, string message) /// Gets the name of the data source that failed. /// public string DataSourceName { get; } + + /// + /// Creates a DataSourceException with a custom message and inner exception. + /// Used when a data source or its nested dependencies fail during initialization. + /// + /// The full error message. + /// The exception that caused this error. + /// A new DataSourceException instance. + public static DataSourceException FromNestedFailure(string message, Exception innerException) + { + return new DataSourceException(message, innerException, isCustomMessage: true); + } + + private DataSourceException(string message, Exception innerException, bool isCustomMessage) + : base(message, innerException) + { + DataSourceName = string.Empty; + } } /// diff --git a/TUnit.Engine.Tests/DataSourceExceptionPropagationTests.cs b/TUnit.Engine.Tests/DataSourceExceptionPropagationTests.cs new file mode 100644 index 0000000000..ab8005332a --- /dev/null +++ b/TUnit.Engine.Tests/DataSourceExceptionPropagationTests.cs @@ -0,0 +1,56 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +/// +/// Tests that exceptions thrown during data source initialization are properly propagated +/// and cause tests to fail with appropriate error messages. +/// See: https://github.com/thomhurst/TUnit/issues/4049 +/// +public class DataSourceExceptionPropagationTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task NestedInitializer_PropertyAccessFailure_FailsTestWithDataSourceException() + { + await RunTestsWithFilter( + "/*/*/NestedInitializerExceptionPropagationTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Failed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(0), + result => result.ResultSummary.Counters.Failed.ShouldBe(1), + result => + { + var errorMessage = result.Results.First().Output?.ErrorInfo?.Message; + errorMessage.ShouldNotBeNull("Expected an error message"); + // Should identify the failing property + errorMessage.ShouldContain("Failed to access property 'NestedInitializer'"); + // Should identify the type containing the failing property + errorMessage.ShouldContain("FailingNestedInitializerFactory"); + // Should indicate when the failure occurred + errorMessage.ShouldContain("during object graph discovery"); + } + ]); + } + + [Test] + public async Task Initializer_InitializeAsyncFailure_FailsTestWithException() + { + await RunTestsWithFilter( + "/*/*/InitializerExceptionPropagationTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Failed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(0), + result => result.ResultSummary.Counters.Failed.ShouldBe(1), + result => + { + var errorMessage = result.Results.First().Output?.ErrorInfo?.Message; + errorMessage.ShouldNotBeNull("Expected an error message"); + // Should contain the original exception message + errorMessage.ShouldContain("Simulated initialization failure"); + } + ]); + } +} 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 cd36f1dcf5..822e18b671 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 @@ -459,6 +459,7 @@ namespace public DataSourceException(string dataSourceName, innerException) { } public DataSourceException(string dataSourceName, string message) { } public string DataSourceName { get; } + public static .DataSourceException FromNestedFailure(string message, innerException) { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] public abstract class DataSourceGeneratorAttribute<[.(..PublicConstructors)] T> : .AsyncDataSourceGeneratorAttribute @@ -1681,6 +1682,22 @@ namespace .DataSources public static string FormatArguments(object?[] arguments, .<> formatters) { } } } +namespace .Discovery +{ + public sealed class InitializerPropertyInfo + { + public InitializerPropertyInfo() { } + public required GetValue { get; init; } + public required string PropertyName { get; init; } + public required PropertyType { get; init; } + } + public static class InitializerPropertyRegistry + { + public static .[]? GetProperties( type) { } + public static bool HasRegistration( type) { } + public static void Register( type, .[] properties) { } + } +} namespace .Enums { public enum DataGeneratorType 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 4ede0dc757..fbdb6afb31 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 @@ -459,6 +459,7 @@ namespace public DataSourceException(string dataSourceName, innerException) { } public DataSourceException(string dataSourceName, string message) { } public string DataSourceName { get; } + public static .DataSourceException FromNestedFailure(string message, innerException) { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] public abstract class DataSourceGeneratorAttribute<[.(..PublicConstructors)] T> : .AsyncDataSourceGeneratorAttribute @@ -1681,6 +1682,22 @@ namespace .DataSources public static string FormatArguments(object?[] arguments, .<> formatters) { } } } +namespace .Discovery +{ + public sealed class InitializerPropertyInfo + { + public InitializerPropertyInfo() { } + public required GetValue { get; init; } + public required string PropertyName { get; init; } + public required PropertyType { get; init; } + } + public static class InitializerPropertyRegistry + { + public static .[]? GetProperties( type) { } + public static bool HasRegistration( type) { } + public static void Register( type, .[] properties) { } + } +} namespace .Enums { public enum DataGeneratorType 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 5e13d66ff5..870981ebd9 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 @@ -459,6 +459,7 @@ namespace public DataSourceException(string dataSourceName, innerException) { } public DataSourceException(string dataSourceName, string message) { } public string DataSourceName { get; } + public static .DataSourceException FromNestedFailure(string message, innerException) { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] public abstract class DataSourceGeneratorAttribute<[.(..PublicConstructors)] T> : .AsyncDataSourceGeneratorAttribute @@ -1681,6 +1682,22 @@ namespace .DataSources public static string FormatArguments(object?[] arguments, .<> formatters) { } } } +namespace .Discovery +{ + public sealed class InitializerPropertyInfo + { + public InitializerPropertyInfo() { } + public required GetValue { get; init; } + public required string PropertyName { get; init; } + public required PropertyType { get; init; } + } + public static class InitializerPropertyRegistry + { + public static .[]? GetProperties( type) { } + public static bool HasRegistration( type) { } + public static void Register( type, .[] properties) { } + } +} namespace .Enums { public enum DataGeneratorType 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 5bba383511..22c1bd53e1 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 @@ -439,6 +439,7 @@ namespace public DataSourceException(string dataSourceName, innerException) { } public DataSourceException(string dataSourceName, string message) { } public string DataSourceName { get; } + public static .DataSourceException FromNestedFailure(string message, innerException) { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] public abstract class DataSourceGeneratorAttribute : .AsyncDataSourceGeneratorAttribute @@ -1634,6 +1635,22 @@ namespace .DataSources public static string FormatArguments(object?[] arguments, .<> formatters) { } } } +namespace .Discovery +{ + public sealed class InitializerPropertyInfo + { + public InitializerPropertyInfo() { } + public required GetValue { get; init; } + public required string PropertyName { get; init; } + public required PropertyType { get; init; } + } + public static class InitializerPropertyRegistry + { + public static .[]? GetProperties( type) { } + public static bool HasRegistration( type) { } + public static void Register( type, .[] properties) { } + } +} namespace .Enums { public enum DataGeneratorType diff --git a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj index 807849de4f..e4161bea03 100644 --- a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj @@ -10,8 +10,8 @@ - - + + diff --git a/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj b/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj index 32ae529da9..c89c3b6687 100644 --- a/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj +++ b/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj @@ -9,7 +9,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj index 96ae79b4e6..5d4f5e21a4 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj @@ -11,7 +11,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj index 448aa458e0..2316a2b3be 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj @@ -10,7 +10,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj index 6893b04136..251f36eda5 100644 --- a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj @@ -10,8 +10,8 @@ - - + + diff --git a/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj b/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj index 44d50bbeb4..a41db1f439 100644 --- a/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj @@ -8,7 +8,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj index 54ff0ae760..97dd3f0631 100644 --- a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj +++ b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj @@ -8,6 +8,6 @@ - + diff --git a/TUnit.Templates/content/TUnit/TestProject.csproj b/TUnit.Templates/content/TUnit/TestProject.csproj index 26d814a413..ff36e3ba17 100644 --- a/TUnit.Templates/content/TUnit/TestProject.csproj +++ b/TUnit.Templates/content/TUnit/TestProject.csproj @@ -8,7 +8,7 @@ - + \ No newline at end of file diff --git a/TUnit.TestProject/Bugs/4049/InitializerExceptionPropagationTests.cs b/TUnit.TestProject/Bugs/4049/InitializerExceptionPropagationTests.cs new file mode 100644 index 0000000000..c2b0bb4cbf --- /dev/null +++ b/TUnit.TestProject/Bugs/4049/InitializerExceptionPropagationTests.cs @@ -0,0 +1,31 @@ +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._4049; + +/// +/// Tests that exceptions thrown during IAsyncInitializer.InitializeAsync +/// are properly propagated and cause tests to fail. +/// See: https://github.com/thomhurst/TUnit/issues/4049 +/// +[EngineTest(ExpectedResult.Failure)] +[ClassDataSource(Shared = SharedType.None)] +public class InitializerExceptionPropagationTests( + InitializerExceptionPropagationTests.FailingInitializerFactory factory) +{ + [Test] + public void Test_Should_Fail_Due_To_Initializer_Exception() + { + // This test should never run - it should fail during initialization + // because the IAsyncInitializer.InitializeAsync throws + throw new InvalidOperationException("This test should not have executed - initializer should have thrown"); + } + + public class FailingInitializerFactory : IAsyncInitializer + { + public Task InitializeAsync() + { + throw new InvalidOperationException("Simulated initialization failure (e.g., Docker container failed to start)"); + } + } +} diff --git a/TUnit.TestProject/Bugs/4049/NestedInitializerExceptionPropagationTests.cs b/TUnit.TestProject/Bugs/4049/NestedInitializerExceptionPropagationTests.cs new file mode 100644 index 0000000000..2bc3947a74 --- /dev/null +++ b/TUnit.TestProject/Bugs/4049/NestedInitializerExceptionPropagationTests.cs @@ -0,0 +1,43 @@ +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._4049; + +/// +/// Tests that exceptions thrown during nested IAsyncInitializer property access +/// are properly propagated and cause tests to fail rather than running with null properties. +/// See: https://github.com/thomhurst/TUnit/issues/4049 +/// +[EngineTest(ExpectedResult.Failure)] +[ClassDataSource(Shared = SharedType.None)] +public class NestedInitializerExceptionPropagationTests( + NestedInitializerExceptionPropagationTests.FailingNestedInitializerFactory factory) +{ + [Test] + public void Test_Should_Fail_Due_To_Nested_Initializer_Exception() + { + // This test should never run - it should fail during discovery/initialization + // because the nested IAsyncInitializer throws during property access + throw new InvalidOperationException("This test should not have executed - nested initializer should have thrown"); + } + + public class FailingNestedInitializerFactory : IAsyncInitializer + { + // This property throws when accessed, simulating a container that fails to start + public FailingNestedInitializer NestedInitializer => + throw new InvalidOperationException("Simulated container startup failure"); + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + } + + public class FailingNestedInitializer : IAsyncInitializer + { + public Task InitializeAsync() + { + return Task.CompletedTask; + } + } +}