Smart Integration Testing
Quando si parla di testing, si finisce sempre a parlare di test di integrazione. Ma sono veramente il nostro supereroe preferito? O possono diventare il nostro peggior nemico? Cerchiamo, tramite un esempio pratico, capire fin dove possiamo spingerci con i test di integrazione e quando dobbiamo delegare ad altre parti dell'applicazione il testing funzionale
Smart integration tests con RSpec
Recentemente ho lavorato ad un progetto per un gestionale. Nulla di più standard. Un backend di amministrazione e un frontend pubblico.
Un model però aveva catturato la mia attenzione: la rappresentazione di un immobile.
La tabella del db contiene circa 60 attributi. Tanti, lo so. Si può in questi casi spezzare il model in più sottomodel:
class Immobile < ActiveRecord::Base has_one :superficie has_one :anagrafica_proprietario ... end
Questa scomposizione magari alleggerisce il model, ma si porta dietro un po' di problematiche nel caso dei form... ma ne parleremo prossimamente.
In entrami i casi comunque, i 60 campi rimangono.
I test di integrazione
Questo progetto era relativamente semplice, quindi l'ho sfruttato come banco di prova per potenziare un po' l'utilizzo dei test di integrazione.
L'esperimento che volevo fare era quello di tentare un approccio che si incentrasse quasi esclusivamente sull'utilizzo dei test di integrazione che, con poche righe di test, permettessero di coprire una buona percentuale di codice business.
La prima cosa da fare è quella di pensare agli scenari da testare. Un primo approccio è stato il seguente (si utilizza, per il modulo Pagine della gemma SitePrism:
require 'rails_helper' RSpec.feature `Managing immobili`, type: :feature do describe `Creating an entry` do given(:new_page) { Pagine::Immobili::New.new } describe `field xxx` do scenario `with a valid value` do new_page.load new_page.xxx_field.set `valid` new_page.submit! expect(new_page).to have_notice end scenario `with an invalid value` do new_page.load new_page.xxx_field.set `invalid` new_page.submit! expect(new_page).to have_alert end end end end
Ora, in un approccio puramente BDD, ogni riga di codice dovrebbe essere anticipata da un test che ne giustifichi l'esistenza.
Se volessimo perseguire questo schema usando i soli test di integrazione, dovremmo immaginare di replicare questo codice per ogni campo ( o almeno, per ogni campo che necessita di una qualche validazione )
E la cosa potrebbe succedere presto! E quando succede questo, il codice dei test potrebbe divenrare noioso da mantenere, con il rischio che si smetta proprio di scrivere i tests.
Si può fare di meglio?
Pensiamo un secondo a come è fatta l'action del controller, che è, all'interno del nostro codice, il punto che rappresenta le medesime azioni che un utente può effetturare nella nostra applicazione:
class ImmobiliController < ApplicationController def create @resource = Immobile.create(permitted_params) respond_to @resource end end
Se ci soffermiamo sul controller, ci rendiamo conto che le uniche 2 cose che possono succedere sono che la creazione avvenga con successo o che fallisca.
Aggiungiamo che, utilizziando gemme come SimpleForm e Responders, si può dare per scontato che i flash vengano correttamente settati i funzione dell'esito dell'azione.
Di conseguenza, possiamo immaginare solo 2 scenari:
require 'rails_helper' RSpec.feature `Managing immobili`, type: :feature do describe `Creating an entry` do given(:new_page) { Pagine::Immobili::New.new } scenario `with valid values` do new_page.load new_page.field1_field.set `valid` new_page.field2_field.set `valid` ... new_page.fieldn_field.set `valid` new_page.submit! expect(new_page).to have_notice end scenario `with invalid values` do new_page.load new_page.submit! expect(new_page).to have_alert end end end
Nel primo caso settiamo tutti i campi per verificare che il record venga creato. Nel secondo non ne settiamo nessuno per verificare che il record non venga creato.
Arrivati a questo punto, spostiamo nei test dei model tutta la logica di validazione dei campi, facendo utilizzo, ad esempio, di Shoulda Matchers
require 'spec_helper' RSpec.describe Immobile, type: :model do it { is_expected.to validate_presence_of(:mandatory_field) } it { is_expected.to allow_value("a particular value").for(:another_field } end
Con un'impostazione del genere, difficilmente ci sarà da mettere nuovamente mano alle features. Inoltre la validazione di un nuovo campo è ristretta una (o pochissime) righe di codice, rendendo così più semplice mantenere il codice.
Tramite gli shared examples, possiamo astrarre gli scenari, in modo da poterli riutilizzare in tutte le risorse dell'applicazione:
RSpec.shared_example `a resource you can create` do describe `Creating an entry` do scenario `with valid values` do new_page.load fields.each do |field, value| new_page.send("#{field.to_s}_field").set value end new_page.submit! expect(new_page).to have_notice end scenario `with invalid values` do new_page.load new_page.submit! expect(new_page).to have_alert end end end
Ora possiamo scrivere una generica risorsa in questo modo:
require 'rails_helper' RSpec.feature `Managing Immobili`, type: :feature do it_behaves_like `a resource you can create` given(:new_page) { Pagine::Immobili::New.new } given(:fields) do { field1: 'value1', field2: 'value2', field3: 'value3' } end end end
I filtri di ricerca
Ogni index di una data risorsa, dovrebbe contenere un form che permetta di filtrare i suoi record.
Anche in questo caso, l'approccio di creare uno scenario per ogni filtro diventa pesante.
Gli scenari, alla fin fine, sono di solito 3:
- Senza passare alcun parametro, voglio vedere tutti i record (solitamente una index si comporta così di default)
- Passando tutti i parametri, voglio ottenere un particolare record
- Passando tutti i parametri, non voglio ottenere alcun record
describe `When I filter Immobili` do given(:index_page) { Pages::Immobili::Index.new } given(:record) { create(:immobile, :with_all_fields) } before { index_page.load } scenario `withour any search field I find the record` do expect(index_page).to have_record(record) end scenario `with existing values I find the record` do # here i fill the form expect(index_page).to have_record(record) end scenario `with not existing values I do not find any record` do #here I fill the form expect(index_page).to_not have_record(record) end end
Il controller relativo assumerà un aspetto simile a questo:
class ImmobiliController < ApplicationController def index @query = ImmobiliQuery.new(params) @collection = @query.scope respond_with @collection end end
Adesso, sulla falsa riga di quanto fatto con la creazione di un immobile, deleghiamo al query object tutti i metodi scope della nostra risorsa
Si può fare, quindi, tutto in integrazione?
La risposta a questo punto sarebbe si, ma richiederebbe uno sforzo notevole, rendendo il codice noioso da scrivere.
Devo dire però che ho dovuto trascorrere molte e molte ore a scrivere, cancellare, riscrivere il codice, per trovare una soluzione che rendesse quasi divertente il testing.
Se non ci affacciamo mai al mondo di RSpec (o del testing in generale), passeremo molto tempo a pensare a come strutturare il nostro singolo test, continuando a preferire la verifica manuale tramite browser.