Cum se scrie un cod testabil și întreținut în PHP

Cadrele oferă un instrument pentru dezvoltarea rapidă a aplicațiilor, dar adesea acumulează datorii tehnice cât de repede vă permit să creați funcționalități. Datoria tehnică este creată atunci când mentenabilitatea nu este o atenție specială a dezvoltatorului. Schimbările viitoare și depanarea devin costisitoare, din cauza lipsei de testare și structură unitară.

Iată cum puteți începe structurarea codului pentru a obține testabilitate și mentenabilitate - și pentru a vă economisi timp.


Vom acoperi (slab)

  1. USCAT
  2. Dependența de injecție
  3. interfeţe
  4. Containere
  5. Unități de testare cu PHPUnit

Să începem cu niște coduri contrived, dar tipice. Aceasta ar putea fi o clasă de model în orice cadru dat.

 user class funcția publică getCurrentUser () $ user_id = $ _SESSION ['user_id']; $ user = App :: db-> select ('id, username') -> unde ('id', $ user_id) -> limită (1) -> get (); dacă ($ user-> num_results ()> 0) return $ user-> row ();  return false; 

Acest cod va funcționa, dar are nevoie de îmbunătățire:

  1. Acest lucru nu este testabil.
    • Ne bazăm pe $ _SESSION variabilă globală. Unitățile de testare a cadrelor, cum ar fi PHPUnit, se bazează pe linia de comandă, unde $ _SESSION și multe alte variabile globale nu sunt disponibile.
    • Ne bazăm pe conexiunea bazei de date. În mod ideal, conexiunile de bază ale bazei de date ar trebui să fie evitate într-un test de unitate. Testarea este despre cod, nu despre date.
  2. Acest cod nu poate fi menținut așa cum ar putea fi. De exemplu, dacă schimbăm sursa de date, va trebui să schimbăm codul bazei de date în fiecare instanță App :: db utilizate în aplicația noastră. De asemenea, cum rămâne cu situațiile în care nu vrem doar informația curentă a utilizatorului?

Un test de încercare a unității

Iată o încercare de a crea un test de unitate pentru funcționalitatea de mai sus.

 class UserModelTest extinde PHPUnit_Framework_TestCase funcția publică testGetUser () $ user = new User (); $ curentUser = $ user-> getCurrentUser (); $ this-> assertEquals (1, $ curentUser-> id); 

Să examinăm asta. În primul rând, testul va eșua. $ _SESSION variabilă folosită în Utilizator obiect nu există într-un test de unitate, deoarece rulează PHP în linia de comandă.

În al doilea rând, nu există setări de conectare la baza de date. Aceasta înseamnă că, pentru a face acest lucru, va trebui să ne punem în aplicare cererea pentru a obține App obiect și a lui db obiect. Vom avea nevoie, de asemenea, de o conexiune la baza de date de lucru pentru a testa împotriva.

Pentru ca această unitate să funcționeze la test, ar trebui să:

  1. Configurați configurarea config pentru un CLI (PHPUnit) rulat în aplicația noastră
  2. Bazați-vă pe o conexiune la baza de date. A face acest lucru înseamnă a se baza pe o sursă de date separată de testul unității noastre. Ce se întâmplă dacă baza de date de testare nu are datele pe care le așteptăm? Ce se întâmplă dacă conexiunea bazei de date este lentă?
  3. Bazându-se pe o aplicație care este bootstrapped, crește cheltuielile generale ale testelor, încetinind drastic testarea unităților. În mod ideal, majoritatea codului nostru pot fi testate independent de cadrul utilizat.

Deci, haideți să ajungem la modul în care putem îmbunătăți acest lucru.


Păstrați codul DRY

Funcția de preluare a utilizatorului curent nu este necesară în acest context simplu. Acesta este un exemplu controversat, dar în spiritul principiilor DRY, prima optimizare pe care o aleg să o fac este generalizarea acestei metode.

 ($ user_id) $ user = App :: db-> select ('user') -> unde ('id', $ user_id) -> limită (1) -> get (); dacă ($ user-> num_results ()> 0) return $ user-> row ();  return false; 

Aceasta oferă o metodă pe care o putem folosi în întreaga noastră aplicație. Putem trece la utilizatorul curent în momentul apelului, mai degrabă decât trecerea acestei funcționalități la model. Codul este mai modular și menținut atunci când nu se bazează pe alte funcționalități (cum ar fi variabila globală a sesiunii).

Cu toate acestea, acest lucru nu este încă testabil și menținut așa cum ar putea fi. Încă ne bazăm pe conexiunea bazei de date.


Dependența de injecție

Să ajutăm la îmbunătățirea situației prin adăugarea unei injecții de dependență. Iată cum ar putea arăta modelul nostru, atunci când trecem conectivitatea bazei de date în clasă.

 user class protejat $ _db; funcția publică __construct ($ db_connection) $ this -> _ db = $ db_connection;  funcția publică getUser ($ user_id) $ user = $ this -> _ db-> select ('user') -> unde ('id', $ user_id) -> limit (1) -> get; dacă ($ user-> num_results ()> 0) return $ user-> row ();  return false; 

Acum, dependențele noastre Utilizator model sunt prevăzute. Clasa noastră nu mai presupune o anumită conexiune la baza de date, nici nu se bazează pe obiecte globale.

În acest moment, clasa noastră este practic testabilă. Putem trece într-o sursă de date la alegerea noastră (cea mai mare parte) și un id de utilizator și să testați rezultatele acelui apel. De asemenea, putem schimba conexiuni separate de baze de date (presupunând că ambele implementează aceleași metode pentru recuperarea datelor). Rece.

Să ne uităm la ce ar putea arăta un test de unitate pentru asta.

 _mockDb (); $ user = utilizator nou ($ db_connection); $ rezultat = $ user-> getUser (1); $ așteptat = nou StdClass (); $ așteptată-> id = 1; $ expected-> username = 'fideloper'; $ this-> assertEquals ($ rezultat-> id, $ așteptat-> id, "Setarea corectă a codului de utilizator"); $ this-> assertEquals ($ rezultat-> numele de utilizator, $ expected-> username, 'Setul corect de utilizator');  funcția protejată _mockDb () // "Mock" (stub) rezultatul obiectului $ row returnResult = new StdClass (); $ returnResult-> id = 1; $ returnResult-> nume utilizator = 'fideloper'; // Obiectul rezultat al bazei de date mock $ result = m :: mock ('DbResult'); $ result-> shouldReceive ('num_results') -> once () -> șiReturn (1); $ result-> shouldReceive ('row') -> o dată () -> șiReturn ($ returnResult); // Obiect conexiune bază de date bază de date $ db = m :: mock ('DbConnection'); $ db-> shouldReceive ('selectează') -> o dată () -> șiReturn ($ db); $ db-> shouldReceive ('unde') -> o dată () -> șiReturn ($ db); $ db-> shouldReceive ('limit') -> o dată () -> șiReturn ($ db); $ db-> shouldReceive ('get') -> o dată () -> șiReturn ($ rezultat); returnați $ db; 

Am adăugat ceva nou în acest test de unitate: Bătrâni. Dăruirea vă permite să "falsificați" (false) obiectele PHP. În acest caz, batem conexiunea bazei de date. Cu martorul nostru, putem trece peste testarea unei conexiuni de baze de date și pur și simplu testați modelul nostru.

Doriți să aflați mai multe despre Mockery?

În acest caz, ne batem o conexiune SQL. Spunem obiectului fals să se aștepte să aibă Selectați, Unde, limită și obține metodele pe care le-a numit. Mă întorc pe Mock, în sine, pentru a reflecta modul în care obiectul SQL se întoarce ($ this), făcând astfel metoda sa "în lanț". Rețineți că, pentru obține , returnez rezultatul apelului bazei de date - a stdClass obiect cu datele de utilizator populate.

Acest lucru rezolvă câteva probleme:

  1. Testează doar clasa noastră de modele. De asemenea, nu testarea unei conexiuni de baze de date.
  2. Putem controla intrările și ieșirile conexiunii bazei de date falsificate și, prin urmare, putem testa în mod fiabil rezultatul apelului bazei de date. Știu că voi obține un ID de utilizator de "1" ca urmare a apelului bazei de date martor.
  3. Nu este nevoie să bootstrap aplicația noastră sau să avem o configurație sau o bază de date prezentă pentru testare.

Încă mai putem face mult mai bine. Iată unde devine interesant.


interfeţe

Pentru a îmbunătăți acest lucru, am putea defini și implementa o interfață. Luați în considerare următorul cod.

 interfață UserRepositoryInterface funcția publică getUser ($ user_id);  class MysqlUserRepository implementează UserRepositoryInterface protejat $ _db; funcția publică __construct ($ db_conn) $ this -> _ db = $ db_conn;  funcția publică getUser ($ user_id) $ user = $ this -> _ db-> select ('user') -> unde ('id', $ user_id) -> limit (1) -> get; dacă ($ user-> num_results ()> 0) return $ user-> row ();  return false;  Utilizator de clasă protected $ userStore; funcția publică __construct (UserRepositoryInterface $ user) $ this-> userStore = $ user;  funcția publică getUser ($ user_id) retur $ this-> userStore-> getUser ($ user_id); 

Sunt câteva lucruri care se întâmplă aici.

  1. În primul rând, definim o interfață pentru utilizatorul nostru sursă de date. Aceasta definește Adăugați utilizator() metodă.
  2. Apoi implementăm această interfață. În acest caz, vom crea o implementare MySQL. Acceptăm un obiect de conexiune la baza de date și îl folosim pentru a atrage un utilizator din baza de date.
  3. În cele din urmă, vom impune utilizarea unei clase de implementare Interfața cu utilizatorul în a noastră Utilizator model. Acest lucru garantează că sursa de date va avea întotdeauna a getUser () Metoda disponibilă, indiferent de sursa de date utilizată pentru implementare Interfața cu utilizatorul.

Rețineți că noi Utilizator tipul de sfaturi de tip Interfața cu utilizatorul în constructorul său. Aceasta înseamnă că o clasă de implementare Interfața cu utilizatorul TREBUIE să fie transmise în Utilizator obiect. Aceasta este o garanție pe care ne bazăm - avem nevoie de ea getUser pentru a fi întotdeauna disponibilă.

Care este rezultatul acestui lucru?

  • Codul nostru este acum complet testabile. Pentru Utilizator clasa, putem mica cu ușurință sursa de date. (Testarea implementărilor sursei de date ar fi treaba unui test separat al unității).
  • Codul nostru este mult mai ușor de întreținut. Putem schimba diferite surse de date fără a trebui să schimbăm codul în întreaga aplicație.
  • Putem crea ORICE sursă de date. ArrayUser, MongoDbUser, CouchDbUser, MemoryUser, etc.
  • Putem transmite cu ușurință orice sursă de date către noi Utilizator obiect dacă trebuie. Dacă decideți să ștergeți SQL, puteți crea o implementare diferită (de exemplu, MongoDbUser) și treci asta în tine Utilizator model.

Am simplificat și testul de unitate!

 _mockUserRepo (); $ user = utilizator nou ($ userRepo); $ rezultat = $ user-> getUser (1); $ așteptat = nou StdClass (); $ așteptată-> id = 1; $ expected-> username = 'fideloper'; $ this-> assertEquals ($ rezultat-> id, $ așteptat-> id, "Setarea corectă a codului de utilizator"); $ this-> assertEquals ($ rezultat-> numele de utilizator, $ expected-> username, 'Setul corect de utilizator');  funcția protejată _mockUserRepo () // Mock rezultatul așteptat $ result = new StdClass (); $ rezultat-> id = 1; $ result-> username = 'fideloper'; // Mock orice depozit de utilizatori $ userRepo = m :: mock ('Fideloper \ Third \ Repository \ UserRepositoryInterface'); $ userRepo-> shouldReceive ('getUser') -> o dată () -> șiReturn ($ rezultat); returnează $ userRepo; 

Am luat munca de a batjocori complet o conexiune de baze de date. În schimb, pur și simplu batem sursa de date și spuneți-i ce să facă când getUser se numește.

Dar, putem face tot mai bine!


Containere

Luați în considerare utilizarea codului nostru actual:

 // În unele controler $ user = new User (nou MysqlUser (App: db-> getConnection ("mysql"))); $ user-> id = App :: sesiune ("user-> id"); $ curentUser = $ user-> getUser ($ user_id);

Ultimul nostru pas va fi să introducem containere. În codul de mai sus, trebuie să creați și să utilizați o grămadă de obiecte doar pentru a obține utilizatorul nostru curent. Acest cod poate fi îngrădit în aplicația dvs. Dacă trebuie să treceți de la MySQL la MongoDB, veți face asta încă trebuie să editați fiecare loc unde apare codul de mai sus. Nu e prea uscat. Containerele pot repara acest lucru.

Un container pur și simplu "conține" un obiect sau o funcționalitate. Este similar cu un registru din aplicația dvs. Putem folosi un container pentru a instanția automat un nou Utilizator obiect cu toate dependențele necesare. Mai jos, folosesc Pimple, o clasă populară de containere.

 // undeva într-un fișier de configurare $ container = nou Pimple (); $ container ["user"] = functie () retur nou utilizator (nou MysqlUser (app: db-> getConnection ('mysql'));  Acum, la toți controlorii noștri, putem scrie: $ currentUser = $ container ['user'] -> getUser (App :: session ('user_id'));

Am mutat crearea Utilizator model într-o singură locație în configurația aplicației. Ca rezultat:

  1. Am păstrat codul nostru DRY. Utilizator obiect și depozitul de date de alegere este definit într-o locație din aplicația noastră.
  2. Putem să ne oprim Utilizator model de la utilizarea MySQL la orice altă sursă de date în UNU Locație. Acest lucru este mult mai sustenabil.

Gândurile finale

În cursul acestui tutorial am realizat următoarele:

  1. A păstrat codul uscat și reutilizabil
  2. Creat cod de întreținere - Putem opri sursele de date pentru obiectele noastre într-o singură locație pentru întreaga aplicație, dacă este necesar
  3. A făcut codul nostru testabil - Putem să falsăm obiecte cu ușurință fără a se baza pe bootstrapping cererea noastră sau crearea unei baze de date de testare
  4. Învățat despre utilizarea injecției de dependență și a interfețelor, pentru a permite crearea unui cod testabil și sustenabil
  5. A văzut modul în care containerele pot ajuta la îmbunătățirea mentenabilității aplicației noastre

Sunt sigur că ați observat că am adăugat mult mai mult cod în numele mentenabilității și testabilității. Se poate face un argument puternic împotriva acestei implementări: intensificăm complexitatea. Într-adevăr, aceasta necesită o cunoaștere mai aprofundată a codului, atât pentru autorul principal, cât și pentru colaboratorii unui proiect.

Cu toate acestea, costul de explicație și de înțelegere este cu mult depășit de cotele de suplimentare în ansamblu scădea în datoria tehnică.

  • Codul este mult mai ușor de întreținut, făcând schimbări posibile într-o locație, mai degrabă decât în ​​mai multe.
  • A fi capabil să testeze unitatea (rapid) va reduce erorile în cod cu o marjă mare - în special în proiecte pe termen lung sau în comunitate (open-source).
  • Făcând extra-munca în față voi economisiți timp și dureri de cap mai târziu.

Resurse

Puteți include Bătaie de joc și PHPUnit în aplicația dvs. cu ușurință folosind Composer. Adăugați-le în secțiunea "necesită-dev" din secțiunea dvs. composer.json fişier:

 "require-dev": "batjocură / batjocură": "0.8. *", "phpunit / phpunit": "3.7.

Apoi, puteți instala dependențele bazate pe Composer cu cerințele "dev":

 $ php compune.phar install --dev

Aflați mai multe despre Mockery, Composer și PHPUnit aici pe Nettuts+.

  • Batjocura: o cale mai buna
  • Gestionare ușoară a pachetelor cu compozitor
  • Testat PHP

Pentru PHP, vă recomandăm să folosiți Laravel 4, deoarece utilizează în mod excepțional containerele și alte concepte scrise aici.

Vă mulțumim pentru lectură!

Cod