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
Tempo di lettura: 6 minuti

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 se poi te ne penti?

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.

Shared Examples

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:

  1. Senza passare alcun parametro, voglio vedere tutti i record (solitamente una index si comporta così di default)
  2. Passando tutti i parametri, voglio ottenere un particolare record
  3. 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.

Hai trovato questo post interessante?Scopri chi siamo

Made with Middleman and DatoCMS, our CMS for static websites