Skip to content

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=test

Reset 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-interaction

Tests ausführen

Alle Tests

bash
vendor/bin/phpunit

Spezifische 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 testFromStringCreatesValidEmail

Mit Coverage

bash
# HTML Coverage Report
vendor/bin/phpunit --coverage-html coverage/

# Öffnen
open coverage/index.html

Test 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.xml

Best Practices

  1. Fast Tests: Unit Tests sollten < 1ms, Integration < 100ms sein
  2. Isolated: Jeder Test unabhängig, keine Side-Effects
  3. Readable: Test-Name beschreibt was getestet wird
  4. Arrange-Act-Assert: Klare Struktur
  5. One Assertion: Pro Test idealerweise eine Assertion
  6. Test Coverage: Ziel: > 80% Code Coverage
  7. Data Providers: Für parametrisierte Tests
  8. Fixtures: Wiederverwendbare Test-Daten
  9. Mocking: Nur externe Dependencies mocken
  10. 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ällig

Checkliste

  • [ ] PHPUnit installiert
  • [ ] phpunit.dist.xml konfiguriert
  • [ ] 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

Weitere Ressourcen

Built with VitePress