This exercise covers the following topics.
all_output
methodsend
method to support Convention over ConfigurationWrite a failing test.
When testing structured output like XML and JSON, it is best to find a parsing tool rather than compare lengthy strings. This ensures that newlines, whitespace, comments, and other insignificant text do not cause tests to fail unnecessarily.
Insert the following feature into a new file features/html.feature
:
Feature: Format statistics for HTML If I compute statistics I want to easily convert them to HTML Scenario: Get HTML Given a file named "infile" with: """ 1,2,3,4 """ When I run `bundle exec statowl -f html all` interactively And I pipe in the file "infile" Then the output is XML And XPath "/html/head/title" includes "Statistics" And XPath "/html/body/p" includes "Mean: 2.5" And XPath "/html/body/p" includes "Median: 2.5" And XPath "/html/body/p" includes "Variance: 1.25" And XPath "/html/body/p" includes "Standard Deviation: 1.118033988749895"
This feature adds a new options flag -f html
to the command line app.
The executable file bin/statowl
currently ignores this flag, so the
all
command's output will still be plain text.
Execute rake features
and copy the code snippets for the
undefined steps to a new file features/step_definitions/command_steps.rb
.
Your steps should now be defined, but pending or skipped.
Implement the first step in features/step_definitions/command_steps.rb
by parsing the results from Aruba's all_output
method:
require 'rexml/document' include REXML Then(/^the output is XML$/) do @document = Document.new(all_output) end ...
This step passes, even though all_output
returns text that is not valid XML.
Implement the second step definition by getting the text contained in the XPath argument:
Then(/^XPath "(.*?)" includes "(.*?)"$/) do |x_path, expected_text| found = false @document.elements.each(x_path) do |element| if element.text == expected_text found = true end end expect(found).to eq(true) end
This step fails.
Modify bin/statowl
to read the -f html
flag and
return valid XHTML:
...desc 'Describe some flag here' default_value 'the default' arg_name 'The name of the argument' flag [:f,:flagname]desc 'Format the results' default_value 'text' arg_name 'format' flag [:f,:format], :desc => 'text or html' ... desc 'Computes all statistics' command :all do |c| c.action do |global_options,options,args| if global_options[:f] == "html" puts """ <!DOCTYPE html> <html> <head> <title>Statistics</title> </head> <body> <p>Mean: 2.5</p> <p>Median: 2.5</p> <p>Variance: 1.25</p> <p>Standard Deviation: 1.118033988749895</p> </body> </html> """ else puts array_from_STDIN.statistics_as_text end end end ...
Your Cucumber scenario should now pass. Check out the new formatting options:
bundle exec bin/statowl help
Then try them for yourself:
echo "1,2,3" | bundle exec bin/statowl -f html all echo "1,2,3" | bundle exec bin/statowl --format=html all
Obviously this trivial solution will not meet project requirements, but your tests pass, so it is time to think about refactoring.
There is a lot of refactoring to do before implementing a useful solution.
First, you are once again violating the Single Responsibility Principle,
because the executable bin/statowl
fails to delegate business logic.
Write a new statistics_as_html
method for the extensions of the Array
class in lib/statowl/extensions.rb
:
... def statistics_as_html """ <!DOCTYPE html> <html> <head> <title>Statistics</title> </head> <body> <p>Mean: 2.5</p> <p>Median: 2.5</p> <p>Variance: 1.25</p> <p>Standard Deviation: 1.118033988749895</p> </body> </html> """ end ...
Now you can delegate HTML formatting and eliminate it from bin/statowl
:
... desc 'Computes all statistics' command :all do |c| c.action do |global_options,options,args| if global_options[:f] == "html" puts array_from_STDIN.statistics_as_html else puts array_from_STDIN.statistics_as_text end end end ...
Still green? Just as it makes sense to use a parser to read structured text like XML and JSON, it is reasonable to use a builder to write it. Ruby builders are powerful, but they can be confusing. Fortunately, you have automated tests to check that your builder is performing as expected.
Replace your
hard-coded string in lib/statowl/extensions.rb
with a hard-coded, but much more elegant, builder:
require 'builder' ... def statistics_as_html title = "Statistics" @builder ||= Builder::XmlMarkup.new(:indent => 2) @builder.declare! :DOCTYPE, :html @builder.html do @builder.head{ @builder.title title } @builder.body do @builder.h1 title @builder.p "Mean: 2.5" @builder.p "Median: 2.5" @builder.p "Variance: 1.25" @builder.p "Standard Deviation: 1.118033988749895" end end end ...
Insert a dependency on the Builder gem in statowl.gemspec
:
s.add_runtime_dependency('builder')
Still green?
Now replace the hard-coded <p>
tags with real numbers:
... def statistics_as_html title = "Statistics" @builder ||= Builder::XmlMarkup.new(:indent => 2) @builder.declare! :DOCTYPE, :html @builder.html do @builder.head{ @builder.title title } @builder.body do @builder.h1 title statistics.each do |key, value| @builder.p "#{key.to_s.split("_").map{ |word| word.capitalize}.join(" ")}: #{value}" end end end end ...
Do all scenarios pass? Compare the text and HTML formats:
echo '1,2,3,4' | bundle exec bin/statowl all echo '1,2,3,4' | bundle exec bin/statowl -f html all
Take a look at your for the statistics_as_text
and statistics_as_html
methods in lib/statowl/extensions.rb
.
What do these lines do?
... statistics.map do |key, value| "#{key.to_s.split('_').map{ |word| word.capitalize }.join(' ')}: #{value}\n" end ... statistics.each do |key, value| @builder.p "#{key.to_s.split("_").map{ |word| word.capitalize}.join(" ")}: #{value}" end ...
You wrote these recently and yet your comprehension of their purpose is probably already a little fuzzy. Remember being tempted to use Extract Method refactoring on this? It was a good idea then and it is even more appropriate now. These lines do not read like you would describe them. Put the logic into a separate method and make it private, which tells the reader of your code that its implementation is not important:
... def statistics_as_text statistics.map do |key, value| "#{replace_underscores_and_capitalize(key)}: #{value}\n" end end def statistics_as_html title = "Statistics" @builder ||= Builder::XmlMarkup.new(:indent => 2) @builder.declare! :DOCTYPE, :html @builder.html do @builder.head{ @builder.title title } @builder.body do @builder.h1 title statistics.each do |key, value| @builder.p "#{replace_underscores_and_capitalize(key)}: #{value}" end end end end private def replace_underscores_and_capitalize(key) key.to_s.split("_").map{ |word| word.capitalize}.join(" ") end ...
Still Green?
There are several styles of XML, all of which can be implemented using the Builder gem. Tags can be used as keys for key-value pairs, for example:
<statowl> <array>1.0,2.0,3.0,4.0,5.0</array> <mean>3.0</mean> <median>3.0</median> <variance>2.0</variance> <standard_deviation>1.4142135623730951</standard_deviation> </statowl>
This is implemented in Builder by passing the attribute name and its value
to the tag!
method:
@builder.array map{ |number| number.to_s }.join(',') statistics.each do |key, value| @builder.tag!(key, value) end
Fixed tags have a more standardized appearance but are verbose and require a lot of typing:
<statowl> <array> <value>1.0,2.0,3.0,4.0,5.0</value> </array> <statistics> <statistic> <name>mean</name> <value>3.0</value> </statistic> <statistic> <name>median</name> <value>3.0</value> </statistic> <statistic> <name>variance</name> <value>2.0</value> </statistic> <statistic> <name>standard_deviation</name> <value>1.4142135623730951</value> </statistic> </statistics> </statowl>
The style is implemented by passing the tag contents to a method corresponding to the tag name:
@builder.array do @builder.value map{ |number| number.to_s }.join(',') end @builder.statistics do statistics.each do |key, value| @builder.statistics do @builder.name key.to_s @builder.value value.to_s end end end
Putting data into attributes is less wordy but more difficult to read:
<statowl> <data array="1.0,2.0,3.0,4.0,5.0"/> <statistics> <statistic mean="3.0"/> <statistic median="3.0"/> <statistic variance="2.0"/> <statistic standard_deviation="1.4142135623730951"/> </statistics> </statowl>
This is implemented by passing a hash to a non-existing method whose name is the tag name:
@builder.data 'array' => map{ |number| number.to_s }.join(',') @builder.statistics do statistics.each do |key, value| @builder.statistic key => value end end
Whichever style you prefer, Builder makes it easy to format the XML and to insert processing instructions:
def statistics_as_xml @builder ||= Builder::XmlMarkup.new(:indent => 2) @builder.instruct! @builder.statowl do # Your XML style goes here end end
The instruct!
method writes
<?xml version="1.0" encoding="UTF-8"?>
Write a new feature called features/xml.feature
that tests for the XML style that you prefer.
The production code should be able to handle this:
echo '1,2,3,4' | bundle exec bin/statowl -f xml all
You will need to write the new Cucumber feature,
add step definitions as necessary,
handle the -f xml
and --format=xml
flags
in your GLI-based executable,
and write an statistics_as_xml
method for your Ruby extensions to the Array
class.
Tip: For the XML<statowl> <statistics> <statistic mean="3.0"/> </statistics> </statowl>this will setfound
equal totrue
:found = false @document.elements.each("statowl/statistics/statistic") do |element| if element.attributes["mean"] == "3.0" found = true end end
Consider what it would take to create a JSON view:
statistics_as_json
to lib/statowl/extensions.rb
bin/statowl
-f html
flag causes a call to the statistics_as_html
method.
The -f xml
flag causes a call to the statistics_as_xml
method.
What do you think the -f json
flag should call?
It is not statistics_as_fortran!
For comparison, consider Java's HttpServlet
class.
When you extend the class to create something like WeatherServlet
then you still need to configure the web.xml
file to point the
URL /weather
to the new servlet.
Configuration is required because you might want to point a different URL
to the servlet, perhaps something like /dogbowl
.
Or maybe you want WeatherServlet
to handle /weather/temperatures.xml
but want DogbowlServlet
to handle weather/temperatures.json
.
The class requires both Java code and XML configuration to make anything work.
There is no default behavior. Argh!
Your implementation of the all
command
in bin/statowl
probably looks similar to this:
... desc 'Computes all statistics' command :all do |c| c.action do |global_options,options,args| if global_options[:f] == "html" puts array_from_STDIN.statistics_as_html elsif global_options[:f] == "xml" puts array_from_STDIN.statistics_as_xml else puts array_from_STDIN.statistics_as_text end end end ...
Eliminate the duplication by parsing the flag and assembling the method name:
... desc 'Computes all statistics' command :all do |c| c.action do |global_options,options,args| method_name = "statistics_as_#{global_options[:f] || 'text'}" puts array_from_STDIN.send(method_name) end end ...
Are your Cucumber scenarios still green?
Now you are ready to add an statistics_as_json
method to lib/statowl/extensions.rb
.
The default command line behavior is to call this method in response to a -f json
or
--format=json
flag.
The executable file bin/statowl
does not need to be configured
to add an additional format as long as you adhere to the convention
that you have established.
Add JSON output to your app. Make it include the original array like this:
{ "array": [1.0,2.0,3.0,4.0], "mean": 2.5, "median": 2.5, "variance": 1.25, "standard_deviation": 1.118033988749895 }
Hash
is easily converted to and from a JSON string.
Moreover, the Hash#eql?
method performs a deep comparison,
which is handy for a simple test.
The methods Hash#==
or Hash#eql
only make shallow checks to ensure that each hash has the same number of keys
and that each key-value pair is equal according to ==
.
Here is an example of a deep comparison:
require 'json' my_json = the_original_hash.to_json the_same_hash = JSON.parse(my_json) expect(the_same_hash.eql?(the_origial_hash)).to eql(true)
statowl.gemspec
.merge
method of Ruby's Hash
class:
def as_hash { :array => self }.merge(statistics) end
In this exercise you parsed XML and searched its content using XPath.
You configured GLI to parse command line flags.
You also gained a lot of experience with Builder
and saw how it creates three different styles of XML.
You implemented convention over configuration using Ruby's send
method.
Finally, you wrote and parsed JSON with amazingly little code.
(How many lines of production code did it take you to implement the statistics_as_json
method?
Can you do it in less?)
In the next exercise you will learn about code quality metrics.
Copyright © 2005-2022 Amp Books LLC All Rights Reserved