From b9f0a31a4aa086ab4c2c89c70294e060431b630d Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 7 Aug 2021 21:45:49 -0600 Subject: [PATCH] Overhaul hooks Mostly cleanup and make managing hooks simpler, hopefully. Tests indicate this configuration matches hook execution order of RSpec. --- CHANGELOG.md | 2 + src/spectator/config.cr | 14 +-- src/spectator/config/builder.cr | 51 +++++---- src/spectator/events.cr | 122 -------------------- src/spectator/example.cr | 11 +- src/spectator/example_group.cr | 126 +++++--------------- src/spectator/example_group_builder.cr | 80 +++---------- src/spectator/example_group_hook.cr | 10 ++ src/spectator/example_hook.cr | 9 +- src/spectator/hooks.cr | 114 ++++++++++++++++++ src/spectator/includes.cr | 2 +- src/spectator/spec_builder.cr | 153 ++++++++++--------------- 12 files changed, 279 insertions(+), 415 deletions(-) delete mode 100644 src/spectator/events.cr create mode 100644 src/spectator/hooks.cr diff --git a/CHANGELOG.md b/CHANGELOG.md index 3acacc6..f7d70df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support custom messages for failed expectations. [#28](https://gitlab.com/arctic-fox/spectator/-/issues/28) - Allow named arguments and assignments for `provided` (`given`) block. - Add `aggregate_failures` to capture and report multiple failed expectations. [#24](https://gitlab.com/arctic-fox/spectator/-/issues/24) +- Add `append_` and `prepend_` variants of hook creation methods. ### Changed - `around_each` hooks wrap `before_all` and `after_all` hooks. [#12](https://github.com/icy-arctic-fox/spectator/issues/12) +- Hook execution order has been tweaked to match RSpec. - `given` (now `provided`) blocks changed to produce a single example. `it` can no longer be nested in a `provided` block. - The "should" syntax no longer reports the source as inside Spectator. - Short-hand "should" syntax must be included by using `require "spectator/should"` - `it { should eq("foo") }` diff --git a/src/spectator/config.cr b/src/spectator/config.cr index 549c5e6..577b938 100644 --- a/src/spectator/config.cr +++ b/src/spectator/config.cr @@ -21,25 +21,25 @@ module Spectator getter example_filter : ExampleFilter # List of hooks to run before all examples in the test suite. - protected getter before_suite_hooks : Array(ExampleGroupHook) + protected getter before_suite_hooks : Deque(ExampleGroupHook) # List of hooks to run before each top-level example group. - protected getter before_all_hooks : Array(ExampleGroupHook) + protected getter before_all_hooks : Deque(ExampleGroupHook) # List of hooks to run before every example. - protected getter before_each_hooks : Array(ExampleHook) + protected getter before_each_hooks : Deque(ExampleHook) # List of hooks to run after all examples in the test suite. - protected getter after_suite_hooks : Array(ExampleGroupHook) + protected getter after_suite_hooks : Deque(ExampleGroupHook) # List of hooks to run after each top-level example group. - protected getter after_all_hooks : Array(ExampleGroupHook) + protected getter after_all_hooks : Deque(ExampleGroupHook) # List of hooks to run after every example. - protected getter after_each_hooks : Array(ExampleHook) + protected getter after_each_hooks : Deque(ExampleHook) # List of hooks to run around every example. - protected getter around_each_hooks : Array(ExampleProcsyHook) + protected getter around_each_hooks : Deque(ExampleProcsyHook) # Creates a new configuration. # Properties are pulled from *source*. diff --git a/src/spectator/config/builder.cr b/src/spectator/config/builder.cr index ff7f08a..9c73506 100644 --- a/src/spectator/config/builder.cr +++ b/src/spectator/config/builder.cr @@ -21,100 +21,107 @@ module Spectator @filters = [] of ExampleFilter # List of hooks to run before all examples in the test suite. - protected getter before_suite_hooks = [] of ExampleGroupHook + protected getter before_suite_hooks = Deque(ExampleGroupHook).new # List of hooks to run before each top-level example group. - protected getter before_all_hooks = [] of ExampleGroupHook + protected getter before_all_hooks = Deque(ExampleGroupHook).new # List of hooks to run before every example. - protected getter before_each_hooks = [] of ExampleHook + protected getter before_each_hooks = Deque(ExampleHook).new # List of hooks to run after all examples in the test suite. - protected getter after_suite_hooks = [] of ExampleGroupHook + protected getter after_suite_hooks = Deque(ExampleGroupHook).new # List of hooks to run after each top-level example group. - protected getter after_all_hooks = [] of ExampleGroupHook + protected getter after_all_hooks = Deque(ExampleGroupHook).new # List of hooks to run after every example. - protected getter after_each_hooks = [] of ExampleHook + protected getter after_each_hooks = Deque(ExampleHook).new # List of hooks to run around every example. - protected getter around_each_hooks = [] of ExampleProcsyHook + protected getter around_each_hooks = Deque(ExampleProcsyHook).new # Attaches a hook to be invoked before all examples in the test suite. def add_before_suite_hook(hook) - @before_suite_hooks << hook + @before_suite_hooks.push(hook) end # Defines a block of code to execute before all examples in the test suite. def before_suite(&block) - @before_suite_hooks << ExampleGroupHook.new(&block) + hook = ExampleGroupHook.new(&block) + add_before_suite_hook(hook) end # Attaches a hook to be invoked before each top-level example group. def add_before_all_hook(hook) - @before_all_hooks << hook + @before_all_hooks.push(hook) end # Defines a block of code to execute before each top-level example group. def before_all(&block) - @before_all_hooks << ExampleGroupHook.new(&block) + hook = ExampleGroupHook.new(&block) + add_before_all_hook(hook) end # Attaches a hook to be invoked before every example. # The current example is provided as a block argument. def add_before_each_hook(hook) - @before_each_hooks << hook + @before_each_hooks.push(hook) end # Defines a block of code to execute before every. # The current example is provided as a block argument. def before_each(&block : Example -> _) - @before_each_hooks << ExampleHook.new(&block) + hook = ExampleHook.new(&block) + add_before_each_hook(hook) end # Attaches a hook to be invoked after all examples in the test suite. def add_after_suite_hook(hook) - @after_suite_hooks << hook + @after_suite_hooks.unshift(hook) end # Defines a block of code to execute after all examples in the test suite. def after_suite(&block) - @after_suite_hooks << ExampleGroupHook.new(&block) + hook = ExampleGroupHook.new(&block) + add_after_suite_hook(hook) end # Attaches a hook to be invoked after each top-level example group. def add_after_all_hook(hook) - @after_all_hooks << hook + @after_all_hooks.unshift(hook) end # Defines a block of code to execute after each top-level example group. def after_all(&block) - @after_all_hooks << ExampleGroupHook.new(&block) + hook = ExampleGroupHook.new(&block) + add_after_all_hook(hook) end # Attaches a hook to be invoked after every example. # The current example is provided as a block argument. def add_after_each_hook(hook) - @after_each_hooks << hook + @after_each_hooks.unshift(hook) end # Defines a block of code to execute after every example. # The current example is provided as a block argument. def after_each(&block : Example -> _) - @after_each_hooks << ExampleHook.new(&block) + hook = ExampleHook.new(&block) + add_after_each_hook(hook) end # Attaches a hook to be invoked around every example. # The current example in procsy form is provided as a block argument. def add_around_each_hook(hook) - @around_each_hooks << hook + @around_each_hooks.push(hook) end # Defines a block of code to execute around every example. # The current example in procsy form is provided as a block argument. - def around_each(&block : Example -> _) - @around_each_hooks << ExampleProcsyHook.new(label: "around_each", &block) + def around_each(&block : Example::Procsy -> _) + hook = ExampleProcsyHook.new(label: "around_each", &block) + add_around_each_hook(hook) end # Creates a configuration. diff --git a/src/spectator/events.cr b/src/spectator/events.cr deleted file mode 100644 index e77c61e..0000000 --- a/src/spectator/events.cr +++ /dev/null @@ -1,122 +0,0 @@ -require "./example_group_hook" -require "./example_hook" - -module Spectator - # Mix-in for managing events and hooks. - # This module is intended to be included by `ExampleGroup`. - module Events - # Defines an event for an example group. - # This event typically runs before or after an example group finishes. - # No contextual information (or example) is provided to the hooks. - # - # The *name* defines the name of the event. - # This must be unique across all events, not just group events. - # Four public methods are defined - two to add a hook and the others to trigger the event which calls every hook. - # One trigger method, prefixed with *call_* will always call the event hooks. - # The other trigger method, prefixed with *call_once_* will only call the event hooks on the first invocation. - # - # A block must be provided to this macro. - # The block defines the logic for invoking all of the hooks. - # A single argument is yielded to the block - the set of hooks for the event. - # - # ``` - # group_event some_hook do |hooks| - # hooks.each(&.call) - # end - # ``` - private macro group_event(name, &block) - @{{name.id}}_hooks = [] of ExampleGroupHook - @{{name.id}}_called = Atomic::Flag.new - - # Adds a hook to be invoked when the *{{name.id}}* event occurs. - def add_{{name.id}}_hook(hook : ExampleGroupHook) : Nil - @{{name.id}}_hooks << hook - end - - # Adds a hook to be invoked when the *{{name.id}}* event occurs. - # The hook is added to the front of the list. - def prepend_{{name.id}}_hook(hook : ExampleGroupHook) : Nil - @{{name.id}}_hooks.unshift(hook) - end - - # Defines a hook for the *{{name.id}}* event. - # The block of code given to this method is invoked when the event occurs. - def {{name.id}}(&block : -> _) : Nil - hook = ExampleGroupHook.new(label: {{name.stringify}}, &block) - add_{{name.id}}_hook(hook) - end - - # Signals that the *{{name.id}}* event has occurred. - # All hooks associated with the event will be called. - def call_{{name.id}} : Nil - handle_{{name.id}}(@{{name.id}}_hooks) - end - - # Signals that the *{{name.id}}* event has occurred. - # Only calls the hooks if the event hasn't been triggered before by this method. - # Returns true if the hooks were called and false if they weren't (called previously). - def call_once_{{name.id}} : Bool - first = @{{name.id}}_called.test_and_set - call_{{name.id}} if first - first - end - - # Logic specific to invoking the *{{name.id}}* hook. - private def handle_{{name.id}}({{block.args.splat}}) - {{block.body}} - end - end - - # Defines an event for an example. - # This event typically runs before or after an example finishes. - # The current example is provided to the hooks. - # - # The *name* defines the name of the event. - # This must be unique across all events. - # Three public methods are defined - two to add a hook and the other to trigger the event which calls every hook. - # - # A block must be provided to this macro. - # The block defines the logic for invoking all of the hooks. - # Two arguments are yielded to the block - the set of hooks for the event, and the current example. - # - # ``` - # example_event some_hook do |hooks, example| - # hooks.each(&.call(example)) - # end - # ``` - private macro example_event(name, &block) - @{{name.id}}_hooks = [] of ExampleHook - - # Adds a hook to be invoked when the *{{name.id}}* event occurs. - def add_{{name.id}}_hook(hook : ExampleHook) : Nil - @{{name.id}}_hooks << hook - end - - # Adds a hook to be invoked when the *{{name.id}}* event occurs. - # The hook is added to the front of the list. - def prepend_{{name.id}}_hook(hook : ExampleHook) : Nil - @{{name.id}}_hooks.unshift(hook) - end - - # Defines a hook for the *{{name.id}}* event. - # The block of code given to this method is invoked when the event occurs. - # The current example is provided as a block argument. - def {{name.id}}(&block : Example ->) : Nil - hook = ExampleHook.new(label: {{name.stringify}}, &block) - add_{{name.id}}_hook(hook) - end - - # Signals that the *{{name.id}}* event has occurred. - # All hooks associated with the event will be called. - # The *example* should be the current example. - def call_{{name.id}}(example : Example) : Nil - handle_{{name.id}}(@{{name.id}}_hooks, example) - end - - # Logic specific to invoking the *{{name.id}}* hook. - private def handle_{{name.id}}({{block.args.splat}}) - {{block.body}} - end - end - end -end diff --git a/src/spectator/example.cr b/src/spectator/example.cr index 513cca8..eacf2e1 100644 --- a/src/spectator/example.cr +++ b/src/spectator/example.cr @@ -98,14 +98,14 @@ module Spectator begin @result = Harness.run do - @group.try(&.call_once_before_all) + @group.try(&.call_before_all) if (parent = @group) - parent.call_around_each(self) { run_internal } + parent.call_around_each(procsy).call else run_internal end if (parent = @group) - parent.call_once_after_all if parent.finished? + parent.call_after_all if parent.finished? end end ensure @@ -191,6 +191,11 @@ module Spectator end end + # Creates a procsy from this example that runs the example. + def procsy + Procsy.new(self) { run_internal } + end + # Creates a procsy from this example and the provided block. def procsy(&block : ->) Procsy.new(self, &block) diff --git a/src/spectator/example_group.cr b/src/spectator/example_group.cr index fb3624f..4edd2e7 100644 --- a/src/spectator/example_group.cr +++ b/src/spectator/example_group.cr @@ -1,12 +1,12 @@ -require "./events" require "./example_procsy_hook" +require "./hooks" require "./node" module Spectator # Collection of examples and sub-groups. class ExampleGroup < Node include Enumerable(Node) - include Events + include Hooks include Iterable(Node) @nodes = [] of Node @@ -19,66 +19,44 @@ module Spectator # `ExampleGroup` manages the association of nodes to groups. protected setter group : ExampleGroup? - # Calls all hooks from the parent group if there is a parent. - # The *hook* is the method name of the group hook to invoke. - private macro call_parent_hooks(hook) - if (parent = @group) - parent.{{hook.id}} - end - end - - # Calls all hooks from the parent group if there is a parent. - # The *hook* is the method name of the example hook to invoke. - # The current *example* must be provided. - private macro call_parent_hooks(hook, example) - if (parent = @group) - parent.{{hook.id}}({{example}}) - end - end - - # Calls group hooks of the current group. - private def call_hooks(hooks) - hooks.each do |hook| - Log.trace { "Invoking hook #{hook}" } - hook.call - end - end - - # Calls example hooks of the current group. - # Requires the current *example*. - private def call_hooks(hooks, example) - hooks.each do |hook| - Log.trace { "Invoking hook #{hook}" } - hook.call(example) - end - end - - group_event before_all do |hooks| + define_hook before_all : ExampleGroupHook do Log.trace { "Processing before_all hooks for #{self}" } - call_parent_hooks(:call_once_before_all) - call_hooks(hooks) + @group.try &.call_before_all + before_all_hooks.each &.call_once end - group_event after_all do |hooks| + define_hook after_all : ExampleGroupHook do Log.trace { "Processing after_all hooks for #{self}" } - call_hooks(hooks) - call_parent_hooks(:call_once_after_all) if @group.try(&.finished?) + after_all_hooks.each &.call_once if finished? + if group = @group + group.call_after_all if group.finished? + end end - example_event before_each do |hooks, example| + define_hook before_each : ExampleHook do |example| Log.trace { "Processing before_each hooks for #{self}" } - call_parent_hooks(:call_before_each, example) - call_hooks(hooks, example) + @group.try &.call_before_each(example) + before_each_hooks.each &.call(example) end - example_event after_each do |hooks, example| + define_hook after_each : ExampleHook do |example| Log.trace { "Processing after_each hooks for #{self}" } - call_hooks(hooks, example) - call_parent_hooks(:call_after_each, example) + after_each_hooks.each &.call(example) + @group.try &.call_after_each(example) + end + + define_hook around_each : ExampleProcsyHook do |procsy| + Log.trace { "Processing around_each hooks for #{self}" } + + around_each_hooks.reverse_each { |hook| procsy = hook.wrap(procsy) } + if group = @group + procsy = group.call_around_each(procsy) + end + procsy end # Creates the example group. @@ -158,57 +136,5 @@ module Spectator @nodes << node node.group = self end - - @around_hooks = [] of ExampleProcsyHook - - # Adds a hook to be invoked when the *around_each* event occurs. - def add_around_each_hook(hook : ExampleProcsyHook) : Nil - @around_hooks << hook - end - - # Adds a hook to be invoked when the *around_each* event occurs. - # The hook is added to the front of the list. - def prepend_around_each_hook(hook : ExampleProcsyHook) : Nil - @around_hooks.unshift(hook) - end - - # Defines a hook for the *around_each* event. - # The block of code given to this method is invoked when the event occurs. - # The current example is provided as a block argument. - def around_each(&block) : Nil - hook = ExampleProcsyHook.new(label: "around_each", &block) - add_around_each_hook(hook) - end - - # Signals that the *around_each* event has occurred. - # All hooks associated with the event will be called. - def call_around_each(example, &block : -> _) : Nil - # Avoid overhead if there's no hooks. - return yield if @around_hooks.empty? - - # Start with a procsy that wraps the original code. - procsy = example.procsy(&block) - procsy = wrap_around_each(procsy) - procsy.call - end - - # Wraps a procsy with the *around_each* hooks from this example group. - # The returned procsy will call each hook then *procsy*. - protected def wrap_around_each(procsy) - # Avoid overhead if there's no hooks. - return procsy if @around_hooks.empty? - - # Wrap each hook with the next. - outer = procsy - @around_hooks.reverse_each do |hook| - outer = hook.wrap(outer) - end - - # If there's a parent, wrap the procsy with its hooks. - # Otherwise, return the outermost procsy. - return outer unless (parent = group?) - - parent.wrap_around_each(outer) - end end end diff --git a/src/spectator/example_group_builder.cr b/src/spectator/example_group_builder.cr index 155c3f1..b5286c1 100644 --- a/src/spectator/example_group_builder.cr +++ b/src/spectator/example_group_builder.cr @@ -2,6 +2,7 @@ require "./example_group" require "./example_group_hook" require "./example_hook" require "./example_procsy_hook" +require "./hooks" require "./label" require "./location" require "./metadata" @@ -12,12 +13,15 @@ module Spectator # Hooks and builders for child nodes can be added over time to this builder. # When done, call `#build` to produce an `ExampleGroup`. class ExampleGroupBuilder < NodeBuilder + include Hooks + + define_hook before_all : ExampleGroupHook + define_hook after_all : ExampleGroupHook, :prepend + define_hook before_each : ExampleHook + define_hook after_each : ExampleHook, :prepend + define_hook around_each : ExampleProcsyHook + @children = [] of NodeBuilder - @before_all_hooks = [] of ExampleGroupHook - @before_each_hooks = [] of ExampleHook - @after_all_hooks = [] of ExampleGroupHook - @after_each_hooks = [] of ExampleHook - @around_each_hooks = [] of ExampleProcsyHook # Creates the builder. # Initially, the builder will have no children and no hooks. @@ -25,62 +29,6 @@ module Spectator def initialize(@name : Label = nil, @location : Location? = nil, @metadata : Metadata = Metadata.new) end - # Attaches a hook to be invoked before any and all examples in the current group. - def add_before_all_hook(hook) - @before_all_hooks << hook - end - - # Defines a block of code to execute before any and all examples in the current group. - def before_all(&block) - @before_all_hooks << ExampleGroupHook.new(&block) - end - - # Attaches a hook to be invoked before every example in the current group. - # The current example is provided as a block argument. - def add_before_each_hook(hook) - @before_each_hooks << hook - end - - # Defines a block of code to execute before every example in the current group. - # The current example is provided as a block argument. - def before_each(&block : Example -> _) - @before_each_hooks << ExampleHook.new(&block) - end - - # Attaches a hook to be invoked after any and all examples in the current group. - def add_after_all_hook(hook) - @after_all_hooks << hook - end - - # Defines a block of code to execute after any and all examples in the current group. - def after_all(&block) - @after_all_hooks << ExampleGroupHook.new(&block) - end - - # Attaches a hook to be invoked after every example in the current group. - # The current example is provided as a block argument. - def add_after_each_hook(hook) - @after_each_hooks << hook - end - - # Defines a block of code to execute after every example in the current group. - # The current example is provided as a block argument. - def after_each(&block : Example -> _) - @after_each_hooks << ExampleHook.new(&block) - end - - # Attaches a hook to be invoked around every example in the current group. - # The current example in procsy form is provided as a block argument. - def add_around_each_hook(hook) - @around_each_hooks << hook - end - - # Defines a block of code to execute around every example in the current group. - # The current example in procsy form is provided as a block argument. - def around_each(&block : Example -> _) - @around_each_hooks << ExampleProcsyHook.new(label: "around_each", &block) - end - # Constructs an example group with previously defined attributes, children, and hooks. # The *parent* is an already constructed example group to nest the new example group under. # It can be nil if the new example group won't have a parent. @@ -100,11 +48,11 @@ module Spectator # Adds all previously configured hooks to an example group. private def apply_hooks(group) - @before_all_hooks.each { |hook| group.add_before_all_hook(hook) } - @before_each_hooks.each { |hook| group.add_before_each_hook(hook) } - @after_all_hooks.each { |hook| group.prepend_after_all_hook(hook) } - @after_each_hooks.each { |hook| group.prepend_after_each_hook(hook) } - @around_each_hooks.each { |hook| group.add_around_each_hook(hook) } + before_all_hooks.each { |hook| group.before_all(hook) } + before_each_hooks.each { |hook| group.before_each(hook) } + after_all_hooks.each { |hook| group.after_all(hook) } + after_each_hooks.each { |hook| group.after_each(hook) } + around_each_hooks.each { |hook| group.around_each(hook) } end end end diff --git a/src/spectator/example_group_hook.cr b/src/spectator/example_group_hook.cr index ef44e8b..bd6bac8 100644 --- a/src/spectator/example_group_hook.cr +++ b/src/spectator/example_group_hook.cr @@ -11,6 +11,7 @@ module Spectator getter! label : Label @proc : -> + @called = Atomic::Flag.new # Creates the hook with a proc. # The *proc* will be called when the hook is invoked. @@ -27,9 +28,18 @@ module Spectator # Invokes the hook. def call : Nil + @called.test_and_set @proc.call end + # Invokes the hook if it hasn't already been invoked. + # Returns true if the hook was invoked (first time being called). + def call_once : Bool + first = @called.test_and_set + @proc.call if first + first + end + # Produces the string representation of the hook. # Includes the location and label if they're not nil. def to_s(io) diff --git a/src/spectator/example_hook.cr b/src/spectator/example_hook.cr index f57c908..edebf26 100644 --- a/src/spectator/example_hook.cr +++ b/src/spectator/example_hook.cr @@ -4,25 +4,28 @@ require "./location" module Spectator # Information about a hook tied to an example and a proc to invoke it. class ExampleHook + # Method signature for example hooks. + alias Proc = Example -> + # Location of the hook in source code. getter! location : Location # User-defined description of the hook. getter! label : Label - @proc : Example -> + @proc : Proc # Creates the hook with a proc. # The *proc* will be called when the hook is invoked. # A *location* and *label* can be provided for debugging. - def initialize(@proc : (Example ->), *, @location : Location? = nil, @label : Label = nil) + def initialize(@proc : Proc, *, @location : Location? = nil, @label : Label = nil) end # Creates the hook with a block. # The block must take a single argument - the current example. # The block will be executed when the hook is invoked. # A *location* and *label* can be provided for debugging. - def initialize(*, @location : Location? = nil, @label : Label = nil, &block : Example -> _) + def initialize(*, @location : Location? = nil, @label : Label = nil, &block : Proc) @proc = block end diff --git a/src/spectator/hooks.cr b/src/spectator/hooks.cr new file mode 100644 index 0000000..1dd60db --- /dev/null +++ b/src/spectator/hooks.cr @@ -0,0 +1,114 @@ +module Spectator + # Mix-in for defining hook methods. + module Hooks + # Defines various methods for adding hooks of a specific type. + # + # The *declaration* defines the name and type of hook. + # It should be a type declaration in the form: `some_hook : ExampleHook`, + # where `some_hook` is the name of the hook, and `ExampleHook` is type type. + # + # A default order can be specified by *order*. + # The *order* argument must be *append* or *prepend*. + # This indicates the order hooks are added by default when called by client code. + # + # Multiple methods are generated. + # The primary methods will be named the same as the hook (from *declaration*). + # These take a pre-built hook instance, or arguments to pass to the hook type's initializer. + # The new hook is added a collection in the order specified by *order*. + # + # Alternate methods are also generated that add hooks in the opposite order of *order*. + # These are prefixed with the opposite order word. + # For instance, when *order* is "append", the prefix will be "prepend", + # resulting in a method named `prepend_some_hook`. + # + # A private getter method is created so that the hooks can be accessed if needed. + # The getter method has `_hooks` appended to the hook name. + # For instance, if the *declaration* contains `important_thing`, then the getter is `important_thing_hooks`. + # + # Lastly, an optional block can be provided. + # If given, a protected method will be defined with the block's contents. + # This method typically operates on (calls) the hooks. + # The private getter method mentioned above can be used to access the hooks. + # Any block arguments will be used as argument in the method. + # The method name has the prefix `call_` followed by the hook name. + # + # ``` + # define_hook important_event : ImportantHook do |example| + # important_event_hooks.each &.call(example) + # end + # + # # ... + # + # important_event do |example| + # puts "An important event occurred for #{example}" + # end + # ``` + macro define_hook(declaration, order = :append, &block) + {% if order.id == :append.id + method = :push.id + alt_method = :unshift.id + alt_prefix = :prepend.id + elsif order.id == :prepend.id + method = :unshift.id + alt_method = :push.id + alt_prefix = :append.id + else + raise "Unknown hook order type - #{order}" + end %} + + # Retrieves all registered hooks for {{declaration.var}}. + protected getter {{declaration.var}}_hooks = Deque({{declaration.type}}).new + + # Registers a new "{{declaration.var}}" hook. + # The hook will be {{order.id}}ed to the list. + def {{declaration.var}}(hook : {{declaration.type}}) : Nil + @{{declaration.var}}_hooks.{{method}}(hook) + end + + # Registers a new "{{declaration.var}}" hook. + # The hook will be {{order.id}}ed to the list. + # A new hook will be created by passing args to `{{declaration.type}}.new`. + def {{declaration.var}}(*args, **kwargs) : Nil + hook = {{declaration.type}}.new(*args, **kwargs) + {{declaration.var}}(hook) + end + + # Registers a new "{{declaration.var}}" hook. + # The hook will be {{order.id}}ed to the list. + # A new hook will be created by passing args to `{{declaration.type}}.new`. + def {{declaration.var}}(*args, **kwargs, &block) : Nil + hook = {{declaration.type}}.new(*args, **kwargs, &block) + {{declaration.var}}(hook) + end + + # Registers a new "{{declaration.var}}" hook. + # The hook will be {{alt_prefix}}ed to the list. + def {{alt_prefix}}_{{declaration.var}}(hook : {{declaration.type}}) : Nil + @{{declaration.var}}_hooks.{{alt_method}}(hook) + end + + # Registers a new "{{declaration.var}}" hook. + # The hook will be {{alt_prefix}}ed to the list. + # A new hook will be created by passing args to `{{declaration.type}}.new`. + def {{alt_prefix}}_{{declaration.var}}(*args, **kwargs) : Nil + hook = {{declaration.type}}.new(*args, **kwargs) + {{alt_prefix}}_{{declaration.var}}(hook) + end + + # Registers a new "{{declaration.var}}" hook. + # The hook will be {{alt_prefix}}ed to the list. + # A new hook will be created by passing args to `{{declaration.type}}.new`. + def {{alt_prefix}}_{{declaration.var}}(*args, **kwargs, &block) : Nil + hook = {{declaration.type}}.new(*args, **kwargs, &block) + {{alt_prefix}}_{{declaration.var}}(hook) + end + + {% if block %} + # Handles calling all "{{declaration.var}}" hooks. + protected def call_{{declaration.var}}({{block.args.splat}}) + {{block.body}} + end + {% end %} + end + end +end diff --git a/src/spectator/includes.cr b/src/spectator/includes.cr index 7f87649..d4fdaa6 100644 --- a/src/spectator/includes.cr +++ b/src/spectator/includes.cr @@ -14,7 +14,6 @@ require "./context_delegate" require "./context_method" require "./dsl" require "./error_result" -require "./events" require "./example_context_delegate" require "./example_context_method" require "./example" @@ -30,6 +29,7 @@ require "./expression" require "./fail_result" require "./formatting" require "./harness" +require "./hooks" require "./label" require "./lazy" require "./lazy_wrapper" diff --git a/src/spectator/spec_builder.cr b/src/spectator/spec_builder.cr index 8bbf408..55c35fc 100644 --- a/src/spectator/spec_builder.cr +++ b/src/spectator/spec_builder.cr @@ -4,6 +4,7 @@ require "./example_builder" require "./example_context_method" require "./example_group" require "./example_group_builder" +require "./hooks" require "./iterative_example_group_builder" require "./pending_example_builder" require "./spec" @@ -17,6 +18,8 @@ module Spectator class SpecBuilder Log = ::Spectator::Log.for(self) + delegate before_all, after_all, before_each, after_each, around_each, to: current + # Stack tracking the current group. # The bottom of the stack (first element) is the root group. # The root group should never be removed. @@ -44,23 +47,7 @@ module Spectator raise "Mismatched start and end groups" unless root? group = root.build - - # Apply hooks from configuration. - config.before_suite_hooks.each { |hook| group.prepend_before_all_hook(hook) } - config.after_suite_hooks.each { |hook| group.prepend_after_all_hook(hook) } - config.before_each_hooks.each { |hook| group.prepend_before_each_hook(hook) } - config.after_each_hooks.each { |hook| group.prepend_after_each_hook(hook) } - config.around_each_hooks.each { |hook| group.prepend_around_each_hook(hook) } - - # `before_all` and `after_all` hooks are slightly different. - # They are applied to every top-level group (groups just under root). - group.each do |node| - next unless node.is_a?(Events) - - config.before_all_hooks.reverse_each { |hook| node.prepend_before_all_hook(hook) } - config.after_all_hooks.reverse_each { |hook| node.prepend_after_all_hook(hook) } - end - + apply_config_hooks(group) Spec.new(group, config) end @@ -153,94 +140,60 @@ module Spectator current << PendingExampleBuilder.new(name, location, metadata, reason) end - # Attaches a hook to be invoked before any and all examples in the test suite. - def before_suite(hook) - Log.trace { "Add before_suite hook #{hook}" } - root.add_before_all_hook(hook) + # Registers a new "before_suite" hook. + # The hook will be appended to the list. + # A new hook will be created by passing args to `ExampleGroupHook.new`. + def before_suite(*args, **kwargs) : Nil + root.before_all(*args, **kwargs) end - # Defines a block of code to execute before any and all examples in the test suite. - def before_suite(&block) - Log.trace { "Add before_suite hook" } - root.before_all(&block) + # Registers a new "before_suite" hook. + # The hook will be appended to the list. + # A new hook will be created by passing args to `ExampleGroupHook.new`. + def before_suite(*args, **kwargs, &block) : Nil + root.before_all(*args, **kwargs, &block) end - # Attaches a hook to be invoked before any and all examples in the current group. - def before_all(hook) - Log.trace { "Add before_all hook #{hook}" } - current.add_before_all_hook(hook) + # Registers a new "before_suite" hook. + # The hook will be prepended to the list. + # A new hook will be created by passing args to `ExampleGroupHook.new`. + def prepend_before_suite(*args, **kwargs) : Nil + root.prepend_before_all(*args, **kwargs) end - # Defines a block of code to execute before any and all examples in the current group. - def before_all(&block) - Log.trace { "Add before_all hook" } - current.before_all(&block) + # Registers a new "before_suite" hook. + # The hook will be prepended to the list. + # A new hook will be created by passing args to `ExampleGroupHook.new`. + def prepend_before_suite(*args, **kwargs, &block) : Nil + root.prepend_before_all(*args, **kwargs, &block) end - # Attaches a hook to be invoked before every example in the current group. - # The current example is provided as a block argument. - def before_each(hook) - Log.trace { "Add before_each hook #{hook}" } - current.add_before_each_hook(hook) + # Registers a new "after_suite" hook. + # The hook will be prepended to the list. + # A new hook will be created by passing args to `ExampleGroupHook.new`. + def after_suite(*args, **kwargs) : Nil + root.before_all(*args, **kwargs) end - # Defines a block of code to execute before every example in the current group. - # The current example is provided as a block argument. - def before_each(&block : Example -> _) - Log.trace { "Add before_each hook block" } - current.before_each(&block) + # Registers a new "after_suite" hook. + # The hook will be prepended to the list. + # A new hook will be created by passing args to `ExampleGroupHook.new`. + def after_suite(*args, **kwargs, &block) : Nil + root.after_all(*args, **kwargs, &block) end - # Attaches a hook to be invoked after any and all examples in the test suite. - def after_suite(hook) - Log.trace { "Add after_suite hook #{hook}" } - root.add_after_all_hook(hook) + # Registers a new "after_suite" hook. + # The hook will be appended to the list. + # A new hook will be created by passing args to `ExampleGroupHook.new`. + def append_after_suite(*args, **kwargs) : Nil + root.append_after_all(*args, **kwargs) end - # Defines a block of code to execute after any and all examples in the test suite. - def after_suite(&block) - Log.trace { "Add after_suite hook" } - root.after_all(&block) - end - - # Attaches a hook to be invoked after any and all examples in the current group. - def after_all(hook) - Log.trace { "Add after_all hook #{hook}" } - current.add_after_all_hook(hook) - end - - # Defines a block of code to execute after any and all examples in the current group. - def after_all(&block) - Log.trace { "Add after_all hook" } - current.after_all(&block) - end - - # Attaches a hook to be invoked after every example in the current group. - # The current example is provided as a block argument. - def after_each(hook) - Log.trace { "Add after_each hook #{hook}" } - current.add_after_each_hook(hook) - end - - # Defines a block of code to execute after every example in the current group. - # The current example is provided as a block argument. - def after_each(&block : Example -> _) - Log.trace { "Add after_each hook" } - current.after_each(&block) - end - - # Attaches a hook to be invoked around every example in the current group. - # The current example in procsy form is provided as a block argument. - def around_each(hook) - Log.trace { "Add around_each hook #{hook}" } - current.add_around_each_hook(hook) - end - - # Defines a block of code to execute around every example in the current group. - # The current example in procsy form is provided as a block argument. - def around_each(&block : Example -> _) - Log.trace { "Add around_each hook" } - current.around_each(&block) + # Registers a new "after_suite" hook. + # The hook will be appended to the list. + # A new hook will be created by passing args to `ExampleGroupHook.new`. + def append_after_suite(*args, **kwargs, &block) : Nil + root.append_after_all(*args, **kwargs, &block) end # Builds the configuration to use for the spec. @@ -279,5 +232,23 @@ module Spectator private def config : Config @config || Config.default end + + # Copy all hooks from config to top-level group. + private def apply_config_hooks(group) + config.before_suite_hooks.reverse_each { |hook| group.prepend_before_all(hook) } + config.after_suite_hooks.each { |hook| group.after_all(hook) } + config.before_each_hooks.reverse_each { |hook| group.prepend_before_each(hook) } + config.after_each_hooks.each { |hook| group.after_each(hook) } + config.around_each_hooks.reverse_each { |hook| group.prepend_around_each(hook) } + + # `before_all` and `after_all` hooks from config are slightly different. + # They are applied to every top-level group (groups just under root). + group.each do |node| + next unless node.is_a?(Hooks) + + config.before_all_hooks.reverse_each { |hook| node.prepend_before_all(hook.dup) } + config.after_all_hooks.each { |hook| node.after_all(hook.dup) } + end + end end end