At Songkick.com we have developed a number of patterns to make it easier to write Cucumber features. I thought I would start sharing some of those patterns here. So here is the first one:
Implicit Reference Pattern
Make use of implicit references to previously discussed topics to produce scenarios which are easier to read and write. Achieving this while avoiding highly coupled steps.
Problem
Storing and relying on state in a step definition can make it hard to reuse. So often state is avoided. This can lead to scenarios like the following:
Given there is a Artist named "XXs"
And I visit the page for the Artist named "The XXs"
This leaves us with:
Verbose steps which are not natural to read.
Extra noise information purely for identity (referencing the name)
Solution
Map implicit references in the language to the objects being discussed.
Accept that we have to store state but do so in an encapsulated way where the feature language is the only thing needed within the step definition to provide a direct mapping to the relevant object from the state.
What we are aiming for is a scenario like this:
Given there is an Artist named "XXs"
When I visit the page for the Artist
Domain model class names are meaningful to everyone
In our features when we talk about something in our domain we refer to its class name and we use the correct capitalization.
Non-technical people still understand what the references mean while allowing us to simplify identifying the model in a snippet of feature text.
This leads to steps such as:
Given the Artist
Given the AdminUser
Implementation
Somewhere to store stuff
We store all the state in a specialised hash. This has a special find_things method which performs some validation and gives us nice error messages if we try and access incorrect types or non-existent objects.
12345678910111213141516171819202122
classStuffContainer<Hashdeffind_thing(opts)expected_type=opts[:type]name=opts[:name]thing=self[name]raise("Unable to find any object in stuff[] with the name '#{name}' that you asked for, boss. I could however offer you one of the following: #{self.to_s}")unlessthingraise("That thing you asked for, it appears to be a #{thing.class.name} when you asked for a #{expected_type.name}")unlessthing.is_a?(expected_type)thingenddefto_sresult=["#{self.length} items in total:"]self.eachdo|key,thing|result<<"the #{thing.class.name}\"#{key}\""endresult.join("\n")endend
Recording the subjects under discussion
In order to record references to created Models we extend Factory Girl’s ‘Factory’ method which is used for all our model creations.
We will record created models in steps like these:
1234
Given/^there is (?:one|an|a|another) ([^ ]+) named "([^"]+)"$/do|entity_type,name|attributes={:name=>name}entity=Factory(entity_type.underscore.to_sym,attributes)end
Resolving Implicit references
Starting with the step definition:
123
When/I (?:view|visit|go to) the page for (#{IDRE})$/do|entity|visitmodel_path(identified_model(entity))end
IDRE represents the ID regexp which provides a way of identifying a model and is reused in many steps.
1
IDRE=/(?:the(?: first | last | )(?:[^ ]+)|the (?:[^ ]+) "(?:[^"]+)"|"(?:[^"]+)")/
The identified_model method turns an English string into a Model. It provides a number of ways of referencing a model.
the Artist
the first Artist
the last Artist
the Artist "Jude"
The identified_model method:
(The key case we are focusing on in this example is where we have no identify just the type of the model – line 11 and 12)
12345678910111213141516171819
defidentified_model(str)casestrwhen/^the (first|last) ([^ ]+)$/klass=safe_constantize($2)returnklass.__send__($1.to_sym)when/^the ([^ ]+) "(.+)"$/klass=safe_constantize($1)instance=stuff.find_thing(:type=>klass,:name=>$2)instance.reloadreturninstancewhen/^the ([^ ]+)$/returnimplicit_model($1)when/^"(.+)"$/instance=stuff[$1]instance.reloadifinstancereturninstanceendraise"No such instance: '#{str}'.\n Current stuff: #{stuff.to_s}"end
The implicit_model method
123456
defimplicit_model(str)klass=safe_constantize(str)raise"expected only one #{klass.name}"ifklass.count>1raise"expected one #{klass.name} to exist"ifklass.count==0klass.firstend
Notice to avoid ambiguity we restrict that only one model of the specified type must exist.
moduleStuffManagmentdefmap_to_id_attribute(type_of_thing){:user=>:username,:concert=>:title,}[type_of_thing]||:nameendclassStuffContainer<Hashdeffind_thing(opts)expected_type=opts[:type]name=opts[:name]thing=self[name]raise("Unable to find any object in stuff[] with the name '#{name}' that you asked for, boss. I could however offer you one of the following: #{self.to_s}")unlessthingraise("That thing you asked for, it appears to be a #{thing.class.name} when you asked for a #{expected_type.name}")unlessthing.is_a?(expected_type)thingenddefto_sresult=["#{self.length} items in total:"]self.eachdo|key,thing|result<<"the #{thing.class.name}\"#{key}\""endresult.join("\n")endenddefclear_stuff@stuff=StuffContainer.newenddefstuffreturn@stuffif@stuffclear_stuffendSEARCH_MODULES=['']defidentified_model(str)casestrwhen/^the (first|last) ([^ ]+)$/klass=safe_constantize($2)returnklass.__send__($1.to_sym)when/^the ([^ ]+) "(.+)"$/klass=safe_constantize($1)instance=stuff.find_thing(:type=>klass,:name=>$2)instance.reloadreturninstancewhen/^the ([^ ]+)$/returnimplicit_model($1)when/^"(.+)"$/instance=stuff[$1]instance.reloadifinstancereturninstanceendraise"No such instance: '#{str}'.\n Current stuff: #{stuff.to_s}"enddefimplicit_model(str)klass=safe_constantize(str)raise"expected only one #{klass.name}"ifklass.count>1raise"expected one #{klass.name} to exist"ifklass.count==0klass.firstenddefsafe_constantize(str)beginrecorded_exception=nilSEARCH_MODULES.eachdo|mod|beginreturn"#{mod}::#{str}".constantizerescueNameError=>erecorded_exception=eendenderror_message="\"#{str}\" does not appear to be a valid object in the domain. Did you mean \"#{str.classify}\"?"\+"\nDetailed error message:\n#{recorded_exception.message}"raiseNameError.new(error_message)endendendWorld(StuffManagment)