Description
In the front-end testing world, https://testing-library.com/ API surface has become very popular.
As far as I can see, it's a bunch of helper methods that allow users to easily traverse the DOM, interact with it, and assert against it. All the core APIs should be fairly straightforward to support based on bUnit's API surface, the queries, the firing of events, the async "wait for", e.g. for elements to appear or disappear, accessibility checks, etc.
The cheat sheet for the DOM Testing Library can be found here: https://testing-library.com/docs/dom-testing-library/cheatsheet
Below are the initial design and docs for bUnit's version of Testing Library. The additional APIs would be part of the bunit.web
project, as the are all related to interacting with the rendered DOM:
Querying and interacting with rendered markup
bUnit includes methods that make it easy to query, interact, and use the rendered markup (DOM), similar to how a user would. They are inspired by the https://testing-library.com library from the JavaScript world. Check out their Guiding Principles for additional tips for writing good tests using the assertion methods in this library.
Mapping between Testing Library and bUnit conventions
Testing Library | bUnit | Description |
---|---|---|
getBy* |
Find* |
Returns the matching node for a query, and throw a ElementNotFoundException exception if no elements match or if more than one match is found (use FindAll* instead if more than one element is expected). |
queryBy* |
Find*OrDefault |
Returns the matching node for a query, or null if no elements match. This is useful for asserting an element that is not present. Throws an error if more than one match is found (use FindAll* and assert the returned collection is empty instead, if this is OK). |
findBy* |
Find*Async |
Returns a Task that completes when the matching element is found which matches the given query. Throws a TimeoutException if no element is found or if more than one element is found after a default timeout of 1000ms. If you need to find more than one element, use FindAll*Async . |
getAllBy* |
FindExactly* |
Returns a collection of exactly the number of matching nodes for a query. If a different amount of elements are found an exception is thrown. |
queryAllBy* |
FindAll* |
Returns a collection of all matching nodes for a query. The collection is empty if no elements are found. |
findAllBy* |
FindAll*Async |
Returns a Task that completes when any matching elements are found which matches the given query. Throws a TimeoutException if no elements are found after a default timeout of 1000ms. |
- | FindExactly*Async |
Returns a Task that completes when exactly the number of matching elements are found for a given query. Throws a TimeoutException if no elements are found after a default timeout of 1000ms. |
As the table above shows, the method names are slightly simplified in bUnit compared to Testing Library, and more aligned with what one would expect in .NET / C# code, e.g. using FindOrDefault
instead of queryBy*
.
Using queries
The query methods are available on the IRenderedFragment
type and IInputElement
types. When a query method is issued on an IRenderedFragment
, it will search the entire component tree for matching elements. When a query method is issued on an IInputElement
, it will only search the children of that element.
Query options
Each query method has a default set of options that can be overridden by passing in an options object. The options object is optional, and if not provided, the default options will be used.
TextMatch
delegate
Most of the query methods can be passed a TextMatch
, in addition to a string or a RegEx
type.
The TextMatch
delegate is used to specify how a given text should be matched if the default string comparer should not be used. This is the signature for TextMatch
:
public delegate bool TextMatch(string? text, IInputElement? element);
Where text
is the text that should be matched, and element
is the element that contains the text. The element
parameter is optional and can be null
if the text is not contained in an element.
TODO: is this possible in bUnit?
Matching text (precision)
Instead of the text matching feature of Testing Library, bUnit uses the StringComparison
enum to specify how to match text. The default is StringComparison.Ordinal
, which is the same as the default in Testing Library, but you can also use StringComparison.OrdinalIgnoreCase
if you want to ignore case or any of the other options for string comparison available in .NET.
Normalization
Similar to how MarkupMatches
normalize text before comparison, the query methods will normalize the text before matching. By default, normalization consists of trimming whitespace from the start and end of text and collapsing multiple adjacent whitespace characters into a single space.
If you want to prevent that normalization or provide alternative normalization (e.g. to remove Unicode control characters), you can provide a normalizer delegate in the options object. This function will be given a string and is expected to return a normalized version of that string.
The signature for the normalizer delegate is:
public delegate string TextNormalizer(string text);
Wait for options
The WaitForOptions
class is used to specify how long to wait for an element to appear in the DOM, and how often to check for the element. The default is to wait for 1000ms and check every 50ms.
public record class WaitForOptions
{
public static readonly WaitForOptions Default = new();
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(1);
public TimeSpan Interval { get; init; } = TimeSpan.FromMilliseconds(50);
}
Previously, the WaitFor*
methods in bUnit only retried the wait predicate after every render. I am not sure if it makes sense to introduce a recurring retry interval like Testing Library provides, but it is something to consider.
In addition, the OnTimeout?: (error: Error) => Error
call back does not seem to be useful in C#/.NET, and neither does mutationObserverOptions?: MutationObserverInit
.
The core Find* method
bUnit ships with Find(string cssSelector)
and FindAll(string cssSelector)
methods already, and it makes sense to continue having these. They are updated to match the structure described above, and replace the WaitForElement
and WaitForElements
methods:
public IInputElement Find(string cssSelector);
public IInputElement? FindOrDefault(string cssSelector);
public Task<IInputElement> FindAsync(string cssSelector, WaitForOptions? waitForOptions = null);
public IReadOnlyList<IInputElement> FindAll(string cssSelector);
public Task<IReadOnlyList<IInputElement>> FindAllAsync(string cssSelector, WaitForOptions? waitForOptions = null);
public IReadOnlyList<IInputElement> FindExactly(string cssSelector, int exactCount);
public Task<IReadOnlyList<IInputElement>> FindExactlyAsync(string cssSelector, int exactCount, WaitForOptions? waitForOptions = null);
ByRole
Check out the ByRole query in Testing Library for a description of the ByRole
query and its options.
Here is the C# API for the ByRole
query:
public IInputElement FindByRole(string role, ByRoleOptions? options = null);
public IInputElement FindByRole(TextMatch roleMatcher, ByRoleOptions? options = null);
public IInputElement FindByRole(RegEx roleRegex, ByRoleOptions? options = null);
public IInputElement? FindByRoleOrDefault(string role, ByRoleOptions? options = null);
public IInputElement? FindByRoleOrDefault(TextMatch roleMatcher, ByRoleOptions? options = null);
public IInputElement? FindByRoleOrDefault(RegEx roleRegex, ByRoleOptions? options = null);
public Task<IInputElement> FindByRoleAsync(string role, ByRoleOptions? options = null, WaitForOptions? waitForOptions = null);
public Task<IInputElement> FindByRoleAsync(TextMatch roleMatcher, ByRoleOptions? options = null, WaitForOptions? waitForOptions = null);
public Task<IInputElement> FindByRoleAsync(RegEx roleRegex, ByRoleOptions? options = null, WaitForOptions? waitForOptions = null);
public IReadOnlyList<IInputElement> FindAllByRole(string role, ByRoleOptions? options = null);
public IReadOnlyList<IInputElement> FindAllByRole(TextMatch roleMatcher, ByRoleOptions? options = null);
public IReadOnlyList<IInputElement> FindAllByRole(RegEx roleRegex, ByRoleOptions? options = null);
public Task<IReadOnlyList<IInputElement>> FindAllByRoleAsync(string role, ByRoleOptions? options = null, WaitForOptions? waitForOptions = null);
public Task<IReadOnlyList<IInputElement>> FindAllByRoleAsync(TextMatch roleMatcher, ByRoleOptions? options = null, WaitForOptions? waitForOptions = null);
public Task<IReadOnlyList<IInputElement>> FindAllByRoleAsync(RegEx roleRegex, ByRoleOptions? options = null, WaitForOptions? waitForOptions = null);
public IReadOnlyList<IInputElement> FindExactlyByRole(string role, int exactCount, ByRoleOptions? options = null);
public IReadOnlyList<IInputElement> FindExactlyByRole(TextMatch roleMatcher, int exactCount, ByRoleOptions? options = null);
public IReadOnlyList<IInputElement> FindExactlyByRole(RegEx roleRegex, int exactCount, ByRoleOptions? options = null);
public Task<IReadOnlyList<IInputElement>> FindExactlyByRoleAsync(string role, int exactCount, ByRoleOptions? options = null, WaitForOptions? waitForOptions = null);
public Task<IReadOnlyList<IInputElement>> FindExactlyByRoleAsync(TextMatch roleMatcher, int exactCount, ByRoleOptions? options = null, WaitForOptions? waitForOptions = null);
public Task<IReadOnlyList<IInputElement>> FindExactlyByRoleAsync(RegEx roleRegex, int exactCount, ByRoleOptions? options = null, WaitForOptions? waitForOptions = null);
// See https://testing-library.com/docs/queries/byrole/#options for
// description of each of the options.
public record class ByRoleOptions
{
public static readonly ByRoleOptions Default = new();
public StringComparison ComparisonType { get; init; } = StringComparison.Ordinal;
public bool Hidden { get; init; } = false;
public bool? Selected { get; init; }
public bool? Checked { get; init; }
public bool? Current { get; init; }
public bool? Pressed { get; init; }
public bool? Expanded { get; init;}
public bool QueryFallbacks { get; init; } = false;
public int? Level { get; init; }
public TextMatch? Name { get; init; }
public TextMatch? Description { get; init; }
public TextNormalizer? Normalizer { get; init; }
}
ByLabelText
Check out the ByLabelText query in Testing Library for a description of the ByLabelText
query and its options.
Here is the C# API for the ByLabelText
query:
public IInputElement FindByLabelText(string role, ByLabelTextOptions? options = null);
public IInputElement FindByLabelText(TextMatch roleMatcher, ByLabelTextOptions? options = null);
public IInputElement FindByLabelText(RegEx roleRegex, ByLabelTextOptions? options = null);
public IInputElement? FindByLabelTextOrDefault(string role, ByLabelTextOptions? options = null);
public IInputElement? FindByLabelTextOrDefault(TextMatch roleMatcher, ByLabelTextOptions? options = null);
public IInputElement? FindByLabelTextOrDefault(RegEx roleRegex, ByLabelTextOptions? options = null);
public Task<IInputElement> FindByLabelTextAsync(string role, ByLabelTextOptions? options = null, WaitForOptions? waitForOptions = null);
public Task<IInputElement> FindByLabelTextAsync(TextMatch roleMatcher, ByLabelTextOptions? options = null, WaitForOptions? waitForOptions = null);
public Task<IInputElement> FindByLabelTextAsync(RegEx roleRegex, ByLabelTextOptions? options = null, WaitForOptions? waitForOptions = null);
public IReadOnlyList<IInputElement> FindAllByLabelText(string role, ByLabelTextOptions? options = null);
public IReadOnlyList<IInputElement> FindAllByLabelText(TextMatch roleMatcher, ByLabelTextOptions? options = null);
public IReadOnlyList<IInputElement> FindAllByLabelText(RegEx roleRegex, ByLabelTextOptions? options = null);
public Task<IReadOnlyList<IInputElement>> FindAllByLabelTextAsync(string role, ByLabelTextOptions? options = null, WaitForOptions? waitForOptions = null);
public Task<IReadOnlyList<IInputElement>> FindAllByLabelTextAsync(TextMatch roleMatcher, ByLabelTextOptions? options = null, WaitForOptions? waitForOptions = null);
public Task<IReadOnlyList<IInputElement>> FindAllByLabelTextAsync(RegEx roleRegex, ByLabelTextOptions? options = null, WaitForOptions? waitForOptions = null);
public IReadOnlyList<IInputElement> FindExactlyByLabelText(string role, int exactCount, ByLabelTextOptions? options = null);
public IReadOnlyList<IInputElement> FindExactlyByLabelText(TextMatch roleMatcher, int exactCount, ByLabelTextOptions? options = null);
public IReadOnlyList<IInputElement> FindExactlyByLabelText(RegEx roleRegex, int exactCount, ByLabelTextOptions? options = null);
public Task<IReadOnlyList<IInputElement>> FindExactlyByLabelTextAsync(string role, int exactCount, ByLabelTextOptions? options = null, WaitForOptions? waitForOptions = null);
public Task<IReadOnlyList<IInputElement>> FindExactlyByLabelTextAsync(TextMatch roleMatcher, int exactCount, ByLabelTextOptions? options = null, WaitForOptions? waitForOptions = null);
public Task<IReadOnlyList<IInputElement>> FindExactlyByLabelTextAsync(RegEx roleRegex, int exactCount, ByLabelTextOptions? options = null, WaitForOptions? waitForOptions = null);
public record class ByLabelTextOptions
{
public static readonly ByLabelTextOptions Default = new();
public StringComparison ComparisonType { get; init; } = StringComparison.Ordinal;
public TextNormalizer? Normalizer { get; init; }
// A css selector to match the child element of the selected label element.
public string Selector { get; init; } = "*";
}
The remaining queries follow the same pattern as the above queries and are not much different.
Firing events
Event firing, or dispatching, as it's called in bUnit until now, will stay pretty much the same, but with a few improvements.
We will drop the overloads that take each property in an EventArgs type and just keep the overload that takes an optional EventArg. If none is provided, we will create a default EventArgs type for the event in question.