2

I'm trying to simplify our unit tests with hand written DSL's. So far I have DSL's that walk developers through processing a service after setting up all preconditions and the construction of an monster object that has a huge constructor and setters that conflict. The monster is one of the many preconditions of processing the service and they constructed by hand over and over as developers write unit tests for new services in our system. So it would be worth putting considerable effort into making constructing them easier. I'm tried of watching people copy and paste the "life support system" from unit test to unit test.

I'm making a separate new classes, one who's job it is to build the monster class and one that walks them through the steps to process the service. These will be used by many developers. This means once it's used widely changes will be difficult. That's why I'm worried about extendability.

Rather than a simple fluent interface builder (where every method returns a this and is always available) I've decided to use an internal DSL that returns a different nested class. This is a very powerful choice. It lets me limit the methods available at any one step to only the valid ones. It also lets me change what the return type of a method is based on the "state" previous choices put the DSL into.

Briefly, the way it works is the first method returns the first nested class that has the next valid method that could not have been called before. This limits the choices intelisense will offer making writing the DSL's calling code easier.

//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();
        }
    }

    ...

}

This can be used to ensure methods are called in some order and ensure that preconditions have been met. The inner classes can be thought of as states the DSL is in. More than one state change path can be supported. Simply being in a state can tell you what states came before so long as no state merges two state paths into one. This all just means if you're careful you can know every needed method was called before building or returning a result.

Fair warning, what follows is java verbose boiler plate at it's finest. The full listing is here: Monster builder code.

The monsters DSL code looks something like this:

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;
        }
    }
}

I'll spare you the service code that uses the same nested class technique to mix optional methods and required methods. It is also able to return different result types based on previous alternate method choices since even though the result() method has the same name and params it's coming off a different inner class.

Despite the unreal amount of typing required to create these DSLs I've found the results to be very powerful. However, I expect people will want new features added, new preconditions for processing the service. If I use this style is there any hope of reuse or would it be better to just write a whole new DSL each time something needs to change?

Cause once it's working and being used I do NOT want to dive back into the existing DSL classes and mess with them.

If you can see other issues please let me know. I'm hoping for a peer review but I appreciate any feedback.

  • 3
    Somehow "and then you have two problems" keeps popping into my mind. – Telastyn Aug 2 '14 at 15:56
  • I suggest that codereview.stackexchange.com would be a better site for this question. – Winston Ewert Aug 2 '14 at 16:44
  • Seconding what @Telastyn said. Java isn't a good language for DSL's, and when your unit testing DSL needs testing, will you build another DSL for those tests? – Doval Aug 6 '14 at 17:58
  • @Doval using something other than java isn't exactly a choice for me but I'd like to hear what language does internal DSL's better. And I always test the test, DSL or not. I just don't test the test as part of the nightly build. – candied_orange Aug 7 '14 at 0:46
  • @CandiedOrange Any ML family language (Standard ML, OCaml, F#, Haskell). Probably Scala. Lisp, obviously, because of macros. I've heard Ruby works too. Java doesn't have tuples/records, algebraic data types or syntax for lists, and defining even the most trivial value type requires 20+ lines when you factor in overriding equals and hashCode and the fact that you need at twice as many lines as you have fields (one for the instance variable, another for the assignment in the constructor, a third if you choose to use getters.) – Doval Aug 7 '14 at 11:26
2

Here's the thing with unit tests. You want them to be really easy to write. Anytime writing a test is made more difficult, it will result in tests not being written, and bugs will be allowed to live. So let's imagine I'm writing a 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());
}

Here's the thing, I had to write a crazy amount of code to create the person object, and I didn't really care about most of those details. The only factors that would seem relevant to whether you can drive would be your date of birth and the beers you've had. Forcing the test writer to specify all of that other details makes it harder to follow the test and discourages me from writing further tests.

Now your solution is probably way better than the status quo, but it still sucks. I would suggest that you really want to use a typical builder interface that set default values for everything. Each test will only set those details pertinent to the test itself, for everything else the defaults should be fine.

void testCanDrive() {
    Person person = new PersonBuilder()
        .setDateOfBirth(new Date(1990, 1, 14))
        .setBeersToday(1)
        .doBuild();
    assertFalse(person.canDrive());
}
  • This point is well taken. Certainly, when reasonable defaults are available, forcing the value to be explicitly set is a bad design. But implementing that is an already solved problem. Heck bean style setters give you that. I'm trying to achieve a design for the cases that can't be defaulted. That's why I want required steps, required alternatives, optional alternatives, as well as optional steps. Help me out? – candied_orange Aug 7 '14 at 1:00
  • @CandiedOrange, clearly your person example doesn't look much like the actual case. Can we see something more like your actually doing? – Winston Ewert Aug 7 '14 at 3:31

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.