1048 lines
24 KiB
Markdown
1048 lines
24 KiB
Markdown
# 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
|