Imparare a scrivere utilità DSL per i test unitari e sono preoccupato per l'estensibilità

2

Sto cercando di semplificare i nostri test unitari con DSL scritte a mano. Finora ho DSL che supportano gli sviluppatori attraverso l'elaborazione di un servizio dopo aver impostato tutte le precondizioni e la costruzione di un monster object che ha un enorme costruttore e setter in conflitto. Il mostro è una delle molte precondizioni dell'elaborazione del servizio e viene costruito a mano a mano a mano che gli sviluppatori scrivono test unitari per nuovi servizi nel nostro sistema. Quindi varrebbe la pena fare uno sforzo considerevole per semplificare la costruzione. Sono tentato di guardare le persone copiare e incollare il "sistema di supporto vitale" dal test dell'unità al test dell'unità.

Sto facendo un nuovo corso separato, uno che ha il compito di costruire la classe mostro e uno che li guida attraverso i passaggi per elaborare il servizio. Questi saranno usati da molti sviluppatori. Ciò significa che una volta utilizzato ampiamente i cambiamenti saranno difficili. Ecco perché sono preoccupato per l'estensibilità.

Piuttosto che un semplice fluent interface builder (dove ogni metodo restituisce un this ed è sempre disponibile) Ho deciso di utilizzare un DSL interno che restituisce una classe nidificata diversa. Questa è una scelta molto potente. Permette di limitare i metodi disponibili in qualsiasi fase solo a quelli validi. Permette anche a me di cambiare il tipo di ritorno di un metodo basato sulle "precedenti" scelte dello stato in cui inserire la DSL.

In breve, il modo in cui funziona è che il primo metodo restituisce la prima classe nidificata che ha il prossimo metodo valido che non avrebbe potuto essere chiamato prima. Ciò limita le scelte che l'intelisense offrirà rendendo più semplice la scrittura del codice di chiamata della DSL.

//Final build() method doesn't exist on PersonBuilder.  Its on a nested class.
public class PersonBuilder {
    public GotRequired doRequired() {
        return new GotRequired();
    }

    //One of many nested classes 
    public class GotRequired {
        public GotFirstName addFirstName(String firstName) {
            mFirstName = firstName;
            return new GotFirstName();
        }
    }

    public class GotFirstName {
        public GotMiddleName addMiddleName(String middleName) {
            mMiddleName = middleName;
            return new GotMiddleName();
        }
    }

    ...

}

Questo può essere usato per assicurare che i metodi vengano chiamati in un certo ordine e assicurare che siano state soddisfatte le condizioni preliminari. Le classi interne possono essere pensate come stati in cui si trova il DSL. È possibile supportare più di un percorso di modifica dello stato. Essere semplicemente in uno stato può dirti quali stati sono venuti prima fino a quando nessuno stato unisce due percorsi di stato in uno. Tutto ciò significa solo che se fai attenzione puoi sapere che ogni metodo necessario è stato chiamato prima di costruire o restituire un risultato.

Un avvertimento equo, quello che segue è il piatto di caldaia verboso java al meglio. L'elenco completo è qui: Codice del generatore di mostri .

Il codice DSL dei mostri ha un aspetto simile al seguente:

public class PersonBuilder {

// -- Required -- //

private String   mFirstName;
private String   mMiddleName;
...

// -- Required Alternatives -- //  

//Not both; not neither
private int      mBeersToday;     //One or
private String   mHowDrunk;       //the other


// -- What all the fuss is about -- //

private Person   mPerson;

/** Call each required method in order offered.*/
public GotRequired doRequired() {
    return new GotRequired();
}

public class GotRequired {
    public GotFirstName addFirstName(String firstName) {
        mFirstName = firstName;
        return new GotFirstName();
    }
}

public class GotFirstName {
    public GotMiddleName addMiddleName(String middleName) {
        mMiddleName = middleName;
        return new GotMiddleName();
    }
}

...

/*
   Client code example


    .doRequiredAlternatives()   //Either x or y. a, b, or c. etc.
        .addBeersToday(3)       //Now can't call addHowDrunk("Hammered");
        .addFavoriteBeer("Duff")//Now can’t call addJobTitle("Safety Inspector");  
   .addBuild();                 //Calls different constructors based on alternatives

*/
    /** Now the interesting forking bit */
    public class GotRequiredAlternatives {
        public GotPerson addBeersToday(int beersToday){
            mBeersToday = beersToday;

            //Got enough for constructor
            mPerson = new Person(
                    mFirstName,
                    mMiddleName, 
                    mLastName,

                    mNickName,                        
                    mMaidenName, 
                    mEyeColor,   
                    mHairColor,  
                    mDateOfBirth,
                    mBeersToday, //GotBeersToday
                    mAliases);

            return new GotPerson();
        }        

        public GotPerson addHowDrunk(String howDrunk){
            mHowDrunk = howDrunk;

            //Got enough for constructor
            mPerson = new Person(
                    mFirstName,
                    mMiddleName, 
                    mLastName,

                    mNickName,                        
                    mMaidenName, 
                    mEyeColor,   
                    mHairColor,  
                    mDateOfBirth,
                    mHowDrunk, //GotHowDrunk
                    mAliases);

            return new GotPerson();
        }        
    }

    //Could have created GotHowDrunk and GotBeersToday 
    //but we're past the constructor choice so we can 
    //forget the path that brought us here.

    public class GotPerson {
        /** Build Person object leaving optional fields set to default values */
        public Person doBuild() {
            return mPerson;
        }
        /** Call any or none of these optional fields */
        public GotOptional doOptional() {
            return new GotOptional();        
        }
    }

    //Since person is not immutable the only thing gained here is confidence that these were set according to persons whacky rules
    class GotOptional {

        /** Build Person object */
        public Person doBuild() {
            return mPerson;
        }

        public GotOptionalAlternatives doOptionalAlternatives() {
            return new GotOptionalAlternatives();
        }

        public GotOptional addClothing(String clothing) {
            mPerson.setClothing(clothing);
            return this; 
        }

        public GotOptional addTatoo(String tattoo) {
            mPerson.setTattoo(tattoo);
            return this; 
        }

        //Add any number of setters that have good default values and do not conflict with each other

    }

    //Ideally person wouldn't allow this anyway but person is set in stone so at least this provides a safer interface
    public class GotOptionalAlternatives {

        /** Build Person object */
        public Person doBuild() {            
            return mPerson;
        }

        //Optional but conflicting setters. Might never be called.  Must never be called together.


        public GotFavoriteBeer addFavoriteBeer(String favoriteBeer){
            //mFavoriteBeer = favoriteBeer;  //TODO remove
            mPerson.setFavoriteBeer(favoriteBeer); //GotFavoriteBeer

            return new GotFavoriteBeer();
        }

        public GotJobTitle addJobTitle(String jobTitle){
            //mJobTitle = jobTitle;  //TODO remove
            mPerson.setJobTitle(jobTitle); //GotJobTitle

            return new GotJobTitle();
        }
    }

    //These are not strictly needed.  They are like one-statement {}'s after an 'if'.  Simply there if more gets added.

    public class GotFavoriteBeer {
        /** Build Person object */
        public Person doBuild() {            
            return mPerson;
        }
    }

    public class GotJobTitle {
        /** Build Person object */
        public Person doBuild() {            
            return mPerson;
        }
    }
}

Vi risparmierò il codice del servizio che utilizza la stessa tecnica di classe annidata per mescolare metodi opzionali e metodi richiesti. È anche in grado di restituire diversi tipi di risultati in base alle precedenti scelte di metodi alternativi, poiché anche se il metodo result () ha lo stesso nome e params, viene fuori da una classe interna diversa.

Nonostante la quantità irreale di battitura richiesta per creare questi DSL ho trovato i risultati molto potenti. Tuttavia, mi aspetto che le persone vorranno aggiungere nuove funzionalità, nuove precondizioni per l'elaborazione del servizio. Se utilizzo questo stile c'è qualche speranza di riuso o sarebbe meglio scrivere semplicemente un DSL completamente nuovo ogni volta che qualcosa deve cambiare?

Perché una volta che funziona e viene utilizzato NON voglio tornare alle classi DSL esistenti e fare casino con loro.

Se riesci a vedere altri problemi, fammi sapere. Spero in una peer review ma apprezzo qualsiasi feedback.

    
posta candied_orange 02.08.2014 - 17:11
fonte

1 risposta

2

Ecco la cosa con i test unitari. Vuoi che siano davvero facili da scrivere. Ogni volta che si scrive un test viene reso più difficile, i test non verranno scritti e gli errori saranno permessi di vivere. Quindi immaginiamo che sto scrivendo un test:

void testCanDrive() {
    Person person = new PersonBuilder()
        .doRequired()
        .addFirstName("John")
        .addMiddleName("Q")
        .addLastName("Smith")
        .addNickName("Johnny")
        .addMaidenName("Public")
        .addEyeColor("Blue")
        .addHairColor("Blond")
        .addDateOfBirth(new Date(1990, 1, 14))
        .addAliases()
        .doRequiredAlternatives()
        .addBeersToday(1)
        .doBuild();
    assertFalse(person.canDrive());
}

Ecco la cosa, ho dovuto scrivere una quantità pazzesca di codice per creare l'oggetto persona, e non mi interessava gran parte di quei dettagli. Gli unici fattori che sembrerebbero rilevanti se tu possa guidare sarebbero la tua data di nascita e le birre che hai avuto. Costringere il test writer a specificare tutti gli altri dettagli rende più difficile seguire il test e scoraggiarmi dallo scrivere ulteriori test.

Ora la soluzione è probabilmente molto meglio dello status quo, ma fa ancora schifo. Ti suggerirei di voler utilizzare una tipica interfaccia builder che imposta valori predefiniti per tutto. Ogni test imposterà solo quei dettagli pertinenti al test stesso, per tutto il resto i valori di default dovrebbero andare bene.

void testCanDrive() {
    Person person = new PersonBuilder()
        .setDateOfBirth(new Date(1990, 1, 14))
        .setBeersToday(1)
        .doBuild();
    assertFalse(person.canDrive());
}
    
risposta data 06.08.2014 - 17:10
fonte

Leggi altre domande sui tag