testing-library / dom-testing-library

🐙 Simple and complete DOM testing utilities that encourage good testing practices.
https://testing-library.com/dom
MIT License
3.26k stars 467 forks source link

Selector for error messages #1152

Closed janhesters closed 2 years ago

janhesters commented 2 years ago

Describe the feature you'd like:

A selector for accessible error messages.


Context: Calls with Kent Podcast Episode "Tests for Accessible Error Messages"


Let's say you have a component like this:

import { useState } from 'react';

export default function TestForm() {
  const [state, setState] = useState('');
  const [value, setValue] = useState('');

  return (
    <form>
      <fieldset>
        <input
          aria-label="Email address"
          aria-describedby="error-message"
          type="email"
          name="email"
          placeholder="you@example.com"
          onChange={event => {
            event.preventDefault();
            setValue(event.currentTarget.value);
          }}
          value={value}
        />
        <button
          onClick={event => {
            event.preventDefault();
            setState('Email taken!');
          }}
          type="submit"
        >
          Save
        </button>
      </fieldset>

      <p id="error-message">{state || <>&nbsp;</>}</p>
    </form>
  );
}

Note: This component uses the common pattern of always rendering the error message, but keeping it empty, when there is no error, which avoids layout shifts. This will become relevant in the suggested implementation below.


It would be awesome if there was a query for grabbing accessible error messages for inputs. In the code above, this would be the p tag with the id of "error-message".

For example:

screen.getErrorByLabelText(/email/i);

could grab the email input's error message.

Suggested implementation:

I did an example implementation using custom queries. But don't know how good or bad it is. Feedback is welcome! If the feedback is thorough enough, I might be able to submit a PR for this issue.

Let's assume we want to test the component described above.

I created getErrorByLabelText like this:

import type { Matcher, SelectorMatcherOptions } from '@testing-library/react';
import { queryByLabelText } from '@testing-library/react';
import { buildQueries, queryHelpers } from '@testing-library/react';

const queryAllErrorsByLabelText = (
  container: HTMLElement,
  text: Matcher,
  options?: SelectorMatcherOptions,
) => {
  const elementWithLabel = queryByLabelText(container, text, options);

  if (!elementWithLabel) {
    return elementWithLabel;
  }

  const id = elementWithLabel.getAttribute('aria-describedby');

  if (!id) {
    return id;
  }

  const foundElement = queryHelpers.queryAllByAttribute(
    'id',
    container,
    id,
    options,
  );

  if (!foundElement) {
    return foundElement;
  }

  if (foundElement.every(element => element.textContent?.trim() === '')) {
    // A common pattern is to always render the error tag to avoid layout shift,
    // so we have to make sure that empty elements are considered.
    return [];
  }

  return foundElement;
};

const getMultipleError = (_: any, labelText: string) =>
  `Found multiple elements with the label of: ${labelText}`;
const getMissingError = (_: any, labelText: string) =>
  `Unable to find an element with the label of: ${labelText}`;

const [
  queryErrorByLabelText,
  getAllErrorByLabelText,
  getErrorByLabelText,
  findAllErrorByLabelText,
  findErrorByLabelText,
  // buildQueries doesn't like that `queryAllErrorsByLabelText` can return `null`.
  // However, it needs to return `null` in all the cases where elements aren't
  // found, so things like `expect(/* ... */).not.toBeInTheDocument();` work.
  // @ts-ignore
] = buildQueries(queryAllErrorsByLabelText, getMultipleError, getMissingError);

export {
  findAllErrorByLabelText,
  findErrorByLabelText,
  getAllErrorByLabelText,
  getErrorByLabelText,
  queryAllErrorsByLabelText,
  queryErrorByLabelText,
};

We can then hook up our custom queries in our test-utils/ like this:

import type { RenderOptions } from '@testing-library/react';
import { queries, render } from '@testing-library/react';
import type { ReactElement } from 'react';

import * as errorByLabelTextQueries from './error-by-label-text-queries';

const customRender = (
  ui: ReactElement,
  options?: Omit<RenderOptions, 'queries'>,
) =>
  // We ts-ignore here, because `errorByLabelTextQueries` can
  // return `null`, which `render` doesn't like for some reason.
  // @ts-ignore
  render(ui, {
    queries: { ...queries, ...errorByLabelTextQueries },
    ...options,
  });

// re-export everything
export * from '@testing-library/react';

// override render method
export { customRender as render };

Finally, we can now write our test:

/**
 * @vitest-environment jsdom
 */

import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';

import { render, screen } from '~/test/test-utils';

import UserProfile from './user-profile-component';

describe('user profile component', () => {
  it('show an error message on submit', async () => {
    const user = userEvent.setup();
    const { getErrorByLabelText, queryErrorByLabelText } = render(
      <UserProfile />,
    );
    await user.type(screen.getByLabelText(/email address/i), 'alice@bob.com');
    expect(queryErrorByLabelText(/email address/i)).not.toBeInTheDocument();
    await user.click(screen.getByRole('button', { name: /save/i }));
    expect(getErrorByLabelText(/email address/i)).toBeInTheDocument();
  });
});

Note: The test example uses Vitest with happy-dom because those tests run a lot faster than Jest. However, happy-dom is currently broken with user-event, which is why we need the comment at the top.


In the final and real implementation, we should be able to use getErrorByLabelText (or whatever the API ends up being) from screen instead of destructuring it from the return value of render.

Describe alternatives you've considered:

There are none, except for closing this feature request without an implementation and always using custom queries 🤷‍♂️

Teachability, Documentation, Adoption, Migration Strategy:

The tests act above as usage examples ☝️

eps1lon commented 2 years ago

You're looking for getByRole('textbox', { description: 'the error message' }).

There's not distinction between an error message and an accessible description which is why queryErrorByLabelText could be misused or create wrong test results.

If you want the element that contains the description of an element you're better off with a custom query since there's no clear use case for that. How the description is created is an implementation detail (e.g. aria-description or aria-describedby="elem1 elem2").

kentcdodds commented 2 years ago

Ah, totally forgot about this! Thanks @eps1lon!

janhesters commented 1 year ago

@eps1lon Veeeery late thank you for the answer!

How would you query for a <input type="date" />'s or <input type="time" />'s error?