Testing Library ile Erişilebilir Komponent Testi
Erişilebilirlik testleri çoğu ekipte hâlâ “sonra yaparız” listesinde bekliyor. Oysa Testing Library tam da bu noktada devreye giriyor ve sizi kullanıcı gibi düşünmeye zorluyor. Birkaç yıl önce müşteri desteğine gelen “buton tıklanamıyor” şikayetlerinin üçte birinin ekran okuyucu kullanan kullanıcılardan geldiğini fark ettiğimde, test yaklaşımımı kökten değiştirmek zorunda kaldım. Bu yazıda o süreçten öğrendiklerimi paylaşacağım.
Testing Library Neden Farklı?
Enzyme kullandıysanız muhtemelen şöyle testler yazdınız:
wrapper.find('.btn-primary').simulate('click');
expect(wrapper.state('isOpen')).toBe(true);
Bu test tamamen implementasyon detaylarına bağlı. CSS class değişti mi? Test kırılır. State yönetimini Redux’a taşıdınız mı? Test kırılır. Peki kullanıcı gerçekten o butona tıklayabiliyor mu? Hiçbir fikriniz yok.
Testing Library’nin felsefesi şu: “Testleriniz, yazılımınızın nasıl kullanıldığına ne kadar benzerse, o kadar güven verirler.” Bu prensip erişilebilirlik ile doğrudan örtüşüyor çünkü ekran okuyucu kullanan biri de getByRole, getByLabelText gibi sorgularla içeriğe ulaşıyor. Aynı şekilde siz de testlerinizde bu sorgularla elemanlara ulaşıyorsunuz.
Kurulum ve Temel Yapılandırma
React projesi için kurulum oldukça basit:
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
jest-dom paketini kurmak kritik çünkü toBeInTheDocument(), toBeVisible(), toHaveAccessibleName() gibi erişilebilirlik odaklı matchers burada tanımlı.
setupTests.js dosyanıza şunu ekleyin:
import '@testing-library/jest-dom';
Eğer Vitest kullanıyorsanız vitest.config.ts içinde:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/setupTests.ts'],
globals: true,
},
});
ARIA Rolleri ile Sorgu Yazmak
Testing Library’de eleman bulmak için en güvenilir yöntem getByRole. Bu sorgu, HTML elementlerinin implicit ARIA rollerini kullanıyor. Yani elemanı otomatik olarak button rolüne sahip, elemanı navigation rolüne sahip.
Şöyle bir navigasyon komponenti düşünelim:
// Navbar.jsx
function Navbar({ user }) {
return (
<nav aria-label="Ana navigasyon">
<ul>
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/settings">Ayarlar</a></li>
{user.isAdmin && (
<li><a href="/admin">Yönetim Paneli</a></li>
)}
</ul>
<button aria-label="Bildirimleri aç" aria-expanded="false">
<span aria-hidden="true">🔔</span>
<span className="visually-hidden">3 okunmamış bildirim</span>
</button>
</nav>
);
}
Bu komponenti test ederken:
import { render, screen } from '@testing-library/react';
import Navbar from './Navbar';
describe('Navbar erişilebilirlik testleri', () => {
const regularUser = { isAdmin: false };
const adminUser = { isAdmin: true };
test('navigasyon landmark doğru etiketlenmiş', () => {
render(<Navbar user={regularUser} />);
// nav elementi "navigation" rolüne sahip, aria-label ile ayırt ediyoruz
const nav = screen.getByRole('navigation', { name: 'Ana navigasyon' });
expect(nav).toBeInTheDocument();
});
test('bildirim butonu erişilebilir isme sahip', () => {
render(<Navbar user={regularUser} />);
// aria-label üzerinden buton bulunabiliyor
const notifButton = screen.getByRole('button', { name: 'Bildirimleri aç' });
expect(notifButton).toHaveAttribute('aria-expanded', 'false');
});
test('admin linki sadece admin kullanıcılara görünür', () => {
const { rerender } = render(<Navbar user={regularUser} />);
expect(screen.queryByRole('link', { name: 'Yönetim Paneli' })).not.toBeInTheDocument();
rerender(<Navbar user={adminUser} />);
expect(screen.getByRole('link', { name: 'Yönetim Paneli' })).toBeInTheDocument();
});
});
Dikkat edin: getByText('🔔') yazmadım. Emoji veya ikon ile eleman bulmak hem kırılgan hem de erişilebilirlik açısından anlamsız. Ekran okuyucu o ikon metnini nasıl okuyorsa, siz de testinizi öyle yazmalısınız.
Form Erişilebilirliği Testi
Form testleri erişilebilirliğin en kritik alanı. Bir kayıt formu üzerinden gidelim:
// RegistrationForm.jsx
function RegistrationForm({ onSubmit }) {
const [errors, setErrors] = useState({});
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const validationErrors = validate(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
onSubmit(Object.fromEntries(formData));
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">
E-posta adresi
<span aria-hidden="true"> *</span>
</label>
<input
id="email"
type="email"
name="email"
aria-required="true"
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={errors.email ? 'true' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">Şifre</label>
<input
id="password"
type="password"
name="password"
aria-describedby="password-hint"
/>
<span id="password-hint">
En az 8 karakter, bir büyük harf ve bir rakam içermeli
</span>
</div>
<button type="submit">Kayıt ol</button>
</form>
);
}
Test tarafında:
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import RegistrationForm from './RegistrationForm';
describe('RegistrationForm', () => {
test('e-posta alanı label ile ilişkilendirilmiş', () => {
render(<RegistrationForm onSubmit={jest.fn()} />);
// getByLabelText label-input bağlantısını doğrular
const emailInput = screen.getByLabelText(/e-posta adresi/i);
expect(emailInput).toBeInTheDocument();
expect(emailInput).toHaveAttribute('type', 'email');
});
test('geçersiz submit sonrası hata mesajı ekran okuyucuya duyurulur', async () => {
const user = userEvent.setup();
render(<RegistrationForm onSubmit={jest.fn()} />);
const submitButton = screen.getByRole('button', { name: 'Kayıt ol' });
await user.click(submitButton);
await waitFor(() => {
// role="alert" ile işaretlenmiş hata mesajı
const errorAlert = screen.getByRole('alert');
expect(errorAlert).toBeInTheDocument();
});
// Input aria-invalid olarak işaretlenmiş mi?
const emailInput = screen.getByLabelText(/e-posta adresi/i);
expect(emailInput).toHaveAttribute('aria-invalid', 'true');
});
test('şifre alanının açıklama metni var', () => {
render(<RegistrationForm onSubmit={jest.fn()} />);
const passwordInput = screen.getByLabelText('Şifre');
// aria-describedby ile bağlı ipucu metni
expect(passwordInput).toHaveAccessibleDescription(
/en az 8 karakter/i
);
});
});
toHaveAccessibleDescription() matcher’ı çok değerli. aria-describedby bağlantısının gerçekten çalışıp çalışmadığını doğruluyor. DOM’a bakıp “bu ID burada yazıyor” demekten çok daha anlamlı.
Klavye Navigasyonu Testi
Fare kullanamayan kullanıcılar için klavye navigasyonu hayati. userEvent kütüphanesi bunu test etmek için tam donanımlı:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Modal from './Modal';
describe('Modal klavye erişilebilirliği', () => {
test('modal açıldığında focus ilk etkileşilebilir elemana gider', async () => {
const user = userEvent.setup();
render(<Modal isOpen={true} onClose={jest.fn()} title="Onay" />);
const modal = screen.getByRole('dialog');
expect(modal).toBeInTheDocument();
// İlk odaklanabilir eleman başlık veya kapat butonu olmalı
const closeButton = screen.getByRole('button', { name: /kapat/i });
expect(closeButton).toHaveFocus();
});
test('Escape tuşu modal kapatır', async () => {
const user = userEvent.setup();
const handleClose = jest.fn();
render(<Modal isOpen={true} onClose={handleClose} title="Onay" />);
await user.keyboard('{Escape}');
expect(handleClose).toHaveBeenCalledTimes(1);
});
test('Tab ile focus modal içinde döngüsel kalır', async () => {
const user = userEvent.setup();
render(
<Modal isOpen={true} onClose={jest.fn()} title="Dosyayı sil">
<button>İptal</button>
<button>Sil</button>
</Modal>
);
const cancelButton = screen.getByRole('button', { name: 'İptal' });
const deleteButton = screen.getByRole('button', { name: 'Sil' });
const closeButton = screen.getByRole('button', { name: /kapat/i });
// Focus trap kontrolü
closeButton.focus();
await user.tab();
expect(cancelButton).toHaveFocus();
await user.tab();
expect(deleteButton).toHaveFocus();
// Son elementten Tab ile başa dönmeli
await user.tab();
expect(closeButton).toHaveFocus();
});
});
Focus trap testi gerçek hayatta çok sık atlanan bir alan. Özellikle modal ve dropdown bileşenlerinde focus modal dışına kaçarsa ekran okuyucu kullanan kullanıcı kaybolur.
jest-axe ile Otomatik ARIA Denetimi
jest-axe paketi, axe-core motorunu Jest ile birleştiriyor ve bilinen ARIA ihlallerini otomatik tespit ediyor:
npm install --save-dev jest-axe
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('DataTable erişilebilirlik denetimi', () => {
test('tablo WCAG kriterlerini karşılıyor', async () => {
const columns = [
{ key: 'name', header: 'Ad Soyad' },
{ key: 'email', header: 'E-posta' },
{ key: 'role', header: 'Rol' },
];
const data = [
{ name: 'Ahmet Yılmaz', email: '[email protected]', role: 'Admin' },
{ name: 'Fatma Kaya', email: '[email protected]', role: 'Kullanıcı' },
];
const { container } = render(
<DataTable
columns={columns}
data={data}
caption="Kullanıcı listesi"
sortable
/>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
jest-axe sihirli bir çözüm değil, otomatik olarak WCAG ihlallerinin ancak yaklaşık yüzde otuzunu yakalayabiliyor. Ama yakaladıkları genellikle en belirgin ve düzeltmesi kolay olanlar: kontrastsız renkler, eksik label’lar, hatalı heading hiyerarşisi gibi.
Gerçek Dünya Senaryosu: Autocomplete Bileşeni
Karmaşık bir örnek üzerinden gidelim. Combobox pattern’i ARIA açısından en zorlu bileşenlerden biri:
// Autocomplete.test.jsx
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe, toHaveNoViolations } from 'jest-axe';
import Autocomplete from './Autocomplete';
expect.extend(toHaveNoViolations);
const sehirler = ['Ankara', 'İstanbul', 'İzmir', 'Bursa', 'Antalya'];
describe('Autocomplete bileşeni', () => {
test('combobox rolü ve ARIA attributes doğru ayarlanmış', () => {
render(
<Autocomplete
label="Şehir seçin"
options={sehirler}
onChange={jest.fn()}
/>
);
const combobox = screen.getByRole('combobox', { name: 'Şehir seçin' });
expect(combobox).toHaveAttribute('aria-autocomplete', 'list');
expect(combobox).toHaveAttribute('aria-expanded', 'false');
});
test('yazarken listbox açılır ve sonuçlar listelenir', async () => {
const user = userEvent.setup();
render(
<Autocomplete
label="Şehir seçin"
options={sehirler}
onChange={jest.fn()}
/>
);
const combobox = screen.getByRole('combobox', { name: 'Şehir seçin' });
await user.type(combobox, 'an');
// Listbox görünür olmalı
const listbox = screen.getByRole('listbox');
expect(listbox).toBeVisible();
expect(combobox).toHaveAttribute('aria-expanded', 'true');
// "an" ile başlayan şehirler filtrelenmeli
const options = within(listbox).getAllByRole('option');
expect(options).toHaveLength(2); // Ankara, Antalya
// Aktif seçenek aria-selected ile işaretlenmeli
expect(options[0]).toHaveAttribute('aria-selected');
});
test('ok tuşlarıyla navigasyon çalışıyor', async () => {
const user = userEvent.setup();
const handleChange = jest.fn();
render(
<Autocomplete
label="Şehir seçin"
options={sehirler}
onChange={handleChange}
/>
);
const combobox = screen.getByRole('combobox', { name: 'Şehir seçin' });
await user.type(combobox, 'a');
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
// İkinci eşleşen seçenek seçilmiş olmalı
expect(handleChange).toHaveBeenCalledWith('Antalya');
expect(combobox).toHaveValue('Antalya');
// Listbox kapanmalı
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
test('axe ihlali yok', async () => {
const { container } = render(
<Autocomplete
label="Şehir seçin"
options={sehirler}
onChange={jest.fn()}
/>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
within() yardımcı fonksiyonu çok kullanışlı. Tüm sayfada değil, belirli bir container içinde arama yapmanızı sağlıyor. Listbox içindeki option’ları bulmak için ideal.
Sık Yapılan Hatalar ve Düzeltmeleri
Birkaç hata özellikle çok karşıma çıktı:
getByTestId bağımlılığından kurtulmak
data-testid attribute’u son çare olmalı. Eğer her elemana data-testid eklemek zorunda kalıyorsanız, bu büyük ihtimalle o elemanın erişilebilir bir adı veya rolü olmadığı anlamına geliyor.
Yanlış:
const button = screen.getByTestId('submit-btn');
Doğru:
const button = screen.getByRole('button', { name: 'Formu gönder' });
act() uyarılarını görmezden gelmek
State güncellemelerini test sırasında görmezden gelirseniz hem güvenilmez test yazarsınız hem de gerçek kullanıcı deneyiminden koparsınız. waitFor veya findBy sorgularını kullanın.
Sadece getByText ile test yazmak
// Kırılgan
const heading = screen.getByText('Kullanıcı Listesi');
// Daha güvenilir ve semantik
const heading = screen.getByRole('heading', { name: 'Kullanıcı Listesi', level: 2 });
İkinci versiyon hem heading olduğunu doğrular hem de seviyesini kontrol eder. Biri içine koysa bile test kırılır.
CI/CD Entegrasyonu
Bu testleri pipeline’a entegre etmek basit ama birkaç noktaya dikkat:
# package.json scripts
{
"scripts": {
"test": "jest --coverage",
"test:a11y": "jest --testPathPattern='*.a11y.test'",
"test:ci": "jest --coverage --coverageReporters=lcov --forceExit"
}
}
Erişilebilirlik testlerini ayrı bir dosya convention’ı ile ayırırsanız (*.a11y.test.jsx) hızlı çalıştırabilir ve raporlayabilirsiniz. Coverage raporlarında hangi bileşenlerin erişilebilirlik testi olmadığını görmek motivasyon sağlıyor.
Bir ekipte çalışırken “bu bileşenin axe testi var mı?” kontrol etmek için basit bir ESLint rule bile yazabilirsiniz ama bu ayrı bir yazı konusu.
Sonuç
Testing Library ile erişilebilir komponent testi yazmak başlangıçta alışılmışın dışında hissettiriyor. querySelector veya class selector’lar yerine getByRole ve getByLabelText yazmak, implementasyonu değil kullanıcı deneyimini test etmeye zorluyor.
Bu geçiş sürecinde en pratik başlangıç noktası mevcut testlerinizi gözden geçirip her data-testid kullanımını sorgulamak. Neden testId verme ihtiyacı duydunuz? O eleman erişilebilir bir ada sahip mi? Bu soruları sormaya başladığınızda hem test kaliteniz hem de bileşen erişilebilirliği birlikte yükseliyor.
jest-axe ile otomatik denetim ekleyerek başlayın, sonra kritik kullanıcı akışlarınız için klavye navigasyon testleri yazın. Form submit, modal açma kapama, dropdown seçimi gibi etkileşimler öncelikli hedefleriniz olsun. Ekran okuyucu kullanan kullanıcıların maruz kaldığı sorunların büyük çoğunluğu bu akışlarda ortaya çıkıyor.
Erişilebilirlik bir checkbox değil, sürekli bir pratik. Testing Library bu pratiği günlük geliştirme döngünüzün içine doğal olarak yerleştiriyor.
