Testing Tools & Guide
Anleitung zum Testen der Appiyon-Plattform mit PHPUnit.
Übersicht
Die Appiyon-Plattform verwendet PHPUnit für Tests, organisiert nach Layer und Test-Typ.
Test-Struktur
tests/
├── Unit/ # Isolierte Unit-Tests
│ └── Appi/
│ ├── Infrastructure/
│ ├── Shared/
│ ├── Foundation/
│ ├── Core/
│ ├── Domain/
│ └── Dev/
├── Integration/ # Integration-Tests (mit DB, Services)
│ └── Appi/
│ ├── Infrastructure/
│ ├── Shared/
│ └── ...
└── Functional/ # End-to-End Tests
└── Appi/
├── Infrastructure/
├── Shared/
└── ...PHPUnit Konfiguration
phpunit.dist.xml
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="tests/bootstrap.php"
colors="true"
executionOrder="depends,defects"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<!-- Unit Tests (schnell, keine Dependencies) -->
<testsuite name="Unit:Infrastructure">
<directory>tests/Unit/Appi/Infrastructure</directory>
</testsuite>
<testsuite name="Unit:Shared">
<directory>tests/Unit/Appi/Shared</directory>
</testsuite>
<!-- ... alle Layer -->
<!-- Integration Tests (mit DB, Services) -->
<testsuite name="Integration:Infrastructure">
<directory>tests/Integration/Appi/Infrastructure</directory>
</testsuite>
<!-- ... alle Layer -->
<!-- Functional Tests (End-to-End) -->
<testsuite name="Functional:Infrastructure">
<directory>tests/Functional/Appi/Infrastructure</directory>
</testsuite>
<!-- ... alle Layer -->
</testsuites>
<php>
<ini name="display_errors" value="1"/>
<ini name="error_reporting" value="-1"/>
<server name="APP_ENV" value="test" force="true"/>
<server name="SHELL_VERBOSITY" value="-1"/>
<server name="SYMFONY_PHPUNIT_REMOVE" value=""/>
<server name="SYMFONY_PHPUNIT_VERSION" value="10.5"/>
</php>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
<exclude>
<directory>src/Appi/*/Entity</directory>
<directory>src/Appi/*/ValueObject</directory>
<file>src/Kernel.php</file>
</exclude>
</source>
</phpunit>Test-Typen
Unit Tests
Isolierte Tests ohne externe Dependencies.
Beispiel: Value Object Test
php
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Appi\Infrastructure\Admin\ValueObject;
use App\Appi\Infrastructure\Admin\ValueObject\AdminEmail;
use App\Appi\Shared\Exception\Domain\DomainException;
use PHPUnit\Framework\TestCase;
class AdminEmailTest extends TestCase
{
public function testFromStringCreatesValidEmail(): void
{
$email = AdminEmail::fromString('admin@example.com');
$this->assertEquals('admin@example.com', $email->getValue());
}
public function testFromStringConvertsToLowercase(): void
{
$email = AdminEmail::fromString('ADMIN@EXAMPLE.COM');
$this->assertEquals('admin@example.com', $email->getValue());
}
public function testFromStringThrowsOnInvalidFormat(): void
{
$this->expectException(DomainException::class);
$this->expectExceptionMessage('Invalid email format');
AdminEmail::fromString('not-an-email');
}
public function testFromStringThrowsOnTooLong(): void
{
$this->expectException(DomainException::class);
$this->expectExceptionMessage('Email must not exceed 255 characters');
$longEmail = str_repeat('a', 250) . '@example.com';
AdminEmail::fromString($longEmail);
}
}Beispiel: Use Case Test
php
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Appi\Infrastructure\Admin\UseCase\CreateAdmin;
use App\Appi\Infrastructure\Admin\Contract\AdminRepositoryInterface;
use App\Appi\Infrastructure\Admin\Entity\Admin;
use App\Appi\Infrastructure\Admin\UseCase\CreateAdmin\CreateAdminCommand;
use App\Appi\Infrastructure\Admin\UseCase\CreateAdmin\CreateAdminHandler;
use App\Appi\Shared\Exception\Domain\DomainException;
use PHPUnit\Framework\TestCase;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class CreateAdminHandlerTest extends TestCase
{
private AdminRepositoryInterface $repository;
private EventDispatcherInterface $eventDispatcher;
private CreateAdminHandler $handler;
protected function setUp(): void
{
$this->repository = $this->createMock(AdminRepositoryInterface::class);
$this->eventDispatcher = $this->createMock(EventDispatcherInterface::class);
$this->handler = new CreateAdminHandler($this->repository, $this->eventDispatcher);
}
public function testHandleCreatesAdmin(): void
{
$command = new CreateAdminCommand(
'Test Admin',
'test@example.com',
'SecurePass123!'
);
$this->repository->expects($this->once())
->method('existsByEmail')
->with('test@example.com')
->willReturn(false);
$this->repository->expects($this->once())
->method('save')
->with($this->isInstanceOf(Admin::class));
$this->eventDispatcher->expects($this->once())
->method('dispatch');
$result = $this->handler->handle($command);
$this->assertInstanceOf(Admin::class, $result);
$this->assertEquals('Test Admin', $result->getName());
$this->assertEquals('test@example.com', $result->getEmail());
}
public function testHandleThrowsWhenEmailExists(): void
{
$command = new CreateAdminCommand(
'Test Admin',
'existing@example.com',
'SecurePass123!'
);
$this->repository->expects($this->once())
->method('existsByEmail')
->with('existing@example.com')
->willReturn(true);
$this->repository->expects($this->never())
->method('save');
$this->expectException(DomainException::class);
$this->expectExceptionMessage('Admin with this email already exists');
$this->handler->handle($command);
}
}Integration Tests
Tests mit realen Dependencies (Datenbank, Services).
Beispiel: Repository Test
php
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Appi\Infrastructure\Admin\Repository;
use App\Appi\Infrastructure\Admin\Entity\Admin;
use App\Appi\Infrastructure\Admin\Repository\AdminRepository;
use App\Appi\Infrastructure\Admin\ValueObject\AdminEmail;
use App\Appi\Infrastructure\Admin\ValueObject\AdminPassword;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class AdminRepositoryTest extends KernelTestCase
{
private AdminRepository $repository;
protected function setUp(): void
{
self::bootKernel();
$this->repository = self::getContainer()->get(AdminRepository::class);
}
public function testSaveAndFindById(): void
{
$admin = new Admin(
'Test Admin',
AdminEmail::fromString('test@example.com'),
AdminPassword::fromPlainText('TestPass123!')
);
$this->repository->save($admin);
$found = $this->repository->findById($admin->getId());
$this->assertNotNull($found);
$this->assertEquals($admin->getId(), $found->getId());
$this->assertEquals('Test Admin', $found->getName());
}
public function testFindByEmail(): void
{
$admin = new Admin(
'Test Admin',
AdminEmail::fromString('find@example.com'),
AdminPassword::fromPlainText('TestPass123!')
);
$this->repository->save($admin);
$found = $this->repository->findByEmail('find@example.com');
$this->assertNotNull($found);
$this->assertEquals('find@example.com', $found->getEmail());
}
public function testExistsByEmail(): void
{
$admin = new Admin(
'Test Admin',
AdminEmail::fromString('exists@example.com'),
AdminPassword::fromPlainText('TestPass123!')
);
$this->repository->save($admin);
$this->assertTrue($this->repository->existsByEmail('exists@example.com'));
$this->assertFalse($this->repository->existsByEmail('notexists@example.com'));
}
protected function tearDown(): void
{
parent::tearDown();
// Cleanup test data if needed
}
}Functional Tests
End-to-End Tests (HTTP Requests, Console Commands).
Beispiel: Console Command Test
php
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Appi\Dev\Console\Command;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
class CreateAdminCommandTest extends KernelTestCase
{
public function testExecuteCreatesAdmin(): void
{
$kernel = self::bootKernel();
$application = new Application($kernel);
$command = $application->find('admin:create');
$commandTester = new CommandTester($command);
$commandTester->execute([
'--name' => 'CLI Test Admin',
'--email' => 'clitest@example.com',
'--password' => 'SecurePass123!',
]);
$output = $commandTester->getDisplay();
$this->assertStringContainsString('Admin successfully created', $output);
$this->assertStringContainsString('clitest@example.com', $output);
$this->assertEquals(0, $commandTester->getStatusCode());
}
public function testExecuteFailsWithInvalidEmail(): void
{
$kernel = self::bootKernel();
$application = new Application($kernel);
$command = $application->find('admin:create');
$commandTester = new CommandTester($command);
$commandTester->execute([
'--name' => 'Test',
'--email' => 'invalid-email',
'--password' => 'SecurePass123!',
]);
$output = $commandTester->getDisplay();
$this->assertStringContainsString('Invalid email format', $output);
$this->assertEquals(1, $commandTester->getStatusCode());
}
}Beispiel: Controller Test
php
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Appi\Dev\Http\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class DashboardControllerTest extends WebTestCase
{
public function testDashboardAccessibleFromAllowedDomain(): void
{
$client = static::createClient();
$client->request('GET', '/admin', [], [], [
'HTTP_HOST' => 'appisym.go4family.net',
]);
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h1', 'Dashboard');
}
public function testDashboardDeniedFromWrongDomain(): void
{
$client = static::createClient();
$client->request('GET', '/admin', [], [], [
'HTTP_HOST' => 'wrongdomain.com',
]);
$this->assertResponseStatusCodeSame(403);
}
}Test-Database Setup
.env.test
bash
APP_ENV=test
DATABASE_URL="postgresql://appiyonadmin:password@127.0.0.1:5432/symfony_test?serverVersion=16&charset=utf8"Setup
bash
# Test-DB erstellen
php bin/console doctrine:database:create --env=test
# Migrations ausführen
php bin/console doctrine:migrations:migrate --env=test --no-interaction
# Schema validieren
php bin/console doctrine:schema:validate --env=testReset zwischen Tests
bash
# Alle Daten löschen, Schema behalten
php bin/console doctrine:schema:drop --env=test --force --full-database
php bin/console doctrine:migrations:migrate --env=test --no-interactionTests ausführen
Alle Tests
bash
vendor/bin/phpunitSpezifische Test-Suite
bash
# Nur Unit Tests Infrastructure
vendor/bin/phpunit --testsuite=Unit:Infrastructure
# Nur Integration Tests
vendor/bin/phpunit --testsuite=Integration:Infrastructure
# Alle Unit Tests
vendor/bin/phpunit tests/Unit/Einzelner Test
bash
vendor/bin/phpunit tests/Unit/Appi/Infrastructure/Admin/ValueObject/AdminEmailTest.php
# Mit Filter (spezifische Test-Methode)
vendor/bin/phpunit --filter testFromStringCreatesValidEmailMit Coverage
bash
# HTML Coverage Report
vendor/bin/phpunit --coverage-html coverage/
# Öffnen
open coverage/index.htmlTest Fixtures
Reusable Test Data
php
<?php
declare(strict_types=1);
namespace App\Tests\Fixtures;
use App\Appi\Infrastructure\Admin\Entity\Admin;
use App\Appi\Infrastructure\Admin\ValueObject\AdminEmail;
use App\Appi\Infrastructure\Admin\ValueObject\AdminPassword;
class AdminFixture
{
public static function create(
string $name = 'Test Admin',
string $email = 'test@example.com',
string $password = 'TestPass123!'
): Admin {
return new Admin(
$name,
AdminEmail::fromString($email),
AdminPassword::fromPlainText($password)
);
}
public static function createMultiple(int $count): array
{
$admins = [];
for ($i = 1; $i <= $count; $i++) {
$admins[] = self::create(
"Test Admin {$i}",
"test{$i}@example.com"
);
}
return $admins;
}
}Mocking
Repository Mock
php
$repository = $this->createMock(AdminRepositoryInterface::class);
$repository->expects($this->once())
->method('findById')
->with(1)
->willReturn($admin);Event Dispatcher Mock
php
$eventDispatcher = $this->createMock(EventDispatcherInterface::class);
$eventDispatcher->expects($this->once())
->method('dispatch')
->with($this->isInstanceOf(AdminCreatedEvent::class));Custom Mock Behavior
php
$repository->method('findById')
->willReturnCallback(function ($id) {
return $id === 1 ? $this->admin : null;
});Data Providers
Für parametrisierte Tests:
php
/**
* @dataProvider invalidEmailProvider
*/
public function testFromStringThrowsOnInvalidEmail(string $email): void
{
$this->expectException(DomainException::class);
AdminEmail::fromString($email);
}
public static function invalidEmailProvider(): array
{
return [
'no at sign' => ['notanemail'],
'no domain' => ['test@'],
'no local' => ['@example.com'],
'spaces' => ['test @example.com'],
'too long' => [str_repeat('a', 250) . '@example.com'],
];
}Test Performance
Database Transactions
Für schnellere Tests:
php
use Doctrine\DBAL\Connection;
class AdminRepositoryTest extends KernelTestCase
{
private Connection $connection;
protected function setUp(): void
{
parent::setUp();
$this->connection = self::getContainer()->get(Connection::class);
$this->connection->beginTransaction();
}
protected function tearDown(): void
{
$this->connection->rollBack();
parent::tearDown();
}
}In-Memory SQLite (fastest)
Für Unit/Integration Tests:
bash
# .env.test
DATABASE_URL="sqlite:///:memory:"CI/CD Integration
GitHub Actions
yaml
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: symfony_test
ports:
- 5432:5432
steps:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: pdo, pdo_pgsql, intl
- name: Install dependencies
run: composer install --prefer-dist
- name: Setup database
run: |
php bin/console doctrine:database:create --env=test
php bin/console doctrine:migrations:migrate --env=test --no-interaction
- name: Run tests
run: vendor/bin/phpunit --coverage-clover coverage.xml
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
files: ./coverage.xmlBest Practices
- Fast Tests: Unit Tests sollten < 1ms, Integration < 100ms sein
- Isolated: Jeder Test unabhängig, keine Side-Effects
- Readable: Test-Name beschreibt was getestet wird
- Arrange-Act-Assert: Klare Struktur
- One Assertion: Pro Test idealerweise eine Assertion
- Test Coverage: Ziel: > 80% Code Coverage
- Data Providers: Für parametrisierte Tests
- Fixtures: Wiederverwendbare Test-Daten
- Mocking: Nur externe Dependencies mocken
- Cleanup: Tests hinterlassen keine Daten
Code Coverage Ziele
- Overall: > 80%
- Use Cases: > 90% (kritische Business Logic)
- Repositories: > 80%
- Value Objects: 100% (wenig Code, hohe Wichtigkeit)
- Entities: 70% (viele Getters/Setters)
Test Commands
bash
# Alle Tests
vendor/bin/phpunit
# Spezifischer Layer
vendor/bin/phpunit tests/Unit/Appi/Infrastructure/
# Mit Verbosity
vendor/bin/phpunit --verbose
# Mit Coverage
vendor/bin/phpunit --coverage-text
# Nur gescheiterte Tests
vendor/bin/phpunit --filter testThatFailed
# Stop on Failure
vendor/bin/phpunit --stop-on-failure
# Test-Reihenfolge
vendor/bin/phpunit --order-by=depends # Nach Dependencies
vendor/bin/phpunit --order-by=random # ZufälligCheckliste
- [ ] PHPUnit installiert
- [ ]
phpunit.dist.xmlkonfiguriert - [ ] Test-Database eingerichtet
- [ ] Test-Struktur angelegt (Unit/Integration/Functional)
- [ ] Fixtures erstellt
- [ ] Unit Tests für Value Objects
- [ ] Unit Tests für Use Cases
- [ ] Integration Tests für Repositories
- [ ] Functional Tests für Console Commands
- [ ] Functional Tests für Controllers
- [ ] Code Coverage > 80%
- [ ] CI/CD Pipeline konfiguriert