Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>

<ItemGroup Label="Implicit usings" Condition="$(MSBuildProjectName) != 'bunit.template' AND $(MSBuildProjectName) != 'bunit.generators'">
<ItemGroup Label="Implicit usings" Condition="$(MSBuildProjectName) != 'bunit.template' AND $(MSBuildProjectName) != 'bunit.generators' AND $(MSBuildProjectName) != 'bunit.analyzers' AND $(MSBuildProjectName) != 'bunit.analyzers.tests'">
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is bunit.analyzers.tests needed here? can probably be removed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bunit.analyzers.tests must remain in the exclusion list. Removing it causes the root Directory.Build.props to add AspNetCore implicit usings that the analyzer test project doesn't have as dependencies, resulting in build errors.

<Using Include="Microsoft.AspNetCore.Components" />
<Using Include="Microsoft.AspNetCore.Components.RenderTree" />
<Using Include="Microsoft.AspNetCore.Components.Rendering" />
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -102,5 +102,6 @@
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.14.0"/>
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0"/>
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing" Version="1.1.2" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit" Version="1.1.2" />
</ItemGroup>
</Project>
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. | [![Nuget](https://img.shields.io/nuget/dt/bunit?logo=nuget&style=flat-square)](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. | [![Nuget](https://img.shields.io/nuget/dt/bunit.analyzers?logo=nuget&style=flat-square)](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. | [![Nuget](https://img.shields.io/nuget/dt/bunit.template?logo=nuget&style=flat-square)](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. | [![Nuget](https://img.shields.io/nuget/dt/bunit.generators?logo=nuget&style=flat-square)](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. | [![Nuget](https://img.shields.io/nuget/dt/bunit.web.query?logo=nuget&style=flat-square)](https://www.nuget.org/packages/bunit.web.query/) |
Expand Down
2 changes: 2 additions & 0 deletions bunit.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<Folder Name="/src/">
<File Path="src/.editorconfig" />
<File Path="src/Directory.Build.props" />
<Project Path="src/bunit.analyzers/bunit.analyzers.csproj" />
<Project Path="src/bunit.generators.internal/bunit.generators.internal.csproj" />
<Project Path="src/bunit.generators/bunit.generators.csproj" />
<Project Path="src/bunit.template/bunit.template.csproj">
Expand All @@ -35,6 +36,7 @@
<File Path="tests/Directory.Build.props" />
<File Path="tests/run-tests.ps1" />
<File Path="tests/xunit.runner.json" />
<Project Path="tests/bunit.analyzers.tests/bunit.analyzers.tests.csproj" />
<Project Path="tests/bunit.generators.tests/bunit.generators.tests.csproj" />
<Project Path="tests/bunit.testassets/bunit.testassets.csproj" />
<Project Path="tests/bunit.tests/bunit.tests.csproj" />
Expand Down
132 changes: 132 additions & 0 deletions docs/site/docs/extensions/bunit-analyzers.md
Original file line number Diff line number Diff line change
@@ -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&lt;T&gt; 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<T>(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<MyComponent>();
IHtmlAnchorElement link = (IHtmlAnchorElement)cut.Find("a");
```

**Correct**:
```csharp
using AngleSharp.Dom;

var cut = Render<MyComponent>();
var link = cut.Find<IHtmlAnchorElement>("a");
```

#### When to Use

`Find<T>()` 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<MouseEventArgs> onClickHandler = _ => { Assert.True(true); };

var cut = ctx.Render(@<MyComponent OnClick="onClickHandler" />);
cut.Find("button").Click();
}
}
```

**Correct**:
```razor
@inherits BunitContext
@code
{
[Fact]
public async Task Test()
{
var wasInvoked = false;

Action<MouseEventArgs> onClick = _ => { wasInvoked = true; };

var cut = Render(@<MyComponent OnClick="onClick" />);
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.
1 change: 1 addition & 0 deletions docs/site/docs/extensions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 15 additions & 0 deletions src/bunit.analyzers/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
103 changes: 103 additions & 0 deletions src/bunit.analyzers/Analyzers/PreferGenericFindAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Analyzer that detects cast expressions from Find() and suggests using Find{T}() instead.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class PreferGenericFindAnalyzer : DiagnosticAnalyzer
{
/// <inheritdoc/>
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptors.PreferGenericFind);

/// <inheritdoc/>
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;
}
}
38 changes: 38 additions & 0 deletions src/bunit.analyzers/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Microsoft.CodeAnalysis;

namespace Bunit.Analyzers;

/// <summary>
/// Diagnostic descriptors for bUnit analyzers.
/// Public to allow testing frameworks to reference diagnostic IDs and descriptors.
/// </summary>
public static class DiagnosticDescriptors
{
private const string Category = "Usage";

/// <summary>
/// BUNIT0001: Razor test files should inherit from BunitContext when using local variables in component parameters.
/// </summary>
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");

/// <summary>
/// BUNIT0002: Prefer Find{T} over casting.
/// </summary>
public static readonly DiagnosticDescriptor PreferGenericFind = new(
id: "BUNIT0002",
title: "Prefer Find<T> 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<T>(selector) instead of casting the result of Find(selector).",
helpLinkUri: "https://bunit.dev/docs/analyzers/bunit0002.html");
}
Loading