# Testing Patterns **Analysis Date:** 2026-02-13 ## Test Framework **Runner:** - PHPUnit 10.1+ (configured in `composer.json`) - Config file: `phpunit.xml` **Assertion Library:** - PHPUnit assertions (built-in) - Laravel testing traits: `RefreshDatabase`, `WithFaker`, `DatabaseMigrations` **Run Commands:** ```bash php artisan test # Run all tests php artisan test --filter=ClassName # Run specific test class php artisan test --filter=test_method_name # Run specific test method php artisan dusk # Run browser (Dusk) tests ``` **Coverage:** ```bash php artisan test --coverage # Generate coverage report ``` ## Test File Organization **Location:** - Unit tests: `tests/Unit/` - Feature tests: `tests/Feature/` - Browser tests: `tests/Browser/` - Shared test utilities: `tests/Traits/` - Test base classes: `tests/TestCase.php`, `tests/DuskTestCase.php` **Naming:** - Test files: PascalCase + `Test.php` suffix (e.g., `FinanceDocumentTest.php`, `CashierLedgerWorkflowTest.php`) - Test methods: `test_` prefix (e.g., `test_payment_belongs_to_member()`) OR `/** @test */` annotation - Test trait files: PascalCase with context (e.g., `CreatesFinanceData.php`, `SeedsRolesAndPermissions.php`) **Structure:** ``` tests/ ├── Unit/ # Model logic, calculations, state checks │ ├── FinanceDocumentTest.php │ ├── MemberTest.php │ ├── BudgetTest.php │ └── MembershipPaymentTest.php ├── Feature/ # HTTP requests, workflows, integrations │ ├── CashierLedgerWorkflowTest.php │ ├── Auth/ │ ├── BankReconciliation/ │ └── ProfileTest.php ├── Browser/ # Full page interactions with Dusk │ ├── MemberDashboardBrowserTest.php │ ├── FinanceWorkflowBrowserTest.php │ └── Pages/ ├── Traits/ # Reusable test setup helpers │ ├── SeedsRolesAndPermissions.php │ ├── CreatesFinanceData.php │ ├── CreatesMemberData.php │ └── CreatesApplication.php ├── TestCase.php # Base test class with setup └── DuskTestCase.php # Base for browser tests ``` ## Test Structure **Suite Organization:** ```php 4999]); $this->assertEquals('small', $document->determineAmountTier()); } } ``` **Patterns:** 1. **Setup Method:** ```php protected function setUp(): void { parent::setUp(); // Seed roles/permissions once per test class $this->artisan('db:seed', ['--class' => 'FinancialWorkflowPermissionsSeeder']); // Fake storage if needed Storage::fake('private'); // Create test users $this->admin = User::factory()->create(); $this->admin->assignRole('admin'); } ``` 2. **Teardown:** Not typically needed; `RefreshDatabase` trait handles rollback automatically. 3. **Assertion Pattern:** Use specific assertions for clarity: ```php // Model relationships $this->assertInstanceOf(Member::class, $payment->member); $this->assertEquals($cashier->id, $payment->verifiedByCashier->id); // Status checks $this->assertTrue($payment->isPending()); $this->assertFalse($document->needsBoardMeetingApproval()); // Database state $this->assertDatabaseHas('cashier_ledger_entries', [ 'entry_type' => 'receipt', 'amount' => 5000, ]); // HTTP responses $response->assertStatus(200); $response->assertRedirect(); ``` ## Mocking **Framework:** Mockery (configured in `composer.json`) **Patterns:** Minimal mocking in this codebase. When needed: 1. **File Storage Mocking:** ```php protected function setUp(): void { parent::setUp(); Storage::fake('private'); // All storage operations use fake disk } // In test $file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf'); $path = $file->store('payment-receipts', 'private'); ``` 2. **Database Factories:** Preferred over mocking for model creation ```php $member = Member::factory()->create(['membership_status' => 'active']); $payment = MembershipPayment::factory()->create(['member_id' => $member->id]); ``` **What to Mock:** - External APIs (not used in current codebase) - File uploads (use `Storage::fake()`) - Email sending (Laravel's test mailers or `Mail::fake()`) **What NOT to Mock:** - Database models (use factories instead) - Business logic methods (test them directly) - Service classes (inject real instances) - Permission/role system (use real Spatie setup) ## Fixtures and Factories **Test Data:** Models use Laravel factories (`database/factories/`): ```php // In test $member = Member::factory()->create([ 'membership_status' => 'active', 'membership_expires_at' => now()->addYear(), ]); ``` **Helper Traits:** Reusable test setup in `tests/Traits/`: 1. **`SeedsRolesAndPermissions`** (`tests/Traits/SeedsRolesAndPermissions.php`): - `seedRolesAndPermissions()`: Seed all financial roles - `createUserWithRole(string $role)`: Create user + assign role - `createAdmin()`, `createSecretary()`, `createChair()`: Convenience methods - `createFinanceApprovalTeam()`: Create all approval users at once 2. **`CreatesFinanceData`** (`tests/Traits/CreatesFinanceData.php`): - `createFinanceDocument(array $attributes)`: Basic document - `createSmallAmountDocument()`: Auto-set amount < 5000 - `createMediumAmountDocument()`: Auto-set 5000-50000 - `createLargeAmountDocument()`: Auto-set > 50000 - `createDocumentAtStage(string $stage)`: Pre-set approval status - `createPaymentOrder()`, `createBankReconciliation()`: Domain-specific - `createFakeAttachment()`: PDF file for testing - `getValidFinanceDocumentData()`: Reusable form data 3. **`CreatesMemberData`** (`tests/Traits/CreatesMemberData.php`): - `createMember()`: Create member + associated user - `createPendingMember()`, `createActiveMember()`, `createExpiredMember()`: Status helpers - `createMemberWithPendingPayment()`: Pre-populated workflow state **Location:** - Traits: `tests/Traits/` - Factories: `database/factories/` - Database seeders: `database/seeders/` **Usage Example:** ```php class FinanceWorkflowTest extends TestCase { use RefreshDatabase, SeedsRolesAndPermissions, CreatesFinanceData; protected function setUp(): void { parent::setUp(); $this->seedRolesAndPermissions(); } public function test_approval_workflow() { $secretary = $this->createSecretary(); $document = $this->createSmallAmountDocument(); $this->actingAs($secretary) ->post(route('admin.finance-document.approve', $document), [ 'approval_notes' => 'Approved' ]) ->assertRedirect(); } } ``` ## Coverage **Requirements:** No minimum coverage enforced (not configured in `phpunit.xml`). **View Coverage:** ```bash php artisan test --coverage # Text report php artisan test --coverage --html # HTML report in coverage/ directory ``` **Target areas (recommended):** - Model methods: 80%+ - Service classes: 80%+ - Controllers: 70%+ (integration tests) - Traits: 80%+ ## Test Types **Unit Tests** (`tests/Unit/`): - Scope: Single method/model logic - Database: Yes, but minimal (models with factories) - Approach: Direct method calls, assertions on return values - Example (`FinanceDocumentTest.php`): ```php public function test_it_determines_small_amount_tier_correctly() { $document = new FinanceDocument(['amount' => 4999]); $this->assertEquals('small', $document->determineAmountTier()); } ``` **Feature Tests** (`tests/Feature/`): - Scope: Full request → response cycle - Database: Yes (full workflow) - Approach: HTTP methods (`$this->get()`, `$this->post()`), database assertions - Example (`CashierLedgerWorkflowTest.php` lines 41-64): ```php public function cashier_can_create_receipt_entry() { $this->actingAs($this->cashier); $response = $this->post(route('admin.cashier-ledger.store'), [ 'entry_type' => 'receipt', 'amount' => 5000, // ... ]); $response->assertRedirect(); $this->assertDatabaseHas('cashier_ledger_entries', [ 'entry_type' => 'receipt', 'amount' => 5000, ]); } ``` **Browser Tests** (`tests/Browser/` with Laravel Dusk): - Scope: Full page interactions (JavaScript, dynamic content) - Database: Yes - Approach: Browser automation, user journey testing - Example (`ExampleTest.php`): ```php public function testBasicExample(): void { $this->browse(function (Browser $browser) { $browser->visit('/') ->assertSee('Laravel'); }); } ``` ## Common Patterns **Async Testing:** Use `RefreshDatabase` trait for atomic transactions: ```php public function test_concurrent_payment_submissions() { $member = $this->createActiveMember(); $payment1 = MembershipPayment::factory()->create([ 'member_id' => $member->id, 'status' => MembershipPayment::STATUS_PENDING ]); // Multiple operations automatically rolled back after test } ``` **Error Testing:** ```php public function test_invalid_amount_rejected() { $validated = $this->validate([ 'amount' => 'required|numeric|min:0' ], ['amount' => -1000]); $this->assertFalse($validated); // Validation fails } ``` **Permission Testing:** ```php public function test_non_admin_cannot_approve_large_document() { $user = $this->createMembershipManager(); $document = $this->createLargeAmountDocument(); $this->actingAs($user) ->post(route('admin.finance-document.approve', $document)) ->assertForbidden(); // 403 Forbidden } ``` **State Transitions:** ```php public function test_document_approval_workflow() { $secretary = $this->createSecretary(); $document = $this->createDocumentAtStage('pending'); // Step 1: Secretary approves $this->actingAs($secretary) ->post(route('admin.finance-document.approve', $document)); $this->assertEquals( FinanceDocument::STATUS_APPROVED_SECRETARY, $document->fresh()->status ); // Step 2: Chair approves $chair = $this->createChair(); $this->actingAs($chair) ->post(route('admin.finance-document.approve', $document->fresh())); $this->assertEquals( FinanceDocument::STATUS_APPROVED_CHAIR, $document->fresh()->status ); } ``` **Database Assertions:** ```php // Check record exists with specific values $this->assertDatabaseHas('finance_documents', [ 'id' => $document->id, 'status' => FinanceDocument::STATUS_APPROVED_SECRETARY, ]); // Check record count $this->assertCount(3, MembershipPayment::all()); // Count with condition $this->assertEquals( 2, MembershipPayment::where('status', 'pending')->count() ); ``` ## Environment & Configuration **Test Environment:** - Set via `phpunit.xml` `` elements - `APP_ENV=testing` - `DB_CONNECTION=sqlite` - `DB_DATABASE=:memory:` (transient in-memory database) - `MAIL_MAILER=array` (no real emails sent) - `QUEUE_CONNECTION=sync` (synchronous job processing) **Database State:** - `RefreshDatabase` trait: Runs migrations before each test, rolls back after - `:memory:` SQLite: Fresh database per test, extremely fast - No fixtures needed; use factories instead **Disabling Middleware (when needed):** ```php protected function setUp(): void { parent::setUp(); $this->withoutMiddleware([ \App\Http\Middleware\EnsureUserIsAdmin::class, \App\Http\Middleware\VerifyCsrfToken::class ]); } ``` --- *Testing analysis: 2026-02-13*