From ac9abfde771f315937bfb223875a8bbaeb82e319 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:31:00 +0000 Subject: [PATCH 1/2] Populate reflection class property metadata (#7522) --- .../PropertyInjection/ClassMetadataHelper.cs | 44 +++++++++++++++++-- .../Building/ReflectionMetadataBuilder.cs | 4 +- .../ReflectionHookDiscoveryService.cs | 4 +- .../Discovery/ReflectionInstanceFactory.cs | 4 +- .../_7499/GenericPropertyMetadataTests.cs | 5 +++ .../Bugs/_7522/PropertyMetadataTests.cs | 35 +++++++++++++++ .../ClassMetadataHelperTests.cs | 35 +++++++++++++++ 7 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 tests/TUnit.TestProject/Bugs/_7522/PropertyMetadataTests.cs create mode 100644 tests/TUnit.UnitTests/ClassMetadataHelperTests.cs diff --git a/src/TUnit.Core/PropertyInjection/ClassMetadataHelper.cs b/src/TUnit.Core/PropertyInjection/ClassMetadataHelper.cs index 2600098a19b..e668d23408a 100644 --- a/src/TUnit.Core/PropertyInjection/ClassMetadataHelper.cs +++ b/src/TUnit.Core/PropertyInjection/ClassMetadataHelper.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Reflection; namespace TUnit.Core.PropertyInjection; @@ -9,6 +10,8 @@ namespace TUnit.Core.PropertyInjection; /// internal static class ClassMetadataHelper { + private static readonly ConcurrentDictionary Properties = new(); + /// /// Gets or creates ClassMetadata for the specified type. /// @@ -33,7 +36,7 @@ public static ClassMetadata GetOrCreateClassMetadata( ReflectionInfo = p }).ToArray() ?? []; - return new ClassMetadata + return WithProperties(new ClassMetadata { Type = type, TypeInfo = new ConcreteType(type), @@ -48,10 +51,45 @@ public static ClassMetadata GetOrCreateClassMetadata( Properties = [], Parameters = constructorParameters, Parent = type.DeclaringType != null ? GetOrCreateClassMetadata(type.DeclaringType) : null - }; + }); }); } + // Match generated metadata: properties declared on this type, including static properties. + // Called inside the class cache factory, so back-references never recurse through that cache. + #if NET8_0_OR_GREATER + [RequiresUnreferencedCode("Property metadata creation requires reflection")] + #endif + public static ClassMetadata WithProperties(ClassMetadata metadata) + { + if (!Properties.TryGetValue(metadata.Type, out var properties)) + { + properties = metadata.Type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Where(p => p.Name != "EqualityContract").ToArray(); + properties = Properties.GetOrAdd(metadata.Type, properties); + } + var result = metadata with + { + Properties = properties.Select(p => new PropertyMetadata + { + Name = p.Name, + Type = p.PropertyType, + ReflectionInfo = p, + IsStatic = (p.GetMethod ?? p.SetMethod)!.IsStatic, + IsNullable = Nullable.GetUnderlyingType(p.PropertyType) != null, + Getter = instance => p.GetValue(instance), + ClassMetadata = null!, + ContainingTypeMetadata = null! + }).ToArray() + }; + foreach (var property in result.Properties) + { + property.ClassMetadata = result; + property.ContainingTypeMetadata = result; + } + return result; + } + /// /// The public constructor whose parameters describe a class's : the one /// marked [TestConstructor], which the test instance is built with, otherwise the first one. Every diff --git a/src/TUnit.Engine/Building/ReflectionMetadataBuilder.cs b/src/TUnit.Engine/Building/ReflectionMetadataBuilder.cs index d344a42b666..f033c0b8c28 100644 --- a/src/TUnit.Engine/Building/ReflectionMetadataBuilder.cs +++ b/src/TUnit.Engine/Building/ReflectionMetadataBuilder.cs @@ -61,7 +61,7 @@ private static ClassMetadata CreateClassMetadata([DynamicallyAccessedMembers(Dyn ? [] : ParameterMetadataFactory.Build(constructor.GetParameters(), nameFallback: null, computeIsNullable: true); - return new ClassMetadata + return TUnit.Core.PropertyInjection.ClassMetadataHelper.WithProperties(new ClassMetadata { Name = TypeNameFormatter.GetNameWithoutGenericArity(type), Type = type, @@ -74,7 +74,7 @@ private static ClassMetadata CreateClassMetadata([DynamicallyAccessedMembers(Dyn Parameters = constructorParameters, Properties = [], Parent = type.DeclaringType != null ? CreateClassMetadata(type.DeclaringType) : null - }; + }); }); } } diff --git a/src/TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs b/src/TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs index 23e8b0f7780..7a40e6222a3 100644 --- a/src/TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs +++ b/src/TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs @@ -895,7 +895,7 @@ public int CompareTo(HookMethodSortKey other) private static ClassMetadata CreateClassMetadata(Type type) { - return ClassMetadata.GetOrAdd(type.FullName ?? type.Name, () => new ClassMetadata + return ClassMetadata.GetOrAdd(type.FullName ?? type.Name, () => TUnit.Core.PropertyInjection.ClassMetadataHelper.WithProperties(new ClassMetadata { Name = TypeNameFormatter.GetNameWithoutGenericArity(type), Type = type, @@ -908,7 +908,7 @@ private static ClassMetadata CreateClassMetadata(Type type) Parameters = [], Properties = [], Parent = type.DeclaringType != null ? CreateClassMetadata(type.DeclaringType) : null - }); + })); } private static Func CreateInstanceHookDelegate(Type type, MethodInfo method) diff --git a/src/TUnit.Engine/Discovery/ReflectionInstanceFactory.cs b/src/TUnit.Engine/Discovery/ReflectionInstanceFactory.cs index f267a7a79c2..e6cc601d27e 100644 --- a/src/TUnit.Engine/Discovery/ReflectionInstanceFactory.cs +++ b/src/TUnit.Engine/Discovery/ReflectionInstanceFactory.cs @@ -167,7 +167,7 @@ private static DataGeneratorMetadata CreatePropertyMetadata(PropertyInfo propert private static ClassMetadata CreateClassMetadata(Type type) { - return ClassMetadata.GetOrAdd(type.FullName ?? type.Name, () => new ClassMetadata + return ClassMetadata.GetOrAdd(type.FullName ?? type.Name, () => TUnit.Core.PropertyInjection.ClassMetadataHelper.WithProperties(new ClassMetadata { Type = type, TypeInfo = new ConcreteType(type), @@ -180,7 +180,7 @@ private static ClassMetadata CreateClassMetadata(Type type) Properties = [], Parameters = [], Parent = type.DeclaringType != null ? CreateClassMetadata(type.DeclaringType) : null - }); + })); } private static bool IsFunc(Type type) diff --git a/tests/TUnit.TestProject/Bugs/_7499/GenericPropertyMetadataTests.cs b/tests/TUnit.TestProject/Bugs/_7499/GenericPropertyMetadataTests.cs index ae406b11795..def7730cd71 100644 --- a/tests/TUnit.TestProject/Bugs/_7499/GenericPropertyMetadataTests.cs +++ b/tests/TUnit.TestProject/Bugs/_7499/GenericPropertyMetadataTests.cs @@ -21,5 +21,10 @@ public async Task MetadataGettersReadClosedInstance() await Assert.That(valueGetter(this)).IsEqualTo(Value); await Assert.That(labelGetter(this)).IsEqualTo("label"); await Assert.That(optionalGetter(this)).IsEqualTo(Optional); + + var properties = TestContext.Current!.Metadata.TestDetails.MethodMetadata.Class.Properties; + await Assert.That(properties.Single(p => p.Name == nameof(Value)).Getter(this)).IsEqualTo(Value); + await Assert.That(properties.Single(p => p.Name == nameof(Label)).Getter(this)).IsEqualTo(Label); + await Assert.That(properties.Single(p => p.Name == nameof(Optional)).Getter(this)).IsEqualTo(Optional); } } diff --git a/tests/TUnit.TestProject/Bugs/_7522/PropertyMetadataTests.cs b/tests/TUnit.TestProject/Bugs/_7522/PropertyMetadataTests.cs new file mode 100644 index 00000000000..c293cf6534a --- /dev/null +++ b/tests/TUnit.TestProject/Bugs/_7522/PropertyMetadataTests.cs @@ -0,0 +1,35 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._7522; + +[EngineTest(ExpectedResult.Pass)] +public class PropertyMetadataTests +{ + public string Value => "value"; + public static int StaticValue => 42; + public int? Optional => null; + private string Hidden => "hidden"; + + [Test] + public async Task PublicPropertiesHaveUsableMetadata() + { + var metadata = TestContext.Current!.Metadata.TestDetails.MethodMetadata.Class; + await Assert.That(metadata.Properties.Length).IsEqualTo(3); + foreach (var property in metadata.Properties) + { + await Assert.That(ReferenceEquals(property.ClassMetadata, metadata)).IsTrue(); + await Assert.That(ReferenceEquals(property.ContainingTypeMetadata, metadata)).IsTrue(); + await Assert.That(property.ReflectionInfo.Name).IsEqualTo(property.Name); + } + var value = metadata.Properties.Single(p => p.Name == nameof(Value)); + await Assert.That(value.Type).IsEqualTo(typeof(string)); + await Assert.That(value.Getter(this)).IsEqualTo("value"); + await Assert.That(value.IsStatic).IsFalse(); + var staticValue = metadata.Properties.Single(p => p.Name == nameof(StaticValue)); + await Assert.That(staticValue.IsStatic).IsTrue(); + await Assert.That(staticValue.Getter(null)).IsEqualTo(42); + var optional = metadata.Properties.Single(p => p.Name == nameof(Optional)); + await Assert.That(optional.IsNullable).IsTrue(); + await Assert.That(optional.Getter(this)).IsNull(); + } +} diff --git a/tests/TUnit.UnitTests/ClassMetadataHelperTests.cs b/tests/TUnit.UnitTests/ClassMetadataHelperTests.cs new file mode 100644 index 00000000000..2d3d1ea16cb --- /dev/null +++ b/tests/TUnit.UnitTests/ClassMetadataHelperTests.cs @@ -0,0 +1,35 @@ +using TUnit.Core.PropertyInjection; + +namespace TUnit.UnitTests; + +public class ClassMetadataHelperTests +{ + [Test] + public async Task PropertiesAreDeclaredPublicAndReferBackToTheirClass() + { + var metadata = ClassMetadataHelper.GetOrCreateClassMetadata(typeof(Derived)); + await Assert.That(metadata.Properties.Length).IsEqualTo(2); + var instance = new Derived(); + var value = metadata.Properties.Single(p => p.Name == nameof(Derived.Value)); + await Assert.That(value.Getter(instance)).IsEqualTo("derived"); + await Assert.That(value.ReflectionInfo.DeclaringType).IsEqualTo(typeof(Derived)); + await Assert.That(ReferenceEquals(value.ClassMetadata, metadata)).IsTrue(); + await Assert.That(ReferenceEquals(value.ContainingTypeMetadata, metadata)).IsTrue(); + var count = metadata.Properties.Single(p => p.Name == nameof(Derived.Count)); + await Assert.That(count.IsStatic).IsTrue(); + await Assert.That(count.Getter(null)).IsEqualTo(42); + await Assert.That(ReferenceEquals(metadata, ClassMetadataHelper.GetOrCreateClassMetadata(typeof(Derived)))).IsTrue(); + } + + public class Base + { + public string Inherited => "inherited"; + } + + public class Derived : Base + { + public string Value => "derived"; + public static int Count => 42; + private string Hidden => "hidden"; + } +} From 6a4eff151ab275081017b8583da829b582ecfd12 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:15:40 +0000 Subject: [PATCH 2/2] Fix nullable property metadata and injection-first cache entries (#7522) --- .../DataGeneratorMetadataCreator.cs | 4 +- .../PropertyInjection/ClassMetadataHelper.cs | 49 ++++++++++++++++++- .../Bugs/_7522/PropertyMetadataTests.cs | 7 ++- .../ClassMetadataHelperTests.cs | 31 ++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/TUnit.Core/DataGeneratorMetadataCreator.cs b/src/TUnit.Core/DataGeneratorMetadataCreator.cs index 52a7c791b46..92c9beb9ec8 100644 --- a/src/TUnit.Core/DataGeneratorMetadataCreator.cs +++ b/src/TUnit.Core/DataGeneratorMetadataCreator.cs @@ -325,7 +325,7 @@ private static ClassMetadata GetClassMetadataForType(Type type) ReflectionInfo = p }).ToArray() ?? []; - return new ClassMetadata + return PropertyInjection.ClassMetadataHelper.WithProperties(new ClassMetadata { Type = type, TypeInfo = new ConcreteType(type), @@ -338,7 +338,7 @@ private static ClassMetadata GetClassMetadataForType(Type type) Properties = [], Parameters = constructorParameters, Parent = type.DeclaringType != null ? GetClassMetadataForType(type.DeclaringType) : null - }; + }); }); } } diff --git a/src/TUnit.Core/PropertyInjection/ClassMetadataHelper.cs b/src/TUnit.Core/PropertyInjection/ClassMetadataHelper.cs index e668d23408a..790d2d1e91a 100644 --- a/src/TUnit.Core/PropertyInjection/ClassMetadataHelper.cs +++ b/src/TUnit.Core/PropertyInjection/ClassMetadataHelper.cs @@ -76,7 +76,7 @@ public static ClassMetadata WithProperties(ClassMetadata metadata) Type = p.PropertyType, ReflectionInfo = p, IsStatic = (p.GetMethod ?? p.SetMethod)!.IsStatic, - IsNullable = Nullable.GetUnderlyingType(p.PropertyType) != null, + IsNullable = IsNullable(p), Getter = instance => p.GetValue(instance), ClassMetadata = null!, ContainingTypeMetadata = null! @@ -90,6 +90,53 @@ public static ClassMetadata WithProperties(ClassMetadata metadata) return result; } + // Read compiler annotations directly so the netstandard build has the same behavior + // without adding a dependency on NullabilityInfoContext (unavailable on that target). + private static bool IsNullable(PropertyInfo property) + { + if (property.PropertyType.IsValueType) + { + return Nullable.GetUnderlyingType(property.PropertyType) != null; + } + + var annotation = GetNullableFlag(property.GetCustomAttributesData(), "System.Runtime.CompilerServices.NullableAttribute"); + if (annotation != null) + { + return annotation == 2; + } + + if (property.GetMethod != null) + { + annotation = GetNullableFlag(property.GetMethod.GetCustomAttributesData(), "System.Runtime.CompilerServices.NullableContextAttribute"); + } + for (var type = property.DeclaringType; annotation == null && type != null; type = type.DeclaringType) + { + annotation = GetNullableFlag(type.GetCustomAttributesData(), "System.Runtime.CompilerServices.NullableContextAttribute"); + } + return annotation == 2; + } + + private static byte? GetNullableFlag(IList attributes, string attributeName) + { + foreach (var attribute in attributes) + { + if (attribute.AttributeType.FullName != attributeName || attribute.ConstructorArguments.Count != 1) + { + continue; + } + var value = attribute.ConstructorArguments[0].Value; + if (value is byte flag) + { + return flag; + } + if (value is IList flags && flags.Count > 0 && flags[0].Value is byte first) + { + return first; + } + } + return null; + } + /// /// The public constructor whose parameters describe a class's : the one /// marked [TestConstructor], which the test instance is built with, otherwise the first one. Every diff --git a/tests/TUnit.TestProject/Bugs/_7522/PropertyMetadataTests.cs b/tests/TUnit.TestProject/Bugs/_7522/PropertyMetadataTests.cs index c293cf6534a..0e6a03202b9 100644 --- a/tests/TUnit.TestProject/Bugs/_7522/PropertyMetadataTests.cs +++ b/tests/TUnit.TestProject/Bugs/_7522/PropertyMetadataTests.cs @@ -8,13 +8,15 @@ public class PropertyMetadataTests public string Value => "value"; public static int StaticValue => 42; public int? Optional => null; + public string? OptionalText => null; + public List Items => []; private string Hidden => "hidden"; [Test] public async Task PublicPropertiesHaveUsableMetadata() { var metadata = TestContext.Current!.Metadata.TestDetails.MethodMetadata.Class; - await Assert.That(metadata.Properties.Length).IsEqualTo(3); + await Assert.That(metadata.Properties.Length).IsEqualTo(5); foreach (var property in metadata.Properties) { await Assert.That(ReferenceEquals(property.ClassMetadata, metadata)).IsTrue(); @@ -25,6 +27,9 @@ public async Task PublicPropertiesHaveUsableMetadata() await Assert.That(value.Type).IsEqualTo(typeof(string)); await Assert.That(value.Getter(this)).IsEqualTo("value"); await Assert.That(value.IsStatic).IsFalse(); + await Assert.That(value.IsNullable).IsFalse(); + await Assert.That(metadata.Properties.Single(p => p.Name == nameof(OptionalText)).IsNullable).IsTrue(); + await Assert.That(metadata.Properties.Single(p => p.Name == nameof(Items)).IsNullable).IsFalse(); var staticValue = metadata.Properties.Single(p => p.Name == nameof(StaticValue)); await Assert.That(staticValue.IsStatic).IsTrue(); await Assert.That(staticValue.Getter(null)).IsEqualTo(42); diff --git a/tests/TUnit.UnitTests/ClassMetadataHelperTests.cs b/tests/TUnit.UnitTests/ClassMetadataHelperTests.cs index 2d3d1ea16cb..e026dae91f1 100644 --- a/tests/TUnit.UnitTests/ClassMetadataHelperTests.cs +++ b/tests/TUnit.UnitTests/ClassMetadataHelperTests.cs @@ -21,6 +21,37 @@ public async Task PropertiesAreDeclaredPublicAndReferBackToTheirClass() await Assert.That(ReferenceEquals(metadata, ClassMetadataHelper.GetOrCreateClassMetadata(typeof(Derived)))).IsTrue(); } + [Test] + public async Task InjectionFirstPopulatesTheSharedClassCache() + { + var data = DataGeneratorMetadataCreator.CreateForPropertyInjection( + typeof(Injected).GetProperty(nameof(Injected.Value))!, typeof(Injected), null, + new ArgumentsAttribute("injected"), "metadata-regression"); + var property = (PropertyMetadata)data.MembersToGenerate.Single(); + var metadata = property.ClassMetadata; + await Assert.That(metadata.Properties.Length).IsEqualTo(1); + await Assert.That(metadata.Properties[0].Getter(new Injected())).IsEqualTo("injected"); + await Assert.That(ReferenceEquals(metadata.Properties[0].ClassMetadata, metadata)).IsTrue(); + await Assert.That(ReferenceEquals(metadata, ClassMetadataHelper.GetOrCreateClassMetadata(typeof(Injected)))).IsTrue(); + } + + [Test] + public async Task NullableContextAppliesWhenThePropertyHasNoExplicitAnnotation() + { + var metadata = ClassMetadataHelper.GetOrCreateClassMetadata(typeof(NullableContextFixture)); + await Assert.That(metadata.Properties.Single().IsNullable).IsTrue(); + } + + public class NullableContextFixture + { + public string? Value => null; + } + + public class Injected + { + public string Value => "injected"; + } + public class Base { public string Inherited => "inherited";