PHPUnit. "Wie teste ich meinen verdammten Controller?"

Hallo Habr.

Bild

Ja, dies ist ein weiterer Beitrag zum Thema Testen. Es scheint, dass es hier bereits möglich ist, zu diskutieren? Alle, die es brauchen - sie schreiben Tests, die es nicht brauchen - sie schreiben nicht, alle sind glücklich! Tatsache ist, dass die meisten Posts über Unit-Tests ... wie man niemanden beleidigt ... idiotische Beispiele haben! Nein, wirklich! Heute werde ich versuchen, es zu beheben. Ich bitte um katze

Wenn Sie also schnell zum Thema Tests googeln, finden Sie nur viele Artikel, die in ihrer Masse in zwei Kategorien unterteilt sind:

1) Das Glück eines Texters. Zuerst sehen wir eine lange Einführung, dann die Geschichte der Unit-Tests im alten Russland, dann zehn Life-Hacks mit Tests und am Ende ein Beispiel. Mit Code wie folgt testen:

<?php class Calculator { public function plus($a, $b) { return $a + $b; } } 

Und ich scherze gerade nicht. Ich habe wirklich Artikel mit einem „Taschenrechner“ als Studienführer gesehen. Ja, ja, ich verstehe, dass es zunächst einmal notwendig ist, alles, Abstraktionen, hin und her zu vereinfachen ... Aber hier endet alles! Und dann mach die Eule fertig, wie sie sagen

2) Überaus ausgefeilte Beispiele. Schreiben wir einen Test und packen ihn in Gitlab CI. Wenn der Test erfolgreich ist, werden wir ihn automatisch reparieren und PHP Infection auf die Tests anwenden, aber wir werden alles mit Hudson verbinden. Und so weiter in diesem Stil. Es scheint nützlich zu sein, aber es scheint nicht das zu sein, wonach Sie suchen. Sie möchten die Stabilität Ihres Projekts jedoch nur geringfügig erhöhen. Und all diese Kontinuitäten - na ja, nicht alle auf einmal.

Infolgedessen bezweifeln die Leute: "Aber brauche ich das?" Ich möchte versuchen, das Testen klarer zu erklären. Und reservieren Sie gleich - ich bin ein Entwickler, ich bin kein Tester. Ich bin mir sicher, dass ich selbst nicht viel weiß und mein erstes Wort in meinem Leben war nicht das Wort "Mok". Ich habe noch nie an TDD gearbeitet! Aber ich weiß mit Sicherheit, dass ich mit meinem derzeitigen Kenntnisstand mehrere Projekte mit Tests abdecken konnte, und genau diese Tests haben bereits ein Dutzend Fehler aufgedeckt. Und wenn es mir helfen würde, könnte es jemand anderem helfen. Einige gefangene Bugs lassen sich nur schwer manuell fangen.

Zunächst ein kurzes Bildungsprogramm im Frage-Antwort-Format:

F: Muss ich ein Framework verwenden? Was ist, wenn ich Yii habe? Was ist, wenn Kohana? Was ist, wenn% one_more_framework_name%?
A: Nein, PHPUnit ist ein unabhängiges Testframework. Sie können es sogar auf einem selbst erstellten Framework mit dem Legacy-Code verschrauben.

F: Und jetzt gehe ich schnell mit meinen Händen durch die Website, und es ist normal. Warum brauche ich das?
A: Ein Durchlauf von mehreren Dutzend Tests dauert mehrere Sekunden. Das automatische Testen ist immer schneller als das manuelle Testen, und mit hochwertigen Tests ist es auch zuverlässiger, da es alle Szenarien abdeckt.

F: Ich habe einen Legacy-Code mit 2000 Zeilenfunktionen. Kann ich das testen?
A: Ja und nein. Theoretisch kann jeder Code mit einem Test abgedeckt werden. In der Praxis sollte der Code mit einer Grundlage für zukünftige Tests geschrieben werden. Eine 2000-Zeilen-Funktion weist zu viele Abhängigkeiten, Verzweigungen und Grenzfälle auf. Es mag sich am Ende herausstellen, dass es alles abdeckt, aber höchstwahrscheinlich wird es eine unannehmbar lange Zeit dauern. Je besser der Code, desto einfacher ist es, ihn zu testen. Je besser die Einzelverantwortung respektiert wird, desto einfacher werden die Tests. Um alte Projekte am häufigsten zu testen, müssen Sie sie zunächst kühl umgestalten.

Bild

F: Ich habe sehr einfache Methoden (Funktionen), was gibt es zu testen? Dort ist alles zuverlässig, es gibt keinen Raum für Fehler!
A: Es sollte klar sein, dass Sie die korrekte Implementierung der Funktion nicht testen (wenn Sie kein TDD haben), sondern lediglich den aktuellen Status der Funktion "korrigieren". Wenn Sie es in Zukunft ändern müssen, können Sie mithilfe des Tests schnell feststellen, ob Sie das Verhalten gestört haben. Beispiel: Es gibt eine Funktion, die E-Mails überprüft. Sie macht es regelmäßig.

 function isValid($email) { $regex = "very_complex_regex_here"; if (is_array($email)) { $result = true; foreach ($email as $item) { if (preg_match($regex, $item) === 0) { $result = false; } } } else { $result = preg_match($regex, $emai) ==! 0; } return $result; } 

Ihr gesamter Code geht davon aus, dass eine gültige E-Mail an diese Funktion den Wert true zurückgibt. Eine Reihe von gültigen E-Mails ist auch wahr. Ein Array mit mindestens einer ungültigen E-Mail-Adresse ist falsch. Nun und so weiter, der Code ist klar. Aber der Tag kam und Sie entschieden sich, die monströse reguläre Saison durch eine externe API zu ersetzen. Aber wie kann man sicherstellen, dass die neu geschriebene Funktion das Funktionsprinzip nicht verändert hat? Plötzlich kommt es nicht mehr gut mit dem Array klar? Oder wird es nicht boolean zurückkehren? Und Tests können dies unter Kontrolle halten. Ein gut geschriebener Test zeigt sofort ein anderes als das erwartete Funktionsverhalten an.

F: Wann werde ich anfangen, Sinn aus Tests zu ziehen?
A: Erstens, sobald Sie einen wesentlichen Teil des Codes abdecken. Je näher die Abdeckung an 100% liegt, desto zuverlässiger ist die Prüfung. Zweitens, sobald Sie globale Änderungen oder Änderungen im komplexen Teil des Codes vornehmen müssen. Tests können Probleme aufdecken, die leicht manuell übersehen werden können (Grenzfälle). Drittens beim Schreiben der Tests selbst! Oft kommt es vor, dass das Schreiben eines Tests Codefehler aufzeigt, die auf den ersten Blick nicht sichtbar sind.

F: Nun, ich habe eine Website auf Laravel. Die Seite ist keine Funktion, die Seite ist ein beschissener Berg von Code. Wie kann man hier testen?
A: Dies wird später besprochen. Kurzum: Wir testen getrennt die Methoden der Controller, getrennt die Middleware, getrennt die Dienste usw.

Eine der Ideen beim Unit-Testen ist es, den getesteten Codeabschnitt zu isolieren. Je weniger Code Sie mit einem Test testen, desto besser. Schauen wir uns ein Beispiel an, das dem wirklichen Leben so nahe wie möglich kommt:

 <?php class Controller { public function __construct($userService, $emailService) { $this->userService = $userService; $this->emailService = $emailService; } public function login($request) { if (empty($request->login) || empty($request->password)) { return "Auth error"; } $password = $this->userService->getPasswordFor($request->login); if (empty($password)) { return "Auth error - no password"; } if ($password !== $request->password) { return "Incorrect password"; } $this->emailService->sendEmail($request->login); return "Success"; } } // .... /* somewhere in project core */ $controller = new Controller($userService, $emailService); $controller->login($request); 

Dies ist eine sehr typische Methode, um sich bei kleinen Projekten im System anzumelden. Wir erwarten nur die richtigen Fehlermeldungen und die versendete E-Mail bei erfolgreicher Anmeldung. Wie teste ich diese Methode? Zunächst müssen Sie externe Abhängigkeiten identifizieren. In unserem Fall gibt es zwei davon - $ userService und $ emailService. Sie werden durch den Klassenkonstruktor geleitet, was unsere Aufgabe erheblich erleichtert. Aber wie bereits erwähnt, ist es umso besser, je weniger Code wir in einem Durchgang testen.

Emulation, Substitution von Objekten heißt mokanem (aus dem Englischen. Mock object, wörtlich: "Objekt-Parodie"). Niemand hat die Mühe, solche Objekte manuell zu schreiben, aber alles wurde bereits vor uns erfunden, so dass eine so wunderbare Bibliothek wie Mockery zur Rettung kommt. Lassen Sie uns Mokas für Services erstellen.

 $userService = Mockery::mock('user_service'); $emailService = Mockery::mock('email_service'); 

Erstellen Sie nun das $ request-Objekt. Zunächst testen wir die Logik des Überprüfens der Anmelde- und Kennwortfelder. Wir möchten sicherstellen, dass unsere Methode diesen Fall korrekt verarbeitet und die gewünschte (!) Nachricht zurückgibt, wenn keine vorhanden ist.

 function testEmptyLogin() { $userService = Mockery::mock('user_service'); $emailService = Mockery::mock('email_service'); $controller = new Controller($userService, $emailService); $request = (object) []; $result = $controller->login($request); } 

Nichts kompliziertes, oder? Wir haben Stubs für die erforderlichen Klassenparameter erstellt, eine Instanz der gewünschten Klasse erstellt und die gewünschte Methode "gezogen", wobei eine absichtlich falsche Anforderung übergeben wurde. Habe eine Antwort bekommen. Aber wie kann man das jetzt überprüfen? Dies ist der wichtigste Teil des Tests - die sogenannte Behauptung. PHPUnit verfügt über Dutzende von vorgefertigten Behauptungen . Verwenden Sie einfach einen von ihnen.

 function testEmptyLogin() { $userService = Mockery::mock('user_service'); $emailService = Mockery::mock('email_service'); $controller = new Controller($userService, $emailService); $request = (object) []; $result = $controller->login($request); // vv assertion here! vv $this->assertEquals("Auth error", $result); } 

Dieser Test garantiert Folgendes: Wenn das Login-Argument bei dem Methodenobjekt ankommt, das kein Login- oder Passwortfeld hat, gibt die Methode die Zeichenfolge "Auth error" zurück. Das ist im Allgemeinen alles. So einfach - aber so nützlich, denn jetzt können wir die Anmeldemethode bearbeiten, ohne befürchten zu müssen, etwas zu beschädigen. Unser Frontend kann sicher sein, dass er einen solchen Fehler bekommt, wenn etwas passiert. Und wenn jemand gegen dieses Verhalten verstößt (z. B. den Fehlertext ändern möchte), zeigt der Test dies sofort an! Die verbleibenden Prüfungen werden hinzugefügt, um möglichst viele Szenarien abzudecken.

 function testEmptyPassword() { $userService = Mockery::mock('user_service'); // $userService->getPasswordFor(__any__arg__); // '' $userService->shouldReceive('getPasswordFor')->andReturn(''); $emailService = Mockery::mock('email_service'); $request = (object) [ 'login' => 'john', 'pass' => '1234' ]; $result = (new Controller($userService, $emailService))->login($request); $this->assertEquals("Auth error - no password", $result); } function testUncorrectPassword() { $userService = Mockery::mock('user_service'); // $userService->getPasswordFor(__any__arg__); // '4321' $userService->shouldReceive('getPasswordFor')->andReturn('4321'); $emailService = Mockery::mock('email_service'); $request = (object) [ 'login' => 'john', 'pass' => '1234' ]; $result = (new Controller($userService, $emailService))->login($request); $this->assertEquals("Incorrect password", $result); } function testSuccessfullLogin() { $userService = Mockery::mock('user_service'); // $userService->getPasswordFor(__any__arg__); // '1234' $userService->shouldReceive('getPasswordFor')->andReturn('1234'); $emailService = Mockery::mock('email_service'); $request = (object) [ 'login' => 'john', 'pass' => '1234' ]; $result = (new Controller($userService, $emailService))->login($request); $this->assertEquals("Success", $result); } 

Beachten Sie die Methoden shouldReceive und andReturn? Sie ermöglichen uns, Methoden in Stubs zu erstellen, die nur das zurückgeben, was wir benötigen. Müssen Sie den falschen Passwortfehler testen? Wir schreiben einen stub $ userService, der immer das falsche Passwort zurückgibt. Und alle.

Und was ist mit Abhängigkeiten, fragen Sie. Wir haben sie dann „ertränkt“, und was ist, wenn sie brechen? Genau dafür ist die maximale Codeabdeckung bei Tests gedacht. Wir werden den Betrieb dieser Dienste im Rahmen der Anmeldung nicht überprüfen - wir werden die Anmeldung in der Hoffnung auf den korrekten Betrieb der Dienste testen. Und dann schreiben wir die gleichen, isolierten Tests für diese Dienste. Und testet dann auf ihre Abhängigkeiten. Usw. Infolgedessen garantiert jeder einzelne Test nur die korrekte Funktion eines kleinen Codeteils, sofern alle Abhängigkeiten korrekt funktionieren. Und da alle Abhängigkeiten auch durch Tests abgedeckt werden, ist auch deren einwandfreie Funktion gewährleistet. Infolgedessen wird jede Änderung am System, die die Logik der Arbeit selbst des kleinsten Codeteils verletzt, sofort in einem bestimmten Test angezeigt. Wie man den Testlauf konkret durchführt - ich werde nicht sagen, die Dokumentation bei PHPUnit ist ziemlich gut. In Laravel reicht es beispielsweise aus, vendor / bin / phpunit im Stammverzeichnis des Projekts auszuführen, um eine Meldung wie diese anzuzeigen

Bild - Alle Tests waren erfolgreich. Oder so ähnlich

Bild Eine der sieben Behauptungen ist fehlgeschlagen.

"Das ist natürlich cool, aber woran komme ich nicht ran?", Fragst du. Und stellen wir uns dazu den folgenden Code vor

 <?php function getInfo($infoApi, $userName) { $response = $infoApi->getInfo($userName); if ($response->status === "API Error") { return null; } return $response->result; } // ... somewhere in system $api = new ExternalApi(); $info = getInfo($api, 'John'); if ($info === null) { die('Api is down'); } echo $info; 

Wir sehen ein vereinfachtes Modell für die Arbeit mit einer externen API. Die Funktion verwendet eine Klasse, um mit der API zu arbeiten, und gibt im Fehlerfall null zurück. Wenn wir bei Verwendung dieser Funktion null erhalten, sollten wir "Panik auslösen" (eine Nachricht an die Slack senden oder dem Entwickler eine E-Mail senden oder einen Fehler in die Kibana werfen. Ja, eine Reihe von Optionen). Alles scheint einfach zu sein, oder? Stellen Sie sich jedoch vor, dass sich ein anderer Entwickler nach einiger Zeit entschlossen hat, diese Funktion zu "reparieren". Er entschied, dass das Zurückgeben von null das letzte Jahrhundert ist und er sollte eine Ausnahme auslösen.

 function getInfo($infoApi, $userName): string { $response = $infoApi->getInfo($userName); if ($response->status === "API Error") { throw new ApiException($response); } return $response->result; } 

Und er hat sogar alle Abschnitte des Codes neu geschrieben, in denen diese Funktion aufgerufen wurde! Alle bis auf einen. Er vermisste ihn. Abgelenkt, müde, einfach falsch - aber man weiß es nie. Fakt ist, dass ein Teil des Codes noch auf das alte Funktionsverhalten wartet. Und PHP ist für uns kein Java - wir erhalten keinen Kompilierungsfehler, weil die Funktion throwable nicht in try-catch eingeschlossen ist. In einem der 100 Szenarien für die Verwendung der Site erhalten wir im Falle eines API-Absturzes keine Nachricht vom System. Darüber hinaus werden wir mit manuellen Tests diese Version des Ereignisses wahrscheinlich nicht erfassen. Die API ist extern, es hängt nicht von uns ab, sie funktioniert gut - und höchstwahrscheinlich werden wir sie bei einem API-Fehler und einer falschen Ausnahmebehandlung nicht in den Griff bekommen. Wenn wir jedoch Tests haben, werden sie diesen Fall sehr gut auffangen, da die ExternalApi-Klasse in einer Reihe von Tests "gedämpft" ist und sowohl normales Verhalten als auch Absturz emuliert. Und der nächste Test wird fallen

 function testApiFail() { $api = Mockery::mock('api'); $api->shouldReceive('getInfo')->andReturn((object) [ 'status' => 'API Error' ]); $result = getInfo($api, 'name'); $this->assertNull($result); } 

Diese Information ist eigentlich genug. Wenn Sie keine Legacy-Nudeln haben, können Sie nach 20 bis 30 Minuten Ihren ersten Test schreiben. Und ein paar Wochen später - um etwas Neues, Cooles zu lernen, kehren Sie zu den Kommentaren unter diesem Beitrag zurück und schreiben Sie, welcher Autor der Govnokoder nicht über% framework_name% weiß und schlechte Tests schreibt, aber Sie müssen% this_way% ausführen. Und ich werde in diesem Fall sehr glücklich sein. Damit ist mein Ziel erreicht: Jemand hat das Testen für sich entdeckt und die allgemeine Professionalität in unserem Bereich ein wenig gesteigert!

Begründete Kritik ist willkommen.

Source: https://habr.com/ru/post/de485418/


All Articles