[Field-Ready Complete Guide] Laravel Test Automation & Accessibility Verification — Pest/PHPUnit, Feature/E2E, Dusk, axe/Pa11y, and CI Setup
What you’ll learn (highlights)
- How to choose between Pest and PHPUnit; designing tests for
Feature/Unit/Browser(Dusk) - Practical patterns for factories, seeding, parallel runs, and mocks/fakes (Mail/Queue/Notification/Event)
- How to integrate automated accessibility checks (axe-core/Pa11y) with Dusk/CI
- E2E validation of navigation, focus, live regions, and keyboard operations
- Coverage, failure screenshots, test data strategy, and speed-up tips
- A CI/CD pipeline template using GitHub Actions
Intended readers (who benefits?)
- Laravel beginner to intermediate engineers: grasp the big picture of testing and harden your project against failures
- Tech leads / QA: run E2E and accessibility checks on every pull request
- Designers / writers: bake screen reader guarantees for UI copy and error messages into the process
Accessibility level: ★★★★★
Use Dusk together with axe/Pa11y and automate verification up to
role="status"/aria-live/aria-describedby/ focus moves / keyboard operations. On failure, capture screenshots and logs and store them in CI.
1. Introduction: Tests ensure “robustness” and “accountability”
The goals of testing are to lock down behavior and to provide a safety net for confident change. Laravel ships HTTP testing, database rollbacks, various fakes, and real-browser automation (Dusk). Layering accessibility checks on top lets you continuously guarantee seeable, readable, operable. Sketch the map first, then improve it step by step.
2. Overall design: Roles by test layer
- Unit: Pure logic for small functions/classes. Avoid I/O; runs in milliseconds.
- Feature (HTTP/DB): Routes, validation, authorization, DB writes, notifications—use cases.
- Browser (Dusk): Real browser interactions to verify screens, focus, key ops, and announcements.
- A11y automated checks: Integrate axe/Pa11y with Dusk/CI to catch basic defects.
- Contract/snapshot: Detect breaking changes to API schemas (OpenAPI) or UI text.
In practice, starting around Feature 70% / Unit 20% / E2E 10% balances cost and impact well.
3. Setup: Pest, parallelism, DB, factories
3.1 Install Pest (optional)
composer require pestphp/pest --dev
php artisan pest:install
- Write function-style tests under
tests/Featureandtests/Unit. Can coexist with PHPUnit.
3.2 Test DB and parallel runs
php artisan test --parallel
# or
php artisan test --parallel --processes=4
- Use the
RefreshDatabasetrait for transaction rollbacks or migrations. - For parallel runs, use in-memory SQLite or provision per-process test MySQL databases.
3.3 Factories and state transitions
// database/factories/PostFactory.php
$factory->define(Post::class, fn() => [
'title' => fake()->sentence(),
'body' => fake()->paragraph(),
'status'=> 'draft',
'published_at' => null,
]);
// State
public function published(): static {
return $this->state(fn() => ['status'=>'published','published_at'=>now()]);
}
- Use states to make draft/published explicit; tests become easier to read.
4. Feature tests: Lock down use cases
4.1 Validation & authorization
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('can create an article', function () {
$user = User::factory()->create();
$this->actingAs($user);
$res = $this->post('/posts', ['title'=>'First Post','body'=>'Body']);
$res->assertRedirect('/posts');
$this->assertDatabaseHas('posts', ['title'=>'First Post','user_id'=>$user->id]);
});
test('guest cannot create', function () {
$this->post('/posts', ['title'=>'x','body'=>'y'])->assertRedirect('/login');
});
4.2 JSON API (Sanctum)
test('can fetch list via API', function () {
Sanctum::actingAs(User::factory()->create(), ['posts:read']);
Post::factory()->count(3)->published()->create();
$this->getJson('/api/v1/posts')
->assertOk()
->assertJsonCount(3, 'data')
->assertJsonStructure(['data'=>[['id','title','author'=>['id','name']]]]);
});
4.3 Verify side effects with fakes
test('notification is sent on create', function () {
Notification::fake();
$user = User::factory()->create();
$this->actingAs($user)->post('/posts', ['title'=>'x','body'=>'y']);
Notification::assertSentTo($user, \App\Notifications\PostPublished::class);
});
4.4 Signed URLs & rate limiting
test('expired URL is rejected', function () {
$url = URL::temporarySignedRoute('files.show', now()->subMinute(), ['id'=>1]);
$this->get($url)->assertForbidden();
});
test('comment posting is throttled', function () {
RateLimiter::for('comment', fn()=>[Limit::perMinute(5)->by('t')]);
$this->actingAs(User::factory()->create());
for($i=0;$i<5;$i++){ $this->post('/comments', ['body'=>'hi']); }
$this->post('/comments', ['body'=>'hi'])->assertStatus(429);
});
5. Browser tests (Dusk): Protect the experience—operations & announcements
5.1 Setup
php artisan dusk:install
php artisan dusk
- Chromedriver included. Run headless in CI.
5.2 Basic flow
// tests/Browser/RegisterTest.php
public function test_register_flow()
{
$this->browse(function (Browser $b) {
$b->visit('/register')
->type('#name','Hanako Yamada')
->type('#email','hanako@example.com')
->type('#password','StrongPassw0rd!')
->type('#password_confirmation','StrongPassw0rd!')
->check('agree')
->press('Sign up')
->waitForLocation('/dashboard')
->assertSee('Welcome');
});
}
5.3 Keyboard ops, focus, live regions
public function test_error_summary_focus_and_readable()
{
$this->browse(function (Browser $b) {
$b->visit('/register')
->press('Sign up') // submit empty
->waitFor('#error-title')
->assertFocused('#error-title') // focus moves to the summary
->assertSeeIn('#error-title','Please review your input.')
->assertPresent('[role="alert"]')
->assertAttribute('#email','aria-invalid','true');
});
}
- On errors, ensure focus moves to the error summary, that
role="alert"exists, and that fields havearia-invalid. - Announce progress/results via
role="status"/aria-live. In Dusk, verify presence in the DOM.
5.4 Screenshots & logs
public function test_capture_on_failure()
{
$this->browse(function (Browser $b) {
$b->visit('/')->screenshot('home'); // storage/screenshots/home.png
});
}
- Auto capture on failure is the shortest debug path. Always store as CI artifacts.
6. Automated accessibility checks: Wire axe/Pa11y into Dusk/CI
6.1 Dusk × axe-core (minimal integration)
npm i -D axe-core- Inject
axe.min.jsinto the page from Dusk and evaluate.
public function test_accessibility_smoke()
{
$this->browse(function (Browser $b) {
$b->visit('/register');
// Inject axe
$axe = file_get_contents(base_path('node_modules/axe-core/axe.min.js'));
$b->script($axe.'; void(0);');
// Run and retrieve results
$results = $b->script('return axe.run(document, { resultTypes: ["violations"] });')[0];
// Simple filter for serious/critical
$violations = collect($results['violations'] ?? [])->filter(function($v){
return in_array('serious', $v['impact'] ?? []) || in_array('critical', $v['impact'] ?? []);
});
if ($violations->isNotEmpty()) {
logger()->error('axe.violations', ['violations'=>$violations]);
}
$this->assertTrue($violations->isEmpty(), 'Accessibility violations detected');
});
}
- Start with a smoke that catches severe issues.
- Tweak detailed rules in CI with Pa11y for easier ops.
6.2 Pa11y (CLI) for scheduled checks across pages
npm i -D pa11y pa11y-ci- Create
pa11y-ci.json.
{
"defaults": {
"timeout": 30000,
"standard": "WCAG2AA",
"wait": 1000,
"chromeLaunchConfig": { "args": ["--no-sandbox","--disable-dev-shm-usage"] }
},
"urls": [
"http://localhost:8000/",
"http://localhost:8000/register",
"http://localhost:8000/login",
"http://localhost:8000/posts"
]
}
- In CI, start the Laravel server and run
npx pa11y-ci.
- List pages in JSON. For false positives, configure rule exceptions selectively.
- Use Dusk to cover experiential issues (focus moves, live updates) that Pa11y may miss.
7. Verifying UI text, error copy, and announcements
- Keep error copy short and specific; link inputs via
aria-describedby. - Use
role="status"/role="alert"depending on priority for toasts/banners. - Ensure you’re not “color-only”; add text/icons too—checkable via DOM in Dusk.
- For i18n, verify
<html lang>and language-of-parts (follow your existing policy).
8. Test data design: Realistic, not over-fabricated
- Factory values should look plausible. Follow real formats for address/phone/price.
- Use a builder pattern to compose complex preconditions (e.g., published post + 3 tags + 2 comments).
- For large data performance tests, use Seeder; in E2E, prefer fixed IDs for easy referencing.
9. Speed-ups: Eliminate bottlenecks
- Parallel runs + in-memory SQLite for fast startup.
- Stub external APIs with
Http::fake()to avoid the network. - Focus Dusk on essentials; compress heavy scenarios to 1–3 cases.
- Stabilize feed/list E2E with API mocks, and focus a11y checks on the UI.
10. CI/CD: GitHub Actions template (MySQL/Redis/Node side-by-side)
name: test
on: [push, pull_request]
jobs:
laravel:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8
env:
MYSQL_DATABASE: testing
MYSQL_USER: user
MYSQL_PASSWORD: secret
MYSQL_ROOT_PASSWORD: root
ports: ["3306:3306"]
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -u root -proot"
--health-interval=10s --health-timeout=5s --health-retries=3
redis:
image: redis:alpine
ports: ["6379:6379"]
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, pdo_mysql, intl
coverage: none
- name: Cache composer
uses: actions/cache@v4
with:
path: vendor
key: composer-${{ hashFiles('composer.lock') }}
- run: composer install --no-interaction --prefer-dist
- name: Setup Node
uses: actions/setup-node@v4
with: { node-version: 20, cache: 'npm' }
- run: npm ci
- run: npm run build --if-present
- name: App key & env
run: |
cp .env.example .env
php artisan key:generate
php artisan migrate --force
- name: Pest / PHPUnit
run: php artisan test --parallel --processes=4
- name: Dusk (headless)
run: php artisan dusk --env=dusk.local
env:
APP_URL: http://127.0.0.1:8000
DB_DATABASE: testing
DB_USERNAME: user
DB_PASSWORD: secret
- name: Start server for Pa11y
run: php -S 127.0.0.1:8000 -t public & echo $! > server.pid && sleep 3
- name: Pa11y CI
run: npx pa11y-ci
- name: Upload artifacts (screenshots)
uses: actions/upload-artifact@v4
if: failure()
with:
name: dusk-screens
path: storage/screenshots
- Always upload failure screenshots.
- Enable
intlso i18n/number/date tests pass.
11. Frequently used fake/mock recipes
Mail::fake();
Queue::fake();
Event::fake();
Notification::fake();
Http::fake([
'https://api.example.com/*' => Http::response(['ok'=>true], 200),
]);
Storage::fake('s3'); // verify uploads
- Safely verify you “sent it”. Don’t forget to assert recipient/subject/count.
- For images/PDFs, use dummies and only verify that processing occurred.
12. E2E perspectives that protect “readability”
- Focus ring remains visible (Tab makes it apparent).
- Buttons/links are reachable via keyboard only.
- Use
role="alert"for critical notices;role="status"for regular; defaultaria-livetopolite. - Images have
alt; decorative ones use emptyalt="". - Highly animated elements respect
prefers-reduced-motion. - Don’t rely on color alone; use text/icons/borders as redundant cues.
In Dusk, rely on DOM assertions and, when needed, verify the presence of styles (avoid depending on exact color values).
13. Representative samples (excerpts)
13.1 a11y for validation errors
public function test_validation_errors_have_describedby()
{
$this->browse(function (Browser $b) {
$b->visit('/register')
->press('Sign up')
->waitFor('#error-title')
->assertPresent('#email-error')
->assertAttribute('#email','aria-describedby', fn($v) => str_contains($v,'email-error'));
});
}
13.2 Toast announcement
public function test_toast_announces_status()
{
$this->browse(function (Browser $b) {
$b->visit('/profile')
->press('Save')
->waitFor('[role="status"]')
->assertSeeIn('[role="status"]','Saved');
});
}
13.3 Alternative text
test('images have alt text', function () {
$html = $this->get('/')->getContent();
expect($html)->toContain('alt=');
});
14. Pitfalls and workarounds
- Trying to do everything with only Dusk → slow and flaky. Make Feature tests central and cover key experiences with Dusk.
- Relying solely on automated a11y checks → focus moves and live updates are hard to auto-detect. Complement with E2E.
- Test data randomized every time → hard to reproduce failures. Stabilize scenarios with fixed IDs or seeds.
- Forgetting to restore fakes → Prefer shared setup/teardown (Pest hooks or PHPUnit
setUp/tearDown) over “one test one assert.” - Chrome/DB mismatches in CI → Always include health checks; use
--disable-dev-shm-usageto avoid memory issues. - Missing failure screenshots → Debug cost skyrockets. Artifact storage must be standard.
15. Checklist (handout-ready)
Strategy
- [ ] Make Feature (HTTP/DB) the core; Unit for logic; E2E for essentials only
- [ ] Run Pest/Feature + Dusk + a11y (Pa11y/axe) on every PR
Data
- [ ] Factory state design (
draft/published, etc.) - [ ] Reproducible scenarios via seeds and fixed IDs
Accessibility
- [ ] Focus moves to error summary;
role="alert"/aria-invalid/aria-describedby - [ ] Announce progress/completion via
role="status"/aria-live - [ ] Image
alt, decorativealt="", designs not color-dependent - [ ] Fully operable by keyboard
Automated checks
- [ ] Dusk + axe smoke test
- [ ] CI runs Pa11y URL list
- [ ] Define failure thresholds by severity
Speed/stability
- [ ] Parallel runs; in-memory SQLite (or multiple DBs)
- [ ] Fakes for Http/Mail/Queue/Storage
- [ ] Store failure screenshots/logs as artifacts
CI
- [ ] Headless Chrome
- [ ] Health checks for services (DB/Redis)
- [ ] Cache dependencies (composer/npm)
16. Summary
- Assign clear roles to Pest/PHPUnit, Feature, and Dusk to make daily changes safe.
- Introduce axe/Pa11y to auto-prevent common early a11y defects.
- Verify the essence of the experience—focus moves, live regions, keyboard ops—via E2E.
- Keep screenshots and logs in CI so failures teach you.
- Start with a smoke; expand pages and rules gradually as you see results.
I hope your project grows into something robust and usable by everyone. Feel free to adopt this template as your team’s default standard.
Accessibility rating for this content: ★★★★★ (very high)
- Continuous guarantees for announcements/focus/color-independence via automated a11y checks and E2E.
- Includes operational accountability (screenshots/logs/request IDs) on failure.
- Future upgrades: regular manual exploratory testing with major screen readers (NVDA/JAWS/VoiceOver/TalkBack).
Reference links
- Laravel (official)
- Pest / PHPUnit
- Browser automation / accessibility
- CI / headless browser
