Come scrivere codice verificabile e gestibile in PHP

I framework forniscono uno strumento per lo sviluppo rapido di applicazioni, ma spesso maturano il debito tecnico con la stessa rapidità con cui consentono di creare funzionalità. Il debito tecnico viene creato quando la manutenibilità non è un obiettivo intenzionale dello sviluppatore. I cambiamenti futuri e il debug diventano costosi, a causa della mancanza di test e strutture unitarie.

Ecco come iniziare a strutturare il codice per ottenere testabilità e manutenibilità e risparmiare tempo.


Copriremo (liberamente)

  1. ASCIUTTO
  2. Iniezione di dipendenza
  3. interfacce
  4. contenitori
  5. Test delle unità con PHPUnit

Iniziamo con un codice inventato, ma tipico. Questa potrebbe essere una classe di modello in ogni dato framework.

 class User public function 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 ();  return false; 

Questo codice funzionerà, ma ha bisogno di miglioramenti:

  1. Questo non è testabile.
    • Ci affidiamo al $ _SESSION variabile globale. I framework di test unitari, come PHPUnit, si basano sulla riga di comando, dove $ _SESSION e molte altre variabili globali non sono disponibili.
    • Facciamo affidamento sulla connessione al database. Idealmente, le connessioni al database effettive dovrebbero essere evitate in un test unitario. Il test riguarda il codice, non i dati.
  2. Questo codice non è manutenibile come potrebbe essere. Ad esempio, se cambiamo l'origine dei dati, dovremo modificare il codice del database in ogni istanza di App :: db usato nella nostra applicazione. Inoltre, per quanto riguarda le istanze in cui non desideriamo solo le informazioni dell'utente corrente?

Un tentativo di unità testata

Ecco un tentativo di creare un unit test per la funzionalità di cui sopra.

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

Esaminiamo questo. Innanzitutto, il test fallirà. Il $ _SESSION variabile usata nel Utente l'oggetto non esiste in un test unitario, poiché esegue PHP nella riga di comando.

In secondo luogo, non vi è alcuna configurazione di connessione al database. Ciò significa che, al fine di rendere questo lavoro, avremo bisogno di riavviare la nostra applicazione al fine di ottenere il App oggetto e suo db oggetto. Avremo anche bisogno di una connessione al database funzionante per testare contro.

Per fare funzionare questo test unitario, dovremmo:

  1. Configura una configurazione di configurazione per una CLI (PHPUnit) eseguita nella nostra applicazione
  2. Affidati a una connessione al database. Fare questo significa affidarsi a una fonte di dati separata dal nostro test unitario. Cosa succede se il nostro database di test non ha i dati che ci aspettiamo? Cosa succede se la nostra connessione al database è lenta?
  3. Affidarsi a un'applicazione che viene sottoposta a bootstrap aumenta il sovraccarico dei test, rallentando drasticamente i test dell'unità. Idealmente, la maggior parte del nostro codice può essere testata indipendentemente dal framework utilizzato.

Quindi, passiamo al modo in cui possiamo migliorare questo.


Conserva il codice ASCIUTTO

La funzione che recupera l'utente corrente non è necessaria in questo semplice contesto. Questo è un esempio forzato, ma nello spirito dei principi DRY, la prima ottimizzazione che scelgo di fare è generalizzare questo metodo.

 class User funzione pubblica 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; 

Questo fornisce un metodo che possiamo usare in tutta la nostra intera applicazione. Possiamo passare l'utente corrente al momento della chiamata, piuttosto che passare tale funzionalità al modello. Il codice è più modulare e mantenibile quando non si basa su altre funzionalità (come la variabile globale della sessione).

Tuttavia, questo non è ancora testabile e manutenibile come potrebbe essere. Stiamo ancora facendo affidamento sulla connessione al database.


Iniezione di dipendenza

Aiutiamo a migliorare la situazione aggiungendo un po 'di Iniezione delle Dipendenze. Ecco come potrebbe apparire il nostro modello, quando passiamo la connessione al database nella classe.

 class User protected $ _db; funzione pubblica __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 ();  return false; 

Ora, le dipendenze del nostro Utente modello sono previsti. La nostra classe non assume più una determinata connessione al database, né si basa su alcun oggetto globale.

A questo punto, la nostra classe è fondamentalmente testabile. Possiamo passare in una fonte di dati di nostra scelta (principalmente) e un id utente e testare i risultati di tale chiamata. Possiamo anche cambiare connessioni di database separate (supponendo che entrambi implementino gli stessi metodi per il recupero dei dati). Freddo.

Diamo un'occhiata a cosa potrebbe sembrare un test unitario per questo.

 _mockDb (); $ user = new User ($ db_connection); $ risultato = $ utente-> getUser (1); $ expected = new StdClass (); $ expected-> id = 1; $ expected-> username = 'fideloper'; $ this-> assertEquals ($ result-> id, $ expected-> id, 'User ID impostato correttamente'); $ this-> assertEquals ($ result-> username, $ expected-> username, 'Username impostato correttamente');  protected function _mockDb () // "Mock" (stub) oggetto risultato riga del database $ returnResult = new StdClass (); $ returnResult-> id = 1; $ returnResult-> username = 'fideloper'; // Oggetto risultato database fittizio $ result = m :: mock ('DbResult'); $ result-> shouldReceive ('num_results') -> once () -> andReturn (1); $ result-> shouldReceive ('row') -> once () -> andReturn ($ returnResult); // Oggetto di connessione database fittizio $ 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; 

Ho aggiunto qualcosa di nuovo a questo test unitario: Mockery. Mockery ti permette di "falsificare" (falsi) oggetti PHP. In questo caso, stiamo prendendo in giro la connessione al database. Con la nostra simulazione, possiamo saltare il test di una connessione al database e semplicemente testare il nostro modello.

Vuoi saperne di più su Mockery?

In questo caso, stiamo prendendo in giro una connessione SQL. Stiamo dicendo all'oggetto finto di aspettarsi di avere il selezionare, dove, limite e ottenere metodi chiamati su di esso. Sto restituendo il Mock, a sua volta, per rispecchiare il modo in cui l'oggetto di connessione SQL restituisce sé stesso ($ questo), rendendo così il suo metodo chiamato "concatenabile". Si noti che, per il ottenere metodo, restituisco il risultato della chiamata del database - a stdClass oggetto con i dati utente popolati.

Questo risolve alcuni problemi:

  1. Stiamo testando solo la nostra classe di modello. Non stiamo testando anche una connessione al database.
  2. Siamo in grado di controllare gli input e gli output della connessione del database fittizio e, quindi, possiamo testare in modo affidabile il risultato della chiamata al database. So che otterrò un ID utente di "1" come risultato della chiamata al database deriso.
  3. Non è necessario eseguire il bootstrap della nostra applicazione o avere una configurazione o un database presente da testare.

Possiamo ancora fare molto meglio. Ecco dove diventa interessante.


interfacce

Per migliorare ulteriormente, potremmo definire e implementare un'interfaccia. Considera il seguente codice.

 interfaccia UserRepositoryInterface getUser funzione pubblica ($ user_id);  classe MysqlUserRepository implementa UserRepositoryInterface protected $ _db; funzione pubblica __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 ();  return false;  class User protected $ userStore; funzione pubblica __construct (UserRepositoryInterface $ user) $ this-> userStore = $ user;  public function getUser ($ user_id) return $ this-> userStore-> getUser ($ user_id); 

Ci sono alcune cose che accadono qui.

  1. Innanzitutto, definiamo un'interfaccia per il nostro utente fonte di dati. Questo definisce il Aggiungi utente() metodo.
  2. Successivamente, implementiamo questa interfaccia. In questo caso, creiamo un'implementazione MySQL. Accettiamo un oggetto di connessione al database e lo usiamo per catturare un utente dal database.
  3. Infine, applichiamo l'uso di una classe che implementa il Interfaccia utente nel nostro Utente modello. Ciò garantisce che l'origine dati abbia sempre un getUser () metodo disponibile, indipendentemente dall'origine dati utilizzata per l'implementazione Interfaccia utente.

Nota che il nostro Utente tipo di oggetto-suggerimenti Interfaccia utente nel suo costruttore. Ciò significa che una classe sta implementando Interfaccia utente DEVE essere passato nel Utente oggetto. Questa è una garanzia su cui facciamo affidamento: abbiamo bisogno del getUser metodo per essere sempre disponibile.

Qual è il risultato di questo?

  • Il nostro codice è ora completamente verificabile. Per il Utente classe, possiamo facilmente prendere in giro la fonte dei dati. (Testare le implementazioni dell'origine dati sarebbe il lavoro di un test unitario separato).
  • Il nostro codice è tanto più mantenibile. Possiamo cambiare diverse fonti di dati senza dover cambiare codice nella nostra applicazione.
  • Possiamo creare QUALUNQUE fonte di dati. ArrayUser, MongoDbUser, CouchDbUser, MemoryUser, ecc.
  • Possiamo facilmente passare qualsiasi fonte di dati al nostro Utente oggetto se abbiamo bisogno di Se decidi di abbandonare SQL, puoi semplicemente creare un'implementazione diversa (ad esempio, MongoDbUser) e passalo al tuo Utente modello.

Abbiamo semplificato anche il nostro test unitario!

 _mockUserRepo (); $ user = new User ($ userRepo); $ risultato = $ utente-> getUser (1); $ expected = new StdClass (); $ expected-> id = 1; $ expected-> username = 'fideloper'; $ this-> assertEquals ($ result-> id, $ expected-> id, 'User ID impostato correttamente'); $ this-> assertEquals ($ result-> username, $ expected-> username, 'Username impostato correttamente');  funzione protetta _mockUserRepo () // Mock risultato atteso $ result = new StdClass (); $ result-> id = 1; $ result-> username = 'fideloper'; // Mock qualsiasi repository utente $ userRepo = m :: mock ('Fideloper \ Third \ Repository \ UserRepositoryInterface'); $ userRepo-> shouldReceive ('getUser') -> once () -> andReturn ($ result); return $ userRepo; 

Abbiamo preso il lavoro di deridere una connessione al database completamente. Invece, semplicemente prendiamo in giro la fonte dei dati e diciamo cosa fare quando getUser è chiamato.

Ma possiamo ancora fare meglio!


contenitori

Considera l'uso del nostro codice attuale:

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

Il nostro passo finale sarà presentare contenitori. Nel codice precedente, abbiamo bisogno di creare e utilizzare un gruppo di oggetti solo per ottenere il nostro utente corrente. Questo codice potrebbe essere disseminato nella tua applicazione. Se hai bisogno di passare da MySQL a MongoDB, lo farai ancora è necessario modificare ogni posizione in cui viene visualizzato il codice sopra. Quello è appena ASCIUTTO. I contenitori possono risolvere questo problema.

Un contenitore semplicemente "contiene" un oggetto o funzionalità. È simile a un registro nella tua applicazione. Possiamo usare un contenitore per istanziare automaticamente un nuovo Utente oggetto con tutte le dipendenze necessarie. Sotto, io uso Pimple, una classe contenitore popolare.

 // Da qualche parte in un file di configurazione $ container = new Pimple (); $ container ["user"] = function () return new User (new MysqlUser (App: db-> getConnection ('mysql')));  // Ora, in tutti i nostri controller, possiamo semplicemente scrivere: $ currentUser = $ container ['user'] -> getUser (App :: session ('user_id'));

Ho spostato la creazione di Utente modello in un'unica posizione nella configurazione dell'applicazione. Di conseguenza:

  1. Abbiamo mantenuto il nostro codice ASCIUTTO. Il Utente oggetto e l'archivio dati di scelta sono definiti in un'unica posizione nella nostra applicazione.
  2. Possiamo cambiare la nostra Utente modello dall'uso di MySQL a qualsiasi altra origine dati in UNO Posizione. Questo è molto più gestibile.

Pensieri finali

Nel corso di questo tutorial, abbiamo realizzato quanto segue:

  1. Mantenuto il nostro codice ASCIUTTO e riutilizzabile
  2. Codice manutenibile creato: possiamo modificare le origini dati per i nostri oggetti in un'unica posizione per l'intera applicazione, se necessario
  3. Abbiamo reso testabile il nostro codice: possiamo simulare facilmente gli oggetti senza fare affidamento sull'avvio automatico della nostra applicazione o sulla creazione di un database di test
  4. Informazioni sull'utilizzo di Injection e interfacce di dipendenza, al fine di consentire la creazione di codice verificabile e gestibile
  5. Ho visto come i contenitori possono aiutare a rendere la nostra applicazione più manutenibile

Sono sicuro che hai notato che abbiamo aggiunto molto più codice nel nome della manutenibilità e testabilità. Un argomento forte può essere fatto contro questa implementazione: stiamo aumentando la complessità. In effetti, ciò richiede una conoscenza più approfondita del codice, sia per l'autore principale che per i collaboratori di un progetto.

Tuttavia, il costo della spiegazione e della comprensione è di gran lunga superfluo nel complesso diminuire in debito tecnico.

  • Il codice è notevolmente più gestibile, rendendo possibili modifiche in una posizione, piuttosto che in diverse.
  • Essere in grado di eseguire un test unitario (rapidamente) ridurrà i bug nel codice di un ampio margine, specialmente nei progetti a lungo termine o guidati dalla comunità (open-source).
  • Fare il lavoro extra in anticipo volontà risparmiare tempo e mal di testa più tardi.

risorse

Puoi includere Presa in giro e PHPUnit nella tua applicazione facilmente usando Composer. Aggiungi questi alla tua sezione "require-dev" nella tua composer.json file:

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

È quindi possibile installare le dipendenze basate su Composer con i requisiti "dev":

 $ php composer.phar installa --dev

Ulteriori informazioni su Mockery, Composer e PHPUnit qui su Nettuts+.

  • Scherno: un modo migliore
  • Facile gestione dei pacchetti con Composer
  • PHP basato su test

Per PHP, considera l'utilizzo di Laravel 4, in quanto fa un uso eccezionale di contenitori e altri concetti scritti qui.

Grazie per aver letto!