Nest spec types

This commit is contained in:
Michael Miller 2021-01-21 00:03:39 -07:00
parent ce6f77656a
commit b7ed4ec14c
No known key found for this signature in database
GPG key ID: F9A0C5C65B162436
12 changed files with 313 additions and 299 deletions

View file

@ -1,5 +1,7 @@
require "../spec"
require "../spec_builder"
require "../example_group_hook"
require "../example_hook"
require "../example_procsy_hook"
require "../spec/builder"
module Spectator::DSL
# Incrementally builds up a test spec from the DSL.
@ -8,7 +10,7 @@ module Spectator::DSL
extend self
# Underlying spec builder.
@@builder = SpecBuilder.new
@@builder = Spec::Builder.new
# Defines a new example group and pushes it onto the group stack.
# Examples and groups defined after calling this method will be nested under the new group.

View file

@ -49,7 +49,7 @@ module Spectator::DSL
# Inserts the correct representation of a example's name.
# If *what* is a string, then it is dropped in as-is.
# For anything else, it is stringified.
# This is intended to be used to convert a description from the spec DSL to `SpecNode#name`.
# This is intended to be used to convert a description from the spec DSL to `Spec::Node#name`.
private macro _spectator_example_name(what)
{% if what.is_a?(StringLiteral) ||
what.is_a?(StringInterpolation) ||

View file

@ -32,7 +32,7 @@ module Spectator::DSL
# If *what* appears to be a type name, it will be symbolized.
# If it's a string, then it is dropped in as-is.
# For anything else, it is stringified.
# This is intended to be used to convert a description from the spec DSL to `SpecNode#name`.
# This is intended to be used to convert a description from the spec DSL to `Spec::Node#name`.
private macro _spectator_group_name(what)
{% if (what.is_a?(Generic) ||
what.is_a?(Path) ||

View file

@ -4,11 +4,11 @@ require "./harness"
require "./pending_result"
require "./result"
require "./source"
require "./spec_node"
require "./spec/node"
module Spectator
# Standard example that runs a test case.
class Example < SpecNode
class Example < Spec::Node
# Currently running example.
class_getter! current : Example

View file

@ -1,15 +1,15 @@
require "./events"
require "./spec_node"
require "./example_procsy_hook"
require "./spec/node"
module Spectator
# Collection of examples and sub-groups.
class ExampleGroup < SpecNode
include Enumerable(SpecNode)
class ExampleGroup < Spec::Node
include Enumerable(Spec::Node)
include Events
include Iterable(SpecNode)
include Iterable(Spec::Node)
@nodes = [] of SpecNode
@nodes = [] of Spec::Node
group_event before_all do |hooks|
Log.trace { "Processing before_all hooks for #{self}" }
@ -65,7 +65,7 @@ module Spectator
# Removes the specified *node* from the group.
# The node will be unassigned from this group.
def delete(node : SpecNode)
def delete(node : Spec::Node)
# Only remove from the group if it is associated with this group.
return unless node.group == self
@ -92,7 +92,7 @@ module Spectator
# Assigns the node to this group.
# If the node already belongs to a group,
# it will be removed from the previous group before adding it to this group.
def <<(node : SpecNode)
def <<(node : Spec::Node)
# Remove from existing group if the node is part of one.
if (previous = node.group?)
previous.delete(node)

View file

@ -1,6 +1,6 @@
require "./example"
require "./example_group"
require "./spec_node"
require "./spec/node"
module Spectator
# Iterates through all examples in a group and its nested groups.
@ -9,12 +9,12 @@ module Spectator
# Stack that contains the iterators for each group.
# A stack is used to track where in the tree this iterator is.
@stack : Array(Iterator(SpecNode))
@stack : Array(Iterator(Spec::Node))
# Creates a new iterator.
# The *group* is the example group to iterate through.
def initialize(@group : ExampleGroup)
iter = @group.each.as(Iterator(SpecNode))
iter = @group.each.as(Iterator(Spec::Node))
@stack = [iter]
end
@ -39,7 +39,7 @@ module Spectator
# Restart the iterator at the beginning.
def rewind
# Same code as `#initialize`, but return self.
iter = @group.each.as(Iterator(SpecNode))
iter = @group.each.as(Iterator(Spec::Node))
@stack = [iter]
self
end

View file

@ -19,14 +19,7 @@ module Spectator
examples = ExampleIterator.new(@root).to_a
@config.shuffle!(examples)
end
private struct Runner
def initialize(@examples : Array(Example))
end
def run
@examples.each(&.run)
end
end
end
end
require "./spec/*"

View file

@ -0,0 +1,206 @@
require "../config"
require "../config_builder"
require "../example"
require "../example_context_method"
require "../example_group"
require "../spec"
module Spectator
class Spec
# Progressively builds a test spec.
#
# A stack is used to track the current example group.
# Adding an example or group will nest it under the group at the top of the stack.
class Builder
Log = ::Spectator::Log.for(self)
# Stack tracking the current group.
# The bottom of the stack (first element) is the root group.
# The root group should never be removed.
# The top of the stack (last element) is the current group.
# New examples should be added to the current group.
@group_stack : Deque(ExampleGroup)
# Configuration for the spec.
@config : Config?
# Creates a new spec builder.
# A root group is pushed onto the group stack.
def initialize
root_group = ExampleGroup.new
@group_stack = Deque(ExampleGroup).new
@group_stack.push(root_group)
end
# Constructs the test spec.
# Returns the spec instance.
#
# Raises an error if there were not symmetrical calls to `#start_group` and `#end_group`.
# This would indicate a logical error somewhere in Spectator or an extension of it.
def build : Spec
raise "Mismatched start and end groups" unless root?
Spec.new(root_group, config)
end
# Defines a new example group and pushes it onto the group stack.
# Examples and groups defined after calling this method will be nested under the new group.
# The group will be finished and popped off the stack when `#end_example` is called.
#
# The *name* is the name or brief description of the group.
# This should be a symbol when describing a type - the type name is represented as a symbol.
# Otherwise, a string should be used.
#
# The *source* optionally defined where the group originates in source code.
#
# The newly created group is returned.
# It shouldn't be used outside of this class until a matching `#end_group` is called.
def start_group(name, source = nil) : ExampleGroup
Log.trace { "Start group: #{name.inspect} @ #{source}" }
ExampleGroup.new(name, source, current_group).tap do |group|
@group_stack << group
end
end
# Completes a previously defined example group and pops it off the group stack.
# Be sure to call `#start_group` and `#end_group` symmetically.
#
# The completed group will be returned.
# At this point, it is safe to use the group.
# All of its examples and sub-groups have been populated.
def end_group : ExampleGroup
Log.trace { "End group: #{current_group}" }
raise "Can't pop root group" if root?
@group_stack.pop
end
# Defines a new example.
# The example is added to the group currently on the top of the stack.
#
# The *name* is the name or brief description of the example.
# This should be a string or nil.
# When nil, the example's name will be populated by the first expectation run inside of the test code.
#
# The *source* optionally defined where the example originates in source code.
#
# The *context* is an instance of the context the test code should run in.
# See `Context` for more information.
#
# A block must be provided.
# It will be yielded two arguments - the example created by this method, and the *context* argument.
# The return value of the block is ignored.
# It is expected that the test code runs when the block is called.
#
# The newly created example is returned.
def add_example(name, source, context, &block : Example -> _) : Example
Log.trace { "Add example: #{name} @ #{source}" }
Example.new(context, block, name, source, current_group)
# The example is added to the current group by `Example` initializer.
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_group.add_before_all_hook(hook)
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_group.before_all(&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_group.add_before_each_hook(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 -> _)
Log.trace { "Add before_each hook block" }
current_group.before_each(&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_group.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_group.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_group.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_group.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_group.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_group.around_each(&block)
end
# Builds the configuration to use for the spec.
# A `ConfigBuilder` is yielded to the block provided to this method.
# That builder will be used to create the configuration.
def config
builder = ConfigBuilder.new
yield builder
@config = builder.build
end
# Sets the configuration of the spec.
# This configuration controls how examples run.
def config=(config)
@config = config
end
# Checks if the current group is the root group.
private def root?
@group_stack.size == 1
end
# Retrieves the root group.
private def root_group
@group_stack.first
end
# Retrieves the current group, which is at the top of the stack.
# This is the group that new examples should be added to.
private def current_group
@group_stack.last
end
# Retrieves the configuration.
# If one wasn't previously set, a default configuration is used.
private def config : Config
@config || ConfigBuilder.default
end
end
end
end

View file

@ -0,0 +1,71 @@
require "../label"
require "../source"
module Spectator
class Spec
# A single item in a test spec.
# This is commonly an `Example` or `ExampleGroup`,
# but can be anything that should be iterated over when running the spec.
abstract class Node
# Location of the node in source code.
getter! source : Source
# User-provided name or description of the node.
# This does not include the group name or descriptions.
# Use `#to_s` to get the full name.
#
# This value will be nil if no name was provided.
# In this case, and the node is a runnable example,
# the name should be set to the description
# of the first matcher that runs in the test case.
#
# If this value is a `Symbol`, the user specified a type for the name.
getter! name : Label
# Updates the name of the node.
protected def name=(@name : String)
end
# Group the node belongs to.
getter! group : ExampleGroup
# Assigns the node to the specified *group*.
# This is an internal method and should only be called from `ExampleGroup`.
# `ExampleGroup` manages the association of nodes to groups.
protected setter group : ExampleGroup?
# Creates the node.
# The *name* describes the purpose of the node.
# It can be a `Symbol` to describe a type.
# The *source* tracks where the node exists in source code.
# The node will be assigned to *group* if it is provided.
def initialize(@name : Label = nil, @source : Source? = nil, group : ExampleGroup? = nil)
# Ensure group is linked.
group << self if group
end
# Indicates whether the node has completed.
abstract def finished? : Bool
# Constructs the full name or description of the node.
# This prepends names of groups this node is part of.
def to_s(io)
name = @name
# Prefix with group's full name if the node belongs to a group.
if (group = @group)
group.to_s(io)
# Add padding between the node names
# only if the names don't appear to be symbolic.
# Skip blank group names (like the root group).
io << ' ' unless !group.name? || # ameba:disable Style/NegatedConditionsInUnless
(group.name?.is_a?(Symbol) && name.is_a?(String) &&
(name.starts_with?('#') || name.starts_with?('.')))
end
name.to_s(io)
end
end
end
end

View file

@ -0,0 +1,14 @@
require "../example"
module Spectator
class Spec
private struct Runner
def initialize(@examples : Array(Example))
end
def run
@examples.each(&.run)
end
end
end
end

View file

@ -1,203 +0,0 @@
require "./config"
require "./config_builder"
require "./example"
require "./example_context_method"
require "./example_group"
module Spectator
# Progressively builds a test spec.
#
# A stack is used to track the current example group.
# Adding an example or group will nest it under the group at the top of the stack.
class SpecBuilder
Log = ::Spectator::Log.for(self)
# Stack tracking the current group.
# The bottom of the stack (first element) is the root group.
# The root group should never be removed.
# The top of the stack (last element) is the current group.
# New examples should be added to the current group.
@group_stack : Deque(ExampleGroup)
# Configuration for the spec.
@config : Config?
# Creates a new spec builder.
# A root group is pushed onto the group stack.
def initialize
root_group = ExampleGroup.new
@group_stack = Deque(ExampleGroup).new
@group_stack.push(root_group)
end
# Constructs the test spec.
# Returns the spec instance.
#
# Raises an error if there were not symmetrical calls to `#start_group` and `#end_group`.
# This would indicate a logical error somewhere in Spectator or an extension of it.
def build : Spec
raise "Mismatched start and end groups" unless root?
Spec.new(root_group, config)
end
# Defines a new example group and pushes it onto the group stack.
# Examples and groups defined after calling this method will be nested under the new group.
# The group will be finished and popped off the stack when `#end_example` is called.
#
# The *name* is the name or brief description of the group.
# This should be a symbol when describing a type - the type name is represented as a symbol.
# Otherwise, a string should be used.
#
# The *source* optionally defined where the group originates in source code.
#
# The newly created group is returned.
# It shouldn't be used outside of this class until a matching `#end_group` is called.
def start_group(name, source = nil) : ExampleGroup
Log.trace { "Start group: #{name.inspect} @ #{source}" }
ExampleGroup.new(name, source, current_group).tap do |group|
@group_stack << group
end
end
# Completes a previously defined example group and pops it off the group stack.
# Be sure to call `#start_group` and `#end_group` symmetically.
#
# The completed group will be returned.
# At this point, it is safe to use the group.
# All of its examples and sub-groups have been populated.
def end_group : ExampleGroup
Log.trace { "End group: #{current_group}" }
raise "Can't pop root group" if root?
@group_stack.pop
end
# Defines a new example.
# The example is added to the group currently on the top of the stack.
#
# The *name* is the name or brief description of the example.
# This should be a string or nil.
# When nil, the example's name will be populated by the first expectation run inside of the test code.
#
# The *source* optionally defined where the example originates in source code.
#
# The *context* is an instance of the context the test code should run in.
# See `Context` for more information.
#
# A block must be provided.
# It will be yielded two arguments - the example created by this method, and the *context* argument.
# The return value of the block is ignored.
# It is expected that the test code runs when the block is called.
#
# The newly created example is returned.
def add_example(name, source, context, &block : Example -> _) : Example
Log.trace { "Add example: #{name} @ #{source}" }
Example.new(context, block, name, source, current_group)
# The example is added to the current group by `Example` initializer.
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_group.add_before_all_hook(hook)
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_group.before_all(&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_group.add_before_each_hook(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 -> _)
Log.trace { "Add before_each hook block" }
current_group.before_each(&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_group.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_group.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_group.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_group.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_group.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_group.around_each(&block)
end
# Builds the configuration to use for the spec.
# A `ConfigBuilder` is yielded to the block provided to this method.
# That builder will be used to create the configuration.
def config
builder = ConfigBuilder.new
yield builder
@config = builder.build
end
# Sets the configuration of the spec.
# This configuration controls how examples run.
def config=(config)
@config = config
end
# Checks if the current group is the root group.
private def root?
@group_stack.size == 1
end
# Retrieves the root group.
private def root_group
@group_stack.first
end
# Retrieves the current group, which is at the top of the stack.
# This is the group that new examples should be added to.
private def current_group
@group_stack.last
end
# Retrieves the configuration.
# If one wasn't previously set, a default configuration is used.
private def config : Config
@config || ConfigBuilder.default
end
end
end

View file

@ -1,69 +0,0 @@
require "./label"
require "./source"
module Spectator
# A single item in a test spec.
# This is commonly an `Example` or `ExampleGroup`,
# but can be anything that should be iterated over when running the spec.
abstract class SpecNode
# Location of the node in source code.
getter! source : Source
# User-provided name or description of the node.
# This does not include the group name or descriptions.
# Use `#to_s` to get the full name.
#
# This value will be nil if no name was provided.
# In this case, and the node is a runnable example,
# the name should be set to the description
# of the first matcher that runs in the test case.
#
# If this value is a `Symbol`, the user specified a type for the name.
getter! name : Label
# Updates the name of the node.
protected def name=(@name : String)
end
# Group the node belongs to.
getter! group : ExampleGroup
# Assigns the node to the specified *group*.
# This is an internal method and should only be called from `ExampleGroup`.
# `ExampleGroup` manages the association of nodes to groups.
protected setter group : ExampleGroup?
# Creates the node.
# The *name* describes the purpose of the node.
# It can be a `Symbol` to describe a type.
# The *source* tracks where the node exists in source code.
# The node will be assigned to *group* if it is provided.
def initialize(@name : Label = nil, @source : Source? = nil, group : ExampleGroup? = nil)
# Ensure group is linked.
group << self if group
end
# Indicates whether the node has completed.
abstract def finished? : Bool
# Constructs the full name or description of the node.
# This prepends names of groups this node is part of.
def to_s(io)
name = @name
# Prefix with group's full name if the node belongs to a group.
if (group = @group)
group.to_s(io)
# Add padding between the node names
# only if the names don't appear to be symbolic.
# Skip blank group names (like the root group).
io << ' ' unless !group.name? || # ameba:disable Style/NegatedConditionsInUnless
(group.name?.is_a?(Symbol) && name.is_a?(String) &&
(name.starts_with?('#') || name.starts_with?('.')))
end
name.to_s(io)
end
end
end