Skip to content

Testing Strategy

Overview

Testing ensures stability across the multi-tenant domain (Shop vs Client users) and complex integrations (queues, webhooks, PDFs, media, search). Prefer feature tests simulating real workflows over isolated unit tests.

Backend (PHPUnit)

  • Framework: PHPUnit 13, with Paratest for parallel runs
  • Entry: ./vendor/bin/sail artisan test
  • Suites: Unit (tests/Unit) and Feature (tests/Feature), declared in phpunit.xml
  • Base test cases:
    • tests/TestCase.php — mocks the Vite manifest (so no build is required), fakes EvoxImage, forces scout.driver=null, and stops Shop/Client current-model faking on teardown.
    • tests/DatabaseTestCase.php — adds RefreshDatabase & WithFaker.
    • tests/Unit/Actions/ActionTestCase.php — tenant-aware helpers: createShopUser(), createClientUser(), createClient(), createUser(), createCar().

phpunit.xml already pins the test environment: array cache/session/mail, sync queue, Scout and Pulse disabled, BCRYPT_ROUNDS=4.

Naming Convention

Use the #[Test] attribute with snake_case method names that describe the behavior. Never the test_ prefix.

php
#[Test]
public function store_validates_notes_max_length(): void

Writing Feature Tests

php
declare(strict_types=1);

namespace Tests\Feature;

use App\Models\Car;
use App\Models\User;
use App\Models\WorkOrder;
use PHPUnit\Framework\Attributes\Test;
use Tests\DatabaseTestCase;

class WorkOrderControllerTest extends DatabaseTestCase
{
    private User $user;

    protected function setUp(): void
    {
        parent::setUp();

        $this->user = User::factory()->withShop()->create();
    }

    #[Test]
    public function update_sets_car_id(): void
    {
        $workOrder = WorkOrder::factory()->create(['shop_id' => $this->user->shops()->first()->id]);
        $car = Car::factory()->create();

        $this->actingAs($this->user)
            ->put(route('shop.workOrders.update', $workOrder), ['car_id' => $car->id])
            ->assertRedirect();

        $this->assertDatabaseHas('work_orders', [
            'id' => $workOrder->id,
            'car_id' => $car->id,
        ]);
    }
}

Reminders:

  • EnsureUserTypeShop redirects (302) rather than returning 403 — assert the redirect.
  • Actions run authorize() before asController(), so policy failures are 403.

Common Options

OptionUse
--filter=NameRun tests matching a pattern
--parallelParallel execution via Paratest
--profileShow the slowest tests — start here before tuning process counts
--coverageGenerate coverage report

Factories & Seeders

  • Use model factories for creating entities; avoid manual attribute arrays unless the test is about those exact values.
  • Use seeders sparingly — seeding is usually the dominant cost in a slow suite.

Search in Tests

Scout is disabled (SCOUT_DRIVER=null in phpunit.xml, re-asserted in TestCase::setUp()), so no Meilisearch instance is needed. Tests that assert indexing behavior must opt in explicitly.

Migrations

Ensure the containers are running; otherwise you will see:

SQLSTATE[08006] [7] could not translate host name "pgsql" to address

Start Docker:

bash
./vendor/bin/sail up -d

Frontend (Vitest)

  • Runner: vitest (v4)
  • Environment: happy-dom, globals: true
  • Config: vitest.config.ts; setup in vitestSetupFile.js
  • Discovery: resources/js/**/*.{test,spec}.{js,ts,jsx,tsx} — tests live in __tests__/ folders next to the source
bash
npm run test           # watch
npm run test:watch
npm run test:coverage

Example Component Test

ts
import { render, screen } from '@testing-library/vue';

import CarCard from '@/Components/Car/CarCard.vue';

test('renders the car name', () => {
    render(CarCard, { props: { car: { id: 1, name: 'Civic' } } });
    expect(screen.getByText('Civic')).toBeTruthy();
});

Pinia Store Testing

Use @pinia/testing to mount stores in isolation.

ts
import { createTestingPinia } from '@pinia/testing';
import { render } from '@testing-library/vue';

import CarList from '@/Components/Car/CarList.vue';

render(CarList, {
    global: { plugins: [createTestingPinia()] },
});

Continuous Integration

.github/workflows/ci.yml runs on pushes and PRs to main/master, with in-progress runs cancelled on new commits. Two parallel jobs:

  • vitestnpm run type-check, then npx vitest run
  • phpunitphp artisan test --parallel --log-junit=junit.xml

Note: CI has no built Vite manifest — TestCase mocks Illuminate\Foundation\Vite, which is why tests must extend it rather than Laravel's base TestCase.

Test Data Integrity

  • Clean up transient files (media, temp PDFs) after tests or use an ephemeral disk.
  • Avoid hitting external APIs (Twilio, S3, Meilisearch) directly; mock clients or use fakes.

Flakiness Mitigation

  • Prefer deterministic dates (Carbon::setTestNow).
  • Keep asynchronous job assertions synchronous — the queue is sync in tests, or use Bus::fake() / Queue::fake().
  • Assert dispatch with Event::fake() rather than waiting on broadcasts.

See troubleshooting.md for resolving common test failures.