I introduced my Specify micro-framework in a previous post. In this post I want to cover an example of how effective I think this kind of approach can be.
This post will be short on details and heavy on code. My goal here is to show what I think is an interesting idea about providing a domain model for what you are working on.
Going with the example from my previous post, imagine you have a “planetary weight” application. This may have components like a REST interface as well as a web-based application. But somewhere in your logic you have elements that actually perform the calculations that allow someone’s weight on another planet to be calculated.
What I want Specify to do is serve as an abstraction layer over unit tests and integration tests. One way to do this is to test to a model. That model can specify the business rules (that you might normally unit test) and the features (that you might normally integration test).
So imagine you have a model like this:
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 |
class Planet < Domain url_is 'http://localhost:9292/weight' url_matches /:\d{4}\/weight/ title_is 'Dialogic - Weight Calculator' # Model Execution class << self G = 6.674e-11 EARTH_SG = 9.81 EARTH = { mass: 5.97e24, radius: 6.378e6 } MERCURY = { mass: 3.301e23, radius: 2.44e6 } VENUS = { mass: 4.8673e24, radius: 6.051e6 } MARS = { mass: 6.4169e23, radius: 3.397e6 } JUPITER = { mass: 1.8981e27, radius: 7.1492e7 } SATURN = { mass: 5.6832e26, radius: 6.0268e7 } URANUS = { mass: 8.6810e25, radius: 2.5559e7 } NEPTUNE = { mass: 1.0241e26, radius: 2.4764e7 } def find_surface_gravity_ratio_for(planet) force = G * self.instance_eval("#{planet.upcase}[:mass]") distance = self.instance_eval("#{planet.upcase}[:radius]")**2 equator_sg = force / distance ratio = equator_sg / EARTH_SG end def a_weight_of(weight) @weight = weight self end def is_what_on(planet) @weight * find_surface_gravity_ratio_for(planet) end end # Page Elements text_field :weight, id: 'wt' text_field :mercury, id: 'outputmrc' text_field :venus, id: 'outputvn' text_field :mars, id: 'outputmars' text_field :jupiter, id: 'outputjp' text_field :saturn, id: 'outputsat' text_field :uranus, id: 'outputur' text_field :neptune, id: 'outputnpt' button :calculate, id: 'calculate' # Page Methods def convert(value) weight.set value calculate.click end def get_weight_for(planet) self.send("#{planet}").when_present.value.to_f end def confirm_weight_for(planet, value) weight = self.send("#{planet}".downcase).when_present.value expect(weight.to_f).to eq value.to_f end def confirm_approximate_weight_for(planet, value, threshold) weight = self.send("#{planet}".downcase).when_present.value.to_f expect(weight.to_f).to be_within(threshold).of(value.to_f.floor) end end |
Note the section under the comment Model Execution
. That’s not the actual code of the application necessarily. In fact, in my case it’s not: the planetary app I wrote uses JavaScript. But what the above section of code does is provide a model of how that part of the application works. This allows me to test my assumptions about it.
So I could provide a Specify file like this:
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 |
require 'spec_helper' Component 'Planet Weight Calculator' do surface_gravity = [ { planet: 'Earth', calc: Planet.find_surface_gravity_ratio_for('Earth') }, { planet: 'Mercury', calc: Planet.find_surface_gravity_ratio_for('Mercury') }, { planet: 'Venus', calc: Planet.find_surface_gravity_ratio_for('Venus') }, { planet: 'Mars', calc: Planet.find_surface_gravity_ratio_for('Mars') }, { planet: 'Jupiter', calc: Planet.find_surface_gravity_ratio_for('Jupiter') }, { planet: 'Saturn', calc: Planet.find_surface_gravity_ratio_for('Saturn') }, { planet: 'Uranus', calc: Planet.find_surface_gravity_ratio_for('Uranus') }, { planet: 'Neptune', calc: Planet.find_surface_gravity_ratio_for('Neptune') } ] weight = [ { planet: 'Earth', calc: Planet.a_weight_of(200).is_what_on('Earth') }, { planet: 'Mercury', calc: Planet.a_weight_of(200).is_what_on('Mercury') }, { planet: 'Venus', calc: Planet.a_weight_of(200).is_what_on('Venus') }, { planet: 'Mars', calc: Planet.a_weight_of(200).is_what_on('Mars') }, { planet: 'Jupiter', calc: Planet.a_weight_of(200).is_what_on('Jupiter') }, { planet: 'Saturn', calc: Planet.a_weight_of(200).is_what_on('Saturn') }, { planet: 'Uranus', calc: Planet.a_weight_of(200).is_what_on('Uranus') }, { planet: 'Neptune', calc: Planet.a_weight_of(200).is_what_on('Neptune') } ] rules 'Surface Gravity Calculation' do Rule 'surface gravity can be calculated for all planets' do surface_gravity.each do |calculate| specify "#{calculate[:planet]} surface gravity: #{calculate[:calc]}" do; end end end Rule 'weight is calculated based on surface gravity' do weight.each do |calculate| specify "#{calculate[:planet]} weight: #{calculate[:calc]}" do; end end end end end |
Were you to run this, you get the following output:
Planet Weight Calculator Surface Gravity Calculation surface gravity can be calculated for all planets Earth surface gravity: 0.9984412061578731 Mercury surface gravity: 0.3772098862532158 Venus surface gravity: 0.9043801139180913 Mars surface gravity: 0.3783130934843111 Jupiter surface gravity: 2.5265121478475008 Saturn surface gravity: 1.0644777190561834 Uranus surface gravity: 0.9040641234578886 Neptune surface gravity: 1.136103689974277 weight is calculated based on surface gravity Earth weight: 199.68824123157464 Mercury weight: 75.44197725064315 Venus weight: 180.87602278361825 Mars weight: 75.66261869686221 Jupiter weight: 505.30242956950013 Saturn weight: 212.89554381123668 Uranus weight: 180.8128246915777 Neptune weight: 227.22073799485537 Finished in 0.00268 seconds (files took 0.38298 seconds to load) 16 examples, 0 failures
So why would you do this? Note that this is not testing the web application at all. It’s simply allowing a model to be executed. This lets us know if we understand exactly how the model should be working. Further, it let’s us reinforce that view with developers or business folks.
For example, a business expert could look at the method find_surface_gravity_ratio_for
in the model representation and determine if I’m executing it correctly.
When I want to test the web application itself, I can provide a Specify file for that as well:
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 |
require 'spec_helper' Feature 'Calculate Planet Weights', :phantomjs do Background(:all) do on_view(Planet) end Background(:each) do on(Planet).convert(200) end rocky_planets = [ { planet: 'mercury', weight: '75.6' }, { planet: 'venus', weight: '181.4' }, { planet: 'mars', weight: '75.4' }, ] gas_giants = [ { planet: 'jupiter', weight: '472.8' }, { planet: 'saturn', weight: '212.8' }, { planet: 'uranus', weight: '177.8' }, { planet: 'neptune', weight: '225' } ] rocky_planets.each do |example| specify "a 200 pound person will weigh #{example[:weight]} on #{example[:planet].capitalize}." do on(Planet).confirm_weight_for(example[:planet], example[:weight]) on(Planet).confirm_approximate_weight_for(example[:planet], example[:weight], 5.9) end end gas_giants.each do |example| specify "a 200 pound person will weigh #{example[:weight]} on #{example[:planet].capitalize}." do on(Planet).confirm_weight_for(example[:planet], example[:weight]) on(Planet).confirm_approximate_weight_for(example[:planet], example[:weight], 5.9) end end end |
The output from this will be the following:
Calculate Planet Weights a 200 pound person will weigh 75.6 on Mercury. a 200 pound person will weigh 181.4 on Venus. a 200 pound person will weigh 75.4 on Mars. a 200 pound person will weigh 472.8 on Jupiter. a 200 pound person will weigh 212.8 on Saturn. a 200 pound person will weigh 177.8 on Uranus. a 200 pound person will weigh 225 on Neptune. Finished in 12.17 seconds (files took 0.35617 seconds to load) 7 examples, 0 failures
Note that this returning a calculation based on the same idea that my model went through. Yet there are differences. For example, the model reports that a 200 pound person would weigh 227.22 pounds on Neptune, whereas the web application is reporting 225. So what we can do is look at if those differences matter. They may be pointing out nothing more than expected deviations given floating point math or they may be pointing out flaws in our understanding of the model.
What I hope you notice in this is the way Specify lets you word your spec files. You can see that I used the Specify DSL slightly differently each time in order to accommodate what I was talking about.
The idea of building a domain model is important, particularly for application domains that are complex. In this example, my domain model serves double-duty: it serves as a mechanism for me to test underlying calculations of the model and also serves as a page object to allow a web service or page to be interacted with. Further, you can wrap these models in specific test DSLs to make the model, the application, and the tests for both more clear.
This is admittedly an area I’m still exploring with Specify but the idea of using a model along with rules and features seems like a promising area of exploration.