Skip to content

Commit

Permalink
Add analyzer and fixer for test classes nested in generic classes (#159)
Browse files Browse the repository at this point in the history
  • Loading branch information
joaonunomota authored Aug 16, 2023
1 parent 49851f3 commit 34d10d5
Show file tree
Hide file tree
Showing 6 changed files with 265 additions and 2 deletions.
26 changes: 26 additions & 0 deletions src/xunit.analyzers.fixes/Utility/CodeAnalysisExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Editing;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Simplification;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;

namespace Xunit.Analyzers.Fixes;
Expand Down Expand Up @@ -142,6 +144,30 @@ public static async Task<Document> RemoveNode(
return editor.GetChangedDocument();
}

public static async Task<Document> ExtractNodeFromParent(
this Document document,
SyntaxNode node,
CancellationToken cancellationToken)
{
var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false);
var parent = node.Parent;

if (parent is not null)
{
editor.RemoveNode(node);

var formattedNode =
node
.WithLeadingTrivia(SyntaxFactory.ElasticMarker)
.WithTrailingTrivia(SyntaxFactory.ElasticMarker)
.WithAdditionalAnnotations(Formatter.Annotation, Simplifier.Annotation);

editor.InsertAfter(parent, formattedNode);
}

return editor.GetChangedDocument();
}

public static async Task<Document> SetBaseClass(
this Document document,
ClassDeclarationSyntax declaration,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Composition;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Xunit.Analyzers.Fixes;

[ExportCodeFixProvider(LanguageNames.CSharp), Shared]
public class TestClassCannotBeNestedInGenericClassFixer : BatchedCodeFixProvider
{
public const string Key_ExtractTestClass = "xUnit1032_TestClassCannotBeNestedInGenericClass";

public TestClassCannotBeNestedInGenericClassFixer() :
base(Descriptors.X1032_TestClassCannotBeNestedInGenericClass.Id)
{ }

public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
if (root is null)
return;

var classDeclaration = root.FindNode(context.Span).FirstAncestorOrSelf<ClassDeclarationSyntax>();
if (classDeclaration is null)
return;

context.RegisterCodeFix(
CodeAction.Create(
"Extract test class from parent class",
ct => context.Document.ExtractNodeFromParent(classDeclaration, ct),
Key_ExtractTestClass
),
context.Diagnostics
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System.Threading.Tasks;
using Xunit;
using Verify = CSharpVerifier<Xunit.Analyzers.TestClassCannotBeNestedInGenericClass>;

public class TestClassCannotBeNestedInGenericClassTests
{
[Fact]
public async Task ReportsDiagnostic_WhenTestClassIsNestedInOpenGenericType()
{
var source = @"
public abstract class OpenGenericType<T>
{
public class NestedTestClass
{
[Xunit.Fact]
public void TestMethod() { }
}
}";

var expected =
Verify
.Diagnostic()
.WithLocation(4, 18);

await Verify.VerifyAnalyzer(source, expected);
}

[Fact]
public async Task ReportsDiagnostic_WhenDerivedTestClassIsNestedInOpenGenericType()
{
var source = @"
public abstract class BaseTestClass
{
[Xunit.Fact]
public void TestMethod() { }
}
public abstract class OpenGenericType<T>
{
public class NestedTestClass : BaseTestClass
{
}
}";

var expected =
Verify
.Diagnostic()
.WithLocation(10, 18);

await Verify.VerifyAnalyzer(source, expected);
}

[Fact]
public async Task DoesNotReportDiagnostic_WhenTestClassIsNestedInClosedGenericType()
{
var source = @"
public abstract class OpenGenericType<T>
{
}
public abstract class ClosedGenericType : OpenGenericType<int>
{
public class NestedTestClass
{
[Xunit.Fact]
public void TestMethod() { }
}
}";

await Verify.VerifyAnalyzer(source);
}

[Fact]
public async Task DoesNotReportDiagnostic_WhenNestedClassIsNotTestClass()
{
var source = @"
public abstract class OpenGenericType<T>
{
public class NestedClass
{
}
}";

await Verify.VerifyAnalyzer(source);
}

[Fact]
public async Task DoesNotReportDiagnostic_WhenTestClassIsNotNestedInOpenGenericType()
{
var source = @"
public abstract class NonGenericType
{
public class NestedTestClass
{
[Xunit.Fact]
public void TestMethod() { }
}
}";

await Verify.VerifyAnalyzer(source);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Xunit;
using Xunit.Analyzers.Fixes;
using Verify = CSharpVerifier<Xunit.Analyzers.TestClassCannotBeNestedInGenericClass>;

public class TestClassCannotBeNestedInGenericClassFixerTests
{
[Fact]
public async void MovesTestClassOutOfGenericParent()
{
const string before = @"
public abstract class OpenGenericType<T>
{
public class [|NestedTestClass|]
{
[Xunit.Fact]
public void TestMethod() { }
}
}";
const string after = @"
public abstract class OpenGenericType<T>
{
}
public class NestedTestClass
{
[Xunit.Fact]
public void TestMethod() { }
}";

await Verify.VerifyCodeFix(before, after, TestClassCannotBeNestedInGenericClassFixer.Key_ExtractTestClass);
}
}
10 changes: 8 additions & 2 deletions src/xunit.analyzers/Utility/Descriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,14 @@ static DiagnosticDescriptor Rule(
Error,
"Test methods must not use blocking task operations, as they can cause deadlocks. Use an async test method and await instead."
);

// Placeholder for rule X1032
public static DiagnosticDescriptor X1032_TestClassCannotBeNestedInGenericClass { get; } =
Rule(
"xUnit1032",
"Test classes cannot be nested within a generic class",
Usage,
Error,
"Test classes cannot be nested within a generic class. Move the test class out of the class it is nested in."
);

public static DiagnosticDescriptor X1033_TestClassShouldHaveTFixtureArgument { get; } =
Rule(
Expand Down
59 changes: 59 additions & 0 deletions src/xunit.analyzers/X1000/TestClassCannotBeNestedInGenericClass.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Xunit.Analyzers;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class TestClassCannotBeNestedInGenericClass : XunitDiagnosticAnalyzer
{
public TestClassCannotBeNestedInGenericClass() :
base(Descriptors.X1032_TestClassCannotBeNestedInGenericClass)
{ }

public override void AnalyzeCompilation(
CompilationStartAnalysisContext context,
XunitContext xunitContext)
{
context.RegisterSymbolAction(context =>
{
if (xunitContext.Core.FactAttributeType is null)
return;
if (context.Symbol is not INamedTypeSymbol classSymbol)
return;
if (classSymbol.ContainingType is null)
return;
if (!classSymbol.ContainingType.IsGenericType)
return;
var doesClassContainTests = DoesInheritenceTreeContainTests(classSymbol, xunitContext, depth: 3);
if (!doesClassContainTests)
return;
context.ReportDiagnostic(
Diagnostic.Create(
Descriptors.X1032_TestClassCannotBeNestedInGenericClass,
classSymbol.Locations.First()
)
);
}, SymbolKind.NamedType);
}

private bool DoesInheritenceTreeContainTests(
INamedTypeSymbol classSymbol,
XunitContext xunitContext,
int depth)
{
var doesClassContainTests =
classSymbol
.GetMembers()
.OfType<IMethodSymbol>()
.Any(m => m.GetAttributes().Any(a => xunitContext.Core.FactAttributeType.IsAssignableFrom(a.AttributeClass)));

if (!doesClassContainTests && classSymbol.BaseType is not null && depth > 0)
return DoesInheritenceTreeContainTests(classSymbol.BaseType, xunitContext, depth - 1);

return doesClassContainTests;
}
}

0 comments on commit 34d10d5

Please sign in to comment.