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.phpsuffix (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:
- 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');
}
-
Teardown: Not typically needed;
RefreshDatabasetrait handles rollback automatically. -
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:
- 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');
- 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/:
-
SeedsRolesAndPermissions(tests/Traits/SeedsRolesAndPermissions.php):seedRolesAndPermissions(): Seed all financial rolescreateUserWithRole(string $role): Create user + assign rolecreateAdmin(),createSecretary(),createChair(): Convenience methodscreateFinanceApprovalTeam(): Create all approval users at once
-
CreatesFinanceData(tests/Traits/CreatesFinanceData.php):createFinanceDocument(array $attributes): Basic documentcreateSmallAmountDocument(): Auto-set amount < 5000createMediumAmountDocument(): Auto-set 5000-50000createLargeAmountDocument(): Auto-set > 50000createDocumentAtStage(string $stage): Pre-set approval statuscreatePaymentOrder(),createBankReconciliation(): Domain-specificcreateFakeAttachment(): PDF file for testinggetValidFinanceDocumentData(): Reusable form data
-
CreatesMemberData(tests/Traits/CreatesMemberData.php):createMember(): Create member + associated usercreatePendingMember(),createActiveMember(),createExpiredMember(): Status helperscreateMemberWithPendingPayment(): 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.phplines 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=testingDB_CONNECTION=sqliteDB_DATABASE=:memory:(transient in-memory database)MAIL_MAILER=array(no real emails sent)QUEUE_CONNECTION=sync(synchronous job processing)
Database State:
RefreshDatabasetrait: 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