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

12 KiB

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:

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:

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

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:
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');
}
  1. Teardown: Not typically needed; RefreshDatabase trait handles rollback automatically.

  2. Assertion Pattern: Use specific assertions for clarity:

// 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:
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');
  1. Database Factories: Preferred over mocking for model creation
$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/):

// 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:

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:

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):
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):
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):
public function testBasicExample(): void
{
    $this->browse(function (Browser $browser) {
        $browser->visit('/')
                ->assertSee('Laravel');
    });
}

Common Patterns

Async Testing: Use RefreshDatabase trait for atomic transactions:

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:

public function test_invalid_amount_rejected()
{
    $validated = $this->validate([
        'amount' => 'required|numeric|min:0'
    ], ['amount' => -1000]);

    $this->assertFalse($validated);  // Validation fails
}

Permission Testing:

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:

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:

// 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):

protected function setUp(): void
{
    parent::setUp();
    $this->withoutMiddleware([
        \App\Http\Middleware\EnsureUserIsAdmin::class,
        \App\Http\Middleware\VerifyCsrfToken::class
    ]);
}

Testing analysis: 2026-02-13