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

## [Unreleased]

### Added

- `Render(RenderFragment)` is preferred via the `OverloadResolutionAttribute`. Reported by [@ScarletKuro](https://github.com/ScarletKuro) in #1800. Fixed by [@linkdotnet](https://github.com/linkdotnet).
- `FindByTestId` to `bunit.web.query` to gather elements by a given test id. By [@jimSampica](https://github.com/jimSampica)

## [2.4.2] - 2025-12-21

### Fixed
Expand Down
23 changes: 23 additions & 0 deletions src/bunit.web.query/TestIds/TestIdNotFoundException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Bunit.TestIds;

/// <summary>
/// Represents a failure to find an element in the searched target
/// using the specified test id.
/// </summary>
public sealed class TestIdNotFoundException : Exception
{
/// <summary>
/// Gets the test id used to search with.
/// </summary>
public string TestId { get; }

/// <summary>
/// Initializes a new instance of the <see cref="TestIdNotFoundException"/> class.
/// </summary>
/// <param name="testId">The test id that was searched for.</param>
public TestIdNotFoundException(string testId)
: base($"Unable to find an element with the Test ID '{testId}'.")
{
TestId = testId;
}
}
19 changes: 19 additions & 0 deletions src/bunit.web.query/TestIds/TestIdOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Bunit.TestIds;

/// <summary>
/// Allows overrides of behavior for FindByTestId method
/// </summary>
public record class ByTestIdOptions
{
internal static readonly ByTestIdOptions Default = new();

/// <summary>
/// The StringComparison used for comparing the desired Test ID to the resulting HTML. Defaults to Ordinal (case sensitive).
/// </summary>
public StringComparison ComparisonType { get; set; } = StringComparison.Ordinal;

/// <summary>
/// The name of the attribute used for finding Test IDs. Defaults to "data-testid".
/// </summary>
public string TestIdAttribute { get; set; } = "data-testid";
}
42 changes: 42 additions & 0 deletions src/bunit.web.query/TestIds/TestIdQueryExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using AngleSharp.Dom;
using Bunit.TestIds;

namespace Bunit;

/// <summary>
/// Extension methods for querying <see cref="IRenderedComponent{TComponent}" /> by Test ID
/// </summary>
public static class TestIdQueryExtensions
{
/// <summary>
/// Returns the first element with the specified Test ID.
/// </summary>
/// <param name="renderedComponent">The rendered fragment to search.</param>
/// <param name="testId">The Test ID to search for (e.g. "myTestId" in &lt;span data-testid="myTestId"&gt;).</param>
/// <param name="configureOptions">Method used to override the default behavior of FindByTestId.</param>
/// <returns>The first element matching the specified role and options.</returns>
/// <exception cref="TestIdNotFoundException">Thrown when no element matching the provided testId is found.</exception>
public static IElement FindByTestId(this IRenderedComponent<IComponent> renderedComponent, string testId, Action<ByTestIdOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(renderedComponent);
ArgumentNullException.ThrowIfNull(testId);

var options = ByTestIdOptions.Default;
if (configureOptions is not null)
{
options = options with { };
configureOptions.Invoke(options);
}

var elems = renderedComponent.Nodes.TryQuerySelectorAll($"[{options.TestIdAttribute}]");

foreach (var elem in elems)
{
var attr = elem.GetAttribute(options.TestIdAttribute);
if (attr is not null && attr.Equals(testId, options.ComparisonType))
return elem;
}

throw new TestIdNotFoundException(testId);
}
}
8 changes: 7 additions & 1 deletion src/bunit/BunitContext.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
#if NET9_0_OR_GREATER
using System.Runtime.CompilerServices;
#endif
using Bunit.Extensions;
using Bunit.Rendering;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -158,9 +161,12 @@ public virtual IRenderedComponent<TComponent> Render<TComponent>(Action<Componen
/// <typeparam name="TComponent">The type of component to find in the render tree.</typeparam>
/// <param name="renderFragment">The render fragment to render.</param>
/// <returns>The <see cref="RenderedComponent{TComponent}"/>.</returns>
#if NET9_0_OR_GREATER
[OverloadResolutionPriority(1)]
#endif
public virtual IRenderedComponent<TComponent> Render<TComponent>(RenderFragment renderFragment)
where TComponent : IComponent
=> this.RenderInsideRenderTree<TComponent>(renderFragment);
=> RenderInsideRenderTree<TComponent>(renderFragment);

/// <summary>
/// Renders the <paramref name="renderFragment"/> and returns it as a <see cref="IRenderedComponent{TComponent}"/>.
Expand Down
102 changes: 102 additions & 0 deletions tests/bunit.web.query.tests/TestIds/TestIdQueryExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
namespace Bunit.TestIds;

public class TestIdQueryExtensionsTests : BunitContext
{
[Fact(DisplayName = "Should find span element with matching testid value")]
public void Test001()
{
var cut = Render<Wrapper>(ps => ps.AddChildContent($"""<span data-testid="myTestId"><span>"""));

var elem = cut.FindByTestId("myTestId");

elem.ShouldNotBeNull();
elem.NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase);
elem.GetAttribute("data-testid").ShouldBe("myTestId");
}

[Fact(DisplayName = "Should throw exception when testid does not exist in the DOM")]
public void Test002()
{
var cut = Render<Wrapper>(ps => ps.AddChildContent("""<span data-testid="testId"><span>"""));

Should.Throw<TestIdNotFoundException>(() => cut.FindByTestId("myTestId")).TestId.ShouldBe("myTestId");
}

[Fact(DisplayName = "Should throw exception when testid casing is different from DOM")]
public void Test003()
{
var cut = Render<Wrapper>(ps => ps.AddChildContent("""<span data-testid="testId"><span>"""));

Should.Throw<TestIdNotFoundException>(() => cut.FindByTestId("MYTESTID")).TestId.ShouldBe("MYTESTID");
}

[Fact(DisplayName = "Should find first div element with matching testid value")]
public void Test004()
{
var cut = Render<Wrapper>(ps => ps.AddChildContent($"""
<div data-testid="myTestId"></div>
<span data-testid="myTestId"><span>
"""));

var elem = cut.FindByTestId("myTestId");

elem.ShouldNotBeNull();
elem.NodeName.ShouldBe("DIV", StringCompareShould.IgnoreCase);
elem.GetAttribute("data-testid").ShouldBe("myTestId");
}

[Fact(DisplayName = "Should find first non-child div element with matching testid value")]
public void Test005()
{
var cut = Render<Wrapper>(ps => ps.AddChildContent($"""
<div data-testid="myTestId">
<span data-testid="myTestId"><span>
</div>
"""));

var elem = cut.FindByTestId("myTestId");

elem.ShouldNotBeNull();
elem.NodeName.ShouldBe("DIV", StringCompareShould.IgnoreCase);
elem.GetAttribute("data-testid").ShouldBe("myTestId");
}

[Fact(DisplayName = "Should find span element with matching testid attribute name and value")]
public void Test006()
{
var cut = Render<Wrapper>(ps => ps.AddChildContent($"""<span data-testidattr="myTestId"><span>"""));

var elem = cut.FindByTestId("myTestId", opts => opts.TestIdAttribute = "data-testidattr");

elem.ShouldNotBeNull();
elem.NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase);
elem.GetAttribute("data-testidattr").ShouldBe("myTestId");
}

[Fact(DisplayName = "Should find span element with equivalent case-insensitive testid value")]
public void Test007()
{
var cut = Render<Wrapper>(ps => ps.AddChildContent("""<span data-testid="myTestId"><span>"""));

var elem = cut.FindByTestId("MYTESTID", opts => opts.ComparisonType = StringComparison.OrdinalIgnoreCase);

elem.ShouldNotBeNull();
elem.NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase);
elem.GetAttribute("data-testid").ShouldBe("myTestId");
}

[Fact(DisplayName = "Should find span element with equivalent case-sensitive testid value")]
public void Test008()
{
var cut = Render<Wrapper>(ps => ps.AddChildContent("""
<span data-testid="myTestId"><span>
<span data-testid="MYTESTID"><span>
"""));

var elem = cut.FindByTestId("MYTESTID");

elem.ShouldNotBeNull();
elem.NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase);
elem.GetAttribute("data-testid").ShouldBe("MYTESTID");
}
}
2 changes: 1 addition & 1 deletion version.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "2.4",
"version": "2.5",
"assemblyVersion": {
"precision": "revision"
},
Expand Down
Loading