Skip to content

Proposal: Alternative API with chain-able queries #843

Open
@kevin940726

Description

@kevin940726

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 :).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestneeds discussionWe need to discuss this to come up with a good solution

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions