Accessibility Testing
Accessibility (a11y) testing ensures your web application is usable by people with disabilities. This includes users who rely on screen readers, keyboard navigation, voice control, and other assistive technologies.
Why Accessibility Testing Matters
- Legal Compliance: Many countries require WCAG compliance for websites
- Broader Audience: Over 1 billion people worldwide have disabilities
- Better UX: Accessible sites are easier to use for everyone
- SEO Benefits: Many a11y improvements also help search engines
- Brand Reputation: Shows commitment to inclusivity
WCAG Guidelines Overview
Web Content Accessibility Guidelines (WCAG) are organized around four principles (POUR):
POUR Principles:
- Perceivable: Information must be presentable to users in ways they can perceive (text alternatives, captions, adaptable content)
- Operable: UI components and navigation must be operable (keyboard accessible, sufficient time, no seizure triggers)
- Understandable: Information and UI operation must be understandable (readable, predictable, input assistance)
- Robust: Content must be robust enough to work with current and future technologies
WCAG Conformance Levels
Level A - Minimum level (essential support)
Level AA - Mid-range level (most sites target this)
Level AAA - Highest level (gold standard)
Automated Testing with axe-core
axe-core is a powerful accessibility testing engine that can find ~57% of WCAG issues automatically.
Installation
# Install axe-core for JavaScript
npm install --save-dev @axe-core/cli axe-core
# Install for Laravel Dusk
composer require --dev duskphp/a11y
Using axe-core with Dusk
<?php
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Duskphp\A11y\AxeRunner;
class AccessibilityTest extends DuskTestCase
{
/** @test */
public function homepage_has_no_accessibility_violations()
{
$this->browse(function (Browser $browser) {
$browser->visit('/')
->assertAccessible(); // Uses axe-core
$violations = $browser->getA11yViolations();
$this->assertCount(0, $violations,
'Found accessibility violations: ' . json_encode($violations, JSON_PRETTY_PRINT)
);
});
}
/** @test */
public function form_has_proper_labels()
{
$this->browse(function (Browser $browser) {
$browser->visit('/contact')
->assertAccessible([
'label', // All form inputs must have labels
'color-contrast', // Sufficient color contrast
'button-name' // Buttons must have accessible names
]);
});
}
}
Running axe-core from Command Line
# Test a single page
npx axe http://localhost:8000
# Test multiple pages
npx axe http://localhost:8000 http://localhost:8000/about --save results.json
# Test specific WCAG level
npx axe http://localhost:8000 --tags wcag2a,wcag2aa
Testing with WAVE (Web Accessibility Evaluation Tool)
WAVE provides visual feedback about accessibility issues directly on your page.
Using WAVE Browser Extension
WAVE Usage:
- Install WAVE extension for Chrome or Firefox
- Navigate to your page
- Click WAVE icon
- Review errors, alerts, and features
- Fix issues and re-test
Integrating WAVE API into Tests
<?php
use Illuminate\Support\Facades\Http;
/** @test */
public function check_accessibility_with_wave_api()
{
$url = 'https://yoursite.com/page';
$waveApiKey = env('WAVE_API_KEY');
$response = Http::get('https://wave.webaim.org/api/request', [
'key' => $waveApiKey,
'url' => $url,
'reporttype' => '4' // JSON format
]);
$result = $response->json();
$this->assertEquals(0, $result['categories']['error']['count'],
'Found ' . $result['categories']['error']['count'] . ' accessibility errors'
);
// Check for specific issues
$this->assertArrayNotHasKey('alt_missing', $result['categories']['error']['items']);
$this->assertArrayNotHasKey('label_missing', $result['categories']['error']['items']);
}
Keyboard Navigation Testing
All functionality must be accessible via keyboard alone (no mouse required).
Testing Tab Order
<?php
/** @test */
public function form_has_logical_tab_order()
{
$this->browse(function (Browser $browser) {
$browser->visit('/register')
->keys('body', '{tab}')
->assertFocused('#name')
->keys('#name', '{tab}')
->assertFocused('#email')
->keys('#email', '{tab}')
->assertFocused('#password')
->keys('#password', '{tab}')
->assertFocused('#password_confirmation')
->keys('#password_confirmation', '{tab}')
->assertFocused('button[type="submit"]');
});
}
/** @test */
public function navigation_menu_is_keyboard_accessible()
{
$this->browse(function (Browser $browser) {
$browser->visit('/')
->keys('body', '{tab}') // Focus on first link
->keys(null, '{enter}') // Activate link
->assertPathIs('/about');
});
}
Testing Focus Visibility
<?php
/** @test */
public function focused_elements_are_visually_indicated()
{
$this->browse(function (Browser $browser) {
$browser->visit('/')
->keys('body', '{tab}')
->assertScript(
'return window.getComputedStyle(document.activeElement).outline !== "none"',
'Focused element should have visible outline'
);
});
}
Screen Reader Testing
Screen readers announce content to visually impaired users. Testing ensures proper semantic structure and ARIA attributes.
Common Screen Readers
- NVDA (Windows) - Free, widely used
- JAWS (Windows) - Commercial, industry standard
- VoiceOver (macOS/iOS) - Built-in Apple screen reader
- TalkBack (Android) - Built-in Android screen reader
Testing Semantic HTML
<?php
/** @test */
public function page_has_proper_heading_structure()
{
$this->browse(function (Browser $browser) {
$browser->visit('/blog');
// Must have exactly one h1
$this->assertEquals(1, $browser->elements('h1')->count());
// Headings should be in logical order
$headings = $browser->script('
return Array.from(document.querySelectorAll("h1, h2, h3, h4, h5, h6"))
.map(h => parseInt(h.tagName.charAt(1)))
');
for ($i = 1; $i < count($headings); $i++) {
$this->assertLessThanOrEqual(
$headings[$i - 1] + 1,
$headings[$i],
'Heading levels should not skip (found h' . $headings[$i - 1] . ' then h' . $headings[$i] . ')'
);
}
});
}
/** @test */
public function images_have_alt_text()
{
$this->browse(function (Browser $browser) {
$browser->visit('/products');
$imagesWithoutAlt = $browser->script('
return Array.from(document.querySelectorAll("img"))
.filter(img => !img.hasAttribute("alt"))
.length
');
$this->assertEquals(0, $imagesWithoutAlt,
'All images must have alt attributes'
);
});
}
/** @test */
public function form_inputs_have_labels()
{
$this->browse(function (Browser $browser) {
$browser->visit('/contact');
$unlabeledInputs = $browser->script('
return Array.from(document.querySelectorAll("input, select, textarea"))
.filter(input => {
const id = input.id;
const ariaLabel = input.getAttribute("aria-label");
const ariaLabelledBy = input.getAttribute("aria-labelledby");
const label = id ? document.querySelector(`label[for="${id}"]`) : null;
return !label && !ariaLabel && !ariaLabelledBy;
})
.length
');
$this->assertEquals(0, $unlabeledInputs,
'All form inputs must have associated labels'
);
});
}
Testing ARIA Attributes
<?php
/** @test */
public function interactive_elements_have_roles()
{
$this->browse(function (Browser $browser) {
$browser->visit('/dashboard');
// Buttons should have button role
$nonButtonElements = $browser->script('
return Array.from(document.querySelectorAll("[role=\"button\"]"))
.filter(el => el.tagName !== "BUTTON")
.every(el => el.hasAttribute("tabindex") && el.hasAttribute("role"))
');
$this->assertTrue($nonButtonElements);
});
}
/** @test */
public function expandable_content_has_proper_aria()
{
$this->browse(function (Browser $browser) {
$browser->visit('/faq');
// Check accordion buttons
$accordionButtons = $browser->elements('[aria-expanded]');
foreach ($accordionButtons as $button) {
$expanded = $button->getAttribute('aria-expanded');
$controls = $button->getAttribute('aria-controls');
$this->assertContains($expanded, ['true', 'false']);
$this->assertNotEmpty($controls);
// Verify controlled element exists
$this->assertNotNull($browser->element('#' . $controls));
}
});
}
Color Contrast Testing
WCAG requires minimum contrast ratios between text and background.
WCAG Contrast Requirements:
- Level AA: 4.5:1 for normal text, 3:1 for large text
- Level AAA: 7:1 for normal text, 4.5:1 for large text
- Large text: 18pt+ or 14pt+ bold
Testing Contrast Programmatically
<?php
/** @test */
public function text_has_sufficient_contrast()
{
$this->browse(function (Browser $browser) {
$browser->visit('/');
// Use axe-core to check contrast
$violations = $browser->script('
return new Promise((resolve) => {
axe.run(document, {
runOnly: ["color-contrast"]
}, (err, results) => {
resolve(results.violations);
});
});
');
$this->assertCount(0, $violations,
'Found color contrast violations: ' . json_encode($violations)
);
});
}
Testing Skip Links
Skip links allow keyboard users to bypass repetitive navigation.
<?php
/** @test */
public function page_has_skip_to_main_content_link()
{
$this->browse(function (Browser $browser) {
$browser->visit('/')
->assertPresent('a[href="#main-content"]')
->keys('body', '{tab}') // First tab should focus skip link
->assertFocused('a[href="#main-content"]')
->keys(null, '{enter}')
->assertFocused('#main-content');
});
}
Testing Dynamic Content
Screen readers need to be notified of dynamic changes.
Testing ARIA Live Regions
<?php
/** @test */
public function notifications_use_aria_live_regions()
{
$this->browse(function (Browser $browser) {
$browser->visit('/dashboard');
// Verify live region exists
$this->assertNotNull($browser->element('[aria-live="polite"]'));
// Trigger notification
$browser->press('@save-button');
// Verify notification appears in live region
$browser->waitFor('[aria-live] .notification')
->assertSeeIn('[aria-live]', 'Changes saved successfully');
});
}
/** @test */
public function loading_states_are_announced()
{
$this->browse(function (Browser $browser) {
$browser->visit('/products');
$loadingRegion = $browser->element('[aria-busy="true"]');
$this->assertNotNull($loadingRegion);
$this->assertEquals('Loading products...',
$loadingRegion->getAttribute('aria-label')
);
});
}
Testing Modal Dialogs
<?php
/** @test */
public function modal_traps_focus()
{
$this->browse(function (Browser $browser) {
$browser->visit('/')
->click('@open-modal-button')
->waitFor('[role="dialog"]')
->assertAttribute('[role="dialog"]', 'aria-modal', 'true');
// Focus should be trapped in modal
$firstFocusable = $browser->element('[role="dialog"] button:first-of-type');
$lastFocusable = $browser->element('[role="dialog"] button:last-of-type');
// Tab to last element
$browser->keys($lastFocusable, '{tab}');
// Should wrap back to first element
$browser->assertFocused($firstFocusable);
// Escape should close modal
$browser->keys(null, '{escape}')
->waitUntilMissing('[role="dialog"]');
});
}
Comprehensive Accessibility Test Suite
<?php
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
class ComprehensiveA11yTest extends DuskTestCase
{
protected $pagesToTest = [
'/',
'/about',
'/products',
'/contact',
'/blog',
];
/** @test */
public function all_pages_pass_automated_checks()
{
$this->browse(function (Browser $browser) {
foreach ($this->pagesToTest as $url) {
$browser->visit($url);
$violations = $browser->getA11yViolations();
$this->assertCount(0, $violations,
"Page {$url} has violations: " . json_encode($violations, JSON_PRETTY_PRINT)
);
}
});
}
/** @test */
public function all_pages_are_keyboard_navigable()
{
$this->browse(function (Browser $browser) {
foreach ($this->pagesToTest as $url) {
$browser->visit($url);
// Should be able to tab through all interactive elements
$interactiveElements = $browser->script('
return document.querySelectorAll(
"a[href], button, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])"
).length
');
$this->assertGreaterThan(0, $interactiveElements,
"Page {$url} has no interactive elements"
);
}
});
}
}
Practice Exercise:
Create an accessibility test suite for a registration form that verifies:
- All form fields have associated labels
- Error messages are announced to screen readers
- Form is fully keyboard accessible
- Color contrast meets WCAG AA standards
- Required fields are properly indicated
- Success/error states use aria-live regions
Accessibility Testing Checklist:
- Run automated tools (axe-core, WAVE)
- Test keyboard navigation
- Verify semantic HTML structure
- Check color contrast
- Test with actual screen readers
- Verify ARIA attributes
- Test focus management
- Check responsive design
- Test with users who have disabilities (when possible)
Summary
Accessibility testing ensures your application is usable by everyone:
- Automated tools: axe-core and WAVE catch ~57% of issues
- Manual testing: Keyboard navigation and screen readers catch the rest
- WCAG compliance: Target Level AA as minimum standard
- Continuous testing: Integrate a11y tests into CI/CD pipeline
- Real users: Nothing beats testing with actual disabled users