# 8. [Microsoft / playwright] feat: expect(locator).toHaveAccessibleErrorMessage

pengooseDev·2024년 12월 31일
0

1. Issue

PR
issue#31249

Input에 대해 input validity기반 a11y 요소 기반 테스트 메서드인 expect(locator).toHaveAccessibleErrorMessage()를 추가해달라는 요청이었다.

3주 동안 w3c 스펙을 기반으로 에러 메시지를 반환하는 경우의 정의에 대한 토론과 추상화 및 피드백을 진행하였다.


2. Context 파악

요구사항이 생각보다 많이 복잡했다.
추상화를 진행하기 전, 아래의 것들을 파악했다.

  1. w3c Spec
  2. 다른 라이브러리의 추상화 방식

aria 속성과 에러메시지를 사용하여 이를 해결하려 했지만, maintainer님이 Input Validity의 속성도 파악하시길 원했다.

올바른 role 파악 > aria-invalid 필드기반 토큰 반환 > inputValidity 파악 > 둘 중 문제가 하나라도 있는 경우 유효하지 않는 상태로 파악

3주 동안 추상화 방식와 테스트코드가 계속 변하였고 최종적으로 나오게된 피쳐는 아래와 같다.


3. 테스트 코드

TC에 대한 오버헤드를 우려해 엣지케이스에 대한 테스트들은 전부 확인한 뒤 제거한 코드이다.

에러 메시지가 여러개인 경우, aria-invalid가 반환하는 여러가지 토큰들, inputValidity 자체의 값.
이것들이 errorMessage를 반환하는 의사결정의 분기에 의존성이 있어 단순히 머릿속으로 이해한 뒤 추상화 하기엔 조금 어려웠다.
머리를 오래 쓰기 싫어서 Spec 및 토의결과 기반으로 TDD를 진행했고, 오랜만에 그것의 효용성을 느끼게 되었다.
(복잡한 Spec 및 많은 의사결정 분기 기반 피쳐는 TDD가 확실히 편한듯 하다)

test('toHaveAccessibleErrorMessage', async ({ page }) => {
  await page.setContent(`
    <form>
      <input role="textbox" aria-invalid="true" aria-errormessage="error-message" />
      <div id="error-message">Hello</div>
      <div id="irrelevant-error">This should not be considered.</div>
    </form>
  `);

  const locator = page.locator('input[role="textbox"]');
  await expect(locator).toHaveAccessibleErrorMessage('Hello');
  await expect(locator).not.toHaveAccessibleErrorMessage('hello');
  await expect(locator).toHaveAccessibleErrorMessage('hello', { ignoreCase: true });
  await expect(locator).toHaveAccessibleErrorMessage(/ell\w/);
  await expect(locator).not.toHaveAccessibleErrorMessage(/hello/);
  await expect(locator).toHaveAccessibleErrorMessage(/hello/, { ignoreCase: true });
  await expect(locator).not.toHaveAccessibleErrorMessage('This should not be considered.');
});

test('toHaveAccessibleErrorMessage should handle multiple aria-errormessage references', async ({ page }) => {
  await page.setContent(`
    <form>
      <input role="textbox" aria-invalid="true" aria-errormessage="error1 error2" />
      <div id="error1">First error message.</div>
      <div id="error2">Second error message.</div>
      <div id="irrelevant-error">This should not be considered.</div>
    </form>
  `);

  const locator = page.locator('input[role="textbox"]');

  await expect(locator).toHaveAccessibleErrorMessage('First error message. Second error message.');
  await expect(locator).toHaveAccessibleErrorMessage(/first error message./i);
  await expect(locator).toHaveAccessibleErrorMessage(/second error message./i);
  await expect(locator).not.toHaveAccessibleErrorMessage(/This should not be considered./i);
});

test.describe('toHaveAccessibleErrorMessage should handle aria-invalid attribute', () => {
  const errorMessageText = 'Error message';

  async function setupPage(page, ariaInvalidValue: string | null) {
    const ariaInvalidAttr = ariaInvalidValue === null ? '' : `aria-invalid="${ariaInvalidValue}"`;
    await page.setContent(`
        <form>
          <input id="node" role="textbox" ${ariaInvalidAttr} aria-errormessage="error-msg" />
          <div id="error-msg">${errorMessageText}</div>
        </form>
      `);
    return page.locator('#node');
  }

  test.describe('evaluated in false', () => {
    test('no aria-invalid attribute', async ({ page }) => {
      const locator = await setupPage(page, null);
      await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
    });
    test('aria-invalid="false"', async ({ page }) => {
      const locator = await setupPage(page, 'false');
      await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
    });
    test('aria-invalid="" (empty string)', async ({ page }) => {
      const locator = await setupPage(page, '');
      await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
    });
  });
  test.describe('evaluated in true', () => {
    test('aria-invalid="true"', async ({ page }) => {
      const locator = await setupPage(page, 'true');
      await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
    });
    test('aria-invalid="foo" (unrecognized value)', async ({ page }) => {
      const locator = await setupPage(page, 'foo');
      await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
    });
  });
});

test.describe('toHaveAccessibleErrorMessage should handle validity state with aria-invalid', () => {
  const errorMessageText = 'Error message';

  test('should show error message when validity is false and aria-invalid is true', async ({ page }) => {
    await page.setContent(`
      <form>
        <input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="true" aria-errormessage="error-msg" />
        <div id="error-msg">${errorMessageText}</div>
      </form>
    `);
    const locator = page.locator('#node');
    await locator.fill('101');
    await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
  });

  test('should show error message when validity is true and aria-invalid is true', async ({ page }) => {
    await page.setContent(`
      <form>
        <input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="true" aria-errormessage="error-msg" />
        <div id="error-msg">${errorMessageText}</div>
      </form>
    `);
    const locator = page.locator('#node');
    await locator.fill('99');
    await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
  });

  test('should show error message when validity is false and aria-invalid is false', async ({ page }) => {
    await page.setContent(`
      <form>
        <input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="false" aria-errormessage="error-msg" />
        <div id="error-msg">${errorMessageText}</div>
      </form>
    `);
    const locator = page.locator('#node');
    await locator.fill('101');
    await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
  });

  test('should not show error message when validity is true and aria-invalid is false', async ({ page }) => {
    await page.setContent(`
      <form>
        <input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="false" aria-errormessage="error-msg" />
        <div id="error-msg">${errorMessageText}</div>
      </form>
    `);
    const locator = page.locator('#node');
    await locator.fill('99');
    await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
  });
});

4. 추상화

> role Util

Maintainer님의 의견에 따라 utils에 로직을 분리하고 기존 컨벤션대로 로직 추가

Role Utils

// https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
const kAriaInvalidRoles = ['application', 'checkbox', 'combobox', 'gridcell', 'listbox', 'radiogroup', 'slider', 'spinbutton', 'textbox', 'tree', 'columnheader', 'rowheader', 'searchbox', 'switch', 'treegrid'];

function getAriaInvalid(element: Element): 'false' | 'true' | 'grammar' | 'spelling' {
  const role = getAriaRole(element) || '';
  if (!role || !kAriaInvalidRoles.includes(role))
    return 'false';
  const ariaInvalid = element.getAttribute('aria-invalid');
  if (!ariaInvalid || ariaInvalid.trim() === '' || ariaInvalid.toLocaleLowerCase() === 'false')
    return 'false';
  if (ariaInvalid === 'true' || ariaInvalid === 'grammar' || ariaInvalid === 'spelling')
    return ariaInvalid;
  return 'true';
}

function getValidityInvalid(element: Element) {
  if ('validity' in element){
    const validity = element.validity as ValidityState | undefined;
    return validity?.valid === false;
  }
  return false;
}

export function getElementAccessibleErrorMessage(element: Element): string {
  // SPEC: https://w3c.github.io/aria/#aria-errormessage
  //
  // TODO: support https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/validationMessage
  const cache = cacheAccessibleErrorMessage;
  let accessibleErrorMessage = cacheAccessibleErrorMessage?.get(element);

  if (accessibleErrorMessage === undefined) {
    accessibleErrorMessage = '';

    const isAriaInvalid = getAriaInvalid(element) !== 'false';
    const isValidityInvalid = getValidityInvalid(element);
    if (isAriaInvalid || isValidityInvalid) {
      const errorMessageId = element.getAttribute('aria-errormessage');
      const errorMessages = getIdRefs(element, errorMessageId);
      // Ideally, this should be a separate "embeddedInErrorMessage", but it would follow the exact same rules.
      // Relevant vague spec: https://w3c.github.io/core-aam/#ariaErrorMessage.
      const parts = errorMessages.map(errorMessage => asFlatString(
          getTextAlternativeInternal(errorMessage, {
            visitedElements: new Set(),
            embeddedInDescribedBy: { element: errorMessage, hidden: isElementHiddenForAria(errorMessage) },
          })
      ));
      accessibleErrorMessage = parts.join(' ').trim();
    }
    cache?.set(element, accessibleErrorMessage);
  }
  return accessibleErrorMessage;
}

Method

export function toHaveAccessibleErrorMessage(
  this: ExpectMatcherState,
  locator: LocatorEx,
  expected: string | RegExp,
  options?: { timeout?: number; ignoreCase?: boolean },
) {
  return toMatchText.call(this, 'toHaveAccessibleErrorMessage', locator, 'Locator', async (isNot, timeout) => {
    const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true });
    return await locator._expect('to.have.accessible.error.message', { expectedText: expectedText, isNot, timeout });
  }, expected, options);
}

3주간 리뷰. 그리고 merged

말한대로 3주 동안 추상화 방식, 에러 판단 방식 등 계속해서 변경되었다.

추상화 > 리뷰 요청 > 피드백 > ... 반복 > merged

3주 간의 41개의 커뮤니케이션 끝에 merged 되었다.

  • maintainer님들에게 조금 죄송한 마음이 드는 PR이었다. 필자가 영어를 더 잘했고 공식 Spec을 어느정도 잘 숙지하고 있었다면 maintainer의 시간을 덜 뺐었을 수 있었을텐데...🥲
    (공식문서 버젼별로 스펙이 조금씩 달랐던 부분이라 다른 문서를 보고 잘못 추상화 한 경우도 있었고, 영어 잘못 해석해서 추상화를 잘못한 경우도 있었다. 🥲)

그래도 공식 스펙과 버젼을 인지하고 추상화하는 연습을 잘 할 수 있었던 PR이라 다음엔 더 나은 코드로 보답하는걸로...!

0개의 댓글