I just started a new project for a web site (non-work related) I am very excited about. One of the things I have to do is make a call to the Yahoo Maps API to Geocode some information. I wanted to wrap the functionality into a class to make it easier to call from my app. I decided to spike a solution to see what it would take. So my first pass looked like:
require 'open-uri'
require 'rexml/document'
include REXML
class LatLngDecoder
#...
def retrieve
open("http://yahooapi?city=#{@city}&state;=#{@state}") do |response|
@status = response.status[STATUS_RESPONSE_CODE_POSITION]
if @status == "200"
xml = ''
response.each_line do |line|
xml += line
end
doc = Document.new(xmlString)
@latitude = XPath.first(doc, "//ResultSet/Result/Latitude/text()")
@longitude = XPath.first(doc, "//ResultSet/Result/Longitude/text()")
end
end
end
end
Once I had it working, I tossed it out and decided to test drive it. The first piece I knew would be easy was the XPath expressions. I’m using the excellent REXML, and started by writing the following tests and making them pass:
require 'test/unit'
require 'LatLngDecoder'
class LatLngDecoderTest < Test::Unit::TestCase
def setup
@latLng = LatLngDecoder.new("Test", "FL")
@xmlString = "
end
def test_fills_lat
@latLng.getLatLngFromXml(@xmlString)
assert_equal(38.95, @latLng.latitude)
end
def test_fills_lng
@latLng.getLatLngFromXml(@xmlString)
assert_equal(-92.33, @latLng.longitude)
end
end
To pass it, I just took the piece from the spike and put it in it’s own method. Unfortunately, that left me with the API call and processing the return element, which looked like:
open("http://yahooapi?city=#{@city}&state;=#{@state}") do |response|
@status = response.status[STATUS_RESPONSE_CODE_POSITION]
if @status == "200"
xml = ''
response.each_line do |line|
xml += line
end
end
end
from the spike. I knew I needed a way to test that I was processing the response back from the api call correctly, without having to actually call it. Especially because I knew I would be writing tests for the status.
So I broke open my Programming Ruby book, but couldn’t find what type of object the open-uri’s open
call passed to the block. Then I dug up the docs for open-uri, and found that:
OpenURI::OpenRead#open returns an IO like object if block is not given. Otherwise it yields the IO object and return the value of the block. The IO object is extended with OpenURI::Meta.
Aha! So I pulled up irb, and found out that the open
call returned a StringIO
object. Further, from the docs, it looks like open-uri mixes in OpenURI::Meta. Knowing that, I was able to write my next test:
def test_get_xml_from_io
mockIO = MockStringIO.new(@xmlString)
mockIO.status = ["200", "Success"]
@latLng.process_response(mockIO)
assert_equal("200", @latLng.status)
assert_equal(38.95, @latLng.latitude)
assert_equal(-92.33, @latLng.longitude)
end
So my test wanted a method which got passed an IO object it could then pull out the methods from. Creating the MockStringIO object was amazingly simple. How simple? Here is the entire class (don’t blink):
require 'open-uri'
class MockStringIO < StringIO
include OpenURI::Meta
end
Yep! That’s it. With that, I was able to get the class down to:
def retrieve
open("http://yahooapi?city=#{@city}&state;=#{@state}") do |response|
process_response(response)
end
end
def process_response(response)
@status = response.status[STATUS_RESPONSE_CODE_POSITION]
if @status == "200"
xml = ''
response.each_line do |line|
xml += line
end
getLatLngFromXml(xml)
end
end
def getLatLngFromXml(xmlString)
doc = Document.new(xmlString)
@latitude = XPath.first(doc, "//ResultSet/Result/Latitude/text()")
@longitude = XPath.first(doc, "//ResultSet/Result/Longitude/text()")
end
So still not perfect (I couldn’t figure out how to test drive the actual call without either doing a form of D/I or writing an acceptance test), but I was pretty happy with the results. And being able to mock the IO object was just an absolute joy.
I’m looking forward to getting the new project online. It’s not anything big, but I think it will have some cool features that I am pumped about.
Follow-up: I found that I had a bug in the program anyway, and it was in the untested part of the code (imagine that!). The correct way to expand variables is “#{variable}” not “${variable}” which was just a typo on my part, but something I had no tests to catch.
I caught it by doing some manual testing, so I extracted out the creation of the URL into a method which I could then test that the variables were being created correctly. Another great example of where we shouldn’t write code without a failing test.
I think you just awoken my interest in ruby again … I had to do the same thing for a friend but using php and suffice to say I accomplished the same thing with 200% more bloat. ;)
Thanks James! That’s one of the things I really love about Ruby – it stays quite out of your way and really lets you build stuff quickly.
I put together a rough draft of some of the functionality of Friday, and was able to get it up and running within 2 hours. It’s a great language! ;)
One simple troubleshooting method I use is to call the “class” method on objects that appear to be returning a type I am not expecting. Just a simple “puts object.class” can save time going through docs!