Skip to main content
Back to ScopeForged

ScopeForged Documentation

Technical documentation, guides, and feature references for the ScopeForged client portal.

Development Guides/Testing Standards

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

  1. Overview
  2. Current Test Coverage
  3. Test Categories
  4. File Organization
  5. Test Structure
  6. Factories and Seeders
  7. Mocking Guidelines
  8. Database Testing
  9. Coverage Requirements
  10. 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

MetricTarget
Overall Coverage60%+
Critical Features (auth, payments)80%+
Services and Actions70%+
Models50%+

Current Test Coverage

As of January 2026, the test suite includes 2670 tests with 6630 assertions.

Coverage by Category

CategoryTestsCoverageNotes
Services~200+~65%Payment, file, workflow, notification, analytics, template, compliance
Models~130~50%Core models with relationships, scopes, accessors
Policies183100%All 18 policies fully tested
Middleware88100%All 13 middleware fully tested
Jobs~40~75%Background jobs and queue handlers
Commands58100%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, or DatabaseMigrations.
  • MUST NOT call app(SomeClass::class) or resolve(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 in tests/Unit/Http/Middleware/
  • app/Http/Requests/ tests go in tests/Unit/Http/Requests/
  • app/Models/Core/ tests go in tests/Unit/Models/Core/
  • app/Services/Analytics/ tests go in tests/Unit/Services/Analytics/

Forbidden patterns:

  • ❌ Root-level test files in tests/Unit/ (must be in subdirectories)
  • tests/Unit/Middleware/ (use tests/Unit/Http/Middleware/ instead)
  • tests/Unit/Requests/ (use tests/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:

  1. declare(strict_types=1) after <?php tag
  2. Namespace matching directory path
  3. 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

TypePatternExample
Feature tests*Test.phpClientManagementTest.php
Unit tests*Test.phpInvoiceServiceTest.php
Browser tests*Test.phpLoginTest.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

TraitPurpose
CreatesFakeModelsHelpers for creating common model setups
MocksServicesService mocking, facade faking, HTTP mocking
DetectsNPlusOneQueriesN+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 user
  • createClientUser() - Client user
  • createClientWithUser() - Client with attached user
  • createClientWithUsers(int $count) - Client with multiple users
  • createProjectWithClient() - Project with parent client
  • createProjectWithFiles(int $count) - Project with files
  • createInvoiceWithItems(int $count) - Invoice with line items
  • createPaidInvoice() - Paid invoice
  • createOverdueInvoice() - Overdue invoice
  • createFullClientSetup() - Complete client setup
  • createAuditTemplate() - Audit template
  • createCompletedAudit() - 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 NotificationService
  • mockRealtimeNotificationService() - Mock RealtimeNotificationService
  • mockPdfService() - Mock PdfService
  • mockFileEncryptionService() - Mock FileEncryptionService
  • mockStorageDisk(string $disk) - Mock a storage disk
  • mockService(string $class) - Mock any service by class
  • partialMockService(string $class) - Partial mock (real methods unless mocked)
  • spyOnService(string $class) - Create a spy
  • mockHttpClient() - Fake all HTTP requests
  • mockHttpResponses(array $responses) - Fake specific HTTP responses
  • fakeQueue() - Fake queue dispatch
  • fakeEvents() - Fake event dispatch
  • fakeNotifications() - Fake notifications
  • fakeMail() - 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

AreaTargetPriority
Authentication90%Critical
Authorization (Policies)80%Critical
Payment Processing90%Critical
Services70%High
Controllers60%Medium
Models50%Medium

Commands Reference

CommandDescription
php artisan testRun all tests
php artisan test --filter=ClientTestRun specific test class
php artisan test --filter=test_admin_can_createRun specific test method
php artisan test tests/FeatureRun tests in directory
php artisan test --parallelRun tests in parallel
php artisan test --coverageRun with coverage report
php artisan test --stop-on-failureStop on first failure

Composer Scripts

ScriptDescription
composer testRun all tests in parallel
composer test:sofRun tests in parallel, stop on failure
composer test:logRun tests and save output to log file
composer test:reportRun tests and generate visual HTML report in tests/.reports/
composer test:coverageGenerate HTML coverage report in tests/.coverage/html
composer test:coverage-reportShow 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'));