Files
usher-manage-stack/.planning/codebase/TESTING.md
2026-02-13 10:34:18 +08:00

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*