Cucumber Patterns
8 Aug
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
Requirements
This pattern is described in the context of ActiveRecord and Factory Girl.
Identification through a single meaningful name
Key to this pattern is mapping a single identifier (that we would happily talk about in our Features) to a model.
We have a method that provides all the mappings of the models to the field that uniquely identifies them.
def map_to_id_attribute(type_of_thing)
{
:user => :username,
:concert => :title,
}[type_of_thing] || :name
end
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.
class StuffContainer < Hash
def find_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}") unless thing
raise("That thing you asked for, it appears to be a #{thing.class.name} when you asked for a #{expected_type.name}") unless thing.is_a?(expected_type)
thing
end
def to_s
result = ["#{self.length} items in total:"]
self.each do |key, thing|
result << "the #{thing.class.name} \"#{key}\""
end
result.join("\n")
end
end
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.
def Factory(type_of_thing, attributes = {})
test_id = attributes[map_to_id_attribute(type_of_thing)]
new_thing = super(type_of_thing, attributes)
stuff[test_id] = new_thing if test_id
new_thing
end
We will record created models in steps like these:
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:
When /I (?:view|visit|go to) the page for (#{IDRE})$/ do |entity|
visit model_path(identified_model(entity))
end
IDRE represents the ID regexp which provides a way of identifying a model and is reused in many steps.
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)
def identified_model(str)
case str
when /^the (first|last) ([^ ]+)$/
klass = safe_constantize($2)
return klass.__send__($1.to_sym)
when /^the ([^ ]+) "(.+)"$/
klass = safe_constantize($1)
instance = stuff.find_thing(:type => klass, :name => $2)
instance.reload
return instance
when /^the ([^ ]+)$/
return implicit_model($1)
when /^"(.+)"$/
instance = stuff[$1]
instance.reload if instance
return instance
end
raise "No such instance: '#{str}'.\n Current stuff: #{stuff.to_s}"
end
The implicit_model method
def implicit_model(str)
klass = safe_constantize(str)
raise "expected only one #{klass.name}" if klass.count > 1
raise "expected one #{klass.name} to exist" if klass.count == 0
klass.first
end
Notice to avoid ambiguity we restrict that only one model of the specified type must exist.
Full source
module StuffManagment
def map_to_id_attribute(type_of_thing)
{
:user => :username,
:concert => :title,
}[type_of_thing] || :name
end
class StuffContainer < Hash
def find_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}") unless thing
raise("That thing you asked for, it appears to be a #{thing.class.name} when you asked for a #{expected_type.name}") unless thing.is_a?(expected_type)
thing
end
def to_s
result = ["#{self.length} items in total:"]
self.each do |key, thing|
result << "the #{thing.class.name} \"#{key}\""
end
result.join("\n")
end
end
def clear_stuff
@stuff = StuffContainer.new
end
def stuff
return @stuff if @stuff
clear_stuff
end
SEARCH_MODULES = ['']
def identified_model(str)
case str
when /^the (first|last) ([^ ]+)$/
klass = safe_constantize($2)
return klass.__send__($1.to_sym)
when /^the ([^ ]+) "(.+)"$/
klass = safe_constantize($1)
instance = stuff.find_thing(:type => klass, :name => $2)
instance.reload
return instance
when /^the ([^ ]+)$/
return implicit_model($1)
when /^"(.+)"$/
instance = stuff[$1]
instance.reload if instance
return instance
end
raise "No such instance: '#{str}'.\n Current stuff: #{stuff.to_s}"
end
def implicit_model(str)
klass = safe_constantize(str)
raise "expected only one #{klass.name}" if klass.count > 1
raise "expected one #{klass.name} to exist" if klass.count == 0
klass.first
end
def safe_constantize(str)
begin
recorded_exception = nil
SEARCH_MODULES.each do |mod|
begin
return "#{mod}::#{str}".constantize
rescue NameError => e
recorded_exception = e
end
end
error_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}"
raise NameError.new(error_message)
end
end
end
World(StuffManagment)
-
nicholas a. evans
-
Joseph Wilk
-
Joseph Wilk
-
nicholas a. evans
-
Antony Sastre
-
Antony Sastre
-
tomtt
-
Matt Wynne
-
jbpros