module XSpecXSpec data structures are very dumb. They:
module XSpecA unit of work, usually created by the it DSL method, is a labeled,
indivisible code block that expresses an assertion about a property of the
system under test. They are run by a scheduler.
UnitOfWork = Struct.new(:name, :block)A context is a recursively nested structure, usually created with the
describe DSL method, that contains other contexts and units of work. Most
of the logic for a context happens at the class level rather than instance,
which is unusual but required for method inheritance to work correctly. It
currently violates the logic rule specified above, more work is required to
decouple it.
require 'xspec/dsl'
class Context
class << self
attr_reader :name, :children, :units_of_work, :evaluatorA context includes the same DSL methods as the root level module, which enables the recursive creation.
def __xspec_context; self; end
include ::XSpec::DSLEach nested context creates a new class that inherits from the parent. Methods can be added to this class as per normal, and are correctly inherited by children. When it comes time to run tests, the scheduler will create a new instance of the context (a class) for each test, making the defined methods available and also ensuring that there is no state pollution between tests.
def make(name, evaluator, &block)
x = Class.new(self)
x.initialize!(name, evaluator)
x.class_eval(&block) if block
x.apply_evaluator!
x
endA class cannot have an implicit initializer, but some variable
inititialization is required so the initialize! method is called
explicitly when ever a dynamic subclass is created.
def initialize!(name, evaluator)
@children = []
@units_of_work = []
@name = name
@evaluator = evaluator
endThe assertion context should be applied after the user has had a chance to add their own methods. It needs to be last so that users can’t clobber the assertion methods.
def apply_evaluator!
include(evaluator)
endExecuting a unit of work creates a new instance and hands it off to the
call method, which is defined by whichever assertion context is being
used. By creating a new instance everytime, no state is preserved
between executions.
def execute(unit_of_work)
new.call(unit_of_work)
endThe root context is nothing special, and behaves the same as all the others.
def root(evaluator)
make(nil, evaluator)
endChild contexts and units of work are typically added by the describe
and it DSL methods respectively.
def add_child_context(name = nil, opts = {}, &block)
self.children << make(name, evaluator, &block)
end
def add_unit_of_work(name = nil, opts = {}, &block)
self.units_of_work << UnitOfWork.new(name, block)
endA shared context is a floating context that isn’t part of any context
heirachy, so its units of work will not be visible to the root node. It
can be brought into any point in the heirachy using copy_into_tree
(aliased as include_context in the DSL), and this can be done
multiple times, which allows definitions to be reused.
This is leaky abstraction, since only units of work are copied from shared contexts. Methods and child contexts are ignored.
def create_shared_context(&block)
make(nil, evaluator, &block)
end
def copy_into_tree(source_context)
target_context = make(
source_context.name,
source_context.evaluator
)
source_context.nested_units_of_work.each do |x|
target_context.units_of_work << x.unit_of_work
end
self.children << target_context
target_context
endThe most convenient way to access all units of work is this recursive
iteration that returns all leaf-nodes as NestedUnitOfWork objects.
require 'enumerator'
def nested_units_of_work(&block)
enum = Enumerator.new do |y|
children.each do |child|
child.nested_units_of_work do |x|
y.yield x.nest_under(self)
end
end
units_of_work.each do |x|
y.yield NestedUnitOfWork.new([self], x)
end
end
if block
enum.each(&block)
else
enum
end
endValues of memoized methods are remembered only for the duration of a
single unit of work. These are typically created using the let DSL
method.
def add_memoized_method(name, &block)
define_method(name) do
memoized[block] ||= instance_eval(&block)
end
endDynamically generated classes are hard to identify in object graphs, so it is helpful for debugging to set an explicit name.
def to_s
"Context:'#{name}'"
end
end
def memoized
@memoized ||= {}
end
endUnits of work can be nested inside contexts. This is the main object that other components of the system work with.
NestedUnitOfWork = Struct.new(:parents, :unit_of_work) do
def block; unit_of_work.block; end
def name; unit_of_work.name; end
def full_name
(parents + [self]).map(&:name).compact.join(' ')
end
def immediate_parent
parents.last
end
def nest_under(parent)
self.class.new([parent] + parents, unit_of_work)
end
endThe result of executing a unit of work, including timing information. This is passed to notifiers for display or other processing.
ExecutedUnitOfWork = Struct.new(:nested_unit_of_work, :errors, :duration) do
def name; nested_unit_of_work.name end
def parents; nested_unit_of_work.parents end
def full_name; nested_unit_of_work.full_name end
endA test failure will be reported as a Failure, which includes contextual
information about the failure useful for reporting to the user.
Failure = Struct.new(:unit_of_work, :message, :caller)An exception is mostly handled the same way as a failure.
CodeException = Class.new(Failure)
end