diff --git a/Directory.Build.props b/Directory.Build.props
index 432358349..7d2bbe961 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -37,7 +37,7 @@
true
-
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index bf8561613..8aaa9b571 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -102,5 +102,6 @@
+
diff --git a/README.md b/README.md
index 98ab4b82f..94f87afcc 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ bUnit is available on NuGet in various incarnations. Most should just pick the [
| Name | Description | NuGet Download Link |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| [bUnit](https://www.nuget.org/packages/bunit/) | Adds support for testing Blazor components. | [](https://www.nuget.org/packages/bunit/) |
+| [bUnit.analyzers](https://www.nuget.org/packages/bunit.analyzers/) | Roslyn analyzers to help identify common mistakes and anti-patterns. | [](https://www.nuget.org/packages/bunit.analyzers/) |
| [bUnit.template](https://www.nuget.org/packages/bunit.template/) | Template, which currently creates xUnit-based bUnit test projects only. | [](https://www.nuget.org/packages/bunit.template/) |
| [bUnit.generators](https://www.nuget.org/packages/bunit.generators/) | Source code generators to minimize code setup in various situations. | [](https://www.nuget.org/packages/bunit.generators/) |
| [bUnit.web.query](https://www.nuget.org/packages/bunit.web.query/) | bUnit implementation of testing-library.com's query APIs. | [](https://www.nuget.org/packages/bunit.web.query/) |
diff --git a/bunit.slnx b/bunit.slnx
index 8c453b444..9096e574c 100644
--- a/bunit.slnx
+++ b/bunit.slnx
@@ -22,6 +22,7 @@
+
@@ -35,6 +36,7 @@
+
diff --git a/docs/site/docs/extensions/bunit-analyzers.md b/docs/site/docs/extensions/bunit-analyzers.md
new file mode 100644
index 000000000..c9b58739f
--- /dev/null
+++ b/docs/site/docs/extensions/bunit-analyzers.md
@@ -0,0 +1,132 @@
+---
+uid: bunit-analyzers
+title: bUnit Analyzers
+---
+
+# bUnit Analyzers
+
+The `bunit.analyzers` package contains a set of Roslyn analyzers that help identify common mistakes and anti-patterns when writing bUnit tests. The analyzers are designed to provide early feedback during development, catching issues before tests are run. To use the analyzers, install the `bunit.analyzers` NuGet package in your test project.
+
+This page describes the available analyzers and their usage.
+
+## Installation
+
+Install the package via NuGet:
+
+```bash
+dotnet add package bunit.analyzers
+```
+
+The analyzers will automatically run during compilation and provide warnings or suggestions in your IDE and build output.
+
+## Available Analyzers
+
+### BUNIT0002: Prefer Find<T> over casting
+
+**Severity**: Info
+**Category**: Usage
+
+This analyzer detects when a cast is applied to the result of `Find(selector)` and suggests using the generic `Find(selector)` method instead. Using the generic method is more concise, type-safe, and expresses intent more clearly.
+
+#### Examples
+
+**Incorrect** - triggers BUNIT0002:
+```csharp
+using AngleSharp.Dom;
+
+var cut = Render();
+IHtmlAnchorElement link = (IHtmlAnchorElement)cut.Find("a");
+```
+
+**Correct**:
+```csharp
+using AngleSharp.Dom;
+
+var cut = Render();
+var link = cut.Find("a");
+```
+
+#### When to Use
+
+`Find()` should be used whenever a specific element type is needed:
+- When working with AngleSharp element interfaces (`IHtmlAnchorElement`, `IHtmlButtonElement`, etc.)
+- When type-specific properties or methods need to be accessed
+- When clearer, more maintainable test code is desired
+
+### BUNIT0001: Razor test files should inherit from BunitContext
+
+**Status**: Planned for future release
+
+This analyzer will detect when Razor test files (`.razor` files) use variables or event callbacks from the test code without inheriting from `BunitContext`. Without the proper inheritance, the error "The render handle is not yet assigned" may be encountered.
+
+#### Planned Examples
+
+**Incorrect** - will trigger BUNIT0001 in the future:
+```razor
+@code
+{
+ [Fact]
+ public void Test()
+ {
+ using var ctx = new BunitContext();
+
+ Action onClickHandler = _ => { Assert.True(true); };
+
+ var cut = ctx.Render(@);
+ cut.Find("button").Click();
+ }
+}
+```
+
+**Correct**:
+```razor
+@inherits BunitContext
+@code
+{
+ [Fact]
+ public async Task Test()
+ {
+ var wasInvoked = false;
+
+ Action onClick = _ => { wasInvoked = true; };
+
+ var cut = Render(@);
+ var button = cut.Find("button");
+ await button.ClickAsync(new MouseEventArgs());
+
+ cut.WaitForAssertion(() => Assert.True(wasInvoked));
+ }
+}
+```
+
+## Configuration
+
+The analyzers can be configured in a project's `.editorconfig` file or using ruleset files. For example, to change the severity of BUNIT0002:
+
+```ini
+# .editorconfig
+[*.cs]
+# Change BUNIT0002 from Info to Warning
+dotnet_diagnostic.BUNIT0002.severity = warning
+
+# Or disable it entirely
+dotnet_diagnostic.BUNIT0002.severity = none
+```
+
+## Contributing
+
+We welcome contributions! If you have ideas for additional analyzers that could help bUnit users, please:
+
+1. Check existing [issues](https://github.com/bunit-dev/bUnit/issues) for similar suggestions
+2. Open a new issue describing the problem the analyzer would solve
+3. Submit a pull request with your implementation
+
+Common areas for improvement include:
+- Detecting incorrect usage of `WaitFor` methods
+- Identifying missing or incorrect component parameter bindings
+- Catching improper service injection patterns
+- Finding opportunities to use helper methods
+
+## Feedback
+
+If you encounter issues with the analyzers or have suggestions for improvements, please [open an issue](https://github.com/bunit-dev/bUnit/issues/new) on GitHub.
diff --git a/docs/site/docs/extensions/index.md b/docs/site/docs/extensions/index.md
index c274a432a..c548eb598 100644
--- a/docs/site/docs/extensions/index.md
+++ b/docs/site/docs/extensions/index.md
@@ -7,4 +7,5 @@ title: Extensions for bUnit
This section covers the various extensions available for bUnit. These extensions are not part of the core bUnit package, but are instead available as separate NuGet packages. The extensions are listed below, and each has its own documentation page.
+ * **[bunit.analyzers](xref:bunit-analyzers)** - A set of Roslyn analyzers that help identify common mistakes and anti-patterns when writing bUnit tests
* **[bunit.generators](xref:bunit-generators)** - A set of source generators that can be used to generate code like stubs for Blazor components
\ No newline at end of file
diff --git a/src/bunit.analyzers/AnalyzerReleases.Unshipped.md b/src/bunit.analyzers/AnalyzerReleases.Unshipped.md
new file mode 100644
index 000000000..191ed37a6
--- /dev/null
+++ b/src/bunit.analyzers/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://raw.githubusercontent.com/dotnet/roslyn/main/src/Compilers/Core/Portable/DiagnosticAnalyzers/AnalyzerReleases.Unshipped.schema.json",
+ "document": [
+ {
+ "id": "BUNIT0001",
+ "isEnabledByDefault": true,
+ "severity": "warning"
+ },
+ {
+ "id": "BUNIT0002",
+ "isEnabledByDefault": true,
+ "severity": "info"
+ }
+ ]
+}
diff --git a/src/bunit.analyzers/Analyzers/PreferGenericFindAnalyzer.cs b/src/bunit.analyzers/Analyzers/PreferGenericFindAnalyzer.cs
new file mode 100644
index 000000000..980675e4e
--- /dev/null
+++ b/src/bunit.analyzers/Analyzers/PreferGenericFindAnalyzer.cs
@@ -0,0 +1,103 @@
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Bunit.Analyzers;
+
+///
+/// Analyzer that detects cast expressions from Find() and suggests using Find{T}() instead.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class PreferGenericFindAnalyzer : DiagnosticAnalyzer
+{
+ ///
+ public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptors.PreferGenericFind);
+
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ if (context is null)
+ {
+ throw new System.ArgumentNullException(nameof(context));
+ }
+
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.EnableConcurrentExecution();
+
+ context.RegisterSyntaxNodeAction(AnalyzeCastExpression, SyntaxKind.CastExpression);
+ }
+
+ private static void AnalyzeCastExpression(SyntaxNodeAnalysisContext context)
+ {
+ var castExpression = (CastExpressionSyntax)context.Node;
+
+ // Check if the cast is on a Find() invocation
+ if (castExpression.Expression is not InvocationExpressionSyntax invocation)
+ {
+ return;
+ }
+
+ // Check if it's a member access expression (e.g., cut.Find(...))
+ if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
+ {
+ return;
+ }
+
+ // Check if the method name is "Find"
+ if (memberAccess.Name.Identifier.ValueText != "Find")
+ {
+ return;
+ }
+
+ // Get the method symbol to verify it's the bUnit Find method
+ var methodSymbol = context.SemanticModel.GetSymbolInfo(memberAccess, context.CancellationToken).Symbol as IMethodSymbol;
+ if (methodSymbol is null)
+ {
+ return;
+ }
+
+ // Check if the method is from IRenderedFragment or related types
+ var containingType = methodSymbol.ContainingType;
+ if (containingType is null || !IsRenderedFragmentType(containingType))
+ {
+ return;
+ }
+
+ // Get the selector argument if present
+ var selector = invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString() ?? "selector";
+
+ // Get the cast type
+ var castType = castExpression.Type.ToString();
+
+ var diagnostic = Diagnostic.Create(
+ DiagnosticDescriptors.PreferGenericFind,
+ castExpression.GetLocation(),
+ castType,
+ selector);
+
+ context.ReportDiagnostic(diagnostic);
+ }
+
+ private static bool IsRenderedFragmentType(INamedTypeSymbol type)
+ {
+ // Check if the type or any of its interfaces is IRenderedFragment
+ var typeName = type.Name;
+ if (typeName is "IRenderedFragment" or "IRenderedComponent" or "RenderedFragment" or "RenderedComponent")
+ {
+ return true;
+ }
+
+ foreach (var @interface in type.AllInterfaces)
+ {
+ if (@interface.Name is "IRenderedFragment" or "IRenderedComponent")
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/bunit.analyzers/DiagnosticDescriptors.cs b/src/bunit.analyzers/DiagnosticDescriptors.cs
new file mode 100644
index 000000000..5207f2d55
--- /dev/null
+++ b/src/bunit.analyzers/DiagnosticDescriptors.cs
@@ -0,0 +1,38 @@
+using Microsoft.CodeAnalysis;
+
+namespace Bunit.Analyzers;
+
+///
+/// Diagnostic descriptors for bUnit analyzers.
+/// Public to allow testing frameworks to reference diagnostic IDs and descriptors.
+///
+public static class DiagnosticDescriptors
+{
+ private const string Category = "Usage";
+
+ ///
+ /// BUNIT0001: Razor test files should inherit from BunitContext when using local variables in component parameters.
+ ///
+ public static readonly DiagnosticDescriptor MissingInheritsInRazorFile = new(
+ id: "BUNIT0001",
+ title: "Razor test files should inherit from BunitContext",
+ messageFormat: "Razor test file should inherit from BunitContext using @inherits BunitContext to avoid render handle errors",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "When writing tests in Razor files that use variables or event callbacks from the test code, the file must inherit from BunitContext. Otherwise, you may encounter the error: The render handle is not yet assigned.",
+ helpLinkUri: "https://bunit.dev/docs/analyzers/bunit0001.html");
+
+ ///
+ /// BUNIT0002: Prefer Find{T} over casting.
+ ///
+ public static readonly DiagnosticDescriptor PreferGenericFind = new(
+ id: "BUNIT0002",
+ title: "Prefer Find over casting",
+ messageFormat: "Use Find<{0}>(\"{1}\") instead of casting",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: true,
+ description: "When finding elements with a specific type, use Find(selector) instead of casting the result of Find(selector).",
+ helpLinkUri: "https://bunit.dev/docs/analyzers/bunit0002.html");
+}
diff --git a/src/bunit.analyzers/README.md b/src/bunit.analyzers/README.md
new file mode 100644
index 000000000..5416f40db
--- /dev/null
+++ b/src/bunit.analyzers/README.md
@@ -0,0 +1,69 @@
+# bUnit Analyzers
+
+This package contains Roslyn analyzers for bUnit that help identify common mistakes and anti-patterns when writing bUnit tests.
+
+## Analyzers
+
+### BUNIT0002: Prefer Find over casting
+
+**Status**: ✅ Implemented
+
+When finding elements with a specific type, use `Find(selector)` instead of casting the result of `Find(selector)`.
+
+**Bad:**
+```csharp
+IHtmlAnchorElement elem = (IHtmlAnchorElement)cut.Find("a");
+```
+
+**Good:**
+```csharp
+var elem = cut.Find("a");
+```
+
+### BUNIT0001: Razor test files should inherit from BunitContext
+
+**Status**: 🚧 Planned
+
+When writing tests in `.razor` files that use variables or event callbacks from the test code, the file must inherit from `BunitContext` using `@inherits BunitContext`. Otherwise, you may encounter the error "The render handle is not yet assigned."
+
+**Bad:**
+```razor
+@code
+{
+ [Fact]
+ public void Test()
+ {
+ using var ctx = new BunitContext();
+ Action onClickHandler = _ => { Assert.True(true); };
+ var cut = ctx.Render(@);
+ }
+}
+```
+
+**Good:**
+```razor
+@inherits BunitContext
+@code
+{
+ [Fact]
+ public void Test()
+ {
+ Action onClickHandler = _ => { Assert.True(true); };
+ var cut = Render(@);
+ }
+}
+```
+
+## Installation
+
+Install the package via NuGet:
+
+```bash
+dotnet add package bunit.analyzers
+```
+
+The analyzers will automatically run during compilation and provide warnings in your IDE.
+
+## Contributing
+
+We welcome contributions! If you have ideas for additional analyzers that could help bUnit users, please open an issue or submit a pull request.
diff --git a/src/bunit.analyzers/bunit.analyzers.csproj b/src/bunit.analyzers/bunit.analyzers.csproj
new file mode 100644
index 000000000..273b89d27
--- /dev/null
+++ b/src/bunit.analyzers/bunit.analyzers.csproj
@@ -0,0 +1,61 @@
+
+
+
+ netstandard2.0
+ 12.0
+ true
+ Bunit
+ disable
+
+
+
+ true
+
+ true
+
+ snupkg
+
+ true
+
+ embedded
+ true
+
+ false
+ false
+
+
+
+ bunit.analyzers
+ bUnit.analyzers
+
+ bUnit.analyzers is an extension to bUnit that provides Roslyn analyzers to help identify common mistakes and anti-patterns when writing bUnit tests.
+
+
+
+
+
+ true
+ \
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props
index cf491ca78..822c247fe 100644
--- a/tests/Directory.Build.props
+++ b/tests/Directory.Build.props
@@ -23,7 +23,7 @@
NU1903
-
+
@@ -35,7 +35,7 @@
-
+
diff --git a/tests/bunit.analyzers.tests/PreferGenericFindAnalyzerTests.cs b/tests/bunit.analyzers.tests/PreferGenericFindAnalyzerTests.cs
new file mode 100644
index 000000000..828f5d8d0
--- /dev/null
+++ b/tests/bunit.analyzers.tests/PreferGenericFindAnalyzerTests.cs
@@ -0,0 +1,229 @@
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.CSharp.Testing;
+using Microsoft.CodeAnalysis.Testing;
+using Microsoft.CodeAnalysis.Testing.Verifiers;
+using Xunit;
+
+namespace Bunit.Analyzers.Tests;
+
+public class PreferGenericFindAnalyzerTests
+{
+ [Fact]
+ public async Task NoDiagnostic_WhenUsingGenericFind()
+ {
+ const string code = @"
+namespace TestNamespace;
+
+public class TestClass
+{
+ public void TestMethod()
+ {
+ var cut = new TestHelper();
+ var elem = cut.Find(""a"");
+ }
+}
+
+public class TestHelper
+{
+ public T Find(string selector) => default(T);
+}
+";
+
+ await VerifyAnalyzerAsync(code);
+ }
+
+ [Fact]
+ public async Task NoDiagnostic_WhenCastingNonFindMethod()
+ {
+ const string code = @"
+namespace TestNamespace;
+
+public class TestClass
+{
+ public void TestMethod()
+ {
+ var obj = new TestHelper();
+ var elem = (string)obj.GetSomething();
+ }
+}
+
+public class TestHelper
+{
+ public object GetSomething() => null;
+}
+";
+
+ await VerifyAnalyzerAsync(code);
+ }
+
+ [Fact]
+ public async Task NoDiagnostic_WhenFindIsNotFromRenderedComponent()
+ {
+ const string code = @"
+namespace TestNamespace;
+
+public class TestClass
+{
+ public void TestMethod()
+ {
+ var helper = new UnrelatedHelper();
+ var result = (string)helper.Find(""test"");
+ }
+}
+
+public class UnrelatedHelper
+{
+ public object Find(string selector) => null;
+}
+";
+
+ await VerifyAnalyzerAsync(code);
+ }
+
+ [Fact]
+ public async Task Diagnostic_WhenCastingFindResultFromIRenderedComponent()
+ {
+ const string code = @"
+namespace TestNamespace;
+
+public interface IMyElement { }
+
+public class TestClass
+{
+ public void TestMethod()
+ {
+ var cut = new MockRenderedComponent();
+ IMyElement elem = {|#0:(IMyElement)cut.Find(""a"")|};
+ }
+}
+
+public interface IRenderedComponent
+{
+ object Find(string selector);
+}
+
+public class MockRenderedComponent : IRenderedComponent
+{
+ public object Find(string selector) => null;
+}
+";
+
+ var expected = new DiagnosticResult(DiagnosticDescriptors.PreferGenericFind)
+ .WithLocation(0)
+ .WithArguments("IMyElement", "\"a\"");
+
+ await VerifyAnalyzerAsync(code, expected);
+ }
+
+ [Fact]
+ public async Task Diagnostic_WhenCastingFindResultFromRenderedComponent()
+ {
+ const string code = @"
+namespace TestNamespace;
+
+public interface IMyElement { }
+
+public class TestClass
+{
+ public void TestMethod()
+ {
+ var cut = new MockRenderedComponent();
+ var elem = {|#0:(IMyElement)cut.Find(""div"")|};
+ }
+}
+
+public interface IRenderedComponent
+{
+ object Find(string selector);
+}
+
+public class MockRenderedComponent : IRenderedComponent
+{
+ public object Find(string selector) => null;
+}
+";
+
+ var expected = new DiagnosticResult(DiagnosticDescriptors.PreferGenericFind)
+ .WithLocation(0)
+ .WithArguments("IMyElement", "\"div\"");
+
+ await VerifyAnalyzerAsync(code, expected);
+ }
+
+ [Fact]
+ public async Task Diagnostic_WhenCastingFindResultFromRenderedComponentType()
+ {
+ const string code = @"
+namespace TestNamespace;
+
+public interface IMyElement { }
+
+public class TestClass
+{
+ public void TestMethod()
+ {
+ var cut = new RenderedComponent();
+ var button = {|#0:(IMyElement)cut.Find(""button"")|};
+ }
+}
+
+public class RenderedComponent
+{
+ public object Find(string selector) => null;
+}
+";
+
+ var expected = new DiagnosticResult(DiagnosticDescriptors.PreferGenericFind)
+ .WithLocation(0)
+ .WithArguments("IMyElement", "\"button\"");
+
+ await VerifyAnalyzerAsync(code, expected);
+ }
+
+ [Fact]
+ public async Task Diagnostic_WithComplexSelector()
+ {
+ const string code = @"
+namespace TestNamespace;
+
+public interface IMyElement { }
+
+public class TestClass
+{
+ public void TestMethod()
+ {
+ var cut = new MockRenderedComponent();
+ var link = {|#0:(IMyElement)cut.Find(""a.nav-link[href='/home']"")|};
+ }
+}
+
+public interface IRenderedComponent
+{
+ object Find(string selector);
+}
+
+public class MockRenderedComponent : IRenderedComponent
+{
+ public object Find(string selector) => null;
+}
+";
+
+ var expected = new DiagnosticResult(DiagnosticDescriptors.PreferGenericFind)
+ .WithLocation(0)
+ .WithArguments("IMyElement", "\"a.nav-link[href='/home']\"");
+
+ await VerifyAnalyzerAsync(code, expected);
+ }
+
+ private static Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected)
+ {
+ var test = new CSharpAnalyzerTest
+ {
+ TestCode = source,
+ ReferenceAssemblies = ReferenceAssemblies.Net.Net80
+ };
+
+ test.ExpectedDiagnostics.AddRange(expected);
+ return test.RunAsync();
+ }
+}
diff --git a/tests/bunit.analyzers.tests/bunit.analyzers.tests.csproj b/tests/bunit.analyzers.tests/bunit.analyzers.tests.csproj
new file mode 100644
index 000000000..6db6f19b3
--- /dev/null
+++ b/tests/bunit.analyzers.tests/bunit.analyzers.tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net8.0;net9.0;net10.0
+ Bunit
+ Bunit.Analyzers.Tests
+ disable
+ true
+ false
+ true
+ false
+ $(NoWarn);NU1701
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+