In an earlier post, I exposed the fallacy that building automated tests required a huge investment -- unless you think 10% of your time is huge. One of the ways we reduced the time was an investment in test infrastructure. This investment significantly reduced the time and pain in authoring individual tests. A key aspect of this infrastructure was a Domain-specific language (DSL) implemented in Groovy.
Writing a language seems daunting, and it certainly seems difficult to justify. But, I already shared the numbers so we now know that it's not daunting at all. So how did we do it?
Start with dessert
One of my favorite geek terms is syntactic sugar. We chose Groovy for our DSL, since it possesses a good amount of sugar and is Java. By "is" I mean that it compiles directly to Java bytecode can invoke any Java class and be invoked by any Java class. There are several language characteristics of Groovy that make it ideal for developing a DSL:
- Dynamic typing
- Parentheses are not required for method invocations.
- Closures
- Easy dereference of class-level members ( no need for getters or setters ).
- Ability to handle dynamic properties and methods through methodMissing and propertyMissing
- Categories and metaclasses
Now plan the menu
After choosing a basic framework and brushing up on what's possible with Groovy, I began by writing an ideal test. I essentially wrote pseudo-code for the functions I wanted to execute in the least-verbose, easiest-to-read way. I began with simulating sensors transmitting data which is a key function for us. Here's the code for it:
Send_Data( key ) {
Activity "01/22/2008 9:47AM".timestamp, "thunderbird.exe", "Window", "Inbox for attr@6sa.com"
Activity "01/23/2008 9:50AM".timestamp, "Eclipse", "Open File", "/home/todd/src/Foo.groovy"
}
That's it. In just a few very readable lines of code, it's easy to ship data to the server. The code is minimalist -- meaning that each line ( and almost each character ) is directly tied to task. How did this compare to the status quo? Here's a snippet of the same functionality in Java.
List<String> data = new ArrayList<String>();
DateFormat timeFormat = new SimpleDateFormat( "M/d/y h:mma" );
String tstamp = timeFormat.parse( "01/22/2008 9:47AM" ).getTime().toString();
data.add( "Activity" );
data.add( tstamp );
data.add( "thunderbird.exe" );
data.add( "Window" );
data.add( "Inbox for attr@6sa.com" );
List<String> dataList = new ArrayList<String>();
dataList.add( StringListCodec.encode( data ));
// Repeat for the second line of data....
Map<String, String> params = new HashMap<String, String>();
params.put("key", key);
params.put("data", StringListCode.encode(dataList) );
return new DataSender( params ).run();
As you can see the DSL is significantly simpler than required Java code. In Java pre-DSL, sending data requires 4X the number of lines of code. Many of the characters and lines are just cruft required by the Java programming language -- not required by our test.
Putting it all together
The first step in putting this together is making use of Groovy's closure capability specifically as the parameter to a method.
def Send_Data( key, closure ) {
def data = new SensorData().processData( closure );
// Call internal utility to send data.
new DataSender( [ key: key, data: data ] ).run()
}
In Groovy, you can always specify the last argument in a method as a closure ( note: the parameter name doesn't have to be closure ). In this example, the contents of the curly braces are passed as an executable block to the method. The method then sends the closure on to a helper class for processing.
The next class is the code to bundle the data for processing. It essentially takes the parameters and builds up an encoded String for transmission. There is a method per data type, so sending alternate types of data, like Commit, simply requires a line within the Send_Data block with "Commit" and the method parameters.
class SensorData {
/* Method to encode Activity data */
def Activity( tstamp, tool, type, data ) {
dataset << [ "Activity", tstamp, tool, type, data ].encode } // .. There are methods for each type of data ..
def Commit( tstamp, tool, commitTime, filename, authorname, repositoryname, branchno, versionnum, totallines, linesadded, linesdeleted, log ) {
dataset << ["Commit", tstamp, tool, commitTime, filename, authorname, repositoryname, branchno, versionnum, totallines, linesadded, linesdeleted, log].encode }
/* Main Method for processing data. */
def processData( closure ) {
// Tell the closure to run on the current instance.
closure.delegate = this;
// Add the ability to write: [ "this", "is", "a", "string", "list" ].encode
ArrayList.metaClass.getEncode = { ->
StringListCodec.encode( delegate )
}
// Run the closure which in turns invokes the methods for each data type.
closure()
return dataset.encode
}
}
The last piece is the date/time helper. While Java has many powerful date and calendar classes, they are extremely verbose. I used a Groovy Category helper to enable easy date entering. A Category is a class that can augment existing classes within a block. In my example, I augment the String class to support a .timestamp which takes the string and returns a timestamp. This is is quite easy to do:
public class SensorTimeHelper {
public static final String timeFormatStr = "M/d/y h:mma";
public static Date getTime ( String str ) throws Exception {
return new SimpleDateFormat( timeFormatStr ).parse(str);
}
public static Long getTimestamp ( String str ) throws Exception{
return new SimpleDateFormat( timeFormatStr ).parse(str).getTime();
}
}
Just a taste
The reality is that you can design your language as complex as you want. The example I provided is just a taste of what's possible and how you can use it. Ultimately, the goal is to make authoring tests ( using the language ) as easy and readable as possible. I'd encourage organizations to consider implementing their own DSLs -- it's certainly not as huge of an endeavor as it would seem and yields great results.