Test funzionali: lenti, accoppiati ma utili

Test funzionali: lenti, accoppiati ma utili

N.B. Questo contenuto è stato prodotto e pubblicato la prima volta da Flowing, società che da luglio 2022 è confluita in Claranet Italia. Insieme a essa sono confluite riflessioni, temi, metodologie e spunti, ampiamente condivisi e orgogliosamente riproposti all’interno di questo blog. ©claranet

PUBBLICATO IL 09/10/2014 DA

Ricardo

Partner

IN SINTESI

Mi trovo spesso a sviluppare applicazioni web tramite l’utilizzo di framework full-stack come Symfony o Zend. Questi strumenti, uniti alla natura delle applicazioni stesse, mi portano a scrivere molti test funzionali che a loro volta si portano dietro sia aspetti positivi che negativi. Qui vorrei esplorare quali sono questi aspetti e quando è realmente importante fare test funzionali.

Prima di tutto capiamo bene di che tipo di test stiamo parlando. Tramite il pattern AAA potremo descrivere un test funzionale in questa maniera:

  • Arrange: prepariamo fixtures necessarie a portare la nostra applicazione in un determinato stato iniziale. Tipicamente le fixtures sono dati da caricare su DB, stub di servizi, ecc.
  • Act: eseguiamo il codice di produzione che vogliamo testare. Ad esempio facciamo una request all’uri che indentifica la pagina che vogliamo testare.
  • Assert: controlliamo che il risultato ottenuto cioè che la response sia quella che ci aspettiamo. Questo viene fatto controllando la response e/o il DOM.

Questa tipologia di test può essere molto utile dato che va a testare alcuni path d’interazione che l’utente eseguirà. Un altro aspetto che mi piace particolarmente è la possibilità di mappare questi test con delle specifiche precedentemente discusse tra team ed esperti di dominio dando vita ad una documentazione ‘vivente’ ovvero compilabile ed eseguibile nuovamente ad ogni cambiamento di specifica ricevuta dagli stessi esperti di dominio.

Un esempio potrebbe essere questo:

“Dato che un cliente ha deciso di comprare un prodotto
quando accede alla categoria contenente il prodotto
allora deve vedere l’elenco di tutti i prodotti di quella categoria”

Tramite l’utilizzo di tool come Cucumber o il suo porting in PHP Behat possiamo rendere eseguibile la specifica appena scritta e quindi automatizzare il processo di accettazione della funzionalità. Se cambia la ‘documentazione’ automaticamente cambiano anche i test che ne controllano l’effettiva validità. Ci troviamo quindi in un contesto dove avremo specifiche scritte in linguaggio naturale comprensibile a tutti i membri del team e dagli esperti di dominio.

Veniamo alle note dolenti.

I test funzionali sono spesso lenti. Il feedback nel ciclo di sviluppo è lento e i tempi di completamento della build possono diventare da subito molto lunghi. Ok, oggi lavoriamo con dischi ssd, processori potenti, tanta ram e tutto il resto, però c’è anche da dire che spesso utilizziamo ambienti virtualizzati che ci semplificano la vita lato setup ma inevitabilmente abbassano le prestazioni. Se poi consideriamo test funzionali che girano su browser per testare il javascript, allora la velocità ne risente ancora di più e dobbiamo farli girare in macchine che dispongano di browser. Ci sono casi in cui è sufficiente utilizzare tool di testing headless come ad esempio zombiejs o phantomjs . Il primo permette di fare test su un browser headless simulato che non ha tutte le features di un browser reale ma che ci permette di avere feedback veloce. Il secondo tool permette di fare test con un browser headless reale ovvero si utilizza un browser vero ma senza finestra. In questo ultimo caso il feedback è più lento ma è comunque più veloce del test su browser reale fatto con selenium.

Altro lato negativo è l’accoppiamento al DOM che spesso è presente in suite di test di questo genere. Puntare elementi tramite classe, id, data-attribute, testi contenuti nell’elemento e così via, genera un accoppiamento che può spesso portare a falsi negativi. I nostri test si rompono, ma solo perché ad esempio qualcuno ha cambiato una classe css. Si genera accoppiamento anche con i dati che carichiamo come fixtures. Pensiamo a test che controllano il numero di elementi all’interno di una pagina. Aggiungiamo una fixtures per un altro test e il test precedente  si potrebbe rompere.

Analizziamo questi due problemi.

Il problema delle fixtures si può mitigare cercando di tenere fixtures separate per ogni test. Confrontandomi con altri dev, ho visto che non a tutti piace tenere le fixtures separate. Personalmente preferisco che ogni test (o comunque classe di test) abbia le proprie fixtures anche a discapito di un po di duplicazione. In futuro vorrei dedicare un post proprio su questo argomento.

Problemi legati all’accoppiamento dal DOM si possono limitare rendendo test più robusti, tramite l’utilizzo del page object pattern con il quale riusciamo ad estrarre dal codice del test tutto quello che è interazione con la pagina come ad esempio cliccare un link, ottenere un form o altro. “Wrappiamo” quindi la pagina o frammenti si essa in oggetti favorendo il riutilizzo del codice, la leggibilità e quindi abbassandone costi di mantenimento.

Riporto l’implementazione utilizzata nella Page Object Extension :

abstract class Page extends DocumentElement implements PageObject{…/** * @param array $urlParameters * * @return Page */public function open(array $urlParameters = array()){    $url = $this->getUrl($urlParameters);    $this->getSession()->visit($url);    $this->verify($urlParameters);    return $this;}..}

Oggetto che rappresenta la homepage:

class Homepage extends Page{    /**     * @var string $path     */    protected $path = '/';    public function startBooking()    {        $this->findLink('book-now')->click();    }}

Test che utilizza l’oggetto Homepage:

/** * @When un ospite decide di prenotare un campo */public function unOspiteDecideDiPrenotareUnCampo(){    $homepage = $this->getPage('Homepage');    $homepage->open();    $homepage->startBooking();    ...}

Un approccio molto interessante sempre legato all’organizzazione e mantenimento dei test  è quello descritto da Gojko Adzic nel suo libro “Specification by Example”. Lui definisce tre livelli gerarchici per organizzare e automatizzare test su interfaccia:

  • business rule: cosa il test vuole descrivere e dimostrare (es: per clienti che comprano due libri c’è la spedizione gratuita),
  • workflow: descrive il flusso di navigazione che l’utente dovrà fare per utilizzare la funzionalità (es: metti due libri nel carrello, inserisci i tuoi dati e verifica che la spedizione sia gratuita),
  • technical activity: descrive i sigoli step che l’utente dovrà fare per utilizzare la funzionalità (es: apri la homepage, inserisci nome utente=”pippo” e password=”pippo”, vai alla pagina /book, clicca nella prima immagine che ha come classe “book-item”, ecc ecc..).

Questo approccio tende a suddividere la parte di “Specification” dalla parte di “Automation” lungo questi tre livelli. Utilizzando Behat potremo vedere la cosa in questa maniera:

  • business rule: descrizione della funzionalità (linguaggio naturale),
  • workflow: descrizione dei scenari (linguaggio naturale),
  • technical activity: singoli step definiti negli scenari e riutilizzabili per altri scenari  (codice).

Utilizzare questa gerarchia e organizzare test tramite page object ci potrebbe aiutare moltissimo nel mantenimento della suite.

Un ulteriore consiglio che Gojko Adzic ci dà, è di suddividere la suite di test in gruppi. La suddivisione può essere fatta ad esempio per isolare test molto lenti dagli altri evitando quindi di farli girare ogni volta. Un altro modo è quello di creare dei gruppi per l’iterazione corrente in modo da tenere presumibilmente snella e veloce la suite di test che interessa le funzionalità che stiamo sviluppando nell’iterazione stessa. Quest’ultimo approccio che non ho mai provato, mi ha colpito particolarmente e cercherò di utilizzarlo in futuro per capire se effettivamente porta dei vantaggi.

Un altro problema è rappresentato dalla natura stessa del test funzionale il quale – in caso di fallimento – ci dice che si è verificato un problematiche se spesso non ci mostra chiaramente il motivo e dove sia il presunto bug. Ad esempio ci aspettiamo che l’elemento con classe ‘username’ contenga un determinato valore mentre invece ne contiene un altro. Essendo ad un livello ‘alto’ di testing non sappiamo quale componente ha prodotto quel valore. É per questo motivo che non dovremo fare solo test funzionali ma anche (e soprattutto) unitari i quali invece esplicitano con più chiarezza dov’è il problema.

Naturalmente questo non è un articolo che vuole sminuire l’utilità dei test funzionali, ma piuttosto vuole discutere quando eventualmente ha senso farli e se è sempre necessario arrivare a testare l’interfaccia, tenendo in considerazione quindi il rapporto costo/beneficio basato sull’effettivo ‘rischio‘ che si corre in caso di bug.

Lavorando su progetti Symfony, fare test funzionali viene quasi naturale. Symfony è ben progettato per supportare questa tipologia di test e, il fatto che le richieste non passino per il server web e non si utilizzi il browser, li rende anche abbastanza veloci (naturalmente non paragonabili a test unitari). É possibile configurare il framework per far girare i test su SQLite il che rende la suite ancora più veloce (non voglio parlare di eventuali brutte sorprese nel fare questa cosa!).

Tutto molto bello, si fanno test funzionali e spesso ci si dimentica se questa sia veramente la strada migliore. Testiamo a questo livello anche cose che forse non sono così importanti. Accumuliamo test accoppiati allla UI che poi richiederanno tempo per essere lanciati e mantenuti.

Alcuni di questi dubbi mi sono venuti grazie a due progetti sui quali sto lavorando e dalla lettura del libro sopra citato.

Il primo progetto è in Symfony ma ha uno stack tecnologico molto ampio dato che c’è un db relazionale, un documentale, un search engine e alcune pagine con una forte interazione javascript. Mediamente i funzionali sono lenti e conseguentemente anche lo sviluppo. Nella mia macchina sono passato da circa 50 a circa 13 minuti.

Il secondo progetto è in zend framework 2 e gira su una Vagrant dove inizialmente avevo cominciato a scrivere test funzionali con Behat mink e Goutte. In pochissimo tempo sono arrivato ad avere una build che si aggirava intorno ai 7 minuti. La prima ottimizzazione che ho fatto è stata quella di evitare che le richieste passassero per Apache. Questo primo step mi ha permesso di abbassare notevolmente i tempi di esecuzione. Da un confronto successivo con il cliente abbiamo deciso che testare l’interfaccia per un applicazione di questo genere (un gestionale) non era fondamentale o quanto meno non lo era per diverse pagine dell’applicazione. Capiamo bene questa cosa. Non voglio dire che per tutti i gestionali non serve fare test funzionali, ma in questo caso specifico il beneficio percepito non giustificava i costi dato che il rischio di avere un bug nell’interfaccia era ben tollerato. Detto questo, ho deciso quindi di testare esclusivamente l’output dei controller, ovvero il view model. Da questo particolare oggetto posso ottenere un array di variabili che verranno poi passate al template twig e controllare che i valori ottenuti dalla computazione siano quelli che mi aspetto.

La maggiorparte dei miei test, carica dati nel DB, inizializza il framework, prepara la request, la passa al router, esegue il dispatch dei controlller e ne testa il risultato (ho praticamente riprodotto l’approccio di Symfony e preso spunto dalla documentazione di Zend).


Di seguito un esempio di codice:

Feature: Orders search  In order to access orders information  As admin  I can search orders  Scenario: Search by username    Given I am logged in    When I go to orders page    And search "user0" customer's orders    I should see all customer's orders

FeatureContext:

/** @var ZendMvcApplication */private static $zendApp;/** * Parameters from behat.yml * * @param array $parameters */public function __construct(array $parameters){    self::initializeZendFramework();    $this->useContext('order', new OrderContext(        $this->getServiceManager(),        $this->createConnection())    );}static private function appBootstrap(){    require_once __DIR__ . '/../../bootstrap.php';}static public function initializeZendFramework()    {        self::appBootstrap();        if (self::$zendApp === null) {            $path = __DIR__                . '/../../config/application.config.php';            self::$zendApp = ZendMvcApplication::init(                require $path            );        }    }/** @return ZendServiceManagerServiceManager */private function getServiceManager(){    return self::$zendApp->getServiceManager();}/** * @Given /^I am logged in$/ */public function iAmLoggedIn(){    $md5Crypt = new Md5PasswordEncoder();    $zf2ServiceManager = $this->getServiceManager();    $authAdapter = AuthAdapterFactory::create(        $zf2ServiceManager,        $md5Crypt,        $this->adminUsername,        $this->adminPassword,        $this->salt    );    $auth = $zf2ServiceManager->get('adminAuthenticationService');    $auth->authenticate($authAdapter);}

OrdersContext:

/** * @When /^I go to orders page$/ */public function iGoToOrdersPage(){    //questo metodo configura controller, request, router    $this->initAction(        'OrderController', //nome controller        'search', //action        'orders', //rotta        array(), //parametri rotta        ZendHttpRequest::METHOD_GET, // http method        self::BASE_URL . '/orders' //url    );}/** * @Given /^search "([^"]*)" customer's orders$/ */public function searchCustomerSOrders($searchedText){    $this->request        ->getQuery()        ->set('searched_field', 'username');    $this->request        ->getQuery()        ->set('searched_text', $searchedText);    $this->request        ->getQuery()        ->set('submit', 'Search');    $this->orders = $this->controller        ->dispatch($this->request);}/** * @Then /^I should see all customer's orders$/ */public function iShouldSeeAllCustomerSOrders(){    $response = $this->controller        ->getResponse();    assertEquals(200, $response->getStatusCode());    $viewModelVars = $this->orders        ->getVariables();    assertInstanceOf('ZendViewModelViewModel', $this->orders);    $orders = $viewModelVars['orders'];    assertCount(10, $orders);    ...}

Questo modo di scrivere test mi ha dato i seguenti vantaggi:

  • i test sono molto veloci (la stessa suite che girava in 7 minuti poi girava in 36 secondi),
  • non accoppiati al DOM,
  • nessuno mi impedisce di scrivere test funzionali ‘alla Symfony’ o utilizzando Mink con Goutte o con Selenium per zone ad alto rischio,
  • ho una specifica funzionale automatizzata descritta in linguaggio naturale.

Naturalmente ci sono anche degli svantaggi:

  • non possono coprire casi in cui serve testare il DOM a meno che non utilizzi l’approccio classico. Se ho scenari nei quali testare l’interazione con il DOM e/o il javascript ha valore, posso comunque farlo,
  • inizialmente la parte di Arrange è stata un po macchinosa da scrivere,
  • sto accoppiando i test alle variabili che passo al template e quindi di fatto il test diventa un test d’integrazione del controller. A mio avviso è comunque un accoppiamento meno costoso da mantenere.

Nel mio caso, il beneficio di questi test sta nel fatto che mi permettono di controllare se la funzionalità è corretta e ancora più importante fare refactoring dei controller senza avere timore di modificare il codice

I costi sono relativamente bassi dato che i test sono molto veloci e meno accoppiati. Sono sicuramente più ‘permissivi’ di test funzionali ‘classici’, ma considerando la buona copertura tra test unitari e d’integrazione e avendo valutato con il cliente che se l’interfaccia si rompe non è un problema di vita o di morte, scrivere test in questo modo diventa utile, accettabile e il rapporto tra costo e beneficio è buono.

In conclusione non diamo per scontato che tutto vada testato a prescindere. Cerchiamo sempre di monitorare la situazione e di fare in modo che i test ci facilitino lo sviluppo non solo in termini di design e validazione ma anche in termini di feedback veloce. Se possiamo evitare di accoppiare i nostri test con l’interfaccia facciamolo dato che avremo test più veloci e robusti. Se ci sono parti in cui è fondamentale testare l’interfaccia allora testiamola.

Tipicamente parti che a livello di business portano molto valore al cliente vanno testate bene anche perché spesso sono quelle che richiedono molte modifche. Parti che sono ad alto rischio, ovvero il danno provocato da un eventuale bug è molto costoso vanno testate.

Evitiamo di scrivere test funzionali che siano puramente codice che solo tecnici comprendono. Oggi ci sono strumenti che ci permettono di scrivere specifiche in linguaggio naturale e successivamente di automatizzarne la validazione. Utilizziamoli dato che portano vantaggi reali a costi più o meno identici. Non dimentichiamoci mai che una volta che abbiamo capito bene cosa dobbiamo fare e perché, il più è fatto.


Contatta i nostri esperti

per parlare di come possiamo aiutare te e la tua azienda ad evolvere

Contattaci