Skip to content

Commit 591e23d

Browse files
thomhurstclaude
andauthored
Detect injected properties in base classes (#3081)
* Fix InheritsTests source location to show actual test method location Resolves issue #3055 where tests using [InheritsTests] attribute reported source location at the attribute location instead of the actual test method location. Changes: - Added GetMethodLocation helper to extract actual method source location - Modified GenerateInheritedTestSources to use method location when available - Falls back to class location if method location unavailable - Updated snapshot tests to reflect correct line numbers 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Simplify fix to use TestAttribute's CallerFilePath and CallerLineNumber Improved the source location fix by leveraging the existing CallerFilePath and CallerLineNumber parameters in TestAttribute constructor arguments, which are automatically captured when the [Test] attribute is applied. This approach is: - Simpler and more reliable than parsing syntax trees - Uses the built-in compiler-provided location information - Removes the need for the GetMethodLocation helper method - More consistent with how regular test methods get their source location The TestAttribute already captures the exact file and line number via [CallerFilePath] and [CallerLineNumber] attributes, making this the preferred approach for getting accurate source locations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Remove redundant comments * Fix InheritsTests line numbers to reflect correct test method locations * Refactor PropertyInjectionSourceGenerator and RequiredPropertyHelper for clarity and efficiency; add tests for bug 3072 * Enhance PropertyInjectionSourceGenerator to skip non-public and unbound generic types; deduplicate class sources for improved efficiency * Fix line numbers in verified test files for accurate test method locations --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 348c5cc commit 591e23d

File tree

4 files changed

+125
-69
lines changed

4 files changed

+125
-69
lines changed

TUnit.Core.SourceGenerator/CodeGenerators/Helpers/RequiredPropertyHelper.cs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,15 @@ private static bool HasDataSourceAttribute(IPropertySymbol property)
4848
{
4949
return property.GetAttributes().Any(attr =>
5050
{
51-
var attrName = attr.AttributeClass?.Name ?? "";
52-
return attrName.EndsWith("DataSourceAttribute") ||
53-
attrName == "ClassDataSource" ||
54-
attrName == "MethodDataSource" ||
55-
attrName == "ArgumentsAttribute" ||
56-
attrName == "DataSourceForAttribute";
51+
var attrClass = attr.AttributeClass;
52+
53+
if (attrClass == null)
54+
{
55+
return false;
56+
}
57+
58+
// Check if the attribute implements IDataSourceAttribute
59+
return attrClass.AllInterfaces.Any(i => i.GloballyQualified() == WellKnownFullyQualifiedClassNames.IDataSourceAttribute.WithGlobalPrefix);
5760
});
5861
}
5962

@@ -85,4 +88,4 @@ public static string GetDefaultValueForType(ITypeSymbol type)
8588
_ => $"default({type.GloballyQualified()})"
8689
};
8790
}
88-
}
91+
}

TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs

Lines changed: 76 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
using System;
21
using System.Collections.Immutable;
3-
using System.Linq;
42
using System.Text;
53
using Microsoft.CodeAnalysis;
64
using Microsoft.CodeAnalysis.CSharp.Syntax;
@@ -27,23 +25,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
2725

2826
private static bool IsClassWithDataSourceProperties(SyntaxNode node)
2927
{
30-
if (node is not TypeDeclarationSyntax typeDecl)
31-
{
32-
return false;
33-
}
34-
35-
// Include classes with properties that have attributes
36-
var hasAttributedProperties = typeDecl.Members
37-
.OfType<PropertyDeclarationSyntax>()
38-
.Any(prop => prop.AttributeLists.Count > 0);
39-
40-
// Also include classes that inherit from data source attributes
41-
var inheritsFromDataSource = typeDecl.BaseList?.Types.Any(t =>
42-
t.ToString().Contains("DataSourceGeneratorAttribute") ||
43-
t.ToString().Contains("AsyncDataSourceGeneratorAttribute") ||
44-
t.ToString().Contains("DataSourceAttribute")) == true;
45-
46-
return hasAttributedProperties || inheritsFromDataSource;
28+
return node is TypeDeclarationSyntax;
4729
}
4830

4931
private static ClassWithDataSourceProperties? GetClassWithDataSourceProperties(GeneratorSyntaxContext context)
@@ -56,6 +38,19 @@ private static bool IsClassWithDataSourceProperties(SyntaxNode node)
5638
return null;
5739
}
5840

41+
// Skip types that are not publicly accessible to avoid accessibility issues
42+
// Also check if the type is nested and ensure the containing types are also public
43+
if (!IsPubliclyAccessible(typeSymbol))
44+
{
45+
return null;
46+
}
47+
48+
// Skip open generic types (unbound type parameters) as they cannot be instantiated
49+
if (typeSymbol.IsUnboundGenericType || typeSymbol.TypeParameters.Length > 0)
50+
{
51+
return null;
52+
}
53+
5954
var propertiesWithDataSources = new List<PropertyWithDataSourceAttribute>();
6055
var dataSourceInterface = semanticModel.Compilation.GetTypeByMetadataName("TUnit.Core.IDataSourceAttribute");
6156

@@ -67,51 +62,34 @@ private static bool IsClassWithDataSourceProperties(SyntaxNode node)
6762
// Check if this type itself implements IDataSourceAttribute (for custom data source classes)
6863
var implementsDataSource = typeSymbol.AllInterfaces.Contains(dataSourceInterface, SymbolEqualityComparer.Default);
6964

70-
var currentType = typeSymbol;
7165
var processedProperties = new HashSet<string>();
7266

73-
while (currentType != null)
67+
var properties = typeSymbol.GetMembersIncludingBase()
68+
.OfType<IPropertySymbol>()
69+
.Where(CanSetProperty);
70+
71+
foreach (var property in properties)
7472
{
75-
foreach (var member in currentType.GetMembers())
73+
if (!processedProperties.Add(property.Name))
7674
{
77-
if (member is IPropertySymbol property && CanSetProperty(property))
78-
{
79-
if (!processedProperties.Add(property.Name))
80-
{
81-
continue;
82-
}
83-
84-
foreach (var attr in property.GetAttributes())
85-
{
86-
if (attr.AttributeClass != null &&
87-
(attr.AttributeClass.IsOrInherits(dataSourceInterface) ||
88-
attr.AttributeClass.AllInterfaces.Contains(dataSourceInterface, SymbolEqualityComparer.Default)))
89-
{
90-
propertiesWithDataSources.Add(new PropertyWithDataSourceAttribute
91-
{
92-
Property = property,
93-
DataSourceAttribute = attr
94-
});
95-
break; // Only one data source per property
96-
}
97-
}
98-
}
75+
continue;
9976
}
10077

101-
currentType = currentType.BaseType;
102-
103-
if (currentType?.SpecialType == SpecialType.System_Object)
78+
foreach (var attr in property.GetAttributes())
10479
{
105-
break;
80+
if (attr.AttributeClass != null &&
81+
attr.AttributeClass.AllInterfaces.Contains(dataSourceInterface, SymbolEqualityComparer.Default))
82+
{
83+
propertiesWithDataSources.Add(new PropertyWithDataSourceAttribute
84+
{
85+
Property = property,
86+
DataSourceAttribute = attr
87+
});
88+
break; // Only one data source per property
89+
}
10690
}
10791
}
10892

109-
// Include the class if it has properties with data sources OR if it implements IDataSourceAttribute
110-
if (propertiesWithDataSources.Count == 0 && !implementsDataSource)
111-
{
112-
return null;
113-
}
114-
11593
return new ClassWithDataSourceProperties
11694
{
11795
ClassSymbol = typeSymbol,
@@ -124,6 +102,36 @@ private static bool CanSetProperty(IPropertySymbol property)
124102
return property.SetMethod != null || property.SetMethod?.IsInitOnly == true;
125103
}
126104

105+
private static bool IsPubliclyAccessible(INamedTypeSymbol typeSymbol)
106+
{
107+
// Check if the type itself is public
108+
if (typeSymbol.DeclaredAccessibility != Accessibility.Public)
109+
{
110+
return false;
111+
}
112+
113+
// If it's a nested type, ensure all containing types are also public
114+
// and don't have unbound type parameters
115+
var containingType = typeSymbol.ContainingType;
116+
while (containingType != null)
117+
{
118+
if (containingType.DeclaredAccessibility != Accessibility.Public)
119+
{
120+
return false;
121+
}
122+
123+
// Check if the containing type has unbound type parameters
124+
if (containingType.IsUnboundGenericType || containingType.TypeParameters.Length > 0)
125+
{
126+
return false;
127+
}
128+
129+
containingType = containingType.ContainingType;
130+
}
131+
132+
return true;
133+
}
134+
127135
private static void GeneratePropertyInjectionSources(SourceProductionContext context, ImmutableArray<ClassWithDataSourceProperties> classes)
128136
{
129137
if (classes.IsEmpty)
@@ -135,17 +143,23 @@ private static void GeneratePropertyInjectionSources(SourceProductionContext con
135143

136144
WriteFileHeader(sourceBuilder);
137145

146+
// Deduplicate classes by symbol to prevent duplicate source generation
147+
var uniqueClasses = classes
148+
.GroupBy(c => c.ClassSymbol, SymbolEqualityComparer.Default)
149+
.Select(g => g.First())
150+
.ToImmutableArray();
151+
138152
// Generate all property sources first with stable names
139153
var classNameMapping = new Dictionary<INamedTypeSymbol, string>(SymbolEqualityComparer.Default);
140-
foreach (var classInfo in classes)
154+
foreach (var classInfo in uniqueClasses)
141155
{
142156
var sourceClassName = GetPropertySourceClassName(classInfo.ClassSymbol);
143157
classNameMapping[classInfo.ClassSymbol] = sourceClassName;
144158
}
145159

146-
GenerateModuleInitializer(sourceBuilder, classes, classNameMapping);
160+
GenerateModuleInitializer(sourceBuilder, uniqueClasses, classNameMapping);
147161

148-
foreach (var classInfo in classes)
162+
foreach (var classInfo in uniqueClasses)
149163
{
150164
GeneratePropertySource(sourceBuilder, classInfo, classNameMapping[classInfo.ClassSymbol]);
151165
}
@@ -218,7 +232,7 @@ private static void GenerateUnsafeAccessorMethods(StringBuilder sb, ClassWithDat
218232

219233
// Use the property's containing type for the UnsafeAccessor, not the derived class
220234
var containingType = propInfo.Property.ContainingType.ToDisplayString();
221-
235+
222236
sb.AppendLine("#if NET8_0_OR_GREATER");
223237
sb.AppendLine($" [global::System.Runtime.CompilerServices.UnsafeAccessor(global::System.Runtime.CompilerServices.UnsafeAccessorKind.Field, Name = \"{backingFieldName}\")]");
224238
sb.AppendLine($" private static extern ref {propertyType} Get{propInfo.Property.Name}BackingField({containingType} instance);");
@@ -344,9 +358,10 @@ private static string GetPropertyCastExpression(IPropertySymbol property, string
344358

345359
private static string GetPropertySourceClassName(INamedTypeSymbol classSymbol)
346360
{
347-
// Use a random GUID for uniqueness
348-
var guid = Guid.NewGuid();
349-
return $"PropertyInjectionSource_{guid:N}";
361+
// Use a deterministic hash based on the fully qualified type name for uniqueness
362+
var fullTypeName = classSymbol.ToDisplayString();
363+
var hash = fullTypeName.GetHashCode();
364+
return $"PropertyInjectionSource_{Math.Abs(hash):x}";
350365
}
351366

352367
private static string FormatTypedConstant(TypedConstant constant)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using TUnit.Core.Interfaces;
2+
using TUnit.TestProject.Attributes;
3+
4+
namespace TUnit.TestProject.Bugs._3072;
5+
6+
public record DataClass
7+
{
8+
public string TestProperty { get; init; } = "TestValue";
9+
}
10+
11+
public abstract class BaseClass
12+
{
13+
[ClassDataSource<DataClass>(Shared = SharedType.PerTestSession)]
14+
public required DataClass TestData { get; init; }
15+
}
16+
17+
public class TestFactory : BaseClass, IAsyncInitializer
18+
{
19+
public Task InitializeAsync()
20+
{
21+
var test = TestData.TestProperty; // TestData is null here in 0.57.24
22+
return Task.CompletedTask;
23+
}
24+
}
25+
26+
[EngineTest(ExpectedResult.Pass)]
27+
public class Tests : IAsyncInitializer
28+
{
29+
[ClassDataSource<TestFactory>(Shared = SharedType.PerTestSession)]
30+
public required TestFactory TestDataFactory { get; init; }
31+
32+
public Task InitializeAsync() => Task.CompletedTask;
33+
34+
[Test]
35+
public async Task Test()
36+
{
37+
await Assert.That(TestDataFactory?.TestData?.TestProperty).IsEqualTo("TestValue");
38+
}
39+
}

TestProject.targets

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
<ItemGroup>
88
<ProjectReference Include="$(MSBuildThisFileDirectory)TUnit.Engine\TUnit.Engine.csproj" />
99
<ProjectReference Include="$(MSBuildThisFileDirectory)TUnit.Assertions\TUnit.Assertions.csproj" />
10-
<PackageReference Include="Microsoft.Testing.Platform.MSBuild" />
1110

1211
<ProjectReference
1312
Include="$(MSBuildThisFileDirectory)TUnit.Assertions.Analyzers\TUnit.Assertions.Analyzers.csproj"

0 commit comments

Comments
 (0)