Wie schreibe ich testbaren und wartbaren Code in PHP?

Frameworks bieten ein Werkzeug für die schnelle Anwendungsentwicklung, bringen aber technische Schulden häufig so schnell auf, wie Sie Funktionalität erstellen können. Technische Schulden entstehen, wenn die Wartbarkeit kein zweckmäßiger Fokus des Entwicklers ist. Zukünftige Änderungen und Fehlerbehebungen werden aufgrund fehlender Komponententests und -strukturen teuer.

So beginnen Sie mit der Strukturierung Ihres Codes, um Testbarkeit und Wartbarkeit zu erreichen - und sparen Sie Zeit.


Wir werden (lose) abdecken

  1. TROCKEN
  2. Abhängigkeitsspritze
  3. Schnittstellen
  4. Behälter
  5. Unit-Tests mit PHPUnit

Beginnen wir mit einigem, aber typischem Code. Dies kann eine Modellklasse in einem gegebenen Rahmen sein.

 Klasse User öffentliche Funktion getCurrentUser () $ user_id = $ _SESSION ['user_id']; $ user = App :: db-> select ('id, username') -> where ('id', $ user_id) -> limit (1) -> get (); if ($ user-> num_results ()> 0) return $ user-> row ();  falsch zurückgeben; 

Dieser Code wird funktionieren, muss aber verbessert werden:

  1. Das ist nicht überprüfbar.
    • Wir verlassen uns auf die $ _SESSION Globale Variable. Unit-Testing-Frameworks wie PHPUnit hängen von der Befehlszeile ab, wo $ _SESSION und viele andere globale Variablen sind nicht verfügbar.
    • Wir setzen auf die Datenbankverbindung. Im Idealfall sollten tatsächliche Datenbankverbindungen in einem Komponententest vermieden werden. Beim Testen geht es um Code, nicht um Daten.
  2. Dieser Code ist nicht so wartbar, wie er sein könnte. Wenn wir beispielsweise die Datenquelle ändern, müssen wir den Datenbankcode in jeder Instanz von ändern App :: db in unserer Anwendung verwendet. Und wie sieht es mit Fällen aus, in denen wir nicht nur die aktuellen Benutzerinformationen wünschen?

Ein versuchter Unit-Test

Hier ist ein Versuch, einen Komponententest für die oben genannte Funktionalität zu erstellen.

 Klasse UserModelTest erweitert PHPUnit_Framework_TestCase public function testGetUser () $ user = new User (); $ currentUser = $ user-> getCurrentUser (); $ this-> assertEquals (1, $ currentUser-> id); 

Lass uns das untersuchen. Erstens schlägt der Test fehl. Das $ _SESSION Variable in der Nutzer Objekt existiert nicht in einem Komponententest, da es PHP in der Befehlszeile ausführt.

Zweitens gibt es keine Datenbankverbindung. Dies bedeutet, dass wir, um dies funktionieren zu können, unsere Anwendung bootstrapen müssen, um die App Objekt und seine db Objekt. Zum Testen wird auch eine funktionierende Datenbankverbindung benötigt.

Damit dieser Gerätetest funktioniert, müssten wir:

  1. Richten Sie ein Konfigurations-Setup für eine CLI (PHPUnit) ein, die in unserer Anwendung ausgeführt wird
  2. Verlassen Sie sich auf eine Datenbankverbindung. Dies bedeutet, dass Sie sich auf eine Datenquelle verlassen müssen, die von unserem Komponententest getrennt ist. Was ist, wenn unsere Testdatenbank nicht die erwarteten Daten enthält? Was ist, wenn unsere Datenbankverbindung langsam ist?
  3. Wenn Sie sich darauf verlassen, dass eine Anwendung bootstrappt ist, erhöhen Sie den Overhead der Tests und verlangsamen die Unit-Tests drastisch. Idealerweise kann der größte Teil unseres Codes unabhängig vom verwendeten Framework getestet werden.

Lassen Sie uns also darüber sprechen, wie wir das verbessern können.


Code trocken halten

Die Funktion zum Abrufen des aktuellen Benutzers ist in diesem einfachen Kontext nicht erforderlich. Dies ist ein konstruiertes Beispiel, aber im Sinne der DRY-Prinzipien möchte ich als erste Optimierung diese Methode verallgemeinern.

 class User öffentliche Funktion getUser ($ user_id) $ user = App :: db-> select ('user') -> where ('id', $ user_id) -> limit (1) -> get (); if ($ user-> num_results ()> 0) return $ user-> row ();  falsch zurückgeben; 

Dies ist eine Methode, die wir für unsere gesamte Anwendung verwenden können. Wir können den aktuellen Benutzer zum Zeitpunkt des Aufrufs übergeben, anstatt diese Funktionalität an das Modell zu übergeben. Code ist modularer und wartungsfreundlicher, wenn er nicht auf andere Funktionen angewiesen ist (z. B. die globale Variable der Sitzung)..

Dies ist jedoch immer noch nicht testbar und wartbar, wie es sein könnte. Wir verlassen uns immer noch auf die Datenbankverbindung.


Abhängigkeitsspritze

Lassen Sie uns helfen, die Situation zu verbessern, indem Sie Abhängigkeitsinjektion hinzufügen. So könnte unser Modell aussehen, wenn wir die Datenbankverbindung in die Klasse übergeben.

 Klasse Benutzer protected $ _db; öffentliche Funktion __construct ($ db_connection) $ this -> _ db = $ db_connection;  public function getUser ($ user_id) $ user = $ this -> _ db-> select ('user') -> where ('id', $ user_id) -> limit (1) -> get (); if ($ user-> num_results ()> 0) return $ user-> row ();  falsch zurückgeben; 

Nun, die Abhängigkeiten unserer Nutzer Modell sind vorgesehen. Unsere Klasse geht nicht mehr von einer bestimmten Datenbankverbindung aus und ist nicht auf globale Objekte angewiesen.

An diesem Punkt ist unsere Klasse grundsätzlich testfähig. Wir können eine Datenquelle unserer Wahl (meistens) und eine Benutzer-ID übergeben und die Ergebnisse dieses Anrufs testen. Wir können auch separate Datenbankverbindungen austauschen (vorausgesetzt, beide implementieren dieselben Methoden zum Abrufen von Daten). Cool.

Schauen wir uns an, wie ein Unit-Test dafür aussehen könnte.

 _mockDb (); $ user = neuer Benutzer ($ db_connection); $ result = $ user-> getUser (1); $ erwartet = new StdClass (); $ erwartet-> id = 1; $ erwartet-> Benutzername = 'fideloper'; $ this-> assertEquals ($ result-> id, $ erwartet-> id, 'Benutzer-ID wurde korrekt festgelegt'); $ this-> assertEquals ($ result-> Benutzername, $ erwartet-> Benutzername, 'Benutzername richtig festgelegt');  protected function _mockDb () // "Mock" (Stub) -Zeilenzeile Ergebnisobjekt $ returnResult = new StdClass (); $ returnResult-> id = 1; $ returnResult-> Benutzername = 'fideloper'; // Mock-Datenbankergebnisobjekt $ result = m :: mock ('DbResult'); $ result-> sollteReceive ('num_results') -> once () -> andReturn (1); $ result-> shouldReceive ('row') -> once () -> andReturn ($ returnResult); // Mock-Datenbankverbindungsobjekt $ db = m :: mock ('DbConnection'); $ db-> shouldReceive ('select') -> once () -> andReturn ($ db); $ db-> sollteReceive ('wo') -> einmal () -> andReturn ($ db); $ db-> shouldReceive ('limit') -> once () -> andReturn ($ db); $ db-> shouldReceive ('get') -> once () -> andReturn ($ result); return $ db; 

Ich habe diesem Unit-Test etwas Neues hinzugefügt: Spott. Mit Mockery können Sie PHP-Objekte "nachahmen". In diesem Fall verspotten wir die Datenbankverbindung. Mit unserem Modell können wir das Testen einer Datenbankverbindung überspringen und unser Modell einfach testen.

Möchten Sie mehr über Mockery erfahren?

In diesem Fall verspotten wir eine SQL-Verbindung. Wir sagen dem Scheinobjekt, dass es das erwartet wählen, woher, Grenze und erhalten Methoden dazu aufgerufen. Ich gebe das Mock selbst zurück, um zu spiegeln, wie das SQL-Verbindungsobjekt sich selbst zurückgibt ($ das), so dass ihre Methode "verkettbar" heißt. Beachten Sie, dass für die erhalten Methode, ich gebe das Ergebnis des Datenbankaufrufs zurück - a stdClass Objekt mit den Benutzerdaten gefüllt.

Dies löst einige Probleme:

  1. Wir testen nur unsere Modellklasse. Wir testen auch keine Datenbankverbindung.
  2. Wir können die Ein- und Ausgänge der Mock-Datenbankverbindung steuern und können daher das Ergebnis des Datenbankaufrufs zuverlässig testen. Ich weiß, dass ich aufgrund des gespielten Datenbankaufrufs eine Benutzer-ID "1" erhalten werde.
  3. Wir müssen unsere Anwendung nicht bootstrappen oder Konfiguration oder Datenbank zum Testen vorlegen.

Wir können noch viel besser machen. Hier wird es interessant.


Schnittstellen

Um dies weiter zu verbessern, könnten wir eine Schnittstelle definieren und implementieren. Betrachten Sie den folgenden Code.

 Schnittstelle UserRepositoryInterface public function getUser ($ user_id);  class MysqlUserRepository implementiert UserRepositoryInterface protected $ _db; öffentliche Funktion __construct ($ db_conn) $ this -> _ db = $ db_conn;  public function getUser ($ user_id) $ user = $ this -> _ db-> select ('user') -> where ('id', $ user_id) -> limit (1) -> get (); if ($ user-> num_results ()> 0) return $ user-> row ();  falsch zurückgeben;  class User protected $ userStore; öffentliche Funktion __construct (UserRepositoryInterface $ user) $ this-> userStore = $ user;  public function getUser ($ user_id) return $ this-> userStore-> getUser ($ user_id); 

Hier passiert einiges.

  1. Zunächst definieren wir eine Schnittstelle für unseren Benutzer Datenquelle. Dies definiert die Nutzer hinzufügen() Methode.
  2. Als Nächstes implementieren wir diese Schnittstelle. In diesem Fall erstellen wir eine MySQL-Implementierung. Wir akzeptieren ein Datenbankverbindungsobjekt und verwenden es, um einen Benutzer aus der Datenbank abzurufen.
  3. Schließlich erzwingen wir die Verwendung einer Klasse, die das implementiert Benutzeroberfläche in unserer Nutzer Modell. Dies garantiert, dass die Datenquelle immer eine getUser () Methode verfügbar, unabhängig davon, welche Datenquelle zur Implementierung verwendet wird Benutzeroberfläche.

Beachten Sie, dass unsere Nutzer Objekttyp-Hinweise Benutzeroberfläche in seinem Konstruktor. Dies bedeutet, dass eine Klasse implementiert wird Benutzeroberfläche MUSS in die übergeben werden Nutzer Objekt. Dies ist eine Garantie, auf die wir uns verlassen - wir brauchen die getUser Methode immer verfügbar zu sein.

Was ist das Ergebnis davon??

  • Unser Code ist jetzt völlig überprüfbar. Für die Nutzer Klasse können wir die Datenquelle leicht verspotten. (Das Testen der Implementierungen der Datenquelle wäre die Aufgabe eines separaten Komponententests.).
  • Unser Code lautet viel wartungsfähiger. Wir können verschiedene Datenquellen austauschen, ohne den Code in unserer Anwendung ändern zu müssen.
  • Wir können schaffen IRGENDEIN Datenquelle. ArrayUser, MongoDbUser, CouchDbUser, MemoryUser usw.
  • Wir können problemlos jede Datenquelle an unsere weitergeben Nutzer Objekt, wenn wir müssen. Wenn Sie sich für SQL entscheiden, können Sie einfach eine andere Implementierung erstellen (z. B., MongoDbUser) und gib das in dein Nutzer Modell-.

Wir haben auch unseren Unit-Test vereinfacht!

 _mockUserRepo (); $ user = neuer Benutzer ($ userRepo); $ result = $ user-> getUser (1); $ erwartet = new StdClass (); $ erwartet-> id = 1; $ erwartet-> Benutzername = 'fideloper'; $ this-> assertEquals ($ result-> id, $ erwartet-> id, 'Benutzer-ID wurde korrekt festgelegt'); $ this-> assertEquals ($ result-> Benutzername, $ erwartet-> Benutzername, 'Benutzername richtig festgelegt');  protected function _mockUserRepo () // Schein erwartetes Ergebnis $ result = new StdClass (); $ result-> id = 1; $ result-> username = 'fideloper'; // Mock jedes Benutzer-Repository $ userRepo = m :: mock ('Fideloper \ Third \ Repository \ UserRepositoryInterface'); $ userRepo-> shouldReceive ('getUser') -> once () -> andReturn ($ result); return $ userRepo; 

Wir haben die Arbeit, eine Datenbankverbindung zu verspotten, komplett erledigt. Stattdessen simulieren wir einfach die Datenquelle und sagen ihr, was wann zu tun ist getUser wird genannt.

Aber wir können es noch besser machen!


Behälter

Beachten Sie die Verwendung unseres aktuellen Codes:

 // In einem Controller $ user = new User (neuer MysqlUser (App: db-> getConnection ("mysql"))); $ user-> id = App :: session ("benutzer-> id"); $ currentUser = $ user-> getUser ($ user_id);

Unser letzter Schritt wird die Einführung sein Behälter. Im obigen Code müssen wir eine Reihe von Objekten erstellen und verwenden, um unseren aktuellen Benutzer zu erreichen. Dieser Code kann in Ihrer Anwendung verstreut sein. Wenn Sie von MySQL zu MongoDB wechseln müssen, müssen Sie immer noch Sie müssen jeden Ort bearbeiten, an dem der obige Code angezeigt wird. Das ist kaum trocken. Container können das beheben.

Ein Container "enthält" einfach ein Objekt oder eine Funktionalität. Es ähnelt einer Registrierung in Ihrer Anwendung. Wir können einen Container verwenden, um automatisch einen neuen zu instanziieren Nutzer Objekt mit allen benötigten Abhängigkeiten. Im Folgenden verwende ich Pimple, eine beliebte Containerklasse.

 // Irgendwo in einer Konfigurationsdatei $ container = new Pimple (); $ container ["user"] = function () neuen Benutzer zurückgeben (neuer MysqlUser (App: db-> getConnection ('mysql')));  // Jetzt können wir in allen Controllern einfach schreiben: $ currentUser = $ container ['user'] -> getUser (App :: session ('user_id'));

Ich habe die Schaffung der verschoben Nutzer Modell an einem Ort in der Anwendungskonfiguration. Als Ergebnis:

  1. Wir haben unseren Code trocken gehalten. Das Nutzer Das Objekt und der gewünschte Datenspeicher werden in unserer Anwendung an einem Ort definiert.
  2. Wir können unser wechseln Nutzer Modell von MySQL zu einer anderen Datenquelle in EIN Standort. Dies ist wesentlich wartungsfreundlicher.

Abschließende Gedanken

Im Verlauf dieses Tutorials haben wir Folgendes erreicht:

  1. Halten Sie unseren Code trocken und wiederverwendbar
  2. Wartungsfähiger Code erstellt - Wir können bei Bedarf Datenquellen für unsere Objekte an einem Ort für die gesamte Anwendung austauschen
  3. Wir haben unseren Code testbar gemacht - Wir können Objekte leicht simulieren, ohne unsere Anwendung bootstrapping oder eine Testdatenbank erstellen zu müssen
  4. Informationen zur Verwendung von Abhängigkeitseinspritzung und Schnittstellen, um das Erstellen von überprüfbarem und wartungsfähigem Code zu ermöglichen
  5. Wir haben gesehen, wie Container helfen können, unsere Anwendung wartbarer zu machen

Ich bin sicher, Sie haben bemerkt, dass wir im Hinblick auf Wartbarkeit und Testbarkeit viel mehr Code hinzugefügt haben. Ein starkes Argument kann gegen diese Implementierung sprechen: Wir erhöhen die Komplexität. In der Tat erfordert dies tiefere Kenntnisse des Codes, sowohl für den Hauptautor als auch für die Mitarbeiter eines Projekts.

Die Kosten für Erklärung und Verständnis werden jedoch durch das zusätzliche Gesamtgewicht bei weitem aufgewogen verringern in technischen Schulden.

  • Der Code ist wesentlich wartungsfreundlicher und ermöglicht Änderungen an einem Ort statt an mehreren Stellen.
  • Durch die Möglichkeit zum schnellen Komponententest werden Fehler im Code um ein Vielfaches reduziert - insbesondere bei langfristigen oder auf Community-basierten (Open-Source-) Projekten.
  • Vorn die Extraarbeit erledigen werden Sparen Sie später Zeit und Kopfschmerzen.

Ressourcen

Sie können einschließen Spott und PHPUnit einfach in Ihre Anwendung mit Composer. Fügen Sie diese zu Ihrem Abschnitt "required-dev" in Ihrem hinzu composer.json Datei:

 "required-dev": "Spott / Spott": "0.8. *", "phpunit / phpunit": "3.7. *"

Sie können dann Ihre Composer-basierten Abhängigkeiten mit den "dev" -Anforderungen installieren:

 $ php composer.phar install --dev

Weitere Informationen zu Mockery, Composer und PHPUnit finden Sie hier auf Nettuts+.

  • Spott: Ein besserer Weg
  • Einfache Paketverwaltung mit Composer
  • Testgetriebenes PHP

Erwägen Sie für PHP die Verwendung von Laravel 4, da Container und andere hier beschriebene Konzepte in außergewöhnlichem Umfang verwendet werden.

Danke fürs Lesen!