Testing Standards Guide
Last Updated: 2026-01-29 Status: Active Audience: Developers
This guide defines testing standards, conventions, and best practices for the Client Portal Laravel application.
Table of Contents
- Overview
- Current Test Coverage
- Test Categories
- File Organization
- Test Structure
- Factories and Seeders
- Mocking Guidelines
- Database Testing
- Coverage Requirements
- Commands Reference
Overview
The application uses PHPUnit 11 for testing with Laravel's built-in testing utilities. Tests are organized into Feature tests (integration) and Unit tests.
Target Metrics
| Metric | Target |
|---|---|
| Overall Coverage | 60%+ |
| Critical Features (auth, payments) | 80%+ |
| Services and Actions | 70%+ |
| Models | 50%+ |
Current Test Coverage
As of January 2026, the test suite includes 2670 tests with 6630 assertions.
Coverage by Category
| Category | Tests | Coverage | Notes |
|---|---|---|---|
| Services | ~200+ | ~65% | Payment, file, workflow, notification, analytics, template, compliance |
| Models | ~130 | ~50% | Core models with relationships, scopes, accessors |
| Policies | 183 | 100% | All 18 policies fully tested |
| Middleware | 88 | 100% | All 13 middleware fully tested |
| Jobs | ~40 | ~75% | Background jobs and queue handlers |
| Commands | 58 | 100% | All 14 console commands tested |
Test Suite Organization
The test directory structure mirrors the app/ directory to maintain consistency and discoverability.
tests/
├── Feature/ # Integration/HTTP tests
│ ├── Admin/ # Admin controller tests
│ ├── Api/ # API endpoint tests
│ ├── Auth/ # Authentication tests
│ ├── Commands/ # Artisan command tests
│ └── Portal/ # Portal controller tests
└── Unit/ # Unit tests (mirrors app/ structure)
├── Commands/ # Console command unit tests
├── Config/ # Configuration tests
├── Enums/ # Enum tests
├── Http/ # HTTP layer tests
│ ├── Middleware/ # Middleware unit tests
│ └── Requests/ # Form request validation tests
├── Jobs/ # Job unit tests
├── Models/ # Model tests (organized by domain)
│ ├── Core/ # Core models (User, Client, etc.)
│ ├── Dashboard/ # Dashboard-related models
│ ├── Projects/ # Project-related models
│ └── Workflows/ # Workflow models
├── Policies/ # Authorization policy tests
├── Services/ # Service tests (organized by domain)
│ ├── Activity/ # Activity tracking services
│ ├── Analytics/ # Analytics services
│ ├── Audit/ # Audit services
│ ├── Cache/ # Cache services
│ ├── Calendar/ # Calendar services
│ ├── Collaboration/ # Collaboration services
│ ├── Compliance/ # Compliance services
│ ├── Dashboard/ # Dashboard services
│ ├── Notifications/ # Notification services
│ ├── RealTime/ # Real-time services
│ ├── Reports/ # Report services
│ ├── Search/ # Search services
│ ├── Security/ # Security services
│ ├── Templates/ # Template services
│ └── Workflow/ # Workflow services
└── Traits/ # Model trait tests
IMPORTANT: This structure is enforced by CI. Run php scripts/testing/coverage/check-test-structure.php to verify compliance.
Running Tests
# Run all tests
php artisan test
# Run with parallel execution (faster)
php artisan test --parallel
# Run specific directory
php artisan test tests/Unit/Services
# Run specific test file
php artisan test tests/Unit/Services/PaymentServiceTest.php
# Run specific test method
php artisan test --filter=test_process_payment_success
Test Categories
1. Feature Tests
Location: tests/Feature/
Purpose: Test complete HTTP request/response cycles and user workflows
namespace Tests\Feature\Admin;
use Tests\TestCase;
use App\Models\User;
use App\Models\Client;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ClientManagementTest extends TestCase
{
use RefreshDatabase;
public function test_admin_can_view_clients_list(): void
{
$admin = User::factory()->admin()->create();
Client::factory()->count(3)->create();
$response = $this->actingAs($admin)->get(route('admin.clients.index'));
$response->assertOk();
$response->assertViewHas('clients');
}
public function test_client_cannot_access_admin_clients(): void
{
$client = User::factory()->client()->create();
$response = $this->actingAs($client)->get(route('admin.clients.index'));
$response->assertForbidden();
}
}
2. Unit Tests
Location: tests/Unit/
Purpose: Test individual classes, methods, and functions in isolation — no database, no Laravel container resolution, no factories.
A unit test:
- MUST NOT use
RefreshDatabase,DatabaseTransactions, orDatabaseMigrations. - MUST NOT call
app(SomeClass::class)orresolve(SomeClass::class)to obtain dependencies — inject them via the constructor instead. - MUST NOT call
Model::factory()->create(). If you need a model with specific attributes, instantiate it directly (new Invoice(['total' => 100])) or use a Mockery double. - MAY use Mockery,
Http::fake(),Event::fake(),Log::spy(), and other fakes that don't hit the database.
If your test legitimately needs the database or container, it's a Feature test, not a unit test — move it to tests/Feature/.
// Good — pure unit test
namespace Tests\Unit\Services;
use App\Services\InvoiceTotalCalculator;
use PHPUnit\Framework\TestCase;
class InvoiceTotalCalculatorTest extends \PHPUnit\Framework\TestCase
{
public function test_calculates_total_from_line_items(): void
{
$calculator = new InvoiceTotalCalculator;
$total = $calculator->fromLines([
['quantity' => 2, 'unit_price' => 100.00],
['quantity' => 1, 'unit_price' => 50.00],
]);
$this->assertEquals(250.00, $total);
}
}
// Belongs in tests/Feature/ — this is integration, not unit
namespace Tests\Feature\Services;
use App\Models\Invoicing\Invoice;
use App\Models\Invoicing\InvoiceItem;
use App\Services\InvoiceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class InvoiceServiceTest extends TestCase
{
use RefreshDatabase;
public function test_calculate_total_persisted_invoice(): void
{
$invoice = Invoice::factory()
->has(InvoiceItem::factory()->count(3)->state([...]))
->create();
$total = (new InvoiceService)->calculateTotal($invoice);
$this->assertEquals(600.00, $total);
}
}
The isolation rule is enforced by tests/Architecture/UnitTestIsolationTest.php. It currently runs in report-only mode (snapshot at tests/.coverage/unit-isolation-violations.txt) until the inventory of existing violations is migrated. New code MUST follow the rule.
3. Browser Tests (Optional)
Location: tests/Browser/
Purpose: End-to-end testing with Laravel Dusk
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use App\Models\User;
class LoginTest extends DuskTestCase
{
public function test_user_can_login(): void
{
$user = User::factory()->create();
$this->browse(function (Browser $browser) use ($user) {
$browser->visit('/login')
->type('email', $user->email)
->type('password', 'password')
->press('Log in')
->assertPathIs('/dashboard');
});
}
}
File Organization
Directory Structure Rules
The tests/Unit/ directory structure must mirror the app/ directory structure:
app/Http/Middleware/tests go intests/Unit/Http/Middleware/app/Http/Requests/tests go intests/Unit/Http/Requests/app/Models/Core/tests go intests/Unit/Models/Core/app/Services/Analytics/tests go intests/Unit/Services/Analytics/
Forbidden patterns:
- ❌ Root-level test files in
tests/Unit/(must be in subdirectories) - ❌
tests/Unit/Middleware/(usetests/Unit/Http/Middleware/instead) - ❌
tests/Unit/Requests/(usetests/Unit/Http/Requests/instead)
Test File Requirements
Every test file must include:
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Analytics; // Must match directory path
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
// ... other imports
class ProjectAnalyticsServiceTest extends TestCase
{
// ...
}
Requirements:
declare(strict_types=1)after<?phptag- Namespace matching directory path
- Class name matching filename
CI Enforcement:
# Verify test structure compliance
php scripts/testing/coverage/check-test-structure.php
# Fix missing strict_types declarations
php scripts/codefix/fix-strict-types.php --dry-run # Preview
php scripts/codefix/fix-strict-types.php # Apply
File Naming
| Type | Pattern | Example |
|---|---|---|
| Feature tests | *Test.php | ClientManagementTest.php |
| Unit tests | *Test.php | InvoiceServiceTest.php |
| Browser tests | *Test.php | LoginTest.php |
Test Structure
Method Naming
Use descriptive names that explain what is being tested:
// Good - Clear and descriptive
public function test_admin_can_create_client_with_valid_data(): void {}
public function test_invoice_status_changes_to_paid_when_payment_received(): void {}
public function test_user_cannot_access_other_users_files(): void {}
// Bad - Vague or unclear
public function test_create(): void {}
public function test_it_works(): void {}
public function testInvoice(): void {}
Arrange-Act-Assert Pattern
public function test_admin_can_update_client(): void
{
// Arrange - Set up test data
$admin = User::factory()->admin()->create();
$client = Client::factory()->create(['company_name' => 'Old Name']);
// Act - Execute the code being tested
$response = $this->actingAs($admin)
->put(route('admin.clients.update', $client), [
'company_name' => 'New Name',
'email' => 'new@example.com',
]);
// Assert - Verify the results
$response->assertRedirect(route('admin.clients.show', $client));
$this->assertDatabaseHas('clients', [
'id' => $client->id,
'company_name' => 'New Name',
]);
}
Testing HTTP Responses
// Test successful responses
$response->assertOk(); // 200
$response->assertCreated(); // 201
$response->assertNoContent(); // 204
// Test redirects
$response->assertRedirect('/dashboard');
$response->assertRedirectToRoute('admin.clients.index');
// Test error responses
$response->assertNotFound(); // 404
$response->assertForbidden(); // 403
$response->assertUnauthorized(); // 401
$response->assertUnprocessable(); // 422 (validation errors)
// Test view data
$response->assertViewIs('admin.clients.index');
$response->assertViewHas('clients');
$response->assertViewHas('clients', function ($clients) {
return $clients->count() === 3;
});
// Test JSON responses
$response->assertJson(['success' => true]);
$response->assertJsonStructure(['data' => ['id', 'name']]);
$response->assertJsonCount(3, 'data');
Factories and Seeders
Factory Best Practices
namespace Database\Factories;
use App\Models\Client;
use Illuminate\Database\Eloquent\Factories\Factory;
class ClientFactory extends Factory
{
protected $model = Client::class;
public function definition(): array
{
return [
'company_name' => fake()->company(),
'email' => fake()->unique()->companyEmail(),
'phone' => fake()->phoneNumber(),
'is_active' => true,
];
}
// State methods for common variations
public function inactive(): static
{
return $this->state(fn (array $attributes) => [
'is_active' => false,
]);
}
public function withProjects(int $count = 3): static
{
return $this->has(Project::factory()->count($count));
}
}
User Factory with Roles
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class UserFactory extends Factory
{
protected $model = User::class;
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => bcrypt('password'),
'role' => 'client',
'remember_token' => Str::random(10),
];
}
public function admin(): static
{
return $this->state(fn (array $attributes) => [
'role' => 'admin',
]);
}
public function client(): static
{
return $this->state(fn (array $attributes) => [
'role' => 'client',
]);
}
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
Using Factories in Tests
// Create single model
$client = Client::factory()->create();
// Create with specific attributes
$client = Client::factory()->create([
'company_name' => 'Acme Corp',
]);
// Create multiple models
$clients = Client::factory()->count(5)->create();
// Create with relationships
$client = Client::factory()
->has(Project::factory()->count(3))
->has(Invoice::factory()->count(2))
->create();
// Use states
$admin = User::factory()->admin()->create();
$inactiveClient = Client::factory()->inactive()->create();
// Create without persisting (make vs create)
$client = Client::factory()->make(); // Not saved to database
Test Helper Traits
The tests/Concerns/ directory contains reusable traits for common testing patterns.
Available Traits
| Trait | Purpose |
|---|---|
CreatesFakeModels | Helpers for creating common model setups |
MocksServices | Service mocking, facade faking, HTTP mocking |
DetectsNPlusOneQueries | N+1 query detection and performance assertions |
CreatesFakeModels
Use this trait to quickly create common model setups:
use Tests\Concerns\CreatesFakeModels;
class InvoiceServiceTest extends TestCase
{
use RefreshDatabase, CreatesFakeModels;
public function test_invoice_calculation(): void
{
// Create a client with user already attached
['client' => $client, 'user' => $user] = $this->createClientWithUser();
// Create invoice with items
['invoice' => $invoice, 'items' => $items] = $this->createInvoiceWithItems(3);
// Create a full client setup (client, user, projects, invoices)
$setup = $this->createFullClientSetup(projectCount: 2, invoiceCount: 3);
}
}
Available Methods:
createAdmin()- Admin usercreateClientUser()- Client usercreateClientWithUser()- Client with attached usercreateClientWithUsers(int $count)- Client with multiple userscreateProjectWithClient()- Project with parent clientcreateProjectWithFiles(int $count)- Project with filescreateInvoiceWithItems(int $count)- Invoice with line itemscreatePaidInvoice()- Paid invoicecreateOverdueInvoice()- Overdue invoicecreateFullClientSetup()- Complete client setupcreateAuditTemplate()- Audit templatecreateCompletedAudit()- Completed audit with template
MocksServices
Use this trait for mocking services and facades:
use Tests\Concerns\MocksServices;
class NotificationTest extends TestCase
{
use RefreshDatabase, MocksServices;
public function test_notification_sent(): void
{
// Mock a specific service
$mock = $this->mockNotificationService();
$mock->shouldReceive('send')->once()->andReturn(true);
// Mock any service by class name
$pdfMock = $this->mockService(PdfService::class);
$pdfMock->shouldReceive('generate')->andReturn('pdf-content');
// Spy on a service (records calls)
$spy = $this->spyOnService(EmailService::class);
// ... later assert
$spy->shouldHaveReceived('send');
// Fake facades
$this->fakeQueue();
$this->fakeEvents();
$this->fakeNotifications();
$this->fakeMail();
// Mock HTTP responses
$this->mockHttpResponses([
'api.stripe.com/*' => Http::response(['id' => 'ch_123']),
]);
}
}
Available Methods:
mockNotificationService()- Mock NotificationServicemockRealtimeNotificationService()- Mock RealtimeNotificationServicemockPdfService()- Mock PdfServicemockFileEncryptionService()- Mock FileEncryptionServicemockStorageDisk(string $disk)- Mock a storage diskmockService(string $class)- Mock any service by classpartialMockService(string $class)- Partial mock (real methods unless mocked)spyOnService(string $class)- Create a spymockHttpClient()- Fake all HTTP requestsmockHttpResponses(array $responses)- Fake specific HTTP responsesfakeQueue()- Fake queue dispatchfakeEvents()- Fake event dispatchfakeNotifications()- Fake notificationsfakeMail()- Fake mail sending
DetectsNPlusOneQueries
Use this trait to detect N+1 query problems:
use Tests\Concerns\DetectsNPlusOneQueries;
class ClientControllerTest extends TestCase
{
use RefreshDatabase, DetectsNPlusOneQueries;
public function test_index_avoids_n_plus_one(): void
{
Client::factory()->count(20)->create();
// Assert max 5 queries for listing 20 clients
$this->assertMaxQueries(5, function () {
$this->get(route('admin.clients.index'));
});
}
public function test_no_duplicate_queries(): void
{
$this->startQueryLog();
$this->get(route('admin.clients.index'));
$this->stopQueryLog();
$this->assertNoDuplicateQueries();
}
}
Mocking Guidelines
When to Mock
DO Mock:
- External API calls (Stripe, email services)
- File system operations (in some cases)
- Time-sensitive operations (
Carbon::setTestNow()) - Queue jobs (when testing dispatch, not execution)
DON'T Mock:
- The code being tested
- Database queries (use RefreshDatabase instead)
- Eloquent models
- Simple utility functions
Mocking External Services
use Illuminate\Support\Facades\Http;
public function test_payment_processing(): void
{
// Mock external API
Http::fake([
'api.stripe.com/*' => Http::response([
'id' => 'ch_123',
'status' => 'succeeded',
], 200),
]);
$response = $this->post(route('payments.process'), [
'amount' => 100,
]);
$response->assertOk();
Http::assertSent(function ($request) {
return $request->url() === 'https://api.stripe.com/charges';
});
}
Mocking Mail
use Illuminate\Support\Facades\Mail;
use App\Mail\InvoiceMail;
public function test_invoice_email_is_sent(): void
{
Mail::fake();
$invoice = Invoice::factory()->create();
$this->post(route('invoices.send', $invoice));
Mail::assertSent(InvoiceMail::class, function ($mail) use ($invoice) {
return $mail->invoice->id === $invoice->id;
});
}
Mocking Events and Jobs
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use App\Events\InvoiceCreated;
use App\Jobs\ProcessPayment;
public function test_event_is_dispatched(): void
{
Event::fake([InvoiceCreated::class]);
Invoice::factory()->create();
Event::assertDispatched(InvoiceCreated::class);
}
public function test_job_is_queued(): void
{
Queue::fake();
$this->post(route('payments.process'), ['amount' => 100]);
Queue::assertPushed(ProcessPayment::class);
}
Database Testing
RefreshDatabase Trait
Use RefreshDatabase for tests that interact with the database:
use Illuminate\Foundation\Testing\RefreshDatabase;
class ClientManagementTest extends TestCase
{
use RefreshDatabase;
// Tests will run with fresh database
}
Database Assertions
// Assert record exists
$this->assertDatabaseHas('clients', [
'company_name' => 'Acme Corp',
'email' => 'contact@acme.com',
]);
// Assert record doesn't exist
$this->assertDatabaseMissing('clients', [
'email' => 'deleted@acme.com',
]);
// Assert record count
$this->assertDatabaseCount('clients', 5);
// Assert soft deleted
$this->assertSoftDeleted('clients', [
'id' => $client->id,
]);
Testing Transactions
public function test_failed_payment_rolls_back_invoice(): void
{
Http::fake([
'api.stripe.com/*' => Http::response(['error' => 'declined'], 400),
]);
$invoice = Invoice::factory()->create(['status' => 'draft']);
$this->post(route('invoices.pay', $invoice));
// Invoice should still be draft (rollback occurred)
$this->assertDatabaseHas('invoices', [
'id' => $invoice->id,
'status' => 'draft',
]);
}
Coverage Requirements
Running Coverage Reports
# Generate coverage report
php artisan test --coverage
# Generate HTML report
php artisan test --coverage-html=coverage
# With minimum threshold
php artisan test --coverage --min=60
Coverage Configuration
In phpunit.xml:
<coverage>
<include>
<directory suffix=".php">./app</directory>
</include>
<exclude>
<directory>./app/Console</directory>
<directory>./app/Exceptions</directory>
<directory>./app/Providers</directory>
</exclude>
</coverage>
Priority Areas
| Area | Target | Priority |
|---|---|---|
| Authentication | 90% | Critical |
| Authorization (Policies) | 80% | Critical |
| Payment Processing | 90% | Critical |
| Services | 70% | High |
| Controllers | 60% | Medium |
| Models | 50% | Medium |
Commands Reference
| Command | Description |
|---|---|
php artisan test | Run all tests |
php artisan test --filter=ClientTest | Run specific test class |
php artisan test --filter=test_admin_can_create | Run specific test method |
php artisan test tests/Feature | Run tests in directory |
php artisan test --parallel | Run tests in parallel |
php artisan test --coverage | Run with coverage report |
php artisan test --stop-on-failure | Stop on first failure |
Composer Scripts
| Script | Description |
|---|---|
composer test | Run all tests in parallel |
composer test:sof | Run tests in parallel, stop on failure |
composer test:log | Run tests and save output to log file |
composer test:report | Run tests and generate visual HTML report in tests/.reports/ |
composer test:coverage | Generate HTML coverage report in tests/.coverage/html |
composer test:coverage-report | Show coverage summary in terminal |
Note: Coverage scripts require Xdebug or PCOV PHP extension to be installed.
See Test HTML Reports for details on
test:report.
Pest Commands (if using Pest)
# Run Pest tests
./vendor/bin/pest
# With coverage
./vendor/bin/pest --coverage
# Filter tests
./vendor/bin/pest --filter="can create client"
Best Practices
Do
- Write tests before or alongside code
- Test both happy path and edge cases
- Use descriptive test names
- Keep tests focused and independent
- Use factories for test data
- Clean up after tests (RefreshDatabase handles this)
Don't
- Test framework code (Laravel internals)
- Write tests that depend on other tests
- Use real external services
- Hard-code IDs or timestamps
- Over-mock (test real behavior when possible)
Common Pitfalls
1. Not Using RefreshDatabase
// Bad - Tests may interfere with each other
class ClientTest extends TestCase
{
public function test_creates_client(): void
{
Client::create([...]); // Data persists between tests!
}
}
// Good - Fresh database for each test
class ClientTest extends TestCase
{
use RefreshDatabase;
// ...
}
2. Testing Implementation Details
// Bad - Tests internal implementation
$this->assertEquals('calculateTotal', $invoice->getMethod());
// Good - Tests observable behavior
$this->assertEquals(100.00, $invoice->total);
3. Forgetting Authentication
// Bad - Test fails because user not authenticated
$response = $this->get(route('admin.clients.index'));
// Good - Authenticate as appropriate user
$admin = User::factory()->admin()->create();
$response = $this->actingAs($admin)->get(route('admin.clients.index'));