Appearance
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) andFeature(tests/Feature), declared inphpunit.xml - Base test cases:
tests/TestCase.php— mocks the Vite manifest (so no build is required), fakesEvoxImage, forcesscout.driver=null, and stopsShop/Clientcurrent-model faking on teardown.tests/DatabaseTestCase.php— addsRefreshDatabase&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(): voidWriting 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:
EnsureUserTypeShopredirects (302) rather than returning 403 — assert the redirect.- Actions run
authorize()beforeasController(), so policy failures are 403.
Common Options
| Option | Use |
|---|---|
--filter=Name | Run tests matching a pattern |
--parallel | Parallel execution via Paratest |
--profile | Show the slowest tests — start here before tuning process counts |
--coverage | Generate 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 addressStart Docker:
bash
./vendor/bin/sail up -dFrontend (Vitest)
- Runner:
vitest(v4) - Environment:
happy-dom,globals: true - Config:
vitest.config.ts; setup invitestSetupFile.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:coverageExample 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:
- vitest —
npm run type-check, thennpx vitest run - phpunit —
php 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
syncin tests, or useBus::fake()/Queue::fake(). - Assert dispatch with
Event::fake()rather than waiting on broadcasts.
See troubleshooting.md for resolving common test failures.