Hvordan skrive testbar og vedlikeholdbar kode i PHP

Rammer gir et verktøy for rask applikasjonsutvikling, men ofte tilfaller teknisk gjeld så raskt som de tillater deg å skape funksjonalitet. Teknisk gjeld oppstår når vedlikeholdsevne ikke er et målrettet fokus for utvikleren. Fremtidige endringer og feilsøking blir kostbare på grunn av mangel på enhetstesting og struktur.

Slik begynner du å strukturere koden din for å oppnå testbarhet og vedlikehold - og spare tid.


Vi vil dekke (løst)

  1. TØRKE
  2. Dependency Injection
  3. grensesnitt
  4. containere
  5. Enhetstester med PHPUnit

La oss begynne med noen konstruerte, men typiske kode. Dette kan være en modellklasse i et gitt rammeverk.

 klassen bruker offentlig funksjon getCurrentUser () $ user_id = $ _SESSION ['user_id']; $ user = App :: db-> velg ('id, brukernavn') -> hvor ('id', $ user_id) -> limit (1) -> get (); hvis ($ user-> num_results ()> 0) return $ user-> row ();  returner falsk; 

Denne koden vil fungere, men trenger forbedring:

  1. Dette kan ikke testes.
    • Vi stoler på $ _SESSION global variabel. Unit-testing rammer, for eksempel PHPUnit, stole på kommandolinjen, hvor $ _SESSION og mange andre globale variabler er ikke tilgjengelige.
    • Vi stoler på databasetilkoblingen. Ideelt sett bør de faktiske databaseforbindelsene unngås i en enhetstest. Testing handler om kode, ikke om data.
  2. Denne koden er ikke så vedlikeholdbar som den kunne være. Hvis vi for eksempel endrer datakilden, må vi endre databasekoden i alle tilfeller av App :: db brukes i vår søknad. Også, hva med tilfeller hvor vi ikke vil bare den nåværende brukerens informasjon?

En forsøkt enhetstest

Her er et forsøk på å opprette en enhetstest for funksjonen ovenfor.

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

La oss undersøke dette. Først vil testen mislykkes. De $ _SESSION variabel som brukes i Bruker objekt eksisterer ikke i en enhetstest, da det kjører PHP i kommandolinjen.

For det andre er det ingen oppsett for databasetilkobling. Dette betyr at vi for å gjøre dette arbeidet må starte opp vår søknad for å få det app objekt og dets db gjenstand. Vi trenger også en arbeidsdatabaseforbindelse for å teste mot.

For å få denne enheten til å teste, må vi:

  1. Oppsett et config-oppsett for en CLI (PHPUnit) -kjøring i vår søknad
  2. Stol på en databaseforbindelse. Å gjøre dette betyr at du stoler på en datakilde som er skilt fra vår enhetstest. Hva om vår testdatabase ikke har dataene vi forventer? Hva om vår databaseforbindelse er treg?
  3. Å stole på at en applikasjon blir bootstrapped øker overhead av testene, senker enhetstesterne dramatisk ned. Ideelt sett kan det meste av vår kode bli testet uavhengig av rammen som brukes.

Så la oss finne ut hvordan vi kan forbedre dette.


Hold kode DRY

Funksjonen som henter den nåværende brukeren er unødvendig i denne enkle sammenhengen. Dette er et opptatt eksempel, men i DRY-prinsippens forstand, er den første optimaliseringen jeg velger å lage, å generalisere denne metoden.

 klassen bruker offentlig funksjon getUser ($ user_id) $ user = App :: db-> velg ('bruker') -> hvor ('id', $ user_id) -> limit (1) -> get (); hvis ($ user-> num_results ()> 0) return $ user-> row ();  returner falsk; 

Dette gir en metode vi kan bruke over hele vårt program. Vi kan passere i den nåværende brukeren på tidspunktet for samtalen, i stedet for å overføre den funksjonaliteten til modellen. Koden er mer modulær og vedlikeholdsbar når den ikke stole på andre funksjoner (som økt global variabel).

Dette er imidlertid fortsatt ikke testbar og vedlikeholdsbar som det kan være. Vi stoler fortsatt på databasetilkoblingen.


Dependency Injection

La oss bidra til å forbedre situasjonen ved å legge til litt avhengighetsinjeksjon. Her er hva vår modell kan se ut når vi overfører databasekunden til klassen.

 klassen bruker protected $ _db; offentlig funksjon __construct ($ db_connection) $ dette -> _ db = $ db_connection;  offentlig funksjon getUser ($ user_id) $ user = $ this -> _ db-> velg ('bruker') -> hvor ('id', $ user_id) -> limit (1) -> get (); hvis ($ user-> num_results ()> 0) return $ user-> row ();  returner falsk; 

Nå, avhengighetene til vår Bruker modell er gitt for. Vår klasse tar ikke lenger ut en viss databaseforbindelse, og heller ikke på noen globale objekter.

På dette punktet er vår klasse i utgangspunktet testbar. Vi kan passere i en datakilde av vårt valg (for det meste) og et bruker-ID, og ​​teste resultatene av samtalen. Vi kan også slå ut separate databaseforbindelser (forutsatt at begge implementerer de samme metodene for å hente data). Kul.

La oss se på hva en enhetstest kan se ut for det.

 _mockDb (); $ user = ny bruker ($ db_connection); $ result = $ user-> getUser (1); $ forventet = ny StdClass (); $ forventet-> id = 1; $ forventet-> brukernavn = 'fideloper'; $ this-> assertEquals ($ result-> id, $ expected-> id, 'Bruker-ID satt riktig'); $ this-> assertEquals ($ result-> brukernavn, $ forventet-> brukernavn, 'Brukernavn satt riktig');  beskyttet funksjon _mockDb () // "Mock" (stub) database rad resultatobjekt $ returnResult = new StdClass (); $ returnResult-> id = 1; $ returnResult-> brukernavn = 'fideloper'; // Mock database result object $ result = m :: mock ('DbResult'); $ result-> shouldReceive ('num_results') -> once () -> ogReturn (1); $ result-> shouldReceive ('row') -> once () -> ogReturn ($ returnResult); // Mock databaseforbindelsesobjekt $ db = m :: mock ('DbConnection'); $ db-> shouldReceive ('select') -> en gang () -> ogReturn ($ db); $ db-> shouldReceive ('where') -> once () -> andReturn ($ db); $ db-> shouldReceive ('limit') -> en gang () -> ogReturn ($ db); $ db-> shouldReceive ('get') -> en gang () -> ogReturn ($ resultat); returner $ db; 

Jeg har lagt til noe nytt for denne testen: Mockery. Mockery lar deg "mock" (falske) PHP-objekter. I dette tilfellet mocker vi databasetilkoblingen. Med vår mock kan vi hoppe over å teste en databaseforbindelse og bare teste modellen vår.

Ønsker å lære mer om Mockery?

I dette tilfellet mocker vi en SQL-tilkobling. Vi forteller det mocke objektet å forvente å ha å velge, hvor, grense og metoder kalt på den. Jeg returnerer Mock, i seg selv, for å speile hvordan SQL-forbindelsesobjektet returnerer seg selv ($ dette), slik at metoden kalles "chainable". Merk at, for metode, returnerer jeg databasen samtale resultatet - a stdClass objekt med brukerdata som er fylt ut.

Dette løser noen problemer:

  1. Vi tester bare modellklassen vår. Vi tester også ikke en databaseforbindelse.
  2. Vi er i stand til å kontrollere inngangene og utgangene til mock databasetilkoblingen, og kan derfor pålidelig teste mot resultatet av databasesamtalen. Jeg vet at jeg får en bruker-ID på "1" som et resultat av den stakkede databasen.
  3. Vi trenger ikke å bootstrap vår søknad eller har noen konfigurasjon eller database til stede for å teste.

Vi kan fortsatt gjøre mye bedre. Her blir det interessant.


grensesnitt

For å forbedre dette videre kunne vi definere og implementere et grensesnitt. Vurder følgende kode.

 grensesnitt UserRepositoryInterface offentlig funksjon getUser ($ user_id);  klasse MysqlUserRepository implementerer UserRepositoryInterface protected $ _db; offentlig funksjon __construct ($ db_conn) $ dette -> _ db = $ db_conn;  offentlig funksjon getUser ($ user_id) $ user = $ this -> _ db-> velg ('bruker') -> hvor ('id', $ user_id) -> limit (1) -> get (); hvis ($ user-> num_results ()> 0) return $ user-> row ();  returner falsk;  klasse Bruker protected $ userStore; offentlig funksjon __construct (UserRepositoryInterface $ bruker) $ this-> userStore = $ user;  offentlig funksjon getUser ($ user_id) return $ this-> userStore-> getUser ($ user_id); 

Det skjer noen ting her.

  1. Først definerer vi et grensesnitt for brukeren vår datakilde. Dette definerer adduser () metode.
  2. Deretter implementerer vi dette grensesnittet. I dette tilfellet oppretter vi en MySQL-implementering. Vi godtar et databaseforbindelsesobjekt, og bruker det til å hente en bruker fra databasen.
  3. Til slutt håndhever vi bruken av en klasse som implementerer Brukergrensesnitt i vår Bruker modell. Dette garanterer at datakilden alltid vil ha en getUser () metode tilgjengelig, uansett hvilken datakilde som brukes til å implementere Brukergrensesnitt.

Legg merke til at vår Bruker Objekttype-tips Brukergrensesnitt i sin konstruktør. Dette betyr at en klasse implementering Brukergrensesnitt Må sendes inn i Bruker gjenstand. Dette er en garanti vi stoler på - vi trenger getUser metode for alltid å være tilgjengelig.

Hva er resultatet av dette?

  • Vår kode er nå fullt testbar. For Bruker klasse, kan vi enkelt mocke datakilden. (Testing av implementeringen av datakilden ville være jobben med en separat enhetstest).
  • Vår kode er mye mer vedlikeholdsdyktig. Vi kan bytte ut forskjellige datakilder uten å måtte endre kode gjennom hele applikasjonen vår.
  • Vi kan lage NOEN datakilde. ArrayUser, MongoDbUser, CouchDbUser, MemoryUser, etc.
  • Vi kan enkelt sende enhver datakilde til vår Bruker objekt hvis vi trenger det. Hvis du bestemmer deg for å dike SQL, kan du bare opprette en annen implementering (for eksempel, MongoDbUser) og send det inn i din Bruker modell.

Vi har også forenklet vår enhetstest!

 _mockUserRepo (); $ user = ny bruker ($ userRepo); $ result = $ user-> getUser (1); $ forventet = ny StdClass (); $ forventet-> id = 1; $ forventet-> brukernavn = 'fideloper'; $ this-> assertEquals ($ result-> id, $ expected-> id, 'Bruker-ID satt riktig'); $ this-> assertEquals ($ result-> brukernavn, $ forventet-> brukernavn, 'Brukernavn satt riktig');  beskyttet funksjon _mockUserRepo () // Mock forventet resultat $ result = new StdClass (); $ resultat-> id = 1; $ resultat-> brukernavn = 'fideloper'; // Mock ethvert brukerregister $ userRepo = m :: mock ('Fideloper \ Third \ Repository \ UserRepositoryInterface'); $ userRepo-> shouldReceive ('getUser') -> en gang () -> ogReturn ($ resultat); returner $ userRepo; 

Vi har tatt arbeidet med å mocking en databaseforbindelse ut helt. I stedet mocker vi bare datakilden, og forteller det hva du skal gjøre når getUser er kalt.

Men vi kan fortsatt gjøre det bedre!


containere

Vurder bruk av vår nåværende kode:

 // I noen kontroller $ user = ny bruker (ny MysqlUser (App: db-> getConnection ("mysql"))); $ user-> id = App :: økt ("bruker-> id"); $ currentUser = $ user-> getUser ($ user_id);

Vårt siste skritt vil være å introdusere beholdere. I koden ovenfor må vi opprette og bruke en gjeng med objekter bare for å få vår nåværende bruker. Denne koden kan være strøget i hele applikasjonen din. Hvis du trenger å bytte fra MySQL til MongoDB, vil du fortsatt må redigeres hvert sted der koden ovenfor vises. Det er neppe tørt. Beholdere kan fikse dette.

En beholder inneholder "en" et objekt eller en funksjonalitet. Det ligner på et register i søknaden din. Vi kan bruke en beholder til å automatisere automatisk en ny Bruker objekt med alle nødvendige avhengigheter. Nedenfor bruker jeg Pimple, en populær containerklasse.

 // Et sted i en konfigurasjonsfil $ container = new Pimple (); $ container ["user"] = function () return ny bruker (ny MysqlUser (App: db-> getConnection ('mysql')));  // Nå, i alle våre kontrollører kan vi bare skrive: $ currentUser = $ container ['user'] -> getUser (App :: session ('user_id'));

Jeg har flyttet etableringen av Bruker modell til ett sted i applikasjonskonfigurasjonen. Som et resultat:

  1. Vi har holdt vår kode DRY. De Bruker objekt og datalageret er valgt på ett sted i vår søknad.
  2. Vi kan slå ut vår Bruker modell fra å bruke MySQL til en annen datakilde i EN plassering. Dette er mye mer vedlikeholdsdyktig.

Siste tanker

I løpet av denne opplæringen oppnådde vi følgende:

  1. Oppbevart koden DRY og gjenbrukbar
  2. Opprettet vedlikeholdskode - Vi kan bytte ut datakilder til våre objekter på ett sted for hele programmet hvis det er nødvendig
  3. Gjør vår kode testbar - Vi kan enkelt gjenstand gjenstander uten å stole på å starte opp vår applikasjon eller opprette en testdatabase
  4. Lært om bruk av Dependency Injection and Interfaces, for å aktivere å skape testbar og vedlikeholdbar kode
  5. Så hvordan beholdere kan hjelpe til med å gjøre søknaden mer vedlikeholdsbar

Jeg er sikker på at du har lagt merke til at vi har lagt til mye mer kode i navnet vedlikehold og testbarhet. Et sterkt argument kan gjøres mot denne gjennomføringen: vi øker kompleksiteten. Faktisk krever dette en dypere kunnskap om kode, både for hovedforfatteren og for samarbeidspartnere i et prosjekt.

Imidlertid er kostnaden for forklaring og forståelse langt utvekt av det ekstra generelle avta i teknisk gjeld.

  • Koden er mye mer vedlikeholdsbar, og gjør endringer mulig på ett sted, i stedet for flere.
  • Å være i stand til enhetstest (raskt) vil redusere feil i kode med stor margin - spesielt i langsiktige eller samfunnsdrevne (open source) prosjekter.
  • Gjør ekstraarbeidet foran vil spare tid og hodepine senere.

ressurser

Du kan inkludere hån og PHPUnit inn i søknaden din enkelt ved å bruke Komponist. Legg til disse i "Require-dev" -delen din i din composer.json fil:

 "krav-dev": "hån / hån": "0.8. *", "phpunit / phpunit": "3.7. *"

Du kan deretter installere Composer-baserte avhengigheter med dev-kravene:

 $ php composer.phar installere --dev

Lær mer om Mockery, Composer og PHPUnit her på Nettuts+.

  • Mockery: En bedre måte
  • Enkel pakkehåndtering med komponist
  • Testdrevet PHP

For PHP, bruk Laravel 4, da det gjør eksepsjonell bruk av containere og andre konsepter skrevet om her.

Takk for at du leste!