Overview
GB App uses PHPUnit for automated testing with Laravel’s testing helpers. The test suite covers feature tests (HTTP endpoints) and unit tests (individual classes).Test Structure
tests/
├── Feature/ # Feature tests (HTTP, integration)
│ ├── Auth/
│ │ ├── LoginTest.php
│ │ ├── TwoFactorAuthenticationTest.php
│ │ └── PasswordResetTest.php
│ ├── ReportControllerTest.php
│ ├── UserControllerTest.php
│ ├── RoleControllerTest.php
│ └── DesignRequestTest.php
├── Unit/ # Unit tests (isolated classes)
│ ├── Models/
│ │ ├── UserTest.php
│ │ └── ReportTest.php
│ └── Traits/
│ └── PowerBITraitTest.php
├── TestCase.php # Base test case
└── CreatesApplication.php # Application factory
Running Tests
Run All Tests
php artisan test
Run Specific Test File
php artisan test tests/Feature/ReportControllerTest.php
Run Specific Test Method
php artisan test --filter testUserCanViewReports
Run Tests with Coverage
php artisan test --coverage
Parallel Testing
Run tests in parallel for faster execution:php artisan test --parallel
Test Configuration
PHPUnit Configuration
File:phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value="gbapp_test"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
</php>
</phpunit>
Test Database
Create a separate test database:CREATE DATABASE gbapp_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Never run tests against your production database. Always use a dedicated test database.
Writing Feature Tests
Authentication Tests
File:tests/Feature/Auth/LoginTest.php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class LoginTest extends TestCase
{
use RefreshDatabase;
public function test_login_screen_can_be_rendered(): void
{
$response = $this->get('/login');
$response->assertStatus(200);
}
public function test_users_can_authenticate_using_the_login_screen(): void
{
$user = User::factory()->create();
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard'));
}
public function test_users_cannot_authenticate_with_invalid_password(): void
{
$user = User::factory()->create();
$this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
}
}
Controller Tests
File:tests/Feature/ReportControllerTest.php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Report;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ReportControllerTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_view_reports(): void
{
$user = User::factory()->create();
$reports = Report::factory()->count(3)->create();
$user->reports()->attach($reports);
$response = $this->actingAs($user)
->get(route('report.index'));
$response->assertStatus(200);
$response->assertInertia(fn ($page) =>
$page->component('Report/Index')
->has('reports', 3)
);
}
public function test_user_can_create_report(): void
{
$user = User::factory()->create();
$user->givePermissionTo('report.create');
$reportData = [
'name' => 'Test Report',
'group_id' => 'test-group-id',
'report_id' => 'test-report-id',
'dataset_id' => 'test-dataset-id',
'access_level' => 'View',
];
$response = $this->actingAs($user)
->post(route('report.store'), $reportData);
$response->assertStatus(200);
$this->assertDatabaseHas('reports', [
'name' => 'Test Report',
'user_id' => $user->id,
]);
}
public function test_user_without_permission_cannot_create_report(): void
{
$user = User::factory()->create();
// No permission assigned
$response = $this->actingAs($user)
->post(route('report.store'), [
'name' => 'Test Report',
]);
$response->assertStatus(403);
}
public function test_super_admin_can_see_all_reports(): void
{
$admin = User::factory()->create();
$admin->assignRole('super-admin');
$reports = Report::factory()->count(5)->create();
$response = $this->actingAs($admin)
->get(route('report.index'));
$response->assertStatus(200);
$response->assertInertia(fn ($page) =>
$page->has('reports', 5)
);
}
}
Writing Unit Tests
Model Tests
File:tests/Unit/Models/UserTest.php
namespace Tests\Unit\Models;
use App\Models\User;
use App\Models\Report;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserTest extends TestCase
{
use RefreshDatabase;
public function test_user_has_reports_relationship(): void
{
$user = User::factory()->create();
$report = Report::factory()->create();
$user->reports()->attach($report);
$this->assertInstanceOf('Illuminate\Database\Eloquent\Collection', $user->reports);
$this->assertEquals(1, $user->reports->count());
}
public function test_user_can_check_permission(): void
{
$user = User::factory()->create();
$user->givePermissionTo('report.create');
$this->assertTrue($user->can('report.create'));
$this->assertFalse($user->can('report.delete'));
}
public function test_user_can_have_advisor(): void
{
$advisor = User::factory()->create();
$technician = User::factory()->create(['advisor_id' => $advisor->id]);
$this->assertEquals($advisor->id, $technician->advisor->id);
}
}
Trait Tests
File:tests/Unit/Traits/PowerBITraitTest.php
namespace Tests\Unit\Traits;
use App\Traits\PowerBITrait;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class PowerBITraitTest extends TestCase
{
use PowerBITrait;
public function test_get_user_access_token_returns_token(): void
{
Http::fake([
'https://login.windows.net/*' => Http::response([
'access_token' => 'fake-access-token',
], 200),
]);
config([
'power-bi.username' => '[email protected]',
'power-bi.password' => 'password',
]);
$token = $this->getUserAccessToken();
$this->assertEquals('fake-access-token', $token);
}
}
Test Factories
User Factory
File:database/factories/UserFactory.php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class UserFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'remember_token' => Str::random(10),
];
}
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
Report Factory
File:database/factories/ReportFactory.php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class ReportFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->words(3, true),
'group_id' => fake()->uuid(),
'report_id' => fake()->uuid(),
'dataset_id' => fake()->uuid(),
'access_level' => fake()->randomElement(['View', 'Edit', 'Create']),
'user_id' => User::factory(),
];
}
}
Database Testing
RefreshDatabase Trait
UseRefreshDatabase to reset the database between tests:
use Illuminate\Foundation\Testing\RefreshDatabase;
class ReportControllerTest extends TestCase
{
use RefreshDatabase;
// Database is migrated fresh before each test
}
Database Assertions
// Assert record exists
$this->assertDatabaseHas('reports', [
'name' => 'Test Report',
'user_id' => $user->id,
]);
// Assert record doesn't exist
$this->assertDatabaseMissing('reports', [
'name' => 'Deleted Report',
]);
// Assert record count
$this->assertDatabaseCount('reports', 5);
// Assert soft deleted
$this->assertSoftDeleted('reports', [
'id' => $report->id,
]);
HTTP Testing
Assertions
// Status code
$response->assertStatus(200);
$response->assertOk();
$response->assertCreated();
$response->assertNoContent();
$response->assertNotFound();
$response->assertForbidden();
$response->assertUnauthorized();
// Redirects
$response->assertRedirect(route('dashboard'));
// JSON response
$response->assertJson([
'name' => 'Test Report',
]);
// Inertia
$response->assertInertia(fn ($page) =>
$page->component('Report/Index')
->has('reports', 10)
->where('flash.success', 'Report created')
);
Mocking External Services
HTTP Mocking
Mock Power BI API calls:use Illuminate\Support\Facades\Http;
public function test_power_bi_token_generation(): void
{
Http::fake([
'https://login.windows.net/*' => Http::response([
'access_token' => 'fake-user-token',
], 200),
'https://api.powerbi.com/*/GenerateToken' => Http::response([
'token' => 'fake-embed-token',
'tokenId' => 'token-id',
'expiration' => now()->addHours(1)->toIso8601String(),
], 200),
]);
$user = User::factory()->create();
$report = Report::factory()->create();
$response = $this->actingAs($user)
->get(route('report.view', [$report->group_id, $report->report_id]));
$response->assertOk();
Http::assertSentCount(2);
}
Testing Best Practices
1. Test One Thing Per Test
// Good
public function test_user_can_create_report(): void { }
public function test_report_requires_name(): void { }
// Bad
public function test_report_creation(): void
{
// Tests creation, validation, permissions, etc.
}
2. Use Descriptive Test Names
// Good
public function test_user_without_permission_cannot_delete_report(): void
// Bad
public function test_delete(): void
3. Arrange, Act, Assert Pattern
public function test_user_can_update_report(): void
{
// Arrange
$user = User::factory()->create();
$user->givePermissionTo('report.update');
$report = Report::factory()->create();
// Act
$response = $this->actingAs($user)
->put(route('report.update', $report), [
'name' => 'Updated Name',
]);
// Assert
$response->assertOk();
$this->assertDatabaseHas('reports', [
'id' => $report->id,
'name' => 'Updated Name',
]);
}
4. Clean Up After Tests
UseRefreshDatabase or clean up manually:
protected function tearDown(): void
{
// Clean up
parent::tearDown();
}
Continuous Integration
GitHub Actions Example
File:.github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
tests:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: gbapp_test
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.2
extensions: mbstring, pdo, pdo_mysql
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist
- name: Run Tests
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: gbapp_test
DB_USERNAME: root
DB_PASSWORD: password
run: php artisan test --coverage
Next Steps
Development Setup
Set up your testing environment
Coding Standards
Follow code quality guidelines
Architecture
Understand the system design
API Reference
Test API endpoints