Files
AFC-Demo/docs/testing-guide.md
T

1048 lines
24 KiB
Markdown
Raw Normal View History

2025-12-03 12:59:30 +01:00
# Testing-Guide: Risk Intelligence Platform
## 📋 Inhaltsverzeichnis
1. [Übersicht](#übersicht)
2. [Test-Framework: Pest](#test-framework-pest)
3. [Test-Struktur](#test-struktur)
4. [Tests ausführen](#tests-ausführen)
5. [Feature Tests schreiben](#feature-tests-schreiben)
6. [Unit Tests schreiben](#unit-tests-schreiben)
7. [Livewire/Volt Tests](#livewirevoltt-tests)
8. [Database Testing](#database-testing)
9. [Test-Factories](#test-factories)
10. [Best Practices](#best-practices)
11. [Coverage & CI/CD](#coverage--cicd)
12. [Troubleshooting](#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
```php
// 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
```bash
# 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
```bash
# 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
```bash
# 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
<?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();
});
```
### Beispiel 2: Company Search
```php
<?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
<?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
<?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
<?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
<?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
```php
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
```php
// 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
```php
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
```php
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
```php
// 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
```php
// 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
```php
// 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
```php
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
```php
// ✅ Gut
test('user cannot delete other users transactions', function () { ... });
// ❌ Schlecht
test('test1', function () { ... });
```
### 3. One Concept per Test
```php
// ✅ 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
```php
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
```php
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
```php
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
```bash
# 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
```xml
<!-- 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
```yaml
# .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:**
```bash
# 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:**
```bash
composer dump-autoload
php artisan optimize:clear
```
---
### Problem: "RefreshDatabase Migration failed"
**Lösung:**
```bash
# 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:**
```bash
php artisan test --parallel
```
2. **SQLite statt PostgreSQL:**
```xml
<!-- phpunit.xml -->
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
```
3. **Bcrypt Rounds reduzieren:**
```xml
<env name="BCRYPT_ROUNDS" value="4"/>
```
---
### Problem: "Too many connections" Error
**Ursache:** Zu viele parallele DB-Connections
**Lösung:**
```bash
# Weniger Prozesse
php artisan test --parallel --processes=2
```
---
## Nützliche Commands
```bash
# 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