Hoe testbare en onderhoudbare code te schrijven in PHP

Kaders bieden een hulpmiddel voor een snelle ontwikkeling van toepassingen, maar bouwen vaak technische schulden op zo snel als ze u toestaan ​​om functionaliteit te creëren. Technische schuld wordt gecreëerd wanneer onderhoudbaarheid geen doelgericht aandachtspunt van de ontwikkelaar is. Toekomstige wijzigingen en debugging worden kostbaar door een gebrek aan unit-testing en structuur.

Hier leest u hoe u begint met het structureren van uw code om testbaarheid en onderhoudbaarheid te bereiken - en u tijd kunt besparen.


We dekken (losjes)

  1. DROOG
  2. Dependency Injection
  3. interfaces
  4. containers
  5. Eenheidstests met PHPUnit

Laten we beginnen met een beetje gekunstelde, maar typische code. Dit kan een modelklasse zijn in een bepaald kader.

 class Gebruiker public function getCurrentUser () $ user_id = $ _SESSION ['user_id']; $ user = App :: db-> select ('id, gebruikersnaam') -> where ('id', $ user_id) -> limit (1) -> get (); if ($ user-> num_results ()> 0) return $ user-> row ();  return false; 

Deze code werkt, maar moet worden verbeterd:

  1. Dit is niet testbaar.
    • We vertrouwen op de $ _SESSION globale variabele. Unit-testing frameworks, zoals PHPUnit, vertrouwen op de opdrachtregel, waar $ _SESSION en veel andere globale variabelen zijn niet beschikbaar.
    • We vertrouwen op de databaseverbinding. Idealiter moeten feitelijke databaseverbindingen worden vermeden in een unit-test. Testen gaat over code, niet over gegevens.
  2. Deze code is niet zo onderhoudbaar als het zou kunnen zijn. Als we bijvoorbeeld de gegevensbron wijzigen, moeten we de databasecode in elke instantie van wijzigen App :: db gebruikt in onze applicatie. En hoe zit het met instanties waar we niet alleen de informatie van de huidige gebruiker willen hebben?

Een beproefde eenheidstest

Hier is een poging om een ​​eenheidscontrole voor de bovenstaande functionaliteit te maken.

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

Laten we dit onderzoeken. Ten eerste zal de test mislukken. De $ _SESSION variabele gebruikt in de Gebruiker object bestaat niet in een eenheidscontrole, omdat het PHP uitvoert in de opdrachtregel.

Ten tweede is er geen verbinding met de database. Dit betekent dat, om dit te laten werken, we onze applicatie moeten booten om de App object en zijn db voorwerp. We hebben ook een werkende databaseverbinding nodig om te testen.

Om deze unit-test te laten werken, moeten we:

  1. Configureer een configuratie-instelling voor een CLI (PHPUnit) -run in onze applicatie
  2. Vertrouw op een databaseverbinding. Dit betekent dat u vertrouwt op een gegevensbron die losstaat van onze unit-test. Wat als onze testdatabase niet beschikt over de gegevens die we verwachten? Wat als onze databaseverbinding langzaam is?
  3. Door erop te vertrouwen dat een toepassing die wordt opgestart, de overhead van de tests verhoogt, wordt het testen van de eenheid drastisch vertraagd. Idealiter kan het grootste deel van onze code worden getest onafhankelijk van het gebruikte framework.

Laten we eens kijken hoe we dit kunnen verbeteren.


Houd Code DRY

De functie die de huidige gebruiker ophaalt, is in deze eenvoudige context niet nodig. Dit is een gekunsteld voorbeeld, maar in de geest van DROGE principes, is de eerste optimalisatie die ik kies te maken deze methode te generaliseren.

 class Gebruiker public function getUser ($ user_id) $ user = App :: db-> select ('user') -> where ('id', $ user_id) -> limit (1) -> get (); if ($ user-> num_results ()> 0) return $ user-> row ();  return false; 

Dit biedt een methode die we kunnen gebruiken in onze hele applicatie. We kunnen de huidige gebruiker op het moment van de oproep doorgeven, in plaats van die functionaliteit naar het model over te dragen. Code is modulair en onderhoudbaar als het niet afhankelijk is van andere functionaliteiten (zoals de globale variabele van de sessie).

Dit is echter nog steeds niet testbaar en onderhoudbaar als het zou kunnen zijn. We vertrouwen nog steeds op de databaseverbinding.


Dependency Injection

Laten we de situatie verbeteren door wat afhankelijkheidsinjectie toe te voegen. Dit is hoe ons model eruit zou kunnen zien als we de connectie van de database doorgeven aan de klas.

 class User protected $ _db; publieke functie __construct ($ db_connection) $ this -> _ db = $ db_connection;  openbare functie getUser ($ user_id) $ user = $ this -> _ db-> select ('user') -> where ('id', $ user_id) -> limit (1) -> get (); if ($ user-> num_results ()> 0) return $ user-> row ();  return false; 

Nu, de afhankelijkheden van onze Gebruiker model zijn voorzien. Onze klasse gaat niet langer uit van een bepaalde databaseverbinding en vertrouwt ook niet op globale objecten.

Op dit punt is onze klasse in principe te testen. We kunnen een gegevensbron van onze keuze (meestal) en een gebruikers-id doorgeven en de resultaten van die oproep testen. We kunnen ook afzonderlijke databaseverbindingen uitschakelen (ervan uitgaande dat beide dezelfde methoden implementeren voor het ophalen van gegevens). Stoer.

Laten we eens kijken hoe een unit test eruit kan zien.

 _mockDb (); $ user = new User ($ db_connection); $ result = $ user-> getUser (1); $ expected = new StdClass (); $ expected-> id = 1; $ expected-> gebruikersnaam = 'fideloper'; $ this-> assertEquals ($ result-> id, $ expected-> id, 'User-ID correct ingesteld'); $ this-> assertEquals ($ resultaat-> gebruikersnaam, $ verwachte-> gebruikersnaam, 'Gebruikersnaam correct ingesteld');  beschermde functie _mockDb () // "Mock" (stub) database rij resultaat object $ returnResult = new StdClass (); $ returnResult-> id = 1; $ returnResult-> gebruikersnaam = 'fideloper'; // Nep database-resultaatobject $ result = m :: mock ('DbResult'); $ result-> shouldReceive ('num_results') -> once () -> andReturn (1); $ result-> shouldReceive ('row') -> once () -> andReturn ($ returnResult); // Mock database-verbindingsobject $ db = m :: mock ('DbConnection'); $ db-> shouldReceive ('select') -> once () -> andReturn ($ db); $ db-> shouldReceive ('where') -> once () -> andReturn ($ db); $ db-> shouldReceive ('limit') -> once () -> andReturn ($ db); $ db-> shouldreceive ('get') -> once () -> andReturn ($ result); return $ db; 

Ik heb iets nieuws toegevoegd aan deze unit test: Mockery. Met spot kun je "nep" (nep) PHP-objecten maken. In dit geval spotten we de databaseverbinding. Met onze mock kunnen we het testen van een databaseverbinding overslaan en eenvoudig ons model testen.

Wilt u meer weten over Mockery?

In dit geval bespotten we een SQL-verbinding. We vertellen het schijnobject dat het verwacht te hebben kiezen, waar, begrenzing en krijgen methoden die erop worden toegepast. Ik stuur de Mock zelf terug om te spiegelen hoe het SQL-verbindingsobject zichzelf teruggeeft ($ this), waardoor de methode 'chainable' wordt genoemd. Merk op dat, voor de krijgen methode, ik stuur het resultaat van de databaseaanroep terug - a stdClass object met de gebruikersgegevens ingevuld.

Dit lost een paar problemen op:

  1. We testen alleen onze modelklasse. We testen ook geen databaseverbinding.
  2. We kunnen de ingangen en uitgangen van de nep-databaseverbinding besturen en kunnen daarom betrouwbaar testen op het resultaat van de databaseaanroep. Ik weet dat ik een gebruikers-ID van "1" krijg als gevolg van de bespotte databaseaanroep.
  3. We hoeven onze applicatie niet op te starten of een configuratie of database aanwezig te hebben om te testen.

We kunnen nog steeds veel beter doen. Hier wordt het interessant.


interfaces

Om dit verder te verbeteren, kunnen we een interface definiëren en implementeren. Beschouw de volgende code.

 interface UserRepositoryInterface openbare functie getUser ($ user_id);  class MysqlUserRepository implementeert UserRepositoryInterface protected $ _db; publieke functie __construct ($ db_conn) $ this -> _ db = $ db_conn;  openbare functie getUser ($ user_id) $ user = $ this -> _ db-> select ('user') -> where ('id', $ user_id) -> limit (1) -> get (); if ($ user-> num_results ()> 0) return $ user-> row ();  return false;  class User protected $ userStore; publieke functie __construct (UserRepositoryInterface $ user) $ this-> userStore = $ user;  openbare functie getUser ($ user_id) return $ this-> userStore-> getUser ($ user_id); 

Er gebeuren hier een paar dingen.

  1. Eerst definiëren we een interface voor onze gebruiker databron. Dit definieert de Voeg gebruiker toe() methode.
  2. Vervolgens implementeren we die interface. In dit geval maken we een MySQL-implementatie aan. We accepteren een databaseverbindingsobject en gebruiken dit om een ​​gebruiker uit de database te halen.
  3. Ten slotte handhaven we het gebruik van een klasse die het Gebruikersomgeving in onze Gebruiker model. Dit garandeert dat de gegevensbron altijd een getUser () methode beschikbaar, ongeacht welke gegevensbron wordt gebruikt om te implementeren Gebruikersomgeving.

Merk op dat onze Gebruiker objecttype-hints Gebruikersomgeving in zijn constructor. Dit betekent dat een klasse wordt geïmplementeerd Gebruikersomgeving MOET worden doorgegeven aan de Gebruiker voorwerp. Dit is een garantie waarop we vertrouwen - we hebben de getUser methode om altijd beschikbaar te zijn.

Wat is het resultaat hiervan?

  • Onze code is nu geheel toetsbaar. Voor de Gebruiker klasse, kunnen we gemakkelijk de gegevensbron bespotten. (Het testen van de implementaties van de gegevensbron zou de taak zijn van een afzonderlijke eenheidscontrole).
  • Onze code is veel meer onderhoudbaar. We kunnen verschillende gegevensbronnen uitschakelen zonder de hele code te hoeven wijzigen in onze applicatie.
  • We kunnen creëren IEDER databron. ArrayUser, MongoDbUser, CouchDbUser, MemoryUser, enz.
  • We kunnen eenvoudig elke gegevensbron doorgeven aan ons Gebruiker object als dat nodig is. Als u besluit SQL te dumpen, kunt u gewoon een andere implementatie maken (bijvoorbeeld, MongoDbUser) en geef dat door aan jouw Gebruiker model-.

We hebben onze unit-test ook vereenvoudigd!

 _mockUserRepo (); $ user = new User ($ userRepo); $ result = $ user-> getUser (1); $ expected = new StdClass (); $ expected-> id = 1; $ expected-> gebruikersnaam = 'fideloper'; $ this-> assertEquals ($ result-> id, $ expected-> id, 'User-ID correct ingesteld'); $ this-> assertEquals ($ resultaat-> gebruikersnaam, $ verwachte-> gebruikersnaam, 'Gebruikersnaam correct ingesteld');  beschermde functie _mockUserRepo () // Bespotten verwacht resultaat $ resultaat = nieuwe StdClass (); $ resultaat-> id = 1; $ result-> gebruikersnaam = 'fideloper'; // Bespotten van elke gebruikersrepository $ userRepo = m :: mock ('Fideloper \ Third \ Repository \ UserRepositoryInterface'); $ userRepo-> shouldReceive ('getUser') -> once () -> andReturn ($ result); return $ userRepo; 

We hebben het werk van het bespotten van een databaseverbinding volledig overgenomen. In plaats daarvan bespotten we eenvoudigweg de gegevensbron en vertellen we wat we moeten doen wanneer getUser wordt genoemd.

Maar we kunnen nog steeds beter doen!


containers

Overweeg het gebruik van onze huidige code:

 // In sommige controller $ user = new User (nieuwe MysqlUser (App: db-> getConnection ("mysql"))); $ user-> id = App :: session ("user-> id"); $ currentUser = $ user-> getUser ($ user_id);

Onze laatste stap zal zijn om te introduceren containers. In de bovenstaande code moeten we een aantal objecten maken en gebruiken om onze huidige gebruiker te krijgen. Deze code kan in uw toepassing liggen. Als je moet overschakelen van MySQL naar MongoDB, dan doe je dat nog steeds moet elke plaats bewerken waar de bovenstaande code verschijnt. Dat is nauwelijks DROOG. Containers kunnen dit verhelpen.

Een container "bevat" eenvoudig een object of functionaliteit. Het lijkt op een register in uw toepassing. We kunnen een container gebruiken om automatisch een nieuwe te instantiëren Gebruiker object met alle benodigde afhankelijkheden. Hieronder gebruik ik Pimple, een populaire containerklasse.

 // Ergens in een configuratiebestand $ container = nieuwe Pimple (); $ container ["user"] = function () nieuwe gebruiker teruggeven (nieuwe MysqlUser (App: db-> getConnection ('mysql')));  // Nu kunnen we in al onze controllers gewoon schrijven: $ currentUser = $ container ['user'] -> getUser (App :: session ('user_id'));

Ik heb de oprichting van de Gebruiker model op één locatie in de toepassingsconfiguratie. Als gevolg:

  1. We hebben onze code DROOG gehouden. De Gebruiker object en de data store naar keuze wordt op één locatie in onze applicatie gedefinieerd.
  2. We kunnen onze Gebruiker model van het gebruik van MySQL naar een andere gegevensbron in EEN plaats. Dit is veel beter houdbaar.

Laatste gedachten

In de loop van deze zelfstudie hebben we het volgende bereikt:

  1. Hield onze code droog en herbruikbaar
  2. Gecreëerde onderhoudbare code: we kunnen indien nodig de gegevensbronnen voor onze objecten op één locatie voor de hele applicatie uitschakelen
  3. Onze code testbaar gemaakt - We kunnen objecten eenvoudig bespotten zonder te vertrouwen op het bootstrappen van onze applicatie of het maken van een testdatabase
  4. Geleerd over het gebruik van Dependency Injection and Interfaces om testbare en onderhoudbare code te kunnen maken
  5. Zag hoe containers kunnen helpen onze toepassing beter te onderhouden

Ik weet zeker dat je hebt gemerkt dat we veel meer code hebben toegevoegd in de naam van onderhoudbaarheid en testbaarheid. Tegen deze implementatie kan een krachtig argument worden ingebracht: we worden steeds complexer. Inderdaad, dit vereist een diepere kennis van code, zowel voor de hoofdauteur als voor medewerkers van een project.

De kosten van uitleg en begrip zijn echter veel groter dan de extra kosten verminderen in technische schulden.

  • De code is veel beter houdbaar, waardoor wijzigingen mogelijk zijn op één locatie in plaats van meerdere.
  • Het kunnen testen (snel) kan bugs in code met een grote marge verminderen - vooral in langlopende of community-gestuurde (open-source) projecten.
  • Het extra werk vooraan doen zullen bespaar later tijd en hoofdpijn.

Middelen

U mag opnemen Spot en PHPUnit in uw applicatie eenvoudig met behulp van Composer. Voeg deze toe aan uw "require-dev" -sectie in uw composer.json het dossier:

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

U kunt vervolgens uw Composer-afhankelijke afhankelijkheden installeren met de "dev" -vereisten:

 $ php composer.phar install --dev

Lees hier meer over Mockery, Composer en PHPUnit op Nettuts+.

  • Spotgoed: een betere manier
  • Eenvoudig pakketbeheer met Composer
  • Testgedreven PHP

Overweeg om voor PHP Laravel 4 te gebruiken, omdat het uitzonderlijk gebruik maakt van containers en andere concepten die hier zijn beschreven.

Bedankt voor het lezen!