Laravel, BDD e You The First Feature

Nella seconda parte di questa serie, chiamata Laravel, BDD e You, inizieremo a descrivere e creare la nostra prima funzione utilizzando Behat e PhpSpec. Nell'ultimo articolo abbiamo creato tutto e visto con quanta facilità possiamo interagire con Laravel nei nostri scenari Behat.

Recentemente il creatore di Behat, Konstantin Kudryashov (a.k.a. everzet), ha scritto un articolo davvero eccezionale chiamato Introducing Modeling by Example. Il flusso di lavoro che useremo, quando costruiamo il nostro servizio, è fortemente ispirato a quello presentato da everzet. 

In breve, useremo lo stesso .caratteristica progettare sia il nostro dominio principale che la nostra interfaccia utente. Ho spesso avuto la sensazione di avere un sacco di duplicazioni nelle mie funzionalità nelle suite di accettazione / funzionali e di integrazione. Quando ho letto il suggerimento di everzet sull'utilizzo della stessa funzionalità per più contesti, tutto ha fatto clic per me e credo che sia la strada da percorrere. 

Nel nostro caso avremo il nostro contesto funzionale, che per ora servirà anche come livello di accettazione e il nostro contesto di integrazione, che coprirà il nostro dominio. Inizieremo costruendo il dominio e successivamente aggiungeremo l'interfaccia utente e le cose specifiche del framework.

Piccoli refettori

Per utilizzare l'approccio "funzionalità condivisa, più contesti", dobbiamo fare alcuni rifacimenti della nostra configurazione esistente.

Per prima cosa cancelleremo la funzionalità di benvenuto che abbiamo fatto nella prima parte, poiché non ne abbiamo davvero bisogno e non segue lo stile generico di cui abbiamo bisogno per utilizzare più contesti.

$ git rm features / functional / welcome.feature

Secondo, avremo le nostre funzionalità nella radice di Caratteristiche cartella, in modo che possiamo andare avanti e rimuovere il sentiero attributo dal nostro behat.yml file. Stiamo anche per rinominare il LaravelFeatureContext a FunctionalFeatureContext (ricordati di cambiare anche il nome della classe):

default: suite: funzionale: contesti: [FunctionalFeatureContext]

Infine, solo per ripulire un po 'le cose, penso che dovremmo spostare tutte le cose relative a Laravel nel suo tratto:

# features / bootstrap / LaravelTrait.php app) $ this-> refreshApplication ();  / ** * Crea l'applicazione. * * @return \ Symfony \ Component \ HttpKernel \ HttpKernelInterface * / public function createApplication () $ unitTesting = true; $ testEnvironment = 'testing'; return richiede __DIR __. '/ ... / ... /bootstrap/start.php';  

Nel FunctionalFeatureContext possiamo quindi utilizzare il tratto ed eliminare le cose che abbiamo appena spostato:

/ ** * Behat classe di contesto. * / class FunctionalFeatureContext implementa SnippetAcceptingContext usa LaravelTrait; / ** * Inizializza il contesto. * * Ogni scenario ottiene il proprio oggetto di contesto. * È anche possibile passare argomenti arbitrari al costruttore di contesto tramite behat.yml. * / public function __construct () 

I tratti sono un ottimo modo per ripulire i tuoi contesti.

Condivisione di una caratteristica

Come presentato nella prima parte, costruiremo una piccola applicazione per il monitoraggio del tempo. La prima funzione riguarderà il tempo di tracciamento e la generazione di un foglio temporale dalle voci tracciate. Ecco la caratteristica:

Funzionalità: tempo di tracciamento Al fine di tracciare il tempo speso per le attività Come dipendente ho bisogno di gestire un foglio di tempo con voci di tempo Scenario: Generazione del foglio di tempo Dato che ho le seguenti voci di tempo | compito | durata | | codifica | 90 | | codifica | 30 | | documentare | 150 | Quando creo il time sheet Quindi il mio tempo totale dedicato alla codifica dovrebbe essere di 120 minuti E il mio tempo totale dedicato alla documentazione dovrebbe essere di 150 minuti E il mio tempo totale dedicato alle riunioni dovrebbe essere di 0 minuti E il mio tempo totale dovrebbe essere di 270 minuti 

Ricorda che questo è solo un esempio. Trovo più facile definire le caratteristiche nella vita reale, dal momento che hai un problema reale che devi risolvere e spesso hai la possibilità di discutere la funzionalità con colleghi, clienti o altre parti interessate.

Ok, facciamo in modo che Behat generi i passaggi dello scenario per noi:

$ vendor / bin / behat --dry-run --append-snippets

Abbiamo bisogno di modificare i passaggi generati solo un po '. Abbiamo solo bisogno di quattro passaggi per coprire lo scenario. Il risultato finale dovrebbe assomigliare a questo:

/ ** * @Given Ho le seguenti voci temporali * / public function iHaveTheFollowingTimeEntries (TableNode $ table) throw new PendingException ();  / ** * @Quando generi il time sheet * / public function iGenerateTheTimeSheet () gira new PendingException ();  / ** * @Quindi il mio tempo totale speso per: compito dovrebbe essere: expectedDuration minutes * / public function myTotalTimeSpentOnTaskShouldBeMinutes ($ task, $ expectedDuration) throw new PendingException ();  / ** * @Quindi il mio tempo totale speso dovrebbe essere: expectedDuration minutes * / public function myTotalTimeSpentShouldBeMinutes ($ expectedDuration) throw new PendingException ();  

Il nostro contesto funzionale è tutto pronto ora, ma abbiamo anche bisogno di un contesto per la nostra suite di integrazione. Innanzitutto, aggiungeremo la suite al behat.yml file:

default: suite: funzionale: contesti: [FunctionalFeatureContext] integrazione: contesti: [IntegrationFeatureContext] 

Successivamente, possiamo semplicemente copiare il valore predefinito FeatureContext:

$ cp features / bootstrap / FeatureContext.php caratteristiche / bootstrap / IntegrationFeatureContext.php 

Ricordarsi di cambiare il nome della classe in IntegrationFeatureContext e anche per copiare la dichiarazione di utilizzo per il PendingException.

Infine, dal momento che condividiamo la funzione, possiamo semplicemente copiare le definizioni in quattro passaggi dal contesto funzionale. Se esegui Behat, vedrai che la funzione viene eseguita due volte: una volta per ogni contesto.

Progettare il dominio

A questo punto, siamo pronti per iniziare a compilare le fasi in sospeso nel nostro contesto di integrazione al fine di progettare il dominio principale della nostra applicazione. Il primo passo è Dato che ho le seguenti voci di tempo, seguito da una tabella con record di immissione del tempo. Semplificando, passiamo semplicemente alle righe della tabella, proviamo ad istanziare una voce temporale per ognuna di esse e aggiungiamole a una matrice di voci nel contesto:

usa TimeTracker \ TimeEntry; ... / ** * @Given Ho le seguenti voci temporali * / public function iHaveTheFollowingTimeEntries (TableNode $ table) $ this-> entries = []; $ rows = $ table-> getHash (); foreach ($ rows as $ row) $ entry = new TimeEntry; $ entry-> task = $ row ['task']; $ entry-> duration = $ row ['duration']; $ this-> entries [] = $ entry;  

Eseguire Behat causerà un errore fatale, dal momento che TimeTracker \ TimeEntry la classe non esiste ancora. È qui che PhpSpec entra in scena. Alla fine, TimeEntry sarà una classe Eloquent, anche se non ci preoccupiamo ancora. PhpSpec e ORM come Eloquent non suonano bene insieme, ma possiamo ancora utilizzare PhpSpec per generare la classe e persino specificare alcuni comportamenti di base. Usiamo i generatori PhpSpec per generare il TimeEntry classe:

$ vendor / bin / phpspec desc "TimeTracker \ TimeEntry" $ vendor / bin / phpspec run Vuoi che crei "TimeTracker \ TimeEntry" per te? y 

Dopo che la classe è stata generata, è necessario aggiornare la sezione di caricamento automatico del nostro composer.json file:

"autoload": "classmap": ["app / comandi", "app / controller", "app / modelli", "app / database / migrazioni", "app / database / semi"], "psr-4" : "TimeTracker \\": "src / TimeTracker", 

E ovviamente corri compositore dump-autoload.

L'esecuzione di PhpSpec ci dà il verde. Correre Behat ci dà anche verde. Che grande inizio!

Lasciamo che Behat ci guidi, che ne dici di passare al prossimo passo, Quando genero il foglio del tempo, subito?

La parola chiave qui è "generare", che sembra un termine dal nostro dominio. Nel mondo di un programmatore, tradurre "generare la scheda attività" in codice potrebbe significare solo istanziare a TimeSheet classe con un sacco di voci di tempo. È importante provare e attenersi alla lingua del dominio quando progettiamo il nostro codice. In questo modo, il nostro codice aiuterà a descrivere il comportamento previsto della nostra applicazione. 

Identifico il termine creare importante per il dominio, motivo per cui penso che dovremmo avere un metodo di generazione statica su a TimeSheet classe che funge da alias per il costruttore. Questo metodo dovrebbe prendere una raccolta di voci di tempo e memorizzarle sul foglio di tempo. 

Invece di usare solo un array, penso che abbia senso usare il Illuminare \ Support \ Collection classe che viene fornito con Laravel. Da TimeEntry sarà un modello Eloquent, quando interrogeremo il database per le voci temporali, otterremo comunque una di queste raccolte Laravel. Che ne dici di qualcosa del genere:

usa Illuminate \ Support \ Collection; utilizzare TimeTracker \ TimeSheet; usa TimeTracker \ TimeEntry; ... / ** * @Quando generi il time sheet * / public function iGenerateTheTimeSheet () $ this-> sheet = TimeSheet :: generate (Collection :: make ($ this-> entries));  

A proposito, TimeSheet non sarà una classe Eloquent. Almeno per ora, abbiamo solo bisogno di mantenere le voci di tempo persistenti, e quindi i fogli di tempo saranno solo generato dalle voci.

Correre Behat, ancora una volta, causerà un errore fatale, perché TimeSheet non esiste. PhpSpec può aiutarci a risolvere questo:

$ vendor / bin / phpspec desc "TimeTracker \ TimeSheet" $ vendor / bin / phpspec run Vuoi che crei per te "TimeTracker \ TimeSheet"? y $ vendor / bin / phpspec esegui $ vendor / bin / behat PHP Errore fatale: chiamata al metodo non definito TimeTracker \ TimeSheet :: generate () 

Abbiamo ancora un errore fatale dopo aver creato la classe, perché la statica creare() il metodo non esiste ancora. Dato che questo è un metodo statico molto semplice, non penso ci sia bisogno di una specifica. Non è altro che un wrapper per il costruttore:

voci = $ voci;  public static function generate (Collection $ entries) return new static ($ entries);  

Ciò ridurrà Behat al verde, ma ora PhpSpec ci squittisce dicendo: L'argomento 1 passato a TimeTracker \ TimeSheet :: __ construct () deve essere un'istanza di Illuminate \ Support \ Collection, nessuna data. Possiamo risolvere questo scrivendo un semplice permettere() funzione che verrà chiamata prima di ogni specifica:

put (new TimeEntry); $ This-> beConstructedWith ($ voci);  function it_is_initializable () $ this-> shouldHaveType ('TimeTracker \ TimeSheet');  

Questo ci porterà di nuovo al verde su tutta la linea. La funzione fa in modo che il time sheet sia sempre costruito con una simulazione della classe Collection.

Ora possiamo tranquillamente passare al Quindi il mio tempo totale trascorso su ...  passo. Abbiamo bisogno di un metodo che prende un nome di attività e restituisce la durata cumulata di tutte le voci con questo nome di attività. Direttamente tradotto dal cetriolino al codice, questo potrebbe essere qualcosa di simile totalTimeSpentOn ($ task):

/ ** * @Quindi il mio tempo totale speso per: compito dovrebbe essere: expectedDuration minutes * / public function myTotalTimeSpentOnTaskShouldBeMinutes ($ task, $ expectedDuration) $ actualDuration = $ this-> sheet-> totalTimeSpentOn ($ task); PHPUnit :: assertEquals ($ expectedDuration, $ actualDuration);  

Il metodo non esiste, quindi eseguendo Behat ci darà Chiamata al metodo non definito TimeTracker \ TimeSheet :: totalTimeSpentOn ().

Per specificare il metodo, scriveremo una specifica che sembra in qualche modo simile a quella che abbiamo già nel nostro scenario:

function it_should_calculate_total_time_spent_on_task () $ entry1 = new TimeEntry; $ entry1-> task = 'sleeping'; $ entry1-> duration = 120; $ entry2 = new TimeEntry; $ entry2-> task = 'eating'; $ entry2-> duration = 60; $ entry3 = new TimeEntry; $ entry3-> task = 'sleeping'; $ entry3-> duration = 120; $ collection = Collection :: make ([$ entry1, $ entry2, $ entry3]); $ This-> beConstructedWith ($ raccolta); $ This-> totalTimeSpentOn ( 'sonno') -> shouldbe (240); $ This-> totalTimeSpentOn ( 'mangiare') -> shouldbe (60);  

Si noti che non usiamo i mock per il TimeEntry e Collezione le istanze. Questa è la nostra suite di integrazione e non credo che sia necessario deriderla. Gli oggetti sono abbastanza semplici e vogliamo assicurarci che gli oggetti nel nostro dominio interagiscano come ci aspettiamo. Probabilmente ci sono molte opinioni su questo, ma questo ha senso per me.

Andando avanti:

$ vendor / bin / phpspec run Vuoi che crei 'TimeTracker \ TimeSheet :: totalTimeSpentOn ()' per te? y $ vendor / bin / phpspec eseguito 25 ✘ dovrebbe calcolare il tempo totale speso per l'attività prevista [intero: 240], ma ottenuto nullo. 

Per filtrare le voci, possiamo usare il filtro() metodo sul Collezione classe. Una soluzione semplice che ci porta al verde:

funzione pubblica totalTimeSpentOn ($ task) $ entries = $ this-> entries-> filter (funzione ($ entry) use ($ task) return $ entry-> task === $ task;); $ duration = 0; foreach ($ entries come $ entry) $ duration + = $ entry-> duration;  return $ duration;  

Le nostre specifiche sono ecologiche, ma ritengo che potremmo beneficiare di alcuni refactoring qui. Il metodo sembra fare due cose diverse: filtrare le voci e accumulare la durata. Cerchiamo di estrarre il secondo al suo metodo:

funzione pubblica totalTimeSpentOn ($ task) $ entries = $ this-> entries-> filter (funzione ($ entry) use ($ task) return $ entry-> task === $ task;); restituire $ this-> sumDuration ($ entries);  funzione protetta sumDuration ($ entries) $ duration = 0; foreach ($ entries come $ entry) $ duration + = $ entry-> duration;  return $ duration;  

PhpSpec è ancora verde e ora abbiamo tre passaggi verdi in Behat. L'ultimo passaggio dovrebbe essere facile da implementare, poiché è in qualche modo simile a quello che abbiamo appena fatto.

/ ** * @Quindi il mio tempo totale speso dovrebbe essere: expectedDuration minutes * / public function myTotalTimeSpentShouldBeMinutes ($ expectedDuration) $ actualDuration = $ this-> sheet-> totalTimeSpent (); PHPUnit :: assertEquals ($ expectedDuration, $ actualDuration);  

Correre Behat ci darà Chiamata al metodo non definito TimeTracker \ TimeSheet :: totalTimeSpent (). Invece di fare un esempio separato nelle nostre specifiche per questo metodo, perché non lo aggiungiamo a quello che già abbiamo? Potrebbe non essere completamente in linea con ciò che è "giusto" da fare, ma cerchiamo di essere un po 'pragmatici:

... $ this-> beConstructedWith ($ collection); $ This-> totalTimeSpentOn ( 'sonno') -> shouldbe (240); $ This-> totalTimeSpentOn ( 'mangiare') -> shouldbe (60); $ This-> totalTimeSpent () -> shouldbe (300); 

Lascia che PhpSpec generi il metodo:

$ vendor / bin / phpspec run Vuoi che crei 'TimeTracker \ TimeSheet :: totalTimeSpent ()' per te? y $ vendor / bin / phpspec eseguito 25 ✘ dovrebbe calcolare il tempo totale speso per l'attività prevista [numero intero: 300], ma ottenuto nullo. 

Arrivare al verde è facile ora che abbiamo il sumDuration () metodo:

funzione pubblica totalTimeSpent () return $ this-> sumDuration ($ this-> entries);  

E ora abbiamo una funzione verde. Il nostro dominio si sta lentamente evolvendo!

Progettare l'interfaccia utente

Ora stiamo passando alla nostra suite funzionale. Progetteremo l'interfaccia utente e gestiremo tutte le cose specifiche di Laravel che non riguardano il nostro dominio.

Mentre lavoriamo nella suite funzionale, possiamo aggiungere il -S flag per indicare a Behat di eseguire solo le nostre funzioni attraverso il FunctionalFeatureContext:

$ vendor / bin / behat -s funzionale 

Il primo passo sarà simile al primo del contesto di integrazione. Invece di limitarsi a rendere le voci persistenti nel contesto di un array, dobbiamo effettivamente renderle permanenti in un database in modo che possano essere recuperate in un secondo momento:

usa TimeTracker \ TimeEntry; ... / ** * @Given Ho le seguenti voci temporali * / public function iHaveTheFollowingTimeEntries (TableNode $ table) $ rows = $ table-> getHash (); foreach ($ rows as $ row) $ entry = new TimeEntry; $ entry-> task = $ row ['task']; $ entry-> duration = $ row ['duration']; $ Entry-> save ();  

Correre Behat ci darà errore fatale Chiamata al metodo non definito TimeTracker \ TimeEntry :: save (), da TimeEntry non è ancora un modello eloquente. È facile da risolvere:

namespace TimeTracker; la classe TimeEntry estende \ Eloquent  

Se eseguiamo di nuovo Behat, Laravel si lamenterà che non può connettersi al database. Possiamo aggiustarlo aggiungendo a database.php file al app / config / testing directory, per aggiungere i dettagli di connessione per il nostro database. Per progetti più grandi, probabilmente si desidera utilizzare lo stesso server di database per i test e il codice di produzione, ma nel nostro caso utilizzeremo solo un database SQLite in memoria. Questo è molto semplice da configurare con Laravel:

 'sqlite', 'connections' => array ('sqlite' => array ('driver' => 'sqlite', 'database' => ': memoria:', 'prefix' => ",),),) ; 

Ora, se gestiamo Behat, ci dirà che non c'è time_entries tavolo. Per risolvere questo problema, dobbiamo effettuare una migrazione:

$ php artisan migrate: make createTimeEntriesTable --create = "time_entries" 
Schema :: create ('time_entries', function (tabella $ Blueprint) $ table-> increments ('id'); $ table-> string ('task'); $ table-> intero ('duration'); $ table-> timestamps ();); 

Non siamo ancora verdi, poiché abbiamo bisogno di un modo per istruire Behat a eseguire le nostre migrazioni prima di ogni scenario, quindi ogni volta abbiamo una lista pulita. Usando le annotazioni di Behat, possiamo aggiungere questi due metodi a LaravelTrait tratto:

/ ** * @BeforeScenario * / public function SetupDatabase () $ this-> app ['artisan'] -> call ('migrate');  / ** * @AfterScenario * / public function cleanDatabase () $ this-> app ['artisan'] -> call ('migrate: reset');  

Questo è abbastanza pulito e diventa il nostro primo passo verso il verde.

Il prossimo è il Quando genero il foglio del tempo passo. Il modo in cui lo vedo, generare il time sheet è l'equivalente di visitare il indice azione della risorsa di immissione del tempo, poiché il foglio del tempo è la raccolta di tutte le voci del tempo. Quindi l'oggetto time sheet è come un contenitore per tutte le voci temporali e ci dà un buon modo per gestire le voci. Invece di andare a / time-iscrizioni, per vedere il foglio dei tempi, penso che il dipendente dovrebbe andare a / Time-scheda. Dovremmo metterlo nella nostra definizione di passo:

/ ** * @Quando generi il time sheet * / public function iGenerateTheTimeSheet () $ this-> call ('GET', '/ time-sheet'); $ this-> crawler = new Crawler ($ this-> client-> getResponse () -> getContent (), url ('/'));  

Ciò causerà a NotFoundHttpException, poiché il percorso non è ancora definito. Come ho appena spiegato, penso che questo URL dovrebbe mappare a indice azione sulla risorsa di immissione del tempo:

Route :: get ('time-time', ['as' => 'time_sheet', 'uses' => 'TimeEntriesController @ index']); 

Per arrivare al verde, dobbiamo generare il controller:

$ php artisan controller: make TimeEntriesController $ compositore dump-autoload 

E noi andiamo.

Infine, dobbiamo eseguire la scansione della pagina per trovare la durata totale delle voci di tempo. Penso che avremo una sorta di tavolo che riassume le durate. Gli ultimi due passaggi sono così simili che li stiamo solo implementando allo stesso tempo:

/ ** * @Quindi il mio tempo totale speso per: compito dovrebbe essere: expectedDuration minutes * / public function myTotalTimeSpentOnTaskShouldBeMinutes ($ task, $ expectedDuration) $ actualDuration = $ this-> crawler-> filter ('td #'. $ Task . "TotalDuration") -> text (); PHPUnit :: assertEquals ($ expectedDuration, $ actualDuration);  / ** * @Quindi il mio tempo totale speso dovrebbe essere: expectedDuration minutes * / public function myTotalTimeSpentShouldBeMinutes ($ expectedDuration) $ actualDuration = $ this-> crawler-> filter ('td # totalDuration') -> text (); PHPUnit :: assertEquals ($ expectedDuration, $ actualDuration);  

Il crawler sta cercando un  nodo con un id di [Nome_attività] TotalDuration o durata totale nell'ultimo esempio.

Dal momento che non abbiamo ancora una vista, il crawler ce lo dirà L'elenco dei nodi corrente è vuoto.

Per risolvere questo problema, costruiamo il indice azione. Per prima cosa, recuperiamo la raccolta di voci temporali. In secondo luogo, generiamo un time sheet dalle voci e lo inviamo alla vista (ancora non esistente).

utilizzare TimeTracker \ TimeSheet; utilizzare TimeTracker \ TimeEntry; class timeEntriesController extends \ BaseController / ** * Visualizza un elenco delle risorse. * * @return Response * / public function index () $ entries = TimeEntry :: all (); $ sheet = TimeSheet :: generate ($ entries); return View :: make ('time_entries.index', compact ('foglio'));  ... 

La vista, per ora, sarà costituita da una semplice tabella con i valori di durata riepilogati:

Orario

Compito Durata totale
codifica $ sheet-> totalTimeSpentOn ('coding')
documentazione $ sheet-> totalTimeSpentOn ('documenting')
incontri $ sheet-> totalTimeSpentOn ('meetings')
Totale $ sheet-> totalTimeSpent ()

Se esegui di nuovo Behat, vedrai che abbiamo implementato la funzione con successo. Forse dovremmo prendere un momento per renderci conto che nemmeno una volta abbiamo aperto un browser! Questo è un enorme miglioramento del nostro flusso di lavoro e, come bonus, ora abbiamo test automatici per la nostra applicazione. Sìì!

Conclusione

Se corri vendor / bin / Behat per eseguire entrambe le suite di Behat, vedrai che entrambi sono ora verdi. Se esegui PhpSpec, sfortunatamente, vedrai che le nostre specifiche sono guaste. Otteniamo un errore fatale Classe 'Eloquente' non trovata in ... . Questo perché Eloquent è un alias. Se dai un'occhiata in app / config / app.php sotto alias, lo vedrai Eloquente è in realtà un alias per Illuminare \ Database Eloquente \ modello \. Per riportare PhpSpec in verde, dobbiamo importare questa classe:

namespace TimeTracker; usa Illuminate \ Database \ Eloquent \ Model come Eloquent; la classe TimeEntry estende Eloquent  

Se esegui questi due comandi:

$ vendor / bin / phpspec eseguito; vendor / bin / Behat 

Vedrai che siamo tornati al verde, sia con Behat che con PhpSpec. Sìì! 

Abbiamo ora descritto e progettato la nostra prima caratteristica, utilizzando completamente un approccio BDD. Abbiamo visto come possiamo trarre beneficio dalla progettazione del dominio principale della nostra applicazione, prima che ci preoccupiamo dell'interfaccia utente e del materiale specifico della struttura. Abbiamo anche visto quanto sia facile interagire con Laravel, e specialmente con il database, nei nostri contesti Behat. 

Nel prossimo articolo, faremo molti refactoring per evitare troppa logica sui nostri modelli Eloquent, dal momento che questi sono più difficili da testare separatamente e sono strettamente accoppiati a Laravel. Rimanete sintonizzati!