Artikel

Unit-Tests erstellen mit einem Zauberprompt

DebuggingKIPHPSymfony

In modernen Symfony-Projekten sind automatisierte Unit-Tests unverzichtbar, um Code-Qualität und Wartbarkeit sicherzustellen. Doch das manuelle Schreiben von PHPUnit-Tests für jeden Controller ist zeitaufwändig und fehleranfällig. Genau hier setzt der Einsatz von Large Language Models (LLMs) wie ChatGPT an: Mit einem präzise formulierten „Magic Prompt“ lassen sich vollständige, lauffähige Testklassen automatisch generieren. In diesem Artikel zeige ich dir, wie du einen solchen Zauberprompt aufbaust, welche Vorteile er bietet und wie du ihn in deinen Entwicklungsprozess integrierst.


Warum ein Magic Prompt?

  1. Effizienzgewinn
    Anstatt Boilerplate-Code für jeden Controller von Hand zu schreiben, erzeugt das LLM auf Knopfdruck eine Testklasse – inklusive aller Mock-Definitionen, Stub-Konfigurationen und Assertions.
  2. Konsistenz & Best Practices
    Ein zentral definierter Prompt stellt sicher, dass alle Tests den gleichen Stil, die gleichen Mock-Regeln und Symfony-spezifischen Vorgaben befolgen.
  3. Onboarding
    Neue Teammitglieder verstehen den Testaufbau schneller, wenn er automatisch und nachvollziehbar aus demselben Prompt entsteht.

Aufbau des Zauberprompts

Ein hochwertiger Prompt muss alle Details enthalten, die für saubere PHPUnit-Tests nötig sind. Im Zentrum stehen:

  • Konstruktor-Injektion aller Abhängigkeiten (statt Reflection-Hacks).
  • Echte Instanzen für finale Klassen (z. B. RateLimiterFactory, Envelope).
  • Mocks nur für Interfaces und Services (Repository, Validator, MessageBusInterface etc.).
  • Exakte Methodensignaturen von Symfony-Hilfsmethoden (isGranted, getUser, json).
  • Verwendung von ConstraintViolationList statt einfacher Arrays.
  • PHPDoc-Annotationen für alle Mock-Properties und Inline-Variablen.
  • Callback-Stubbing für ParameterBagInterface mit Default-Werten.

Durch die Kombination dieser Regeln im Prompt generiert das LLM direkt eine PHPUnit-Testklasse, die ohne zusätzliche Hacks und ohne Kernel-Bootstrapping per vendor/bin/phpunit läuft.


Detaillierte Erläuterung der Prompt-Punkte

PunktZweck
1. Keine Reflection-HacksSchützt readonly-Properties und hält Tests Symfony-kompatibel.
2. Echte Instanzen für finale KlassenVermeidet Unmockable-Errors und Seiteneffekte.
3. Nur Interfaces und Services mockenSorgt für klare Testgrenzen und bessere Lesbarkeit.
4. Exakte MethodensignaturenStellt Kompatibilität mit allen Symfony-Versionen sicher.
5. Stub-Überschreibungen in Test-SubclassErlaubt kontrollierte Überschreibung von Controller-Hilfsmethoden.
6. ConstraintViolationList zurückgebenVerhindert Typfehler bei Validator-Stubs in Symfony 6+.
7. PHPDoc für Mock-PropertiesUnterdrückt IDE-Warnings (Intelliphense etc.).
8. Inline-PHPDoc für lokale MocksGleicher Effekt auf Methodenscope-Ebene.
9. Genauer Rückgabetyp bei RepositoriesEntityRepository statt nur ObjectRepository, für spezifische Methoden.
10. flush() ohne willReturn()Da flush(): void keinen Rückgabewert hat.
11. dispatch() gibt echten EnvelopeTestet echtes Messenger-Verhalten.
12. JSON-Antworten via json_decode() prüfenRobuster gegen Format- und Whitespace-Änderungen.
13. Läuft ohne Kernel-BootstrapSchneller, leichter Unit-Test ohne Integrationstest-Overhead.
14. Callback-Stub für ParameterBagInterfaceDefault-Werte werden korrekt zurückgegeben, keine UnexpectedValueException.

Integration in den Entwicklungsprozess

  1. Prompt zentral verwalten
    Lege deinen Zauberprompt in einem Repository ab (z. B. docs/prompt.md).
  2. Automatisierte Generierung
    Baue einen kleinen CLI-Befehl, der per Git-Hook oder Makefile den Prompt an ChatGPT sendet und das Ergebnis in tests/Controller ablegt.
  3. Code-Review
    Auch automatisch generierte Tests sollten per Pull Request geprüft werden – so schaffst du zusätzliche Sicherheit.
  4. Prompt-Versionierung
    Ändert sich dein Symfony-Standard (z. B. neue Signaturen), versioniere den Prompt und weise im Projekt auf die korrekte Version hin.

Best Practices & Lessons Learned

  • Keep it DRY: Verwende Variablen und Templates, falls dein Prompt für mehrere Controller-Typen gilt.
  • Regelmäßig aktualisieren: Nach jedem größeren Symfony-Release prüfst du deinen Prompt auf neue Methodensignaturen oder Dependency-Änderungen.
  • Granulare Kontrolle: Bei komplexen Logiken in Controllern erwäge zusätzliche manuelle Tests oder gezielte Integrationstests.
  • Monitoring: Verfolge, wie oft generierte Tests angepasst werden – hohes Änderungsvolumen kann auf Lücken im Prompt hinweisen.

Der Zauberprompt

**Du bekommst eine Symfony-Controller-Klasse (z. B. `ApiSomethingController`). Erstelle mir eine funktionsfähige PHPUnit-Testklasse, die sämtliche Abhängigkeiten per Konstruktor injiziert und dabei folgende Vorgaben erfüllt — inklusive aller Lessons Learned, damit künftige Tests ohne Inkompatibilitäten durchlaufen:**

1. **Keine** `readonly`-Properties per Reflection ändern oder hacken.
2. **Keine** finalen Klassen mocken (z. B. `RateLimiterFactory`, `Envelope`); instanziiere sie stattdessen real (z. B. `new RateLimiterFactory(..., new InMemoryStorage())`, `new Envelope(new Send…Message(...))`).
3. Nur reguläre Mock-Objekte für Interfaces und Services (Repository, Validator, PasswordHasher, RateLimitService, UserDeletionService, MessageBusInterface etc.) verwenden.
4. **Methodensignaturen exakt übernehmen**:

   * `public function isGranted(string $attribute, $subject = null): bool`
   * `protected function getUser(): ?UserInterface`
   * `public function json(mixed $data, int $status = 200, array $headers = [], array $context = []): JsonResponse`
5. `getUser()`, `isGranted()` und `json()` in der anonymen Test-Controller-Subclass nur via PHPUnit-Stub überschreiben und Signaturen 1:1 matchen.
6. `ValidatorInterface::validate()` so stubben, dass es ein echtes `ConstraintViolationList`-Objekt liefert, nicht ein Array.
7. **Für alle** gemockten **Klassen-Properties** **nicht** typisieren, sondern **ausschließlich** per PHPDoc annotieren, z. B.

   ```php
   /** @var UserRepository&\PHPUnit\Framework\MockObject\MockObject */
   private $userRepository;
   ```

   damit Intelliphense keine „Found MockObject”-Warnings wirft.
8. **Für alle lokalen Mock-Variablen in jeder Testmethode** ebenfalls eine Inline-PHPDoc hinzufügen, z. B.

   ```php
   /** @var UserPasswordHasherInterface&\PHPUnit\Framework\MockObject\MockObject $hasher */
   $hasher = $this->createMock(UserPasswordHasherInterface::class);
   ```
9. **Wichtig:** Wenn Du `$entityManager->getRepository(User::class)` mockst, musst Du **genau** den Rückgabetyp `Doctrine\ORM\EntityRepository` statt nur `ObjectRepository` angeben!
10. **`flush()`** hat Rückgabetyp `void`. Stubpe es **ohne** `willReturn()`, also z. B.

    ```php
    $em->expects($this->once())->method('flush');
    ```
11. **MessageBusInterface::dispatch()** soll einen echten `Envelope` zurückliefern. Instanziiere ihn real:

    ```php
    ->willReturn(new Envelope(new SendAccountDeletedMailMessage(...)));
    ```
12. Teste JSON-Antworten immer über

    ```php
    $data = json_decode($response->getContent(), true);
    $this->assertSame([...], $data);
    ```

    statt String-Vergleichen.
13. Direkt lauffähig mit `phpunit`, **ohne** zusätzliche Hacks oder Kernel-Bootstrapping.
14. **Stub ParameterBagInterface::get() korrekt:** Wenn Du `$params->get(...)` stubbst, nutze eine Callback-Stub mit **zwei Parametern**

    ```php
    $params->method('get')->willReturnCallback(function(string $key, $default = null) {
        $map = ['foo' => 'bar'];
        return $map[$key] ?? $default;
    });
    ```

    damit der Default-Wert für ungetestete Keys zurückgegeben wird und keine `UnexpectedValueException` geworfen wird.

**Gib nur die vollständige Testklasse in PHP zurück.**

0 Kommentare