Files
AFC-Demo/docs/testing-guide.md
T
cbazza 2f7bd923df
Build and Push Docker Image only on Conventional Commits / build (push) Has been cancelled
local-docker-db branch openend
2025-12-03 12:59:30 +01:00

24 KiB

Testing-Guide: Risk Intelligence Platform

📋 Inhaltsverzeichnis

  1. Übersicht
  2. Test-Framework: Pest
  3. Test-Struktur
  4. Tests ausführen
  5. Feature Tests schreiben
  6. Unit Tests schreiben
  7. Livewire/Volt Tests
  8. Database Testing
  9. Test-Factories
  10. Best Practices
  11. Coverage & CI/CD
  12. Troubleshooting

Übersicht

Die Risk Intelligence Platform nutzt Pest v4 als Testing-Framework - eine moderne, ausdrucksstarke Alternative zu PHPUnit.

Test-Philosophie

Feature Tests über Unit Tests - Teste Verhalten, nicht Implementierung Factories für Test-Daten - Konsistente, realistische Test-Daten RefreshDatabase - Jeder Test startet mit sauberer DB Arrange-Act-Assert - Klare Test-Struktur Beschreibende Test-Namen - Tests als lebende Dokumentation

Aktuelle Test-Coverage

tests/
├── Feature/          # 19 Test-Dateien
│   ├── Auth/        # Authentication & 2FA
│   ├── Settings/    # User Settings
│   ├── Jobs/        # Background Jobs
│   └── Livewire/    # Livewire Components
└── Unit/            # 1 Test-Datei
    └── ExampleTest.php

Gesamt: ~20 Test-Dateien mit 50+ einzelnen Tests


Test-Framework: Pest

Warum Pest?

Pest v4 bietet moderne Testing-Features:

  • Ausdrucksstarke Syntax - test() statt public function test...()
  • Expectations API - expect($value)->toBe(true)
  • Datasets - Parametrisierte Tests
  • Higher Order Tests - ->it() Syntax
  • Browser Testing - Integriertes Browser-Testing (Pest v4)
  • Parallel Execution - Schnellere Test-Ausführung

Pest vs PHPUnit

// PHPUnit (alt)
class UserTest extends TestCase
{
    public function test_user_can_login()
    {
        $this->assertTrue(true);
    }
}

// Pest (modern)
test('user can login', function () {
    expect(true)->toBeTrue();
});

Test-Struktur

Verzeichnis-Layout

tests/
├── Pest.php                    # Pest-Konfiguration
├── TestCase.php                # Basis Test-Klasse
│
├── Feature/                    # Feature Tests (End-to-End)
│   ├── Auth/
│   │   ├── AuthenticationTest.php
│   │   ├── RegistrationTest.php
│   │   ├── PasswordResetTest.php
│   │   ├── EmailVerificationTest.php
│   │   ├── PasswordConfirmationTest.php
│   │   └── TwoFactorChallengeTest.php
│   │
│   ├── Settings/
│   │   ├── ProfileUpdateTest.php
│   │   ├── PasswordUpdateTest.php
│   │   └── TwoFactorAuthenticationTest.php
│   │
│   ├── Jobs/
│   │   └── SyncBackendDataPoolTest.php
│   │
│   ├── Livewire/
│   │   └── Upload/
│   │       └── IndexTest.php
│   │
│   ├── CompanySearchTest.php
│   ├── TransactionReviewTest.php
│   ├── CompanyTransactionsTest.php
│   ├── BackendModelsTest.php
│   ├── NavigationTest.php
│   ├── DashboardTest.php
│   └── ExampleTest.php
│
└── Unit/                       # Unit Tests (isoliert)
    └── ExampleTest.php

Test-Typen

Typ Zweck Beispiel
Feature End-to-End User Flows "User kann sich einloggen"
Unit Einzelne Klassen/Methoden "calculateRiskScore() gibt korrekten Wert zurück"
Browser UI-Tests im echten Browser "Click-Flow durch Transaction Review"

Tests ausführen

Basis-Commands

# Alle Tests ausführen
php artisan test

# Oder direkt mit Pest
./vendor/bin/pest

# Nur Feature Tests
php artisan test --testsuite=Feature

# Nur Unit Tests
php artisan test --testsuite=Unit

# Parallele Ausführung (schneller)
php artisan test --parallel

Spezifische Tests

# Einzelne Test-Datei
php artisan test tests/Feature/CompanySearchTest.php

# Test mit bestimmtem Namen
php artisan test --filter="user can login"

# Test-Gruppe
php artisan test --group=auth

# Mit Ausgabe-Details
php artisan test --verbose

# Stopp beim ersten Fehler
php artisan test --stop-on-failure

Output-Formate

# Minimal (nur Zusammenfassung)
php artisan test --compact

# Mit Coverage (benötigt Xdebug)
php artisan test --coverage

# Mit Coverage-Minimum
php artisan test --coverage --min=80

# HTML Coverage Report
php artisan test --coverage-html coverage/

Feature Tests schreiben

Feature Tests testen User Flows und End-to-End Szenarien.

Beispiel 1: Authentifizierung

<?php

use App\Models\User;

test('user can login with valid credentials', function () {
    // Arrange - Test-User erstellen
    $user = User::factory()->create([
        'email' => 'test@example.com',
        'password' => bcrypt('password'),
    ]);

    // Act - Login-Versuch
    $response = $this->post('/login', [
        'email' => 'test@example.com',
        'password' => 'password',
    ]);

    // Assert - Prüfungen
    $response->assertRedirect('/dashboard');
    $this->assertAuthenticated();
});

test('user cannot login with invalid password', function () {
    $user = User::factory()->create([
        'email' => 'test@example.com',
        'password' => bcrypt('password'),
    ]);

    $response = $this->post('/login', [
        'email' => 'test@example.com',
        'password' => 'wrong-password',
    ]);

    $response->assertSessionHasErrors();
    $this->assertGuest();
});
<?php

use App\Models\Company;
use App\Models\Transaction;
use App\Models\User;

test('authenticated users can visit company search page', function () {
    // Arrange
    $this->actingAs(User::factory()->create());

    // Act
    $response = $this->get(route('company-search'));

    // Assert
    $response->assertOk();
    $response->assertSee('Unternehmensauskunft');
});

test('search filters companies by name', function () {
    $this->actingAs(User::factory()->create());

    // Arrange - Test-Companies erstellen
    $mercedes = Company::factory()->create([
        'name' => 'Mercedes-Benz Group AG',
        'ticker' => 'MBG',
    ]);

    $volkswagen = Company::factory()->create([
        'name' => 'Volkswagen AG',
        'ticker' => 'VOW',
    ]);

    Transaction::factory()->for($mercedes)->create();
    Transaction::factory()->for($volkswagen)->create();

    // Act - Suche nach "Mercedes"
    $component = Livewire\Volt\Volt::test('company-search')
        ->set('search', 'Mercedes');

    // Assert - Nur Mercedes gefunden
    $companies = $component->get('companies');

    expect($companies)
        ->toHaveCount(1)
        ->and($companies->first()->id)->toBe($mercedes->id);
});

Beispiel 3: Job Testing

<?php

use App\Jobs\SyncBackendDataPool;
use Illuminate\Support\Facades\DB;

test('full sync truncates and rebuilds data pool', function () {
    // Arrange - Alte Daten einfügen
    DB::table('backend_data_pool')->insert([
        'transaction_id' => 999,
        'corporate_entity' => 'Old Company',
        'tx_amount' => 1000.00,
        'status' => 'done',
        'prompt_id' => 1,
        'output_key' => 'test_key',
        'content' => 'test content',
        'created_at' => now(),
        'last_modified_at' => now(),
        'synced_at' => now(),
    ]);

    expect(DB::table('backend_data_pool')->count())->toBe(1);

    // Act - Full Sync ausführen
    $job = new SyncBackendDataPool(fullSync: true, batchSize: 100);
    $job->handle();

    // Assert - Alte Daten entfernt
    $oldRecord = DB::table('backend_data_pool')
        ->where('transaction_id', 999)
        ->first();

    expect($oldRecord)->toBeNull();
});

test('incremental sync only adds new records', function () {
    // Arrange - Initial Sync
    $job = new SyncBackendDataPool(fullSync: true);
    $job->handle();

    $initialCount = DB::table('backend_data_pool')->count();

    // Act - Incremental Sync
    $incrementalJob = new SyncBackendDataPool(fullSync: false);
    $incrementalJob->handle();

    // Assert - Keine Duplikate
    $newCount = DB::table('backend_data_pool')->count();
    expect($newCount)->toBe($initialCount);
});

Unit Tests schreiben

Unit Tests testen einzelne Klassen/Methoden isoliert.

Beispiel 1: KYC Risk Calculator

<?php

use App\Services\KycRiskCalculator;

test('calculates low risk correctly', function () {
    $calculator = new KycRiskCalculator();

    $result = $calculator->calculate([
        'transaction_score' => 20,
        'sanctions' => 0,
        'country_risk' => 10,
        'pep' => 0,
        'corruption' => 0,
    ]);

    expect($result['level'])->toBe('low')
        ->and($result['score'])->toBeLessThan(50);
});

test('calculates critical risk with sanctions', function () {
    $calculator = new KycRiskCalculator();

    $result = $calculator->calculate([
        'transaction_score' => 50,
        'sanctions' => 100,  // Critical!
        'country_risk' => 30,
        'pep' => 20,
        'corruption' => 10,
    ]);

    expect($result['level'])->toBe('critical')
        ->and($result['score'])->toBeGreaterThan(70);
});

Beispiel 2: Model Methods

<?php

use App\Models\Transaction;
use App\Models\Company;

test('transaction statusLabel returns correct labels', function () {
    $transaction = Transaction::factory()->make([
        'status' => Transaction::STATUS_TRUE_POSITIVE,
    ]);

    expect($transaction->statusLabel())->toBe('Kritisches Risiko');

    $transaction->status = Transaction::STATUS_CLEARED;
    expect($transaction->statusLabel())->toBe('Geringes Risiko');
});

test('company has many transactions relationship', function () {
    $company = Company::factory()
        ->has(Transaction::factory()->count(3))
        ->create();

    expect($company->transactions)->toHaveCount(3);
    expect($company->transactions->first())->toBeInstanceOf(Transaction::class);
});

Livewire/Volt Tests

Livewire Volt Component Tests

<?php

use App\Models\User;
use Livewire\Volt\Volt;

test('component initializes with empty search', function () {
    $this->actingAs(User::factory()->create());

    Volt::test('company-search')
        ->assertSet('search', '')
        ->assertOk();
});

test('search input updates component state', function () {
    $this->actingAs(User::factory()->create());

    Volt::test('company-search')
        ->assertSet('search', '')
        ->set('search', 'Mercedes')
        ->assertSet('search', 'Mercedes');
});

test('component displays search results', function () {
    $this->actingAs(User::factory()->create());

    Company::factory()->create(['name' => 'Mercedes-Benz AG']);

    Volt::test('company-search')
        ->set('search', 'Mercedes')
        ->assertSee('Mercedes-Benz AG');
});

test('component calls action method', function () {
    $this->actingAs(User::factory()->create());

    $company = Company::factory()->create();

    Volt::test('company-search')
        ->call('viewCompany', $company->id)
        ->assertRedirect(route('company.transactions', $company));
});

Testing Livewire Properties

test('component has required properties', function () {
    $this->actingAs(User::factory()->create());

    Volt::test('company-search')
        ->assertPropertyWired('search')  // wire:model="search"
        ->assertSee('wire:model.live.debounce.300ms="search"', false);
});

test('component computes overview correctly', function () {
    $this->actingAs(User::factory()->create());

    Company::factory()
        ->has(Transaction::factory()->requiresReview()->count(5))
        ->create();

    $component = Volt::test('company-search');

    $overview = $component->get('overview');

    expect($overview)
        ->toHaveKey('companies')
        ->toHaveKey('open_alerts')
        ->toHaveKey('open_volume');
});

Database Testing

RefreshDatabase Trait

// tests/Pest.php
pest()->extend(Tests\TestCase::class)
    ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
    ->in('Feature');

Was macht RefreshDatabase?

  • Migriert DB vor jedem Test
  • Rollt Änderungen nach jedem Test zurück
  • Nutzt Transactions für Speed

Database Assertions

use Illuminate\Foundation\Testing\RefreshDatabase;

test('company is created in database', function () {
    $company = Company::factory()->create([
        'name' => 'Test AG',
    ]);

    // Assert in DB
    $this->assertDatabaseHas('companies', [
        'name' => 'Test AG',
    ]);

    // Oder mit Pest Expectation
    expect(Company::where('name', 'Test AG')->exists())->toBeTrue();
});

test('transaction is deleted with company', function () {
    $company = Company::factory()
        ->has(Transaction::factory())
        ->create();

    $transactionId = $company->transactions->first()->id;

    // Delete Company (CASCADE DELETE)
    $company->delete();

    // Assert Transaction auch gelöscht
    $this->assertDatabaseMissing('transactions', [
        'id' => $transactionId,
    ]);
});

Seeding in Tests

test('can filter high risk transactions', function () {
    // Seed Daten
    $this->seed(CompanySeeder::class);

    // Oder inline
    Company::factory()
        ->has(
            Transaction::factory()
                ->status(Transaction::STATUS_TRUE_POSITIVE)
                ->count(3)
        )
        ->create();

    $highRisk = Transaction::where('status', 'true_positive')->get();

    expect($highRisk)->toHaveCount(3);
});

Test-Factories

Factories generieren konsistente Test-Daten.

Company Factory

// database/factories/CompanyFactory.php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class CompanyFactory extends Factory
{
    public function definition(): array
    {
        return [
            'name' => fake()->company(),
            'legal_name' => fake()->company() . ' AG',
            'ticker' => strtoupper(fake()->lexify('???')),
            'sector' => fake()->randomElement([
                'Automotive', 'Technology', 'Finance', 'Healthcare'
            ]),
            'country' => fake()->countryCode(),
            'headquarters' => fake()->city() . ', ' . fake()->country(),
            'kyc_risk_level' => fake()->randomElement(['low', 'high', 'critical']),
            'summary' => fake()->paragraph(),
        ];
    }

    // Custom States
    public function highRisk(): static
    {
        return $this->state(fn (array $attributes) => [
            'kyc_risk_level' => 'high',
        ]);
    }

    public function criticalRisk(): static
    {
        return $this->state(fn (array $attributes) => [
            'kyc_risk_level' => 'critical',
        ]);
    }
}

Transaction Factory

// database/factories/TransactionFactory.php

class TransactionFactory extends Factory
{
    public function definition(): array
    {
        return [
            'company_id' => Company::factory(),
            'reference' => 'TXN-' . fake()->unique()->randomNumber(6),
            'amount' => fake()->randomFloat(2, 100, 1000000),
            'currency' => 'EUR',
            'counterparty' => fake()->company(),
            'counterparty_country' => fake()->countryCode(),
            'channel' => fake()->randomElement(['SEPA', 'SWIFT', 'Internal']),
            'executed_at' => fake()->dateTimeBetween('-1 year', 'now'),
            'risk_score' => fake()->numberBetween(0, 255),
            'status' => fake()->randomElement([
                Transaction::STATUS_CLEARED,
                Transaction::STATUS_FALSE_POSITIVE,
                Transaction::STATUS_TRUE_POSITIVE,
            ]),
            'requires_review' => fake()->boolean(),
        ];
    }

    // States
    public function requiresReview(bool $value = true): static
    {
        return $this->state(fn (array $attributes) => [
            'requires_review' => $value,
        ]);
    }

    public function status(string $status): static
    {
        return $this->state(fn (array $attributes) => [
            'status' => $status,
        ]);
    }

    public function highRisk(): static
    {
        return $this->state(fn (array $attributes) => [
            'risk_score' => fake()->numberBetween(150, 200),
            'status' => Transaction::STATUS_FALSE_POSITIVE,
            'requires_review' => true,
        ]);
    }

    public function criticalRisk(): static
    {
        return $this->state(fn (array $attributes) => [
            'risk_score' => fake()->numberBetween(200, 255),
            'status' => Transaction::STATUS_TRUE_POSITIVE,
            'requires_review' => true,
        ]);
    }
}

Factory-Nutzung in Tests

// Einfach
$company = Company::factory()->create();

// Mit Overrides
$company = Company::factory()->create([
    'name' => 'Custom Name AG',
]);

// Mit State
$company = Company::factory()->highRisk()->create();

// Mit Beziehungen
$company = Company::factory()
    ->has(Transaction::factory()->count(5))
    ->create();

// Oder anders herum
$transaction = Transaction::factory()
    ->for(Company::factory()->highRisk())
    ->create();

// Mehrere mit State-Chain
$companies = Company::factory()
    ->count(3)
    ->highRisk()
    ->create();

// Ohne DB-Speicherung (nur Object)
$company = Company::factory()->make();

Best Practices

1. Arrange-Act-Assert Pattern

test('user can update profile', function () {
    // Arrange - Setup
    $user = User::factory()->create(['name' => 'Old Name']);
    $this->actingAs($user);

    // Act - Aktion ausführen
    $response = $this->put('/profile', [
        'name' => 'New Name',
        'email' => $user->email,
    ]);

    // Assert - Prüfungen
    $response->assertRedirect();
    expect($user->fresh()->name)->toBe('New Name');
});

2. Beschreibende Test-Namen

// ✅ Gut
test('user cannot delete other users transactions', function () { ... });

// ❌ Schlecht
test('test1', function () { ... });

3. One Concept per Test

// ✅ Gut - Ein Test pro Konzept
test('validates required name field', function () { ... });
test('validates email format', function () { ... });
test('validates unique email', function () { ... });

// ❌ Schlecht - Zu viel in einem Test
test('validates all form fields', function () {
    // Tests name, email, password, etc...
});

4. Nutze Datasets für ähnliche Tests

test('validates email format', function (string $email, bool $valid) {
    $response = $this->post('/register', [
        'email' => $email,
        'password' => 'password',
    ]);

    if ($valid) {
        $response->assertSessionHasNoErrors('email');
    } else {
        $response->assertSessionHasErrors('email');
    }
})->with([
    'valid email' => ['test@example.com', true],
    'missing @' => ['testexample.com', false],
    'missing domain' => ['test@', false],
    'spaces' => ['test @example.com', false],
]);

5. beforeEach & afterEach Hooks

beforeEach(function () {
    // Vor jedem Test in dieser Datei
    $this->user = User::factory()->create();
    $this->actingAs($this->user);
});

afterEach(function () {
    // Nach jedem Test
    // Cleanup falls nötig
});

test('can access dashboard', function () {
    // $this->user ist bereits verfügbar
    $this->get('/dashboard')->assertOk();
});

6. Test Doubles & Mocking

use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeMail;

test('sends welcome email on registration', function () {
    // Arrange - Mail mocken
    Mail::fake();

    // Act
    $this->post('/register', [
        'name' => 'Test User',
        'email' => 'test@example.com',
        'password' => 'password',
    ]);

    // Assert - Mail wurde gesendet
    Mail::assertSent(WelcomeMail::class, function ($mail) {
        return $mail->hasTo('test@example.com');
    });
});

Coverage & CI/CD

Code Coverage generieren

# Einfacher Coverage-Report
php artisan test --coverage

# Mit Minimum-Schwellwert
php artisan test --coverage --min=80

# HTML Report
php artisan test --coverage-html coverage/

# Report öffnen
open coverage/index.html

Coverage-Konfiguration

<!-- phpunit.xml -->
<source>
    <include>
        <directory>app</directory>
    </include>
    <exclude>
        <directory>app/Console/Commands</directory>
        <file>app/Providers/AppServiceProvider.php</file>
    </exclude>
</source>

GitHub Actions Integration

# .github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: 8.4
          extensions: pdo_pgsql, mbstring, xml

      - name: Install Dependencies
        run: composer install --no-interaction

      - name: Run Tests
        run: php artisan test --parallel

Troubleshooting

Problem: "Database file does not exist"

Lösung:

# SQLite DB erstellen
touch database/database.sqlite

# Oder in .env.testing
DB_CONNECTION=sqlite
DB_DATABASE=:memory:

Problem: "Class not found in test"

Lösung:

composer dump-autoload
php artisan optimize:clear

Problem: "RefreshDatabase Migration failed"

Lösung:

# Migrations prüfen
php artisan migrate:status

# Rollback & Fresh
php artisan migrate:fresh --env=testing

Problem: Tests laufen sehr langsam

Lösungen:

  1. Parallele Ausführung:
php artisan test --parallel
  1. SQLite statt PostgreSQL:
<!-- phpunit.xml -->
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
  1. Bcrypt Rounds reduzieren:
<env name="BCRYPT_ROUNDS" value="4"/>

Problem: "Too many connections" Error

Ursache: Zu viele parallele DB-Connections

Lösung:

# Weniger Prozesse
php artisan test --parallel --processes=2

Nützliche Commands

# Test-Liste anzeigen
php artisan test --list-tests

# Neue Test-Datei erstellen
php artisan make:test CompanyTest              # Feature Test
php artisan make:test CompanyTest --unit       # Unit Test
php artisan make:test CompanyTest --pest       # Pest Syntax

# Test mit Debugging
php artisan test --filter="specific test" --stop-on-failure

# Watch Mode (re-run bei Änderungen)
./vendor/bin/pest --watch

# Nur fehlgeschlagene Tests
php artisan test --failed

Test-Checkliste

Beim Schreiben neuer Features:

  • Feature Test geschrieben?
  • Edge Cases getestet?
  • Validation getestet?
  • Authorization getestet?
  • Database-Constraints getestet?
  • Error Handling getestet?
  • Factories aktualisiert?
  • Tests laufen durch (php artisan test)?
  • Code formatiert (vendor/bin/pint)?

Zusammenfassung

Test-Pyramide

        /\
       /  \  Unit Tests (schnell, viele)
      /____\
     /      \
    / Feature \  Feature Tests (mittel, weniger)
   /___________\
  /             \
 /   Browser     \  Browser Tests (langsam, wenige)
/_________________\

Coverage-Ziele

Bereich Target Coverage
Models 90%+
Services 85%+
Jobs 80%+
Controllers 70%+
Commands 60%+

Test-Performance

Anzahl Tests Laufzeit (sequenziell) Laufzeit (parallel)
50 Tests ~30s ~10s
100 Tests ~60s ~20s
200 Tests ~120s ~40s

Erstellt: 2025-11-24 Version: 1.0 Autor: Risk Intelligence Platform Team