xspec 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ # # Autorun
2
+
3
+ # A convenience file that sets up an auto-running common case DSL in the
4
+ # top-level namespace to get you going fast.
5
+ require 'xspec'
6
+
7
+ extend XSpec.dsl
8
+ autorun!
@@ -0,0 +1,181 @@
1
+ # # Data Structures
2
+
3
+ # XSpec data structures are very dumb. They:
4
+ #
5
+ # * Only contain iteration and creation logic.
6
+ # * Do not store recursive references ("everything flows downhill").
7
+ module XSpec
8
+ # A unit of work, usually created by the `it` DSL method, is a labeled,
9
+ # indivisible code block that expresses an assertion about a property of the
10
+ # system under test. They are run by an evaluator.
11
+ UnitOfWork = Struct.new(:name, :block)
12
+
13
+
14
+ # A context is a recursively nested structure, usually created with the
15
+ # `describe` DSL method, that contains other contexts and units of work. Most
16
+ # of the logic for a context happens at the class level rather than instance,
17
+ # which is unusual but required for method inheritance to work correctly. It
18
+ # currently violates the logic rule specified above, more work is required to
19
+ # decouple it.
20
+ require 'xspec/dsl'
21
+ class Context
22
+ class << self
23
+ attr_reader :name, :children, :units_of_work, :assertion_context
24
+
25
+ # A context includes the same DSL methods as the root level module, which
26
+ # enables the recursive creation.
27
+ def __xspec_context; self; end
28
+ include ::XSpec::DSL
29
+
30
+ # Each nested context creates a new class that inherits from the parent.
31
+ # Methods can be added to this class as per normal, and are correctly
32
+ # inherited by children. When it comes time to run tests, the evaluator
33
+ # will create a new instance of the context (a class) for each test,
34
+ # making the defined methods available and also ensuring that there is no
35
+ # state pollution between tests.
36
+ def make(name, assertion_context, &block)
37
+ x = Class.new(self)
38
+ x.initialize!(name, assertion_context)
39
+ x.class_eval(&block) if block
40
+ x.apply_assertion_context!
41
+ x
42
+ end
43
+
44
+ # A class cannot have an implicit initializer, but some variable
45
+ # inititialization is required so the `initialize!` method is called
46
+ # explicitly when ever a dynamic subclass is created.
47
+ def initialize!(name, assertion_context)
48
+ @children = []
49
+ @units_of_work = []
50
+ @name = name
51
+ @assertion_context = assertion_context
52
+ end
53
+
54
+ # The assertion context should be applied after the user has had a chance
55
+ # to add their own methods. It needs to be last so that users can't
56
+ # clobber the assertion methods.
57
+ def apply_assertion_context!
58
+ include(assertion_context)
59
+ end
60
+
61
+ # Executing a unit of work creates a new instance and hands it off to the
62
+ # `call` method, which is defined by whichever assertion context is being
63
+ # used. By creating a new instance everytime, no state is preserved
64
+ # between executions.
65
+ def execute(unit_of_work)
66
+ new.call(unit_of_work)
67
+ end
68
+
69
+ # The root context is nothing special, and behaves the same as all the
70
+ # others.
71
+ def root(assertion_context)
72
+ make(nil, assertion_context)
73
+ end
74
+
75
+ # Child contexts and units of work are typically added by the `describe`
76
+ # and `it` DSL methods respectively.
77
+ def add_child_context(name = nil, opts = {}, &block)
78
+ self.children << make(name, assertion_context, &block)
79
+ end
80
+
81
+ def add_unit_of_work(name = nil, opts = {}, &block)
82
+ self.units_of_work << UnitOfWork.new(name, block)
83
+ end
84
+
85
+ # A shared context is a floating context that isn't part of any context
86
+ # heirachy, so its units of work will not be visible to the root node. It
87
+ # can be brought into any point in the heirachy using `copy_into_tree`
88
+ # (aliased as `it_behaves_like_a` in the DSL), and this can be done
89
+ # multiple times, which allows definitions to be reused.
90
+ #
91
+ # This is leaky abstraction, since only units of work are copied from
92
+ # shared contexts. Methods and child contexts are ignored.
93
+ def create_shared_context(&block)
94
+ make(nil, assertion_context, &block)
95
+ end
96
+
97
+ def copy_into_tree(source_context)
98
+ target_context = make(
99
+ source_context.name,
100
+ source_context.assertion_context
101
+ )
102
+ source_context.nested_units_of_work.each do |x|
103
+ target_context.units_of_work << x.unit_of_work
104
+ end
105
+ self.children << target_context
106
+ target_context
107
+ end
108
+
109
+ # The most convenient way to access all units of work is this recursive
110
+ # iteration that returns all leaf-nodes as `NestedUnitOfWork` objects.
111
+ require 'enumerator'
112
+ def nested_units_of_work(&block)
113
+ enum = Enumerator.new do |y|
114
+ children.each do |child|
115
+ child.nested_units_of_work do |x|
116
+ y.yield x.nest_under(self)
117
+ end
118
+ end
119
+
120
+ units_of_work.each do |x|
121
+ y.yield NestedUnitOfWork.new([self], x)
122
+ end
123
+ end
124
+
125
+ if block
126
+ enum.each(&block)
127
+ else
128
+ enum
129
+ end
130
+ end
131
+
132
+ # Values of memoized methods are remembered only for the duration of a
133
+ # single unit of work. These are typically created using the `let` DSL
134
+ # method.
135
+ def add_memoized_method(name, &block)
136
+ define_method(name) do
137
+ memoized[block] ||= instance_eval(&block)
138
+ end
139
+ end
140
+
141
+ # Dynamically generated classes are hard to identify in object graphs, so
142
+ # it is helpful for debugging to set an explicit name.
143
+ def to_s
144
+ "Context:'#{name}'"
145
+ end
146
+ end
147
+
148
+ def memoized
149
+ @memoized ||= {}
150
+ end
151
+ end
152
+
153
+ # Units of work can be nested inside contexts. This is the main object that
154
+ # other components of the system work with.
155
+ NestedUnitOfWork = Struct.new(:parents, :unit_of_work) do
156
+ def block; unit_of_work.block; end
157
+ def name; unit_of_work.name; end
158
+
159
+ def immediate_parent
160
+ parents.last
161
+ end
162
+
163
+ def nest_under(parent)
164
+ self.class.new([parent] + parents, unit_of_work)
165
+ end
166
+ end
167
+
168
+ # The result of executing a unit of work, including timing information. This
169
+ # is passed to notifiers for display or other processing.
170
+ ExecutedUnitOfWork = Struct.new(:nested_unit_of_work, :errors, :duration) do
171
+ def name; nested_unit_of_work.name end
172
+ def parents; nested_unit_of_work.parents end
173
+ end
174
+
175
+ # A test failure will be reported as a `Failure`, which includes contextual
176
+ # information about the failure useful for reporting to the user.
177
+ Failure = Struct.new(:unit_of_work, :message, :caller)
178
+
179
+ # An exception is mostly handled the same way as a failure.
180
+ CodeException = Class.new(Failure)
181
+ end
@@ -0,0 +1,30 @@
1
+ # # Defaults
2
+
3
+ # These are the defaults used by `XSpec.dsl`, but feel free to specify your own
4
+ # instead. They are set up in such a way that if you can override a component
5
+ # down in the bowels without having to provide an entire top level evaluator.
6
+ require 'xspec/evaluators'
7
+ require 'xspec/assertion_contexts'
8
+ require 'xspec/notifiers'
9
+
10
+ module XSpec
11
+ def add_defaults(options = {})
12
+ # A notifier makes it possible to observe the state of the system, be that
13
+ # progress or details of failing tests.
14
+ options[:notifier] ||= XSpec::Notifier::DEFAULT
15
+
16
+ # A unit of work will run as an instance method on the context it is
17
+ # defined in, but in addition an assertion context will be added as well.
18
+ # This is a module that is included as the final step in constructing a
19
+ # context. Allows for different matchers and expectation frameworks to be
20
+ # used.
21
+ options[:assertion_context] ||= AssertionContext::DEFAULT
22
+
23
+ # An evaluator is responsible for scheduling units of work and handing them
24
+ # off to the assertion context. Any logic regarding threads, remote
25
+ # execution or the like belongs in an evaluator.
26
+ options[:evaluator] ||= Evaluator::DEFAULT.new(options)
27
+ options
28
+ end
29
+ module_function :add_defaults
30
+ end
@@ -0,0 +1,26 @@
1
+ # Common DSL functions are provided as a module so that they can be used in
2
+ # both top-level and nested contexts. The method names are modeled after
3
+ # rspec, and should behave roughly the same.
4
+ module XSpec
5
+ module DSL
6
+ def it(*args, &block)
7
+ __xspec_context.add_unit_of_work(*args, &block)
8
+ end
9
+
10
+ def describe(*args, &block)
11
+ __xspec_context.add_child_context(*args, &block)
12
+ end
13
+
14
+ def let(*args, &block)
15
+ __xspec_context.add_memoized_method(*args, &block)
16
+ end
17
+
18
+ def shared_context(*args, &block)
19
+ __xspec_context.create_shared_context(*args, &block)
20
+ end
21
+
22
+ def it_behaves_like_a(context)
23
+ __xspec_context.copy_into_tree(context)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,40 @@
1
+ # # Evaluators
2
+
3
+ # Evaluators are responsible for collecting all units of work to be run and
4
+ # scheduling them.
5
+ module XSpec
6
+ module Evaluator
7
+ # The serial evaluator, unsurprisingly, runs all units of works serially in
8
+ # a loop. It is about as simple an evaluator as you can imagine. Parents
9
+ # are responsible for actually executing the work.
10
+ class Serial
11
+ def initialize(opts)
12
+ @notifier = opts.fetch(:notifier)
13
+ @clock = opts.fetch(:clock, ->{ Time.now.to_f })
14
+ end
15
+
16
+ def run(context)
17
+ notifier.run_start
18
+
19
+ context.nested_units_of_work.each do |x|
20
+ notifier.evaluate_start(x)
21
+
22
+ start_time = clock.()
23
+ errors = x.immediate_parent.execute(x)
24
+ finish_time = clock.()
25
+
26
+ result = ExecutedUnitOfWork.new(x, errors, finish_time - start_time)
27
+ notifier.evaluate_finish(result)
28
+ end
29
+
30
+ notifier.run_finish
31
+ end
32
+
33
+ protected
34
+
35
+ attr_reader :notifier, :clock
36
+ end
37
+
38
+ DEFAULT = Serial
39
+ end
40
+ end
@@ -0,0 +1,310 @@
1
+ # # Notifiers
2
+
3
+ # Without a notifier, there is no way for XSpec to interact with the outside
4
+ # world. A notifier handles progress updates as tests are executed, and
5
+ # summarizing the run when it finished.
6
+ module XSpec
7
+ module Notifier
8
+ # Many notifiers play nice with others, and can be composed together in a
9
+ # way that each notifier will have its callback run in turn.
10
+ module Composable
11
+ def +(other)
12
+ Composite.new(self, other)
13
+ end
14
+ end
15
+
16
+ class Composite
17
+ include Composable
18
+
19
+ def initialize(*notifiers)
20
+ @notifiers = notifiers
21
+ end
22
+
23
+ def run_start
24
+ notifiers.each(&:run_start)
25
+ end
26
+
27
+ def evaluate_start(*args)
28
+ notifiers.each {|x| x.evaluate_start(*args) }
29
+ end
30
+
31
+ def evaluate_finish(*args)
32
+ notifiers.each {|x| x.evaluate_finish(*args) }
33
+ end
34
+
35
+ def run_finish
36
+ notifiers.map(&:run_finish).all?
37
+ end
38
+
39
+ protected
40
+
41
+ attr_reader :notifiers
42
+ end
43
+
44
+ # Outputs a single character for each executed unit of work representing
45
+ # the result.
46
+ class Character
47
+ include Composable
48
+
49
+ def initialize(out = $stdout)
50
+ @out = out
51
+ end
52
+
53
+ def run_start; end
54
+
55
+ def evaluate_start(*_); end
56
+
57
+ def evaluate_finish(result)
58
+ @out.print label_for_failure(result.errors[0])
59
+ @failed ||= result.errors.any?
60
+ end
61
+
62
+ def run_finish
63
+ @out.puts
64
+ !@failed
65
+ end
66
+
67
+ protected
68
+
69
+ def label_for_failure(f)
70
+ case f
71
+ when CodeException then 'E'
72
+ when Failure then 'F'
73
+ else '.'
74
+ end
75
+ end
76
+
77
+ end
78
+
79
+ # Renders a histogram of test durations after the entire run is complete.
80
+ class TimingsAtEnd
81
+ include Composable
82
+
83
+ DEFAULT_SPLITS = [0.001, 0.005, 0.01, 0.1, 1.0, Float::INFINITY]
84
+
85
+ def initialize(out: $stdout,
86
+ splits: DEFAULT_SPLITS,
87
+ width: 20)
88
+
89
+ @timings = {}
90
+ @splits = splits
91
+ @width = width
92
+ @out = out
93
+ end
94
+
95
+ def run_start(*_); end
96
+
97
+ def evaluate_start(_)
98
+ end
99
+
100
+ def evaluate_finish(result)
101
+ timings[result] = result.duration
102
+ end
103
+
104
+ def run_finish
105
+ buckets = bucket_from_splits(timings, splits)
106
+ max = buckets.values.max
107
+
108
+ out.puts " Timings:"
109
+ buckets.each do |(split, count)|
110
+ label = split.infinite? ? "∞" : split
111
+
112
+ out.puts " %6s %-#{width}s %i" % [
113
+ label,
114
+ '#' * (count / max.to_f * width.to_f).ceil,
115
+ count
116
+ ]
117
+ end
118
+ out.puts
119
+ end
120
+
121
+ private
122
+
123
+ attr_reader :timings, :splits, :width, :out
124
+
125
+ def bucket_from_splits(timings, splits)
126
+ initial_buckets = splits.each_with_object({}) do |b, a|
127
+ a[b] = 0
128
+ end
129
+
130
+ buckets = timings.each_with_object(initial_buckets) do |(_, d), a|
131
+ split = splits.detect {|x| d < x }
132
+ a[split] += 1
133
+ end
134
+
135
+ remove_trailing_zero_counts(buckets)
136
+ end
137
+
138
+ def remove_trailing_zero_counts(buckets)
139
+ Hash[
140
+ buckets
141
+ .to_a
142
+ .reverse
143
+ .drop_while {|_, x| x == 0 }
144
+ .reverse
145
+ ]
146
+ end
147
+ end
148
+
149
+ # Outputs error messages and backtraces after the entire run is complete.
150
+ class FailuresAtEnd
151
+ include Composable
152
+
153
+ def initialize(out = $stdout)
154
+ @errors = []
155
+ @out = out
156
+ end
157
+
158
+ def run_start; end
159
+
160
+ def evaluate_start(*_); end
161
+
162
+ def evaluate_finish(result)
163
+ self.errors += result.errors
164
+ end
165
+
166
+ def run_finish
167
+ return true if errors.empty?
168
+
169
+ out.puts
170
+ errors.each do |error|
171
+ out.puts "%s:\n%s\n\n" % [full_name(error.unit_of_work), error.message.lines.map {|x| " #{x}"}.join("")]
172
+ clean_backtrace(error.caller).each do |line|
173
+ out.puts " %s" % line
174
+ end
175
+ out.puts
176
+ end
177
+
178
+ false
179
+ end
180
+
181
+ def full_name(unit_of_work)
182
+ (unit_of_work.parents + [unit_of_work]).map(&:name).compact.join(' ')
183
+ end
184
+
185
+ # A standard backtrace contains many entries for XSpec itself which are
186
+ # not useful for debugging your tests, so they are stripped out.
187
+ def clean_backtrace(backtrace)
188
+ lib_dir = File.dirname(File.expand_path('..', __FILE__))
189
+
190
+ backtrace.reject {|x|
191
+ File.dirname(x).start_with?(lib_dir)
192
+ }
193
+ end
194
+
195
+ protected
196
+
197
+ attr_accessor :out, :errors
198
+ end
199
+
200
+ # Includes nicely formatted names and durations of each test in the output,
201
+ # with color.
202
+ class ColoredDocumentation
203
+ require 'set'
204
+
205
+ include Composable
206
+
207
+ VT100_COLORS = {
208
+ :black => 30,
209
+ :red => 31,
210
+ :green => 32,
211
+ :yellow => 33,
212
+ :blue => 34,
213
+ :magenta => 35,
214
+ :cyan => 36,
215
+ :white => 37
216
+ }
217
+
218
+ def color_code_for(color)
219
+ VT100_COLORS.fetch(color)
220
+ end
221
+
222
+ def colorize(text, color)
223
+ "\e[#{color_code_for(color)}m#{text}\e[0m"
224
+ end
225
+
226
+ def decorate(result)
227
+ name = result.name
228
+ out = if result.errors.any?
229
+ colorize(append_failed(name), :red)
230
+ else
231
+ colorize(name , :green)
232
+ end
233
+ "%.3fs " % result.duration + out
234
+ end
235
+
236
+ def initialize(out = $stdout)
237
+ self.indent = 2
238
+ self.last_seen_names = []
239
+ self.failed = false
240
+ self.out = out
241
+ end
242
+
243
+ def run_start; end
244
+
245
+ def evaluate_start(*_); end
246
+
247
+ def evaluate_finish(result)
248
+ output_context_header! result.parents.map(&:name).compact
249
+
250
+ spaces = ' ' * (last_seen_names.size * indent)
251
+
252
+ self.failed ||= result.errors.any?
253
+
254
+ out.puts "%s%s" % [spaces, decorate(result)]
255
+ end
256
+
257
+ def run_finish
258
+ out.puts
259
+ !failed
260
+ end
261
+
262
+ protected
263
+
264
+ attr_accessor :last_seen_names, :indent, :failed, :out
265
+
266
+ def output_context_header!(parent_names)
267
+ if parent_names != last_seen_names
268
+ tail = parent_names - last_seen_names
269
+
270
+ out.puts
271
+ if tail.any?
272
+ existing_indent = parent_names.size - tail.size
273
+ tail.each_with_index do |name, i|
274
+ out.puts '%s%s' % [' ' * ((existing_indent + i) * indent), name]
275
+ end
276
+ end
277
+
278
+ self.last_seen_names = parent_names
279
+ end
280
+ end
281
+
282
+ def append_failed(name)
283
+ [name, "FAILED"].compact.join(' - ')
284
+ end
285
+ end
286
+
287
+ # Includes nicely formatted names and durations of each test in the output.
288
+ class Documentation < ColoredDocumentation
289
+ def colorize(name, _)
290
+ name
291
+ end
292
+ end
293
+
294
+ # A notifier that does not do anything and always returns successful.
295
+ # Useful as a parent class for other notifiers or for testing.
296
+ class Null
297
+ include Composable
298
+
299
+ def run_start; end
300
+ def evaluate_start(*_); end
301
+ def evaluate_finish(*_); end
302
+ def run_finish; true; end
303
+ end
304
+
305
+ DEFAULT =
306
+ ColoredDocumentation.new +
307
+ TimingsAtEnd.new +
308
+ FailuresAtEnd.new
309
+ end
310
+ end