Mehr Spaß beim Testen mit der eigenen Testing-API: Die "setUp()"-Methode in PHPUnit-Tests eliminieren

Laptop mit Code auf dem Bildschirm.
von Benedikt Fabian Software Engineer & Ausbilder Veröffentlicht am 25.06.2025 21 Minuten Lesezeit

Das Implementieren von automatisierten Tests ist ein täglicher Begleiter meiner Arbeit. Daher bin ich ständig auf der Suche nach neuen Ideen, wie wir das Schreiben von Tests für uns einfacher und effizienter machen können. Diese setze ich in einer eigenen Testing-API um, die wir dann projektübergreifend nutzen können. 

Meine jüngste Entwicklung rund ums Testing beschäftigt sich mit der "setUp()"-Methode in PHPUnit-Tests und wie man diese loswerden kann.

TL;DR:

  • Wenn man im Alltag so viele PHPUnit-Test schreibt wie wir, kann es sich lohnen eine eigene Testing-API zu implementieren.
  • Eine eigene Testing-API kann im Alltag viel Code und Zeit sparen.
  • Ich habe einen Trait implementiert, der die Unit under Test erzeugen kann. Damit kann man sich die setUp()-Methode sparen.
  • Den Code dazu kannst Du hier einsehen: https://github.com/e3n-team/testing-unit-creator
  • Oder direkt als Composer Package installieren: 

composer require e3n/testing-unit-creator --dev

Das Thema Qualität hat bei e3n einen hohen Stellenwert. Unsere Projekte sind umfangreich und befinden sich häufig in einem komplexen Umfeld. Für unsere Kunden spielt Korrektheit und Robustheit der Software eine entscheidende Rolle. Gerade im E-Commerce hat die Qualität unserer Arbeit einen direkten Einfluss auf den Umsatz unserer Kunden. Dementsprechend hoch ist die Testabdeckung in unseren Projekten. In unseren Individualprojekten beträgt diese meist um die 100 Prozent. Die erreichen wir durch einen Mix aus Unit-Tests, Integrationstests, Funktionstests und Oberflächentests.

Die "setup()"-Methode

Ich teste so gut wie jede Klasse mit einem Unit-Test. Selbst wenn die Klasse über einen Integrationstest oder Funktionstest abgedeckt ist, macht es für mich häufig Sinn, zusätzlich die Unit an sich nochmal explizit und isoliert zu testen. Ich schreibe täglich also viele Unit-Tests. Der Aufbau der TestCases ähnelt sich dabei häufig.

    
    /** @covers \App\Client */
class ClientTest extends TestCase
{
    private ServiceA $serviceA;

    private ServiceB $serviceB;

    private ServiceC $serviceC;

    private Client $unit;

    protected function setUp(): void
    {
        parent::setUp();

        $this->serviceA = $this->createMock(ServiceA::class);
        $this->serviceB = $this->createMock(ServiceB::class);
        $this->serviceC = $this->createMock(ServiceC::class);
        $this->unit     = new Client(
            $this->serviceA,
            $this->serviceB,
            $this->serviceC
        );
    }

    public function testDoSomething(): void
    {
        $this->serviceA
             ->expects(self::once())
             ->method('doA')
             ->willReturn('A');

        $this->serviceB
             ->expects(self::once())
             ->method('doB')
             ->willReturn('B');

        $this->serviceC
             ->expects(self::once())
             ->method('doC')
             ->willReturn('C');

        $expected = 'ABC';
        $actual   = $this->unit->doSomething();

        self::assertSame($expected, $actual);
    }
}
    

In der setUp()-Methode wird die Unit under Test inklusive aller Abhängigkeiten des Konstruktors erzeugt. Die Abhängigkeiten befriedige ich meistens durch Mocks. Diese weise ich Class Properties zu, um sie später in den einzelnen Test-Methoden nutzen zu können.
Den Code dafür empfinde ich als Boilerplate, der vom eigentlichen Test ablenkt, und suche nach einem Weg diesen loszuwerden.

Wiederverwendbare Routine zum Erzeugen der "Unit under Test"

Die Idee ist es, eine wiederverwendbare Routine zu implementieren, die die Unit under Test mit all ihren Abhängigkeiten erzeugt. Außerdem soll der Zugriff auf die instanziierten Abhängigkeiten weiterhin möglich sein. Für wiederverwendbare Code-Schnipsel bieten sich Traits wunderbar an.

Die "Unit under Test" bestimmen

Um die Unit under Test erzeugen zu können, muss man erstmal an die Information kommen, welches die Unit under Test überhaupt ist. Wir geben in unseren Unit-Tests über die @covers-Annotation an, welche Unit getestet wird. Diese Information kann man sich zu Nutze machen. In höheren PHPUnit-Versionen steht das Attribute #[CoversClass] zur Verfügung. Daher möchte ich beide Möglichkeiten unterstützen.
Die Informationen lassen sich per Reflection einfach auslesen:

    
    use Exception;
use phpDocumentor\Reflection\DocBlock\Tags\Covers;
use phpDocumentor\Reflection\DocBlockFactory;
use PHPUnit\Framework\Attributes\CoversClass;
use ReflectionAttribute;
use ReflectionClass;
use Webmozart\Assert\InvalidArgumentException;

/**
 * @template UNIT of object
 */
trait UnitCreatorTrait
{
    /** @return class-string<UNIT> */
    protected function getUnitClass(): string
    {
        $fqcn = $this->getUnitClassByDocBlock() ?? $this->getUnitClassByAttribute();

        if ($fqcn) {
            return $fqcn;
        }

        throw new Exception('Provide a unit class by @covers annotation, #[CoversClass] attribute or getUnitClass() method.');
    }

    /** @return null|class-string<UNIT> */
    private function getUnitClassByDocBlock(): ?string
    {
        try {
            $testCase        = new ReflectionClass($this);
            $docBlockFactory = DocBlockFactory::createInstance();
            $docBlock        = $docBlockFactory->create($testCase);
            $coversTag       = $docBlock->getTagsByName('covers')[0] ?? null;

            if ($coversTag instanceof Covers === false) {
                return null;
            }

            return (string)$coversTag->getReference();
        } catch (InvalidArgumentException) {
            return null;
        }
    }

    /** @return null|class-string<UNIT> */
    private function getUnitClassByAttribute(): ?string
    {
        $testCase        = new ReflectionClass($this);
        $coversAttribute = $testCase->getAttributes(CoversClass::class)[0] ?? null;

        if ($coversAttribute instanceof ReflectionAttribute === false) {
            return null;
        }

        return $coversAttribute->newInstance()->className();
    }
}
    

Die Methode getUnitClass() ruft zwei weitere Methoden getUnitClassByDocBlock() und getUnitClassByAttribute() auf, um die Unit under Test zu bestimmen. Diese versuchen mittels Reflection die @covers-Annotation und das #[CoversClass] Attribute auszulesen. Nutzt man beides nicht, hat man immer noch die Möglichkeit getUnitClass() zu überschreiben und den Fully Qualified Class Name der Unit zurückzugeben.

Die Parameter des Konstruktors bestimmen und erzeugen

Als Nächstes benötigen wir die Parameter des Konstruktors der Unit und dessen Typen.
Diese lassen sich ebenfalls über Reflection bestimmen. Dabei stellt sich die Frage, wie man mit builtin Typen umgehen möchte.
Meiner Meinung nach sollten solche Werte nicht zufällig und besser vom Test selber bestimmt werden. Also muss ich auch dafür eine Möglichkeit schaffen.

    
    use Exception;
use PHPUnit\Framework\MockObject\MockObject;
use ReflectionClass;

/**
 * @template UNIT of object
 */
trait UnitCreatorTrait
{
    /** @var array<string, MockObject> */
    private array $mocks = [];

    /** @return array<string, mixed> */
    protected function getUnitConstructorParameters(): array
    {
        return [];
    }

    /**
     * @template MOCK of object
     * @param class-string<MOCK> $class
     * @return MOCK&MockObject
     */
    protected function mock(string $class): object
    {
        if (isset($this->mocks[$class]) === false) {
            $this->mocks[$class] = $this->createMock($class);
        }

        return $this->mocks[$class];
    }

    /**
     * @param ReflectionClass<UNIT> $unitReflection
     * @return array<string, mixed>
     */
    private function buildParameters(ReflectionClass $unitReflection): array
    {
        $parameters               = $this->getUnitConstructorParameters();
        $parameterReflections     = $unitReflection->getConstructor()?->getParameters() ?? [];
        $missingBuiltinParameters = [];

        foreach ($parameterReflections as $parameterReflection) {
            $parameterReflectionType = $parameterReflection->getType();
            $parameterName           = $parameterReflection->getName();
            $parameterType           = $parameterReflectionType->getName();

            if (array_key_exists($parameterName, $parameters)) {
                continue;
            }

            if ($parameterReflectionType->isBuiltin() === true) {
                $missingBuiltinParameters[] = $parameterName;
                continue;
            }

            $parameters[$parameterName]  = $this->mock($parameterType);
            $this->mocks[$parameterType] = $parameters[$parameterName];
        }

        if ($missingBuiltinParameters !== []) {
            throw new Exception(
                sprintf(
                    'Missing parameters for constructor of `%s`: %s',
                    $unitReflection->getName(),
                    implode(', ', $missingBuiltinParameters)
                )
            );
        }

        return $parameters;
    }
}
    

Die Methode getUnitConstructorParameters() kann vom Test überschrieben werden. So besteht die Möglichkeit, die Werte von bestimmten Parametern zu bestimmen. Erwartet wird ein Array mit den Parameternamen als Keys.

Die Methode mock() erzeugt einen Mock der übergebenen Klasse, sofern noch nicht geschehen, und liefert diesen zurück. Diese Methode bietet dem Test auch Zugriff auf die Mocks, sodass dieser das Verhalten der Abhängigkeiten bestimmen kann.

Die buildParameters() Methode nutzt diese beiden Methoden. Über Reflection werden die Parameter des Konstruktors, dessen Namen und Typen bestimmt.
Wird ein Parameter bereits von der getUnitConstructorParameters() Methode geliefert, wird dieser auch genutzt. Ansonsten wird ein Mock mittels der mock() Methode erstellt, vorausgesetzt der Parameter ist nicht ein builtin Typ.

Die "Unit under Test" erzeugen

Jetzt haben wir alles was wir brauchen. getUnitClass() liefert uns den Klassennamen und buildParameters() die vom Konstruktor benötigten Parameter. Wir brauchen nur noch die Unit zu instanziieren und dabei die Parameter zu übergeben.

    
    use ReflectionClass;

/**
 * @template UNIT of object
 */
trait UnitCreatorTrait
{
    /** @var UNIT|null */
    private ?object $unit = null;

    /** @return UNIT */
    protected function getUnit(): object
    {
        if ($this->unit === null) {
            $unitClass      = $this->getUnitClass();
            $unitReflection = new ReflectionClass($unitClass);
            $parameters     = $this->buildParameters($unitReflection);
            $this->unit     = new $unitClass(...$parameters);
        }

        return $this->unit;
    }
}
    

Das Ergebnis

Als Ergebnis haben wir einen kürzeren Testcase ohne setUp() Methode und ohne die Class Properties.

    
    use e3n\Test\UnitCreatorTrait;
use PHPUnit\Framework\TestCase;

/**
 * @covers \App\Client
 */
class ClientTest extends TestCase
{
    /** @use UnitCreatorTrait<Client> */
    use UnitCreatorTrait;

    public function testDoSomething(): void
    {
        $this->mock(ServiceA::class)
             ->expects(self::once())
             ->method('doA')
             ->willReturn('A');

        $this->mock(ServiceB::class)
             ->expects(self::once())
             ->method('doB')
             ->willReturn('B');

        $this->mock(ServiceC::class)
             ->expects(self::once())
             ->method('doC')
             ->willReturn('C');

        $expected = 'ABC';
        $actual   = $this->getUnit()->doSomething();

        self::assertSame($expected, $actual);
    }
}
    

Anstatt die Unit under Test selber zu erzeugen, nutzt der Test einfach die getUnit() Methode. Auf Mocks besteht weiterhin Zugriff über die mock() Methode. Das spart Code und Zeit.

Weiterentwicklung

Im täglichen Gebrauch haben sich weitere Anforderungen an die Testing-API ergeben, weshalb diese mittlerweile weiterentwickelt wurde. Der hier gezeigte Code entspricht nicht mehr dem aktuellen Stand, kann die grundsätzliche Idee aber trotzdem veranschaulichen.

Die aktuelle Implementierung unterstützt beispielsweise abstrakte Klassen und Variadics im Konstruktor und bietet die Möglichkeit, für Abhängigkeiten vom gleichen Typen mehrere Mock-Instanzen zu erzeugen.

GitHub & Composer

Den Code kannst Du in unserem GitHub Repository einsehen:

https://github.com/e3n-team/testing-unit-creator

Ich habe den Code zusätzlich als Composer Package veröffentlicht, damit auch Du ihn nutzen kannst.

Über Feedback, Fragen und Anregungen würde ich mich sehr freuen.

    
    composer require e3n/testing-unit-creator --dev