Merge branch 'master' into mocks-and-doubles

This commit is contained in:
Michael Miller 2020-02-23 12:00:37 -07:00
commit 86729c6745
70 changed files with 1799 additions and 209 deletions

View file

@ -15,6 +15,9 @@ before_script:
spec: spec:
script: script:
- crystal spec --error-on-warnings - crystal spec --error-on-warnings
style:
script:
- bin/ameba - bin/ameba
- crystal tool format --check - crystal tool format --check

5
.guardian.yml Normal file
View file

@ -0,0 +1,5 @@
files: ./**/*.cr
run: time crystal spec --error-trace
---
files: ./shard.yml
run: shards

View file

@ -1,16 +1,16 @@
name: spectator name: spectator
version: 0.9.1 version: 0.9.9
description: | description: |
A feature-rich spec testing framework for Crystal with similarities to RSpec. A feature-rich spec testing framework for Crystal with similarities to RSpec.
authors: authors:
- Michael Miller <icy.arctic.fox@gmail.com> - Michael Miller <icy.arctic.fox@gmail.com>
crystal: 0.31.0 crystal: 0.33.0
license: MIT license: MIT
development_dependencies: development_dependencies:
ameba: ameba:
github: crystal-ameba/ameba github: crystal-ameba/ameba
version: ~> 0.10 version: ~> 0.11.0

View file

@ -0,0 +1,32 @@
require "../spec_helper"
Spectator.describe "eq matcher" do
it "is true for equal values" do
expect(42).to eq(42)
end
it "is false for inequal values" do
expect(42).to_not eq(24)
end
it "is true for identical references" do
string = "foobar"
expect(string).to eq(string)
end
it "is false for different references" do
string1 = "foo"
string2 = "bar"
expect(string1).to_not eq(string2)
end
double(:fake) do
stub instance.==(other) { true }
end
it "uses the == operator" do
dbl = double(:fake)
expect(42).to eq(dbl)
expect(dbl).to have_received(:==).with(42).once
end
end

View file

@ -0,0 +1,112 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-core/v/3-8/docs/hooks/before-and-after-hooks
# and modified to fit Spectator and Crystal.
Spectator.describe "`before` and `after` hooks" do
context "Define `before_each` block" do
class Thing
def widgets
@widgets ||= [] of Symbol # Must specify array element type.
end
end
describe Thing do
before_each do
@thing = Thing.new
end
describe "initialize in before_each" do
it "has 0 widgets" do
widgets = @thing.as(Thing).widgets # Must cast since compile type is Thing?
expect(widgets.size).to eq(0) # Use size instead of count.
end
it "can accept new widgets" do
widgets = @thing.as(Thing).widgets # Must cast since compile type is Thing?
widgets << :foo
end
it "does not share state across examples" do
widgets = @thing.as(Thing).widgets # Must cast since compile type is Thing?
expect(widgets.size).to eq(0) # Use size instead of count.
end
end
end
end
context "Define `before_all` block in example group" do
class Thing
def widgets
@widgets ||= [] of Symbol # Must specify array element type.
end
end
describe Thing do
# Moved before_all into the same example group.
# Unlike Ruby, inherited class variables don't share the same value.
# See: https://crystal-lang.org/reference/syntax_and_semantics/class_variables.html
describe "initialized in before_all" do
@@thing : Thing?
before_all do
@@thing = Thing.new # Must use class variables.
end
it "has 0 widgets" do
widgets = @@thing.as(Thing).widgets # Must cast since compile type is Thing?
expect(widgets.size).to eq(0) # Use size instead of count.
end
it "can accept new widgets" do
widgets = @@thing.as(Thing).widgets # Must cast since compile type is Thing?
widgets << :foo
end
it "shares state across examples" do
widgets = @@thing.as(Thing).widgets # Must cast since compile type is Thing?
expect(widgets.size).to eq(1) # Use size instead of count.
end
end
end
end
context "Failure in `before_each` block" do
# TODO
end
context "Failure in `after_each` block" do
# TODO
end
context "Define `before` and `after` blocks in configuration" do
# TODO
end
context "`before`/`after` blocks are run in order" do
# Examples changed from using puts to appending to an array.
describe "before and after callbacks" do
@@order = [] of Symbol
before_all do
@@order << :before_all
end
before_each do
@@order << :before_each
end
after_each do
@@order << :after_each
end
after_all do
@@order << :after_all
end
it "gets run in order" do
expect(@@order).to_eventually eq(%i[before_all before_each after_each after_all])
end
end
end
end

View file

@ -0,0 +1,140 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-core/v/3-8/docs/subject/explicit-subject
# and modified to fit Spectator and Crystal.
Spectator.describe "Explicit Subject" do
context "A `subject` can be defined and used in the top level group scope" do
describe Array(Int32) do # TODO: Multiple arguments to describe/context.
subject { [1, 2, 3] }
it "has the prescribed elements" do
expect(subject).to eq([1, 2, 3])
end
end
end
context "The `subject` define in an outer group is available to inner groups" do
describe Array(Int32) do
subject { [1, 2, 3] }
describe "has some elements" do
it "which are the prescribed elements" do
expect(subject).to eq([1, 2, 3])
end
end
end
end
context "The `subject` is memoized within an example but not across examples" do
describe Array(Int32) do
# Changed to class variable to get around compiler error/crash.
# Unhandled exception: Negative argument (ArgumentError)
@@element_list = [1, 2, 3]
subject { @@element_list.pop }
# TODO: RSpec calls the "actual" block after the "change block".
xit "is memoized across calls (i.e. the block is invoked once)" do
expect do
3.times { subject }
end.to change { @@element_list }.from([1, 2, 3]).to([1, 2])
expect(subject).to eq(3)
end
# TODO: RSpec calls the "actual" block after the "change block".
xit "is not memoized across examples" do
expect { subject }.to change { @@element_list }.from([1, 2]).to([1])
expect(subject).to eq(2)
end
end
end
context "The `subject` is available in `before` blocks" do
describe Array(Int32) do # TODO: Multiple arguments to describe/context.
subject { [] of Int32 }
before_each { subject.push(1, 2, 3) }
it "has the prescribed elements" do
expect(subject).to eq([1, 2, 3])
end
end
end
context "Helper methods can be invoked from a `subject` definition block" do
describe Array(Int32) do # TODO: Multiple arguments to describe/context.
def prepared_array
[1, 2, 3]
end
subject { prepared_array }
it "has the prescribed elements" do
expect(subject).to eq([1, 2, 3])
end
end
end
context "Use the `subject!` bang method to call the definition block before the example" do
describe "eager loading with subject!" do
subject! { element_list.push(99) }
let(:element_list) { [1, 2, 3] }
it "calls the definition block before the example" do
element_list.push(5)
expect(element_list).to eq([1, 2, 3, 99, 5])
end
end
end
context "Use `subject(:name)` to define a memoized helper method" do
# Globals not supported, using class variable instead.
@@count = 0
describe "named subject" do
subject(:global_count) { @@count += 1 }
it "is memoized across calls (i.e. the block is invoked once)" do
expect do
2.times { global_count }
end.not_to change { global_count }.from(1)
end
it "is not cached across examples" do
expect(global_count).to eq(2)
end
it "is still available using the subject method" do
expect(subject).to eq(3)
end
it "works with the one-liner syntax" do
is_expected.to eq(4)
end
it "the subject and named helpers return the same object" do
expect(global_count).to be(subject)
end
it "is set to the block return value (i.e. the global $count)" do
expect(global_count).to be(@@count)
end
end
end
context "Use `subject!(:name)` to define a helper method called before the example" do
describe "eager loading using a named subject!" do
subject!(:updated_list) { element_list.push(99) }
let(:element_list) { [1, 2, 3] }
it "calls the definition block before the example" do
element_list.push(5)
expect(element_list).to eq([1, 2, 3, 99, 5])
expect(updated_list).to be(element_list)
end
end
end
end

View file

@ -0,0 +1,29 @@
require "../../spec_helper"
Spectator.describe "Arbitrary helper methods" do
context "Use a method define in the same group" do
describe "an example" do
def help
:available
end
it "has access to methods define in its group" do
expect(help).to be(:available)
end
end
end
context "Use a method defined in a parent group" do
describe "an example" do
def help
:available
end
describe "in a nested group" do
it "has access to methods defined in its parent group" do
expect(help).to be(:available)
end
end
end
end
end

View file

@ -0,0 +1,43 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-core/v/3-8/docs/subject/implicitly-defined-subject
# and modified to fit Spectator and Crystal.
Spectator.describe "Implicitly defined subject" do
context "`subject` exposed in top-level group" do
describe Array(String) do
it "should be empty when first created" do
expect(subject).to be_empty
end
end
end
context "`subject` in a nested group" do
describe Array(String) do
describe "when first created" do
it "should be empty" do
expect(subject).to be_empty
end
end
end
end
context "`subject` in a nested group with a different class (innermost wins)" do
class ArrayWithOneElement < Array(String)
def initialize(*_args)
super
unshift "first element"
end
end
describe Array(String) do
describe ArrayWithOneElement do
context "referenced as subject" do
it "contains one element" do
expect(subject).to contain("first element")
end
end
end
end
end
end

View file

@ -0,0 +1,45 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-core/v/3-8/docs/helper-methods/let-and-let
# and modified to fit Spectator and Crystal.
Spectator.describe "Let and let!" do
context "Use `let` to define memoized helper method" do
# Globals aren't supported, use class variables instead.
@@count = 0
describe "let" do
let(:count) { @@count += 1 }
it "memoizes thte value" do
expect(count).to eq(1)
expect(count).to eq(1)
end
it "is not cached across examples" do
expect(count).to eq(2)
end
end
end
context "Use `let!` to define a memoized helper method that is called in a `before` hook" do
# Globals aren't supported, use class variables instead.
@@count = 0
describe "let!" do
# Use class variable here.
@@invocation_order = [] of Symbol
let!(:count) do
@@invocation_order << :let!
@@count += 1
end
it "calls the helper method in a before hook" do
@@invocation_order << :example
expect(@@invocation_order).to eq([:let!, :example])
expect(count).to eq(1)
end
end
end
end

View file

@ -0,0 +1,31 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-core/v/3-8/docs/subject/one-liner-syntax
# and modified to fit Spectator and Crystal.
Spectator.describe "One-liner syntax" do
context "Implicit subject" do
describe Array(Int32) do
# Rather than:
# it "should be empty" do
# subject.should be_empty
# end
it { should be_empty }
# or
it { is_expected.to be_empty }
end
end
context "Explicit subject" do
describe Array(Int32) do
describe "with 3 items" do
subject { [1, 2, 3] }
it { should_not be_empty }
# or
it { is_expected.not_to be_empty }
end
end
end
end

View file

@ -0,0 +1,37 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/all-matcher
# and modified to fit Spectator and Crystal.
Spectator.describe "`all` matcher" do
context "array usage" do
describe [1, 3, 5] do
it { is_expected.to all(be_odd) }
it { is_expected.to all(be_an(Int32)) } # Changed to Int32 to satisfy compiler.
it { is_expected.to all(be < 10) }
# deliberate failures
it_fails { is_expected.to all(be_even) }
it_fails { is_expected.to all(be_a(String)) }
it_fails { is_expected.to all(be > 2) }
end
end
context "compound matcher usage" do
# Changed `include` to `contain` to match our own.
# `include` is a keyword and can't be used as a method name in Crystal.
# TODO: Add support for compound matchers.
describe ["anything", "everything", "something"] do
xit { is_expected.to all(be_a(String)) } # .and contain("thing") ) }
xit { is_expected.to all(be_a(String)) } # .and end_with("g") ) }
xit { is_expected.to all(start_with("s")) } # .or contain("y") ) }
# deliberate failures
# TODO: Add support for compound matchers.
xit { is_expected.to all(contain("foo")) } # .and contain("bar") ) }
xit { is_expected.to all(be_a(String)) } # .and start_with("a") ) }
xit { is_expected.to all(start_with("a")) } # .or contain("z") ) }
end
end
end

View file

@ -0,0 +1,17 @@
require "../../spec_helper"
Spectator.describe "`be_between` matcher" do
context "basic usage" do
describe 7 do
it { is_expected.to be_between(1, 10) }
it { is_expected.to be_between(0.2, 27.1) }
it { is_expected.not_to be_between(1.5, 4) }
it { is_expected.not_to be_between(8, 9) }
# boundaries check
it { is_expected.to be_between(0, 7) }
it { is_expected.to be_between(7, 10) }
it { is_expected.not_to (be_between(0, 7).exclusive) }
end
end
end

View file

@ -0,0 +1,66 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/be-matchers
# and modified to fit Spectator and Crystal.
Spectator.describe "`be` matchers" do
context "be_truthy matcher" do
specify { expect(true).to be_truthy }
specify { expect(7).to be_truthy }
specify { expect("foo").to be_truthy }
specify { expect(nil).not_to be_truthy }
specify { expect(false).not_to be_truthy }
# deliberate failures
specify_fails { expect(true).not_to be_truthy }
specify_fails { expect(7).not_to be_truthy }
specify_fails { expect("foo").not_to be_truthy }
specify_fails { expect(nil).to be_truthy }
specify_fails { expect(false).to be_truthy }
end
context "be_falsey matcher" do
specify { expect(nil).to be_falsey }
specify { expect(false).to be_falsey }
specify { expect(true).not_to be_falsey }
specify { expect(7).not_to be_falsey }
specify { expect("foo").not_to be_falsey }
# deliberate failures
specify_fails { expect(nil).not_to be_falsey }
specify_fails { expect(false).not_to be_falsey }
specify_fails { expect(true).to be_falsey }
specify_fails { expect(7).to be_falsey }
specify_fails { expect("foo").to be_falsey }
end
context "be_nil matcher" do
specify { expect(nil).to be_nil }
specify { expect(false).not_to be_nil }
specify { expect(true).not_to be_nil }
specify { expect(7).not_to be_nil }
specify { expect("foo").not_to be_nil }
# deliberate failures
specify_fails { expect(nil).not_to be_nil }
specify_fails { expect(false).to be_nil }
specify_fails { expect(true).to be_nil }
specify_fails { expect(7).to be_nil }
specify_fails { expect("foo").to be_nil }
end
context "be matcher" do
specify { expect(true).to be }
specify { expect(7).to be }
specify { expect("foo").to be }
specify { expect(nil).not_to be }
specify { expect(false).not_to be }
# deliberate failures
specify_fails { expect(true).not_to be }
specify_fails { expect(7).not_to be }
specify_fails { expect("foo").not_to be }
specify_fails { expect(nil).to be }
specify_fails { expect(false).to be }
end
end

View file

@ -0,0 +1,24 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/be-within-matcher
# and modified to fit Spectator and Crystal.
Spectator.describe "`be_within` matcher" do
context "basic usage" do
describe 27.5 do
it { is_expected.to be_within(0.5).of(27.9) }
it { is_expected.to be_within(0.5).of(28.0) }
it { is_expected.to be_within(0.5).of(27.1) }
it { is_expected.to be_within(0.5).of(27.0) }
it { is_expected.not_to be_within(0.5).of(28.1) }
it { is_expected.not_to be_within(0.5).of(26.9) }
# deliberate failures
it_fails { is_expected.not_to be_within(0.5).of(28) }
it_fails { is_expected.not_to be_within(0.5).of(27) }
it_fails { is_expected.to be_within(0.5).of(28.1) }
it_fails { is_expected.to be_within(0.5).of(26.9) }
end
end
end

View file

@ -0,0 +1,47 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/change-matcher
# and modified to fit Spectator and Crystal.
Spectator.describe "`change` matcher" do
# Modified this example type to work in Crystal.
module Counter
extend self
@@count = 0
def increment
@@count += 1
end
def count
@@count
end
end
context "expect change" do
describe "Counter#increment" do # TODO: Allow multiple arguments to context/describe.
it "should increment the count" do
expect { Counter.increment }.to change { Counter.count }.from(0).to(1)
end
# deliberate failure
it_fails "should increment the count by 2" do
expect { Counter.increment }.to change { Counter.count }.by(2)
end
end
end
context "expect no change" do
describe "Counter#increment" do # TODO: Allow multiple arguments to context/describe.
# deliberate failures
it_fails "should not increment the count by 1 (using not_to)" do
expect { Counter.increment }.not_to change { Counter.count }
end
it_fails "should not increment the count by 1 (using to_not)" do
expect { Counter.increment }.to_not change { Counter.count }
end
end
end
end

View file

@ -0,0 +1,44 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/comparison-matchers
# and modified to fit Spectator and Crystal.
Spectator.describe "Comparison matchers" do
context "numeric operator matchers" do
describe 18 do
it { is_expected.to be < 20 }
it { is_expected.to be > 15 }
it { is_expected.to be <= 19 }
it { is_expected.to be >= 17 }
# deliberate failures
it_fails { is_expected.to be < 15 }
it_fails { is_expected.to be > 20 }
it_fails { is_expected.to be <= 17 }
it_fails { is_expected.to be >= 19 }
# it { is_expected.to be < 'a' } # Removed because Crystal doesn't support Int32#<(Char)
end
describe 'a' do
it { is_expected.to be < 'b' }
# deliberate failures
# it { is_expected.to be < 18 } # Removed because Crystal doesn't support Char#<(Int32)
end
end
context "string operator matchers" do
describe "Strawberry" do
it { is_expected.to be < "Tomato" }
it { is_expected.to be > "Apple" }
it { is_expected.to be <= "Turnip" }
it { is_expected.to be >= "Banana" }
# deliberate failures
it_fails { is_expected.to be < "Cranberry" }
it_fails { is_expected.to be > "Zuchini" }
it_fails { is_expected.to be <= "Potato" }
it_fails { is_expected.to be >= "Tomato" }
end
end
end

View file

@ -0,0 +1,30 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/contain-exactly-matcher
# and modified to fit Spectator and Crystal.
Spectator.describe "`contain_exactly` matcher" do
context "Array is expected to contain every value" do
describe [1, 2, 3] do
it { is_expected.to contain_exactly(1, 2, 3) }
it { is_expected.to contain_exactly(1, 3, 2) }
it { is_expected.to contain_exactly(2, 1, 3) }
it { is_expected.to contain_exactly(2, 3, 1) }
it { is_expected.to contain_exactly(3, 1, 2) }
it { is_expected.to contain_exactly(3, 2, 1) }
# deliberate failures
it_fails { is_expected.to contain_exactly(1, 2, 1) }
end
end
context "Array is not expected to contain every value" do
describe [1, 2, 3] do
it { is_expected.to_not contain_exactly(1, 2, 3, 4) }
it { is_expected.to_not contain_exactly(1, 2) }
# deliberate failures
it_fails { is_expected.to_not contain_exactly(1, 3, 2) }
end
end
end

View file

@ -0,0 +1,96 @@
require "../../spec_helper"
# In Ruby, this is the `include` matcher.
# However, `include` is a reserved keyword in Crystal.
# So instead, it is `contain` in Spectator.
Spectator.describe "`contain` matcher" do
context "array usage" do
describe [1, 3, 7] do
it { is_expected.to contain(1) }
it { is_expected.to contain(3) }
it { is_expected.to contain(7) }
it { is_expected.to contain(1, 7) }
it { is_expected.to contain(1, 3, 7) }
# Utility matcher method `a_kind_of` is not supported.
# it { is_expected.to contain(a_kind_of(Int)) }
# TODO: Compound matchers aren't supported.
# it { is_expected.to contain(be_odd.and be < 10) }
# TODO: Fix behavior and cleanup output.
# This syntax is allowed, but produces a wrong result and bad output.
xit { is_expected.to contain(be_odd) }
xit { is_expected.not_to contain(be_even) }
it { is_expected.not_to contain(17) }
it { is_expected.not_to contain(43, 100) }
# deliberate failures
it_fails { is_expected.to contain(4) }
it_fails { is_expected.to contain(be_even) }
it_fails { is_expected.not_to contain(1) }
it_fails { is_expected.not_to contain(3) }
it_fails { is_expected.not_to contain(7) }
it_fails { is_expected.not_to contain(1, 3, 7) }
# both of these should fail since it contains 1 but not 9
it_fails { is_expected.to contain(1, 9) }
it_fails { is_expected.not_to contain(1, 9) }
end
end
context "string usage" do
describe "a string" do
it { is_expected.to contain("str") }
it { is_expected.to contain("a", "str", "ng") }
it { is_expected.not_to contain("foo") }
it { is_expected.not_to contain("foo", "bar") }
# deliberate failures
it_fails { is_expected.to contain("foo") }
it_fails { is_expected.not_to contain("str") }
it_fails { is_expected.to contain("str", "foo") }
it_fails { is_expected.not_to contain("str", "foo") }
end
end
context "hash usage" do
# A hash can't be described inline here for some reason.
# So it is placed in the subject instead.
describe ":a => 7, :b => 5" do
subject { {:a => 7, :b => 5} }
# Hash syntax is changed here from `:a => 7` to `a: 7`.
# it { is_expected.to contain(:a) }
# it { is_expected.to contain(:b, :a) }
# TODO: This hash-like syntax isn't supported.
# it { is_expected.to contain(a: 7) }
# it { is_expected.to contain(b: 5, a: 7) }
# it { is_expected.not_to contain(:c) }
# it { is_expected.not_to contain(:c, :d) }
# it { is_expected.not_to contain(d: 2) }
# it { is_expected.not_to contain(a: 5) }
# it { is_expected.not_to contain(b: 7, a: 5) }
# deliberate failures
# it { is_expected.not_to contain(:a) }
# it { is_expected.not_to contain(:b, :a) }
# it { is_expected.not_to contain(a: 7) }
# it { is_expected.not_to contain(a: 7, b: 5) }
# it { is_expected.to contain(:c) }
# it { is_expected.to contain(:c, :d) }
# it { is_expected.to contain(d: 2) }
# it { is_expected.to contain(a: 5) }
# it { is_expected.to contain(a: 5, b: 7) }
# Mixed cases--the hash contains one but not the other.
# All 4 of these cases should fail.
# it { is_expected.to contain(:a, :d) }
# it { is_expected.not_to contain(:a, :d) }
# it { is_expected.to contain(a: 7, d: 3) }
# it { is_expected.not_to contain(a: 7, d: 3) }
end
end
end

View file

@ -0,0 +1,29 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/cover-matcher
# and modified to fit Spectator and Crystal.
Spectator.describe "`cover` matcher" do
context "range usage" do
describe (1..10) do
it { is_expected.to cover(4) }
it { is_expected.to cover(6) }
it { is_expected.to cover(8) }
it { is_expected.to cover(4, 6) }
it { is_expected.to cover(4, 6, 8) }
it { is_expected.not_to cover(11) }
it { is_expected.not_to cover(11, 12) }
# deliberate failures
it_fails { is_expected.to cover(11) }
it_fails { is_expected.not_to cover(4) }
it_fails { is_expected.not_to cover(6) }
it_fails { is_expected.not_to cover(8) }
it_fails { is_expected.not_to cover(4, 6, 8) }
# both of these should fail since it covers 5 but not 11
it_fails { is_expected.to cover(5, 11) }
it_fails { is_expected.not_to cover(5, 11) }
end
end
end

View file

@ -0,0 +1,31 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/end-with-matcher
# and modified to fit Spectator and Crystal.
Spectator.describe "`end_with` matcher" do
context "string usage" do
describe "this string" do
it { is_expected.to end_with "string" }
it { is_expected.not_to end_with "stringy" }
# deliberate failures
it_fails { is_expected.not_to end_with "string" }
it_fails { is_expected.to end_with "stringy" }
end
end
context "array usage" do
describe [0, 1, 2, 3, 4] do
it { is_expected.to end_with 4 }
# TODO: Add support for multiple items at the end of an array.
# it { is_expected.to end_with 3, 4 }
it { is_expected.not_to end_with 3 }
# it { is_expected.not_to end_with 0, 1, 2, 3, 4, 5 }
# deliberate failures
it_fails { is_expected.not_to end_with 4 }
it_fails { is_expected.to end_with 3 }
end
end
end

View file

@ -0,0 +1,64 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/equality-matchers
# and modified to fit Spectator and Crystal.
Spectator.describe "Equality matchers" do
context "compare using eq (==)" do
describe "a string" do
it "is equal to another string of the same value" do
expect("this string").to eq("this string")
end
it "is not equal to another string of a different value" do
expect("this string").not_to eq("a different string")
end
end
describe "an integer" do
it "is equal to a float for the same value" do
expect(5).to eq(5.0)
end
end
end
context "compare using ==" do
describe "a string" do
it "is equal to another string of the same value" do
expect("this string").to be == "this string"
end
it "is not equal to another string of a different value" do
expect("this string").not_to be == "a different string"
end
end
describe "an integer" do
it "is equal to a float of the same value" do
expect(5).to be == 5.0
end
end
end
# There are no #eql? and #equal? methods in Crystal, so these tests are skipped.
context "compare using be (same?)" do
it "is equal to itself" do
string = "this string"
expect(string).to be(string)
end
it "is not equal to another reference of the same value" do
# Strings with identical contents are the same reference in Crystal.
# This test is modified to reflect that.
# expect("this string").not_to be("this string")
box1 = Box.new("this string")
box2 = Box.new("this string")
expect(box1).not_to be(box2)
end
it "is not equal to another string of a different value" do
expect("this string").not_to be("a different string")
end
end
end

View file

@ -0,0 +1,36 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/have-attributes-matcher
# and modified to fit Spectator and Crystal.
Spectator.describe "`have_attributes` matcher" do
context "basic usage" do
# Use `record` instead of `Struct.new`.
record Person, name : String, age : Int32
describe Person.new("Jim", 32) do
# Changed some syntax for Ruby hashes to Crystal named tuples.
# Spectator doesn't support helper matchers like `a_string_starting_with` and `a_value <`.
# But maybe in the future it will.
it { is_expected.to have_attributes(name: "Jim") }
# it { is_expected.to have_attributes(name: a_string_starting_with("J") ) }
it { is_expected.to have_attributes(age: 32) }
# it { is_expected.to have_attributes(age: (a_value > 30) ) }
it { is_expected.to have_attributes(name: "Jim", age: 32) }
# it { is_expected.to have_attributes(name: a_string_starting_with("J"), age: (a_value > 30) ) }
it { is_expected.not_to have_attributes(name: "Bob") }
it { is_expected.not_to have_attributes(age: 10) }
# it { is_expected.not_to have_attributes(age: (a_value < 30) ) }
# deliberate failures
it_fails { is_expected.to have_attributes(name: "Bob") }
it_fails { is_expected.to have_attributes(name: 10) }
# fails if any of the attributes don't match
it_fails { is_expected.to have_attributes(name: "Bob", age: 32) }
it_fails { is_expected.to have_attributes(name: "Jim", age: 10) }
it_fails { is_expected.to have_attributes(name: "Bob", age: 10) }
end
end
end

View file

@ -0,0 +1,28 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/match-matcher
# and modified to fit Spectator and Crystal.
Spectator.describe "`match` matcher" do
context "string usage" do
describe "a string" do
it { is_expected.to match(/str/) }
it { is_expected.not_to match(/foo/) }
# deliberate failures
it_fails { is_expected.not_to match(/str/) }
it_fails { is_expected.to match(/foo/) }
end
end
context "regular expression usage" do
describe /foo/ do
it { is_expected.to match("food") }
it { is_expected.not_to match("drinks") }
# deliberate failures
it_fails { is_expected.not_to match("food") }
it_fails { is_expected.to match("drinks") }
end
end
end

View file

@ -0,0 +1,81 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/predicate-matchers
# and modified to fit Spectator and Crystal.
Spectator.describe "Predicate matchers" do
context "should be_zero (based on Int#zero?)" do
describe 0 do
it { is_expected.to be_zero }
end
describe 7 do
# deliberate failure
it_fails { is_expected.to be_zero }
end
end
context "should_not be_empty (based on Array#empty?)" do
describe [1, 2, 3] do
it { is_expected.not_to be_empty }
end
describe [] of Int32 do
# deliberate failure
it_fails { is_expected.not_to be_empty }
end
end
context "should have_key (based on Hash#has_key?)" do
describe Hash do
subject { {:foo => 7} }
it { is_expected.to have_key(:foo) }
# deliberate failure
it_fails { is_expected.to have_key(:bar) }
end
end
context "should_not have_all_string_keys (based on custom #has_all_string_keys? method)" do
class ::Hash(K, V)
def has_all_string_keys?
keys.all? { |k| String === k }
end
end
describe Hash do
context "with symbol keys" do
subject { {:foo => 7, :bar => 5} }
it { is_expected.not_to have_all_string_keys }
end
context "with string keys" do
subject { {"foo" => 7, "bar" => 5} }
# deliberate failure
it_fails { is_expected.not_to have_all_string_keys }
end
end
end
context "matcher arguments are passed on to the predicate method" do
struct ::Int
def multiple_of?(x)
(self % x).zero?
end
end
describe 12 do
it { is_expected.to be_multiple_of(3) }
it { is_expected.not_to be_multiple_of(7) }
# deliberate failures
it_fails { is_expected.not_to be_multiple_of(4) }
it_fails { is_expected.to be_multiple_of(5) }
end
end
# The examples using private methods cause a compilation error in Crystal, and can't be used here.
end

View file

@ -0,0 +1,94 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/raise-error-matcher
# and modified to fit Spectator and Crystal.
Spectator.describe "`raise_error` matcher" do
context "expect any error" do
# This example originally calls a non-existent method.
# That isn't allowed in Crystal.
# The example has been changed to just raise a runtime error.
describe "dividing by zero" do
it "raises" do
expect { 42 // 0 }.to raise_error
end
end
end
context "expect specific error" do
# Again, can't even compile if a method doesn't exist.
# So using a different exception here.
describe "dividing by zero" do
it "raises" do
expect { 42 // 0 }.to raise_error(DivisionByZeroError)
end
end
end
# The following examples are changed slightly.
# `raise Type.new(message)` is the syntax in Crystal,
# whereas it is `raise Type, message` in Ruby.
# Additionally, `StandardError` doesn't exist in Crystal,
# so `Exception` is used instead.
context "match message with a string" do
describe "matching error message with string" do
it "matches the error message" do
expect { raise Exception.new("this message exactly") }
.to raise_error("this message exactly")
end
end
end
context "match message with a regexp" do
describe "matching error message with regex" do
it "matches the error message" do
expect { raise Exception.new("my message") }
.to raise_error(/my mess/)
end
end
end
context "matching message with `with_message`" do
describe "matching error message with regex" do
it "matches the error message" do
expect { raise Exception.new("my message") }
.to raise_error.with_message(/my mess/)
end
end
end
context "match class + message with string" do
describe "matching error message with string" do
it "matches the error message" do
expect { raise Exception.new("this message exactly") }
.to raise_error(Exception, "this message exactly")
end
end
end
context "match class + message with regexp" do
describe "matching error message with regex" do
it "matches the error message" do
expect { raise Exception.new("my message") }
.to raise_error(Exception, /my mess/)
end
end
end
# TODO: Support passing a block to `raise_error` matcher.
# context "set expectations on error object passed to block" do
# it "raises DivisionByZeroError" do
# expect { 42 // 0 }.to raise_error do |error|
# expect(error).to be_a(DivisionByZeroError)
# end
# end
# end
context "expect no error at all" do
describe "#to_s" do
it "does not raise" do
expect { 42.to_s }.not_to raise_error
end
end
end
end

View file

@ -0,0 +1,28 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/respond-to-matcher
# and modified to fit Spectator and Crystal.
Spectator.describe "`respond_to` matcher" do
context "basic usage" do
describe "a string" do
it { is_expected.to respond_to(:size) } # It's size in Crystal, not length.
it { is_expected.to respond_to(:hash, :class, :to_s) }
it { is_expected.not_to respond_to(:to_model) }
it { is_expected.not_to respond_to(:compact, :flatten) }
# deliberate failures
it_fails { is_expected.to respond_to(:to_model) }
it_fails { is_expected.to respond_to(:compact, :flatten) }
it_fails { is_expected.not_to respond_to(:size) }
it_fails { is_expected.not_to respond_to(:hash, :class, :to_s) }
# mixed examples--String responds to :length but not :flatten
# both specs should fail
it_fails { is_expected.to respond_to(:size, :flatten) }
it_fails { is_expected.not_to respond_to(:size, :flatten) }
end
end
# Spectator doesn't support argument matching with respond_to.
end

View file

@ -0,0 +1,31 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/start-with-matcher
# and modified to fit Spectator and Crystal.
Spectator.describe "`start_with` matcher" do
context "with a string" do
describe "this string" do
it { is_expected.to start_with "this" }
it { is_expected.not_to start_with "that" }
# deliberate failures
it_fails { is_expected.not_to start_with "this" }
it_fails { is_expected.to start_with "that" }
end
end
context "with an array" do
describe [0, 1, 2, 3, 4] do
it { is_expected.to start_with 0 }
# TODO: Add support for multiple items at the beginning of an array.
# it { is_expected.to start_with(0, 1) }
it { is_expected.not_to start_with(2) }
# it { is_expected.not_to start_with(0, 1, 2, 3, 4, 5) }
# deliberate failures
it_fails { is_expected.not_to start_with 0 }
it_fails { is_expected.to start_with 3 }
end
end
end

View file

@ -0,0 +1,100 @@
require "../../spec_helper"
# Examples taken from:
# https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/type-matchers
# and modified to fit Spectator and Crystal.
Spectator.describe "Type matchers" do
context "be_(a_)kind_of matcher" do
# The docs use Float as an example.
# This doesn't work with the Crystal compiler,
# so a custom hierarchy is used instead.
# "Error: can't use Number as generic type argument yet, use a more specific type"
module MyModule; end
class Base; end
class Derived < Base
include MyModule
end
describe Derived do
# the actual class
it { is_expected.to be_kind_of(Derived) }
it { is_expected.to be_a_kind_of(Derived) }
it { is_expected.to be_a(Derived) }
# the superclass
it { is_expected.to be_kind_of(Base) }
it { is_expected.to be_a_kind_of(Base) }
it { is_expected.to be_an(Base) }
# an included module
it { is_expected.to be_kind_of(MyModule) }
it { is_expected.to be_a_kind_of(MyModule) }
it { is_expected.to be_a(MyModule) }
# negative passing case
it { is_expected.not_to be_kind_of(String) }
it { is_expected.not_to be_a_kind_of(String) }
it { is_expected.not_to be_a(String) }
# deliberate failures
it_fails { is_expected.not_to be_kind_of(Derived) }
it_fails { is_expected.not_to be_a_kind_of(Derived) }
it_fails { is_expected.not_to be_a(Derived) }
it_fails { is_expected.not_to be_kind_of(Base) }
it_fails { is_expected.not_to be_a_kind_of(Base) }
it_fails { is_expected.not_to be_an(Base) }
it_fails { is_expected.not_to be_kind_of(MyModule) }
it_fails { is_expected.not_to be_a_kind_of(MyModule) }
it_fails { is_expected.not_to be_a(MyModule) }
it_fails { is_expected.to be_kind_of(String) }
it_fails { is_expected.to be_a_kind_of(String) }
it_fails { is_expected.to be_a(String) }
end
context "be_(an_)instance_of matcher" do
# The docs use Float as an example.
# This doesn't work with the Crystal compiler,
# so a custom hierarchy is used instead.
# "Error: can't use Number as generic type argument yet, use a more specific type"
module MyModule; end
class Base; end
class Derived < Base
include MyModule
end
describe Derived do
# the actual class
it { is_expected.to be_instance_of(Derived) }
it { is_expected.to be_an_instance_of(Derived) }
# the superclass
it { is_expected.not_to be_instance_of(Base) }
it { is_expected.not_to be_an_instance_of(Base) }
# an included module
it { is_expected.not_to be_instance_of(MyModule) }
it { is_expected.not_to be_an_instance_of(MyModule) }
# another class with no relation to the subject's hierarchy
it { is_expected.not_to be_instance_of(String) }
it { is_expected.not_to be_an_instance_of(String) }
# deliberate failures
it_fails { is_expected.not_to be_instance_of(Derived) }
it_fails { is_expected.not_to be_an_instance_of(Derived) }
it_fails { is_expected.to be_instance_of(Base) }
it_fails { is_expected.to be_an_instance_of(Base) }
it_fails { is_expected.to be_instance_of(MyModule) }
it_fails { is_expected.to be_an_instance_of(MyModule) }
it_fails { is_expected.to be_instance_of(String) }
it_fails { is_expected.to be_an_instance_of(String) }
end
end
end
end

View file

@ -1,4 +1,13 @@
require "../src/spectator" require "../src/spectator"
# Prevent Spectator from trying to run tests on its own. macro it_fails(description = nil, &block)
Spectator.autorun = false it {{description}} do
expect do
{{block.body}}
end.to raise_error(Spectator::ExampleFailed)
end
end
macro specify_fails(description = nil, &block)
it_fails {{description}} {{block}}
end

23
spec/subject_spec.cr Normal file
View file

@ -0,0 +1,23 @@
require "./spec_helper"
class Base; end
module SomeModule; end
Spectator.describe "Subject" do
subject { Base.new }
context "nested" do
it "inherits the parent explicit subject" do
expect(subject).to be_a(Base)
end
end
context "module" do
describe SomeModule do
it "sets the implicit subject to the module" do
expect(subject).to be(SomeModule)
end
end
end
end

View file

@ -6,7 +6,7 @@ module Spectator
extend self extend self
# Current version of the Spectator library. # Current version of the Spectator library.
VERSION = "0.9.1" VERSION = "0.9.9"
# Top-level describe method. # Top-level describe method.
# All specs in a file must be wrapped in this call. # All specs in a file must be wrapped in this call.

View file

@ -184,6 +184,22 @@ module Spectator
is_expected.to_not eq({{expected}}) is_expected.to_not eq({{expected}})
end end
macro should(matcher)
is_expected.to({{matcher}})
end
macro should_not(matcher)
is_expected.to_not({{matcher}})
end
macro should_eventually(matcher)
is_expected.to_eventually({{matcher}})
end
macro should_never(matcher)
is_expected.to_never({{matcher}})
end
# Immediately fail the current test. # Immediately fail the current test.
# A reason can be passed, # A reason can be passed,
# which is reported in the output. # which is reported in the output.

View file

@ -3,7 +3,7 @@ require "../spec_builder"
module Spectator module Spectator
module DSL module DSL
macro it(description, _source_file = __FILE__, _source_line = __LINE__, &block) macro it(description = nil, _source_file = __FILE__, _source_line = __LINE__, &block)
{% if block.is_a?(Nop) %} {% if block.is_a?(Nop) %}
{% if description.is_a?(Call) %} {% if description.is_a?(Call) %}
def %run def %run
@ -20,17 +20,17 @@ module Spectator
%source = ::Spectator::Source.new({{_source_file}}, {{_source_line}}) %source = ::Spectator::Source.new({{_source_file}}, {{_source_line}})
::Spectator::SpecBuilder.add_example( ::Spectator::SpecBuilder.add_example(
{{description.is_a?(StringLiteral) ? description : description.stringify}}, {{description.is_a?(StringLiteral) || description.is_a?(NilLiteral) ? description : description.stringify}},
%source, %source,
{{@type.name}} {{@type.name}}
) { |test| test.as({{@type.name}}).%run } ) { |test| test.as({{@type.name}}).%run }
end end
macro specify(description, &block) macro specify(description = nil, &block)
it({{description}}) {{block}} it({{description}}) {{block}}
end end
macro pending(description, _source_file = __FILE__, _source_line = __LINE__, &block) macro pending(description = nil, _source_file = __FILE__, _source_line = __LINE__, &block)
{% if block.is_a?(Nop) %} {% if block.is_a?(Nop) %}
{% if description.is_a?(Call) %} {% if description.is_a?(Call) %}
def %run def %run
@ -47,17 +47,17 @@ module Spectator
%source = ::Spectator::Source.new({{_source_file}}, {{_source_line}}) %source = ::Spectator::Source.new({{_source_file}}, {{_source_line}})
::Spectator::SpecBuilder.add_pending_example( ::Spectator::SpecBuilder.add_pending_example(
{{description.is_a?(StringLiteral) ? description : description.stringify}}, {{description.is_a?(StringLiteral) || description.is_a?(NilLiteral) ? description : description.stringify}},
%source, %source,
{{@type.name}} {{@type.name}}
) { |test| test.as({{@type.name}}).%run } ) { |test| test.as({{@type.name}}).%run }
end end
macro skip(description, &block) macro skip(description = nil, &block)
pending({{description}}) {{block}} pending({{description}}) {{block}}
end end
macro xit(description, &block) macro xit(description = nil, &block)
pending({{description}}) {{block}} pending({{description}}) {{block}}
end end
end end

View file

@ -19,13 +19,21 @@ module Spectator
%source = ::Spectator::Source.new({{_source_file}}, {{_source_line}}) %source = ::Spectator::Source.new({{_source_file}}, {{_source_line}})
::Spectator::SpecBuilder.start_group({{description}}, %source) ::Spectator::SpecBuilder.start_group({{description}}, %source)
{% if what.is_a?(Path) || what.is_a?(Generic) %} {% if (what.is_a?(Path) || what.is_a?(Generic)) && (described_type = what.resolve?) %}
macro described_class macro described_class
{{what}} {{described_type.name}}
end end
def subject(*args) subject do
described_class.new(*args) {% if described_type < Reference || described_type < Value %}
described_class.new
{% else %}
described_class
{% end %}
end
{% else %}
def _spectator_implicit_subject(*args)
{{what}}
end end
{% end %} {% end %}

View file

@ -94,6 +94,60 @@ module Spectator
be_a({{expected}}) be_a({{expected}})
end end
# Indicates that some value should be of a specified type.
# The `Object#is_a?` method is used for this check.
# A type name or type union should be used for *expected*.
# This method is identical to `#be_a`,
# and exists just to improve grammar.
#
# Examples:
# ```
# expect(123).to be_kind_of(Int)
# ```
macro be_kind_of(expected)
be_a({{expected}})
end
# Indicates that some value should be of a specified type.
# The `Object#is_a?` method is used for this check.
# A type name or type union should be used for *expected*.
# This method is identical to `#be_a`,
# and exists just to improve grammar.
#
# Examples:
# ```
# expect(123).to be_a_kind_of(Int)
# ```
macro be_a_kind_of(expected)
be_a({{expected}})
end
# Indicates that some value should be of a specified type.
# The value's runtime class is checked.
# A type name or type union should be used for *expected*.
#
# Examples:
# ```
# expect(123).to be_instance_of(Int32)
# ```
macro be_instance_of(expected)
::Spectator::Matchers::InstanceMatcher({{expected}}).new
end
# Indicates that some value should be of a specified type.
# The value's runtime class is checked.
# A type name or type union should be used for *expected*.
# This method is identical to `#be_an_instance_of`,
# and exists just to improve grammar.
#
# Examples:
# ```
# expect(123).to be_an_instance_of(Int32)
# ```
macro be_an_instance_of(expected)
be_instance_of({{expected}})
end
# Indicates that some value should respond to a method call. # Indicates that some value should respond to a method call.
# One or more method names can be provided. # One or more method names can be provided.
# #
@ -288,10 +342,10 @@ module Spectator
# expect(100).to be_between(97, 101).exclusive # 97, 98, 99, or 100 (not 101) # expect(100).to be_between(97, 101).exclusive # 97, 98, 99, or 100 (not 101)
# ``` # ```
macro be_between(min, max) macro be_between(min, max)
%range = Range.new({{min}}, {{max}})) %range = Range.new({{min}}, {{max}})
%label = [{{min.stringify}}, {{max.stringify}}].join(" to ") %label = [{{min.stringify}}, {{max.stringify}}].join(" to ")
%test_value = ::Spectator::TestValue.new(%range, %label) %test_value = ::Spectator::TestValue.new(%range, %label)
:Spectator::Matchers::RangeMatcher.new(%test_value) ::Spectator::Matchers::RangeMatcher.new(%test_value)
end end
# Indicates that some value should be within a delta of an expected value. # Indicates that some value should be within a delta of an expected value.
@ -400,6 +454,27 @@ module Spectator
::Spectator::Matchers::ContainMatcher.new(%test_value) ::Spectator::Matchers::ContainMatcher.new(%test_value)
end end
# Indicates that some range (or collection) should contain another value.
# This is typically used on a `Range` (although any `Enumerable` works).
# The `includes?` method is used.
#
# Examples:
# ```
# expect(1..10).to contain(5)
# expect((1..)).to contain(100)
# expect(..100).to contain(50)
# ```
#
# Additionally, multiple arguments can be specified.
# ```
# expect(1..10).to contain(2, 3)
# expect(..100).to contain(0, 50)
# ```
macro cover(*expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.splat.stringify}})
::Spectator::Matchers::ContainMatcher.new(%test_value)
end
# Indicates that some value or set should contain another value. # Indicates that some value or set should contain another value.
# This is similar to `#contain`, but uses a different method for matching. # This is similar to `#contain`, but uses a different method for matching.
# Typically a `String` or `Array` (any `Enumerable` works) is checked against. # Typically a `String` or `Array` (any `Enumerable` works) is checked against.
@ -466,22 +541,23 @@ module Spectator
have_value({{expected}}) have_value({{expected}})
end end
# Indicates that some set should contain some values in exact order. # Indicates that some set should contain some values in any order.
# #
# Example: # Example:
# ``` # ```
# expect([1, 2, 3]).to contain_exactly(1, 2, 3) # expect([1, 2, 3]).to contain_exactly(3, 2, 1)
# ``` # ```
macro contain_exactly(*expected) macro contain_exactly(*expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::ArrayMatcher.new(%test_value) ::Spectator::Matchers::ArrayMatcher.new(%test_value)
end end
# Indicates that some set should contain the same values in exact order as another set. # Indicates that some set should contain the same values in any order as another set.
# This is the same as `#contain_exactly`, but takes an array as an argument.
# #
# Example: # Example:
# ``` # ```
# expect([1, 2, 3]).to match_array([1, 2, 3]) # expect([1, 2, 3]).to match_array([3, 2, 1])
# ``` # ```
macro match_array(expected) macro match_array(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}})
@ -716,8 +792,8 @@ module Spectator
{% raise "Undefined local variable or method '#{call}'" %} {% raise "Undefined local variable or method '#{call}'" %}
{% end %} {% end %}
descriptor = { {{method_name}}: Tuple.new({{call.args.splat}}) } descriptor = { {{method_name}}: ::Tuple.new({{call.args.splat}}) }
label = String::Builder.new({{method_name.stringify}}) label = ::String::Builder.new({{method_name.stringify}})
{% unless call.args.empty? %} {% unless call.args.empty? %}
label << '(' label << '('
{% for arg, index in call.args %} {% for arg, index in call.args %}

View file

@ -19,11 +19,18 @@ module Spectator
end end
macro let!(name, &block) macro let!(name, &block)
# TODO: Doesn't work with late-defined values (let). @%wrapper : ::Spectator::ValueWrapper?
@%value = {{yield}}
def %wrapper
{{block.body}}
end
before_each do
@%wrapper = ::Spectator::TypedValueWrapper.new(%wrapper)
end
def {{name.id}} def {{name.id}}
@%value @%wrapper.as(::Spectator::TypedValueWrapper(typeof(%wrapper))).value
end end
end end

View file

@ -6,6 +6,9 @@ module Spectator
# Concrete types must implement the `#run_impl` method. # Concrete types must implement the `#run_impl` method.
abstract class Example < ExampleComponent abstract class Example < ExampleComponent
@finished = false @finished = false
@description : String? = nil
protected setter description
# Indicates whether the example has already been run. # Indicates whether the example has already been run.
def finished? : Bool def finished? : Bool
@ -24,10 +27,12 @@ module Spectator
end end
def description : String | Symbol def description : String | Symbol
@test_wrapper.description @description || @test_wrapper.description
end end
def symbolic? : Bool def symbolic? : Bool
return false unless @test_wrapper.description?
description = @test_wrapper.description description = @test_wrapper.description
description.starts_with?('#') || description.starts_with?('.') description.starts_with?('#') || description.starts_with?('.')
end end

View file

@ -44,6 +44,10 @@ module Spectator::Expectations
values?.not_nil! values?.not_nil!
end end
def description
@match_data.description
end
# Creates the JSON representation of the expectation. # Creates the JSON representation of the expectation.
def to_json(json : ::JSON::Builder) def to_json(json : ::JSON::Builder)
json.object do json.object do

View file

@ -46,6 +46,7 @@ module Spectator
# Reports the outcome of an expectation. # Reports the outcome of an expectation.
# An exception will be raised when a failing result is given. # An exception will be raised when a failing result is given.
def report_expectation(expectation : Expectations::Expectation) : Nil def report_expectation(expectation : Expectations::Expectation) : Nil
@example.description = expectation.description unless @example.test_wrapper.description?
@reporter.report(expectation) @reporter.report(expectation)
end end

View file

@ -26,7 +26,7 @@ module Spectator::Matchers
match_data = matcher.match(element) match_data = matcher.match(element)
break match_data unless match_data.matched? break match_data unless match_data.matched?
end end
found || SuccessfulMatchData.new found || SuccessfulMatchData.new(description)
end end
# Negated matching for this matcher is not supported. # Negated matching for this matcher is not supported.

View file

@ -5,7 +5,7 @@ require "./unordered_array_matcher"
module Spectator::Matchers module Spectator::Matchers
# Matcher for checking that the contents of one array (or similar type) # Matcher for checking that the contents of one array (or similar type)
# has the exact same contents as another and in the same order. # has the exact same contents as another but may be in any order.
struct ArrayMatcher(ExpectedType) < Matcher struct ArrayMatcher(ExpectedType) < Matcher
# Expected value and label. # Expected value and label.
private getter expected private getter expected
@ -25,15 +25,19 @@ module Spectator::Matchers
def match(actual : TestExpression(T)) : MatchData forall T def match(actual : TestExpression(T)) : MatchData forall T
actual_elements = actual.value.to_a actual_elements = actual.value.to_a
expected_elements = expected.value.to_a expected_elements = expected.value.to_a
index = compare_arrays(expected_elements, actual_elements) missing, extra = compare_arrays(expected_elements, actual_elements)
case index if missing.empty? && extra.empty?
when Int # Content differs. # Contents are identical.
failed_content_mismatch(expected_elements, actual_elements, index, actual.label) SuccessfulMatchData.new(description)
when true # Contents are identical. else
SuccessfulMatchData.new # Content differs.
else # Size differs. FailedMatchData.new(description, "#{actual.label} does not contain exactly #{expected.label}",
failed_size_mismatch(expected_elements, actual_elements, actual.label) expected: expected_elements.inspect,
actual: actual_elements.inspect,
missing: missing.empty? ? "None" : missing.inspect,
extra: extra.empty? ? "None" : extra.inspect
)
end end
end end
@ -42,14 +46,17 @@ module Spectator::Matchers
def negated_match(actual : TestExpression(T)) : MatchData forall T def negated_match(actual : TestExpression(T)) : MatchData forall T
actual_elements = actual.value.to_a actual_elements = actual.value.to_a
expected_elements = expected.value.to_a expected_elements = expected.value.to_a
missing, extra = compare_arrays(expected_elements, actual_elements)
case compare_arrays(expected_elements, actual_elements) if missing.empty? && extra.empty?
when Int # Contents differ. # Contents are identical.
SuccessfulMatchData.new FailedMatchData.new(description, "#{actual.label} contains exactly #{expected.label}",
when true # Contents are identical. expected: "Not #{expected_elements.inspect}",
failed_content_identical(expected_elements, actual_elements, actual.label) actual: actual_elements.inspect
else # Size differs. )
SuccessfulMatchData.new else
# Content differs.
SuccessfulMatchData.new(description)
end end
end end
@ -65,49 +72,41 @@ module Spectator::Matchers
UnorderedArrayMatcher.new(expected) UnorderedArrayMatcher.new(expected)
end end
# Compares two arrays to determine whether they contain the same elements, and in the same order. # Compares two arrays to determine whether they contain the same elements, but in any order.
# If the arrays are the same, then `true` is returned. # A tuple of two arrays is returned.
# If they are different, `false` or an integer is returned. # The first array is the missing elements (present in expected, missing in actual).
# `false` is returned when the sizes of the arrays don't match. # The second array array is the extra elements (not present in expected, present in actual).
# An integer is returned, that is the index of the mismatched elements in the arrays.
private def compare_arrays(expected_elements, actual_elements) private def compare_arrays(expected_elements, actual_elements)
if expected_elements.size == actual_elements.size # Produce hashes where the array elements are the keys, and the values are the number of occurances.
index = 0 expected_hash = expected_elements.group_by(&.itself).map { |k, v| {k, v.size} }.to_h
expected_elements.zip(actual_elements) do |expected_element, actual_element| actual_hash = actual_elements.group_by(&.itself).map { |k, v| {k, v.size} }.to_h
return index unless expected_element == actual_element
index += 1 {
hash_count_difference(expected_hash, actual_hash),
hash_count_difference(actual_hash, expected_hash),
}
end end
true
# Expects two hashes, with values as counts for keys.
# Produces an array of differences with elements repeated if needed.
private def hash_count_difference(first, second)
# Subtract the number of occurances from the other array.
# A duplicate hash is used here because the original can't be modified,
# since it there's a two-way comparison.
#
# Then reject elements that have zero (or less) occurances.
# Lastly, expand to the correct number of elements.
first.map do |element, count|
if second_count = second[element]?
{element, count - second_count}
else else
false {element, count}
end end
end end.reject do |(_, count)|
count <= 0
# Produces match data for a failure when the array sizes differ. end.map do |(element, count)|
private def failed_size_mismatch(expected_elements, actual_elements, actual_label) Array.new(count, element)
FailedMatchData.new("#{actual_label} does not contain exactly #{expected.label} (size mismatch)", end.flatten
expected: expected_elements.inspect,
actual: actual_elements.inspect,
"expected size": expected_elements.size.to_s,
"actual size": actual_elements.size.to_s
)
end
# Produces match data for a failure when the array content is mismatched.
private def failed_content_mismatch(expected_elements, actual_elements, index, actual_label)
FailedMatchData.new("#{actual_label} does not contain exactly #{expected.label} (element mismatch)",
expected: expected_elements[index].inspect,
actual: actual_elements[index].inspect,
index: index.to_s
)
end
# Produces match data for a failure when the arrays are identical, but they shouldn't be (negation).
private def failed_content_identical(expected_elements, actual_elements, actual_label)
FailedMatchData.new("#{actual_label} contains exactly #{expected.label}",
expected: "Not #{expected_elements.inspect}",
actual: actual_elements.inspect
)
end end
end end
end end

View file

@ -28,9 +28,9 @@ module Spectator::Matchers
def match(actual : TestExpression(T)) : MatchData forall T def match(actual : TestExpression(T)) : MatchData forall T
snapshot = snapshot_values(actual.value) snapshot = snapshot_values(actual.value)
if match?(snapshot) if match?(snapshot)
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual.label} does not have attributes #{expected.label}", **values(snapshot)) FailedMatchData.new(description, "#{actual.label} does not have attributes #{expected.label}", **values(snapshot))
end end
end end
@ -39,9 +39,9 @@ module Spectator::Matchers
def negated_match(actual : TestExpression(T)) : MatchData forall T def negated_match(actual : TestExpression(T)) : MatchData forall T
snapshot = snapshot_values(actual.value) snapshot = snapshot_values(actual.value)
if match?(snapshot) if match?(snapshot)
FailedMatchData.new("#{actual.label} has attributes #{expected.label}", **negated_values(snapshot)) FailedMatchData.new(description, "#{actual.label} has attributes #{expected.label}", **negated_values(snapshot))
else else
SuccessfulMatchData.new SuccessfulMatchData.new(description)
end end
end end

View file

@ -16,6 +16,12 @@ module Spectator::Matchers
expected.value === actual.value expected.value === actual.value
end end
# Overload that takes a regex so that the operands are flipped.
# This mimics RSpec's behavior.
private def match?(actual : TestExpression(Regex)) : Bool forall T
actual.value === expected.value
end
# Message displayed when the matcher isn't satisifed. # Message displayed when the matcher isn't satisifed.
# #
# This is only called when `#match?` returns false. # This is only called when `#match?` returns false.

View file

@ -30,21 +30,21 @@ module Spectator::Matchers
before, after = change(actual) before, after = change(actual)
if expected_before == before if expected_before == before
if before == after if before == after
FailedMatchData.new("#{actual.label} did not change #{expression.label}", FailedMatchData.new(description, "#{actual.label} did not change #{expression.label}",
before: before.inspect, before: before.inspect,
after: after.inspect after: after.inspect
) )
elsif expected_after == after elsif expected_after == after
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual.label} did not change #{expression.label} to #{expected_after.inspect}", FailedMatchData.new(description, "#{actual.label} did not change #{expression.label} to #{expected_after.inspect}",
before: before.inspect, before: before.inspect,
after: after.inspect, after: after.inspect,
expected: expected_after.inspect expected: expected_after.inspect
) )
end end
else else
FailedMatchData.new("#{expression.label} was not initially #{expected_before.inspect}", FailedMatchData.new(description, "#{expression.label} was not initially #{expected_before.inspect}",
expected: expected_before.inspect, expected: expected_before.inspect,
actual: before.inspect, actual: before.inspect,
) )
@ -57,15 +57,15 @@ module Spectator::Matchers
before, after = change(actual) before, after = change(actual)
if expected_before == before if expected_before == before
if expected_after == after if expected_after == after
FailedMatchData.new("#{actual.label} changed #{expression.label} from #{expected_before.inspect} to #{expected_after.inspect}", FailedMatchData.new(description, "#{actual.label} changed #{expression.label} from #{expected_before.inspect} to #{expected_after.inspect}",
before: before.inspect, before: before.inspect,
after: after.inspect after: after.inspect
) )
else else
SuccessfulMatchData.new SuccessfulMatchData.new(description)
end end
else else
FailedMatchData.new("#{expression.label} was not initially #{expected_before.inspect}", FailedMatchData.new(description, "#{expression.label} was not initially #{expected_before.inspect}",
expected: expected_before.inspect, expected: expected_before.inspect,
actual: before.inspect, actual: before.inspect,
) )

View file

@ -27,18 +27,18 @@ module Spectator::Matchers
def match(actual : TestExpression(T)) : MatchData forall T def match(actual : TestExpression(T)) : MatchData forall T
before, after = change(actual) before, after = change(actual)
if expected != before if expected != before
FailedMatchData.new("#{expression.label} was not initially #{expected}", FailedMatchData.new(description, "#{expression.label} was not initially #{expected}",
expected: expected.inspect, expected: expected.inspect,
actual: before.inspect, actual: before.inspect,
) )
elsif before == after elsif before == after
FailedMatchData.new("#{actual.label} did not change #{expression.label} from #{expected}", FailedMatchData.new(description, "#{actual.label} did not change #{expression.label} from #{expected}",
before: before.inspect, before: before.inspect,
after: after.inspect, after: after.inspect,
expected: "Not #{expected.inspect}" expected: "Not #{expected.inspect}"
) )
else else
SuccessfulMatchData.new SuccessfulMatchData.new(description)
end end
end end
@ -47,14 +47,14 @@ module Spectator::Matchers
def negated_match(actual : TestExpression(T)) : MatchData forall T def negated_match(actual : TestExpression(T)) : MatchData forall T
before, after = change(actual) before, after = change(actual)
if expected != before if expected != before
FailedMatchData.new("#{expression.label} was not initially #{expected}", FailedMatchData.new(description, "#{expression.label} was not initially #{expected}",
expected: expected.inspect, expected: expected.inspect,
actual: before.inspect actual: before.inspect
) )
elsif before == after elsif before == after
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual.label} changed #{expression.label} from #{expected}", FailedMatchData.new(description, "#{actual.label} changed #{expression.label} from #{expected}",
before: before.inspect, before: before.inspect,
after: after.inspect, after: after.inspect,
expected: expected.inspect expected: expected.inspect

View file

@ -25,12 +25,12 @@ module Spectator::Matchers
def match(actual : TestExpression(T)) : MatchData forall T def match(actual : TestExpression(T)) : MatchData forall T
before, after = change(actual) before, after = change(actual)
if before == after if before == after
FailedMatchData.new("#{actual.label} did not change #{expression.label}", FailedMatchData.new(description, "#{actual.label} did not change #{expression.label}",
before: before.inspect, before: before.inspect,
after: after.inspect after: after.inspect
) )
else else
SuccessfulMatchData.new SuccessfulMatchData.new(description)
end end
end end
@ -39,9 +39,9 @@ module Spectator::Matchers
def negated_match(actual : TestExpression(T)) : MatchData forall T def negated_match(actual : TestExpression(T)) : MatchData forall T
before, after = change(actual) before, after = change(actual)
if before == after if before == after
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual.label} changed #{expression.label}", FailedMatchData.new(description, "#{actual.label} changed #{expression.label}",
before: before.inspect, before: before.inspect,
after: after.inspect after: after.inspect
) )

View file

@ -25,14 +25,14 @@ module Spectator::Matchers
def match(actual : TestExpression(T)) : MatchData forall T def match(actual : TestExpression(T)) : MatchData forall T
before, after = change(actual) before, after = change(actual)
if before == after if before == after
FailedMatchData.new("#{actual.label} did not change #{expression.label}", FailedMatchData.new(description, "#{actual.label} did not change #{expression.label}",
before: before.inspect, before: before.inspect,
after: after.inspect after: after.inspect
) )
elsif @evaluator.call(before, after) elsif @evaluator.call(before, after)
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual.label} did not change #{expression.label} #{@relativity}", FailedMatchData.new(description, "#{actual.label} did not change #{expression.label} #{@relativity}",
before: before.inspect, before: before.inspect,
after: after.inspect after: after.inspect
) )

View file

@ -27,15 +27,15 @@ module Spectator::Matchers
def match(actual : TestExpression(T)) : MatchData forall T def match(actual : TestExpression(T)) : MatchData forall T
before, after = change(actual) before, after = change(actual)
if before == after if before == after
FailedMatchData.new("#{actual.label} did not change #{expression.label}", FailedMatchData.new(description, "#{actual.label} did not change #{expression.label}",
before: before.inspect, before: before.inspect,
after: after.inspect, after: after.inspect,
expected: expected.inspect expected: expected.inspect
) )
elsif expected == after elsif expected == after
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual.label} did not change #{expression.label} to #{expected}", FailedMatchData.new(description, "#{actual.label} did not change #{expression.label} to #{expected}",
before: before.inspect, before: before.inspect,
after: after.inspect, after: after.inspect,
expected: expected.inspect expected: expected.inspect

View file

@ -18,6 +18,13 @@ module Spectator::Matchers
end end
end end
# If the expectation is negated, then this method is called instead of `#match?`.
private def does_not_match?(actual : TestExpression(T)) : Bool forall T
!expected.value.any? do |item|
actual.value.includes?(item)
end
end
# Message displayed when the matcher isn't satisifed. # Message displayed when the matcher isn't satisifed.
# #
# This is only called when `#match?` returns false. # This is only called when `#match?` returns false.
@ -25,7 +32,7 @@ module Spectator::Matchers
# The message should typically only contain the test expression labels. # The message should typically only contain the test expression labels.
# Actual values should be returned by `#values`. # Actual values should be returned by `#values`.
private def failure_message(actual) : String private def failure_message(actual) : String
"#{actual.label} does not match #{expected.label}" "#{actual.label} does not contain #{expected.label}"
end end
# Message displayed when the matcher isn't satisifed and is negated. # Message displayed when the matcher isn't satisifed and is negated.

View file

@ -23,7 +23,8 @@ module Spectator::Matchers
# Actually performs the test against the expression. # Actually performs the test against the expression.
def match(actual : TestExpression(T)) : MatchData forall T def match(actual : TestExpression(T)) : MatchData forall T
if (value = actual.value).responds_to?(:ends_with?) value = actual.value
if value.is_a?(String) || value.responds_to?(:ends_with?)
match_ends_with(value, actual.label) match_ends_with(value, actual.label)
else else
match_last(value, actual.label) match_last(value, actual.label)
@ -33,10 +34,11 @@ module Spectator::Matchers
# Performs the test against the expression, but inverted. # Performs the test against the expression, but inverted.
# A successful match with `#match` should normally fail for this method, and vice-versa. # A successful match with `#match` should normally fail for this method, and vice-versa.
def negated_match(actual : TestExpression(T)) : MatchData forall T def negated_match(actual : TestExpression(T)) : MatchData forall T
if actual.value.responds_to?(:ends_with?) value = actual.value
negated_match_ends_with(actual) if value.is_a?(String) || value.responds_to?(:ends_with?)
negated_match_ends_with(value, actual.label)
else else
negated_match_last(actual) negated_match_last(value, actual.label)
end end
end end
@ -44,9 +46,9 @@ module Spectator::Matchers
# This method expects (and uses) the `#ends_with?` method on the value. # This method expects (and uses) the `#ends_with?` method on the value.
private def match_ends_with(actual_value, actual_label) private def match_ends_with(actual_value, actual_label)
if actual_value.ends_with?(expected.value) if actual_value.ends_with?(expected.value)
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual_label} does not end with #{expected.label} (using #ends_with?)", FailedMatchData.new(description, "#{actual_label} does not end with #{expected.label} (using #ends_with?)",
expected: expected.value.inspect, expected: expected.value.inspect,
actual: actual_value.inspect actual: actual_value.inspect
) )
@ -60,9 +62,9 @@ module Spectator::Matchers
last = list.last last = list.last
if expected.value === last if expected.value === last
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual_label} does not end with #{expected.label} (using expected === last)", FailedMatchData.new(description, "#{actual_label} does not end with #{expected.label} (using expected === last)",
expected: expected.value.inspect, expected: expected.value.inspect,
actual: last.inspect, actual: last.inspect,
list: list.inspect list: list.inspect
@ -72,31 +74,31 @@ module Spectator::Matchers
# Checks whether the actual value does not end with the expected value. # Checks whether the actual value does not end with the expected value.
# This method expects (and uses) the `#ends_with?` method on the value. # This method expects (and uses) the `#ends_with?` method on the value.
private def negated_match_ends_with(actual) private def negated_match_ends_with(actual_value, actual_label)
if actual.value.ends_with?(expected.value) if actual_value.ends_with?(expected.value)
FailedMatchData.new("#{actual.label} ends with #{expected.label} (using #ends_with?)", FailedMatchData.new(description, "#{actual_label} ends with #{expected.label} (using #ends_with?)",
expected: expected.value.inspect, expected: "Not #{expected.value.inspect}",
actual: actual.value.inspect actual: actual_value.inspect
) )
else else
SuccessfulMatchData.new SuccessfulMatchData.new(description)
end end
end end
# Checks whether the last element of the value is not the expected value. # Checks whether the last element of the value is not the expected value.
# This method expects that the actual value is a set (enumerable). # This method expects that the actual value is a set (enumerable).
private def negated_match_last(actual) private def negated_match_last(actual_value, actual_label)
list = actual.value.to_a list = actual_value.to_a
last = list.last last = list.last
if expected.value === last if expected.value === last
FailedMatchData.new("#{actual.label} ends with #{expected.label} (using expected === last)", FailedMatchData.new(description, "#{actual_label} ends with #{expected.label} (using expected === last)",
expected: expected.value.inspect, expected: "Not #{expected.value.inspect}",
actual: last.inspect, actual: last.inspect,
list: list.inspect list: list.inspect
) )
else else
SuccessfulMatchData.new SuccessfulMatchData.new(description)
end end
end end
end end

View file

@ -33,16 +33,16 @@ module Spectator::Matchers
def match(actual : TestExpression(T)) : MatchData forall T def match(actual : TestExpression(T)) : MatchData forall T
exception = capture_exception { actual.value } exception = capture_exception { actual.value }
if exception.nil? if exception.nil?
FailedMatchData.new("#{actual.label} did not raise", expected: ExceptionType.inspect) FailedMatchData.new(description, "#{actual.label} did not raise", expected: ExceptionType.inspect)
else else
if exception.is_a?(ExceptionType) if exception.is_a?(ExceptionType)
if (value = expected.value).nil? if (value = expected.value).nil?
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
if value === exception.message if value === exception.message
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual.label} raised #{exception.class}, but the message is not #{expected.label}", FailedMatchData.new(description, "#{actual.label} raised #{exception.class}, but the message is not #{expected.label}",
"expected type": ExceptionType.inspect, "expected type": ExceptionType.inspect,
"actual type": exception.class.inspect, "actual type": exception.class.inspect,
"expected message": value.inspect, "expected message": value.inspect,
@ -51,7 +51,7 @@ module Spectator::Matchers
end end
end end
else else
FailedMatchData.new("#{actual.label} did not raise #{ExceptionType}", FailedMatchData.new(description, "#{actual.label} did not raise #{ExceptionType}",
expected: ExceptionType.inspect, expected: ExceptionType.inspect,
actual: exception.class.inspect actual: exception.class.inspect
) )
@ -64,32 +64,37 @@ module Spectator::Matchers
def negated_match(actual : TestExpression(T)) : MatchData forall T def negated_match(actual : TestExpression(T)) : MatchData forall T
exception = capture_exception { actual.value } exception = capture_exception { actual.value }
if exception.nil? if exception.nil?
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
if exception.is_a?(ExceptionType) if exception.is_a?(ExceptionType)
if (value = expected.value).nil? if (value = expected.value).nil?
FailedMatchData.new("#{actual.label} raised #{exception.class}", FailedMatchData.new(description, "#{actual.label} raised #{exception.class}",
expected: "Not #{ExceptionType}", expected: "Not #{ExceptionType}",
actual: exception.class.inspect actual: exception.class.inspect
) )
else else
if value === exception.message if value === exception.message
FailedMatchData.new("#{actual.label} raised #{exception.class} with message matching #{expected.label}", FailedMatchData.new(description, "#{actual.label} raised #{exception.class} with message matching #{expected.label}",
"expected type": ExceptionType.inspect, "expected type": ExceptionType.inspect,
"actual type": exception.class.inspect, "actual type": exception.class.inspect,
"expected message": value.inspect, "expected message": value.inspect,
"actual message": exception.message.to_s "actual message": exception.message.to_s
) )
else else
SuccessfulMatchData.new SuccessfulMatchData.new(description)
end end
end end
else else
SuccessfulMatchData.new SuccessfulMatchData.new(description)
end end
end end
end end
def with_message(message : T) forall T
value = TestValue.new(message)
ExceptionMatcher(ExceptionType, T).new(value)
end
# Runs a block of code and returns the exception it threw. # Runs a block of code and returns the exception it threw.
# If no exception was thrown, *nil* is returned. # If no exception was thrown, *nil* is returned.
private def capture_exception private def capture_exception

View file

@ -15,11 +15,13 @@ module Spectator::Matchers
getter values : Array(Tuple(Symbol, String)) getter values : Array(Tuple(Symbol, String))
# Creates the match data. # Creates the match data.
def initialize(@failure_message, @values) def initialize(description, @failure_message, @values)
super(description)
end end
# Creates the match data. # Creates the match data.
def initialize(@failure_message, **values) def initialize(description, @failure_message, **values)
super(description)
@values = values.to_a @values = values.to_a
end end
end end

View file

@ -7,13 +7,6 @@ module Spectator::Matchers
# Each key in the tuple is a predicate (without the '?' and 'has_' prefix) to test. # Each key in the tuple is a predicate (without the '?' and 'has_' prefix) to test.
# Each value is a a `Tuple` of arguments to pass to the predicate method. # Each value is a a `Tuple` of arguments to pass to the predicate method.
struct HavePredicateMatcher(ExpectedType) < ValueMatcher(ExpectedType) struct HavePredicateMatcher(ExpectedType) < ValueMatcher(ExpectedType)
# Expected value and label.
private getter expected
# Creates the matcher with a expected values.
def initialize(@expected : TestValue(ExpectedType))
end
# Short text about the matcher's purpose. # Short text about the matcher's purpose.
# This explains what condition satisfies the matcher. # This explains what condition satisfies the matcher.
# The description is used when the one-liner syntax is used. # The description is used when the one-liner syntax is used.
@ -25,9 +18,9 @@ module Spectator::Matchers
def match(actual : TestExpression(T)) : MatchData forall T def match(actual : TestExpression(T)) : MatchData forall T
snapshot = snapshot_values(actual.value) snapshot = snapshot_values(actual.value)
if match?(snapshot) if match?(snapshot)
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual.label} does not have #{expected.label}", **values(snapshot)) FailedMatchData.new(description, "#{actual.label} does not have #{expected.label}", **values(snapshot))
end end
end end
@ -36,9 +29,9 @@ module Spectator::Matchers
def negated_match(actual : TestExpression(T)) : MatchData forall T def negated_match(actual : TestExpression(T)) : MatchData forall T
snapshot = snapshot_values(actual.value) snapshot = snapshot_values(actual.value)
if match?(snapshot) if match?(snapshot)
FailedMatchData.new("#{actual.label} has #{expected.label}", **values(snapshot)) FailedMatchData.new(description, "#{actual.label} has #{expected.label}", **values(snapshot))
else else
SuccessfulMatchData.new SuccessfulMatchData.new(description)
end end
end end

View file

@ -0,0 +1,57 @@
require "./matcher"
module Spectator::Matchers
# Matcher that tests a value is of a specified type.
struct InstanceMatcher(Expected) < StandardMatcher
# Short text about the matcher's purpose.
# This explains what condition satisfies the matcher.
# The description is used when the one-liner syntax is used.
def description : String
"is an instance of #{Expected}"
end
# Checks whether the matcher is satisifed with the expression given to it.
private def match?(actual : TestExpression(T)) : Bool forall T
actual.value.class == Expected
end
# Message displayed when the matcher isn't satisifed.
#
# This is only called when `#match?` returns false.
#
# The message should typically only contain the test expression labels.
# Actual values should be returned by `#values`.
private def failure_message(actual) : String
"#{actual.label} is not an instance of #{Expected}"
end
# Message displayed when the matcher isn't satisifed and is negated.
# This is essentially what would satisfy the matcher if it wasn't negated.
#
# This is only called when `#does_not_match?` returns false.
#
# The message should typically only contain the test expression labels.
# Actual values should be returned by `#values`.
private def failure_message_when_negated(actual) : String
"#{actual.label} is an instance of #{Expected}"
end
# Additional information about the match failure.
# The return value is a NamedTuple with Strings for each value.
private def values(actual)
{
expected: Expected.to_s,
actual: actual.value.class.inspect,
}
end
# Additional information about the match failure when negated.
# The return value is a NamedTuple with Strings for each value.
private def negated_values(actual)
{
expected: "Not #{Expected}",
actual: actual.value.class.inspect,
}
end
end
end

View file

@ -3,5 +3,10 @@ module Spectator::Matchers
abstract struct MatchData abstract struct MatchData
# Indicates whether the match as successful or not. # Indicates whether the match as successful or not.
abstract def matched? : Bool abstract def matched? : Bool
getter description : String
def initialize(@description : String)
end
end end
end end

View file

@ -24,9 +24,9 @@ module Spectator::Matchers
def match(actual : TestExpression(T)) : MatchData forall T def match(actual : TestExpression(T)) : MatchData forall T
snapshot = snapshot_values(actual.value) snapshot = snapshot_values(actual.value)
if match?(snapshot) if match?(snapshot)
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual.label} is not #{expected.label}", **values(snapshot)) FailedMatchData.new(description, "#{actual.label} is not #{expected.label}", **values(snapshot))
end end
end end
@ -35,9 +35,9 @@ module Spectator::Matchers
def negated_match(actual : TestExpression(T)) : MatchData forall T def negated_match(actual : TestExpression(T)) : MatchData forall T
snapshot = snapshot_values(actual.value) snapshot = snapshot_values(actual.value)
if match?(snapshot) if match?(snapshot)
FailedMatchData.new("#{actual.label} is #{expected.label}", **values(snapshot)) FailedMatchData.new(description, "#{actual.label} is #{expected.label}", **values(snapshot))
else else
SuccessfulMatchData.new SuccessfulMatchData.new(description)
end end
end end

View file

@ -13,6 +13,7 @@ module Spectator::Matchers
# Returns a new matcher, with the same bounds, but uses an inclusive range. # Returns a new matcher, with the same bounds, but uses an inclusive range.
def inclusive def inclusive
label = expected.label
new_range = Range.new(range.begin, range.end, exclusive: false) new_range = Range.new(range.begin, range.end, exclusive: false)
expected = TestValue.new(new_range, label) expected = TestValue.new(new_range, label)
RangeMatcher.new(expected) RangeMatcher.new(expected)
@ -20,6 +21,7 @@ module Spectator::Matchers
# Returns a new matcher, with the same bounds, but uses an exclusive range. # Returns a new matcher, with the same bounds, but uses an exclusive range.
def exclusive def exclusive
label = expected.label
new_range = Range.new(range.begin, range.end, exclusive: true) new_range = Range.new(range.begin, range.end, exclusive: true)
expected = TestValue.new(new_range, label) expected = TestValue.new(new_range, label)
RangeMatcher.new(expected) RangeMatcher.new(expected)

View file

@ -13,7 +13,13 @@ module Spectator::Matchers
# Checks whether the matcher is satisifed with the expression given to it. # Checks whether the matcher is satisifed with the expression given to it.
private def match?(actual : TestExpression(T)) : Bool forall T private def match?(actual : TestExpression(T)) : Bool forall T
expected.value.same?(actual.value) value = expected.value
if value.responds_to?(:same?)
value.same?(actual.value)
else
# Value type (struct) comparison.
actual.value.class == value.class && actual.value == value
end
end end
# Message displayed when the matcher isn't satisifed. # Message displayed when the matcher isn't satisifed.

View file

@ -16,10 +16,10 @@ module Spectator::Matchers
# Actually performs the test against the expression. # Actually performs the test against the expression.
def match(actual : TestExpression(T)) : MatchData forall T def match(actual : TestExpression(T)) : MatchData forall T
snapshot = snapshot_values(actual.value) snapshot = snapshot_values(actual.value)
if match?(snapshot) if snapshot.values.all?
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual.label} does not respond to #{label}", **values(snapshot)) FailedMatchData.new(description, "#{actual.label} does not respond to #{label}", **values(snapshot))
end end
end end
@ -27,10 +27,10 @@ module Spectator::Matchers
# A successful match with `#match` should normally fail for this method, and vice-versa. # A successful match with `#match` should normally fail for this method, and vice-versa.
def negated_match(actual : TestExpression(T)) : MatchData forall T def negated_match(actual : TestExpression(T)) : MatchData forall T
snapshot = snapshot_values(actual.value) snapshot = snapshot_values(actual.value)
if match?(snapshot) if snapshot.values.any?
FailedMatchData.new("#{actual.label} responds to #{label}", **values(snapshot)) FailedMatchData.new(description, "#{actual.label} responds to #{label}", **values(snapshot))
else else
SuccessfulMatchData.new SuccessfulMatchData.new(description)
end end
end end
@ -46,13 +46,6 @@ module Spectator::Matchers
{% end %} {% end %}
end end
# Checks if all results from the snapshot are satisified.
private def match?(snapshot)
# The snapshot did the hard work.
# Here just check if all values are true.
snapshot.values.all?
end
# Produces the tuple for the failed match data from a snapshot of the results. # Produces the tuple for the failed match data from a snapshot of the results.
private def values(snapshot) private def values(snapshot)
{% begin %} {% begin %}

View file

@ -25,9 +25,9 @@ module Spectator::Matchers
# Additionally, `#failure_message` and `#values` are called for a failed match. # Additionally, `#failure_message` and `#values` are called for a failed match.
def match(actual : TestExpression(T)) : MatchData forall T def match(actual : TestExpression(T)) : MatchData forall T
if match?(actual) if match?(actual)
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new(failure_message(actual), values(actual).to_a) FailedMatchData.new(description, failure_message(actual), values(actual).to_a)
end end
end end
@ -39,10 +39,11 @@ module Spectator::Matchers
# Otherwise, a `FailedMatchData` instance is returned. # Otherwise, a `FailedMatchData` instance is returned.
# Additionally, `#failure_message_when_negated` and `#negated_values` are called for a failed match. # Additionally, `#failure_message_when_negated` and `#negated_values` are called for a failed match.
def negated_match(actual : TestExpression(T)) : MatchData forall T def negated_match(actual : TestExpression(T)) : MatchData forall T
# TODO: Invert description.
if does_not_match?(actual) if does_not_match?(actual)
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new(failure_message_when_negated(actual), negated_values(actual).to_a) FailedMatchData.new(description, failure_message_when_negated(actual), negated_values(actual).to_a)
end end
end end

View file

@ -22,7 +22,8 @@ module Spectator::Matchers
# Actually performs the test against the expression. # Actually performs the test against the expression.
def match(actual : TestExpression(T)) : MatchData forall T def match(actual : TestExpression(T)) : MatchData forall T
if (value = actual.value).responds_to?(:starts_with?) value = actual.value
if value.is_a?(String) || value.responds_to?(:starts_with?)
match_starts_with(value, actual.label) match_starts_with(value, actual.label)
else else
match_first(value, actual.label) match_first(value, actual.label)
@ -32,7 +33,8 @@ module Spectator::Matchers
# Performs the test against the expression, but inverted. # Performs the test against the expression, but inverted.
# A successful match with `#match` should normally fail for this method, and vice-versa. # A successful match with `#match` should normally fail for this method, and vice-versa.
def negated_match(actual : TestExpression(T)) : MatchData forall T def negated_match(actual : TestExpression(T)) : MatchData forall T
if (value = actual.value).responds_to?(:starts_with?) value = actual.value
if value.is_a?(String) || value.responds_to?(:starts_with?)
negated_match_starts_with(value, actual.label) negated_match_starts_with(value, actual.label)
else else
negated_match_first(value, actual.label) negated_match_first(value, actual.label)
@ -43,9 +45,9 @@ module Spectator::Matchers
# This method expects (and uses) the `#starts_with?` method on the value. # This method expects (and uses) the `#starts_with?` method on the value.
private def match_starts_with(actual_value, actual_label) private def match_starts_with(actual_value, actual_label)
if actual_value.starts_with?(expected.value) if actual_value.starts_with?(expected.value)
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual_label} does not start with #{expected.label} (using #starts_with?)", FailedMatchData.new(description, "#{actual_label} does not start with #{expected.label} (using #starts_with?)",
expected: expected.value.inspect, expected: expected.value.inspect,
actual: actual_value.inspect actual: actual_value.inspect
) )
@ -59,9 +61,9 @@ module Spectator::Matchers
first = list.first first = list.first
if expected.value === first if expected.value === first
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual_label} does not start with #{expected.label} (using expected === first)", FailedMatchData.new(description, "#{actual_label} does not start with #{expected.label} (using expected === first)",
expected: expected.value.inspect, expected: expected.value.inspect,
actual: first.inspect, actual: first.inspect,
list: list.inspect list: list.inspect
@ -73,12 +75,12 @@ module Spectator::Matchers
# This method expects (and uses) the `#starts_with?` method on the value. # This method expects (and uses) the `#starts_with?` method on the value.
private def negated_match_starts_with(actual_value, actual_label) private def negated_match_starts_with(actual_value, actual_label)
if actual_value.starts_with?(expected.value) if actual_value.starts_with?(expected.value)
FailedMatchData.new("#{actual_label} starts with #{expected.label} (using #starts_with?)", FailedMatchData.new(description, "#{actual_label} starts with #{expected.label} (using #starts_with?)",
expected: expected.value.inspect, expected: "Not #{expected.value.inspect}",
actual: actual_value.inspect actual: actual_value.inspect
) )
else else
SuccessfulMatchData.new SuccessfulMatchData.new(description)
end end
end end
@ -89,13 +91,13 @@ module Spectator::Matchers
first = list.first first = list.first
if expected.value === first if expected.value === first
FailedMatchData.new("#{actual_label} starts with #{expected.label} (using expected === first)", FailedMatchData.new(description, "#{actual_label} starts with #{expected.label} (using expected === first)",
expected: expected.value.inspect, expected: "Not #{expected.value.inspect}",
actual: first.inspect, actual: first.inspect,
list: list.inspect list: list.inspect
) )
else else
SuccessfulMatchData.new SuccessfulMatchData.new(description)
end end
end end
end end

View file

@ -8,7 +8,7 @@ module Spectator::Matchers
# This explains what condition satisfies the matcher. # This explains what condition satisfies the matcher.
# The description is used when the one-liner syntax is used. # The description is used when the one-liner syntax is used.
def description : String def description : String
"is as #{Expected}" "is a #{Expected}"
end end
# Checks whether the matcher is satisifed with the expression given to it. # Checks whether the matcher is satisifed with the expression given to it.

View file

@ -25,9 +25,9 @@ module Spectator::Matchers
missing, extra = array_diff(expected_elements, actual_elements) missing, extra = array_diff(expected_elements, actual_elements)
if missing.empty? && extra.empty? if missing.empty? && extra.empty?
SuccessfulMatchData.new SuccessfulMatchData.new(description)
else else
FailedMatchData.new("#{actual_label} does not contain #{expected.label} (unordered)", FailedMatchData.new(description, "#{actual_label} does not contain #{expected.label} (unordered)",
expected: expected_elements.inspect, expected: expected_elements.inspect,
actual: actual_elements.inspect, actual: actual_elements.inspect,
missing: missing.inspect, missing: missing.inspect,
@ -44,12 +44,12 @@ module Spectator::Matchers
missing, extra = array_diff(expected_elements, actual_elements) missing, extra = array_diff(expected_elements, actual_elements)
if missing.empty? && extra.empty? if missing.empty? && extra.empty?
FailedMatchData.new("#{actual_label} contains #{expected.label} (unordered)", FailedMatchData.new(description, "#{actual_label} contains #{expected.label} (unordered)",
expected: "Not #{expected_elements.inspect}", expected: "Not #{expected_elements.inspect}",
actual: actual_elements.inspect, actual: actual_elements.inspect,
) )
else else
SuccessfulMatchData.new SuccessfulMatchData.new(description)
end end
end end

View file

@ -17,13 +17,28 @@ module Spectator::Mocks
named = false named = false
name = definition.name.id name = definition.name.id
params = definition.args params = definition.args
# Possibly a weird compiler bug, but syntax like this:
# stub instance.==(other) { true }
# Results in `other` being the call `other { true }`.
# This works around the issue by pulling out the block
# and setting the parameter to just the name.
if params.last.is_a?(Call)
body = params.last.block
params[-1] = params.last.name
end
args = params.map do |p| args = params.map do |p|
n = p.is_a?(TypeDeclaration) ? p.var : p.id n = p.is_a?(TypeDeclaration) ? p.var : p.id
r = named ? "#{n}: #{n}".id : n r = named ? "#{n}: #{n}".id : n
named = true if n.starts_with?('*') named = true if n.starts_with?('*')
r r
end end
# The unless is here because `||=` can't be used in macros @_@
unless body
body = definition.block.is_a?(Nop) ? block : definition.block body = definition.block.is_a?(Nop) ? block : definition.block
end
elsif definition.is_a?(TypeDeclaration) # stub foo : Symbol elsif definition.is_a?(TypeDeclaration) # stub foo : Symbol
name = definition.var name = definition.var
params = [] of MacroId params = [] of MacroId

View file

@ -12,13 +12,19 @@ module Spectator::Mocks
named = false named = false
name = definition.name.id name = definition.name.id
params = definition.args params = definition.args
if params.last.is_a?(Call)
body = params.last.block
params[-1] = params.last.name
end
args = params.map do |p| args = params.map do |p|
n = p.is_a?(TypeDeclaration) ? p.var : p.id n = p.is_a?(TypeDeclaration) ? p.var : p.id
r = named ? "#{n}: #{n}".id : n r = named ? "#{n}: #{n}".id : n
named = true if n.starts_with?('*') named = true if n.starts_with?('*')
r r
end end
unless body
body = definition.block.is_a?(Nop) ? block : definition.block body = definition.block.is_a?(Nop) ? block : definition.block
end
elsif definition.is_a?(TypeDeclaration) # stub foo : Symbol elsif definition.is_a?(TypeDeclaration) # stub foo : Symbol
name = definition.var name = definition.var
params = [] of MacroId params = [] of MacroId
@ -28,17 +34,19 @@ module Spectator::Mocks
raise "Unrecognized stub format" raise "Unrecognized stub format"
end end
original = if @type.methods.find { |m| m.name.id == name } t = @type
:previous_def
else
:super
end.id
receiver = if receiver == :self.id receiver = if receiver == :self.id
original = :previous_def.id t = t.class
"self." "self."
else else
"" ""
end.id end.id
original = if (name == :new.id && receiver == "self.".id) ||
(t.superclass.has_method?(name) && !t.overrides?(t.superclass, name))
:super
else
:previous_def
end.id
%} %}
{% if body && !body.is_a?(Nop) %} {% if body && !body.is_a?(Nop) %}

View file

@ -19,6 +19,7 @@ module Spectator
ResultCapture.new.tap do |result| ResultCapture.new.tap do |result|
context.run_before_hooks(self) context.run_before_hooks(self)
run_example(result) run_example(result)
@finished = true
context.run_after_hooks(self) context.run_after_hooks(self)
run_deferred(result) unless result.error run_deferred(result) unless result.error
end end

View file

@ -42,7 +42,7 @@ module Spectator
# Adds an example type to the current group. # Adds an example type to the current group.
# The class name of the example should be passed as an argument. # The class name of the example should be passed as an argument.
# The example will be instantiated later. # The example will be instantiated later.
def add_example(description : String, source : Source, def add_example(description : String?, source : Source,
example_type : ::SpectatorTest.class, &runner : ::SpectatorTest ->) : Nil example_type : ::SpectatorTest.class, &runner : ::SpectatorTest ->) : Nil
builder = ->(values : TestValues) { example_type.new(values).as(::SpectatorTest) } builder = ->(values : TestValues) { example_type.new(values).as(::SpectatorTest) }
factory = RunnableExampleBuilder.new(description, source, builder, runner) factory = RunnableExampleBuilder.new(description, source, builder, runner)
@ -52,7 +52,7 @@ module Spectator
# Adds an example type to the current group. # Adds an example type to the current group.
# The class name of the example should be passed as an argument. # The class name of the example should be passed as an argument.
# The example will be instantiated later. # The example will be instantiated later.
def add_pending_example(description : String, source : Source, def add_pending_example(description : String?, source : Source,
example_type : ::SpectatorTest.class, &runner : ::SpectatorTest ->) : Nil example_type : ::SpectatorTest.class, &runner : ::SpectatorTest ->) : Nil
builder = ->(values : TestValues) { example_type.new(values).as(::SpectatorTest) } builder = ->(values : TestValues) { example_type.new(values).as(::SpectatorTest) }
factory = PendingExampleBuilder.new(description, source, builder, runner) factory = PendingExampleBuilder.new(description, source, builder, runner)

View file

@ -6,7 +6,7 @@ module Spectator::SpecBuilder
abstract class ExampleBuilder abstract class ExampleBuilder
alias FactoryMethod = TestValues -> ::SpectatorTest alias FactoryMethod = TestValues -> ::SpectatorTest
def initialize(@description : String, @source : Source, @builder : FactoryMethod, @runner : TestMethod) def initialize(@description : String?, @source : Source, @builder : FactoryMethod, @runner : TestMethod)
end end
abstract def build(group) : ExampleComponent abstract def build(group) : ExampleComponent

View file

@ -8,13 +8,19 @@ module Spectator
# Used to instantiate tests and run them. # Used to instantiate tests and run them.
struct TestWrapper struct TestWrapper
# Description the user provided for the test. # Description the user provided for the test.
getter description def description
@description || @source.to_s
end
# Location of the test in source code. # Location of the test in source code.
getter source getter source
# Creates a wrapper for the test. # Creates a wrapper for the test.
def initialize(@description : String, @source : Source, @test : ::SpectatorTest, @runner : TestMethod) def initialize(@description : String?, @source : Source, @test : ::SpectatorTest, @runner : TestMethod)
end
def description?
!@description.nil?
end end
def run def run

View file

@ -6,6 +6,14 @@ require "./spectator/dsl"
class SpectatorTest class SpectatorTest
include ::Spectator::DSL include ::Spectator::DSL
def _spectator_implicit_subject
nil
end
def subject
_spectator_implicit_subject
end
def initialize(@spectator_test_values : ::Spectator::TestValues) def initialize(@spectator_test_values : ::Spectator::TestValues)
end end
end end