Better Cypress Selectors in ASP.NET
September 16th, 2022Working with Cypress on an ASP.NET project recently, I was getting frustrated with adding arbitrary strings to the markup as selectors for the tests. Strings are difficult to work with: they have no structure, can contain nearly anything, and are difficult to refactor. They are, in my opinion, the junk drawer of programming languages. I prefer more structured data.
I designed a tiny utility for generating Cypress selectors from a hierarchy of nested classes; making them easier to work with using developer tools and adding structure to the way the selectors are defined throughout the project.
What I Was Trying to Solve
Dynamic IDs
As the project is an ASP.NET codebase, using Tag Helpers, the IDs for those elements are based on the name of the properties in the code with which they are associated. If the property is renamed, the ID in the HTML would then also change — a side-effect often overlooked until Cypress tests would fail for non-obvious reasons.
<input asp-for="@Model.IsComplete" type="checkbox" />
cy.get('#IsComplete').should('be.checked')
In the Cypress Best Practices guide, the ideal method of defining selectors is to use an attribute that isn’t generated or used by any other aspect of the codebase. Cypress-specific data-cy
attributes are suggested.
<input asp-for="@Model.IsComplete" type="checkbox" data-cy="is-complete" />
cy.get('[data-cy="is-complete"]').should('be.checked')
Inconsistent Naming
Another problem was inconsistent naming of IDs on similar elements, from one page to another; frustrating the ability to create Cypress commands to simplify the tests. For example, the H1
tag on one page might have a selector of “heading” and on another page the selector might be “page-heading”.
// Only useful for SOME pages...
Cypress.Commands.add('pageHeading', () => {
cy.get('[data-cy="heading"]')
})
cy.pageHeading.should('contain.text', 'Something')
<!-- Oops -->
<h1 data-cy="page-heading">Something</h1>
Lack of Discoverability
Because some IDs are generated by the code, others are hard-coded, and the naming is inconsistent, it is impossible to make educated guesses about what a selector might be. This means that every test needs extra time to inspect every single element to find out what ID it has… and then that is repeated when the ID changes when the code is refactored.
The Tool and The Taxonomy
In an attempt to solve these problems I built a little tool (that I named Typespace) that lead to the creation of a selector taxonomy. Let’s look at the tool and how it works, first.
Typespace
The name is a play on “Namespace” because, in the same way namespaces are nested to provide organisation to code (like System.Text.RegularExpressions
), this tool uses nested types to provide organisation to — and form part of — the selectors.
A tiny example looks like this:
public sealed class Select
{
public sealed class Page
{
private static string headerCache;
public static string Header => Typespace.Name( ref headerCache );
}
}
That would be used like this:
string header = Select.Page.Header; // header == select-page-header
Or, more probably, like this:
<h1 data-cy="@Select.Page.Header">Something</h1>
<!-- produces -->
<h1 data-cy="select-page-header">Something</h1>
Impromptu Taxonomy
Being a hierarchy of classes containing properties, this lends itself really well to organization and the creation of a selector taxonomy1.
It quickly became evident that there were some elements common to all pages (such as the page header) and there were others that were very specific to a particular area of the site (user account details, for example) so those were nested appropriately to make them easier to discover and to also produce more self-descriptive selectors.
public sealed class Select
{
private static string headerCache;
public static string Header => Typespace.Name( ref headerCache );
public sealed class Input
{
private static string yesCache, noCache, submitCache;
public static string Yes => Typespace.Name( ref yesCache );
public static string No => Typespace.Name( ref noCache );
public static string Submit => Typespace.Name( ref submitCache );
}
public sealed class User
{
private static string emailCache;
public static string Email => Typespace.Name( ref emailCache );
public sealed class Account
{
private static string isActiveCache;
public static string IsActive => Typespace.Name( ref isActiveCache );
}
}
}
This produces selectors like these:
Select.Header // == select-header
Select.Input.Yes // == select-input-yes
Select.Input.Submit // == select-input-submit
Select.User.Account.IsActive // == select-user-account-isactive
The selectors don’t include the type name of the element they are associated with (i.e., not Select.Input.SubmitButton
) for two reasons: it might change, and including it makes the selectors longer and the aim is to keep them concise.
Also, so far at least, we’ve kept all of our selector definitions inside a single Select
root class, meaning they will all start with select-
making them easier to find in the browser.
It’s Just Smoke and Mirrors
Under the covers, Typespace is just an implicit string conversion. It grabs the name of the property (Header
) and the type that it exists within (which includes the type hierarchy: Select.Page
) then combines them (Select.Page.Header
), replaces dots with hyphens (Select-Page-Header
) and converts the result to lowercase (select-page-header
) which results in a pretty reasonable, HTML-friendly attribute value.
The “trick” here is that the Header
property is an expression bodied property meaning that the Typespace code will find the implicit get
method in its stack trace (Header
is actually get_Header
, but the prefix is trimmed.)
Let’s have a look at the code. This is the core of the solution; there is a bit more, but this does the main work.
public class Typespace
{
// ...
[MethodImpl( MethodImplOptions.NoInlining )]
public static implicit operator string( Typespace _ )
{
var frame = new StackFrame( 2 );
MethodBase? method = frame.GetMethod();
Type? declaringType = method?.DeclaringType;
if ( declaringType is { Namespace: { } } )
{
ReadOnlySpan<char> typeName = declaringType.FullName.AsSpan( declaringType.Namespace.Length + 1 );
ReadOnlySpan<char> memberName = RemovePropertyPrefixes( method?.Name );
return NonAlphaNumeric.Replace( $"{typeName}-{memberName}", "-" ).ToLowerInvariant();
}
return string.Empty;
}
// ...
}
The interesting part is grabbing the StackFrame
. I’d originally used a StackTrace
before I realised that only one frame was needed and that it is actually possible to just grab one on its own. The first couple of frames need to be skipped (this implicit operator method and another of Typespace’s methods) to get to the one related to the property “getter.” It turns out that’s as simple as telling the StackFrame
constructor which one you want. Very useful!
The rest of the code is very straightforward. Grab the method and the type in which it exists then check that the type is a non-null object with a non-null namespace — using some shiny pattern matching syntax. Then the rest is string manipulation. I use Span<T>
to save a few allocations along the way to keep the memory footprint to a minimum.
What About Those Cache Fields?
Those are there to optimize for speed: once a string conversion is done, the output is stored in the cache variable. This makes Typespace really fast: benchmarked at around 1 nanosecond per call!
Method | Mean | Error | StdDev | Rank | Allocated |
---|---|---|---|---|---|
Typespace | 1.065 ns | 0.0061 ns | 0.0057 ns | 1 | - |
(Performance measured with BenchmarkDotNet)
Cache management is done internally by Typespace and this is the other skipped method alluded to earlier: The Name
method.
public static string Name( ref string? cachedValue, string disabledDefault = "" ) =>
IsEnabled ? cachedValue ??= new Typespace() : disabledDefault;
This is terse, but simple. The cache field is passed in (by reference so that it can be modified) and there is a default value that will be returned when Typespace is disabled. If Typespace is enabled, either the cached value is returned or a new instance of Typespace is created and assigned to it (automatically constructing the string representation at this point) and then returned.
Leaving Cypress selectors in front-end code in production is not ideal, so Typespace provides a means to disable it, completely bypassing any selector generation: Typespace.IsEnabled
.
Solving Problems
So; does Typespace solve all of the problems outlined earlier? Almost. It certainly makes it easier to do the right thing.
Dynamic IDs
Cypress best practice solves this one: once the Cypress tests are decoupled from the IDs, classes, and other attributes that are generated or used by the code, and get dedicated selectors, this becomes a non-issue.
Lack of Discoverability
This is a big win. Being a type hierarchy, features such as Visual Studio’s IntelliSense will show what’s available to use. And if there isn’t something appropriate it’s easy to hop over to the code and add an extra nested class or property.
Inconsistent Naming
Another big win. It’s certainly easier to select from the graph of selectors than to have to think up a new name for a selector and make sure it doesn’t clash with anything else on the page. And, as the number of selectors increases, a de facto naming convention gradually coalesces.
Refactorable Strings
Another major benefit is that the attribute value is no longer a string — it’s a type — and refactoring tools generally offer much more support for types than for strings.
For example, using strings for selectors (such as select-page-header
), if the decision was made to, say, replace “page” with “section” and update all usages, this will be a careful find-and-replace operation. That will probably be fine, but will need all results to be checked to make sure the matching isn’t too broad, or too narrow.
However, as a set of types, the full support of the refactoring tool can be leveraged to rename the Page
class and update usages.
Only Half of the Problem
Typespace lives in the app codebase. So this only solves half of the problem: the app developer’s experience is nicer, but the developer working on the Cypress tests still has to do everything manually. Fortunately the app developer can at least provide a more predictable, straight-forward experience for them.
Self-Documenting?
Discoverability in a code editor is nice, but it would be even nicer if Typespace had tooling for producing documentation describing what selectors are available and what each is used for.
This is on the to-do list.
Maybe something with a bit of reflection over the Select
class (or anything that references Typespace
) and using the Description
attribute. Then it could produce a document explaining all of the available selectors: their names and descriptions, grouped by the class hierarchy.
Where can I Find it?
The code is on GitHub with an MIT license.
If you find it useful, please consider dropping a comment here or star the repository. If you have feature ideas, bugs, PRs, etc. create a ticket!
-
Yes, it took me 5 minutes to remember the word “taxonomy” and now I shall use it. Liberally! ↩