Test e modelli di dominio: come usare i builder e semplificarsi la vita

Quali connessioni ci sono tra un modello di dominio e il test di un’applicazione?

Il modello di dominio è il cuore di ogni applicazione e strutturarlo bene porta a lavorare meglio, più rapidamente e a creare un prodotto capace di evolvere e facile da manutenere. Farlo però, il modello di dominio, non è facilissimo: spesso è composto da centinaia, se non migliaia, di classi e relazioni. La sua realizzazione è un tema complesso e interessante su cui sicuramente torneremo a scrivere in futuro.

Questa volta vorremmo invece soffermarci su un altro aspetto e cioè la connessione tra modello di dominio e test. Abbiamo già detto in un altro articolo quanto siano importanti i test e che una loro caratteristica è lo scope (la granularità), rispetto alla quale potremmo dividerli in almeno tre categorie: test di unità, test di integrazione, test di sistema. La procedura di base tuttavia non cambia: quale che sia lo scope del test, si dovranno comunque rispettare le seguenti fasi:

  1. Preparare il contesto (istanziare gli oggetti che serviranno durante il test)
  2. Eseguire il test invocando uno o più metodi
  3. Fare asserzioni sui risultati (valore restituito dai metodi, stato degli oggetti)

Il modello di dominio non fa eccezione: essendo parte dell’applicazione dovranno esistere dei test in grado di validarlo, che chiameremo test di unità. Gli oggetti del modello di dominio serviranno a alla prima fase, la preparazione del contesto.

Ricapitoliamo: per controllare il corretto funzionamento di un’applicazione servono i test di integrazione, e scrivere i test di integrazione risulta estremamente faticoso perché si devono mettere in vita i numerosi oggetti che servono a preparare il contesto per i test, soprattutto istanze del modello di dominio.

Per essere ancora più chiari, prendiamo in esame un piccolo modello, tenendo a mente che è solo per fini esplicativi e certamente non è la rappresentazione perfetta della modellazione di questo dominio.

Supponiamo di voler istanziare gli oggetti che descrivono la pizza:

Esempio 1

Unit g = new Unit();
g.setName( "g" );
Unit ml = new Unit();
ml.setName( "ml" );
CompositeFood pizza = new CompositeFood();
pizza.setName( "Pizza" );
SimpleFood flour = new SimpleFood();
flour.setName( "Flour" );
Quantity q1 = new Quantity();
q1.setUnit( g );
q1.setValue( valueOf( 500 ));
Ingredient c1 = new Ingredient();
c1.setFood( flour );
c1.setQuantity( q1 );
pizza.getIngredients().add( c1 );
SimpleFood water = new SimpleFood();
water.setName( "Water");
Quantity q2 = new Quantity();
q2.setUnit( ml );
q2.setValue( valueOf( 300 ));
Ingredient c2 = new Ingredient();
c2.setFood( water );
c2.setQuantity( q2 );
pizza.getIngredients().add( c2 );
SimpleFood tomato = new SimpleFood();
tomato.setName( "Crushed tomotoes" );
Quantity q3 = new Quantity();
q3.setUnit( g );
q3.setValue( valueOf( 500 ));
Ingredient c3 = new Ingredient();
c3.setFood( tomato );
c3.setQuantity( q3 );
pizza.getIngredients().add( c3 );
SimpleFood mozzarella = new SimpleFood();
mozzarella.setName( "Fior di latte mozzarella cheese");
Quantity q4 = new Quantity();
q4.setUnit( g );
q4.setValue( valueOf( 400 ));
Ingredient c4 = new Ingredient();
c4.setFood( mozzarella );
c4.setQuantity( q4 );
pizza.getIngredients().add( c4 );
SimpleFood salt = new SimpleFood();
salt.setName( "Salt");
Quantity q5 = new Quantity();
q5.setUnit( g );
q5.setValue( valueOf( 10 ));
Ingredient c5 = new Ingredient();
c5.setFood( salt );
c5.setQuantity( q5 );
pizza.getIngredients().add( c5 );
SimpleFood yeast = new SimpleFood();
yeast.setName( "Fresh brewer's yeast ");
Quantity q6 = new Quantity();
q6.setUnit( g );
q6.setValue( valueOf( 5 ));
Ingredient c6 = new Ingredient();
c6.setFood( salt );
c6.setQuantity( q6 );
pizza.getIngredients().add( c6 );
SimpleFood oil = new SimpleFood();
oil.setName( "Extra virgin olive oil");
Quantity q7 = new Quantity();
q7.setUnit( g );
q7.setValue( valueOf( 35 ));
Ingredient c7 = new Ingredient();
c7.setFood( oil );
c7.setQuantity( q7 );
pizza.getIngredients().add( c7 );

Si può chiaramente osservare come il codice sia a un tempo strutturalmente semplice (abbiamo istanziato oggetti e assegnato valori ad attributi) ma anche molto lungo e di difficile manutenzione.

Perfino in questo, che è un esempio giocattolo servono molte righe e non risulta chiaro, a colpo d’occhio, la funzione del codice. Se poi ci mettiamo che è pure noioso da scrivere, è facile concludere che la possibilità di infilarci dentro un errore senza accorgersene sia abbastanza alta.

Prima di tutto, proviamo ad adottare un accorgimento utile: è possibile costruire costruttori con parametri o metodi di factory.

Ad esempio, per come abbiamo modellato il domino potremmo introdurre dei metodi di factory in Unit e Quantity. I metodi di factory sono una buona soluzione ma quando abbiamo a che fare con modelli più complessi, il codice risultante resta verboso e la possibilità di errore rimane alta.

In questo caso ci viene in aiuto il pattern Builder (Design Patterns – Elements Of Reusable Object Oriented Software (1995) – Gamma, Helm, Johnson, Vlissides) e i sempre più comuni DSL (Domain Specific Language – Martin Fowler).

Quando si parla di DSL è bene specificare che ne esistono di molti tipi per varie situazioni. Nella nostra quello che sembra più adatto in questo contesto è il tipo Fluent Builder, per le ragioni che seguono:

  1. Il codice di costruzione degli oggetti è scritto con lo stesso linguaggio con cui è scritta l’applicazione.
  2. E’ facile rilevare modifiche nel modello, che si palesano con errori di compilazione, ed è facile rifattorizzare i Fluent Builder per adattarli alle evoluzioni del dominio.
  3. L’IDE mi fornisce suggerimenti mentre scrivo.
  4. E’ possibile rendere espliciti vincoli nella sintassi del DSL, così da rendere proprio impossibile commettere certi sbagli.

I DSL di questo tipo sono molto importanti ma non sono di per sè una novità (vedi DSL fluent builder). Esistono strumenti (talvolta anche le funzioni di generazione di alcuni IDE) che consentono di generare il Builder, con interfaccia fluent, in modo automatico.

Negli esempi che seguiranno i Builder non sono stati generati automaticamente tramite strumenti standard di generazione codice, come Lombok e simili, ma implementati a partire da una libreria di Jaewa che supporta la generazione automatica tramite un AnnotationProcessor. Il motivo è che quelli ottenuti con gli strumenti standard non supportano alcune funzioni importanti che invece abbiamo inserito nelle librerie di Jaewa.

Potremo così esemplificare sia i vantaggi di questo tipo di DSL in generale, sia alcuni temi molto importanti riguardo la scrittura del codice di inizializzazione dei test di integrazione.

Costruiamo un DSL interno implementando CompositeFoodBuilder SimpleFoodBuilder e IngredientBuilder. Vediamo come si presenta il codice per istanziare la pizza usando queste classi.

Esempio 2

Unit g = u( "g" );
Unit ml = u( "ml" );                                    (1)
Unit kcal = u( "kcal" );

CompositeFood pizza = new CompositeFoodBuilder<>()      (2)
   .name( "Pizza" )                                     (3)
   .ingredient()
      .name( "Flour ")                                  (4)
      .q( 500, g )
   .end()

   .ingredient()
      .name( "Water")
      .q( 300, ml )
   .end()

   .ingredient()
      .name( "Crushed tomotoes")
      .q( 500, g )
   .end()

   .ingredient()
      .name( "Fior di latte mozzarella cheese" )
      .q( 400, g )
   .end()

   .ingredient()
      .name( "Salt" )
      .q( 10, g )
   .end()

   .ingredient()
      .name( "Fresh brewer's yeast" )
      .q( 5, g )
   .end()

   .ingredient()
      .name( "Extra virgin olive oil" )
      .q( 35, g )
   .end()

   .calories( 813, kcal )
   .carbohydrates( 156, g )
   .fat( 18, g )
   .proteins( 18, g )

   .getContent();                                           (5)

(1) uso i metodi di factory per costruire le unità di misura
(2) istanzio il builder di cui ho bisogno
(3) chiamo i metodi del builder che mi servono
(4) posso aggiungere ai builder metodi che rendono più semplice
(5) chiamo il metodo per ottenere l’oggetto costruito

Tutto molto più chiaro, giusto? Questa volta il codice mostra in modo molto chiaro che stiamo costruendo l’istanza di un alimento composto da certi ingredienti di cui siamo in facilmente in grado di riconoscere tipo e quantità.

Rispetto all’esempio precedente abbiamo anche potuto aggiungere i valori nutritivi della pizza in modo semplice e compatto.

Anzi: potremmo affermare tranquillamente che questo codice (per quanto ancora perfettibile) potrebbe essere interpretato anche da un non addetto ai lavori.

Ma torniamo un’altra volta alla pizza (frase che nessuno di noi avrebbe mai pensato di scrivere su questo blog) per vedere con un altro esempio come si possa usare lo stesso DSL per descriverla usando costruttori diversi offerti dai builder.

Esempio 3

SimpleFood flour = new SimpleFoodBuilder<>()
   .name( "Flour")                                     (1)
   .getContent();

CompositeFood pizza = new CompositeFoodBuilder<>()
   .name( "Pizza" )
   .ingredient()
      .food( flour )                                   (2)
      .q( 500, g )
   .end()

   .ingredient()
      .simpleFood()
         .name( "Water")                               (3)
      .end()
      .q( 300, ml )
   .end()

   .ingredient()
      .name( "Crushed tomotoes")                       (4)
      .q( 500, g )
   .end()

   .ingredient()
      .name( "Fior di latte mozzarella cheese" )
      .q( 400, g )
   .end()

   .ingredient()
      .name( "Salt" )
      .q( 10, g )
   .end()

   .ingredient()
      .name( "Fresh brewer's yeast" )
      .q( 5, g )
   .end()

   .ingredient()
      .name( "Extra virgin olive oil" )
      .q( 35, g )
   .end()

   .calories( 813, kcal )
   .carbohydrates( 156, g )
   .fat( 18, g )
   .proteins( 18, g )

   .getContent();

(1) uso di SimpleFood builder per istanziare l’oggetto “farina”
(2) per costruire un ingrediente della pizza posso usare un oggetto precedentemete
istanziato
(3) in alternativa ad usare un oggetto esterno posso definire “inline” ciò di cui ho bisogno
(4) gli approcci descritti in 2 e 3 sono quelli standard e più comuni, ma estendere il DSL è pittosto semplice.

Frammento implementazione

class IngredientBuilder<R> ...

    ...

    public IngredientBuilder<R> name( String name ) {
        simpleFood().name( name );
        return this;
    }

}

L’esempio 3 ci mette di fronte a un problema che i DSL così come li abbiamo realizzati fino a qui non potrebbero risolvere.

Se volessi creare l’istanza di più cibi, infatti, sarebbe molto probabile che alcuni ingredienti elementari (SimpleFood) siano condivisi da più ricette. Questo ci porterebbe a dover introdurre un numero sempre crescente di variabili di appoggio per poter riusare la stessa istanza in punti diversi del codice.

Proviamo a vederlo in pratica:

Esempio 4

...

Food sugar = new SimpleFoodBuilder<>()
    .name( "Sugar" )                                    (1)
    .getContent();

CompositeFood cake = new CompositeFoodBuilder<>()
    .name( "Tiramisù" )

    .ingredient()
        .food( sugar )                                  (2)
        .q( 300, g)
    .end()

    ...

    .ingredient()
        .name( "Coffee" )
        .q( 200, ml )
    .end()

    .ingredient()
        .compositeFood()
            .name( "Savoiardi")                         (3)

            .ingredient()
                .name( "Flour")
                .q( 500, g )
            .end()

            .ingredient()
                .food( sugar )
            .end()
        .end()
    .end()

    .getContent();

(1) costruisco istanza di sugar è la appoggi in una variabile perché penso di volerla riusare
(2) uso l’istanza precedentemente costruita
(3) caso che non avevamo visto.

Un CompositeFood, qui definito inline, può essere un ingrediente di base di un’altra ricetta, ad esempio i Savoiardi sono un ingrediente di base del Tiramisù (sappiamo che stiamo tutti pensando alla stessa polemica, ma non la tratteremo in questo articolo), ma anche della zuppa inglese.

Il rischio è di arrivare velocemente ad un codice molto simile a quello di Esempio 1, ricadendo in quelle caratteristiche da cui stavamo cercando di liberarci.

Per risolvere questo problema proviamo a realizzare un BuilderContext. Non lo troverete nel libro dei DSL, è una cosa che abbiamo ideato a Jaewa.

Nello specifico proviamo a creare un’istanza di builder context che risponda a queste condizioni:

  • Il builder context mette a disposizione i metodi dei builder (che ha senso chiamare a livello radice)
  • Ogni buider può assegnare un’etichetta (hook) agli oggetti
  • Lo scope degli hook è il context builder
  • Il builder context riesce a risolvere gli hook (dato un hook trova l’oggetto corrispondente)
  • I builder possono usare gli hook per stabilire relazioni
  • BuilderContext è un tipo particolare di Builder

Ora vediamo un esempio:

Esempio 5

List<Food> foods = new FoodBuilderContext()                     (1)
    .simpleFood()
        .hook( "SGR" )
        .name( "Sugar")                                         (2)
    .end()

    .compositeFood()
        .name( "Tiramisù" )

        .ingredient()
            .food( hook( "SGR" ) )                              (3)
            .q( 300, g)
        .end()

        .ingredient()
            .name( "Mascarpone" )
            .q( 500, g)
        .end()

        .ingredient()
            .name( "Coffee" )
            .q( 200, ml )
        .end()

        .ingredient()
            .compositeFood()
                .hook( "SVRD" )                                 (4)
                .name( "Savoiardi")

                .ingredient()
                    .name( "Flour")
                    .q( 500, g )
                .end()

                .ingredient()
                    .food( hook( "SGR" ))
                .end()
            .end()
        .end()
    .end()

    .compositeFood()
        .name( "Zuppa inglese" )

        .ingredient()
            .food( hook( "SVRD" ))                              (5)
        .end()

        .ingredient()
            .name( "Crema")
        .end()

        .ingredient()
            .name( "Alchermes" )
        .end()

    .end()

    .getContent();

(1) istanzio il context builder per il mio dominio applicativo (ho intenzione di costruire più oggetti Food)
(2) se lo desidero posso assegnare un hook ad un oggetto. In questo caso ho assegnato l’hook “SGR” allo zucchero
(3) in ogni momento posso usare un hook per stabilire una relazione
(4) posso assegnare un hook anche agli oggetti costruiti inline (in questo modo posso evitare il codice “frammentato” delle veriabili d’appoggio)
(5) uso l’hook definito, non è necessario che l’hook sia definito prima. La definizione di zuppa inglese poteva stare all’inizio e tutto andava bene lo stesso (gli hook vengono realmente risolti sono all’invocazione del metodo getContent())

Gli hook sono un metodo alternativo alle variabili d’appoggio per ottenere un riferimento agli oggetti in corso di creazione, e da soli non consentono di risolvere un altro tipo di problema: supponiamo di voler costruire un oggetto con 100 ingredienti per un particolare test di integrazione.

Il codice di inizializzazione potrebbe essere questo:

Esempio 6

Unit g = new Unit();
CompositeFoodBuilder<?> cfb = new CompositeFoodBuilder<>();  (1)
cfb.name( "Complex food" );                                  (2)
for ( int i = 1; i <= 100; i++ ) {
   cfb.ingredient()
      .name( "Ingredient " + i )                             (3)
      .q( i, g );
}
CompositeFood food = cfb.getContent();

(1) sono costretto ad usare una variabile d’appoggio per avere un riferimento al builder
(2) invoco i metodi come fosse una query api e non sfrutto l’interfaccia di tipo fluent
(3) il codice di controllo “interrompe” il flusso di costruzione dell’oggetto

Per risolvere questo tipo di problema abbiamo dotato i builder di un metodo execute che prende in ingresso un metodo da applicare:

public interface BuilderMethod<B extends Builder<?,B,?>> {
   void build( B builder );
}

Usando le lambda di java8 il codice risultante diventa:

Esempio 7

CompositeFood food = new CompositeFoodBuilder<>()
    .name( "Complex food" )

    .execute( cfb -> {
        for ( int i = 1; i <= 100; i++ ) {
            cfb.ingredient()
                .name( "Ingredient " + i )
                .q( i, g );

        }
    })

   .getContent();

In questo caso il flusso del DSL non viene interrotto e posso ottenere un rifermento ad ogni builder in modo semplice.

Esempio 8

CompositeFood food = new CompositeFoodBuilder<>()
    .name( "Complex food" )

    .execute( this::fakeIngredients )                   (1)

    .ingredient()
        .name( "Special ingredient" )                   (2)
    .end()

    .getContent();

(1) si riesce a rendere leggibile il codice senza interrompere il flusso
(2) si possono mischiare “estensioni” e codice standard in modo natuale

Per rendere ancora più modulare la definizione di oggetti abbiamo aggiunto la possibilità di includere uno o più BuilderContext da parte di un BuilderContext.

Ad esempio posso usare un builder context per descrivere tutti i frutti, un altro per tutte le verdure o tutte le ricette di pesce. Ogni volta che ne definisco uno, scelgo cosa importare:

Esempio 9

FoodBuilderContext vegetables = new FoodBuilderContext();       (1)

vegetables                                                      (2)
   .simpleFood()
      .hook( "TMT")
      .name( "Tomato" )
   .end()
   .compositeFood()
      .name( "Onions" )
   .end();

List<Food> foods = new FoodBuilderContext()
   .include( vegetables )                                       (3)
   .compositeFood()
      .name( "Pizza" )

       .ingredient()
          .name( "Flour ")
          .q( 500, g )
       .end()

       .ingredient()
          .food( hook( "TMT" ))
       .end()
   .end()

   .getContent();

(1) definisco il BuilderContext per contenere una porizione riusabile di oggetti
(2) uso i metodi normali dei builder per definre cosa contiene il builder context (NON invoco il metodo getContent())
(3) quando definisco il mio BuilderContext scelgo di includere anche le definizioni contentute in altri

Esiste un costrutto Java che permette di rendere più elegante e compatta la scrittura di 1 e 2:

Esempio 10

FoodBuilderContext vegetables = new FoodBuilderContext() {{       (1)
   simpleFood()
      .hook( "TMT")
      .name( "Tomato" )
   .end()
   .compositeFood()
      .name( "Onions" )
   .end();
}};

List<Food> foods = new FoodBuilderContext()
   .include( vegetables )
   .compositeFood()
      .name( "Pizza" )

      .ingredient()
         .name( "Flour ")
         .q( 500, g )
      .end()

      .ingredient()
         .food( hook( "TMT" ))
      .end()

   .end()

   .getContent();

(1) il costrutto (java standard) con doppia graffa aperta corrisonde a definire una inner class anonima che estende FoodBuilderContext e ridefinisce il solo costruttore (col codice per l’appunto racchiuso tra le graffe)

Conclusioni:

Abbiamo esplorato alcuni dei problemi più comuni di quando si creano istanze di grafi di oggetti, e abbiamo proposto le soluzioni a nostro avviso più efficaci per farlo nel modo più semplice e meno orientato possibile.

In particolare i nostri ragionamenti si incentrano sui DSL interni perché li riteniamo essere un argomento molto importante, con un alto grado di impatto sulla possibilità di condurre i test di integrazione che, ripetiamolo, sono essenziali a ridurre drasticamente il numero di bug e muoversi correttamente verso il continous deployment.