module Spectator::DSL::StructureDSL

Overview

Domain specific language for the main structure of a spec. The primary components of this are #describe, #context, and #it.

These macros define modules and classes. Those modules and classes are used to create the test cases.

A class is created for every block of code that contains test code. An #it block creates a class derived from RunnableExample. A #pending block creates a class derived from PendingExample. The classes are built so that they run the example's code when invoked. However, the actual example code is placed into a separate "wrapper" class. This is done to avoid overlap with the Spectator namespace. The example code ends up having visibility only into itself and the DSL.

Here's some skeleton code to demonstrate this:

it "does something" do
  # Test code goes here...
end

becomes...

# Class describing the example
# and provides a means of running the test.
# Typically every class, module, and method
# that the user might see or be able to reference is obscured.
# Fresh variables from Crystal's macros are used to achive this.
# It makes debugging Spectator more difficult,
# but prevents name collision with user code.
class Example123 < RunnableExample
  def initialize(group, sample_values)
    # group and sample_values are covered later.
    super
    @instance = Test123.new(sample_values)
  end

  # Returns the text provided by the user.
  # This isn't stored as a member
  # so that it can be referenced directly in compiled code.
  def what
    "does something"
  end

  # This method is called by `RunnableExample`
  # when the example code should be ran.
  def run_instance
    @instance._run123
  end
end

# Wrapper class for the example code.
# This isolates it from Spectator's internals.
class Test123
  include Context123 # More on this in a bit.
  include ExampleDSL # Include DSL for the example code.

  # Generated method name to avoid conflicts.
  def _run123
    # Test code goes here...
  end
end

Modules are used to provide context and share methods across examples. They are used as mix-ins for the example code. The example code wrapper class includes its parent module. This allows the example to access anything that was defined in the same context. Contexts can be nested, and this is achieved by including the parent module. Whenever a module or class is defined, it includes its parent so that functionality can be inherited.

For example:

describe "#foo" do
  subject { described_class.foo(value) }

  context "when given :bar" do
    let(value) { :bar }

    it "does something" do
      # ...
    end
  end
end

becomes...

# describe "#foo"
module Context123
  # Start a new group.
  # More on this in a bit.
  Builder.start_group("#foo")

  def subject
    described_class.foo(value)
  end

  # context "when given :bar"
  module Context456
    include Context123 # Inherit parent module.

    # Start a nested group.
    Builder.start_group("when given :bar")

    def value
      :bar
    end

    # Wrapper class for the test case.
    class Test456
      include Context456 # Include context.

      # Rest of test code...
    end

    # Example class for the test case.
    class Example456 < RunnableExample
      # Rest of example code...
    end

    # Add example to group.
    Builder.add_example(Example456)

    # End "when given :bar" group.
    Builder.end_group
  end

  # End "#foo" group.
  Builder.end_group
end

In addition to providing modules as mix-ins, example groups are defined with #describe and #context. The DSL makes use of Builder to construct the run-time portion of the spec. As groups are defined, they are pushed on a stack and popped off after everything nested in them is defined. Builder tracks the current group (top of the stack). This way, examples, hooks, nested groups, and other items can be added to it. Groups and examples are nested in a parent group. The only group that isn't nested is the root group - RootExampleGroup.

Some example groups make use of sample values. Sample values are a collection of test values that can be used in examples. For more information, see Internals::SampleValues.

Defined in:

spectator/dsl/structure_dsl.cr

Macro Summary

Macro Detail

macro after_all(&block) #

Creates a hook that will run following all examples in the group. The block of code provided to this macro is used for the hook. The hook is executed only once. Even if an example fails or raises an error, the hook will run.

NOTE Inside a #sample block, the hook is run once, not once per iteration.

This can be useful to cleanup after testing:

after_all { Thing.stop } # 2

it "does something" do
  # 1
end

The hook cannot use values and methods in the group like examples can. This is because the hook is not associated with one example, but many.

let(array) { [1, 2, 3] }
after_all { array << 4 } # *ERROR!*

If multiple #after_all blocks are specified, then they are run in the order they were defined.

after_all { Thing.first }  # 1
after_all { Thing.second } # 2

With nested groups, the inner blocks will run first.

describe Something do
  after_all { Something.cleanup } # 3

  describe "#foo" do
    after_all { Something.stop } # 2

    it "does a cool thing" do
      # 1
    end
  end
end

NOTE Post-conditions should not be checked in an #after_all or related block. Errors that occur in an #after_all block will halt testing and abort with an error. Use #post_condition instead for post-test checks.

See also: #before_all, #before_each, #after_each, and #around_each.


[View source]
macro after_each #

Creates a hook that will run following every example in the group. The block of code provided to this macro is used for the hook. The hook is executed once per example in the group (and sub-groups). Even if an example fails or raises an error, the hook will run.

NOTE Inside a #sample block, the hook is run after every example of every iteration.

This can be useful for cleaning up environments after tests:

after_each { Thing.stop } # 2

it "does something" do
  # 1
end

The hook can use values and methods in the group like examples can. It is called in the same scope as the example code.

let(array) { [1, 2, 3] }
after_each { array << 4 }

If multiple #after_each blocks are specified, then they are run in the order they were defined.

after_each { Thing.first }  # 1
after_each { Thing.second } # 2

With nested groups, the inner blocks will run first.

describe Something do
  after_each { Something.cleanup } # 3

  describe "#foo" do
    after_each { Something.stop } # 2

    it "does a cool thing" do
      # 1
    end
  end
end

NOTE Post-conditions should not be checked in an #after_each or related block. Errors that occur in an #after_each block will halt testing and abort with an error. Use #post_condition instead for post-test checks.

See also: #before_all, #before_each, #after_all, and #around_each.


[View source]
macro around_each(&block) #

Creates a hook that will run for every example in the group. This can be used as an alternative to #before_each and #after_each. The block of code provided to this macro is used for the hook. The hook is executed once per example in the group (and sub-groups). If the hook raises an exception, the current example will be skipped and marked as an error.

Sometimes the test code must run in a block:

around_each do |proc|
  Thing.run do
    proc.call
  end
end

it "does something" do
  # ...
end

The block argument is provided a Proc. To run the example, that proc must be called. Make sure to call it!

around_each do |proc|
  Thing.run
  # Missing proc.call
end

it "does something" do
  # Whoops! This is never run.
end

The hook can use values and methods in the group like examples can. It is called in the same scope as the example code.

let(array) { [1, 2, 3] }
around_each do |proc|
  array << 4
  proc.call
  array.pop
end

If multiple #around_each blocks are specified, then they are run in the order they were defined.

around_each { |p| p.call } # 1
around_each { |p| p.call } # 2

With nested groups, the outer blocks will run first. But the return from calling the proc will be in the oposite order.

describe Something do
  around_each do |proc|
    Thing.foo # 1
    proc.call
    Thing.bar # 5
  end

  describe "#foo" do
    around_each do |proc|
      Thing.foo # 2
      proc.call
      Thing.bar # 4
    end

    it "does a cool thing" do
      # 3
    end
  end
end

NOTE Pre- and post-conditions should not be checked in an #around_each or similar block. Errors that occur in an #around_each block will halt testing and abort with an error. Use #pre_condition and #post_condition instead for pre- and post-test checks.

See also: #before_all, #before_each, #after_all, and #after_each.


[View source]
macro before_all(&block) #

Creates a hook that will run prior to any example in the group. The block of code provided to this macro is used for the hook. The hook is executed only once. If the hook raises an exception, the current example will be skipped and marked as an error.

NOTE Inside a #sample block, the hook is run once, not once per iteration.

This can be useful to initialize something before testing:

before_all { Thing.start } # 1

it "does something" do
  # 2
end

The hook cannot use values and methods in the group like examples can. This is because the hook is not associated with one example, but many.

let(array) { [1, 2, 3] }
before_all { array << 4 } # *ERROR!*

If multiple #before_all blocks are specified, then they are run in the order they were defined.

before_all { Thing.first }  # 1
before_all { Thing.second } # 2

With nested groups, the outer blocks will run first.

describe Something do
  before_all { Something.start } # 1

  describe "#foo" do
    before_all { Something.foo } # 2

    it "does a cool thing" do
      # 3
    end
  end
end

NOTE Pre-conditions should not be checked in a #before_all or related block. Errors that occur in a #before_all block will halt testing and abort with an error. Use #pre_condition instead for pre-test checks.

See also: #before_each, #after_all, #after_each, and #around_each.


[View source]
macro before_each #

Creates a hook that will run prior to every example in the group. The block of code provided to this macro is used for the hook. The hook is executed once per example in the group (and sub-groups). If the hook raises an exception, the current example will be skipped and marked as an error.

NOTE Inside a #sample block, the hook is run before every example of every iteration.

This can be useful for setting up environments for tests:

before_each { Thing.start } # 1

it "does something" do
  # 2
end

The hook can use values and methods in the group like examples can. It is called in the same scope as the example code.

let(array) { [1, 2, 3] }
before_each { array << 4 }

If multiple #before_each blocks are specified, then they are run in the order they were defined.

before_each { Thing.first }  # 1
before_each { Thing.second } # 2

With nested groups, the outer blocks will run first.

describe Something do
  before_each { Something.start } # 1

  describe "#foo" do
    before_each { Something.foo } # 2

    it "does a cool thing" do
      # 3
    end
  end
end

NOTE Pre-conditions should not be checked in a #before_each or related block. Errors that occur in a #before_each block will halt testing and abort with an error. Use #pre_condition instead for pre-test checks.

See also: #before_all, #after_all, #after_each, and #around_each.


[View source]
macro context(what, &block) #

Creates a new example group to describe a situation. The what argument describes the scenario or case being tested. Additional example groups and DSL may be nested in the block.

The #describe and #context are identical in terms of functionality. However, #describe is typically used on classes and methods, while #context is used for use cases and scenarios.

Using context blocks in conjunction with hooks, #let, and other methods provide an easy way to define the scenario in code. This also gives each example in the context an identical situation to run in.

For instance:

describe String do
  context "when empty" do
    subject { "" }

    it "has a size of zero" do
      expect(subject.size).to eq(0)
    end

    it "is blank" do
      expect(subject.blank?).to be_true
    end
  end

  context "when not empty" do
    subject { "foobar" }

    it "has a non-zero size" do
      expect(subject.size).to_not eq(0)
    end

    it "is not blank" do
      expect(subject.blank?).to be_false
    end
  end
end

While this is a somewhat contrived example, it demonstrates how contexts can reuse code. Contexts also make it clearer how a scenario is setup.


[View source]
macro describe(what, &block) #

Creates a new example group to describe a component. The what argument describes "what" is being tested. Additional example groups and DSL may be nested in the block.

Typically when testing a method, the spec is written like so:

describe "#foo" do
  it "does something" do
    # ...
  end
end

When describing a class (or any other type), the what parameter doesn't need to be quoted.

describe String do
  it "does something" do
    # ...
  end
end

And when combining the two together:

describe String do
  describe "#size" do
    it "returns the length" do
      # ...
    end
  end
end

The #describe and #context are identical in terms of functionality. However, #describe is typically used on classes and methods, while #context is used for use cases and scenarios.


[View source]
macro given(*assignments, &block) #

Creates an example group with a very concise syntax. This can be used in scenarios where one or more input values change the result of various methods. The normal DSL can be used within this context, but a shorter syntax provides an easier way to read and write multiple tests.

Here's an example of where this is useful:

describe Int32 do
  subject { described_class.new(value) }

  context "when given 5" do
    describe "#odd?" do
      subject { value.odd? }

      it "is true" do
        is_expected.to be_true
      end

      # NOTE: These could also be the one-liner syntax,
      # but that is still very verbose.
    end

    describe "#even?" do
      subject { value.even? }

      it "is false" do
        is_expected.to be_false
      end
    end
  end

  context "when given 42" do
    describe "#odd?" do
      subject { value.odd? }

      it "is false" do
        is_expected.to be_false
      end
    end

    describe "#even?" do
      subject { value.even? }

      it "is true" do
        is_expected.to be_true
      end
    end
  end
end

There's a lot of repetition and nested groups to test a very simple scenario.

Using a #given block, this type of scenario becomes much more compact.

describe Int32 do
  subject { described_class.new(value) }

  given value = 5 do
    expect(&.odd?).to be_true
    expect(&.even?).to be_false
  end

  given value = 42 do
    expect(&.odd?).to be_false
    expect(&.even?).to be_true
  end
end

One or more assignments can be used. Each assignment is passed to its own #let. For example:

given x = 1, y = 2 do
  expect(x + y).to eq(3)
end

Each statement in the block is converted to the one-liner syntax of #it. For instance:

given x = 1 do
  expect(x).to eq(1)
end

is converted to:

context "x = 1" do
  let(x) { 1 }

  it expect(x).to eq(1)
end

Additionally, the "it" syntax can be used and mixed in. This allows for flexibility and a more readable format when needed.

given x = 1 do
  it "is odd" do
    expect(x.odd?).to be_true
  end

  it is(&.odd?)
end

[View source]
macro it(what, _source_file = __FILE__, _source_line = __LINE__, &block) #

Creates an example, or a test case. The what argument describes "what" is being tested or asserted. The block contains the code to run the test. One or more expectations should be in the block.

it "can do math" do
  expect(1 + 2).to eq(3)
end

See ExampleDSL and MatcherDSL for additional macros and methods that can be used in example code blocks.

A short-hand, one-liner syntax can also be used. Typically, this is combined with #subject. For instance:

subject { 1 + 2 }
it is_expected.to eq(3)

[View source]
macro it(&block) #

Creates an example, or a test case. The block contains the code to run the test. One or more expectations should be in the block.

it { expect(1 + 2).to eq(3) }

See ExampleDSL and MatcherDSL for additional macros and methods that can be used in example code blocks.

A short-hand, one-liner syntax can also be used. Typically, this is combined with #subject. For instance:

subject { 1 + 2 }
it is_expected.to eq(3)

[View source]
macro let(name, &block) #

Defines an expression by name. The name can be used in examples to retrieve the value (basically a method). This can be used to define a value once and reuse it in multiple examples.

There are two variants - assignment and block. Both must be given a name.

For the assignment variant:

let string = "foobar"

it "isn't empty" do
  expect(string.empty?).to be_false
end

it "is six characters" do
  expect(string.size).to eq(6)
end

The value is evaluated and stored immediately. This is different from other #let variants that lazily-evaluate.

let current_time = Time.utc
let(lazy_time) { Time.utc }

it "lazy evaluates" do
  sleep 5
  expect(lazy_time).to_not eq(now)
end

However, the value is not reused across tests. Each test will have its own copy.

let array = [0, 1, 2]

it "modifies the array" do
  array[0] = 42
  expect(array).to eq([42, 1, 2])
end

it "doesn't carry across tests" do
  array[1] = 777
  expect(array).to eq([0, 777, 2])
end

The block variant expects a name and a block. The name can be a symbol or a literal - same as Object#getter. The block should return the value.

For instance:

let(string) { "foobar" }

it "isn't empty" do
  expect(string.empty?).to be_false
end

it "is six characters" do
  expect(string.size).to eq(6)
end

The value is lazy-evaluated - meaning that it is only created on the first reference to it. Afterwards, the value is cached, so the same value is returned with consecutive calls.

let(current_time) { Time.utc }

it "lazy evaluates" do
  now = current_time
  sleep 5
  expect(current_time).to eq(now)
end

However, the value is not reused across tests. It will be reconstructed the first time it is referenced in the next test.

let(array) { [0, 1, 2] }

it "modifies the array" do
  array[0] = 42
  expect(array).to eq([42, 1, 2])
end

it "doesn't carry across tests" do
  array[1] = 777
  expect(array).to eq([0, 777, 2])
end

[View source]
macro let!(name) #

The noisier sibling to #let. Defines an expression by giving it a name. The name can be used in examples to retrieve the value (basically a method). This can be used to define a value once and reuse it in multiple examples.

This macro expects a name and a block. The name can be a symbol or a literal - same as Object#getter. The block should return the value.

For instance:

let!(string) { "foobar" }

it "isn't empty" do
  expect(string.empty?).to be_false
end

it "is six characters" do
  expect(string.size).to eq(6)
end

The value is lazy-evaluated - meaning that it is only created when it is referenced. Unlike #let, the value is not cached and is recreated on each call.

let!(current_time) { Time.utc }

it "lazy evaluates" do
  now = current_time
  sleep 5
  expect(current_time).to_not eq(now)
end

[View source]
macro pending(what, _source_file = __FILE__, _source_line = __LINE__, &block) #

Creates an example, or a test case, that does not run. This can be used to prototype functionality that isn't ready. The what argument describes "what" is being tested or asserted. The block contains the code to run the test. One or more expectations should be in the block.

pending "something that isn't implemented yet" do
  # ...
end

See ExampleDSL and MatcherDSL for additional macros and methods that can be used in example code blocks.

NOTE Crystal appears to "lazily" compile code. Any code that isn't referenced seems to be ignored. Sometimes syntax, type, and other compile-time errors can occur in unreferenced code and won't be caught by the compiler. By creating a #pending test, the code will be referenced. Thus, forcing the compiler to at least process the code, even if it isn't run.


[View source]
macro pending(&block) #

Creates an example, or a test case, that does not run. This can be used to prototype functionality that isn't ready. The what argument describes "what" is being tested or asserted. The block contains the code to run the test. One or more expectations should be in the block.

pending do
  # Something that isn't implemented yet.
end

[View source]
macro post_condition #

Defines a block of code to run after every example in the group. The condition is executed once per example in the group (and sub-groups). If the condition fails, then the example fails.

NOTE Inside a #sample block, the condition is checked before every example of every iteration.

This can be useful for ensuring the state after a test.

# The variable x shouldn't be modified if an error is raised.
post_condition { expect(x).to eq(original_x) }

it "raises on divide by zero" do
  expect_raises { x /= 0 }
end

The condition can use values and methods in the group like examples can. It is called in the same scope as the example code.

let(array) { [1, 2, 3] }
post_condition { expect(array.size).to eq(3) }

If multiple #post_condition blocks are specified, then they are run in the order they were defined.

post_condition { expect(array).to_not be_nil } # 1
post_condition { expect(array.size).to eq(3) } # 2

With nested groups, the inner blocks will run first.

describe Something do
  post_condition { is_expected.to_not be_nil } # 3

  describe "#foo" do
    post_condition { expect(subject.foo).to_not be_nil } # 2

    it "does a cool thing" do
      # 1
    end
  end
end

See also: #pre_condition.


[View source]
macro pre_condition #

Defines a block of code to run prior to every example in the group. The condition is executed once per example in the group (and sub-groups). If the condition fails, then the example fails.

NOTE Inside a #sample block, the condition is checked before every example of every iteration.

This can be useful for ensuring the state before a test.

pre_condition { expect(array).to_not be_nil }

it "is the correct length" do
  expect(array.size).to eq(3)
end

The condition can use values and methods in the group like examples can. It is called in the same scope as the example code.

let(array) { [1, 2, 3] }
pre_condition { expect(array.size).to eq(3) }

If multiple #pre_condition blocks are specified, then they are run in the order they were defined.

pre_condition { expect(array).to_not be_nil } # 1
pre_condition { expect(array.size).to eq(3) } # 2

With nested groups, the outer blocks will run first.

describe Something do
  pre_condition { is_expected.to_not be_nil } # 1

  describe "#foo" do
    pre_condition { expect(subject.foo).to_not be_nil } # 2

    it "does a cool thing" do
      # 3
    end
  end
end

See also: #post_condition.


[View source]
macro random_sample(collection, count, &block) #

Creates a new example group to test multiple random values with. This method takes a collection of values and count and repeats the contents of the block with each value. This method randomly selects count items from the collection. The collection argument should be a literal collection, such as an array, or a function that returns an enumerable.

NOTE If an enumerable is used, it must be finite.

The block can accept an argument. If it does, then the argument's name is used to reference the current item in the collection. If an argument isn't provided, then value can be used instead.

Example with a block argument:

random_sample some_integers, 5 do |integer|
  it "sets the value" do
    subject.value = integer
    expect(subject.value).to eq(integer)
  end
end

Same spec, but without a block argument:

random_sample some_integers, 5 do
  it "sets the value" do
    subject.value = value
    expect(subject.value).to eq(value)
  end
end

In the examples above, the test case (#it block) is repeated for 5 random elements in some_integers. some_integers is a ficticous collection. The collection will be iterated once. #sample and #random_sample blocks can be nested, and work similarly to loops.

NOTE If the count is the same or higher than the number of elements in the collection, then this method if functionaly equivalent to #sample.

See also: #sample


[View source]
macro sample(collection, count = nil, &block) #

Creates a new example group to test multiple values with. This method takes a collection of values and repeats the contents of the block with each value. The collection argument should be a literal collection, such as an array, or a function that returns an enumerable. Additionally, a count may be specified to limit the number of values tested.

NOTE If an infinite enumerable is provided for the collection, then a count must be specified. Only the first count items will be used.

The block can accept an argument. If it does, then the argument's name is used to reference the current item in the collection. If an argument isn't provided, then value can be used instead.

Example with a block argument:

sample some_integers do |integer|
  it "sets the value" do
    subject.value = integer
    expect(subject.value).to eq(integer)
  end
end

Same spec, but without a block argument:

sample some_integers do
  it "sets the value" do
    subject.value = value
    expect(subject.value).to eq(value)
  end
end

In the examples above, the test case (#it block) is repeated for each element in some_integers. some_integers is a ficticous collection. The collection will be iterated once. #sample and #random_sample blocks can be nested, and work similarly to loops.

A limit can be specified as well. After the collection, a count can be added to limit the number of items taken from the collection. For instance:

sample some_integers, 5 do |integer|
  it "sets the value" do
    subject.value = integer
    expect(subject.value).to eq(integer)
  end
end

See also: #random_sample


[View source]
macro skip(what, &block) #

Same as #pending. Included for compatibility with RSpec.


[View source]
macro skip(&block) #

Same as #pending. Included for compatibility with RSpec.


[View source]
macro specify(what, &block) #

An alternative way to write an example. This is identical to #it.


[View source]
macro specify(&block) #

An alternative way to write an example. This is identical to #it, except that it doesn't take a "what" argument.


[View source]
macro subject(&block) #

Explicitly defines the subject being tested. The #subject method can be used in examples to retrieve the value (basically a method).

This macro expects a block. The block should return the value of the subject. This can be used to define a value once and reuse it in multiple examples.

For instance:

subject { "foobar" }

it "isn't empty" do
  expect(subject.empty?).to be_false
end

it "is six characters" do
  expect(subject.size).to eq(6)
end

By using a subject, some of the DSL becomes simpler. For example, ExampleDSL#is_expected can be used.

subject { "foobar" }

it "isn't empty" do
  is_expected.to_not be_empty # is the same as:
  expect(subject).to_not be_empty
end

This macro is functionaly equivalent to:

let(:subject) { "foo" }

The subject is created the first time it is referenced (lazy initialization). It is cached so that the same instance is used throughout the test. The subject will be recreated for each test it is used in.

subject { [0, 1, 2] }

it "modifies the array" do
  subject[0] = 42
  is_expected.to eq([42, 1, 2])
end

it "doesn't carry across tests" do
  subject[1] = 777
  is_expected.to eq([0, 777, 2])
end

[View source]
macro subject(name, &block) #

Explicitly defines the subject being tested. The #subject method can be used in examples to retrieve the value (basically a method). This also names the subject so that it can be referenced by the name instead.

This macro expects a block. The block should return the value of the subject. This can be used to define a value once and reuse it in multiple examples.

For instance:

subject(string) { "foobar" }

it "isn't empty" do
  # Refer to it with `subject`.
  expect(subject.empty?).to be_false
end

it "is six characters" do
  # Refer to it by its name `string`.
  expect(string.size).to eq(6)
end

The subject is created the first time it is referenced (lazy initialization). It is cached so that the same instance is used throughout the test. The subject will be recreated for each test it is used in.


[View source]
macro xit(what, &block) #

Same as #pending. Included for compatibility with RSpec.


[View source]
macro xit(&block) #

Same as #pending. Included for compatibility with RSpec.


[View source]