Skip to content

Commit 6c9dfec

Browse files
authored
feat: add diagnostics for abstract test classes with data sources (#3286)
1 parent dc8ff25 commit 6c9dfec

File tree

6 files changed

+295
-3
lines changed

6 files changed

+295
-3
lines changed
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
using Verifier = TUnit.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier<TUnit.Analyzers.AbstractTestClassWithDataSourcesAnalyzer>;
2+
3+
namespace TUnit.Analyzers.Tests;
4+
5+
public class AbstractTestClassWithDataSourcesAnalyzerTests
6+
{
7+
[Test]
8+
public async Task No_Warning_For_Concrete_Class_With_Data_Source()
9+
{
10+
await Verifier
11+
.VerifyAnalyzerAsync(
12+
"""
13+
using TUnit.Core;
14+
using System.Collections.Generic;
15+
16+
public class ConcreteTests
17+
{
18+
public static IEnumerable<int> TestData() => new[] { 1, 2, 3 };
19+
20+
[Test]
21+
[MethodDataSource(nameof(TestData))]
22+
public void DataDrivenTest(int value)
23+
{
24+
}
25+
}
26+
"""
27+
);
28+
}
29+
30+
[Test]
31+
public async Task No_Warning_For_Abstract_Class_Without_Data_Sources()
32+
{
33+
await Verifier
34+
.VerifyAnalyzerAsync(
35+
"""
36+
using TUnit.Core;
37+
38+
public abstract class AbstractTestBase
39+
{
40+
[Test]
41+
public void SimpleTest()
42+
{
43+
}
44+
}
45+
"""
46+
);
47+
}
48+
49+
[Test]
50+
public async Task No_Warning_For_Abstract_Class_Without_Tests()
51+
{
52+
await Verifier
53+
.VerifyAnalyzerAsync(
54+
"""
55+
using TUnit.Core;
56+
57+
public abstract class AbstractBase
58+
{
59+
public void HelperMethod()
60+
{
61+
}
62+
}
63+
"""
64+
);
65+
}
66+
67+
[Test]
68+
public async Task Warning_For_Abstract_Class_With_MethodDataSource()
69+
{
70+
await Verifier
71+
.VerifyAnalyzerAsync(
72+
"""
73+
using TUnit.Core;
74+
using System.Collections.Generic;
75+
76+
public abstract class {|#0:AbstractTestBase|}
77+
{
78+
public static IEnumerable<int> TestData() => new[] { 1, 2, 3 };
79+
80+
[Test]
81+
[MethodDataSource(nameof(TestData))]
82+
public void DataDrivenTest(int value)
83+
{
84+
}
85+
}
86+
""",
87+
88+
Verifier.Diagnostic(Rules.AbstractTestClassWithDataSources)
89+
.WithLocation(0)
90+
.WithArguments("AbstractTestBase")
91+
);
92+
}
93+
94+
[Test]
95+
public async Task Warning_For_Abstract_Class_With_InstanceMethodDataSource()
96+
{
97+
await Verifier
98+
.VerifyAnalyzerAsync(
99+
"""
100+
using TUnit.Core;
101+
using System.Collections.Generic;
102+
103+
public abstract class {|#0:ServiceCollectionTest|}
104+
{
105+
public IEnumerable<int> SingletonServices() => new[] { 1, 2, 3 };
106+
107+
[Test]
108+
[InstanceMethodDataSource(nameof(SingletonServices))]
109+
public void ServiceCanBeCreatedAsSingleton(int value)
110+
{
111+
}
112+
}
113+
""",
114+
115+
Verifier.Diagnostic(Rules.AbstractTestClassWithDataSources)
116+
.WithLocation(0)
117+
.WithArguments("ServiceCollectionTest")
118+
);
119+
}
120+
121+
[Test]
122+
public async Task Warning_For_Abstract_Class_With_Arguments()
123+
{
124+
await Verifier
125+
.VerifyAnalyzerAsync(
126+
"""
127+
using TUnit.Core;
128+
129+
public abstract class {|#0:AbstractTestBase|}
130+
{
131+
[Test]
132+
[Arguments(1)]
133+
[Arguments(2)]
134+
public void DataDrivenTest(int value)
135+
{
136+
}
137+
}
138+
""",
139+
140+
Verifier.Diagnostic(Rules.AbstractTestClassWithDataSources)
141+
.WithLocation(0)
142+
.WithArguments("AbstractTestBase")
143+
);
144+
}
145+
146+
[Test]
147+
public async Task Warning_For_Abstract_Class_With_ClassDataSource()
148+
{
149+
await Verifier
150+
.VerifyAnalyzerAsync(
151+
"""
152+
using TUnit.Core;
153+
154+
public class TestData
155+
{
156+
}
157+
158+
public abstract class {|#0:AbstractTestBase|}
159+
{
160+
[Test]
161+
[ClassDataSource<TestData>]
162+
public void DataDrivenTest(TestData data)
163+
{
164+
}
165+
}
166+
""",
167+
168+
Verifier.Diagnostic(Rules.AbstractTestClassWithDataSources)
169+
.WithLocation(0)
170+
.WithArguments("AbstractTestBase")
171+
);
172+
}
173+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.Diagnostics;
4+
using TUnit.Analyzers.Extensions;
5+
using TUnit.Analyzers.Helpers;
6+
7+
namespace TUnit.Analyzers;
8+
9+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
10+
public class AbstractTestClassWithDataSourcesAnalyzer : ConcurrentDiagnosticAnalyzer
11+
{
12+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } =
13+
ImmutableArray.Create(Rules.AbstractTestClassWithDataSources);
14+
15+
protected override void InitializeInternal(AnalysisContext context)
16+
{
17+
context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
18+
}
19+
20+
private void AnalyzeSymbol(SymbolAnalysisContext context)
21+
{
22+
if (context.Symbol is not INamedTypeSymbol namedTypeSymbol)
23+
{
24+
return;
25+
}
26+
27+
// Only analyze abstract classes
28+
if (!namedTypeSymbol.IsAbstract)
29+
{
30+
return;
31+
}
32+
33+
// Check if it's a test class
34+
if (!namedTypeSymbol.IsTestClass(context.Compilation))
35+
{
36+
return;
37+
}
38+
39+
// Get all test methods in this class
40+
var testMethods = namedTypeSymbol.GetMembers()
41+
.OfType<IMethodSymbol>()
42+
.Where(m => m.IsTestMethod(context.Compilation))
43+
.ToList();
44+
45+
if (!testMethods.Any())
46+
{
47+
return;
48+
}
49+
50+
// Check if any test method has a data source attribute
51+
var hasDataSourceAttributes = testMethods.Any(method =>
52+
{
53+
var attributes = method.GetAttributes();
54+
return attributes.Any(attr =>
55+
{
56+
var attributeClass = attr.AttributeClass;
57+
if (attributeClass == null)
58+
{
59+
return false;
60+
}
61+
62+
// Check for data source attributes
63+
var currentType = attributeClass;
64+
while (currentType != null)
65+
{
66+
var typeName = currentType.Name;
67+
68+
// Check for known data source attributes
69+
if (typeName.Contains("DataSource") || typeName == "ArgumentsAttribute")
70+
{
71+
return true;
72+
}
73+
74+
currentType = currentType.BaseType;
75+
}
76+
77+
// Also check if it implements IDataSourceAttribute
78+
return attributeClass.AllInterfaces.Any(i =>
79+
i.GloballyQualified() == WellKnown.AttributeFullyQualifiedClasses.IDataSourceAttribute.WithGlobalPrefix);
80+
});
81+
});
82+
83+
if (hasDataSourceAttributes)
84+
{
85+
context.ReportDiagnostic(Diagnostic.Create(
86+
Rules.AbstractTestClassWithDataSources,
87+
namedTypeSymbol.Locations.FirstOrDefault(),
88+
namedTypeSymbol.Name)
89+
);
90+
}
91+
}
92+
}

TUnit.Analyzers/AnalyzerReleases.Shipped.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ TUnit0046 | Usage | Warning | Data source should return Func<T> for lazy evaluat
3232
TUnit0049 | Usage | Error | [Matrix] parameters require [MatrixDataSource] attribute on the test method
3333
TUnit0050 | Usage | Error | Too many test arguments provided
3434
TUnit0056 | Usage | Error | Instance data source methods must use [InstanceMethodDataSource] attribute
35+
TUnit0060 | Usage | Info | Data source may produce no tests - ensure it provides at least one test case
3536

3637
#### Hook and Lifecycle Rules
3738
Rule ID | Category | Severity | Notes
@@ -54,6 +55,7 @@ TUnit0028 | Usage | Error | Do not override TUnit's AttributeUsage settings
5455
TUnit0029 | Usage | Error | Duplicate attribute where only one is allowed
5556
TUnit0030 | Usage | Warning | Test class doesn't inherit base class tests - add [InheritsTests] to include them
5657
TUnit0032 | Usage | Error | [DependsOn] and [NotInParallel] attributes conflict - tests with dependencies must support parallel execution
58+
TUnit0059 | Usage | Warning | Abstract test class with data sources requires [InheritsTests] on concrete class to execute tests
5759
TUnit0033 | Usage | Error | Circular or conflicting test dependencies detected
5860

5961
#### Async and Execution Rules

TUnit.Analyzers/Resources.resx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,24 @@
426426
<data name="TUnit0058Title" xml:space="preserve">
427427
<value>Hook method has unknown parameters</value>
428428
</data>
429+
<data name="TUnit0059Description" xml:space="preserve">
430+
<value>Abstract test class has test methods with data source attributes. Tests from abstract classes require a concrete class with [InheritsTests] attribute to be executed.</value>
431+
</data>
432+
<data name="TUnit0059MessageFormat" xml:space="preserve">
433+
<value>Abstract test class '{0}' has test methods with data sources. Add [InheritsTests] on a concrete class to execute these tests.</value>
434+
</data>
435+
<data name="TUnit0059Title" xml:space="preserve">
436+
<value>Abstract test class with data sources requires [InheritsTests]</value>
437+
</data>
438+
<data name="TUnit0060Description" xml:space="preserve">
439+
<value>Instance method data source may return an empty collection at runtime, which would result in no tests being generated. Ensure the data source method returns at least one item.</value>
440+
</data>
441+
<data name="TUnit0060MessageFormat" xml:space="preserve">
442+
<value>Data source '{0}' may return no data. Ensure it provides at least one test case.</value>
443+
</data>
444+
<data name="TUnit0060Title" xml:space="preserve">
445+
<value>Data source may produce no tests</value>
446+
</data>
429447
<data name="TUnit0300Description" xml:space="preserve">
430448
<value>Generic types and methods may not be AOT-compatible when using dynamic type creation. Consider using concrete types or ensure all generic combinations are known at compile time.</value>
431449
</data>

TUnit.Analyzers/Rules.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ public static class Rules
141141
public static readonly DiagnosticDescriptor HookUnknownParameters =
142142
CreateDescriptor("TUnit0058", UsageCategory, DiagnosticSeverity.Error);
143143

144+
public static readonly DiagnosticDescriptor AbstractTestClassWithDataSources =
145+
CreateDescriptor("TUnit0059", UsageCategory, DiagnosticSeverity.Warning);
146+
147+
public static readonly DiagnosticDescriptor PotentialEmptyDataSource =
148+
CreateDescriptor("TUnit0060", UsageCategory, DiagnosticSeverity.Info);
149+
144150
public static readonly DiagnosticDescriptor GenericTypeNotAotCompatible =
145151
CreateDescriptor("TUnit0300", UsageCategory, DiagnosticSeverity.Warning);
146152

TUnit.Analyzers/TestDataAnalyzer.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ public class TestDataAnalyzer : ConcurrentDiagnosticAnalyzer
2727
Rules.ReturnFunc,
2828
Rules.MatrixDataSourceAttributeRequired,
2929
Rules.TooManyArguments,
30-
Rules.InstanceMethodSource
30+
Rules.InstanceMethodSource,
31+
Rules.PotentialEmptyDataSource
3132
);
3233

3334
protected override void InitializeInternal(AnalysisContext context)
@@ -194,10 +195,10 @@ private void Analyze(SymbolAnalysisContext context,
194195
context.Compilation.GetTypeByMetadataName(WellKnown.AttributeFullyQualifiedClasses.MethodDataSource.WithoutGlobalPrefix)))
195196
{
196197
// For property injection, only validate against the property type, not method parameters
197-
var typesToValidate = propertySymbol != null
198+
var typesToValidate = propertySymbol != null
198199
? ImmutableArray.Create(propertySymbol.Type)
199200
: parameters.Select(p => p.Type).ToImmutableArray().WithoutCancellationTokenParameter();
200-
201+
201202
CheckMethodDataSource(context, attribute, testClassType, typesToValidate, propertySymbol);
202203
}
203204

0 commit comments

Comments
 (0)