How to test a XML builder view
Written by matt on October 31st, 2007
As a good Rubyist, I do TDD and even BDD.
Since I've started using RSpec I've started writing tests against my views. RSpec makes things really easy and I've been enjoying testing my views.
I'm not the only one having fun, check this great post from Mr Planet Argon aka Robby Russel
Recently I was working on implementing some Sexy Charts and I was using a XML builder to create an XML view of for a controller. Since I wanted to be a good Rails Ninja and obey the BDD rules, I figured I needed to test my XML view. Making sure that the nodes and the attributes were properly created. Turned out that is wasn't too hard, there was many options but none were very well documented so I decided to write this quick tutorial.
UPDATE 31 Oct 2007: After a comment from Josh Knowles, I updated the tests to test with have_tags (built in RSpec) and hpricot.
Hpricot
hpricot is a awesome HTML parser perfect for screen scraping. But wait, there's more to this awesome library, hpricot can also parse XML.
If you watched the excellent RSpec peepcasts you probably noticed that topfunky aka Geoffrey Grosenbach uses hpricot to test a remote API.
In our case, we'll use hpricot to test that our generated XML follows our expectations.
XML Builder + RSpec
Let's write a quick test to make sure our controller uses a XML builder view:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
describe AveragesController, "handling GET /averages.xml" do before do Average.stub!(:find).and_return(@average) end def do_get @request.env["HTTP_ACCEPT"] = "application/xml" get :index end it "should render the action using the XML builder" do do_get response.should render_template('averages/index.xml.builder') end end |
To make this example pass, we need to modify our rspec generated controller.
1 2 3 4 5 6 7 8 |
def index @averages = Average.find(:all) respond_to do |format| format.html # index.html.erb format.xml { render :action => "index.xml.builder", :layout => false } end end |
(Please note that I'm using Rails 2.0 and that's why I'm not using a .rxml view)
Here is what our XML file should end up looking like:
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 |
<?xml version="1.0" encoding="UTF-8"?> <chart> <series> <value xid="0">January</value> <value xid="1">February</value> <value xid="2">March</value> <value xid="3">April</value> <value xid="4">May</value> <value xid="5">June</value> <value xid="6">July</value> <value xid="7">August</value> <value xid="8">September</value> <value xid="9">October</value> <value xid="10">November</value> <value xid="11">December</value> </series> <graphs> <graph fill_alpha="50" color="#FF0000" fill_color="#CC0000" title="high"> <value xid="0">65.1</value> <value xid="1">65.7</value> <value xid="2">64.9</value> <value xid="3">66.7</value> <value xid="4">67.1</value> <value xid="5">69.3</value> <value xid="6">73.0</value> <value xid="7">74.8</value> <value xid="8">75.4</value> <value xid="9">73.4</value> <value xid="10">68.9</value> <value xid="11">65.3</value> </graph> <graph fill_alpha="50" color="#0000CC" fill_color="#0000CC" title="low"> <value xid="0">48.9</value> <value xid="1">50.7</value> <value xid="2">52.9</value> <value xid="3">55.6</value> <value xid="4">59.2</value> <value xid="5">61.9</value> <value xid="6">65.7</value> <value xid="7">67.3</value> <value xid="8">65.7</value> <value xid="9">61.0</value> <value xid="10">54.0</value> <value xid="11">48.7</value> </graph> </graphs> </chart> |
Let's write some tests to make sure our view is ok:
index.xml.builder_spec.rb
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 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
require File.dirname(__FILE__) + '/../../spec_helper' require 'hpricot' describe "/averages/index.xml.builder" do include AveragesHelper before do average_1 = mock_model(Average) average_1.stub!(:month).and_return("January") average_1.stub!(:high).and_return("74.5") average_1.stub!(:low).and_return("61.5") average_2 = mock_model(Average) average_2.stub!(:month).and_return("February") average_2.stub!(:high).and_return("82.5") average_2.stub!(:low).and_return("71.5") assigns[:averages] = [average_1, average_2] end it "should render the months in the series" do render "/averages/index.xml.builder" response.should have_tag("value", 'January') response.should have_tag("value", 'February') # Same thing but with Hpricot doc = Hpricot.XML(response.body.to_s) (doc/:value).first.inner_html.should == 'January' (doc/:value)[1].inner_html.should == 'February' end it "should set the xid attributes for the series" do render "/averages/index.xml.builder" response.should have_tag("value[xid=0]:first-child") response.should have_tag("value[xid=1]:last-child") # Same thing but with Hpricot doc = Hpricot.XML(response.body.to_s) (doc/:value).first["xid"].should == '0' (doc/:value).last["xid"].should == '1' end it "should have 2 graphs and they should have a title" do render "/averages/index.xml.builder" response.should have_tag("graph[title=high]:first-child") response.should have_tag("graph[title=low]:last-child") # Same thing but with Hpricot doc = Hpricot.XML(response.body.to_s) (doc/:graph).size.should == 2 (doc/:graph).first["title"].should == 'high' (doc/:graph).last["title"].should == 'low' end it "should have a color set by graph" do render "/averages/index.xml.builder" response.should have_tag("graph[color]:first-child") response.should have_tag("graph[color]:last-child") response.should have_tag("graph[fill_color]:last-child") response.should have_tag("graph[fill_alpha]:last-child") # Same thing but with Hpricot doc = Hpricot.XML(response.body.to_s) (doc/:graph).first["color"].should_not be_nil (doc/:graph).last["color"].should_not be_nil (doc/:graph).last["fill_color"].should_not be_nil (doc/:graph).last["fill_alpha"].should_not be_nil end it "should have an xid for each graph value" do render "/averages/index.xml.builder" response.should have_tag("graph > value[xid=0]:first-child") # Same thing but with Hpricot doc = Hpricot.XML(response.body.to_s) (doc/:graph/:value).first["xid"].should == "0" end it "should have the high average as values of the first graph" do render "/averages/index.xml.builder" response.should have_tag("graph > value:first-child", "74.5") # Same thing but with Hpricot doc = Hpricot.XML(response.body.to_s) (doc/:graph/:value).first.inner_html.should == "74.5" end end |
The first thing you must do (after installing the hpricot gem) is to require hpricot in your test:
require 'hpricot' |
Now that hpricot is created we can use it to parse the response and check against our expectations.
(we create mock objects to pass to the view so we know exactly what to expect and we separate Model/Controller/Views tests)
To check against our response we have to use hpricot parser syntax. It might look at bit funny at first, but believe me it's really easy once you get it.
But first, let's parse the view:
1 2 3 4 |
# Render the mocked up data using the xml view render "/averages/index.xml.builder" # Load and parse the view response body: doc = Hpricot.XML(response.body.to_s) |
Let's look at the first test:
1 2 3 4 5 6 |
it "should render the months in the series" do render "/averages/index.xml.builder" doc = Hpricot.XML(response.body.to_s) (doc/:value).first.inner_html.should == 'January' (doc/:value)[1].inner_html.should == 'February' end |
(doc/:value) returns all the value nodes, we take the first one and extract its content. We expect that it would match the name of the month for the first average.
Let's now look at another test:
1 2 3 4 5 6 7 |
it "should have 2 graphs and they should have a title" do render "/averages/index.xml.builder" doc = Hpricot.XML(response.body.to_s) (doc/:graph).size.should == 2 (doc/:graph).first["title"].should == 'high' (doc/:graph).last["title"].should == 'low' end |
The thing to look at here is the fact that we are checking on the node's attribute "title". Really simple syntax and clean test, isn't it?
Finally let's look at the last example:
1 2 3 4 5 |
it "should have the high average as values of the first graph" do render "/averages/index.xml.builder" doc = Hpricot.XML(response.body.to_s) (doc/:graph/:value).first.inner_html.should == "74.5" end |
We are checking that the content of the first value node nested inside a graph node is equal to 74.5 which is the high average for the first month.
In practice, you probably won't write all these tests at once, but anyway, let's look at our XML builder which will make all these tests pass:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
xml.instruct! :xml, :version=>"1.0", :encoding=>"UTF-8" xml.chart do xml.series do @averages.each_with_index do |average, index| xml.value average.month, :xid => index end end xml.graphs do xml.graph :title => 'high', :color => "#FF0000", :fill_alpha => "50", :fill_color => "#CC0000" do @averages.each_with_index do |average, index| xml.value average.high, :xid => index end end xml.graph :title => 'low', :color => "#0000CC", :fill_alpha => "50", :fill_color => "#0000CC" do @averages.each_with_index do |average, index| xml.value average.low, :xid => index end end end end |
Hpricot is a really nice tool which can make your BDD life much easier. And even if you don't do BDD/TDD yet, it's a great way to verify that any XML data you receive/generate is valid.
Happy testing
Comments
-
Great article. How about making your life easier with a helper?
def hpricot yield Hpricot.XML(response.body.to_s) endit "should have the high average as values of the first graph" do render "/averages/index.xml.builder" hpricot do |doc| (doc/:graph/:value).first.inner_html.should == "74.5" end endAlso, in places where you use @innerhtml@ you should maybe use @innertext@ if you want to test text content of a specific element, not markup.
-
I hate Textile, it always mangles what I wanted to say :(
-
Cool use of hpricot, but why not just use the built in have_tag matcher? There is nothing unique about the xml that couldn't be tested the same as html.
-
@mislav you are replacing doc = Hpricot.XML(response.body.to_s) by a bloc using a helper. I like the idea of using a helper, but why not doing something like:
def parse_xml_response Hpricot.XML(response.body.to_s) endthen use it as:
it "should have the high average as values of the first graph" do render "/averages/index.xml.builder" doc = parsexmlresponse (doc/:graph/:value).first.inner_html.should == "74.5" end
Didn't try it but looks better than a bloc to me. What do you think? Did you have a specific reason to use a bloc?
Good point about inner_text!
-
stupid textle!
Here is the snippet of code which got messed up:
it "should have the high average as values of the first graph" do render "/averages/index.xml.builder" doc = parse_xml_response (doc/:graph/:value).first.inner_html.should == "74.5" end@joshknowles
it "should render the months in the series" do render "/averages/index.xml.builder" response.should have_tag("value", 'February') response.should have_tag("value", 'November') doc = Hpricot.XML(response.body.to_s) (doc/:value).first.inner_html.should == 'February' (doc/:value)[1].inner_html.should == 'November' endworks well but I can't find a way to check a node's attribute:
it "should set the xid attributes for the series" do render "/averages/index.xml.builder" doc = Hpricot.XML(response.body.to_s) (doc/:value).first["xid"].should == '0' (doc/:value).last["xid"].should == '1' end-Matt
-
after chatting offline with Josh, I found a solution:
it "should set the xid attributes for the series" do render "/averages/index.xml.builder" response.should have_tag("value[xid=0]:first-child") response.should have_tag("value[xid=1]:last-child") # same as: doc = Hpricot.XML(response.body.to_s) (doc/:value).first["xid"].should == '0' (doc/:value).last["xid"].should == '1'end
havetag wraps assertselect, check out all the cool options you get with assert_select:
http://cheat.errtheblog.com/s/assert_select/
Thanks Josh!
-
Just FYI, your xml feed refreshes several times a day. All articles are marked as new. NetNewsWire tells me I have tons of new stuff to read, but it's a lie!
-
@josh make sure you are using the feedburner feed: http://feeds.feedburner.com/railsontherun for some reason the atom feed provided by mephisto resets itself.


