question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Proposal: Alternative API with chain-able queries

See original GitHub issue

Describe the feature you’d like:

The original motivation of this proposal is coming from the performance perspective. *ByRole queries are quite slow, compared to the others, and especially in the jsdom environment. There are already several issues of this: #698, #820. The solutions proposed there were usually either passing hidden: true to the options, or using another query. Both are not ideal and we would be losing some confidence and specificity to our tests.

Needless to say, the ultimate solution will always be to improve the performance of the *ByRole queries directly. However, that seems to be unlikely to happen any time soon. This got me thinking that if there are any alternatives.

The basic idea of this proposal is to allow queries to be chained together so that multiple queries together can increase the specificity. Take the below code for instance:

const button = screen.getByRole('button', { name: /click me/I });

On jsdom, with large DOM tree, this query would sometimes take seconds to evaluate. If we change it to use getByText, however, would usually take less than 100ms:

const button = screen.getByText(/click me/i);

These two are not interchangeable though. We want the performance of getByText, but we also want the specificity getByRoles provides. What if we can combine those two queries?

// Imaginary API
const button = screen.getByTextThenByRole(/click me/i, "button");

Suggested implementation:

The proposal is to split queries into two parts: queries and filters.

Queries are high-level selector function like get, getAll, find, etc. They take a container and a list of filters as arguments and return the result element.

Filters are pure higher-order functions which take node element as argument and determine if the node matches given creteria. For instance, byText(/click me/i)(node) checks if node has text content of /click me/i and returns true or false.

Importing:

import {
  // queries
  get,
  getAll,
  query,
  queryAll,
  find,
  findAll,

  // filters
  byText,
  byTestId,
  byRole
} from "@testing-library/dom";

Queries:

// Get an element with a filter
const button = get(container, byText(/click me/i));

// Find an element with a filter
const button = await find(container, byText(/click me/i));

// Get an element with multiple filters
const button = get(container, byText(/click me/i), byRole("button"));

// Get multiple elements with multiple filters and optional options
const buttons = getAll(
  container,
  byText(/click me/i, {
    exact: false
  }),
  byRole("button")
);

With screen. Auto-bind the first argument to the top-most container.

const button = screen.get(byText(/click me/i));

const buttons = await screen.findAll(byText(/click me/i), byRole("button"));

Here are some of the highlights and trade-offs in this proposal:

Performance

As mentioned earlier in the motivation, the *ByRole queries are quite slow. We can now rewrite that query into something like this.

const button = screen.get(byText(/click me/i), byRole("button"));

What it says is: First we get all the elements that have text content of /click me/i, and then we filter them by their roles and only select those with has a role of button. Finally, we return the first element of the list or throw an error if the number of the list is not one.

Note that this is still not interchangeable with the getByRole query earlier, but a closer alternative than getByText alone.

The order of filters matters, if we filter byRole first, then it’s gonna be just time-consuming or even worse as in our original problem.

Specificity

Chain-able queries can increase specificity level by combining multiple queries. As shown in the above example, byText + byRole has higher specificity than getByText only, and also achieves a similar effect as in getByRole but with performance benefits.

However, we could argue that combining multiple queries is rarely needed. The queries we provide are already very specified. We don’t normally need to chain queries together unless for performance concerns. Perhaps we could provide a more diverse set of filters to be chained together with specificity in mind.

// Instead of
screen.geyByLabelText("Username", { selector: "input" });
// We could do
screen.get(byLabelText("Username"), bySelector("input"));

By introducing an imaginary bySelector filter, we can now split byLabelText with options into two different filters. The bySelector filter can also be reused in combination with other filters like byText as well. Some other examples below:

// From
screen.getByRole("tab", { selected: true });
// To
screen.get(byRole("tab"), bySelected(true));

// From
screen.getByRole("heading", { level: 2 });
// To
screen.get(byRole("heading"), byLevel(2));

Whether these filters have the correct abstractions is not the main point of this proposal, but only to serve as an inspiration for the potential work we could do.

Extensibility

Filters are just pure higher-order functions. We can create our own filter functions with ease. Below is a simplified version of queryAllByDataCy mentioned in the Add custom queries section.

function byDataCy(dataCyValue) {
  return function(node) {
    return node.dataset.cy === dataCyValue;
  };
}

screen.get(byDataCy("my-cy-id"));

What is unknown is how we should handle the error messages. A potential solution would be to bind getMultipleError and getMissingError to the function and let get handles the rest.

byDataCy.getMultipleError = (c, dataCyValue) =>
  `Found multiple elements with the data-cy attribute of: ${dataCyValue}`;
byDataCy.getMissingError = (c, dataCyValue) =>
  `Unable to find an element with the data-cy attribute of: ${dataCyValue}`;

Another solution would be to return an object in the filter function, like have seen in Jest’s custom matchers API.

function byDataCy(dataCyValue) {
  return function(node) {
    return {
      pass: node.dataset.cy === dataCyValue,
      getMultipleError: (c, dataCyValue) =>
        `Found multiple elements with the data-cy attribute of: ${dataCyValue}`,
      getMissingError: (c, dataCyValue) =>
        `Unable to find an element with the data-cy attribute of: ${dataCyValue}`
    };
  };
}

The final API is TBD, but I would prefer the latter since it’s more flexible.

Not only are the filters extensible, but we could also create our own queries variants as well, though unlikely to be useful. Let’s say we want to create a new query called includes, which essentially is the same as queryAllBy* but returns boolean if there’s a match.

function includes(container, ...filters) {
  const nodes = queryAll(container, ...filters);
  return nodes.length > 0;
}

includes(container, byText(/click me/i)) === true;

Direct mapping to Jest custom matchers

@testing-library/jest-dom is a really helpful tool. It almost has 1-to-1 mappings between @testing-library/dom and Jest, but still lacking some matchers.

With this proposal, we can create custom matchers very easily with direct mappings to all of our existing filters without duplicate code.

expect.extend({
  toMatchFilters(node, ...filters) {
    const pass = filters.every(filter => filter(node));
    return {
      pass,
      message: () => "Oops!"
    };
  }
});

expect(button).toMatchFilters(byRole("button"), byText(/click me/i));

within for complex queries

Let’s say we want to select the button under the my-section section, we can query it like this:

const mySection = getByTestId("my-section");
const button = within(mySection).getByRole("button");

What if we can query it in one-go with an imaginary API?

const button = get(
  byTestId("my-section"),
  within(), // or dive(), getDescendents(), getChildren(), ...
  byRole("button")
);

Not sure how helpful would that be though, just a random thought regarding this proposal.

Describe alternatives you’ve considered:

One drawback of this proposal is that we can no longer import every query directly from screen, we have to also import byText, byRole, byTestId, etc. However, I believe modern editors should be able to auto-import them as soon as we type by* in the filters.

If that’s unavailable, there’s a not-so-great alternative proposal in chain-able style:

const button = screen
  .byText(/click me/i)
  .byRole("button")
  .get();

const buttons = await screen
  .byTest(/click me/i)
  .byRole("button")
  .findAll();

In this alternative proposal, we have to also provide a screen.extend API to be able to use custom queries though.

Teachability, Documentation, Adoption, Migration Strategy:

The drawback of this proposal is obvious: we don’t want to introduce new APIs to do the same things, especially when the current behavior already satisfies 80% of the usages. I can totally understand if we decide to not go with this approach. We also don’t want to deprecate the current API any time soon. Fortunately, though, this proposal can be fully backward-compatible. We can even provide a codemod if we like to encourage this usage.

IMHO, I think we should recommend the new API whenever possible, as they cover all the cases of the old API but also open to more opportunities. We could ship these new API as a minor version first, and then start deprecating the old API in a few major releases (but still supporting them). That is, of course, if we are happy about this proposal.

WDYT? Not worth the effort? Sounds interesting? Appreciate any kind of feedbacks 😃.

Issue Analytics

  • State:open
  • Created 3 years ago
  • Comments:10 (8 by maintainers)

github_iconTop GitHub Comments

3reactions
gnapsecommented, Dec 4, 2020

Thanks for taking the time to draft that proposal. It certainly looks interesting.

Regardless of how open we maintainers may be with this proposal, I am 99.99% sure it is something that would first need to be incubated in a separate package to be able to judge more thoroughly its real convenience. And even then, it has to justify making a huge breaking change if we were to adopt it.

The need to importe multiple stuff is also IMO a downside in ergonomics. Yes, modern editors auto-import, but still. Lots of names when today a single screen import gives you everything.

Anyway, I am not against it, but a sample implementation would go a long way into being able to better judge this.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Cleaning Up Function Chains with the Pipeline Operator
From the technical perspective, writing this logic as a chain of function calls works just fine. However, the resulting code is dense.
Read more >
GraphQL API vs WPGraphQL: the fight!
Persisted queries combine the best of both GraphQL and REST: they are created using GraphQL, so it has no under/over fetching of data,...
Read more >
Popup API Alternatives | Open UI
This is a companion document to the main Popup proposal, and it walks through various alternative approaches to the same problem, ...
Read more >
REST API for Oracle Fusion Cloud SCM - Get all proposals
The value of this query parameter is one or more expressions. Example: ?q=Deptno>=10 and <= 30 ... SupplyChainFit; string; Supply chain fit of...
Read more >
Proposal - Web3 Analytics - Dune Analytics for off-chain data ...
Adding engagement/time spent tracking and retention / cohort analysis queries and dashboard components; Automatic updating of queries and dashboards; Low ...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found