tag:www.rhnh.net,2008:/rack
Rack - Xavier Shay's Blog
2009-11-18T19:25:58Z
Enki
Xavier Shay
notreal@rhnh.net
tag:www.rhnh.net,2008:Post/807
2009-11-18T19:25:00Z
2009-11-18T19:25:58Z
Full stack testing rack applications
<p>Herein is described a method for full stack testing <a href="http://getcloudkit.com/">CloudKit</a> apps. The same techniques could easily be applied to other rack web application or framework, which is pretty much all the ruby ones these days (<a href="http://rubyonrails.org/">rails</a>, <a href="http://www.sinatrarb.com/">sinatra</a>, <a href="http://github.com/hassox/pancake">pancake</a>, etc…) This method is ideal for non-html services. For <span class="caps">HTML</span> you’re probably better off just using webrat/selenium.</p>
There are two external services that make up our stack:
<ul>
<li>CloudKit application</li>
<li>OpenID server</li>
</ul>
<p>Both of these are rack applications, so we can start them up using the same method in our spec helper.</p><table class="CodeRay"><tr>
<td class="line_numbers" title="click to toggle" onclick="with (this.firstChild.style) { display = (display == '') ? 'none' : '' }"><pre>1<tt>
</tt>2<tt>
</tt>3<tt>
</tt>4<tt>
</tt>5<tt>
</tt>6<tt>
</tt>7<tt>
</tt>8<tt>
</tt>9<tt>
</tt><strong>10</strong><tt>
</tt>11<tt>
</tt>12<tt>
</tt>13<tt>
</tt>14<tt>
</tt>15<tt>
</tt>16<tt>
</tt>17<tt>
</tt>18<tt>
</tt>19<tt>
</tt><strong>20</strong><tt>
</tt>21<tt>
</tt>22<tt>
</tt>23<tt>
</tt>24<tt>
</tt>25<tt>
</tt>26<tt>
</tt>27<tt>
</tt></pre></td>
<td class="code"><pre ondblclick="with (this.style) { overflow = (overflow == 'auto' || overflow == '') ? 'visible' : 'auto' }">require <span class="s"><span class="dl">'</span><span class="k">spec</span><span class="dl">'</span></span><tt>
</tt>require <span class="s"><span class="dl">'</span><span class="k">pathname</span><span class="dl">'</span></span><tt>
</tt>require Pathname(<span class="pc">__FILE__</span>).dirname + <span class="s"><span class="dl">'</span><span class="k">support/application_server</span><span class="dl">'</span></span><tt>
</tt>require Pathname(<span class="pc">__FILE__</span>).dirname + <span class="s"><span class="dl">'</span><span class="k">support/tcp_socket</span><span class="dl">'</span></span><tt>
</tt><tt>
</tt><span class="co">TEST_PORTS</span> = {<tt>
</tt> <span class="sy">:app</span> => <span class="i">9293</span>,<tt>
</tt> <span class="sy">:openid</span> => <span class="i">9294</span><tt>
</tt>}<tt>
</tt><tt>
</tt><span class="gv">$servers</span> = <span class="pc">nil</span><tt>
</tt><span class="co">Spec</span>::<span class="co">Runner</span>.configure <span class="r">do</span> |config|<tt>
</tt> config.before(<span class="sy">:all</span>) <span class="r">do</span><tt>
</tt> <span class="gv">$servers</span> ||= <span class="co">Support</span>::<span class="co">ApplicationServer</span>.multi_boot(<tt>
</tt> {<tt>
</tt> <span class="sy">:config</span> => <span class="co">File</span>.expand_path(<span class="co">Dir</span>.pwd + <span class="s"><span class="dl">'</span><span class="k">/config.ru</span><span class="dl">'</span></span>),<tt>
</tt> <span class="sy">:port</span> => <span class="co">TEST_PORTS</span>[<span class="sy">:app</span>],<tt>
</tt> <span class="sy">:daemonize</span> => <span class="pc">true</span><tt>
</tt> },<tt>
</tt> {<tt>
</tt> <span class="sy">:config</span> => <span class="co">File</span>.expand_path(<span class="co">Dir</span>.pwd + <span class="s"><span class="dl">'</span><span class="k">/spec/support/rack_my_id.rb</span><span class="dl">'</span></span>),<tt>
</tt> <span class="sy">:port</span> => <span class="co">TEST_PORTS</span>[<span class="sy">:openid</span>],<tt>
</tt> <span class="sy">:daemonize</span> => <span class="pc">true</span><tt>
</tt> }<tt>
</tt> )<tt>
</tt> <span class="r">end</span><tt>
</tt><span class="r">end</span><tt>
</tt></pre></td>
</tr></table>
You need some support files – the first two are based heavily on code from webrat, the latter is a dead simple OpenID server that I wrote specifically for testing:
<ul>
<li><a href="http://gist.github.com/237652">application_server.rb</a></li>
<li><a href="http://gist.github.com/237652">tcp_socker.rb</a></li>
<li><a href="http://github.com/xaviershay/rack-my-id">rack_my_id.rb</a></li>
</ul>
<p>A global variable is required here, since <code>before(:all)</code> in rspec runs once per describe block, rather than once per test run. An <code>at_exit</code> hook is used to shutdown the services after the test run.</p>
<p>You need a way of resetting your data between test runs. The default <code>CloudKit::MemoryTable</code> does not provide a mechanism for this – any deleted resource will exist in the version history of that resource (and will respond with a 410 rather than 404). By subclassing <code>MemoryTable</code>, we can provide a <code>purge</code> method that does what we need:</p><table class="CodeRay"><tr>
<td class="line_numbers" title="click to toggle" onclick="with (this.firstChild.style) { display = (display == '') ? 'none' : '' }"><pre>1<tt>
</tt>2<tt>
</tt>3<tt>
</tt>4<tt>
</tt>5<tt>
</tt>6<tt>
</tt>7<tt>
</tt>8<tt>
</tt>9<tt>
</tt><strong>10</strong><tt>
</tt>11<tt>
</tt>12<tt>
</tt>13<tt>
</tt>14<tt>
</tt>15<tt>
</tt>16<tt>
</tt>17<tt>
</tt>18<tt>
</tt></pre></td>
<td class="code"><pre ondblclick="with (this.style) { overflow = (overflow == 'auto' || overflow == '') ? 'visible' : 'auto' }"><span class="c"># A custom storage adapter that allows a total purge of a collection</span><tt>
</tt><span class="c"># This is handy in test mode to clear out data between specs</span><tt>
</tt><span class="r">class</span> <span class="cl">PurgeableTable</span> < <span class="co">CloudKit</span>::<span class="co">MemoryTable</span><tt>
</tt> <span class="c"># Remove all resources in a collection.</span><tt>
</tt> <span class="c"># Unlike a normal delete, which versions the resource (and sets up a 410 response),</span><tt>
</tt> <span class="c"># this method removes all trace of the resource (it will 404).</span><tt>
</tt> <span class="c">#</span><tt>
</tt> <span class="c"># Example:</span><tt>
</tt> <span class="c"># CloudKit.setup_storage_adapter(adapter = PurgeableTable.new)</span><tt>
</tt> <span class="c"># adapter.purge('/items')</span><tt>
</tt> <span class="r">def</span> <span class="fu">purge</span>(collection)<tt>
</tt> query {|q|<tt>
</tt> q.add_condition(<span class="s"><span class="dl">'</span><span class="k">collection_reference</span><span class="dl">'</span></span>, <span class="sy">:eql</span>, collection)<tt>
</tt> }.each <span class="r">do</span> |item|<tt>
</tt> <span class="iv">@hash</span>.delete(<span class="iv">@keys</span>.delete(item[<span class="sy">:pk</span>]))<tt>
</tt> <span class="r">end</span><tt>
</tt> <span class="r">end</span><tt>
</tt><span class="r">end</span><tt>
</tt></pre></td>
</tr></table>
<p>Since we’ll be testing the CloudKit app from a separate process, we also need a way of triggering a purge. An easy way is some custom rack middleware that provides a <span class="caps">URL</span> we can hit to reset the app. Clearly, we only want to enable this in test mode.</p><table class="CodeRay"><tr>
<td class="line_numbers" title="click to toggle" onclick="with (this.firstChild.style) { display = (display == '') ? 'none' : '' }"><pre>1<tt>
</tt>2<tt>
</tt>3<tt>
</tt>4<tt>
</tt>5<tt>
</tt>6<tt>
</tt>7<tt>
</tt>8<tt>
</tt>9<tt>
</tt><strong>10</strong><tt>
</tt>11<tt>
</tt>12<tt>
</tt>13<tt>
</tt>14<tt>
</tt>15<tt>
</tt>16<tt>
</tt></pre></td>
<td class="code"><pre ondblclick="with (this.style) { overflow = (overflow == 'auto' || overflow == '') ? 'visible' : 'auto' }"><span class="r">class</span> <span class="cl">ResetApp</span><tt>
</tt> <span class="r">def</span> <span class="fu">initialize</span>(app, options = {})<tt>
</tt> <span class="iv">@app</span> = app<tt>
</tt> <span class="iv">@options</span> = options<tt>
</tt> <span class="r">end</span><tt>
</tt><tt>
</tt> <span class="r">def</span> <span class="fu">call</span>(env)<tt>
</tt> request = <span class="co">Rack</span>::<span class="co">Request</span>.new(env)<tt>
</tt> <span class="r">if</span> request.path == <span class="s"><span class="dl">'</span><span class="k">/test_reset</span><span class="dl">'</span></span> && request.request_method == <span class="s"><span class="dl">'</span><span class="k">POST</span><span class="dl">'</span></span><tt>
</tt> <span class="iv">@options</span>[<span class="sy">:adapter</span>].purge(<span class="s"><span class="dl">'</span><span class="k">/items</span><span class="dl">'</span></span>)<tt>
</tt> <span class="r">return</span> <span class="co">Rack</span>::<span class="co">Response</span>.new([], <span class="i">200</span>).finish<tt>
</tt> <span class="r">else</span><tt>
</tt> <span class="iv">@app</span>.call(env)<tt>
</tt> <span class="r">end</span><tt>
</tt> <span class="r">end</span><tt>
</tt><span class="r">end</span><tt>
</tt></pre></td>
</tr></table>
<table class="CodeRay"><tr>
<td class="line_numbers" title="click to toggle" onclick="with (this.firstChild.style) { display = (display == '') ? 'none' : '' }"><pre>1<tt>
</tt>2<tt>
</tt>3<tt>
</tt>4<tt>
</tt>5<tt>
</tt>6<tt>
</tt></pre></td>
<td class="code"><pre ondblclick="with (this.style) { overflow = (overflow == 'auto' || overflow == '') ? 'visible' : 'auto' }"><span class="c"># config.ru</span><tt>
</tt><span class="co">CloudKit</span>.setup_storage_adapter(adapter = <span class="co">PurgeableTable</span>.new)<tt>
</tt><tt>
</tt><span class="r">if</span> <span class="co">ENV</span>[<span class="s"><span class="dl">"</span><span class="k">RACK_ENV</span><span class="dl">"</span></span>] == <span class="s"><span class="dl">'</span><span class="k">test</span><span class="dl">'</span></span><tt>
</tt> use <span class="co">ResetApp</span>, <span class="sy">:adapter</span> => adapter<tt>
</tt><span class="r">end</span><tt>
</tt></pre></td>
</tr></table>
<p>Now all the infrastructure is set up, we can test the CloudKit app using familiar ruby <span class="caps">HTTP</span> libraries:</p><table class="CodeRay"><tr>
<td class="line_numbers" title="click to toggle" onclick="with (this.firstChild.style) { display = (display == '') ? 'none' : '' }"><pre>1<tt>
</tt>2<tt>
</tt>3<tt>
</tt>4<tt>
</tt>5<tt>
</tt>6<tt>
</tt>7<tt>
</tt>8<tt>
</tt>9<tt>
</tt><strong>10</strong><tt>
</tt>11<tt>
</tt>12<tt>
</tt>13<tt>
</tt>14<tt>
</tt>15<tt>
</tt>16<tt>
</tt>17<tt>
</tt>18<tt>
</tt>19<tt>
</tt><strong>20</strong><tt>
</tt>21<tt>
</tt>22<tt>
</tt>23<tt>
</tt>24<tt>
</tt>25<tt>
</tt>26<tt>
</tt>27<tt>
</tt>28<tt>
</tt>29<tt>
</tt><strong>30</strong><tt>
</tt>31<tt>
</tt>32<tt>
</tt>33<tt>
</tt>34<tt>
</tt>35<tt>
</tt>36<tt>
</tt>37<tt>
</tt>38<tt>
</tt>39<tt>
</tt></pre></td>
<td class="code"><pre ondblclick="with (this.style) { overflow = (overflow == 'auto' || overflow == '') ? 'visible' : 'auto' }">require <span class="s"><span class="dl">'</span><span class="k">httparty</span><span class="dl">'</span></span><tt>
</tt>require <span class="s"><span class="dl">'</span><span class="k">mechanize</span><span class="dl">'</span></span><tt>
</tt>require <span class="s"><span class="dl">'</span><span class="k">json</span><span class="dl">'</span></span><tt>
</tt>require <span class="s"><span class="dl">'</span><span class="k">oauth</span><span class="dl">'</span></span><tt>
</tt><tt>
</tt>describe <span class="s"><span class="dl">'</span><span class="k">OAuth + OpendID</span><span class="dl">'</span></span> <span class="r">do</span><tt>
</tt> include <span class="co">HTTParty</span><tt>
</tt> base_uri <span class="s"><span class="dl">"</span><span class="k">localhost:</span><span class="il"><span class="idl">#{</span><span class="co">TEST_PORTS</span>[<span class="sy">:app</span>]<span class="idl">}</span></span><span class="dl">"</span></span><tt>
</tt><tt>
</tt> before(<span class="sy">:each</span>) <span class="r">do</span><tt>
</tt> <span class="co">HTTParty</span>.post(<span class="s"><span class="dl">"</span><span class="k">/test_reset</span><span class="dl">"</span></span>).code.should == <span class="i">200</span><tt>
</tt> <span class="r">end</span><tt>
</tt><tt>
</tt> specify <span class="s"><span class="dl">'</span><span class="k">Registering for an oauth token</span><span class="dl">'</span></span> <span class="r">do</span><tt>
</tt> <span class="iv">@consumer</span> = <span class="co">OAuth</span>::<span class="co">Consumer</span>.new(<span class="s"><span class="dl">'</span><span class="k">cloudkitconsumer</span><span class="dl">'</span></span>,<span class="s"><span class="dl">'</span><span class="dl">'</span></span>,<tt>
</tt> <span class="sy">:site</span> => <span class="s"><span class="dl">"</span><span class="k">http://localhost:</span><span class="il"><span class="idl">#{</span><span class="co">TEST_PORTS</span>[<span class="sy">:app</span>]<span class="idl">}</span></span><span class="dl">"</span></span>,<tt>
</tt> <span class="sy">:authorize_path</span> => <span class="s"><span class="dl">"</span><span class="k">/oauth/authorization</span><span class="dl">"</span></span>,<tt>
</tt> <span class="sy">:access_token_path</span> => <span class="s"><span class="dl">"</span><span class="k">/oauth/access_tokens</span><span class="dl">"</span></span>,<tt>
</tt> <span class="sy">:request_token_path</span> => <span class="s"><span class="dl">"</span><span class="k">/oauth/request_tokens</span><span class="dl">"</span></span><tt>
</tt> )<tt>
</tt> <span class="iv">@request_token</span> = <span class="iv">@consumer</span>.get_request_token<tt>
</tt><tt>
</tt> agent = <span class="co">WWW</span>::<span class="co">Mechanize</span>.new<tt>
</tt> page = agent.get(<span class="iv">@request_token</span>.authorize_url)<tt>
</tt> login_form = page.forms.first<tt>
</tt> login_form.field_with(<span class="sy">:name</span> => <span class="s"><span class="dl">"</span><span class="k">openid_url</span><span class="dl">"</span></span>).value = <span class="s"><span class="dl">"</span><span class="k">localhost:</span><span class="il"><span class="idl">#{</span><span class="co">TEST_PORTS</span>[<span class="sy">:openid</span>]<span class="idl">}</span></span><span class="dl">"</span></span><tt>
</tt> page = agent.submit(login_form)<tt>
</tt><tt>
</tt> oauth_form = page.forms.first<tt>
</tt> page = agent.submit(oauth_form, oauth_form.button_with(<span class="sy">:value</span> => <span class="s"><span class="dl">"</span><span class="k">Approve</span><span class="dl">"</span></span>))<tt>
</tt><tt>
</tt> <span class="c"># Get access token</span><tt>
</tt> <span class="iv">@access_token</span> = <span class="iv">@request_token</span>.get_access_token<tt>
</tt><tt>
</tt> <span class="c"># Update an item</span><tt>
</tt> result = <span class="iv">@access_token</span>.put(<span class="s"><span class="dl">"</span><span class="k">/items/12345</span><span class="dl">"</span></span>, {<span class="sy">:name</span> => <span class="s"><span class="dl">"</span><span class="k">Hello</span><span class="dl">"</span></span>}.to_json)<tt>
</tt> result.code.should == <span class="s"><span class="dl">"</span><span class="k">201</span><span class="dl">"</span></span><tt>
</tt> <span class="r">end</span><tt>
</tt><span class="r">end</span><tt>
</tt></pre></td>
</tr></table>
<p>There’s a lot of code and not much supporting text here. I’m hoping it all just clicks together pretty easy. Hit me up with any questions.</p>