Erwartete Exceptions richtig testen

Der klassische Ablauf beim Testen von Code, der eine Exception werfen soll, ist der Folgende (PHPUnit):

/**
 * @expectedException InvalidArgumentException
 */
public function testException()
{
   throw new InvalidArgumentException();
}

Problem dabei: Wir haben nicht spezifiziert, an welcher Stelle die Exception geworfen werden soll. Außerdem können wir nicht prüfen, ob die geworfene Exception genau die erwartete oder nur igendeine war.

Jetzt lässt sich das noch aufbohren:

/**
 * @expectedException        InvalidArgumentException
 * @expectedExceptionMessage Right Message
 */
public function testExceptionHasRightMessage()
{
    throw new InvalidArgumentException('Right Message');
}

Auch damit werde ich nicht glücklich. Wenn ich jetzt z.B. mehrere Exceptions in einem Test prüfen möchte (guter Stil hin oder her) stößt man an die Grenzen diesen Ansatzes.

Etwas feingranularer ist das Handling mit der nachfolgend vorstellten Methode setExpectedException.

Alternative Methode

public function testExceptionHasRightMessage()
{
    $this->setExpectedException(
      'InvalidArgumentException', 'Right Message'
    );
	
    throw new InvalidArgumentException('Right Message');
}

Macht letztlich einen entscheidenden Unterschied verglichen mit der Variante per Annotation: Ich kann den Zeitpunkt selbst bestimmen, ab dem ich eine Exception erwarte. Die Annotation greift direkt ab der ersten Zeile der Methode, in der ich vielleicht noch garkeine Exception haben möchte.

Weils so schön simpel ist: Das macht setExpectedException under the hood:

public function setExpectedException($exceptionName, $exceptionMessage = '', $exceptionCode = 0)
{
    if ($exceptionName == 'Exception') {
	throw new InvalidArgumentException(
	  'You must not expect the generic exception class.'
  	);
    }

    $this->expectedException        = $exceptionName;
    $this->expectedExceptionMessage = $exceptionMessage;
    $this->expectedExceptionCode    = $exceptionCode;
    $this->expectedExceptionTrace   = debug_backtrace();
}

… und wenn dann eine Exception fliegt, prüft das PHP Unit folgendermaßen ab:

try {
	$testResult = $method->invokeArgs(
	      $this, array_merge($this->data, $this->dependencyInput)
	);
}

catch (Exception $e) {
	if (!$e instanceof PHPUnit_Framework_IncompleteTest &&
		!$e instanceof PHPUnit_Framework_SkippedTest &&
		is_string($this->expectedException) &&
		$e instanceof $this->expectedException) {
		if (is_string($this->expectedExceptionMessage) &&
			!empty($this->expectedExceptionMessage)) {
			$this->assertContains(
			  $this->expectedExceptionMessage,
			  $e->getMessage()
			);
		}
       // ...
}

Volle Kontrolle!

Auch wenn das Testen mehrerer Exceptions in einer Methode durchaus umstritten ist, gibt es nun noch ein weiteres, gern genutztes Pattern, das eben dies ermöglicht:

try
{
    code();
    $this->fail("No Exception was thrown");
}
catch (InvalidArgumentException $ex)
{
    $this->assertEquals($ex->getMessage(), "Expected Exception-Text", "Wrong exceptiontext...");
}
catch (Exception $ex)
{
    $this->fail("Wrong Exception was thrown");
}

Muss natürlich für maximalen Komfort noch ausgebaut werden, ihr versteht worauf ich hinauswill.

Um das etwas komfortabler und wiederholungsfreier zu gestalten, hat dieser Herr eine Erweiterung zu PHPUnit geschrieben, womit uns seine Methode assertThrowsException die Arbeit abnimmt und den zu testenden Code in einer anonymen Funktion kapselt. Fühlt sich für mich am sympathischsten an.

<?php
public function testSomeImportantMethod() {
    $someClass = new SomeClass();

    $this->assertThrowsException('InvalidArgumentException', function () use($someClass) {
            $someClass->someMethod();
        }
    );
}

Update: Danke an Thomas für eine Richtigstellung, habe den Artikel entsprechend angepasst.

Weitere Posts:

Dieser Beitrag wurde unter php, webdev veröffentlicht. Setze ein Lesezeichen auf den Permalink.

4 Antworten auf Erwartete Exceptions richtig testen

  1. Thomas sagt:

    Sorry, aber ich kann einiges hier nicht so stehen lassen:

    > Sieht nett aus, und bringt sogar einen Vorteil: Wenn ich mehrere
    > Exceptions in einem Test abprüfen möchte, komme ich einfach frech
    > mehrfach mit setExpectedException daher.

    Mal abgesehen von der Sinnhaftigkeit: Wie willst Du mehrere Exceptions abprüfen? Bei der ersten bist Du aus der Testmethode draußen. Und mit bedingten Exceptions willst Du hoffentlich nicht anfangen. Dann haben die Tests ganz andere Probleme.

    > Ich kann jetzt leider keinen wirklichen Nachteil von
    > setExpectedException erkennen – außer, dass das resetten tricky ist.

    Warum willst Du resetten? Bei dem Ansatz mit setExpectedException() schreibst Du zunächst das Fixture-Setup und anschließend in der Execution-Phase an entsprechender Stelle setExpectedException() und in der nächsten Zeile kracht es. Ich wüsste nicht, warum Du dann resetten willst…

    > Um das etwas komfortabler und wiederholungsfreier zu gestalten,
    > hat dieser Herr eine Erweiterung zu PHPUnit geschrieben, womit
    > uns seine Methode assertThrowsException die Arbeit abnimmt und
    > den zu testenden Code in einer anonymen Funktion kapselt. Fühlt
    > sich für mich am sympathischsten an.

    AAARRRGGGGHHH Das ist doch total hässlich. Das hat nichts mehr mit einfachem und lesbarem Test mit möglichst geringer Komplexität zu tun. Auf der Unconference hat mir jemand (dessen Wort in diesem Zusammenhang ein gewisses Gewicht hat) von diesem Ansatz erzählt – er fand es auch schrecklich.

    Wir können gerne über den try-catch-Ansatz reden, ob der akademisch/konzeptionell schöner ist, wie es mir ein anderer Teilnehmer auf der Unconference schmackhaft machen wollte. (Ich finde setExpectedException() trotzdem schöner, einfacher und für Ungeübte weniger fehleranfälliger.) Aber der Closure-Ansatz geht gar nicht!

    Schöne Grüße

    Thomas

    1. david sagt:

      Hi Thomas, ich danke dir für deine Richtigstellung. Ich hätte schwören können, dass es mit setExpectedException nach dem Werfen einer erwarteten Exception weiter geht. Deswegen auch mein Wunsch nach einem – so betrachtet – überflüssigen Reset.

      Letztlich ists eine Geschmacksfrage, was man nun vom Abprüfen mehrerer Exceptions in einem Test hält. Rein akademisch sollte der Test nicht die interne Abprüf-Reihenfolge im inneren der Methode kennen müssen, um genau zu wissen, welche Exception bei welcher Art von falschem Input wieder herauszukommen hat.

      Dennoch gibt es Fälle, wo das Exceptions abprüfen im Mittelpunkt steht und eine Aufteilung auf mehrere Methoden zum unnötigen Overhead wird. Deswegen verwende ich gern oben gezeigtes try / catch – pattern, welches leider das Problem hat, dass es viel redundanten Code mit sich herumschleppt, der immer wieder kopiert werden muss. Deswegen gefällt mir der wiederverwendbare Ansatz mit Closure auch so gut.

  2. Sven sagt:

    Wenn es nötig sein sollte, zu testen, ob eine Methode die richtige von mehreren Exceptions wirft, sollte man diese Methode fixen, nicht den Test.

    Denn mehr als eine Exception ist ein Zeichen für versuchte Programmsteuerung beim Caller, also getarntes GOTO.

  3. Maik Tizziani sagt:

    @Sven: dem kann ich nur bedingt zustimmen.

    Ich habe einen ganz normale Setter-Methode

    public function setValue(string $text): self
    {
    $text = preg_replace(‚/\s+/‘, ‚ ‚, $text);
    $text = trim($text);

    if(stlen($text) == 0)
    throw new NullStringException();

    if(strlen($text) 8)
    throw new StringToLongException;

    return $this;
    }

    hier würde ich durchaus verschiedene Exceptions werfen. Und wenn ich meinen Test mit DataProvidern spicke kann ich das auch wunderbar abfangen

    /**
    * @dataProvider getFailingSpecs
    **/
    public function testForException($exceptionName, $value): void
    {
    $this->expectException($exceptionName);

    $setValue($value);
    }

    public function getFailingSpecs(): array
    {
    return [
    [NullStringException::class, ‚ ‚],
    [StringToShortException::class, ‚a ‚],
    [StringToLongException::class, ‚ asdf asdf asdf asdf ‚]
    ]
    }

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert