Giorno 9: I test funzionali
Ieri abbiamo visto come realizzare test unitari per le classi di Jobeet usando la libreria per i test lime distribuita con symfony.
Oggi scriveremo test funzionali per le feature già implementate nei moduli job
e category
.
Test funzionali
I ~test funzionali|Test Funzionali~ sono un ottimo strumento per testare la propria applicazione in modo completo: dalla richiesta fatta da un browser alla risposta inviata dal server. Permettono di testare tutti i livelli di un'applicazione: il routing, il modello, le azioni e i template. Molto probabilmente sono molto simili a ciò che fate già manualmente: ogni volta che aggiungete o modificate un'azione, c'è bisogno di andare sul browser e verificare che tutto funzioni a dovere, cliccando sui link e controllando gli elementi nella pagina visualizzata. In altre parole, eseguite lo scenario relativo al caso d'uso appena implementato.
Visto che il processo è manuale, può risultare noioso e fonte di errori. Ogni volta che cambiate qualcosa nel codice, dovete passare attraverso tutti gli scenari, per assicurarvi che quello che avete fatto non abbia rotto qualcosa. Non va bene. I test funzionali in symfony mettono a disposizione una via per descrivere in modo semplice gli scenari. Ogni scenario può essere eseguito automaticamente più e più volte per simulare la user experience di un utente attraverso il suo browser. Come i test unitari, vi danno la possibilità di scrivere codice in tranquillità.
Il framework dei test funzionali non sostituisce strumenti come "~Selenium~". Selenium gira direttamente nel browser, per automatizzare i test tra le varie piattaforme e i vari browser, quindi è in grado di testare i JavaScript della propria applicazione.
La classe sfBrowser
In symfony i test funzionali vengono eseguiti attraverso un ~browser|Browser~ speciale implementato dalla classe sfBrowser
. Esso si comporta come un browser realizzato per la propria applicazione e direttamente connesso a essa, senza il bisogno di un server web. Vi dà l'accesso a tutti gli oggetti di symfony prima e dopo ogni richiesta, dandovi l'opportunità di analizzarli e fare i controlli di cui avete bisogno.
sfBrowser
mette a disposizione i metodi che simulano le azioni compiute in un classico broswer:
Metodo | Descrizione |
---|---|
get() |
Get di un URL |
post() |
Post a un URL |
call() |
Chiama un URL (usato per i metodi PUT e DELETE ) |
back() |
Torna indietro di una pagina nella cronologia |
forward() |
Avanza di una pagina nella cronologia |
reload() |
Ricarica la pagina corrente |
click() |
Clicca un link o un pulsante |
select() |
Seleziona un radiobutton o checkbox |
deselect() |
Deseleziona un radiobutton o checkbox |
restart() |
Rilancia il browser |
Vediamo alcuni esempi di utilizzo dei metodi sfBrowser
:
$browser = new sfBrowser(); $browser-> get('/')-> click('Design')-> get('/category/programming?page=2')-> get('/category/programming', array('page' => 2))-> post('search', array('keywords' => 'php')) ;
sfBrowser
contiene ulteriori metodi per configurare l'aspetto del browser:
Metodo | Descrizione |
---|---|
setHttpHeader() |
Imposta gli header HTTP |
setAuth() |
Imposta le credenziali di base per l'autenticazione |
setCookie() |
Imposta un cookie |
removeCookie() |
Rimuove un cookie |
clearCookies() |
Rimuove tutti i cookie correnti |
followRedirect() |
Segue un rinvio |
La classe sfTestFunctional
Abbiamo un browser, ma abbiamo anche bisogno di un modo per analizzare gli oggetti di symfony per eseguire i test attuali. Potremmo farlo con lime e alcuni metodi di sfBrowser
come getResponse()
e getRequest()
, ma symfony mette a disposizione una via migliore.
I metodi di test sono forniti da un'altra classe, sfTestFunctional
che accetta un'istanza di sfBrowser
nel suo costruttore. La classe sfTestFunctional
delega i test agli oggetti ~tester|Tester~. Svariati tester sono distribuiti con symfony, inoltre se ne possono realizzarne altri.
Come abbiamo visto ieri, i test funzionali vengono memorizzati nella cartella test/functional/
. Per Jobeet, i test si trovano nella sotto-cartella test/functional/frontend/
, visto che ogni applicazione ha la propria sotto-cartella. Questa cartella contiene due file: categoryActionsTest.php
e jobActionsTest.php
, semplici test funzionali creati automaticamente dai task che generano i moduli:
// test/functional/frontend/categoryActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new sfTestFunctional(new sfBrowser()); $browser-> get('/category/index')-> with('request')->begin()-> isParameter('module', 'category')-> isParameter('action', 'index')-> end()-> with('response')->begin()-> isStatusCode(200)-> checkElement('body', '!/This is a temporary page/')-> end() ;
A prima vista, lo script precedente potrebbe sembrare un po' strano. Questo perché i metodi di sfBrowser
e sfTestFunctional
implementano una ~interfaccia fluida|Interfaccia fluida~, restituendo sempre $this
. Questo permette di concatenare le chiamate ai metodi, per una migliore leggibilità. Il pezzo di codice sopra è equivalente a:
// test/functional/frontend/categoryActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new sfTestFunctional(new sfBrowser()); $browser->get('/category/index'); $browser->with('request')->begin(); $browser->isParameter('module', 'category'); $browser->isParameter('action', 'index'); $browser->end(); $browser->with('response')->begin(); $browser->isStatusCode(200); $browser->checkElement('body', '!/This is a temporary page/'); $browser->end();
I test sono eseguiti in blocchi. Ogni blocco inizia con with('TESTER NAME')->begin()
e termina con end()
:
$browser-> with('request')->begin()-> isParameter('module', 'category')-> isParameter('action', 'index')-> end() ;
Il codice verifica che il parametro module
della richiesta sia uguale a category
e che action
sia uguale a index
.
Quando serve chiamare un solo metodo in un tester, non è necessario creare un blocco:
with('request')->isParameter('module', 'category')
.
Il Tester della Richiesta
Il ~request tester|Richiesta HTTP (Test)~ mette a disposizione i metodi per analizzare e testare gli oggetti sfWebRequest
:
Metodo | Descrizione |
---|---|
isParameter() |
Controlla il valore di un parametro di una richiesta |
isFormat() |
Controlla il formato di una richiesta |
isMethod() |
Controlla un metodo |
hasCookie() |
Controlla se la richiesta ha un cookie con un dato nome |
isCookie() |
Controlla il valore di un cookie |
Il Tester della Risposta
C'è anche una classe ~response tester|Risposta HTTP (Test)~, che mette a disposizione i metodi per analizzare e testare gli oggetti sfWebResponse
:
Metodo | Descrizione |
---|---|
checkElement() |
Verifica se un selettore CSS di risposta corrisponda a un criterio |
checkForm() |
Checks an sfForm form object |
debug() |
Prints the response output to ease debug |
matches() |
Tests a response against a regexp |
isHeader() |
Controlla il valore dell'header |
isStatusCode() |
Controlla il codice di stato della risposta |
isRedirected() |
Controlla se la risposta corrente è un rinvio |
isValid() |
Controlla se una risposta è in XML ben formato (passando true come parametro, si valida la risposta anche su come deve essere il tipo di documento) |
Descriveremo altre classi tester nei prossimi giorni (per form, user, cache, ...).
Eseguire Test funzionali
Come per i test unitari, i test funzionali posso essere eseguiti direttamene eseguendo il file di test
$ php test/functional/frontend/categoryActionsTest.php
O utilizzando il task test:functional
:
$ php symfony test:functional frontend categoryActions
Dati da testare
Come per i test unitari per ##ORM##, occorre caricare i dati da testare ogni volta che si esegue un test funzionale. Possiamo riutilizzare il codice scritto ieri:
include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new sfTestFunctional(new sfBrowser());
$loader = new sfPropelData(); $loader->loadData(sfConfig::get('sf_test_dir').'/fixtures'); Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures');
Caricare dati in un test funzionale è più facile rispetto a un test unitario, in quanto il database è già stato inizializzato dallo script di bootstrap.
Come per i test unitari, non copieremo questo pezzo di codice in ogni file di test, ma creeremo piuttosto una nostra classe che eredita da sfTestFunctional
:
// lib/test/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function loadData() {
$loader = new sfPropelData(); $loader->loadData(sfConfig::get('sf_test_dir').'/fixtures'); Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures');
return $this;
}
}
Scrivere Test Funzionali
Scrivere test funzionali è come eseguire uno scenario in un browser. Abbiamo già descritto tutti gli scenari che abbiamo bisogno di testare nel giorno 2.
Per prima cosa, testiamo l'homepage modificando il file jobActionsTest.php
. Rimpiazziamo il codice esistente con il seguente:
I lavori scaduti non sono elencati
// test/functional/frontend/jobActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new JobeetTestFunctional(new sfBrowser()); $browser->loadData(); $browser->info('1 - The homepage')-> get('/')-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'index')-> end()-> with('response')->begin()-> info(' 1.1 - Expired jobs are not listed')-> checkElement('.jobs td.position:contains("expired")', false)-> end() ;
Come con lime
, un messaggio d'informazione può essere inserito chiamando il metodo info()
, per rendere l'output più leggibile. Per verificare l'esclusione di lavori scaduti dalla homepage, controlliamo che il ~selettore CSS~ .jobs td.position:contains("expired")
non trovi corrispondenze nell'HTML generato (ricordate che nei file con le fixture, i lavori scaduti che abbiamo contengono "expired" nella posizione). Quando il secondo parametro del metodo checkElement()
è un booleano, il metodo testa l'esistenza di un nodo che corrisponda al selettore CSS.
Il metodo
checkElement()
è capace di interpretare la maggioranza dei selettori CSS3 validi.
Sono elencati solo n lavori per una categoria
Aggiungiamo il seguente codice alla fine del file di test:
// test/functional/frontend/jobActionsTest.php $max = sfConfig::get('app_max_jobs_on_homepage'); $browser->info('1 - The homepage')-> get('/')-> info(sprintf(' 1.2 - Only %s jobs are listed for a category', $max))-> with('response')-> checkElement('.category_programming tr', $max) ;
Il metodo checkElement()
può inoltre controllare che un selettore abbia 'n' nodi corrispondenti nel documento, passando un intero come secondo parametro.
Una categoria ha un link alla pagina della categoria solo se ha troppi lavori
// test/functional/frontend/jobActionsTest.php $browser->info('1 - The homepage')-> get('/')-> info(' 1.3 - A category has a link to the category page only if too many jobs')-> with('response')->begin()-> checkElement('.category_design .more_jobs', false)-> checkElement('.category_programming .more_jobs')-> end() ;
In questi test, dobbiamo controllare che non ci sia il link "more jobs" per la categoria design (.category_design .more_jobs
non esista), e che ci sia il link "more jobs" per la categoria programming (.category_programming .more_jobs
esista).
I lavori sono ordinati cronologicamente
// lavori più recenti nella categoria programming $criteria = new Criteria(); $criteria->add(JobeetCategoryPeer::SLUG, 'programming'); $category = JobeetCategoryPeer::doSelectOne($criteria);
$criteria = new Criteria();
$criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN);
$criteria->add(JobeetJobPeer::CATEGORY_ID, $category->getId());
$criteria->addDescendingOrderByColumn(JobeetJobPeer::CREATED_AT);
$job = JobeetJobPeer::doSelectOne($criteria);
$q = Doctrine_Query::create() ->select('j.*') ->from('JobeetJob j') ->leftJoin('j.JobeetCategory c') ->where('c.slug = ?', 'programming') ->andWhere('j.expires_at > ?', date('Y-m-d', time())) ->orderBy('j.created_at DESC');
$job = $q->fetchOne();
$browser->info('1 - The homepage')->
get('/')->
info(' 1.4 - Jobs are sorted by date')->
with('response')->begin()->
checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $job->getId()))->
end()
;
Per testare se i lavori sono ordinati cronologicamente, occorre controllare che l'ultimo lavoro elencato nella homepage sia quello che ci si aspetta. Possiamo farlo verificando che l'URL contenga la ~chiave primaria|Chiave primaria~ attesa. Siccome la chiave primaria cambia tra le esecuzioni, abbiamo innanzitutto bisogno dell'oggetto ##ORM## dal database.
Anche se il test funziona così com'è, dobbiamo eseguire una leggera rifattorizzazione, dato che l'estrazione del primo lavoro della categoria programming può essere riutilizzata da qualche altra parte nei test. Non sposteremo il codice nel modello, dato che è specifico per il test. Al contrario, lo sposteremo all'interno della classe JobeetTestFunctional
che abbiamo creato in precedenza. Questa classe si comporta come una ~classe per il test|Tester~ specifica per Jobeet:
// lib/test/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function getMostRecentProgrammingJob() {
// lavori più recenti nella categoria programming $criteria = new Criteria(); $criteria->add(JobeetCategoryPeer::SLUG, 'programming'); $category = JobeetCategoryPeer::doSelectOne($criteria);
$criteria = new Criteria();
$criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN);
$criteria->add(JobeetJobPeer::CATEGORY_ID, $category->getId());
$criteria->addDescendingOrderByColumn(JobeetJobPeer::CREATED_AT);
return JobeetJobPeer::doSelectOne($criteria);
$q = Doctrine_Query::create() ->select('j.*') ->from('JobeetJob j') ->leftJoin('j.JobeetCategory c') ->where('c.slug = ?', 'programming') ->andWhere('j.expires_at > ?', date('Y-m-d', time())) ->orderBy('j.created_at DESC');
return $q->fetchOne();
}
// ...
}
Ora possiamo sostituire il codice precedente del test con il nuovo:
// test/functional/frontend/jobActionsTest.php $browser->info('1 - The homepage')-> get('/')-> info(' 1.4 - Jobs are sorted by date')-> with('response')->begin()-> checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $browser->getMostRecentProgrammingJob()->getId()))-> end() ;
Ogni lavoro nella homepage è cliccabile
$job = $browser->getMostRecentProgrammingJob(); $browser->info('2 - The job page')-> get('/')-> info(' 2.1 - Each job on the homepage is clickable and give detailed information')-> click('Web Developer', array(), array('position' => 1))-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'show')-> isParameter('company_slug', $job->getCompanySlug())-> isParameter('location_slug', $job->getLocationSlug())-> isParameter('position_slug', $job->getPositionSlug())-> isParameter('id', $job->getId())-> end() ;
Per testare il link al lavoro nella homepage, simuliamo il click sul testo "Web Developer". Dato che questo testo è presente più volte nella stessa pagina, abbiamo chiesto esplicitamente al browser di cliccare il primo (array('position' => 1)
).
Ogni parametro di richiesta è così testato per assicurare che il routing abbia fatto il suo lavoro correttamente.
Imparare per esempi
In questa sezione abbiamo fornito tutto il codice necessario per testare le pagine dei lavori e delle categorie. Leggete attentamente il codice, perché potreste imparare alcuni interessanti nuovi trucchi.
// lib/test/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function loadData() {
$loader = new sfPropelData(); $loader->loadData(sfConfig::get('sf_test_dir').'/fixtures'); Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures');
return $this;
}
public function getMostRecentProgrammingJob()
{
// lavori più recenti nella categoria programming $criteria = new Criteria(); $criteria->add(JobeetCategoryPeer::SLUG, 'programming'); $category = JobeetCategoryPeer::doSelectOne($criteria);
$criteria = new Criteria();
$criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN);
$criteria->addDescendingOrderByColumn(JobeetJobPeer::CREATED_AT);
return JobeetJobPeer::doSelectOne($criteria);
$q = Doctrine_Query::create() ->select('j.*') ->from('JobeetJob j') ->leftJoin('j.JobeetCategory c') ->where('c.slug = ?', 'programming') ->andWhere('j.expires_at > ?', date('Y-m-d', time())) ->orderBy('j.created_at DESC');
return $q->fetchOne();
}
public function getExpiredJob()
{
// lavoro scaduto $criteria = new Criteria(); $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::LESS_THAN);
return JobeetJobPeer::doSelectOne($criteria);
$q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.expires_at < ?', date('Y-m-d', time()));
return $q->fetchOne();
} }
// test/functional/frontend/jobActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
$browser = new JobeetTestFunctional(new sfBrowser());
$browser->loadData();
$browser->info('1 - The homepage')->
get('/')->
with('request')->begin()->
isParameter('module', 'job')->
isParameter('action', 'index')->
end()->
with('response')->begin()->
info(' 1.1 - Expired jobs are not listed')->
checkElement('.jobs td.position:contains("expired")', false)->
end()
;
$max = sfConfig::get('app_max_jobs_on_homepage');
$browser->info('1 - The homepage')->
info(sprintf(' 1.2 - Only %s jobs are listed for a category', $max))->
with('response')->
checkElement('.category_programming tr', $max)
;
$browser->info('1 - The homepage')->
get('/')->
info(' 1.3 - A category has a link to the category page only if too many jobs')->
with('response')->begin()->
checkElement('.category_design .more_jobs', false)->
checkElement('.category_programming .more_jobs')->
end()
;
$browser->info('1 - The homepage')->
info(' 1.4 - Jobs are sorted by date')->
with('response')->begin()->
checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $browser->getMostRecentProgrammingJob()->getId()))->
end()
;
$job = $browser->getMostRecentProgrammingJob();
$browser->info('2 - The job page')->
get('/')->
info(' 2.1 - Each job on the homepage is clickable and give detailed information')->
click('Web Developer', array(), array('position' => 1))->
with('request')->begin()->
isParameter('module', 'job')->
isParameter('action', 'show')->
isParameter('company_slug', $job->getCompanySlug())->
isParameter('location_slug', $job->getLocationSlug())->
isParameter('position_slug', $job->getPositionSlug())->
isParameter('id', $job->getId())->
end()->
info(' 2.2 - A non-existent job forwards the user to a 404')->
get('/job/foo-inc/milano-italy/0/painter')->
with('response')->isStatusCode(404)->
info(' 2.3 - An expired job page forwards the user to a 404')->
get(sprintf('/job/sensio-labs/paris-france/%d/web-developer', $browser->getExpiredJob()->getId()))->
with('response')->isStatusCode(404)
;
// test/functional/frontend/categoryActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
$browser = new JobeetTestFunctional(new sfBrowser());
$browser->loadData();
$browser->info('1 - The category page')->
info(' 1.1 - Categories on homepage are clickable')->
get('/')->
click('Programming')->
with('request')->begin()->
isParameter('module', 'category')->
isParameter('action', 'show')->
isParameter('slug', 'programming')->
end()->
info(sprintf(' 1.2 - Categories with more than %s jobs also have a "more" link', sfConfig::get('app_max_jobs_on_homepage')))->
get('/')->
click('27')->
with('request')->begin()->
isParameter('module', 'category')->
isParameter('action', 'show')->
isParameter('slug', 'programming')->
end()->
info(sprintf(' 1.3 - Only %s jobs are listed', sfConfig::get('app_max_jobs_on_category')))->
with('response')->checkElement('.jobs tr', sfConfig::get('app_max_jobs_on_category'))->
info(' 1.4 - The job listed is paginated')->
with('response')->begin()->
checkElement('.pagination_desc', '/32 jobs/')->
checkElement('.pagination_desc', '#page 1/2#')->
end()->
click('2')->
with('request')->begin()->
isParameter('page', 2)->
end()->
with('response')->checkElement('.pagination_desc', '#page 2/2#')
;
Debug dei test funzionali
A volte un test funzionale fallisce. Siccome symfony simula un browser senza interfaccia grafica, può essere difficile diagnosticare il problema. Fortunatamente, symfony fornisce il metodo ~debug()|Debug
~ per mostrare gli header e i contenuti della risposta:
$browser->with('response')->debug();
Il metodo debug()
può essere inserito ovunque in un blocco di test response
e fermerà l'esecuzione dello script.
Insieme di test funzionali
Il task test:functional
può essere anche usato per lanciare tutti i test funzionali per un'applicazione:
$ php symfony test:functional frontend
Il task mostra una riga per ogni file di test:
Insieme di test
Come ci si poteva aspettare, c'è anche un task per lanciare tutti i test di un progetto (unitari e funzionali):
$ php symfony test:all
Quando si ha una grande suite di test, può essere necessario molto tempo per lanciare tutti i test ogni volta che si apporta una modifica, in particolare se alcuni test falliscono. Questo perché ogni volta che si mette a posto un test, bisogna rilanciare nuovamente l'intera suite di test per essere sicuri di non avere introdotto degli errori in qualche altro test. Ma finché non vengono messi a posto i test che falliscono, non c'è motivo per rilanciare tutti gli altri test. Il task test:all
ha una opzione --only-failed
che forza il task a rieseguire solo i test che sono falliti durante la precedente esecuzione:
$ php symfony test:all --only-failed
La prima volta che si lancia il task, tutti i test sono eseguiti come al solito. Ma per le successive esecuzioni dei test, solo i test che sono falliti l'ultima volta sono eseguiti. A poco a poco che si corregge il codice, alcuni test passeranno e saranno rimossi dalla successiva esecuzione. Quando tutti i test passano nuovamente, viene eseguita l'intera suite di test...
Se si desidera integrare la propria suite di test in un processo di continuous integration, usare l'opzione
--xml
per forzare il tasktest:all
a generare un output XML compatibile con JUnit.$ php symfony test:all --xml=log.xml
A domani
Concludiamo il nostro tour degli strumenti di test di symfony. Non avete più scuse per non testare le vostre applicazioni! Con il framework lime e con il framework dei test, symfony fornisce strumenti potenti per aiutarvi a scrivere test con poco sforzo.
Abbiamo solo scalfito la superfici dei test funzionali. D'ora in poi, ogni volta che implementeremo una feature, scriveremo anche dei test, per imparare di più sul framework dei test.
Domani parleremo di un'altra grande feature di symfony: il framework dei form.
ORM
インデックス
Document Index
関連ページリスト
Related Pages
- Giorno 1: Impostare il progetto
- Giorno 2: Il progetto
- Giorno 3: Il ~Modello dei dati~
- Giorno 4: Il controllore e la vista
- Giorno 5: Il routing
- Giorno 6: Di più sul Modello
- Giorno 7: Giocare con la pagina delle categorie
- Giorno 8: I test unitari
- Giorno 9: I test funzionali
- Giorno 10: Form
- Giorno 11: Testare i Form
- Giorno 12: Admin Generator
- Giorno 13: L'utente
- Giorno 14: Feed
- Giorno 15: Web Service
- Giorno 16: Inviare ~email|Email~
- Giorno 17: Ricerca
- Giorno 18: ~AJAX~
- Giorno 19: Internazionalizzazione e Localizzazione
- Giorno 20: I plugin
- Giorno 21: La Cache
- Giorno 22: Il rilascio
- Giorno 23: Un altro sguardo a symfony
- Appendice B - Licenza
- Riconoscimenti
日本語ドキュメント
Japanese Documents
- 2011/01/18 Chapter 17 - Extending Symfony
- 2011/01/18 The generator.yml Configuration File
- 2011/01/18 Les tâches
- 2011/01/18 Emails
- 2010/11/26 blogチュートリアル(8) ビューの作成