IlmHamroh
JavaScript Full-stack/22-qism. Frontend testlash14/16-dars23 daqiqa
Mundarija (31)

Accessibility testlari: axe, klaviatura oqimi va avtomatika chegarasi

Qisqacha: A11y testi uch qatlamdan iborat. (1) Rol so'rovlari va jest-dom matcherlari (toHaveAccessibleName, toBeInvalid) — har komponent testida bepul. (2) axe-core — tayyor qoidalar dvigateli: nomsiz tugma, label'siz maydon, kontrast; Playwright'da @axe-core/playwright bilan expect(buzilishlar).toEqual([]). (3) Klaviatura oqimi testi — Tab, Enter, Esc va fokus qayerga borishi. axe xatolarning faqat bir qismini topadi: <div onClick> tugmani ham, fokus yo'qolishini ham ko'rmaydi. Qolganini klaviatura testi va odam topadi.

Bu darsda

  • Komponent testida a11y'ni rol, nom, tavsif va holat bilan tekshirasiz.
  • axe-core natijasini (violations, incomplete, passes) o'qiysiz va qoida teglarini tanlaysiz.
  • @axe-core/playwright bilan sahifa testini yozasiz; jest-axe va vitest-axe holatini bilasiz.
  • user-event va Playwright bilan klaviatura oqimini sinaysiz: Tab tartibi, Esc, fokus qaytishi.
  • Avtomatika nimani ko'rmasligini misollarda ko'rasiz va qo'lda tekshiruvga nima qolishini aytasiz.

Oldin bilishingiz kerak: Accessibility'ni tekshirish, Fokus boshqaruvi va klaviatura navigatsiyasi, RTL I, RTL II, Playwright II, Storybook II.

1. Nega bu kerak?

Accessibility'ni tekshirish darsida axe DevTools'ni qo'lda bosgan edik va va'da qilgan edik: "axe-core'ni avtomatik testlarga ulaymiz". Qo'lda tekshiruvning muammosi bitta — uni har safar kimdir eslashi kerak. Sardor yangi tugma qo'shdi, ikonkasini chiroyli qildi, aria-label ni unutdi. Malika ekran o'quvchida faqat "tugma" eshitadi. Buni kim, qachon sezadi?

vazifalar da axe 13-qismdan beri ishlaydi — lekin kurs tomonidagi tekshiruvda. Nega test yozamiz darsidagi testlar xaritasida a11y qatori 0 edi: o'quvchi reposida bitta ham a11y testi yo'q. Bu darsda shu qatorni to'ldiramiz.

A11y testi bitta vosita emas. Uch qatlam bor va ular bir-birini to'ldiradi:

flowchart TB
  A["1. Rol so'rovlari<br/>getByRole, toHaveAccessibleName"] --> D["Har komponent testida"]
  B["2. axe-core qoidalari<br/>nom, label, kontrast, ARIA"] --> E["Sahifa yoki story"]
  C["3. Klaviatura oqimi<br/>Tab, Enter, Esc, fokus"] --> F["Komponent va e2e"]
  G["4. Odam<br/>ekran o'quvchi, 200% zoom"] --> H["Relizdan oldin"]

Rasmda: yuqoridagi uchtasi avtomatik, to'rtinchisi — qo'lda. Pastga tushgan sari tekshiruv qimmatroq, lekin ko'proq narsani ko'radi.

2. Birinchi qatlam: komponent testi allaqachon a11y testi

2.1 Nom, tavsif va holat

RTL I da ko'rdik: nomsiz tugmani getByRole topa olmaydi. Endi buni ataylab ishlatamiz. Maydonning to'rt a11y xususiyatini tekshiramiz: nomi (label), tavsifi (aria-describedby dagi yordam matni), holati (aria-invalid) va fokusi.

«Bahor» SMS-xabarnomaga yozilish formasi:

jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRef, useState } from "react";
import { expect, test } from "vitest";

function SmsForm() {
  const [error, setError] = useState("");
  const input = useRef(null);
  function submit(e) {
    e.preventDefault();
    if (!/^\+998\d{9}$/.test(input.current.value)) {
      setError("Raqam +998 bilan boshlanib, 12 raqam bo'lsin");
      input.current.focus();
    }
  }
  return (
    <form onSubmit={submit} noValidate>
      <label htmlFor="tel">Telefon</label>
      <input
        id="tel"
        ref={input}
        aria-invalid={error ? "true" : undefined}
        aria-describedby={
          error ? "tel-yordam tel-xato" : "tel-yordam"
        }
      />
      <p id="tel-yordam">Aksiyalar haqida SMS keladi</p>
      <p id="tel-xato" role="alert">
        {error}
      </p>
      <button>Yozilish</button>
    </form>
  );
}

test("bo'sh forma — xato e'lon, fokus joyida", async () => {
  const user = userEvent.setup();
  render(<SmsForm />);
  const tel = screen.getByRole("textbox", { name: "Telefon" });
  expect(tel).toHaveAccessibleDescription(
    "Aksiyalar haqida SMS keladi",
  );
  expect(tel).not.toBeInvalid();
  await user.click(screen.getByRole("button", { name: "Yozilish" }));
  expect(tel).toBeInvalid();
  expect(tel).toHaveFocus();
  const alert = screen.getByRole("alert");
  console.log(alert.textContent);
  // ikkala matn birga, bo'sh joy bilan ulanadi
  expect(tel).toHaveAccessibleDescription(
    /^Aksiyalar haqida SMS keladi Raqam .*12 raqam/,
  );
});

Konsolda:

text
Raqam +998 bilan boshlanib, 12 raqam bo'lsin
  ✓ bo'sh forma — xato e'lon, fokus joyida
Testlar: 1 o'tdi (jami 1)

Test Malika eshitadigan hamma narsani tekshirdi:

  • getByRole("textbox", { name: "Telefon" }) — maydonning nomi bor (label ulangan).
  • toHaveAccessibleDescription — ekran o'quvchi nomdan keyin o'qiydigan matn. Xato chiqqach, aria-describedby ikki id ni ko'rsatadi: "tel-yordam tel-xato". Brauzer ikkala matnni ro'yxat tartibida, bo'sh joy bilan ulaydi — Malika avval yordam matnini, keyin xatoni eshitadi. Yordam matni yo'qolmaydi: xatoni tuzatayotganda ham u kerak.
  • toBeInvalid — aria-invalid="true": "maydon xato" deb e'lon qilinadi.
  • toHaveFocus — Bobur Tab bosib xatoni qidirmaydi, fokus o'zi maydonga qaytdi.

Bu oddiy komponent testi. Lekin uning har qatori a11y talabidan kelib chiqqan. Bu — birinchi va eng arzon qatlam.

2.2 Label olib tashlansa

Endi htmlFor ni unutgan versiya. Rol so'rovi buni darhol ko'radi:

jsx
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";

function BrokenForm() {
  return (
    <form>
      <label>Telefon</label>
      <input id="tel" placeholder="+998 90 123 45 67" />
    </form>
  );
}

test("label ulanmagan — nom yo'q", () => {
  render(<BrokenForm />);
  const field = screen.getByPlaceholderText("+998 90 123 45 67");
  console.log(field.labels.length);
  expect(() =>
    screen.getByRole("textbox", { name: "Telefon" }),
  ).toThrow("Unable to find an accessible element");
});

Konsolda:

text
0
  ✓ label ulanmagan — nom yo'q
Testlar: 1 o'tdi (jami 1)

labels.length — 0: <label> matni ko'rinadi, lekin maydonga ulanmagan. Ko'zi ko'radigan odam farqni sezmaydi. Ekran o'quvchi esa maydonga kelganda faqat "tahrirlash maydoni" deydi. Testni getByPlaceholderText bilan yozsak — o'tib ketardi va hech kim bilmasdi. RTL I dagi ustuvorlik tartibining ma'nosi shu.

Tekshirib ko'ring: Tugmada faqat ikonka bor va title="O'chirish". getByRole("button", { name: "O'chirish" }) uni topadimi? Bu yaxshi yechimmi?

Javob

Topadi: title ham kirish nomi manbai (eng oxirgisi). Lekin yaxshi emas: title faqat sichqoncha ustida turganda ko'rinadi, telefonda va klaviaturada ko'rinmaydi. aria-label="O'chirish" yoki ko'rinmas matn (sr-only) aniqroq.

3. Ikkinchi qatlam: axe-core

3.1 axe qanday ishlaydi

axe-core — Deque kompaniyasining ochiq kodli a11y dvigateli. axe DevTools kengaytmasi, Lighthouse'ning a11y bo'limi va Storybook a11y addon'i — hammasi ichida shu kutubxona. U DOM'ni olib, qoidalar (rules) bo'yicha tekshiradi — bizning sinovimizda 89 ta qoida ishladi. Natija to'rt guruh:

Guruh Ma'nosi
violations aniq buzilish — test yiqilishi kerak
incomplete axe ishonchi komil emas — "odam ko'rsin"
passes tekshirildi, toza
inapplicable qoidaga mos element yo'q (masalan, sahifada rasm yo'q)

Har buzilishda: id (qoida nomi, masalan button-name), impact (critical, serious, moderate, minor), nodes (qaysi elementlar, CSS selektor bilan) va helpUrl (Deque hujjati). Qoidalar teglar bilan guruhlangan: wcag2a, wcag2aa, wcag21aa, wcag22aa (WCAG darajalari), best-practice (standart talab qilmaydi, lekin tavsiya).

H-79 dagi tamoyilni eslang: axe noto'g'ri signal bermaslikka harakat qiladi. Ishonchi komil bo'lmasa — violations ga emas, incomplete ga qo'yadi. Bu muhim: incomplete bo'sh bo'lmasa, test o'tadi, lekin kimdir qarashi kerak.

3.2 Komponent darajasida: jest-axe va vitest-axe

Komponent testida axe'ni ulaydigan ikki mashhur paket bor. Ularning holati (2026-10-20, npm):

Paket Oxirgi versiya Holat
jest-axe 11.0.0 (2026-07-26) faol; toHaveNoViolations matcheri; axe-core 4.12.1 ga qotirilgan
vitest-axe 0.1.0 (2022-10-21) barqaror versiya eski; 1.0.0-pre.5 (2025-01) — sinov versiyasi

Kanon vitest-axe ni olmadi: barqaror versiyasi 2022 yilniki. Lekin bu axe'ni komponent testida ishlatib bo'lmaydi degani emas — axe-core ning o'zi yetadi. Biz Vitest 5.0.4 + jsdom 30.1.2 + axe-core 4.13.0 bilan alohida papkada sinab ko'rdik (kanon emas):

jsx
import { render } from "@testing-library/react";
import axe from "axe-core";
import { test } from "vitest";

test("axe nimani ko'radi", async () => {
  const { container } = render(
    <form>
      <input id="a" placeholder="Taom qidirish" />
      <input id="b" />
      <div onClick={() => {}}>Buyurtma berish</div>
      <span tabIndex={3}>Savat</span>
    </form>,
  );
  const n = await axe.run(container);
  console.log(n.violations.map((v) =>
    `${v.id}: ${v.nodes.map((x) => x.target.join(" ")).join(", ")}`));
  const label = n.passes.find((v) => v.id === "label");
  console.log("label passes:", label?.nodes.map((x) => x.target[0]));
});

Terminalda:

text
Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package
stdout | t/axe2.test.jsx > placeholder va label
[ 'label: #b', 'tabindex: span' ]
label passes: [ '#a' ]

To'rt elementdan axe ikkitasini topdi:

  • label: #b — nomsiz maydon. To'g'ri.
  • tabindex: span — musbat tabIndex Tab tartibini buzadi (Fokus boshqaruvi). To'g'ri.
  • #a o'tdi. Faqat placeholder li maydonni axe nomli deb hisobladi: placeholder ham kirish nomi manbai. Bizning ustuvorlik tartibimizda esa bu zaif joy — yozish boshlanganda placeholder yo'qoladi.
  • <div onClick> — jim. Sichqonchada ishlaydigan, klaviaturada ishlamaydigan "tugma". axe buni ko'rmadi: div da hech qanday ARIA xato yo'q, u shunchaki matnli blok.

Birinchi qator ham saboq: jsdom'da <canvas> yo'q. axe kontrastni hisoblash uchun canvas'dan foydalanadi, shuning uchun color-contrast qoidasi jsdom'da doim incomplete ga tushadi. Kontrast — faqat haqiqiy brauzerda (Playwright, Storybook, Browser Mode).

Maslahat: Komponent darajasidagi axe arzon, lekin jsdom chegaralangan. Kontrast, ko'rinish va fokus halqasi kabi narsalar uchun axe'ni haqiqiy brauzerda ishlating.

Natija oynasida axe-core yuklanmaydi — yuqoridagi blok faqat terminal uchun. Saytda shu g'oyani rol so'rovlari bilan ko'rdik — «Birinchi qatlam» bo'limida.

3.3 Sahifa darajasida: @axe-core/playwright

Haqiqiy brauzerda butun sahifani tekshirish — Deque'ning rasmiy paketi @axe-core/playwright. API — quruvchi (builder):

ts
import AxeBuilder from "@axe-core/playwright";

const natija = await new AxeBuilder({ page })
  .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
  .include("main") // faqat shu qism (ixtiyoriy)
  .exclude(".reklama") // tashqi vidjetni chiqarish
  .disableRules(["color-contrast"]) // faqat sabab bilan!
  .analyze();

analyze() — o'sha to'rt guruhli natija. Uni testda qanday tekshirish kerak? Eng sodda yo'l expect(natija.violations).toEqual([]) — lekin yiqilganda u har buzilishni 50 qatorlik obyekt sifatida chiqaradi (yuqoridagi button-name xatosida biz shunday uzun farqni ko'rdik). Kanon yaxshiroq yo'l tanladi: buzilishni bitta satrga aylantiradi — qoida va element. Bu «Vazifalar qadami» da.

Tekshirib ko'ring: disableRules(["color-contrast"]) qachon to'g'ri, qachon xato?

Javob

Xato — "test qizil, tez yashil bo'lsin" deb o'chirsangiz: muammo qoladi, test esa yolg'on gapiradi. To'g'ri — aniq sabab bo'lsa va izohda yozilsa. Masalan, tashqi vidjet sizning nazoratingizda emas (unda exclude aniqroq) yoki axe SVG grafikda fonni aniqlay olmaydi (incomplete). Har o'chirish — izoh va TEXNIK-QARZ qatori bilan.

3.4 Story darajasida

Storybook II da addon-a11y ni ulagan edik: har story render bo'lgach axe ishlaydi, a11y: { test: "error" } bilan buzilish testni yiqitadi. Kanon u yerda nomsiz tugma story'sini ataylab yozib ko'rgan — button-name bilan yiqildi. Story darajasi bitta komponentning hamma holatini (bo'sh, xato, yuklanmoqda) tekshiradi; sahifa darajasi — ular birga qanday ishlashini.

Qatlam Vosita Kontrast Fokus va Tab
Komponent (jsdom) axe-core / jest-axe yo'q (incomplete) user-event bilan
Story (Chrome) addon-a11y bor play bilan
Sahifa (Chrome) @axe-core/playwright bor Playwright keyboard

4. Avtomatika chegarasi

4.1 axe nimani ko'rmaydi

H-79 da aytgan edik: avtomat vositalar muammolarning taxminan uchdan biridan yarmigacha qismini topadi. Qolgani xulq va ma'no:

axe topadi axe topa olmaydi
nomsiz tugma, label'siz maydon <div onClick> — klaviaturada ishlamaydigan "tugma"
noto'g'ri ARIA atribut va rol dialog yopilganda fokus yo'qolishi
kontrast (brauzerda) Tab tartibi mantiqsiz (lekin texnik to'g'ri)
alt yo'q rasm alt="rasm" — bor, lekin ma'nosiz
musbat tabindex xato e'lon qilinmaydi yoki kech e'lon qilinadi

O'ng ustundagilar uchun xulq testi kerak: "Bobur faqat klaviatura bilan buyurtma bera oladimi?".

4.2 Klaviatura bilan buyurtma

Bir xil ko'rinadigan ikki "tugma": biri <div>, biri <button>. axe ikkalasida jim. Klaviatura testi esa farqni ko'radi:

jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";

function DivButton({ onOrder }) {
  return <div onClick={onOrder}>Buyurtma berish</div>;
}

function RealButton({ onOrder }) {
  return <button onClick={onOrder}>Buyurtma berish</button>;
}

for (const [name, Order] of [
  ["div", DivButton],
  ["button", RealButton],
]) {
  test(`${name}: Tab + Enter bilan buyurtma`, async () => {
    const user = userEvent.setup();
    const onOrder = vi.fn();
    render(<Order onOrder={onOrder} />);
    await user.tab();
    console.log(name, "fokusda:", document.activeElement.tagName);
    await user.keyboard("{Enter}");
    expect(onOrder).toHaveBeenCalledTimes(1);
  });
}

Konsolda:

text
div fokusda: BODY
  × div: Tab + Enter bilan buyurtma
    → expected "vi.fn()" to be called 1 times, but got 0 times
button fokusda: BUTTON
  ✓ button: Tab + Enter bilan buyurtma
Testlar: 1 yiqildi, 1 o'tdi (jami 2)

div ga Tab bilan yetib bo'lmaydi — fokus BODY da qoldi. user.tab() brauzerdagi Tab tugmasini taqlid qiladi: keyingi fokuslanadigan elementga o'tadi. user.keyboard("{Enter}") — fokusdagi elementga Enter. <button> da Enter va Space'ni brauzer o'zi click ga aylantiradi, div da — yo'q.

div ga tabIndex={0} va role="button" qo'shsangiz, Tab yetib boradi, lekin Enter baribir ishlamaydi — onKeyDown ni ham o'zingiz yozishingiz kerak. To'g'ri yechim bitta: <button>.

4.3 Fokus qaytishi

Fokus boshqaruvi darsidagi qoida: menyu yoki dialog yopilganda fokus uni ochgan tugmaga qaytadi. Aks holda fokus body ga tushadi va Bobur sahifa boshidan yana Tab bosadi. Menyu Esc bilan yopiladi — bir versiyada fokus qaytadi, ikkinchisida yo'q:

jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRef, useState } from "react";
import { describe, expect, test } from "vitest";

function SortMenu({ returnFocus }) {
  const [open, setOpen] = useState(false);
  const trigger = useRef(null);
  function close() {
    setOpen(false);
    if (returnFocus) trigger.current.focus();
  }
  return (
    <div>
      <button
        ref={trigger}
        aria-expanded={open}
        onClick={() => setOpen(!open)}
      >
        Saralash
      </button>
      {open && (
        <ul onKeyDown={(e) => e.key === "Escape" && close()}>
          <li>
            <button autoFocus>Narx bo'yicha</button>
          </li>
        </ul>
      )}
    </div>
  );
}

describe.each([[false], [true]])("returnFocus=%s", (returnFocus) => {
  test("Esc — menyu yopiladi, fokus tugmada", async () => {
    const user = userEvent.setup();
    render(<SortMenu returnFocus={returnFocus} />);
    const trigger = screen.getByRole("button", { name: "Saralash" });
    await user.click(trigger);
    expect(trigger).toHaveAttribute("aria-expanded", "true");
    const first = screen.getByRole("button", { name: /Narx/ });
    expect(first).toHaveFocus();
    await user.keyboard("{Escape}");
    expect(trigger).toHaveAttribute("aria-expanded", "false");
    expect(trigger).toHaveFocus();
  });
});

Konsolda:

text
  × returnFocus=false > Esc — menyu yopiladi, fokus tugmada
    → expected <button> to have focus, but focus is on <body>
  ✓ returnFocus=true > Esc — menyu yopiladi, fokus tugmada
Testlar: 1 yiqildi, 1 o'tdi (jami 2)

Birinchi versiyada menyu yopildi, aria-expanded to'g'ri — axe uchun hammasi joyida. Lekin fokus yo'q bo'lib ketdi: fokusdagi tugma DOM'dan o'chdi. Xabar aniq aytadi: fokus <body> da. Buni faqat xulq testi ko'radi.

Tekshirib ko'ring: Nega testda user.click dan keyin toHaveFocus() ni menyu bandi uchun tekshirdik? Bu talabmi?

Javob

Ha — APG menyu naqshi (ARIA patternlar): menyu ochilganda fokus birinchi bandga o'tadi, shunda strelka va Enter darhol ishlaydi. Test talabni yozib qo'ydi: kimdir autoFocus ni olib tashlasa, test aytadi.

4.4 Tab tartibi

Musbat tabIndex ni axe topdi. Lekin uning zarari qanday ko'rinadi? Tab'ni ketma-ket bosib, fokus yo'lini yozamiz:

jsx
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { test } from "vitest";

function Header() {
  return (
    <header>
      <a href="#menyu">Menyu</a>
      <a href="#bron">Bron</a>
      <button tabIndex={1}>Savat (2)</button>
      <input aria-label="Qidirish" />
    </header>
  );
}

test("Tab yo'li", async () => {
  const user = userEvent.setup();
  render(<Header />);
  const path = [];
  for (let i = 0; i < 4; i++) {
    await user.tab();
    const el = document.activeElement;
    path.push(el.textContent || el.getAttribute("aria-label"));
  }
  console.log(path.join(" → "));
});

Konsolda:

text
Savat (2) → Menyu → Bron → Qidirish
  ✓ Tab yo'li
Testlar: 1 o'tdi (jami 1)

Ko'zga «Savat» uchinchi turibdi, Tab esa birinchi unga boradi. Malika va Bobur uchun sahifa "sakraydi". Testda yo'lni massiv bilan tekshirsangiz (expect(path).toEqual(["Menyu", "Bron", "Savat (2)", "Qidirish"])) — tartib talabga aylanadi.

5. Qo'lda nima qoladi

Avtomatik qatlamlar qanchalik ko'p bo'lmasin, ba'zi savollarga faqat odam javob beradi:

  • Ekran o'quvchi bilan haqiqiy vazifa (NVDA, VoiceOver, TalkBack): "Malika buyurtma bera oladimi?". Testlar alohida xususiyatlarni tekshiradi, odam esa butun yo'lni.
  • Ma'no: alt rasmni to'g'ri tasvirlaydimi, xato matni tushunarlimi, tugma nomi harakatni aytadimi ("Bosing" emas, "Buyurtma berish").
  • 200% kattalashtirish va 320 px en — matn kesilmaydimi (H-79).
  • incomplete ro'yxati — axe "qarang" degan joylar.
  • Harakat va vaqt: animatsiya prefers-reduced-motion da to'xtaydimi, xabar o'qib bo'linguncha yo'qolmaydimi.

Amaliy tartib: har PR'da avtomatik qatlamlar (CI), har yangi vidjet yoki sahifada klaviatura va ekran o'quvchi bilan 5 daqiqa, relizdan oldin to'liq tekshiruv ro'yxati.

6. Vazifalar qadami: axe va klaviatura oqimi (holat 14)

6.1 O'rnatish va narx

bash
git switch -c test/a11y
npm i -D -E @axe-core/playwright@4.13.0

Versiya tanlovi halol: @axe-core/playwright 4.13.0 — 2026-08-11 reliz. 4.14.0 ham bor, lekin u 2026-10-15 da chiqqan — loyiha qoidasi bo'yicha 7 kundan yangi reliz olinmaydi.

Narx: +2 paket, +3.1 MB node_modules. Bitta nozik joy: @axe-core/playwright 4.13.0 axe-core ~4.13.0 ni talab qiladi, Storybook II dagi addon-a11y esa axe-core 4.14.0 ni olib kelgan. npm ularni birlashtira olmaydi — axe-core ikki nusxada turadi. Bu TEXNIK-QARZ 72-qatoriga yozildi: 4.14.0 ga ko'tarilganda bitta nusxa qoladi. Ilova bundle'i o'zgarmaydi: JS 206.7 KB gzip. Qamrov ham o'zgarmadi — e2e testlar coverage'ga kirmaydi (TEXNIK-QARZ 74).

6.2 e2e/a11y.spec.ts: axe qismi

Fayl boshidagi izoh — darsning qisqa mazmuni (qatorlari telefon uchun bo'lingan):

ts
// a11y.spec.ts — 22/#14: accessibility testlari. Ikki qism:
// 1) axe-core (@axe-core/playwright) — avtomatik qoidalar
//    (WCAG 2.2 AA + best-practice): kontrast, nomlar, rollar,
//    ro'yxat tuzilishi … Avtomatika xatolarning taxminan uchdan
//    birini topadi — qolgani:
// 2) klaviatura oqimi — sichqonchasiz foydalanuvchi yo'li,
//    fokus qayerda

Teglar va yordamchi funksiya:

ts
// Zavod tekshiruvi (olchov/a11y.mjs) bilan bir xil teglar
const TEGLAR = [
  "wcag2a",
  "wcag2aa",
  "wcag21a",
  "wcag21aa",
  "wcag22aa",
  "best-practice",
];

async function axeBuzilishlari(page: Page) {
  const natija = await new AxeBuilder({ page })
    .withTags(TEGLAR)
    .analyze();
  // Xato bo'lsa — qaysi qoida va qaysi element (hisobotda o'qiladi)
  return natija.violations.map(
    (b) =>
      `${b.id}: ${b.nodes.map((n) => n.target.join(" ")).join(", ")}`,
  );
}

Kurs tomonidagi tekshiruv qaysi teglar bilan ishlagan bo'lsa, o'quvchi testi ham aynan shular bilan. Endi kurs tekshiruvi "0 buzilish" desa, sizning testingiz ham shuni aytadi — ular bitta o'lchov.

Har test bir holatni ochadi va oxirida bitta qator: expect(await axeBuzilishlari(page)).toEqual([]). Holatlar — 8 ta: ro'yxat yorug' va qorong'i temada (page.emulateMedia({ colorScheme })), buyruqlar oynasi ochiq, o'chirish dialogi ochiq, /sozlamalar, /vazifa/1, /ru/sozlamalar va kirmagan foydalanuvchi uchun /kirish. Hammasida — 0. Ochiq oynalar alohida sinalgani muhim: axe faqat hozir DOM'da bor narsani ko'radi, yopiq dialog uning uchun yo'q.

Testning o'zi ishlashini kanon tekshirdi: «Yangi vazifa» maydonining <label> i test ichida vaqtincha o'chirildi.

text
    - Expected  - 1
    + Received  + 3
    +   "label: #yangi-matn",

Mana nega buzilish satrga aylantirildi: farq bir qatorda — qoida (label) va element (#yangi-matn). Obyektni to'g'ridan solishtirganda shu ma'lumot 50 qator ichida yo'qolardi.

6.3 Klaviatura oqimi

To'rt test — Bobur yo'li:

  • Birinchi Tab — «Ro'yxatga o'tish» havolasi (Fokus boshqaruvi dagi skip link); Enter — fokus havoladan ketadi, keyingi Tab ko'rinadigan elementga tushadi.
  • Ctrl+K → «Statistika» → Enter — /statistika sahifasi, fokus h2 sarlavhada.
  • O'chirish dialogi — tugmada Enter ochadi, fokus «Bekor qilish» da; Esc — dialog yopiladi, fokus tugmaga qaytadi.
  • Bo'sh kirish formasi — Enter: maydonda aria-invalid="true", fokus maydonda, xato matni bo'sh emas.

Dialog testi — «Fokus qaytishi» bo'limidagi menyuning haqiqiy brauzerdagi varianti:

ts
    const tugma = page.getByRole("button", {
      name: "O'chirish: Non olish #bozor",
    });
    await tugma.focus();
    await page.keyboard.press("Enter");
    await expect(
      page.getByRole("button", { name: "Bekor qilish" }),
    ).toBeFocused();
    await page.keyboard.press("Escape");
    await expect(page.getByRole("dialog")).toBeHidden();
    await expect(tugma).toBeFocused();

Bu yerda native <dialog> va showModal() ishlaydi — jsdom'da yo'q narsa (RTL I). toBeFocused() — Playwright'ning web-first tekshiruvi: fokus kelguncha kutadi.

6.4 Birinchi o'tishdagi ikki xato

Testlar birinchi yozilishda ikki joyda yiqildi. Ikkalasi ham foydali saboq:

  1. getByRole("combobox") — 2 ta element. Sahifada buyruqlar oynasining qidiruv maydonidan tashqari tartib <select> i ham bor (select ning roli ham combobox). Yechim — qidiruvni toraytirish: page.getByRole("dialog", { name: "Buyruqlar" }).getByRole("combobox").
  2. Ctrl+K dan keyin darhol yozish — harflar yo'qoldi. Buyruqlar oynasi kech yuklanadi (lazy va code splitting, 20-qismdagi cmdk qarori). Playwright harflarni yozdi, lekin maydon hali yo'q edi. Yechim — avval toBeFocused() ni kutish, keyin yozish. Izohi kodda qoldi.

Ikkinchisi faqat test muammosi emas. Tez yozadigan haqiqiy foydalanuvchi ham sekin internetda birinchi harflarni yo'qotadi. Test buni ko'rsatdi; ilova o'zgarmadi, cheklov izohda.

6.5 Natija va commit

Testlar: Vitest 348, brauzer 6, Storybook 12, e2e 21 (10 + a11y 11), vizual 6. npm test issiq 9.1 s. Yangi paketdan keyingi birinchi ishga tushirish sovuq — 44.1 s (Vite keshi qayta yig'iladi). Kurs tomonidagi axe tekshiruvi ham 0 buzilish. JS 206.7 KB — o'zgarmadi.

bash
git add -A
git commit

Commit (kanon): test: axe (@axe-core/playwright) — 8 holat 0 buzilish; klaviatura oqimi testlari.

7. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
axe 0, lekin klaviaturada ishlamaydi axe xulqni ko'rmaydi Tab/Enter/Esc testi
dialog ichini axe tekshirmadi dialog yopiq edi avval oching, keyin analyze()
jsdom'da color-contrast doim incomplete canvas yo'q kontrast — brauzerda
yiqilganda 50 qatorlik obyekt violations to'g'ridan solishtirilgan id: selektor satrlari
qoida o'chirildi, sababi noma'lum "tez yashil bo'lsin" exclude yoki izoh + TEXNIK-QARZ
getByRole("combobox") — 2 ta <select> ham combobox ota element bilan toraytirish
yozilgan harflar yo'qoldi maydon hali yuklanmagan avval toBeFocused()
placeholder'li maydon o'tdi axe placeholder'ni nom deb sanaydi getByRole(…, { name }) + label

8. Mashqlar

Mashqlar kurs/mashqlar/22/14-a11y-testlar/ da. Jonli yechimlarni «Ishga tushir» bilan sinang.

1-mashq (oson): bo'sh joylarni to'ldiring

  • aria-describedby dagi matnni tekshiradigan matcher: toHaveAccessible
  • aria-invalid="true" ni tekshiradigan matcher: toBe()
  • axe'ning "ishonchim komil emas" guruhi: natija.
  • Keyingi fokuslanadigan elementga o'tish: await user.()
  • Playwright'da axe: new ({ page }).analyze()
Yechim
  • Description — ekran o'quvchi nomdan keyin o'qiydigan tavsif.
  • Invalid — aria-invalid yoki brauzer validatsiyasi xatosi.
  • incomplete — odam qarashi kerak; test yiqilmaydi.
  • tab — user-event dagi Tab tugmasi; { shift: true } — orqaga.
  • AxeBuilder — @axe-core/playwright dan; withTags, include, exclude.

2-mashq (o'rta): bron formasining a11y testi

BookingForm: «Ism» maydoni (label bilan, required), «Mehmonlar soni» (type="number", tavsifi: «1 dan 12 gacha»), «Bron qilish» tugmasi. Ism bo'sh yuborilsa: ism maydoni aria-invalid, xato matni role="alert" da («Ismni yozing»), fokus ism maydonida. Test yozing: (1) mehmonlar maydonining tavsifi; (2) bo'sh yuborishdan keyingi uch holat; (3) ism yozilib yuborilsa — alert bo'sh va maydon aria-invalid emas.

Ishora: noValidate — brauzerning o'z xabarini o'chiradi; toBeInvalid aria-invalid ga qaraydi.

Yechim
jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRef, useState } from "react";
import { expect, test } from "vitest";

function BookingForm() {
  const [error, setError] = useState("");
  const name = useRef(null);
  function submit(e) {
    e.preventDefault();
    if (!name.current.value.trim()) {
      setError("Ismni yozing");
      name.current.focus();
    } else setError("");
  }
  return (
    <form onSubmit={submit} noValidate>
      <label htmlFor="ism">Ism</label>
      <input
        id="ism"
        ref={name}
        required
        aria-invalid={error ? "true" : undefined}
        aria-describedby="ism-xato"
      />
      <p id="ism-xato" role="alert">
        {error}
      </p>
      <label htmlFor="soni">Mehmonlar soni</label>
      <input
        id="soni"
        type="number"
        defaultValue={2}
        aria-describedby="mehmon-yordam"
      />
      <p id="mehmon-yordam">1 dan 12 gacha</p>
      <button>Bron qilish</button>
    </form>
  );
}

test("tavsif", () => {
  render(<BookingForm />);
  const guests = screen.getByRole("spinbutton", {
    name: "Mehmonlar soni",
  });
  expect(guests).toHaveAccessibleDescription("1 dan 12 gacha");
});

test("bo'sh ism — xato, fokus, e'lon", async () => {
  const user = userEvent.setup();
  render(<BookingForm />);
  const submit = screen.getByRole("button", { name: "Bron qilish" });
  await user.click(submit);
  const name = screen.getByRole("textbox", { name: "Ism" });
  expect(name).toBeInvalid();
  expect(name).toHaveFocus();
  console.log(screen.getByRole("alert").textContent);
});

test("ism bor — xato yo'q", async () => {
  const user = userEvent.setup();
  render(<BookingForm />);
  const name = screen.getByRole("textbox", { name: "Ism" });
  await user.type(name, "Dilshod");
  const submit = screen.getByRole("button", { name: "Bron qilish" });
  await user.click(submit);
  expect(screen.getByRole("alert")).toBeEmptyDOMElement();
  expect(name).not.toBeInvalid();
});

Konsolda:

text
  ✓ tavsif
Ismni yozing
  ✓ bo'sh ism — xato, fokus, e'lon
  ✓ ism bor — xato yo'q
Testlar: 3 o'tdi (jami 3)

role="alert" doim DOM'da turadi, faqat matni o'zgaradi — ekran o'quvchilar bo'sh hududga keyin qo'shilgan matnni ishonchliroq e'lon qiladi. required bo'lsa ham toBeInvalid yuborishdan oldin ishlatilmadi: noValidate formada brauzer validatsiyasi faqat checkValidity() da ishlaydi.

3-mashq (qiyin): tablar klaviatura bilan

MenuTabs — uchta tab («Issiq taomlar», «Salatlar», «Ichimliklar») va panel. APG tab naqshi (ARIA patternlar): faqat tanlangan tab Tab tartibida (tabIndex 0, qolganlari −1, roving tabindex); ArrowRight / ArrowLeft — keyingi/oldingi tab (oxiridan boshiga aylanadi), tanlanadi va fokus oladi. Test: (1) birinchi Tab — «Issiq taomlar» ga; (2) ArrowRight ×2 — «Ichimliklar» tanlangan va fokusda, panel matni mos; (3) yana ArrowRight — «Issiq taomlar» ga qaytadi; (4) Tab — tablardan chiqib, panelga o'tadi.

Ishora: user.keyboard("{ArrowRight}"); tanlanganini getByRole("tab", { selected: true }) bilan toping.

Yechim
jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRef, useState } from "react";
import { expect, test } from "vitest";

const tabs = [
  ["Issiq taomlar", "Osh, Manti"],
  ["Salatlar", "Achchiq-chuchuk"],
  ["Ichimliklar", "Ko'k choy"],
];

function MenuTabs() {
  const [active, setActive] = useState(0);
  const refs = useRef([]);
  function onKeyDown(e) {
    const step = { ArrowRight: 1, ArrowLeft: -1 }[e.key];
    if (!step) return;
    const next = (active + step + tabs.length) % tabs.length;
    setActive(next);
    refs.current[next].focus();
  }
  return (
    <div>
      <div role="tablist" aria-label="Menyu" onKeyDown={onKeyDown}>
        {tabs.map(([name], i) => (
          <button
            key={name}
            role="tab"
            id={`tab-${i}`}
            ref={(el) => (refs.current[i] = el)}
            aria-selected={i === active}
            tabIndex={i === active ? 0 : -1}
            onClick={() => setActive(i)}
          >
            {name}
          </button>
        ))}
      </div>
      <div
        role="tabpanel"
        tabIndex={0}
        aria-labelledby={`tab-${active}`}
      >
        {tabs[active][1]}
      </div>
    </div>
  );
}

test("tablar klaviatura bilan", async () => {
  const user = userEvent.setup();
  render(<MenuTabs />);
  await user.tab();
  const hot = screen.getByRole("tab", { name: "Issiq taomlar" });
  expect(hot).toHaveFocus();
  await user.keyboard("{ArrowRight}{ArrowRight}");
  const selected = screen.getByRole("tab", { selected: true });
  console.log(selected.textContent);
  expect(selected).toHaveFocus();
  expect(screen.getByRole("tabpanel")).toHaveTextContent("Ko'k choy");
  await user.keyboard("{ArrowRight}");
  expect(screen.getByRole("tab", { selected: true })).toBe(hot);
  await user.tab();
  expect(screen.getByRole("tabpanel")).toHaveFocus();
});

Konsolda:

text
Ichimliklar
  ✓ tablar klaviatura bilan
Testlar: 1 o'tdi (jami 1)

Oxirgi qadam eng muhimi: Tab tablar orasida yurmaydi, guruhdan chiqadi — roving tabindex shuning uchun. Hamma tabda tabIndex={0} bo'lsa, (4) yiqiladi: fokus «Salatlar» ga tushadi. axe bu farqni ko'rmaydi — ikkala variant ham ARIA bo'yicha to'g'ri.

4-mashq: Vazifalar qadami

test/a11y branch'ida «Vazifalar qadami» dagi o'zgarishlarni qiling: @axe-core/playwright@4.13.0, e2e/a11y.spec.ts (8 ta axe holati, klaviatura oqimi, kirish formasi). Keyin testning o'zini sinang.

Yechim

Tekshiruv ro'yxati:

  • npm run test:e2e — 21 test o'tadi (a11y — 11).
  • npm ls axe-core — ikki versiya (4.13.0 va 4.14.0); TEXNIK-QARZ'ga qator.
  • Sinov: test ichida page.locator('label[for="yangi-matn"]').evaluate((e) => e.remove()) — axe testi label: #yangi-matn bilan yiqiladi. Olib tashlang.
  • Sinov: o'chirish dialogidagi fokus qaytishini vaqtincha buzing (yopilganda tugmaga focus() chaqirilmasin) — klaviatura testi yiqiladi, axe testi esa o'tadi. Shu farq — darsning asosiy fikri. Qaytaring.
  • npm run build — JS 206.7 KB o'zgarmagan.

9. Real ishda

  • Qonun va shartnomalar: Yevropa Ittifoqining European Accessibility Act talablari 2025-06-28 dan amalda; ko'p xalqaro buyurtmachilar WCAG 2.1/2.2 AA ni shartnomaga yozadi. Avtomatik a11y testlari — "biz tekshiramiz" degan da'voning isboti.
  • CI'da: axe testlari e2e bilan birga ishlaydi (Playwright II); yangi buzilish PR'ni to'xtatadi. Katta eski loyihada avval hozirgi buzilishlarni ro'yxatga olib, faqat yangilarini taqiqlash mumkin — keyin ro'yxatni kamaytirish.
  • Dizayn tizimlarida — Storybook a11y addon har komponent holatida (Storybook II); Radix, React Aria kabi kutubxonalar o'z klaviatura testlari bilan keladi.
  • Intervyu savollari: "axe nimani topa olmaydi?", "incomplete nima?", "fokus qaytishini qanday test qilasiz?", "roving tabindex nima?".

Xulosa

  • A11y testi — uch avtomatik qatlam (rol so'rovlari, axe, klaviatura oqimi) va odam.
  • Rol so'rovlari va toHaveAccessibleName/Description, toBeInvalid, toHaveFocus — har komponent testida bepul a11y tekshiruvi.
  • axe-core: violations — yiqitadi, incomplete — odam qarasin; jsdom'da kontrast yo'q, u brauzer ishi.
  • jest-axe faol, vitest-axe barqaror versiyasi 2022 yilniki; sahifa uchun @axe-core/playwright.
  • axe <div onClick>, fokus yo'qolishi va mantiqsiz Tab tartibini ko'rmaydi — user.tab(), user.keyboard() va Playwright keyboard ko'radi.
  • vazifalar: 8 holatda axe 0, klaviatura oqimi 4 test, e2e 21; axe-core ikki nusxa — halol TEXNIK-QARZ.

Keyingi dars: Test strategiyasi, test ma'lumotlari va flaky testlar — nimani soxtalash kerak, test ma'lumotlarini fabrika bilan yaratish va vazifalar dagi haqiqiy beqaror testlarni topib tuzatish.

Manbalar

  • axe-core: Rule descriptions, API (axe.run, natija guruhlari) — github.com/dequelabs/axe-core
  • @axe-core/playwright — AxeBuilder (withTags, include, exclude, disableRules) — github.com/dequelabs/axe-core-npm
  • Playwright: Accessibility testing (playwright.dev/docs/accessibility-testing)
  • Testing Library: ByRole, jest-dom toHaveAccessibleDescription, toBeInvalid; user-event tab, keyboard
  • W3C WAI-ARIA Authoring Practices: Menu Button, Tabs (w3.org/WAI/ARIA/apg)
  • npm reestri (2026-10-20): jest-axe 11.0.0, vitest-axe 0.1.0 / 1.0.0-pre.5, axe-core 4.14.0
  • Haqiqiy chiqishlar: Vitest 5.0.4, jsdom 30.1.2, axe-core 4.13.0, Playwright 1.64.0, Chrome 155, Node 24.21.0 (2026-10-20)
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Accessibility testlari: axe, klaviatura oqimi va avtomatika chegarasi — IlmHamroh