Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ All notable changes to **bUnit** will be documented in this file. The project ad

## [Unreleased]

### Added

- `FindByRole` and `FindAllByRole` to `bunit.web.query` to find elements by their WAI-ARIA role, mirroring Playwright's `GetByRole` API. Supports implicit roles from semantic HTML (e.g. button, anchor with href, heading elements), explicit `role` attributes, and filtering by accessible name, level, checked, pressed, selected, expanded, and disabled states.

## [2.6.2] - 2026-02-27

### Added
Expand Down
5 changes: 3 additions & 2 deletions docs/site/docs/verification/verify-markup.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Generally, the strategy for verifying markup produced by components depends on w

With a **reusable component library**, the markup produced may be considered part of the externally observable behavior of the component, and that should thus be verified, since users of the component may depend on the markup having a specific structure. Consider using `MarkupMatches` and semantic comparison described below to get the best protection against regressions and good maintainability.

When **building components for a Blazor app**, the externally observable behavior of components are how they visibly look and behave from an end-users point of view, e.g. what the user sees and interact with in a browser. In this scenario, consider use `FindByLabelText` and related methods described below to inspect and assert against individual elements look and feel, for a good balance between protection against regressions and maintainability. Learn more about this testing approach at https://testing-library.com.
When **building components for a Blazor app**, the externally observable behavior of components are how they visibly look and behave from an end-users point of view, e.g. what the user sees and interact with in a browser. In this scenario, consider using `FindByRole`, `FindByLabelText`, and related methods described below to inspect and assert against individual elements look and feel, for a good balance between protection against regressions and maintainability. Learn more about this testing approach at https://testing-library.com.

This page covers the following **verification approaches:**

Expand All @@ -35,7 +35,8 @@ The rendered markup from a component is available as a DOM node through the <xre

bUnit supports multiple different ways of searching and querying the rendered HTML elements:

- `FindByLabelText(string labelText)` that takes a text string used to label an input element and returns an `IElement` as output, or throws an exception if none are found (this is included in the experimental library [bunit.web.query](https://www.nuget.org/packages/bunit.web.query)). Use this method when possible compared to the generic `Find` and `FindAll` methods.
- `FindByRole(AriaRole role)` that finds elements by their WAI-ARIA role, either implicit (e.g. `<button>` has role "button") or explicit (via the `role` attribute). Supports filtering by accessible name, heading level, and states like checked, pressed, selected, expanded, and disabled. Throws an exception if none are found. `FindAllByRole` returns all matches. This is the recommended query method for accessing non-form fields, following the [testing-library.com](https://testing-library.com/docs/queries/byrole/) approach. (Included in the library [bunit.web.query](https://www.nuget.org/packages/bunit.web.query).)
- `FindByLabelText(string labelText)` that takes a text string to retrieve an element by its label and returns an `IElement` as output, or throws an exception if none are found which is an accessibility violation (this is included in the library [bunit.web.query](https://www.nuget.org/packages/bunit.web.query)). This is recommended for accessing form elements.
- [`Find(string cssSelector)`](xref:Bunit.RenderedComponentExtensions.Find``1(Bunit.IRenderedComponent{``0},System.String)) takes a "CSS selector" as input and returns an `IElement` as output, or throws an exception if none are found.
- [`FindAll(string cssSelector)`](xref:Bunit.RenderedComponentExtensions.FindAll``1(Bunit.IRenderedComponent{``0},System.String)) takes a "CSS selector" as input and returns a list of `IElement` elements.

Expand Down
31 changes: 31 additions & 0 deletions src/bunit.web.query/ByRoleElementFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using AngleSharp.Dom;
using Bunit.Web.AngleSharp;

namespace Bunit;

internal sealed class ByRoleElementFactory : IElementWrapperFactory
{
private readonly IRenderedComponent<IComponent> testTarget;
private readonly AriaRole role;
private readonly ByRoleOptions options;

public Action? OnElementReplaced { get; set; }

public ByRoleElementFactory(IRenderedComponent<IComponent> testTarget, AriaRole role, ByRoleOptions options)
{
this.testTarget = testTarget;
this.role = role;
this.options = options;
testTarget.OnMarkupUpdated += FragmentsMarkupUpdated;
}

private void FragmentsMarkupUpdated(object? sender, EventArgs args)
=> OnElementReplaced?.Invoke();

public TElement GetElement<TElement>() where TElement : class, IElement
{
var element = testTarget.FindByRoleInternal(role, options) as TElement;

return element ?? throw new ElementRemovedFromDomException(role.ToRoleString());
}
}
130 changes: 130 additions & 0 deletions src/bunit.web.query/Roles/AccessibleNameCalculator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using AngleSharp.Dom;

namespace Bunit.Roles;

internal static class AccessibleNameCalculator
{
internal static string? GetAccessibleName(IElement element, INodeList rootNodes)
{
// 1. aria-labelledby — resolve each referenced ID, join with spaces
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This feels like we should consider our strategy pattern here.

var labelledBy = element.GetAttribute("aria-labelledby");
if (!string.IsNullOrEmpty(labelledBy))
{
var ids = labelledBy.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var parts = new List<string>();
foreach (var id in ids)
{
var referenced = rootNodes.TryQuerySelector($"#{id}");
if (referenced is not null)
{
var text = referenced.TextContent.Trim();
if (!string.IsNullOrEmpty(text))
parts.Add(text);
}
}

if (parts.Count > 0)
return string.Join(" ", parts);
}

// 2. aria-label attribute
var ariaLabel = element.GetAttribute("aria-label");
if (!string.IsNullOrEmpty(ariaLabel))
return ariaLabel.Trim();

// 3. For IMG, AREA, INPUT[type=image]: alt attribute
var isImageInput = element.NodeName == "INPUT" &&
string.Equals(element.GetAttribute("type"), "image", StringComparison.OrdinalIgnoreCase);
if (element.NodeName is "IMG" or "AREA" || isImageInput)
{
var alt = element.GetAttribute("alt");
if (!string.IsNullOrEmpty(alt))
return alt.Trim();
}

// 4. For form controls: associated <label>
if (element.NodeName is "INPUT" or "SELECT" or "TEXTAREA" or "METER" or "OUTPUT" or "PROGRESS")
{
var labelName = GetLabelName(element, rootNodes);
if (labelName is not null)
return labelName;
}

// 5. Text content (for buttons, links, headings, etc.)
var textContent = element.TextContent.Trim();
if (!string.IsNullOrEmpty(textContent))
return textContent;

// 6. title attribute (fallback)
var title = element.GetAttribute("title");
if (!string.IsNullOrEmpty(title))
return title.Trim();

// 7. placeholder attribute (fallback for inputs)
if (element.NodeName is "INPUT" or "TEXTAREA")
{
var placeholder = element.GetAttribute("placeholder");
if (!string.IsNullOrEmpty(placeholder))
return placeholder.Trim();
}

return null;
}

private static string? GetLabelName(IElement element, INodeList rootNodes)
{
// Check for label via "for" attribute matching element's id
var id = element.GetAttribute("id");
if (!string.IsNullOrEmpty(id))
{
var labels = rootNodes.TryQuerySelectorAll($"label[for='{id}']");
foreach (var label in labels)
{
var text = label.TextContent.Trim();
if (!string.IsNullOrEmpty(text))
return text;
}
}

// Check for wrapping <label>
var parent = element.ParentElement;
while (parent is not null)
{
if (parent.NodeName == "LABEL")
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We've got these magic strings in a few places... centralize?

{
var text = GetTextContentExcluding(parent, element).Trim();
if (!string.IsNullOrEmpty(text))
return text;
}

parent = parent.ParentElement;
}

return null;
}

private static string GetTextContentExcluding(IElement container, IElement excluded)
{
var parts = new List<string>();
CollectTextNodes(container, excluded, parts);
return string.Join("", parts);
}

private static void CollectTextNodes(INode node, IElement excluded, List<string> parts)
{
foreach (var child in node.ChildNodes)
{
if (child == excluded)
continue;

if (child.NodeType == NodeType.Text)
{
parts.Add(child.TextContent);
}
else if (child.NodeType == NodeType.Element)
{
CollectTextNodes(child, excluded, parts);
}
}
}
}
Loading