php elephant sticker
Photo by RealToughCandy.com on Pexels.com

[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/Feature and tests/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 RefreshDatabase trait 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 have aria-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)

  1. npm i -D axe-core
  2. Inject axe.min.js into 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

  1. npm i -D pa11y pa11y-ci
  2. 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"
  ]
}
  1. 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 intl so 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; default aria-live to polite.
  • Images have alt; decorative ones use empty alt="".
  • 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-usage to 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, decorative alt="", 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

By greeden

Leave a Reply

Your email address will not be published. Required fields are marked *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)