Skip to content

Commit c40735b

Browse files
scottsauberegillinkdotnet
authored
feat: Add FindByLabelText to find elements by the text of their labels (#1252)
* add label, aria-label, wrapped label * switch to strategy pattern * feat: support for all element types that can have a label * feat: support for all element types that can have a wrapped label * feat: support for all element types that can have an aria-label * feat: support for all element types that can have an aria-labelledby * style: remove comment * fix: use theorydata * fix: use method instead of list * failing test to prove the re-rendered element issue * move to element factory to prevent re-renders causing issues * fix: switch to array for strategies * fix: remove todos * fix: move to new ielementwrapperfactory * fix: use custom labelnotfoundexception * feat: support for different casing sensitivity * chore: add xml docs to indicate defaults of ByLabelTextOptions * fix: make classes add public * fix: remove project references * fix: move to source generator to public * chore: switch to use wrapper component for tests * chore: switch to use wrapper component for tests * chore: rename to labelquerycounter for re-rendering test * fix: remove warnings * refactor: remove string duplication in tests * fix: remove nullability warning * fix: add sealed to remove warnings * feat: add support for whitespace * add xml comments * fix: labelElement can be null if not found * chore: fix indention * chore: remove duplicated word * fix: make label options immutable * test: verify generate test output * chore: remove whitespace in test case name * refactor: simplify null check * fix: cover scenario where wrapped label has nested HTML * fix: rename test * test: add additional test for nested html with for attributes * docs: cover FindByLabelText and update verify markup section to discuss different markup verify approaches * docs: verify-markup.md Co-authored-by: Steven Giesel <[email protected]> * refactor: use configure options pattern instead of passing option object --------- Co-authored-by: Egil Hansen <[email protected]> Co-authored-by: Steven Giesel <[email protected]>
1 parent 3ad9984 commit c40735b

23 files changed

+734
-61
lines changed

docs/site/docs/verification/verify-markup.md

Lines changed: 58 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,63 @@ title: Verifying markup from a component
55

66
# Verifying markup from a component
77

8-
When a component is rendered in a test, the result is a <xref:Bunit.IRenderedFragment> or a <xref:Bunit.IRenderedComponent`1>. Through these, it is possible to access the rendered markup (HTML) of the component and, in the case of <xref:Bunit.IRenderedComponent`1>, the instance of the component.
8+
Generally, the strategy for verifying markup produced by components depends on whether you are creating reusable component library or a single-use Blazor app component.
99

10-
> [!NOTE]
11-
> An <xref:Bunit.IRenderedComponent`1> inherits from <xref:Bunit.IRenderedFragment>. This page will only cover features of the <xref:Bunit.IRenderedFragment> type. <xref:Bunit.IRenderedComponent`1> is covered on the <xref:verify-component-state> page.
10+
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.
11+
12+
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.
1213

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

15-
- Basic verification of raw markup
16-
- Semantic comparison of markup
1716
- Inspecting the individual DOM nodes in the DOM tree
17+
- Semantic comparison of markup
1818
- Finding expected differences in markup between renders
19+
- Verification of raw markup
1920

2021
The following sections will cover each of these.
2122

22-
## Basic verification of raw markup
23+
## Result of rendering components
2324

24-
To access the rendered markup of a component, just use the <xref:Bunit.IRenderedFragment.Markup> property on <xref:Bunit.IRenderedFragment>. This holds the *raw* HTML from the component as a `string`.
25+
When a component is rendered in a test, the result is a <xref:Bunit.IRenderedFragment> or a <xref:Bunit.IRenderedComponent`1>. Through these, it is possible to access the rendered markup (HTML) of the component and, in the case of <xref:Bunit.IRenderedComponent`1>, the instance of the component.
2526

26-
> [!WARNING]
27-
> Be aware that all indentions and whitespace in your components (`.razor` files) are included in the raw rendered markup, so it is often wise to normalize the markup string a little. For example, via the string `Trim()` method to make the tests more stable. Otherwise, a change to the formatting in your components might break the tests unnecessarily when it does not need to.
28-
>
29-
> To avoid these issues and others related to asserting against raw markup, use the semantic HTML comparer that comes with bUnit, described in the next section.
27+
> [!NOTE]
28+
> An <xref:Bunit.IRenderedComponent`1> inherits from <xref:Bunit.IRenderedFragment>. This page will only cover features of the <xref:Bunit.IRenderedFragment> type. <xref:Bunit.IRenderedComponent`1> is covered on the <xref:verify-component-state> page.
3029
31-
To get the markup as a string, do the following:
30+
## Inspecting DOM nodes
3231

33-
[!code-csharp[](../../../samples/tests/xunit/VerifyMarkupExamples.cs?start=16&end=19&highlight=3)]
32+
The rendered markup from a component is available as a DOM node through the <xref:Bunit.IRenderedFragment.Nodes> property on <xref:Bunit.IRenderedFragment>. The nodes and element types comes from [AngleSharp](https://anglesharp.github.io/) that follows the W3C DOM API specifications and gives you the same results as a state-of-the-art browser’s implementation of the DOM API in JavaScript. Besides the official DOM API, AngleSharp and bUnit add some useful extension methods on top. This makes working with DOM nodes convenient.
33+
34+
### Finding DOM elements
35+
36+
bUnit supports multiple different ways of searching and querying the rendered HTML elements:
37+
38+
- `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.
39+
- [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) takes a "CSS selector" as input and returns an `IElement` as output, or throws an exception if none are found.
40+
- [`FindAll(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.FindAll(Bunit.IRenderedFragment,System.String,System.Boolean)) takes a "CSS selector" as input and returns a list of `IElement` elements.
41+
42+
Let's see some examples of using the [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) and [`FindAll(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.FindAll(Bunit.IRenderedFragment,System.String,System.Boolean)) methods to query the `<FancyTable>` component listed below.
43+
44+
[!code-razor[FancyTable.razor](../../../samples/components/FancyTable.razor)]
45+
46+
To find the `<caption>` element and the first `<td>` elements in each row, do the following:
47+
48+
[!code-csharp[](../../../samples/tests/xunit/VerifyMarkupExamples.cs?start=54&end=57&highlight=3-4)]
49+
50+
Once you have one or more elements, you verify against them, such as by inspecting their properties through the DOM API. For example:
3451

35-
You can perform standard string assertions against the markup string, like checking whether it contains a value or is empty.
52+
[!code-csharp[](../../../samples/tests/xunit/VerifyMarkupExamples.cs?start=59&end=61)]
53+
54+
#### Auto-refreshing Find() queries
55+
56+
An element found with the [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) method will be updated if the component it came from is re-rendered.
57+
58+
However, that does not apply to elements that are found by traversing the DOM tree via the <xref:Bunit.IRenderedFragment.Nodes> property on <xref:Bunit.IRenderedFragment>, for example, as those nodes do not know when their root component is re-rendered. Consequently, they don’t know when they should be updated.
59+
60+
As a result of this, it is always recommended to use the [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) method when searching for a single element. Alternatively, always reissue the query whenever you need the element.
61+
62+
#### Auto-refreshable FindAll() queries
63+
64+
The [`FindAll(string cssSelector, bool enableAutoRefresh = false)`](xref:Bunit.RenderedFragmentExtensions.FindAll(Bunit.IRenderedFragment,System.String,System.Boolean)) method has an optional parameter, `enableAutoRefresh`, which when set to `true` will return a collection of `IElement`. This automatically refreshes itself when the component the elements came from is re-rendered.
3665

3766
## Semantic comparison of markup
3867

@@ -91,45 +120,6 @@ The semantic HTML comparer can be customized to make a test case even more stabl
91120

92121
Learn more about the customization options on the <xref:semantic-html-comparison> page.
93122

94-
## Inspecting DOM nodes
95-
96-
The rendered markup from a component is available as a DOM node through the <xref:Bunit.IRenderedFragment.Nodes> property on <xref:Bunit.IRenderedFragment>, as well as the `Find(string cssSelector)` and `FindAll(string cssSelector)` extension methods on <xref:Bunit.IRenderedFragment>.
97-
98-
The <xref:Bunit.IRenderedFragment.Nodes> property and the `FindAll()` method return an [AngleSharp](https://anglesharp.github.io/) `INodeList` type, and the `Find()` method returns an [AngleSharp](https://anglesharp.github.io/) `IElement` type.
99-
100-
The DOM API in AngleSharp follows the W3C DOM API specifications and gives you the same results as a state-of-the-art browser’s implementation of the DOM API in JavaScript. Besides the official DOM API, AngleSharp and bUnit add some useful extension methods on top. This makes working with DOM nodes convenient.
101-
102-
### Finding nodes with the Find() and FindAll() methods
103-
104-
Users of the famous JavaScript framework [jQuery](https://jquery.com/) will recognize these two methods:
105-
106-
- [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) takes a "CSS selector" as input and returns an `IElement` as output, or throws an exception if none are found.
107-
- [`FindAll(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.FindAll(Bunit.IRenderedFragment,System.String,System.Boolean)) takes a "CSS selector" as input and returns a list of `IElement` elements.
108-
109-
Let's see some examples of using the [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) and [`FindAll(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.FindAll(Bunit.IRenderedFragment,System.String,System.Boolean)) methods to query the `<FancyTable>` component listed below.
110-
111-
[!code-razor[FancyTable.razor](../../../samples/components/FancyTable.razor)]
112-
113-
To find the `<caption>` element and the first `<td>` elements in each row, do the following:
114-
115-
[!code-csharp[](../../../samples/tests/xunit/VerifyMarkupExamples.cs?start=54&end=57&highlight=3-4)]
116-
117-
Once you have one or more elements, you verify against them, such as by inspecting their properties through the DOM API. For example:
118-
119-
[!code-csharp[](../../../samples/tests/xunit/VerifyMarkupExamples.cs?start=59&end=61)]
120-
121-
#### Auto-refreshing Find() queries
122-
123-
An element found with the [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) method will be updated if the component it came from is re-rendered.
124-
125-
However, that does not apply to elements that are found by traversing the DOM tree via the <xref:Bunit.IRenderedFragment.Nodes> property on <xref:Bunit.IRenderedFragment>, for example, as those nodes do not know when their root component is re-rendered. Consequently, they don’t know when they should be updated.
126-
127-
As a result of this, it is always recommended to use the [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) method when searching for a single element. Alternatively, always reissue the query whenever you need the element.
128-
129-
#### Auto-refreshable FindAll() queries
130-
131-
The [`FindAll(string cssSelector, bool enableAutoRefresh = false)`](xref:Bunit.RenderedFragmentExtensions.FindAll(Bunit.IRenderedFragment,System.String,System.Boolean)) method has an optional parameter, `enableAutoRefresh`, which when set to `true` will return a collection of `IElement`. This automatically refreshes itself when the component the elements came from is re-rendered.
132-
133123
## Finding expected differences
134124

135125
It can sometimes be easier to verify that an expected change, and only that change, has occurred in the rendered markup than it can be to specify how all the rendered markup should look after re-rendering.
@@ -178,3 +168,18 @@ This is what happens in the test:
178168
8. Finally the last item in the list is found and clicked, and the <xref:Bunit.IRenderedFragment.GetChangesSinceSnapshot> method is used to find the changes, a single diff, which is verified as a removal of the second item.
179169

180170
As mentioned earlier, the `IDiff` assertion helpers are still experimental. Any feedback and suggestions for improvements should be directed to the [related issue](https://github.com/egil/bUnit/issues/84) on GitHub.
171+
172+
## Verification of raw markup
173+
174+
To access the rendered markup of a component, just use the <xref:Bunit.IRenderedFragment.Markup> property on <xref:Bunit.IRenderedFragment>. This holds the *raw* HTML from the component as a `string`.
175+
176+
> [!WARNING]
177+
> Be aware that all indentions and whitespace in your components (`.razor` files) are included in the raw rendered markup, so it is often wise to normalize the markup string a little. For example, via the string `Trim()` method to make the tests more stable. Otherwise, a change to the formatting in your components might break the tests unnecessarily when it does not need to.
178+
>
179+
> To avoid these issues and others related to asserting against raw markup, use the semantic HTML comparer that comes with bUnit, described in the next section.
180+
181+
To get the markup as a string, do the following:
182+
183+
[!code-csharp[](../../../samples/tests/xunit/VerifyMarkupExamples.cs?start=16&end=19&highlight=3)]
184+
185+
Standard string assertions can be performed against the markup string, such as checking whether it contains a value or is empty.

src/bunit.generators.internal/Web.AngleSharp/IElementWrapper.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ namespace Bunit.Web.AngleSharp;
88
/// Represents a wrapper around an <typeparamref name="TElement"/>.
99
/// </summary>
1010
[GeneratedCodeAttribute("Bunit.Web.AngleSharp", "1.0.0.0")]
11-
internal interface IElementWrapper<out TElement> where TElement : class, IElement
11+
public interface IElementWrapper<out TElement> where TElement : class, IElement
1212
{
1313
/// <summary>
1414
/// Gets the wrapped element.

src/bunit.generators.internal/Web.AngleSharp/IElementWrapperFactory.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace Bunit.Web.AngleSharp;
99
/// Represents an <see cref="IElement"/> factory, used by a <see cref="WrapperBase{TElement}"/>.
1010
/// </summary>
1111
[GeneratedCodeAttribute("Bunit.Web.AngleSharp", "1.0.0.0")]
12-
internal interface IElementWrapperFactory
12+
public interface IElementWrapperFactory
1313
{
1414
/// <summary>
1515
/// A method that returns the latest version of the element to wrap.

src/bunit.generators.internal/Web.AngleSharp/WrapperBase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace Bunit.Web.AngleSharp;
1111
/// </summary>
1212
[DebuggerNonUserCode]
1313
[GeneratedCodeAttribute("Bunit.Web.AngleSharp", "1.0.0.0")]
14-
internal abstract class WrapperBase<TElement> : IElementWrapper<TElement>
14+
public abstract class WrapperBase<TElement> : IElementWrapper<TElement>
1515
where TElement : class, IElement
1616
{
1717
private readonly IElementWrapperFactory elementFactory;

src/bunit.generators.internal/Web.AngleSharp/WrapperElementsGenerator.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,16 @@ private static void GenerateWrapperFactory(StringBuilder source, IEnumerable<INa
6565
{
6666
source.AppendLine("""namespace Bunit.Web.AngleSharp;""");
6767
source.AppendLine();
68+
source.AppendLine("/// <summary>");
69+
source.AppendLine("/// Provide helpers for wrapped HTML elements.");
70+
source.AppendLine("/// </summary>");
6871
source.AppendLine("[System.CodeDom.Compiler.GeneratedCodeAttribute(\"Bunit.Web.AngleSharp\", \"1.0.0.0\")]");
69-
source.AppendLine($"internal static class WrapperExtensions");
72+
source.AppendLine("public static class WrapperExtensions");
7073
source.AppendLine("{");
7174
source.AppendLine();
75+
source.AppendLine("/// <summary>");
76+
source.AppendLine("/// Provide wrapper to be used when elements re-render.");
77+
source.AppendLine("/// </summary>");
7278
source.AppendLine($"\tpublic static global::AngleSharp.Dom.IElement WrapUsing<TElementFactory>(this global::AngleSharp.Dom.IElement element, TElementFactory elementFactory) where TElementFactory : Bunit.Web.AngleSharp.IElementWrapperFactory => element switch");
7379
source.AppendLine("\t{");
7480

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using AngleSharp.Dom;
2+
using Bunit.Web.AngleSharp;
3+
4+
namespace Bunit;
5+
6+
internal sealed class ByLabelTextElementFactory : IElementWrapperFactory
7+
{
8+
private readonly IRenderedFragment testTarget;
9+
private readonly string labelText;
10+
private readonly ByLabelTextOptions options;
11+
12+
public Action? OnElementReplaced { get; set; }
13+
14+
public ByLabelTextElementFactory(IRenderedFragment testTarget, string labelText, ByLabelTextOptions options)
15+
{
16+
this.testTarget = testTarget;
17+
this.labelText = labelText;
18+
this.options = options;
19+
testTarget.OnMarkupUpdated += FragmentsMarkupUpdated;
20+
}
21+
22+
private void FragmentsMarkupUpdated(object? sender, EventArgs args)
23+
=> OnElementReplaced?.Invoke();
24+
25+
public TElement GetElement<TElement>() where TElement : class, IElement
26+
{
27+
var element = testTarget.FindByLabelTextInternal(labelText, options) as TElement;
28+
29+
return element ?? throw new ElementRemovedFromDomException(labelText);
30+
}
31+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace Bunit;
2+
3+
/// <summary>
4+
/// Allows overrides of behavior for FindByLabelText method
5+
/// </summary>
6+
public record class ByLabelTextOptions
7+
{
8+
/// <summary>
9+
/// The default behavior used by FindByLabelText if no overrides are specified
10+
/// </summary>
11+
internal static readonly ByLabelTextOptions Default = new();
12+
13+
/// <summary>
14+
/// The StringComparison used for comparing the desired Label Text to the resulting HTML. Defaults to Ordinal (case sensitive).
15+
/// </summary>
16+
public StringComparison ComparisonType { get; set; } = StringComparison.Ordinal;
17+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using AngleSharp.Dom;
2+
3+
namespace Bunit;
4+
5+
internal static class LabelElementExtensions
6+
{
7+
internal static bool IsHtmlElementThatCanHaveALabel(this IElement element) => element.NodeName switch
8+
{
9+
"INPUT" => true,
10+
"SELECT" => true,
11+
"TEXTAREA" => true,
12+
"BUTTON" => true,
13+
"METER" => true,
14+
"OUTPUT" => true,
15+
"PROGRESS" => true,
16+
_ => false
17+
};
18+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
namespace Bunit;
2+
3+
/// <summary>
4+
/// Represents a failure to find an element in the searched target
5+
/// using the Label's text.
6+
/// </summary>
7+
[Serializable]
8+
public class LabelNotFoundException : Exception
9+
{
10+
/// <summary>
11+
/// Gets the Label Text used to search with.
12+
/// </summary>
13+
public string LabelText { get; } = "";
14+
15+
/// <summary>
16+
/// Initializes a new instance of the <see cref="LabelNotFoundException"/> class.
17+
/// </summary>
18+
/// <param name="labelText"></param>
19+
public LabelNotFoundException(string labelText)
20+
: base($"Unable to find a label with the text of '{labelText}'.")
21+
{
22+
LabelText = labelText;
23+
}
24+
25+
26+
/// <summary>
27+
/// Initializes a new instance of the <see cref="LabelNotFoundException"/> class.
28+
/// </summary>
29+
protected LabelNotFoundException(SerializationInfo info, StreamingContext context)
30+
: base(info, context) { }
31+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using AngleSharp.Dom;
2+
using Bunit.Labels.Strategies;
3+
4+
namespace Bunit;
5+
6+
/// <summary>
7+
/// Extension methods for querying IRenderedFragments by Label
8+
/// </summary>
9+
public static class LabelQueryExtensions
10+
{
11+
private static readonly IReadOnlyList<ILabelTextQueryStrategy> LabelTextQueryStrategies =
12+
[
13+
// This is intentionally in the order of most likely to minimize strategies tried to find the label
14+
new LabelTextUsingForAttributeStrategy(),
15+
new LabelTextUsingAriaLabelStrategy(),
16+
new LabelTextUsingWrappedElementStrategy(),
17+
new LabelTextUsingAriaLabelledByStrategy(),
18+
];
19+
20+
/// <summary>
21+
/// Returns the first element (i.e. an input, select, textarea, etc. element) associated with the given label text.
22+
/// </summary>
23+
/// <param name="renderedFragment">The rendered fragment to search.</param>
24+
/// <param name="labelText">The text of the label to search (i.e. the InnerText of the Label, such as "First Name" for a `<label>First Name</label>`)</param>
25+
/// <param name="configureOptions">Method used to override the default behavior of FindByLabelText.</param>
26+
public static IElement FindByLabelText(this IRenderedFragment renderedFragment, string labelText, Action<ByLabelTextOptions>? configureOptions = null)
27+
{
28+
var options = ByLabelTextOptions.Default;
29+
if (configureOptions is not null)
30+
{
31+
options = options with { };
32+
configureOptions.Invoke(options);
33+
}
34+
35+
return FindByLabelTextInternal(renderedFragment, labelText, options) ?? throw new LabelNotFoundException(labelText);
36+
}
37+
38+
internal static IElement? FindByLabelTextInternal(this IRenderedFragment renderedFragment, string labelText, ByLabelTextOptions options)
39+
{
40+
foreach (var strategy in LabelTextQueryStrategies)
41+
{
42+
var element = strategy.FindElement(renderedFragment, labelText, options);
43+
44+
if (element is not null)
45+
return element;
46+
}
47+
48+
return null;
49+
}
50+
}

0 commit comments

Comments
 (0)