Test engineers most definitely should have Ruby as part of their tool set. This post is not so much to showcase my clear bias for Ruby when writing test solutions. This post is rather to showcase with some evidence why I have chosen Ruby as my tool of choice. My reason is mainly because you can do so many cool things with Ruby that other languages struggle with, particularly if your emphasis is on creating a DSL.
I came across the video Python vs. Ruby: A Battle to the Death and it reaffirmed much of what I felt I already knew. Specifically, the video makes it very clear that Python cannot — and will likely never be able to do — the cool things that Ruby can do, particularly when crafting test tool solutions. And if that’s the case for Python, it’s doubly so for a top-heavy language like Java in my opinion.
For me it all really comes down to how easy it is to create high-level and low-level DSLs that allow you to create humanizing and fluent interfaces. And Ruby makes that incredibly easy. (I would argue that this holds even when you use dynamic alternatives to Java and C#, like Groovy and Boo. That’s an opinion and not one I’m setting out to prove in this article.)
Rather than belabor this post with a bunch of tedious point-by-point comparisons, I’d rather just show how easy it was for me to build an RSpec clone in a very few lines of code. Let’s first start with what I wanted to do. If you want to play along, create a file called test_rtest.rb and put the following in it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
require './rtest' test "boolean logic" do condition "true should be true" do true.should == true end condition "false should not be true" do false.should == false end condition "an expression can be true" do (5 == 5).should == true end condition "an expression can be false" do (5 == 6).should == false end end |
Keep in mind here that I’m not going to be going into detail of what all of the code in this article does in all particulars. This is meant to be an illustrative post rather than a training one. I want that above code to be executable. With that code notice that I’m using ‘test’ and ‘condition’ as high-level keywords. These do not exist in the Ruby language. So how am I going to do that? Well, you’ll notice that require statement at the top. So create a file called rtest.rb and put the following in it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
module Kernel def test(statement, &block) tests = DSL.new.parse(statement, block) tests.execute end end class Object def should self end end class DSL def initialize @tests = {} end def parse(statement, block) self.instance_eval(&block) Runner.new(statement, @tests) end def condition(statement, &block) @tests[statement] = block end end class Runner def initialize(statement, tests) @statement = statement @tests = tests @success_count = 0 @failure_count = 0 end def execute puts "\n#{@statement}" @tests.each_pair do |name, block| print " - #{name}" result = self.instance_eval(&block) result ? @success_count += 1 : @failure_count += 1 puts result ? " SUCCESS" : " FAILURE" end summary end def summary puts "\n#{@tests.keys.size} conditions run, #{@success_count} passed, #{@failure_count} failed" end def should_not_be_even(value) !value.even? end end |
Notice that I made test() in the Kernel module so that it is available anywhere. This works in Ruby because Object — which is the top level object for all other objects — includes the Kernel module. What about condition? Well, that can be defined on the internal DSL object. What this is doing is allowing me to create “blocks” of logic called “test” and those blocks will support a language of their own. One element of that language is yet another block called “condition”.
In the above logic, a parse method is called that, within the DSL, starts up a Runner instance. This Runner instance is used to iterate over the test blocks, effectively calling the logic of those blocks. Effectively all of the condition blocks that have been stored are evaluated in the context of the Runner instance.
Something else to note is that the Runner instance can also contain methods that can be used as a language within the condition blocks. So, for example, note the method called “should_not_be_even”. What this means is that part of your DSL can be defined as methods on the runtime mechanism. So that means I could do stuff like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
test "arithmetic logic" do condition "addition" do should_not_be_even(2 + 1) end condition "subtraction" do (2 - 2).should == 0 end condition "multiplication" do value = 3 * 1 should_not_be_even(value) end end |
Also notice how I’m using “should” all over the place but I’m not using RSpec at all, which usually provides should a method. If you look at the rtest.rb file you’ll see that I’ve added a should method to the high-level Object that all object instances in Ruby inherit from. This allows me to call should against anything that Ruby considers an object.
What you should take away from this is that I just created an RSpec clone (admittedly a very bare bones one) in very few lines of code. And I did this while allowing the language constructs to be readable and understandable within a given domain: that of writing test cases with test conditions. This is what the video above regarding Python was saying would be somewhat difficult to do without some of Ruby’s adaptability and flexibility. That certainly holds true for languages like C# and Java.
None of this means that test engineers should not learn various languages and leverage the strengths of each when it is beneficial to do so. But, for me, this example shows that test engineers can write very cool, very expressive, and very humanizing interfaces when they leverage what Ruby will provide.