diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d3a18812..69b43ac27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/bunit.web.query/TestIds/TestIdNotFoundException.cs b/src/bunit.web.query/TestIds/TestIdNotFoundException.cs new file mode 100644 index 000000000..258b483d1 --- /dev/null +++ b/src/bunit.web.query/TestIds/TestIdNotFoundException.cs @@ -0,0 +1,23 @@ +namespace Bunit.TestIds; + +/// +/// Represents a failure to find an element in the searched target +/// using the specified test id. +/// +public sealed class TestIdNotFoundException : Exception +{ + /// + /// Gets the test id used to search with. + /// + public string TestId { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The test id that was searched for. + public TestIdNotFoundException(string testId) + : base($"Unable to find an element with the Test ID '{testId}'.") + { + TestId = testId; + } +} diff --git a/src/bunit.web.query/TestIds/TestIdOptions.cs b/src/bunit.web.query/TestIds/TestIdOptions.cs new file mode 100644 index 000000000..8d4d79a78 --- /dev/null +++ b/src/bunit.web.query/TestIds/TestIdOptions.cs @@ -0,0 +1,19 @@ +namespace Bunit.TestIds; + +/// +/// Allows overrides of behavior for FindByTestId method +/// +public record class ByTestIdOptions +{ + internal static readonly ByTestIdOptions Default = new(); + + /// + /// The StringComparison used for comparing the desired Test ID to the resulting HTML. Defaults to Ordinal (case sensitive). + /// + public StringComparison ComparisonType { get; set; } = StringComparison.Ordinal; + + /// + /// The name of the attribute used for finding Test IDs. Defaults to "data-testid". + /// + public string TestIdAttribute { get; set; } = "data-testid"; +} diff --git a/src/bunit.web.query/TestIds/TestIdQueryExtensions.cs b/src/bunit.web.query/TestIds/TestIdQueryExtensions.cs new file mode 100644 index 000000000..c37623d2b --- /dev/null +++ b/src/bunit.web.query/TestIds/TestIdQueryExtensions.cs @@ -0,0 +1,42 @@ +using AngleSharp.Dom; +using Bunit.TestIds; + +namespace Bunit; + +/// +/// Extension methods for querying by Test ID +/// +public static class TestIdQueryExtensions +{ + /// + /// Returns the first element with the specified Test ID. + /// + /// The rendered fragment to search. + /// The Test ID to search for (e.g. "myTestId" in <span data-testid="myTestId">). + /// Method used to override the default behavior of FindByTestId. + /// The first element matching the specified role and options. + /// Thrown when no element matching the provided testId is found. + public static IElement FindByTestId(this IRenderedComponent renderedComponent, string testId, Action? 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); + } +} diff --git a/src/bunit/BunitContext.cs b/src/bunit/BunitContext.cs index 948d44b15..cb3aea4d9 100644 --- a/src/bunit/BunitContext.cs +++ b/src/bunit/BunitContext.cs @@ -1,3 +1,6 @@ +#if NET9_0_OR_GREATER +using System.Runtime.CompilerServices; +#endif using Bunit.Extensions; using Bunit.Rendering; using Microsoft.Extensions.Logging; @@ -158,9 +161,12 @@ public virtual IRenderedComponent Render(ActionThe type of component to find in the render tree. /// The render fragment to render. /// The . +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif public virtual IRenderedComponent Render(RenderFragment renderFragment) where TComponent : IComponent - => this.RenderInsideRenderTree(renderFragment); + => RenderInsideRenderTree(renderFragment); /// /// Renders the and returns it as a . diff --git a/tests/bunit.web.query.tests/TestIds/TestIdQueryExtensionsTests.cs b/tests/bunit.web.query.tests/TestIds/TestIdQueryExtensionsTests.cs new file mode 100644 index 000000000..c14af52ad --- /dev/null +++ b/tests/bunit.web.query.tests/TestIds/TestIdQueryExtensionsTests.cs @@ -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(ps => ps.AddChildContent($"""""")); + + 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(ps => ps.AddChildContent("""""")); + + Should.Throw(() => cut.FindByTestId("myTestId")).TestId.ShouldBe("myTestId"); + } + + [Fact(DisplayName = "Should throw exception when testid casing is different from DOM")] + public void Test003() + { + var cut = Render(ps => ps.AddChildContent("""""")); + + Should.Throw(() => cut.FindByTestId("MYTESTID")).TestId.ShouldBe("MYTESTID"); + } + + [Fact(DisplayName = "Should find first div element with matching testid value")] + public void Test004() + { + var cut = Render(ps => ps.AddChildContent($""" +
+ + """)); + + 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(ps => ps.AddChildContent($""" +
+ +
+ """)); + + 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(ps => ps.AddChildContent($"""""")); + + 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(ps => ps.AddChildContent("""""")); + + 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(ps => ps.AddChildContent(""" + + + """)); + + var elem = cut.FindByTestId("MYTESTID"); + + elem.ShouldNotBeNull(); + elem.NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase); + elem.GetAttribute("data-testid").ShouldBe("MYTESTID"); + } +} diff --git a/version.json b/version.json index 1fe82ff13..2543d4d9d 100644 --- a/version.json +++ b/version.json @@ -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" },