Hur man skriver testbar och upprätthållbar kod i PHP

Ramar skapar ett verktyg för snabb applikationsutveckling, men genererar ofta tekniska skulder så snabbt som de låter dig skapa funktionalitet. Teknisk skuld skapas när underhållsbehov inte är ett målmedvetet fokus hos utvecklaren. Framtida förändringar och felsökning blir kostsamma på grund av brist på enhetstestning och struktur.

Så här börjar du strukturera din kod för att uppnå testbarhet och underhåll - och spara tid.


Vi kommer att täcka (löst)

  1. TORR
  2. Beroende på injektion
  3. gränssnitt
  4. behållare
  5. Enhetstester med PHPUnit

Låt oss börja med några konstruerade, men typiska kod. Detta kan vara en modellklass i en given ram.

 klass användare offentlig funktion getCurrentUser () $ user_id = $ _SESSION ['user_id']; $ user = App :: db-> välj ('id, användarnamn') -> var ('id', $ user_id) -> limit (1) -> get (); om ($ user-> num_results ()> 0) return $ user-> row ();  returnera false; 

Denna kod kommer att fungera, men behöver förbättras:

  1. Detta kan inte testas.
    • Vi är beroende av $ _SESSION global variabel. Enhetsprovningsramar, till exempel PHPUnit, är beroende av kommandoraden, där $ _SESSION och många andra globala variabler är inte tillgängliga.
    • Vi är beroende av databasanslutningen. Ideellt bör de faktiska databasanslutningarna undvikas i en enhetstest. Testning handlar om kod, inte om data.
  2. Den här koden är inte lika underhållbar som den kan vara. Om vi ​​till exempel ändrar datakällan måste vi ändra databaskoden i alla fall av App :: db används i vår ansökan. Också vad gäller instanser där vi inte bara vill ha den nuvarande användarens information?

Ett försökt enhetstest

Här är ett försök att skapa ett enhetstest för ovanstående funktionalitet.

 klass UserModelTest utökar PHPUnit_Framework_TestCase public function testGetUser () $ user = new User (); $ currentUser = $ user-> getCurrentUser (); $ this-> assertEquals (1, $ currentUser-> id); 

Låt oss undersöka detta. Först kommer testet att misslyckas. De $ _SESSION variabel som används i Användare objekt existerar inte i ett enhetstest, eftersom det kör PHP i kommandoraden.

För det andra finns det ingen databasanslutning setup. Det betyder att vi måste starta vår ansökan för att kunna få det för att kunna göra detta arbete App objekt och dess db objekt. Vi behöver också en arbetsdatabasanslutning för att testa mot.

För att göra det här testet arbete, skulle vi behöva:

  1. Installera en config-inställning för en CLI (PHPUnit) -körning i vår applikation
  2. Lita på en databasanslutning. Att göra detta innebär att förlita sig på en datakälla som är separat från vårt enhetstest. Vad händer om vår testdatabas inte har de data som vi förväntar oss? Vad händer om vår databasanslutning är långsam?
  3. Att förlita sig på en applikation som startas upp ökar testets överhuvud, vilket sänker enhetstesterna dramatiskt. Idealt sett kan det mesta av vår kod testas oberoende av vilken ram som används.

Så, låt oss ta reda på hur vi kan förbättra detta.


Behåll kod DRY

Funktionen som hämtar den nuvarande användaren är onödig i detta enkla sammanhang. Detta är ett konstruerat exempel, men i DRY-principens anda, den första optimeringen jag väljer att göra är att generalisera denna metod.

 klass användare allmän funktion getUser ($ user_id) $ user = App :: db-> välj ('användare') -> where ('id', $ user_id) -> limit (1) -> get (); om ($ user-> num_results ()> 0) return $ user-> row ();  returnera false; 

Detta ger en metod som vi kan använda över hela vår ansökan. Vi kan vidarebefordra den aktuella användaren vid samtalets gång, istället för att överföra den funktionen till modellen. Koden är mer modulär och underhållbar när den inte är beroende av andra funktioner (till exempel den globala variabelen).

Detta är dock fortfarande inte testbart och underhållbart som det kan vara. Vi är fortfarande beroende av databasanslutningen.


Beroende på injektion

Låt oss hjälpa till att förbättra situationen genom att lägga till viss beredskapsinjektion. Här är vad vår modell kan se ut, när vi överför databasförbindelsen till klassen.

 klass användare protected $ _db; offentlig funktion __construct ($ db_connection) $ this -> _ db = $ db_connection;  allmän funktion getUser ($ user_id) $ user = $ this -> _ db-> select ('användare') -> var ('id', $ user_id) -> limit (1) -> get (); om ($ user-> num_results ()> 0) return $ user-> row ();  returnera false; 

Nu beror våra Användare modell finns för. Vår klass tar inte längre ut en viss databasförbindelse, och bygger inte heller på några globala objekt.

Vid denna tidpunkt är vår klass i princip testbar. Vi kan skicka in en datakälla efter eget val (mestadels) och ett användar-ID, och testa resultaten av samtalet. Vi kan också byta ut separata databasanslutningar (förutsatt att båda implementerar samma metoder för att hämta data). Häftigt.

Låt oss titta på vad ett enhetstest kan se ut för det.

 _mockDb (); $ user = ny användare ($ db_connection); $ result = $ user-> getUser (1); $ expected = new StdClass (); $ expected-> id = 1; $ expected-> användarnamn = 'fideloper'; $ this-> assertEquals ($ result-> id, $ expected-> id, 'User ID set correctly'); $ this-> assertEquals ($ result-> användarnamn, $ förväntat-> användarnamn, 'Användarnamn satt korrekt');  skyddad funktion _mockDb () // "Mock" (stub) databasrad resultatobjekt $ returnResult = new StdClass (); $ returnResult-> id = 1; $ returnResult-> användarnamn = 'fideloper'; // Mock databasresultatobjekt $ result = m :: mock ('DbResult'); $ result-> shouldReceive ('num_results') -> once () -> ochReturn (1); $ result-> shouldReceive ('row') -> en gång () -> ochReturn ($ returnResult); // Mock-databasanslutningsobjekt $ db = m :: mock ('DbConnection'); $ db-> shouldReceive ('select') -> en gång () -> ochReturn ($ db); $ db-> shouldReceive ('where') -> once () -> andReturn ($ db); $ db-> shouldReceive ('limit') -> en gång () -> ochReturn ($ db); $ db-> shouldReceive ('get') -> en gång () -> ochReturn ($ resultat); returnera $ db; 

Jag har lagt till något nytt för detta test: Mockery. Mockery låter dig "spotta" (falska) PHP-objekt. I det här fallet mockar vi databasanslutningen. Med vår mock kan vi hoppa över testning av en databasanslutning och helt enkelt testa vår modell.

Vill du lära dig mer om Mockery?

I det här fallet mockar vi en SQL-anslutning. Vi berättar att det mocka objektet förväntar sig att ha Välj, var, begränsa och skaffa sig metoder som kallas på det. Jag återvänder Mock, självt, för att spegla hur SQL-kopplingsobjektet återvänder sig ($ detta), vilket gör att dess metod kallas "chainable". Observera att för skaffa sig metod returnerar jag databasuppkopplingsresultatet - a stdClass objekt med användardata som är fyllda.

Detta löser några problem:

  1. Vi testar bara vår modellklass. Vi testar inte heller en databasanslutning.
  2. Vi kan styra inmatningar och utgångar från mock databasanslutningen och kan därför på ett tillförlitligt sätt testa mot resultatet av databassamtalet. Jag vet att jag får ett användar-ID på "1" som ett resultat av det mocked databassamtalet.
  3. Vi behöver inte starta upp vår applikation eller ha någon konfiguration eller databas som är närvarande för att testa.

Vi kan fortfarande göra mycket bättre. Här är det som blir intressant.


gränssnitt

För att förbättra detta ytterligare kunde vi definiera och implementera ett gränssnitt. Tänk på följande kod.

 gränssnitt UserRepositoryInterface public function getUser ($ user_id);  klass MysqlUserRepository implementerar UserRepositoryInterface protected $ _db; offentlig funktion __construct ($ db_conn) $ this -> _ db = $ db_conn;  allmän funktion getUser ($ user_id) $ user = $ this -> _ db-> select ('användare') -> var ('id', $ user_id) -> limit (1) -> get (); om ($ user-> num_results ()> 0) return $ user-> row ();  returnera false;  klass User protected $ userStore; offentlig funktion __construct (UserRepositoryInterface $ user) $ this-> userStore = $ user;  allmän funktion getUser ($ user_id) return $ this-> userStore-> getUser ($ user_id); 

Det händer några saker här.

  1. Först definierar vi ett gränssnitt för vår användare datakälla. Detta definierar Lägg till användare() metod.
  2. Därefter implementerar vi det gränssnittet. I det här fallet skapar vi ett MySQL-genomförande. Vi accepterar ett databasförbindningsobjekt och använder det för att fånga en användare från databasen.
  3. Slutligen verkställer vi användningen av en klass som implementerar Användargränssnitt i vår Användare modell. Detta garanterar att datakällan alltid kommer att ha en getUser () tillgänglig metod, oavsett vilken datakälla som används för att genomföra Användargränssnitt.

Observera att vår Användare Objekttyp-tips Användargränssnitt i sin konstruktör. Detta innebär att en klass genomförande Användargränssnitt MÅSTE gå in i Användare objekt. Det här är en garanti som vi lita på - vi behöver getUser metod att alltid vara tillgänglig.

Vad är resultatet av detta?

  • Vår kod är nu fullt testbar. För Användare klass kan vi enkelt mocka datakällan. (Testa implementeringen av datakällan skulle vara jobbet med ett separat enhetstest).
  • Vår kod är mycket mer underhållsbar. Vi kan byta ut olika datakällor utan att behöva byta kod under hela applikationen.
  • Vi kan skapa NÅGRA datakälla. ArrayUser, MongoDbUser, CouchDbUser, MemoryUser, etc.
  • Vi kan enkelt skicka någon datakälla till vår Användare objekt om vi behöver. Om du bestämmer dig för att dölja SQL, kan du bara skapa en annan implementering (till exempel, MongoDbUser) och skicka det till din Användare modell.

Vi har också förenklat vårt test på enheten!

 _mockUserRepo (); $ user = new User ($ userRepo); $ result = $ user-> getUser (1); $ expected = new StdClass (); $ expected-> id = 1; $ expected-> användarnamn = 'fideloper'; $ this-> assertEquals ($ result-> id, $ expected-> id, 'User ID set correctly'); $ this-> assertEquals ($ result-> användarnamn, $ förväntat-> användarnamn, 'Användarnamn satt korrekt');  skyddad funktion _mockUserRepo () // Mock förväntat resultat $ result = new StdClass (); $ resultat-> id = 1; $ result-> användarnamn = 'fideloper'; // Mock något användarregister $ userRepo = m :: mock ('Fideloper \ Third \ Repository \ UserRepositoryInterface'); $ userRepo-> shouldReceive ('getUser') -> en gång () -> ochReturn ($ resultat); returnera $ userRepo; 

Vi har arbetat med att mocka en databasanslutning helt ut. I stället stöter vi bara på datakällan och berättar vad som ska göras när getUser kallas.

Men vi kan fortfarande göra det bättre!


behållare

Tänk på användningen av vår nuvarande kod:

 // I någon controller $ user = new User (ny MysqlUser (App: db-> getConnection ("mysql"))); $ user-> id = App :: session ("user-> id"); $ currentUser = $ user-> getUser ($ user_id);

Vårt sista steg kommer att vara att introducera behållare. I ovanstående kod måste vi skapa och använda en massa objekt bara för att få vår nuvarande användare. Den här koden kan vara skräpad över din ansökan. Om du behöver byta från MySQL till MongoDB, så kommer du fortfarande måste redigera varje ställe där ovanstående kod visas. Det är knappast torkat. Behållare kan fixa detta.

En behållare innehåller "ett" objekt eller funktionalitet. Det liknar ett register i din ansökan. Vi kan använda en behållare för att automatiskt skapa en ny Användare objekt med alla nödvändiga beroenden. Nedan använder jag Pimple, en populär containerklass.

 // Någonstans i en konfigurationsfil $ container = new Pimple (); $ container ["user"] = function () returnera ny användare (ny MysqlUser (App: db-> getConnection ('mysql')));  // Nu kan vi i alla våra kontroller skriva: $ currentUser = $ container ['user'] -> getUser (App :: session ('user_id'));

Jag har flyttat skapandet av Användare modell till en plats i applikationskonfigurationen. Som ett resultat:

  1. Vi har hållit vår kod DRY. De Användare objekt och valfri datalagring är definierad på en plats i vår ansökan.
  2. Vi kan byta ut vår Användare modell från att använda MySQL till någon annan datakälla i ETT plats. Detta är mycket mer underhållbart.

Slutgiltiga tankar

Under den här handledningen genomförde vi följande:

  1. Håll vår kod DRY och återanvändbar
  2. Skapad underhållsbar kod - Vi kan byta ut datakällor för våra objekt på en plats för hela applikationen om det behövs
  3. Gjorde vår kod testbar - Vi kan mocka objekt enkelt utan att förlita oss på att starta upp vår applikation eller skapa en testdatabas
  4. Lär dig om hur du använder Dependency Injection and Interfaces för att möjliggöra att skapa testbar och underhållbar kod
  5. Såg hur behållare kan hjälpa till att göra vår ansökan mer underhållsbar

Jag är säker på att du har märkt att vi har lagt till mycket mer kod i namn av underhållbarhet och testbarhet. Ett starkt argument kan göras mot denna implementering: vi ökar komplexiteten. Detta kräver faktiskt en djupare kunskap om kod, både för huvudförfattaren och för samarbetspartners av ett projekt.

Kostnaden för förklaring och förståelse är dock väldigt utvägd av det extra övergripande minska i teknisk skuld.

  • Koden är mycket mer underhållbar, vilket möjliggör ändringar på en plats, snarare än flera.
  • Att kunna prova enhetstest (snabbt) kommer att minska buggar i kod med stor marginal - särskilt i långsiktiga eller community-driven (open source) projekt.
  • Gör extraarbete framåt kommer spara tid och huvudvärk senare.

Medel

Du kan inkludera Hån och PHPUnit till din ansökan enkelt med hjälp av kompositören. Lägg till dessa i din "Require-dev" -sektion i din composer.json fil:

 "kräver-dev": "hån": "0.8. *", "phpunit / phpunit": "3.7. *"

Du kan sedan installera dina Composer-baserade beroenden med "dev" -kraven:

 $ php composer.phar installera --dev

Läs mer om Mockery, Composer och PHPUnit här på Nettuts+.

  • Mockery: Ett bättre sätt
  • Enkel pakethantering med kompositör
  • Testdriven PHP

För PHP, överväga att använda Laravel 4, eftersom det gör exceptionell användning av behållare och andra begrepp skrivna om här.

Tack för att du läser!