430 lines
12 KiB
Markdown
430 lines
12 KiB
Markdown
# 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
|
|
<?php
|
|
|
|
namespace Tests\Unit;
|
|
|
|
use App\Models\FinanceDocument;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
class FinanceDocumentTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
/** @test */
|
|
public function it_determines_small_amount_tier_correctly()
|
|
{
|
|
$document = new FinanceDocument(['amount' => 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` `<env>` 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*
|