Merge branch 'release/0.10' into 'master'

v0.10 Release

Closes #57 and #28

See merge request arctic-fox/spectator!29
This commit is contained in:
Mike Miller 2021-08-19 19:59:00 +00:00
commit 60085eb726
266 changed files with 7457 additions and 4728 deletions

3
.gitignore vendored
View file

@ -7,3 +7,6 @@
# Libraries don't need dependency lock # Libraries don't need dependency lock
# Dependencies will be locked in application that uses them # Dependencies will be locked in application that uses them
/shard.lock /shard.lock
# Ignore JUnit output
output.xml

View file

@ -14,21 +14,37 @@ before_script:
spec: spec:
script: script:
- shards - shards
- crystal spec --error-on-warnings - crystal spec --error-on-warnings --junit_output=.
artifacts:
when: always
paths:
- output.xml
reports:
junit: output.xml
format:
script:
- shards
- crystal tool format --check
style: style:
script: script:
- shards - shards
- bin/ameba - bin/ameba
- crystal tool format --check
nightly: nightly:
image: "crystallang/crystal:nightly" image: "crystallang/crystal:nightly"
allow_failure: true allow_failure: true
script: script:
- shards --ignore-crystal-version - shards --ignore-crystal-version
- crystal spec --error-on-warnings - crystal spec --error-on-warnings --junit_output=.
- crystal tool format --check - crystal tool format --check
artifacts:
when: always
paths:
- output.xml
reports:
junit: output.xml
pages: pages:
stage: deploy stage: deploy

View file

@ -1,5 +1,11 @@
files: ./**/*.cr files: ./src/**/*.cr
run: time crystal spec --error-trace run: time crystal spec --error-trace
--- ---
files: ./src/**/*.cr
run: bin/ameba %file%
---
files: ./spec/**/*.cr
run: time crystal spec --error-trace %file%
---
files: ./shard.yml files: ./shard.yml
run: shards run: shards

View file

@ -4,6 +4,58 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- Fix resolution of types with the same name in nested scopes. [#31](https://github.com/icy-arctic-fox/spectator/issues/31)
- `around_each` hooks wrap `before_all` and `after_all` hooks. [#12](https://github.com/icy-arctic-fox/spectator/issues/12)
- Hook execution order has been tweaked to match RSpec.
### Added
- `before_each`, `after_each`, and `around_each` hooks are yielded the current example as a block argument.
- The `let` and `subject` blocks are yielded the current example as a block argument.
- Add internal logging that uses Crystal's `Log` utility. Provide the `LOG_LEVEL` environment variable to enable.
- Support dynamic creation of examples.
- Capture and log information for hooks.
- Tags can be added to examples and example groups.
- Add matcher to check compiled type of values.
- Examples can be skipped by using a `:pending` tag. A reason method can be specified: `pending: "Some excuse"`
- Examples without a test block are marked as pending. [#37](https://gitlab.com/arctic-fox/spectator/-/issues/37)
- Examples can be skipped during execution by using `skip` or `pending` in the example block. [#17](https://gitlab.com/arctic-fox/spectator/-/issues/17)
- Sample blocks can be temporarily skipped by using `xsample` or `xrandom_sample`.
- Add `before_suite` and `after_suite` hooks. [#21](https://gitlab.com/arctic-fox/spectator/-/issues/21)
- Support defining hooks in `Spectator.configure` block. [#21](https://gitlab.com/arctic-fox/spectator/-/issues/21)
- Examples with failures or skipped during execution will report the location of that result. [#57](https://gitlab.com/arctic-fox/spectator/-/issues/57)
- Support custom messages for failed expectations. [#28](https://gitlab.com/arctic-fox/spectator/-/issues/28)
- Allow named arguments and assignments for `provided` (`given`) block.
- Add `aggregate_failures` to capture and report multiple failed expectations. [#24](https://gitlab.com/arctic-fox/spectator/-/issues/24)
- Supports matching groups. [#25](https://gitlab.com/arctic-fox/spectator/-/issues/25) [#24](https://github.com/icy-arctic-fox/spectator/issues/24)
- Add `filter_run_including`, `filter_run_excluding`, and `filter_run_when_matching` to config block.
- By default, only run tests when any are marked with `focus: true`.
- Add "f-prefix" blocks for examples and groups (`fit`, `fdescribe`, etc.) as a short-hand for specifying `focus: true`.
- Add HTML formatter. Operates the same as the JUnit formatter. Specify `--html_output=DIR` to use. [#22](https://gitlab.com/arctic-fox/spectator/-/issues/22) [#3](https://github.com/icy-arctic-fox/spectator/issues/3)
### Changed
- `given` (now `provided`) blocks changed to produce a single example. `it` can no longer be nested in a `provided` block.
- The "should" syntax no longer reports the source as inside Spectator.
- Short-hand "should" syntax must be included by using `require "spectator/should"` - `it { should eq("foo") }`
- Better error messages and detection when DSL methods are used when they shouldn't (i.e. `describe` inside `it`).
- Prevent usage of reserved keywords in DSL (such as `initialize`).
- The count argument for `sample` and `random_sample` groups must be named (use `count: 5` instead of just `5`).
- Helper methods used as arguments for `sample` and `random_sample` must be class methods.
- Simplify and reduce instanced types and generics. Should speed up compilation times.
- Overhaul example creation and handling.
- Overhaul storage of test values.
- Overhaul reporting and formatting. Cleaner output for failures and pending tests.
- Cleanup and simplify DSL implementation.
- Other minor internal improvements and cleanup.
### Deprecated
- `pending` blocks will behave differently in v0.11.0. They will mimic RSpec in that they _compile and run_ the block expecting it to fail. Use a `skip` (or `xit`) block instead to prevent compiling the example.
- `given` has been renamed to `provided`. The `given` keyword may be reused later for memoization.
### Removed
- Removed one-liner `it`-syntax without braces (block).
## [0.9.40] - 2021-07-10 ## [0.9.40] - 2021-07-10
### Fixed ### Fixed
- Fix stubbing of class methods. - Fix stubbing of class methods.

View file

@ -25,7 +25,7 @@ Add this to your application's `shard.yml`:
development_dependencies: development_dependencies:
spectator: spectator:
gitlab: arctic-fox/spectator gitlab: arctic-fox/spectator
version: ~> 0.9.40 version: ~> 0.10.0
``` ```
Usage Usage
@ -277,13 +277,14 @@ For details on mocks and doubles, see the [wiki](https://gitlab.com/arctic-fox/s
Spectator matches Crystal's default Spec output with some minor changes. Spectator matches Crystal's default Spec output with some minor changes.
JUnit and TAP are also supported output formats. JUnit and TAP are also supported output formats.
There is also a highly detailed JSON output. There are also highly detailed JSON and HTML outputs.
Development Development
----------- -----------
This shard is still under development and is not recommended for production use (same as Crystal). This shard is still in active development.
However, feel free to play around with it and use it for non-critical projects. New features are being added and existing functionality improved.
Spectator is well-tested, but may have some yet-to-be-found bugs.
### Feature Progress ### Feature Progress
@ -344,20 +345,20 @@ Items not marked as completed may have partial implementations.
- [ ] Message ordering - `expect().to receive().ordered` - [ ] Message ordering - `expect().to receive().ordered`
- [X] Null doubles - [X] Null doubles
- [X] Verifying doubles - [X] Verifying doubles
- [ ] Runner - [X] Runner
- [X] Fail fast - [X] Fail fast
- [ ] Test filtering - by name, context, and tags - [X] Test filtering - by name, context, and tags
- [X] Fail on no tests - [X] Fail on no tests
- [X] Randomize test order - [X] Randomize test order
- [X] Dry run - for validation and checking formatted output - [X] Dry run - for validation and checking formatted output
- [X] Config block in `spec_helper.cr` - [X] Config block in `spec_helper.cr`
- [X] Config file - `.spectator` - [X] Config file - `.spectator`
- [ ] Reporter and formatting - [X] Reporter and formatting
- [X] RSpec/Crystal Spec default - [X] RSpec/Crystal Spec default
- [X] JSON - [X] JSON
- [X] JUnit - [X] JUnit
- [X] TAP - [X] TAP
- [ ] HTML - [X] HTML
### How it Works (in a nutshell) ### How it Works (in a nutshell)
@ -383,9 +384,10 @@ The CI build checks for properly formatted code.
Documentation is automatically generated and published to GitLab pages. Documentation is automatically generated and published to GitLab pages.
It can be found here: https://arctic-fox.gitlab.io/spectator It can be found here: https://arctic-fox.gitlab.io/spectator
This project is developed on [GitLab](https://gitlab.com/arctic-fox/spectator), This project's home is (and primarily developed) on [GitLab](https://gitlab.com/arctic-fox/spectator).
and mirrored to [GitHub](https://github.com/icy-arctic-fox/spectator). A mirror is maintained to [GitHub](https://github.com/icy-arctic-fox/spectator).
Issues and PRs/MRs are accepted on both. Issues, pull requests (merge requests), and discussion are welcome on both.
Maintainers will ensure your contributions make it in.
### Testing ### Testing

View file

@ -1,5 +1,5 @@
name: spectator name: spectator
version: 0.9.40 version: 0.10.0
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.
@ -13,4 +13,4 @@ license: MIT
development_dependencies: development_dependencies:
ameba: ameba:
github: crystal-ameba/ameba github: crystal-ameba/ameba
version: ~> 0.13.1 version: ~> 0.14.3

View file

@ -0,0 +1,31 @@
require "./spec_helper"
Spectator.describe Spectator do
it "supports custom expectation messages" do
expect do
expect(false).to be_true, "paradox!"
end.to raise_error(Spectator::ExampleFailed, "paradox!")
end
it "supports custom expectation messages with a proc" do
count = 0
expect do
expect(false).to be_true, ->{ count += 1; "Failed #{count} times" }
end.to raise_error(Spectator::ExampleFailed, "Failed 1 times")
end
context "not_to" do
it "supports custom expectation messages" do
expect do
expect(true).not_to be_true, "paradox!"
end.to raise_error(Spectator::ExampleFailed, "paradox!")
end
it "supports custom expectation messages with a proc" do
count = 0
expect do
expect(true).not_to be_true, ->{ count += 1; "Failed #{count} times" }
end.to raise_error(Spectator::ExampleFailed, "Failed 1 times")
end
end
end

71
spec/helpers/example.cr Normal file
View file

@ -0,0 +1,71 @@
require "ecr"
require "json"
require "./result"
module Spectator::SpecHelpers
# Wrapper for compiling and running an example at runtime and getting a result.
class Example
# Creates the example.
# The *spec_helper_path* is the path to spec_helper.cr file.
# The name or ID of the example is given by *example_id*.
# Lastly, the source code for the example is given by *example_code*.
def initialize(@spec_helper_path : String, @example_id : String, @example_code : String)
end
# Instructs the Crystal compiler to compile the test.
# Returns an instance of `JSON::Any`.
# This will be the outcome and information about the test.
# Output will be surpressed for the test.
# If an error occurs while attempting to compile and run the test, an error will be raised.
def compile
# Create a temporary file containing the test.
with_tempfile do |source_file|
args = ["run", "--no-color", source_file, "--", "--json"]
Process.run(crystal_executable, args) do |process|
JSON.parse(process.output)
rescue JSON::ParseException
raise "Compilation of example #{@example_id} failed\n\n#{process.error.gets_to_end}"
end
end
end
# Same as `#compile`, but returns the result of the first example in the test.
# Returns a `SpectatorHelpers::Result` instance.
def result
output = compile
example = output["examples"][0]
Result.from_json_any(example)
end
# Constructs the string representation of the example.
# This produces the Crystal source code.
# *io* is the file handle to write to.
# The *dir* is the directory of the file being written to.
# This is needed to resolve the relative path to the spec_helper.cr file.
private def write(io, dir)
spec_helper_path = Path[@spec_helper_path].relative_to(dir) # ameba:disable Lint/UselessAssign
ECR.embed(__DIR__ + "/example.ecr", io)
end
# Creates a temporary file containing the compilable example code.
# Yields the path of the temporary file.
# Ensures the file is deleted after it is done being used.
private def with_tempfile
tempfile = File.tempfile("_#{@example_id}_spec.cr") do |file|
dir = File.dirname(file.path)
write(file, dir)
end
begin
yield tempfile.path
ensure
tempfile.delete
end
end
# Attempts to find the Crystal compiler on the system or raises an error.
private def crystal_executable
Process.find_executable("crystal") || raise("Could not find Crystal compiler")
end
end
end

5
spec/helpers/example.ecr Normal file
View file

@ -0,0 +1,5 @@
require "<%= spec_helper_path %>"
Spectator.describe "<%= @example_id %>" do
<%= @example_code %>
end

View file

@ -0,0 +1,28 @@
module Spectator::SpecHelpers
# Information about an `expect` call in an example.
struct Expectation
# Indicates whether the expectation passed or failed.
getter? satisfied : Bool
# Message when the expectation failed.
# Only available when `#satisfied?` is false.
getter! message : String
# Additional information about the expectation.
# Only available when `#satisfied?` is false.
getter! values : Hash(String, String)
# Creates the expectation outcome.
def initialize(@satisfied, @message, @values)
end
# Extracts the expectation information from a `JSON::Any` object.
def self.from_json_any(object : JSON::Any)
satisfied = object["satisfied"].as_bool
message = object["failure"]?.try(&.as_s?)
values = object["values"]?.try(&.as_h?)
values = values.transform_values(&.as_s) if values
new(satisfied, message, values)
end
end
end

67
spec/helpers/result.cr Normal file
View file

@ -0,0 +1,67 @@
module Spectator::SpecHelpers
# Information about an example compiled and run at runtime.
struct Result
# Status of the example after running.
enum Outcome
Success
Failure
Error
Unknown
end
# Full name and description of the example.
getter name : String
# Status of the example after running.
getter outcome : Outcome
# List of expectations ran in the example.
getter expectations : Array(Expectation)
# Creates the result.
def initialize(@name, @outcome, @expectations)
end
# Checks if the example was successful.
def success?
outcome.success?
end
# :ditto:
def successful?
outcome.success?
end
# Checks if the example failed, but did not error.
def failure?
outcome.failure?
end
# Checks if the example encountered an error.
def error?
outcome.error?
end
# Extracts the result information from a `JSON::Any` object.
def self.from_json_any(object : JSON::Any)
name = object["description"].as_s
outcome = parse_outcome_string(object["status"].as_s)
expectations = if (list = object["expectations"].as_a?)
list.map { |e| Expectation.from_json_any(e) }
else
[] of Expectation
end
new(name, outcome, expectations)
end
# Converts a result string, such as "fail" to an enum value.
private def self.parse_outcome_string(string)
case string
when /pass/i then Outcome::Success
when /fail/i then Outcome::Failure
when /error/i then Outcome::Error
else Outcome::Unknown
end
end
end
end

View file

@ -1,29 +1,29 @@
require "./spec_helper" require "./spec_helper"
Spectator.describe Spectator do Spectator.describe Spectator do
let(current_example) { ::Spectator::Harness.current.example } let(current_example) { ::Spectator::Example.current }
subject(source) { current_example.source } subject(location) { current_example.location }
context "line numbers" do context "line numbers" do
it "contains starting line of spec" do it "contains starting line of spec" do
expect(source.line).to eq(__LINE__ - 1) expect(location.line).to eq(__LINE__ - 1)
end end
it "contains ending line of spec" do it "contains ending line of spec" do
expect(source.end_line).to eq(__LINE__ + 1) expect(location.end_line).to eq(__LINE__ + 1)
end end
it "handles multiple lines and examples" do it "handles multiple lines and examples" do
# Offset is important. # Offset is important.
expect(source.line).to eq(__LINE__ - 2) expect(location.line).to eq(__LINE__ - 2)
# This line fails, refer to https://github.com/crystal-lang/crystal/issues/10562 # This line fails, refer to https://github.com/crystal-lang/crystal/issues/10562
# expect(source.end_line).to eq(__LINE__ + 2) # expect(location.end_line).to eq(__LINE__ + 2)
# Offset is still important. # Offset is still important.
end end
end end
context "file names" do context "file names" do
subject { source.file } subject { location.file }
it "match source code" do it "match source code" do
is_expected.to eq(__FILE__) is_expected.to eq(__FILE__)

View file

@ -34,16 +34,16 @@ Spectator.describe "Explicit Subject" do
subject { @@element_list.pop } subject { @@element_list.pop }
# TODO: RSpec calls the "actual" block after the "change block". skip "is memoized across calls (i.e. the block is invoked once)",
xit "is memoized across calls (i.e. the block is invoked once)" do reason: "RSpec calls the \"actual\" block after the \"change block\"." do
expect do expect do
3.times { subject } 3.times { subject }
end.to change { @@element_list }.from([1, 2, 3]).to([1, 2]) end.to change { @@element_list }.from([1, 2, 3]).to([1, 2])
expect(subject).to eq(3) expect(subject).to eq(3)
end end
# TODO: RSpec calls the "actual" block after the "change block". skip "is not memoized across examples",
xit "is not memoized across examples" do reason: "RSpec calls the \"actual\" block after the \"change block\"." do
expect { subject }.to change { @@element_list }.from([1, 2]).to([1]) expect { subject }.to change { @@element_list }.from([1, 2]).to([1])
expect(subject).to eq(2) expect(subject).to eq(2)
end end

View file

@ -11,7 +11,7 @@ Spectator.describe "Let and let!" do
describe "let" do describe "let" do
let(:count) { @@count += 1 } let(:count) { @@count += 1 }
it "memoizes thte value" do it "memoizes the value" do
expect(count).to eq(1) expect(count).to eq(1)
expect(count).to eq(1) expect(count).to eq(1)
end end

View file

@ -21,17 +21,15 @@ Spectator.describe "`all` matcher" do
# Changed `include` to `contain` to match our own. # Changed `include` to `contain` to match our own.
# `include` is a keyword and can't be used as a method name in Crystal. # `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 describe ["anything", "everything", "something"] do
xit { is_expected.to all(be_a(String)) } # .and contain("thing") ) } skip reason: "Add support for compound matchers." { is_expected.to all(be_a(String).and contain("thing")) }
xit { is_expected.to all(be_a(String)) } # .and end_with("g") ) } skip reason: "Add support for compound matchers." { is_expected.to all(be_a(String).and end_with("g")) }
xit { is_expected.to all(start_with("s")) } # .or contain("y") ) } skip reason: "Add support for compound matchers." { is_expected.to all(start_with("s").or contain("y")) }
# deliberate failures # deliberate failures
# TODO: Add support for compound matchers. skip reason: "Add support for compound matchers." { is_expected.to all(contain("foo").and contain("bar")) }
xit { is_expected.to all(contain("foo")) } # .and contain("bar") ) } skip reason: "Add support for compound matchers." { is_expected.to all(be_a(String).and start_with("a")) }
xit { is_expected.to all(be_a(String)) } # .and start_with("a") ) } skip reason: "Add support for compound matchers." { is_expected.to all(start_with("a").or contain("z")) }
xit { is_expected.to all(start_with("a")) } # .or contain("z") ) }
end end
end end
end end

View file

@ -12,16 +12,14 @@ Spectator.describe "`contain` matcher" do
it { is_expected.to contain(1, 7) } it { is_expected.to contain(1, 7) }
it { is_expected.to contain(1, 3, 7) } it { is_expected.to contain(1, 3, 7) }
# Utility matcher method `a_kind_of` is not supported. skip reason: "Utility matcher method `a_kind_of` is not supported." { is_expected.to contain(a_kind_of(Int)) }
# it { is_expected.to contain(a_kind_of(Int)) }
# TODO: Compound matchers aren't supported. skip reason: "Compound matchers aren't supported." { is_expected.to contain(be_odd.and be < 10) }
# it { is_expected.to contain(be_odd.and be < 10) }
# TODO: Fix behavior and cleanup output. # TODO: Fix behavior and cleanup output.
# This syntax is allowed, but produces a wrong result and bad output. # This syntax is allowed, but produces a wrong result and bad output.
xit { is_expected.to contain(be_odd) } skip reason: "Fix behavior and cleanup output." { is_expected.to contain(be_odd) }
xit { is_expected.not_to contain(be_even) } skip reason: "Fix behavior and cleanup output." { is_expected.not_to contain(be_even) }
it { is_expected.not_to contain(17) } it { is_expected.not_to contain(17) }
it { is_expected.not_to contain(43, 100) } it { is_expected.not_to contain(43, 100) }
@ -62,35 +60,31 @@ Spectator.describe "`contain` matcher" do
subject { {:a => 7, :b => 5} } subject { {:a => 7, :b => 5} }
# Hash syntax is changed here from `:a => 7` to `a: 7`. # Hash syntax is changed here from `:a => 7` to `a: 7`.
# it { is_expected.to contain(:a) } skip reason: "This hash-like syntax isn't supported." { is_expected.to contain(a: 7) }
# it { is_expected.to contain(:b, :a) } skip reason: "This hash-like syntax isn't supported." { is_expected.to contain(b: 5, a: 7) }
skip reason: "This hash-like syntax isn't supported." { is_expected.not_to contain(:c) }
# TODO: This hash-like syntax isn't supported. skip reason: "This hash-like syntax isn't supported." { is_expected.not_to contain(:c, :d) }
# it { is_expected.to contain(a: 7) } skip reason: "This hash-like syntax isn't supported." { is_expected.not_to contain(d: 2) }
# it { is_expected.to contain(b: 5, a: 7) } skip reason: "This hash-like syntax isn't supported." { is_expected.not_to contain(a: 5) }
# it { is_expected.not_to contain(:c) } skip reason: "This hash-like syntax isn't supported." { is_expected.not_to contain(b: 7, a: 5) }
# 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 # deliberate failures
# it { is_expected.not_to contain(:a) } skip reason: "This hash-like syntax isn't supported." { is_expected.not_to contain(:a) }
# it { is_expected.not_to contain(:b, :a) } skip reason: "This hash-like syntax isn't supported." { is_expected.not_to contain(:b, :a) }
# it { is_expected.not_to contain(a: 7) } skip reason: "This hash-like syntax isn't supported." { is_expected.not_to contain(a: 7) }
# it { is_expected.not_to contain(a: 7, b: 5) } skip reason: "This hash-like syntax isn't supported." { is_expected.not_to contain(a: 7, b: 5) }
# it { is_expected.to contain(:c) } skip reason: "This hash-like syntax isn't supported." { is_expected.to contain(:c) }
# it { is_expected.to contain(:c, :d) } skip reason: "This hash-like syntax isn't supported." { is_expected.to contain(:c, :d) }
# it { is_expected.to contain(d: 2) } skip reason: "This hash-like syntax isn't supported." { is_expected.to contain(d: 2) }
# it { is_expected.to contain(a: 5) } skip reason: "This hash-like syntax isn't supported." { is_expected.to contain(a: 5) }
# it { is_expected.to contain(a: 5, b: 7) } skip reason: "This hash-like syntax isn't supported." { is_expected.to contain(a: 5, b: 7) }
# Mixed cases--the hash contains one but not the other. # Mixed cases--the hash contains one but not the other.
# All 4 of these cases should fail. # All 4 of these cases should fail.
# it { is_expected.to contain(:a, :d) } skip reason: "This hash-like syntax isn't supported." { is_expected.to contain(:a, :d) }
# it { is_expected.not_to contain(:a, :d) } skip reason: "This hash-like syntax isn't supported." { is_expected.not_to contain(:a, :d) }
# it { is_expected.to contain(a: 7, d: 3) } skip reason: "This hash-like syntax isn't supported." { is_expected.to contain(a: 7, d: 3) }
# it { is_expected.not_to contain(a: 7, d: 3) } skip reason: "This hash-like syntax isn't supported." { is_expected.not_to contain(a: 7, d: 3) }
end end
end end
end end

View file

@ -18,10 +18,9 @@ Spectator.describe "`end_with` matcher" do
context "array usage" do context "array usage" do
describe [0, 1, 2, 3, 4] do describe [0, 1, 2, 3, 4] do
it { is_expected.to end_with 4 } it { is_expected.to end_with 4 }
# TODO: Add support for multiple items at the end of an array. skip reason: "Add support for multiple items at the end of an array." { is_expected.to end_with 3, 4 }
# it { is_expected.to end_with 3, 4 }
it { is_expected.not_to end_with 3 } it { is_expected.not_to end_with 3 }
# it { is_expected.not_to end_with 0, 1, 2, 3, 4, 5 } skip reason: "Add support for multiple items at the end of an array." { is_expected.not_to end_with 0, 1, 2, 3, 4, 5 }
# deliberate failures # deliberate failures
it_fails { is_expected.not_to end_with 4 } it_fails { is_expected.not_to end_with 4 }

View file

@ -14,14 +14,14 @@ Spectator.describe "`have_attributes` matcher" do
# Spectator doesn't support helper matchers like `a_string_starting_with` and `a_value <`. # Spectator doesn't support helper matchers like `a_string_starting_with` and `a_value <`.
# But maybe in the future it will. # But maybe in the future it will.
it { is_expected.to have_attributes(name: "Jim") } it { is_expected.to have_attributes(name: "Jim") }
# it { is_expected.to have_attributes(name: a_string_starting_with("J") ) } skip reason: "Add support for fuzzy matchers." { 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: 32) }
# it { is_expected.to have_attributes(age: (a_value > 30) ) } skip reason: "Add support for fuzzy matchers." { 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: "Jim", age: 32) }
# it { is_expected.to have_attributes(name: a_string_starting_with("J"), age: (a_value > 30) ) } skip reason: "Add support for fuzzy matchers." { 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(name: "Bob") }
it { is_expected.not_to have_attributes(age: 10) } it { is_expected.not_to have_attributes(age: 10) }
# it { is_expected.not_to have_attributes(age: (a_value < 30) ) } skip reason: "Add support for fuzzy matchers." { is_expected.not_to have_attributes(age: (a_value < 30)) }
# deliberate failures # deliberate failures
it_fails { is_expected.to have_attributes(name: "Bob") } it_fails { is_expected.to have_attributes(name: "Bob") }

View file

@ -75,14 +75,13 @@ Spectator.describe "`raise_error` matcher" do
end end
end end
# TODO: Support passing a block to `raise_error` matcher. context "set expectations on error object passed to block" do
# context "set expectations on error object passed to block" do skip "raises DivisionByZeroError", reason: "Support passing a block to `raise_error` matcher." do
# it "raises DivisionByZeroError" do expect { 42 // 0 }.to raise_error do |error|
# expect { 42 // 0 }.to raise_error do |error| expect(error).to be_a(DivisionByZeroError)
# expect(error).to be_a(DivisionByZeroError) end
# end end
# end end
# end
context "expect no error at all" do context "expect no error at all" do
describe "#to_s" do describe "#to_s" do

View file

@ -18,10 +18,9 @@ Spectator.describe "`start_with` matcher" do
context "with an array" do context "with an array" do
describe [0, 1, 2, 3, 4] do describe [0, 1, 2, 3, 4] do
it { is_expected.to start_with 0 } it { is_expected.to start_with 0 }
# TODO: Add support for multiple items at the beginning of an array. skip reason: "Add support for multiple items at the beginning of an array." { is_expected.to start_with(0, 1) }
# it { is_expected.to start_with(0, 1) }
it { is_expected.not_to start_with(2) } it { is_expected.not_to start_with(2) }
# it { is_expected.not_to start_with(0, 1, 2, 3, 4, 5) } skip reason: "Add support for multiple items at the beginning of an array." { is_expected.not_to start_with(0, 1, 2, 3, 4, 5) }
# deliberate failures # deliberate failures
it_fails { is_expected.not_to start_with 0 } it_fails { is_expected.not_to start_with 0 }

View file

@ -0,0 +1,58 @@
require "./spec_helper"
# This is a meta test that ensures specs can be compiled and run at runtime.
# The purpose of this is to report an error if this process fails.
# Other tests will fail, but display a different name/description of the test.
# This clearly indicates that runtime testing failed.
#
# Runtime compilation is used to get output of tests as well as check syntax.
# Some specs are too complex to be ran normally.
# Additionally, this allows examples to easily check specific failure cases.
# Plus, it makes testing user-reported issues easy.
Spectator.describe "Runtime compilation", :slow, :compile do
given_example passing_example do
it "does something" do
expect(true).to be_true
end
end
it "can compile and retrieve the result of an example" do
expect(passing_example).to be_successful
end
it "can retrieve expectations" do
expect(passing_example.expectations).to_not be_empty
end
given_example failing_example do
it "does something" do
expect(true).to be_false
end
it "doesn't run" do
expect(true).to be_false
end
end
it "detects failed examples" do
expect(failing_example).to be_failure
end
given_example malformed_example do
it "does something" do
asdf
end
end
it "raises on compilation errors" do
expect { malformed_example }.to raise_error(/compilation/i)
end
given_expectation satisfied_expectation do
expect(true).to be_true
end
it "can compile and retrieve expectations" do
expect(satisfied_expectation).to be_satisfied
end
end

View file

@ -1,4 +1,6 @@
require "../src/spectator" require "../src/spectator"
require "../src/spectator/should"
require "./helpers/**"
macro it_fails(description = nil, &block) macro it_fails(description = nil, &block)
it {{description}} do it {{description}} do
@ -11,3 +13,35 @@ end
macro specify_fails(description = nil, &block) macro specify_fails(description = nil, &block)
it_fails {{description}} {{block}} it_fails {{description}} {{block}}
end end
# Defines an example ("it" block) that is lazily compiled.
# When the example is referenced with *id*, it will be compiled and the results retrieved.
# The value returned by *id* will be a `Spectator::SpecHelpers::Result`.
# This allows the test result to be inspected.
macro given_example(id, &block)
let({{id}}) do
::Spectator::SpecHelpers::Example.new(
{{__FILE__}},
{{id.id.stringify}},
{{block.body.stringify}}
).result
end
end
# Defines an example ("it" block) that is lazily compiled.
# The "it" block must be omitted, as the block provided to this macro will be wrapped in one.
# When the expectation is referenced with *id*, it will be compiled and the result retrieved.
# The value returned by *id* will be a `Spectator::SpecHelpers::Expectation`.
# This allows an expectation to be inspected.
# Only the last expectation performed will be returned.
# An error is raised if no expectations ran.
macro given_expectation(id, &block)
let({{id}}) do
result = ::Spectator::SpecHelpers::Example.new(
{{__FILE__}},
{{id.id.stringify}},
{{"it do\n" + block.body.stringify + "\nend"}}
).result
result.expectations.last || raise("No expectations found from {{id.id}}")
end
end

View file

@ -0,0 +1,41 @@
require "../spec_helper"
Spectator.describe Spectator do
describe "aggregate_failures" do
it "captures multiple failed expectations" do
expect do
aggregate_failures do
expect(true).to be_false
expect(false).to be_true
end
end.to raise_error(Spectator::MultipleExpectationsFailed, /2 failures/)
end
it "raises normally for one failed expectation" do
expect do
aggregate_failures do
expect(true).to be_false
expect(true).to be_true
end
end.to raise_error(Spectator::ExpectationFailed)
end
it "doesn't raise when there are no failed expectations" do
expect do
aggregate_failures do
expect(false).to be_false
expect(true).to be_true
end
end.to_not raise_error(Spectator::ExpectationFailed)
end
it "supports naming the block" do
expect do
aggregate_failures "contradiction" do
expect(true).to be_false
expect(false).to be_true
end
end.to raise_error(Spectator::MultipleExpectationsFailed, /contradiction/)
end
end
end

View file

@ -0,0 +1,24 @@
require "../spec_helper"
Spectator.describe Spectator::Anything do
it "matches everything" do
expect(true).to match(subject)
expect(false).to match(subject)
expect(nil).to match(subject)
expect(42).to match(subject)
expect(42.as(Int32 | String)).to match(subject)
expect(["foo", "bar"]).to match(subject)
end
describe "#to_s" do
subject { super.to_s }
it { is_expected.to contain("anything") }
end
describe "#inspect" do
subject { super.inspect }
it { is_expected.to contain("anything") }
end
end

View file

@ -0,0 +1,34 @@
require "../spec_helper"
Spectator.describe Spectator::Block do
describe "#value" do
it "calls the block" do
called = false
block = described_class.new { called = true }
expect { block.value }.to change { called }.to(true)
end
it "can be called multiple times (doesn't cache the value)" do
count = 0
block = described_class.new { count += 1 }
block.value # Call once, count should be 1.
expect { block.value }.to change { count }.from(1).to(2)
end
end
describe "#to_s" do
let(block) do
described_class.new("Test Label") { 42 }
end
subject { block.to_s }
it "contains the label" do
is_expected.to contain("Test Label")
end
it "contains the value" do
is_expected.to contain("42")
end
end
end

View file

@ -0,0 +1,55 @@
require "../spec_helper"
Spectator.describe Spectator do
context "consice syntax" do
describe "provided group with a single assignment" do
provided x = 42 do
expect(x).to eq(42)
end
end
describe "provided group with multiple assignments" do
provided x = 42, y = 123 do
expect(x).to eq(42)
expect(y).to eq(123)
end
end
describe "provided group with a single named argument" do
provided x: 42 do
expect(x).to eq(42)
end
end
describe "provided group with multiple named arguments" do
provided x: 42, y: 123 do
expect(x).to eq(42)
expect(y).to eq(123)
end
end
describe "provided group with mix of assignments and named arguments" do
provided x = 42, y: 123 do
expect(x).to eq(42)
expect(y).to eq(123)
end
provided x = 42, y = 123, z: 0, foo: "bar" do
expect(x).to eq(42)
expect(y).to eq(123)
expect(z).to eq(0)
expect(foo).to eq("bar")
end
end
describe "provided group with references to other arguments" do
let(foo) { "bar" }
provided x = 3, y: x * 5, baz: foo.sub('r', 'z') do
expect(x).to eq(3)
expect(y).to eq(15)
expect(baz).to eq("baz")
end
end
end
end

View file

@ -0,0 +1,15 @@
require "../spec_helper"
Spectator.describe Spectator::Lazy do
it "returns the value of the block" do
lazy = Spectator::Lazy(Int32).new
expect { lazy.get { 42 } }.to eq(42)
end
it "caches the value" do
lazy = Spectator::Lazy(Int32).new
count = 0
expect { lazy.get { count += 1 } }.to change { count }.from(0).to(1)
expect { lazy.get { count += 1 } }.to_not change { count }
end
end

View file

@ -0,0 +1,28 @@
require "../spec_helper"
Spectator.describe Spectator::LazyWrapper do
it "returns the value of the block" do
expect { subject.get { 42 } }.to eq(42)
end
it "caches the value" do
wrapper = described_class.new
count = 0
expect { wrapper.get { count += 1 } }.to change { count }.from(0).to(1)
expect { wrapper.get { count += 1 } }.to_not change { count }
end
# This type of nesting is used when `super` is called in a subject block.
# ```
# subject { super.to_s }
# ```
it "works with nested wrappers" do
outer = described_class.new
inner = described_class.new
value = outer.get do
inner.get { 42 }.to_s
end
expect(value).to eq("42")
expect(value).to be_a(String)
end
end

View file

@ -0,0 +1,36 @@
require "../spec_helper"
Spectator.describe Spectator::Value do
subject { described_class.new(42, "Test Label") }
it "stores the value" do
# NOTE: This cast is a workaround for [issue #55](https://gitlab.com/arctic-fox/spectator/-/issues/55)
value = subject.as(Spectator::Value(Int32)).value
expect(value).to eq(42)
end
describe "#to_s" do
subject { super.to_s }
it "contains the label" do
is_expected.to contain("Test Label")
end
it "contains the value" do
is_expected.to contain("42")
end
end
describe "#inspect" do
let(value) { described_class.new([42], "Test Label") }
subject { value.inspect }
it "contains the label" do
is_expected.to contain("Test Label")
end
it "contains the value" do
is_expected.to contain("[42]")
end
end
end

View file

@ -0,0 +1,18 @@
require "../spec_helper"
Spectator.describe Spectator::Wrapper do
it "stores a value" do
wrapper = described_class.new(42)
expect(wrapper.get(Int32)).to eq(42)
end
it "retrieves a value using the block trick" do
wrapper = described_class.new(Int32)
expect(wrapper.get { Int32 }).to eq(Int32)
end
it "raises on invalid cast" do
wrapper = described_class.new(42)
expect { wrapper.get(String) }.to raise_error(TypeCastError)
end
end

View file

@ -1,54 +1,18 @@
require "colorize"
require "log"
require "./spectator/includes" require "./spectator/includes"
require "./spectator_test"
# Module that contains all functionality related to Spectator. # Module that contains all functionality related to Spectator.
module Spectator module Spectator
extend self extend self
include DSL::Top
# Current version of the Spectator library. # Current version of the Spectator library.
VERSION = "0.9.40" VERSION = {{ `shards version #{__DIR__}`.stringify.chomp }}
# Top-level describe method. # Logger for Spectator internals.
# All specs in a file must be wrapped in this call. ::Log.setup_from_env
# This takes an argument and a block. Log = ::Log.for(self)
# The argument is what your spec is describing.
# It can be any Crystal expression,
# but is typically a class name or feature string.
# The block should contain all of the specs for what is being described.
# Example:
# ```
# Spectator.describe Foo do
# # Your specs for `Foo` go here.
# end
# ```
# NOTE: Inside the block, the `Spectator` prefix is no longer needed.
# Actually, prefixing methods and macros with `Spectator`
# most likely won't work and can cause compiler errors.
macro describe(description, &block)
# This macro creates the foundation for all specs.
# Every group of examples is defined a separate module - `SpectatorExamples`.
# There's multiple reasons for this.
#
# The first reason is to provide namespace isolation.
# We don't want the spec code to accidentally pickup types and values from the `Spectator` module.
# Another reason is that we need a root module to put all examples and groups in.
# And lastly, the spec DSL needs to be given to the block of code somehow.
# The DSL is included in the `SpectatorTest` class.
#
# For more information on how the DSL works, see the `DSL` module.
# Root-level class that contains all examples and example groups.
class SpectatorTest
# Pass off the description argument and block to `DSL::StructureDSL.describe`.
# That method will handle creating a new group for this spec.
describe({{description}}) {{block}}
end
end
# :ditto:
macro context(description, &block)
describe({{description}}) {{block}}
end
# Flag indicating whether Spectator should automatically run tests. # Flag indicating whether Spectator should automatically run tests.
# This should be left alone (set to true) in typical usage. # This should be left alone (set to true) in typical usage.
@ -79,14 +43,14 @@ module Spectator
exit(1) if autorun? && !run exit(1) if autorun? && !run
end end
@@config_builder = ConfigBuilder.new @@config_builder = Config::Builder.new
@@config : Config? @@config : Config?
# Provides a means to configure how Spectator will run and report tests. # Provides a means to configure how Spectator will run and report tests.
# A `ConfigBuilder` is yielded to allow changing the configuration. # A `ConfigBuilder` is yielded to allow changing the configuration.
# NOTE: The configuration set here can be overriden # NOTE: The configuration set here can be overriden
# with a `.spectator` file and command-line arguments. # with a `.spectator` file and command-line arguments.
def configure : Nil def configure(& : Config::Builder -> _) : Nil
yield @@config_builder yield @@config_builder
end end
@ -98,38 +62,29 @@ module Spectator
config.random config.random
end end
# Trick for detecting if a constant is defined.
# Includes the block of code if the *constant* is defined.
private macro on_defined(constant)
{% if constant.resolve? %}
{{yield}}
{% end %}
end
# Builds the tests and runs the framework. # Builds the tests and runs the framework.
private def run private def run
# Silence default logger, only if it's used somewhere in the program. # Silence default logger.
on_defined(::Log) do ::Log.setup_from_env(default_level: :none)
::Log.setup_from_env(default_level: :none)
end
# Build the test suite and run it. # Build the spec and run it.
suite = ::Spectator::SpecBuilder.build(config.example_filter) spec = DSL::Builder.build
Runner.new(suite, config).run spec.run
rescue ex rescue ex
# Re-enable logger for fatal error.
::Log.setup_from_env
# Catch all unhandled exceptions here. # Catch all unhandled exceptions here.
# Examples are already wrapped, so any exceptions they throw are caught. # Examples are already wrapped, so any exceptions they throw are caught.
# But if an exception occurs outside an example, # But if an exception occurs outside an example,
# it's likely the fault of the test framework (Spectator). # it's likely the fault of the test framework (Spectator).
# So we display a helpful error that could be reported and return non-zero. # So we display a helpful error that could be reported and return non-zero.
display_error_stack(ex) Log.fatal(exception: ex) { "Spectator encountered an unexpected error" }
false false
end end
# Processes and builds up a configuration to use for running tests. # Global configuration used by Spectator for running tests.
private def config class_getter(config) { build_config }
@@config ||= build_config
end
# Builds the configuration. # Builds the configuration.
private def build_config private def build_config
@ -155,33 +110,11 @@ module Spectator
private def apply_config_file(file_path = CONFIG_FILE_PATH) : Nil private def apply_config_file(file_path = CONFIG_FILE_PATH) : Nil
return unless File.exists?(file_path) return unless File.exists?(file_path)
args = File.read(file_path).lines args = File.read(file_path).lines
CommandLineArgumentsConfigSource.new(args).apply_to(@@config_builder) Config::CLIArgumentsApplicator.new(args).apply(@@config_builder)
end end
# Applies configuration options from the command-line arguments # Applies configuration options from the command-line arguments
private def apply_command_line_args : Nil private def apply_command_line_args : Nil
CommandLineArgumentsConfigSource.new.apply_to(@@config_builder) Config::CLIArgumentsApplicator.new.apply(@@config_builder)
end
# Displays a complete error stack.
# Prints an error and everything that caused it.
# Stacktrace is included.
private def display_error_stack(error) : Nil
puts
puts "Encountered an unexpected error in framework"
# Loop while there's a cause for the error.
# Print each error in the stack.
loop do
display_error(error)
error = error.cause
break unless error
end
end
# Display a single error and its stacktrace.
private def display_error(error) : Nil
puts
puts "Caused by: #{error.message}"
puts error.backtrace.join("\n")
end end
end end

View file

@ -0,0 +1,53 @@
require "./label"
module Spectator
# Represents an expression from a test.
# This is typically captured by an `expect` macro.
# It consists of a label and the value of the expression.
# The label should be a string recognizable by the user,
# or nil if one isn't available.
#
# This base class is provided so that all generic sub-classes can be stored as this one type.
# The value of the expression can be retrieved by downcasting to the expected type with `#cast`.
#
# NOTE: This is intentionally a class and not a struct.
# If it were a struct, changes made to the value held by an instance may not be kept when passing it around.
# See commit ca564619ad2ae45f832a058d514298c868fdf699.
abstract class AbstractExpression
# User recognizable string for the expression.
# This can be something like a variable name or a snippet of Crystal code.
getter label : Label
# Creates the expression.
# The *label* is usually the Crystal code evaluating to the `#raw_value`.
# It can be nil if it isn't available.
def initialize(@label : Label)
end
# Retrieves the evaluated value of the expression.
abstract def raw_value
# Attempts to cast `#raw_value` to the type *T* and return it.
def cast(type : T.class) : T forall T
raw_value.as(T)
end
# Produces a string representation of the expression.
# This consists of the label (if one is available) and the value.
def to_s(io)
if (label = @label)
io << label << ": "
end
raw_value.to_s(io)
end
# Produces a detailed string representation of the expression.
# This consists of the label (if one is available) and the value.
def inspect(io)
if (label = @label)
io << label << ": "
end
raw_value.inspect(io)
end
end
end

View file

@ -1,15 +1,25 @@
module Spectator module Spectator
# Type dedicated to matching everything.
# This is intended to be used as a value to compare against when the value doesn't matter.
# Can be used like so:
# ```
# anything = Spectator::Anything.new
# expect("foo").to match(anything)
# ```
struct Anything struct Anything
def ==(other) # Always returns true.
true
end
def ===(other) def ===(other)
true true
end end
def =~(other) # Displays "anything".
true def to_s(io)
io << "anything"
end
# Displays "<anything>".
def inspect(io)
io << "<anything>"
end end
end end
end end

34
src/spectator/block.cr Normal file
View file

@ -0,0 +1,34 @@
require "./expression"
require "./label"
module Spectator
# Represents a block from a test.
# This is typically captured by an `expect` macro.
# It consists of a label and parameterless block.
# The label should be a string recognizable by the user,
# or nil if one isn't available.
class Block(T) < Expression(T)
# Creates the block expression from a proc.
# The *proc* will be called to evaluate the value of the expression.
# The *label* is usually the Crystal code for the *proc*.
# It can be nil if it isn't available.
def initialize(@block : -> T, label : Label = nil)
super(label)
end
# Creates the block expression by capturing a block as a proc.
# The block will be called to evaluate the value of the expression.
# The *label* is usually the Crystal code for the *block*.
# It can be nil if it isn't available.
def initialize(label : Label = nil, &@block : -> T)
super(label)
end
# Evaluates the block and returns the value from it.
# This method _does not_ cache the resulting value like `#value` does.
# Successive calls to this method may return different values.
def value : T
@block.call
end
end
end

View file

@ -1,178 +0,0 @@
require "option_parser"
module Spectator
# Generates configuration from the command-line arguments.
class CommandLineArgumentsConfigSource < ConfigSource
# Creates the configuration source.
# By default, the command-line arguments (ARGV) are used.
# But custom arguments can be passed in.
def initialize(@args : Array(String) = ARGV)
end
# Applies the specified configuration to a builder.
# Calling this method from multiple sources builds up the final configuration.
def apply_to(builder : ConfigBuilder) : Nil
OptionParser.parse(@args) do |parser|
control_parser_options(parser, builder)
filter_parser_options(parser, builder)
output_parser_options(parser, builder)
end
end
# Adds options to the parser for controlling the test execution.
private def control_parser_options(parser, builder)
fail_fast_option(parser, builder)
fail_blank_option(parser, builder)
dry_run_option(parser, builder)
random_option(parser, builder)
seed_option(parser, builder)
order_option(parser, builder)
end
# Adds the fail-fast option to the parser.
private def fail_fast_option(parser, builder)
parser.on("-f", "--fail-fast", "Stop testing on first failure") do
builder.fail_fast
end
end
# Adds the fail-blank option to the parser.
private def fail_blank_option(parser, builder)
parser.on("-b", "--fail-blank", "Fail if there are no examples") do
builder.fail_blank
end
end
# Adds the dry-run option to the parser.
private def dry_run_option(parser, builder)
parser.on("-d", "--dry-run", "Don't run any tests, output what would have run") do
builder.dry_run
end
end
# Adds the randomize examples option to the parser.
private def random_option(parser, builder)
parser.on("-r", "--rand", "Randomize the execution order of tests") do
builder.randomize
end
end
# Adds the random seed option to the parser.
private def seed_option(parser, builder)
parser.on("--seed INTEGER", "Set the seed for the random number generator (implies -r)") do |seed|
builder.randomize
builder.seed = seed.to_u64
end
end
# Adds the example order option to the parser.
private def order_option(parser, builder)
parser.on("--order ORDER", "Set the test execution order. ORDER should be one of: defined, rand, or rand:SEED") do |method|
case method.downcase
when "defined"
builder.randomize = false
when /^rand/
builder.randomize
parts = method.split(':', 2)
builder.seed = parts[1].to_u64 if parts.size > 1
else
nil
end
end
end
# Adds options to the parser for filtering examples.
private def filter_parser_options(parser, builder)
example_option(parser, builder)
line_option(parser, builder)
location_option(parser, builder)
end
# Adds the example filter option to the parser.
private def example_option(parser, builder)
parser.on("-e", "--example STRING", "Run examples whose full nested names include STRING") do |pattern|
filter = NameExampleFilter.new(pattern)
builder.add_example_filter(filter)
end
end
# Adds the line filter option to the parser.
private def line_option(parser, builder)
parser.on("-l", "--line LINE", "Run examples whose line matches LINE") do |line|
filter = LineExampleFilter.new(line.to_i)
builder.add_example_filter(filter)
end
end
# Adds the location filter option to the parser.
private def location_option(parser, builder)
parser.on("--location FILE:LINE", "Run the example at line 'LINE' in the file 'FILE', multiple allowed") do |location|
source = Source.parse(location)
filter = SourceExampleFilter.new(source)
builder.add_example_filter(filter)
end
end
# Adds options to the parser for changing output.
private def output_parser_options(parser, builder)
verbose_option(parser, builder)
help_option(parser, builder)
profile_option(parser, builder)
json_option(parser, builder)
tap_option(parser, builder)
junit_option(parser, builder)
no_color_option(parser, builder)
end
# Adds the verbose output option to the parser.
private def verbose_option(parser, builder)
parser.on("-v", "--verbose", "Verbose output using document formatter") do
builder.formatter = Formatting::DocumentFormatter.new
end
end
# Adds the help output option to the parser.
private def help_option(parser, builder)
parser.on("-h", "--help", "Show this help") do
puts parser
exit
end
end
# Adds the profile output option to the parser.
private def profile_option(parser, builder)
parser.on("-p", "--profile", "Display the 10 slowest specs") do
builder.profile
end
end
# Adds the JSON output option to the parser.
private def json_option(parser, builder)
parser.on("--json", "Generate JSON output") do
builder.formatter = Formatting::JsonFormatter.new
end
end
# Adds the TAP output option to the parser.
private def tap_option(parser, builder)
parser.on("--tap", "Generate TAP output (Test Anything Protocol)") do
builder.formatter = Formatting::TAPFormatter.new
end
end
# Adds the JUnit output option to the parser.
private def junit_option(parser, builder)
parser.on("--junit_output OUTPUT_DIR", "Generate JUnit XML output") do |output_dir|
formatter = Formatting::JUnitFormatter.new(output_dir)
builder.add_formatter(formatter)
end
end
# Adds the "no color" output option to the parser.
private def no_color_option(parser, builder)
parser.on("--no-color", "Disable colored output") do
Colorize.enabled = false
end
end
end
end

View file

@ -1,13 +0,0 @@
module Spectator
# Filter that combines multiple other filters.
class CompositeExampleFilter < ExampleFilter
# Creates the example filter.
def initialize(@filters : Array(ExampleFilter))
end
# Checks whether the example satisfies the filter.
def includes?(example) : Bool
@filters.any?(&.includes?(example))
end
end
end

View file

@ -0,0 +1,15 @@
require "./node_filter"
module Spectator
# Filter that combines multiple other filters.
class CompositeNodeFilter < NodeFilter
# Creates the example filter.
def initialize(@filters : Array(NodeFilter))
end
# Checks whether the node satisfies the filter.
def includes?(node) : Bool
@filters.any?(&.includes?(node))
end
end
end

View file

@ -1,51 +1,120 @@
require "./config/*"
require "./node_filter"
require "./example_group"
require "./filtered_example_iterator"
require "./formatting/formatter"
require "./node_iterator"
require "./run_flags"
module Spectator module Spectator
# Provides customization and describes specifics for how Spectator will run and report tests. # Provides customization and describes specifics for how Spectator will run and report tests.
class Config class Config
@formatters : Array(Formatting::Formatter) # Primary formatter all events will be sent to.
getter formatter : Formatting::Formatter
# Indicates whether the test should abort on first failure. # Flags indicating how the spec should run.
getter? fail_fast : Bool getter run_flags : RunFlags
# Indicates whether the test should fail if there are no examples. # Seed used for random number generation.
getter? fail_blank : Bool getter random_seed : UInt64
# Indicates whether the test should be done as a dry-run. # Filter used to select which examples to run.
# Examples won't run, but the output will show that they did. getter node_filter : NodeFilter
getter? dry_run : Bool
# Random number generator to use for everything. # Filter used to select which examples to _not_ run.
getter random : Random getter node_reject : NodeFilter
# Indicates whether tests are run in a random order. # Tags to filter on if they're present in a spec.
getter? randomize : Bool protected getter match_filters : Metadata
# Random seed used for number generation. # List of hooks to run before all examples in the test suite.
getter! random_seed : UInt64? protected getter before_suite_hooks : Deque(ExampleGroupHook)
# Indicates whether profiling information should be displayed. # List of hooks to run before each top-level example group.
getter? profile : Bool protected getter before_all_hooks : Deque(ExampleGroupHook)
# Filter that determines which examples to run. # List of hooks to run before every example.
getter example_filter : ExampleFilter protected getter before_each_hooks : Deque(ExampleHook)
# List of hooks to run after all examples in the test suite.
protected getter after_suite_hooks : Deque(ExampleGroupHook)
# List of hooks to run after each top-level example group.
protected getter after_all_hooks : Deque(ExampleGroupHook)
# List of hooks to run after every example.
protected getter after_each_hooks : Deque(ExampleHook)
# List of hooks to run around every example.
protected getter around_each_hooks : Deque(ExampleProcsyHook)
# Creates a new configuration. # Creates a new configuration.
def initialize(builder) # Properties are pulled from *source*.
@formatters = builder.formatters # Typically, *source* is a `Config::Builder`.
@fail_fast = builder.fail_fast? def initialize(source)
@fail_blank = builder.fail_blank? @formatter = source.formatter
@dry_run = builder.dry_run? @run_flags = source.run_flags
@random = builder.random @random_seed = source.random_seed
@randomize = builder.randomize? @node_filter = source.node_filter
@random_seed = builder.seed? @node_reject = source.node_reject
@profile = builder.profile? @match_filters = source.match_filters
@example_filter = builder.example_filter
@before_suite_hooks = source.before_suite_hooks
@before_all_hooks = source.before_all_hooks
@before_each_hooks = source.before_each_hooks
@after_suite_hooks = source.after_suite_hooks
@after_all_hooks = source.after_all_hooks
@after_each_hooks = source.after_each_hooks
@around_each_hooks = source.around_each_hooks
end end
# Yields each formatter that should be reported to. # Produces the default configuration.
def each_formatter def self.default : self
@formatters.each do |formatter| Builder.new.build
yield formatter end
# Shuffles the items in an array using the configured random settings.
# If `#randomize?` is true, the *items* are shuffled and returned as a new array.
# Otherwise, the items are left alone and returned as-is.
# The array of *items* is never modified.
def shuffle(items)
return items unless run_flags.randomize?
items.shuffle(random)
end
# Shuffles the items in an array using the configured random settings.
# If `#randomize?` is true, the *items* are shuffled and returned.
# Otherwise, the items are left alone and returned as-is.
# The array of *items* is modified, the items are shuffled in-place.
def shuffle!(items)
return items unless run_flags.randomize?
items.shuffle!(random)
end
# Creates an iterator configured to select the filtered examples.
def iterator(group : ExampleGroup)
match_filter = match_filter(group)
iterator = FilteredExampleIterator.new(group, @node_filter)
iterator = iterator.select(match_filter) if match_filter
iterator.reject(@node_reject)
end
# Creates a node filter if any conditionally matching filters apply to an example group.
private def match_filter(group : ExampleGroup) : NodeFilter?
iterator = NodeIterator.new(group)
filters = @match_filters.compact_map do |key, value|
filter = TagNodeFilter.new(key.to_s, value)
filter.as(NodeFilter) if iterator.rewind.any?(filter)
end end
CompositeNodeFilter.new(filters) unless filters.empty?
end
# Retrieves the configured random number generator.
# This will produce the same generator with the same seed every time.
def random
Random.new(random_seed)
end end
end end
end end

View file

@ -0,0 +1,317 @@
require "../composite_node_filter"
require "../node_filter"
require "../formatting"
require "../metadata"
require "../null_node_filter"
require "../run_flags"
require "../tag_node_filter"
module Spectator
class Config
# Mutable configuration used to produce a final configuration.
# Use the setters in this class to incrementally build a configuration.
# Then call `#build` to create the final configuration.
class Builder
# Seed used for random number generation.
property random_seed : UInt64 = Random.rand(100000_u64)
# Toggles indicating how the test spec should execute.
property run_flags = RunFlags::None
protected getter match_filters : Metadata = {:focus => nil.as(String?)}
@primary_formatter : Formatting::Formatter?
@additional_formatters = [] of Formatting::Formatter
@filters = [] of NodeFilter
@rejects = [] of NodeFilter
# List of hooks to run before all examples in the test suite.
protected getter before_suite_hooks = Deque(ExampleGroupHook).new
# List of hooks to run before each top-level example group.
protected getter before_all_hooks = Deque(ExampleGroupHook).new
# List of hooks to run before every example.
protected getter before_each_hooks = Deque(ExampleHook).new
# List of hooks to run after all examples in the test suite.
protected getter after_suite_hooks = Deque(ExampleGroupHook).new
# List of hooks to run after each top-level example group.
protected getter after_all_hooks = Deque(ExampleGroupHook).new
# List of hooks to run after every example.
protected getter after_each_hooks = Deque(ExampleHook).new
# List of hooks to run around every example.
protected getter around_each_hooks = Deque(ExampleProcsyHook).new
# Attaches a hook to be invoked before all examples in the test suite.
def add_before_suite_hook(hook)
@before_suite_hooks.push(hook)
end
# Defines a block of code to execute before all examples in the test suite.
def before_suite(&block)
hook = ExampleGroupHook.new(&block)
add_before_suite_hook(hook)
end
# Attaches a hook to be invoked before each top-level example group.
def add_before_all_hook(hook)
@before_all_hooks.push(hook)
end
# Defines a block of code to execute before each top-level example group.
def before_all(&block)
hook = ExampleGroupHook.new(&block)
add_before_all_hook(hook)
end
# Attaches a hook to be invoked before every example.
# The current example is provided as a block argument.
def add_before_each_hook(hook)
@before_each_hooks.push(hook)
end
# Defines a block of code to execute before every.
# The current example is provided as a block argument.
def before_each(&block : Example -> _)
hook = ExampleHook.new(&block)
add_before_each_hook(hook)
end
# Attaches a hook to be invoked after all examples in the test suite.
def add_after_suite_hook(hook)
@after_suite_hooks.unshift(hook)
end
# Defines a block of code to execute after all examples in the test suite.
def after_suite(&block)
hook = ExampleGroupHook.new(&block)
add_after_suite_hook(hook)
end
# Attaches a hook to be invoked after each top-level example group.
def add_after_all_hook(hook)
@after_all_hooks.unshift(hook)
end
# Defines a block of code to execute after each top-level example group.
def after_all(&block)
hook = ExampleGroupHook.new(&block)
add_after_all_hook(hook)
end
# Attaches a hook to be invoked after every example.
# The current example is provided as a block argument.
def add_after_each_hook(hook)
@after_each_hooks.unshift(hook)
end
# Defines a block of code to execute after every example.
# The current example is provided as a block argument.
def after_each(&block : Example -> _)
hook = ExampleHook.new(&block)
add_after_each_hook(hook)
end
# Attaches a hook to be invoked around every example.
# The current example in procsy form is provided as a block argument.
def add_around_each_hook(hook)
@around_each_hooks.push(hook)
end
# Defines a block of code to execute around every example.
# The current example in procsy form is provided as a block argument.
def around_each(&block : Example::Procsy -> _)
hook = ExampleProcsyHook.new(label: "around_each", &block)
add_around_each_hook(hook)
end
# Creates a configuration.
def build : Config
Config.new(self)
end
# Sets the primary formatter to use for reporting test progress and results.
def formatter=(formatter : Formatting::Formatter)
@primary_formatter = formatter
end
# Adds an extra formatter to use for reporting test progress and results.
def add_formatter(formatter : Formatting::Formatter)
@additional_formatters << formatter
end
# Retrieves the formatters to use.
# If one wasn't specified by the user,
# then `#default_formatter` is returned.
private def formatters
@additional_formatters + [(@primary_formatter || default_formatter)]
end
# The formatter that should be used if one wasn't provided.
private def default_formatter
Formatting::ProgressFormatter.new
end
# A single formatter that will satisfy the configured output.
# If one formatter was configured, then it is returned.
# Otherwise, a `Formatting::BroadcastFormatter` is returned.
protected def formatter
case (formatters = self.formatters)
when .one? then formatters.first
else Formatting::BroadcastFormatter.new(formatters)
end
end
# Enables fail-fast mode.
def fail_fast
@run_flags |= RunFlags::FailFast
end
# Sets the fail-fast flag.
def fail_fast=(flag)
if flag
@run_flags |= RunFlags::FailFast
else
@run_flags &= ~RunFlags::FailFast
end
end
# Indicates whether fail-fast mode is enabled.
protected def fail_fast?
@run_flags.fail_fast?
end
# Enables fail-blank mode (fail on no tests).
def fail_blank
@run_flags |= RunFlags::FailBlank
end
# Enables or disables fail-blank mode.
def fail_blank=(flag)
if flag
@run_flags |= RunFlags::FailBlank
else
@run_flags &= ~RunFlags::FailBlank
end
end
# Indicates whether fail-fast mode is enabled.
# That is, it is a failure if there are no tests.
protected def fail_blank?
@run_flags.fail_blank?
end
# Enables dry-run mode.
def dry_run
@run_flags |= RunFlags::DryRun
end
# Enables or disables dry-run mode.
def dry_run=(flag)
if flag
@run_flags |= RunFlags::DryRun
else
@run_flags &= ~RunFlags::DryRun
end
end
# Indicates whether dry-run mode is enabled.
# In this mode, no tests are run, but output acts like they were.
protected def dry_run?
@run_flags.dry_run?
end
# Randomizes test execution order.
def randomize
@run_flags |= RunFlags::Randomize
end
# Enables or disables running tests in a random order.
def randomize=(flag)
if flag
@run_flags |= RunFlags::Randomize
else
@run_flags &= ~RunFlags::Randomize
end
end
# Indicates whether tests are run in a random order.
protected def randomize?
@run_flags.randomize?
end
# Displays profiling information
def profile
@run_flags |= RunFlags::Profile
end
# Enables or disables displaying profiling information.
def profile=(flag)
if flag
@run_flags |= RunFlags::Profile
else
@run_flags &= ~RunFlags::Profile
end
end
# Indicates whether profiling information should be displayed.
protected def profile?
@run_flags.profile?
end
# Adds a filter to determine which examples can run.
def add_node_filter(filter : NodeFilter)
@filters << filter
end
# Specifies one or more tags to constrain running examples to.
def filter_run_including(*tags : Symbol, **values)
tags.each { |tag| @filters << TagNodeFilter.new(tag) }
values.each { |tag, value| @filters << TagNodeFilter.new(tag, value.to_s) }
end
# Adds a filter to prevent examples from running.
def add_node_reject(filter : NodeFilter)
@rejects << filter
end
# Specifies one or more tags to exclude from running examples.
def filter_run_excluding(*tags : Symbol, **values)
tags.each { |tag| @rejects << TagNodeFilter.new(tag) }
values.each { |tag, value| @rejects << TagNodeFilter.new(tag, value.to_s) }
end
# Specifies one or more tags to filter on only if they're present in the spec.
def filter_run_when_matching(*tags : Symbol, **values)
tags.each { |tag| @match_filters[tag] = nil }
values.each { |tag, value| @match_filters[tag] = value.to_s }
end
# Retrieves a filter that determines which examples can run.
# If no filters were added with `#add_node_filter`,
# then the returned filter will allow all examples to be run.
protected def node_filter
case (filters = @filters)
when .empty? then NullNodeFilter.new
when .one? then filters.first
else CompositeNodeFilter.new(filters)
end
end
# Retrieves a filter that prevents examples from running.
# If no filters were added with `#add_node_reject`,
# then the returned filter will allow all examples to be run.
protected def node_reject
case (filters = @rejects)
when .empty? then NullNodeFilter.new(false)
when .one? then filters.first
else CompositeNodeFilter.new(filters)
end
end
end
end
end

View file

@ -0,0 +1,240 @@
require "colorize"
require "option_parser"
require "../formatting"
require "../line_node_filter"
require "../location"
require "../location_node_filter"
require "../name_node_filter"
require "../tag_node_filter"
module Spectator
class Config
# Applies command-line arguments to a configuration.
class CLIArgumentsApplicator
# Logger for this class.
Log = Spectator::Log.for("config")
# Creates the configuration source.
# By default, the command-line arguments (ARGV) are used.
# But custom arguments can be passed in.
def initialize(@args : Array(String) = ARGV)
end
# Applies the specified configuration to a builder.
# Calling this method from multiple sources builds up the final configuration.
def apply(builder) : Nil
OptionParser.parse(@args) do |parser|
control_parser_options(parser, builder)
filter_parser_options(parser, builder)
output_parser_options(parser, builder)
end
end
# Adds options to the parser for controlling the test execution.
private def control_parser_options(parser, builder)
fail_fast_option(parser, builder)
fail_blank_option(parser, builder)
dry_run_option(parser, builder)
random_option(parser, builder)
seed_option(parser, builder)
order_option(parser, builder)
end
# Adds the fail-fast option to the parser.
private def fail_fast_option(parser, builder)
parser.on("-f", "--fail-fast", "Stop testing on first failure") do
Log.debug { "Enabling fail-fast (-f)" }
builder.fail_fast
end
end
# Adds the fail-blank option to the parser.
private def fail_blank_option(parser, builder)
parser.on("-b", "--fail-blank", "Fail if there are no examples") do
Log.debug { "Enabling fail-blank (-b)" }
builder.fail_blank
end
end
# Adds the dry-run option to the parser.
private def dry_run_option(parser, builder)
parser.on("-d", "--dry-run", "Don't run any tests, output what would have run") do
Log.debug { "Enabling dry-run (-d)" }
builder.dry_run
end
end
# Adds the randomize examples option to the parser.
private def random_option(parser, builder)
parser.on("-r", "--rand", "Randomize the execution order of tests") do
Log.debug { "Randomizing test order (-r)" }
builder.randomize
end
end
# Adds the random seed option to the parser.
private def seed_option(parser, builder)
parser.on("--seed INTEGER", "Set the seed for the random number generator (implies -r)") do |seed|
Log.debug { "Randomizing test order and setting RNG seed to #{seed}" }
builder.randomize
builder.random_seed = seed.to_u64
end
end
# Adds the example order option to the parser.
private def order_option(parser, builder)
parser.on("--order ORDER", "Set the test execution order. ORDER should be one of: defined, rand, or rand:SEED") do |method|
case method.downcase
when "defined"
Log.debug { "Disabling randomized tests (--order defined)" }
builder.randomize = false
when /^rand/
builder.randomize
parts = method.split(':', 2)
if (seed = parts[1]?)
Log.debug { "Randomizing test order and setting RNG seed to #{seed} (--order rand:#{seed})" }
builder.random_seed = seed.to_u64
else
Log.debug { "Randomizing test order (--order rand)" }
end
end
end
end
# Adds options to the parser for filtering examples.
private def filter_parser_options(parser, builder)
example_option(parser, builder)
line_option(parser, builder)
location_option(parser, builder)
tag_option(parser, builder)
end
# Adds the example filter option to the parser.
private def example_option(parser, builder)
parser.on("-e", "--example STRING", "Run examples whose full nested names include STRING") do |pattern|
Log.debug { "Filtering for examples named '#{pattern}' (-e '#{pattern}')" }
filter = NameNodeFilter.new(pattern)
builder.add_node_filter(filter)
end
end
# Adds the line filter option to the parser.
private def line_option(parser, builder)
parser.on("-l", "--line LINE", "Run examples whose line matches LINE") do |line|
Log.debug { "Filtering for examples on line #{line} (-l #{line})" }
filter = LineNodeFilter.new(line.to_i)
builder.add_node_filter(filter)
end
end
# Adds the location filter option to the parser.
private def location_option(parser, builder)
parser.on("--location FILE:LINE", "Run the example at line 'LINE' in the file 'FILE', multiple allowed") do |location|
Log.debug { "Filtering for examples at #{location} (--location '#{location}')" }
location = Location.parse(location)
filter = LocationNodeFilter.new(location)
builder.add_node_filter(filter)
end
end
# Adds the tag filter option to the parser.
private def tag_option(parser, builder)
parser.on("--tag TAG[:VALUE]", "Run examples with the specified TAG, or exclude examples by adding ~ before the TAG.") do |tag|
negated = tag.starts_with?('~')
tag = tag.lchop('~')
Log.debug { "Filtering for example with tag #{tag}" }
parts = tag.split(':', 2, remove_empty: true)
if parts.size > 1
tag = parts.first
value = parts.last
end
filter = TagNodeFilter.new(tag, value)
if negated
builder.add_node_reject(filter)
else
builder.add_node_filter(filter)
end
end
end
# Adds options to the parser for changing output.
private def output_parser_options(parser, builder)
verbose_option(parser, builder)
help_option(parser, builder)
profile_option(parser, builder)
json_option(parser, builder)
tap_option(parser, builder)
junit_option(parser, builder)
html_option(parser, builder)
no_color_option(parser, builder)
end
# Adds the verbose output option to the parser.
private def verbose_option(parser, builder)
parser.on("-v", "--verbose", "Verbose output using document formatter") do
Log.debug { "Setting output format to document (-v)" }
builder.formatter = Formatting::DocumentFormatter.new
end
end
# Adds the help output option to the parser.
private def help_option(parser, builder)
parser.on("-h", "--help", "Show this help") do
puts parser
exit
end
end
# Adds the profile output option to the parser.
private def profile_option(parser, builder)
parser.on("-p", "--profile", "Display the 10 slowest specs") do
Log.debug { "Enabling timing information (-p)" }
builder.profile
end
end
# Adds the JSON output option to the parser.
private def json_option(parser, builder)
parser.on("--json", "Generate JSON output") do
Log.debug { "Setting output format to JSON (--json)" }
builder.formatter = Formatting::JSONFormatter.new
end
end
# Adds the TAP output option to the parser.
private def tap_option(parser, builder)
parser.on("--tap", "Generate TAP output (Test Anything Protocol)") do
Log.debug { "Setting output format to TAP (--tap)" }
builder.formatter = Formatting::TAPFormatter.new
end
end
# Adds the JUnit output option to the parser.
private def junit_option(parser, builder)
parser.on("--junit_output OUTPUT_DIR", "Generate JUnit XML output") do |output_dir|
Log.debug { "Setting output format to JUnit XML (--junit_output '#{output_dir}')" }
formatter = Formatting::JUnitFormatter.new(output_dir)
builder.add_formatter(formatter)
end
end
# Adds the HTML output option to the parser.
private def html_option(parser, builder)
parser.on("--html_output OUTPUT_DIR", "Generate HTML output") do |output_dir|
Log.debug { "Setting output format to HTML (--html_output '#{output_dir}')" }
formatter = Formatting::HTMLFormatter.new(output_dir)
builder.add_formatter(formatter)
end
end
# Adds the "no color" output option to the parser.
private def no_color_option(parser, builder)
parser.on("--no-color", "Disable colored output") do
Log.debug { "Disabling color output (--no-color)" }
Colorize.enabled = false
end
end
end
end
end

View file

@ -1,158 +0,0 @@
module Spectator
# Mutable configuration used to produce a final configuration.
# Use the setters in this class to incrementally build a configuration.
# Then call `#build` to create the final configuration.
class ConfigBuilder
# Creates a default configuration.
def self.default
new.build
end
# Random number generator to use.
protected getter random = Random::DEFAULT
def initialize
@seed = seed = @random.rand(UInt16).to_u64
@random.new_seed(seed)
end
@primary_formatter : Formatting::Formatter?
@additional_formatters = [] of Formatting::Formatter
@fail_fast = false
@fail_blank = false
@dry_run = false
@randomize = false
@profile = false
@filters = [] of ExampleFilter
# Sets the primary formatter to use for reporting test progress and results.
def formatter=(formatter : Formatting::Formatter)
@primary_formatter = formatter
end
# Adds an extra formater to use for reporting test progress and results.
def add_formatter(formatter : Formatting::Formatter)
@additional_formatters << formatter
end
# Retrieves the formatters to use.
# If one wasn't specified by the user,
# then `#default_formatter` is returned.
protected def formatters
@additional_formatters + [(@primary_formatter || default_formatter)]
end
# The formatter that should be used,
# if one wasn't provided.
private def default_formatter
Formatting::DotsFormatter.new
end
# Enables fail-fast mode.
def fail_fast
self.fail_fast = true
end
# Sets the fail-fast flag.
def fail_fast=(flag)
@fail_fast = flag
end
# Indicates whether fail-fast mode is enabled.
protected def fail_fast?
@fail_fast
end
# Enables fail-blank mode (fail on no tests).
def fail_blank
self.fail_blank = true
end
# Enables or disables fail-blank mode.
def fail_blank=(flag)
@fail_blank = flag
end
# Indicates whether fail-fast mode is enabled.
# That is, it is a failure if there are no tests.
protected def fail_blank?
@fail_blank
end
# Enables dry-run mode.
def dry_run
self.dry_run = true
end
# Enables or disables dry-run mode.
def dry_run=(flag)
@dry_run = flag
end
# Indicates whether dry-run mode is enabled.
# In this mode, no tests are run, but output acts like they were.
protected def dry_run?
@dry_run
end
# Seed used for random number generation.
getter! seed : UInt64?
# Sets the seed for the random number generator.
def seed=(seed)
@seed = seed
@random = Random.new(seed)
end
# Randomizes test execution order.
def randomize
self.randomize = true
end
# Enables or disables running tests in a random order.
def randomize=(flag)
@randomize = flag
end
# Indicates whether tests are run in a random order.
protected def randomize?
@randomize
end
# Displays profiling information
def profile
self.profile = true
end
# Enables or disables displaying profiling information.
def profile=(flag)
@profile = flag
end
# Indicates whether profiling information should be displayed.
protected def profile?
@profile
end
# Adds a filter to determine which examples can run.
def add_example_filter(filter : ExampleFilter)
@filters << filter
end
# Retrieves a filter that determines which examples can run.
# If no filters were added with `#add_example_filter`,
# then the returned filter will allow all examples to be run.
protected def example_filter
if @filters.empty?
NullExampleFilter.new
else
CompositeExampleFilter.new(@filters)
end
end
# Creates a configuration.
def build : Config
Config.new(self)
end
end
end

View file

@ -1,8 +0,0 @@
module Spectator
# Interface for all places that configuration can originate.
abstract class ConfigSource
# Applies the specified configuration to a builder.
# Calling this method from multiple sources builds up the final configuration.
abstract def apply_to(builder : ConfigBuilder) : Nil
end
end

32
src/spectator/context.cr Normal file
View file

@ -0,0 +1,32 @@
# Base class that all test cases run in.
# This type is used to store all test case contexts as a single type.
# The instance must be downcast to the correct type before calling a context method.
# This type is intentionally outside the `Spectator` module.
# The reason for this is to prevent name collision when using the DSL to define a spec.
abstract class SpectatorContext
# Produces a dummy string to represent the context as a string.
# This prevents the default behavior, which normally stringifies instance variables.
# Due to the sheer amount of types Spectator can create
# and that the Crystal compiler instantiates a `#to_s` and/or `#inspect` for each of those types,
# an explosion in method instances can be created.
# The compile time is drastically reduced by using a dummy string instead.
def to_s(io)
io << "Context"
end
# :ditto:
def inspect(io)
io << "Context<" << self.class << '>'
end
end
module Spectator
# Base class that all test cases run in.
# This type is used to store all test case contexts as a single type.
# The instance must be downcast to the correct type before calling a context method.
#
# Nested contexts, such as those defined by `context` and `describe` in the DSL, can define their own methods.
# The intent is that a proc will downcast to the correct type and call one of those methods.
# This is how methods that contain test cases, hooks, and other context-specific code blocks get invoked.
alias Context = ::SpectatorContext
end

View file

@ -0,0 +1,27 @@
require "./context"
require "./context_method"
require "./null_context"
module Spectator
# Stores a test context and a method to call within it.
struct ContextDelegate
# Creates the delegate.
# The *context* is the instance of the test context.
# The *method* is proc that downcasts *context* and calls a method on it.
def initialize(@context : Context, @method : ContextMethod)
end
# Creates a delegate with a null context.
# The context will be ignored and the block will be executed in its original scope.
def self.null(&block : -> _)
context = NullContext.new
method = ContextMethod.new { block.call }
new(context, method)
end
# Invokes a method in the test context.
def call
@method.call(@context)
end
end
end

View file

@ -0,0 +1,10 @@
require "./context"
module Spectator
# Encapsulates a method in a test context.
# This could be used to invoke a test case or hook method.
# The context is passed as an argument.
# The proc should downcast the context instance to the desired type
# and call a method on that context.
alias ContextMethod = Context ->
end

View file

@ -2,6 +2,13 @@ require "./dsl/*"
module Spectator module Spectator
# Namespace containing methods representing the spec domain specific language. # Namespace containing methods representing the spec domain specific language.
#
# Note: Documentation inside macros is kept to a minimuum to reduce generated code.
# This also helps keep error traces small.
# Documentation only useful for debugging is included in generated code.
module DSL module DSL
# Keywords that cannot be used in specs using the DSL.
# These are either problematic or reserved for internal use.
RESERVED_KEYWORDS = %i[initialize]
end end
end end

View file

@ -1,216 +0,0 @@
require "../expectations/expectation_partial"
require "../source"
require "../test_block"
require "../test_value"
module Spectator
module DSL
# Starts an expectation.
# This should be followed up with `Spectator::Expectations::ExpectationPartial#to`
# or `Spectator::Expectations::ExpectationPartial#to_not`.
# The value passed in will be checked
# to see if it satisfies the conditions specified.
#
# This method should be used like so:
# ```
# expect(actual).to eq(expected)
# ```
# Where the actual value is returned by the system-under-test,
# and the expected value is what the actual value should be to satisfy the condition.
macro expect(actual, _source_file = __FILE__, _source_line = __LINE__)
%test_value = ::Spectator::TestValue.new({{actual}}, {{actual.stringify}})
%source = ::Spectator::Source.new({{_source_file}}, {{_source_line}})
::Spectator::Expectations::ExpectationPartial.new(%test_value, %source)
end
# Starts an expectation on a block of code.
# This should be followed up with `Spectator::Expectations::ExpectationPartial#to`
# or `Spectator::Expectations::ExpectationPartial#to_not`.
# The block passed in, or its return value, will be checked
# to see if it satisfies the conditions specified.
#
# This method should be used like so:
# ```
# expect { raise "foo" }.to raise_error
# ```
# The block of code is passed along for validation to the matchers.
#
# The short, one argument syntax used for passing methods to blocks can be used.
# So instead of doing this:
# ```
# expect(subject.size).to eq(5)
# ```
# The following syntax can be used instead:
# ```
# expect(&.size).to eq(5)
# ```
# The method passed will always be evaluated on the subject.
macro expect(&block)
{% if block.is_a?(Nop) %}
{% raise "Argument or block must be provided to expect" %}
{% end %}
# Check if the short-hand method syntax is used.
# This is a hack, since macros don't get this as a "literal" or something similar.
# The Crystal compiler will translate:
# ```
# &.foo
# ```
# to:
# ```
# { |__arg0| __arg0.foo }
# ```
# The hack used here is to check if it looks like a compiler-generated block.
{% if block.args.size == 1 && block.args[0] =~ /^__arg\d+$/ && block.body.is_a?(Call) && block.body.id =~ /^__arg\d+\./ %}
# Extract the method name to make it clear to the user what is tested.
# The raw block can't be used because it's not clear to the user.
{% method_name = block.body.id.split('.')[1..-1].join('.') %}
%proc = ->{ subject.{{method_name.id}} }
%test_block = ::Spectator::TestBlock.create(%proc, {{"#" + method_name}})
{% elsif block.args.empty? %}
# In this case, it looks like the short-hand method syntax wasn't used.
# Capture the block as a proc and pass along.
%proc = ->{{block}}
%test_block = ::Spectator::TestBlock.create(%proc, {{"`" + block.body.stringify + "`"}})
{% else %}
{% raise "Unexpected block arguments in expect call" %}
{% end %}
%source = ::Spectator::Source.new({{block.filename}}, {{block.line_number}})
::Spectator::Expectations::ExpectationPartial.new(%test_block, %source)
end
# Starts an expectation.
# This should be followed up with `Spectator::Expectations::ExpectationPartial#to`
# or `Spectator::Expectations::ExpectationPartial#to_not`.
# The value passed in will be checked
# to see if it satisfies the conditions specified.
#
# This method is identical to `#expect`,
# but is grammatically correct for the one-liner syntax.
# It can be used like so:
# ```
# it expects(actual).to eq(expected)
# ```
# Where the actual value is returned by the system-under-test,
# and the expected value is what the actual value should be to satisfy the condition.
macro expects(actual)
expect({{actual}})
end
# Starts an expectation on a block of code.
# This should be followed up with `Spectator::Expectations::ExpectationPartial#to`
# or `Spectator::Expectations::ExpectationPartial#to_not`.
# The block passed in, or its return value, will be checked
# to see if it satisfies the conditions specified.
#
# This method is identical to `#expect`,
# but is grammatically correct for the one-liner syntax.
# It can be used like so:
# ```
# it expects { 5 / 0 }.to raise_error
# ```
# The block of code is passed along for validation to the matchers.
#
# The short, one argument syntax used for passing methods to blocks can be used.
# So instead of doing this:
# ```
# it expects(subject.size).to eq(5)
# ```
# The following syntax can be used instead:
# ```
# it expects(&.size).to eq(5)
# ```
# The method passed will always be evaluated on the subject.
macro expects(&block)
expect {{block}}
end
# Short-hand for expecting something of the subject.
# These two are functionally equivalent:
# ```
# expect(subject).to eq("foo")
# is_expected.to eq("foo")
# ```
macro is_expected
expect(subject)
end
# Short-hand form of `#is_expected` that can be used for one-liner syntax.
# For instance:
# ```
# it "is 42" do
# expect(subject).to eq(42)
# end
# ```
# Can be shortened to:
# ```
# it is(42)
# ```
#
# These three are functionally equivalent:
# ```
# expect(subject).to eq("foo")
# is_expected.to eq("foo")
# is("foo")
# ```
#
# See also: `#is_not`
macro is(expected)
is_expected.to eq({{expected}})
end
# Short-hand, negated form of `#is_expected` that can be used for one-liner syntax.
# For instance:
# ```
# it "is not 42" do
# expect(subject).to_not eq(42)
# end
# ```
# Can be shortened to:
# ```
# it is_not(42)
# ```
#
# These three are functionally equivalent:
# ```
# expect(subject).to_not eq("foo")
# is_expected.to_not eq("foo")
# is_not("foo")
# ```
#
# See also: `#is`
macro is_not(expected)
is_expected.to_not eq({{expected}})
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.
# A reason can be passed,
# which is reported in the output.
def fail(reason : String)
raise ExampleFailed.new(reason)
end
# :ditto:
@[AlwaysInline]
def fail
fail("Example failed")
end
end
end

View file

@ -0,0 +1,108 @@
require "../example_group_hook"
require "../example_hook"
require "../example_procsy_hook"
require "../spec_builder"
module Spectator::DSL
# Incrementally builds up a test spec from the DSL.
# This is intended to be used only by the Spectator DSL.
module Builder
extend self
# Underlying spec builder.
private class_getter(builder) { SpecBuilder.new(Spectator.config) }
# Defines a new example group and pushes it onto the group stack.
# Examples and groups defined after calling this method will be nested under the new group.
# The group will be finished and popped off the stack when `#end_example` is called.
#
# See `Spec::Builder#start_group` for usage details.
def start_group(*args)
builder.start_group(*args)
end
# Defines a new iterative example group and pushes it onto the group stack.
# Examples and groups defined after calling this method will be nested under the new group.
# The group will be finished and popped off the stack when `#end_example` is called.
#
# See `Spec::Builder#start_iterative_group` for usage details.
def start_iterative_group(*args)
builder.start_iterative_group(*args)
end
# Completes a previously defined example group and pops it off the group stack.
# Be sure to call `#start_group` and `#end_group` symmetically.
#
# See `Spec::Builder#end_group` for usage details.
def end_group(*args)
builder.end_group(*args)
end
# Defines a new example.
# The example is added to the group currently on the top of the stack.
#
# See `Spec::Builder#add_example` for usage details.
def add_example(*args, &block : Example ->)
builder.add_example(*args, &block)
end
# Defines a new pending example.
# The example is added to the group currently on the top of the stack.
#
# See `Spec::Builder#add_pending_example` for usage details.
def add_pending_example(*args)
builder.add_pending_example(*args)
end
# Defines a block of code to execute before any and all examples in the test suite.
def before_suite(location = nil, label = "before_suite", &block)
hook = ExampleGroupHook.new(location: location, label: label, &block)
builder.before_suite(hook)
end
# Defines a block of code to execute before any and all examples in the current group.
def before_all(location = nil, label = "before_all", &block)
hook = ExampleGroupHook.new(location: location, label: label, &block)
builder.before_all(hook)
end
# Defines a block of code to execute before every example in the current group
def before_each(location = nil, label = "before_each", &block : Example -> _)
hook = ExampleHook.new(location: location, label: label, &block)
builder.before_each(hook)
end
# Defines a block of code to execute after any and all examples in the test suite.
def after_suite(location = nil, label = "after_suite", &block)
hook = ExampleGroupHook.new(location: location, label: label, &block)
builder.after_suite(hook)
end
# Defines a block of code to execute after any and all examples in the current group.
def after_all(location = nil, label = "after_all", &block)
hook = ExampleGroupHook.new(location: location, label: label, &block)
builder.after_all(hook)
end
# Defines a block of code to execute after every example in the current group.
def after_each(location = nil, label = "after_each", &block : Example ->)
hook = ExampleHook.new(location: location, label: label, &block)
builder.after_each(hook)
end
# Defines a block of code to execute around every example in the current group.
def around_each(location = nil, label = "around_each", &block : Example::Procsy ->)
hook = ExampleProcsyHook.new(location: location, label: label, &block)
builder.around_each(hook)
end
# Constructs the test spec.
# Returns the spec instance.
#
# Raises an error if there were not symmetrical calls to `#start_group` and `#end_group`.
# This would indicate a logical error somewhere in Spectator or an extension of it.
def build : Spec
builder.build
end
end
end

View file

@ -0,0 +1,47 @@
require "./examples"
require "./groups"
require "./memoize"
module Spectator::DSL
# DSL methods and macros for shorter syntax.
module Concise
# Defines an example and input values in a shorter syntax.
# The only arguments given to this macro are one or more assignments.
# The names in the assigments will be available in the example code.
#
# If the code block is omitted, then the example is skipped (marked as not implemented).
#
# Tags and metadata cannot be used with this macro.
#
# ```
# given x = 42 do
# expect(x).to eq(42)
# end
# ```
macro provided(*assignments, **kwargs, &block)
{% raise "Cannot use 'provided' inside of a test block" if @def %}
class Given%given < {{@type.id}}
{% for assignment in assignments %}
let({{assignment.target}}) { {{assignment.value}} }
{% end %}
{% for name, value in kwargs %}
let({{name}}) { {{value}} }
{% end %}
{% if block %}
example {{block}}
{% else %}
example {{assignments.splat.stringify}}
{% end %}
end
end
# :ditto:
@[Deprecated("Use `provided` instead.")]
macro given(*assignments, **kwargs, &block)
{% raise "Cannot use 'given' inside of a test block" if @def %}
provided({{assignments.splat(",")}} {{kwargs.double_splat}}) {{block}}
end
end
end

View file

@ -1,72 +1,151 @@
require "../source" require "../context"
require "../spec_builder" require "../location"
require "./builder"
require "./metadata"
module Spectator module Spectator::DSL
module DSL # DSL methods for defining examples and test code.
macro it(description = nil, &block) module Examples
{% if block.is_a?(Nop) %} include Metadata
{% if description.is_a?(Call) %}
def %run # Defines a macro to generate code for an example.
{{description}} # The *name* is the name given to the macro.
#
# In addition, another macro is defined that marks the example as pending.
# The pending macro is prefixed with 'x'.
# For instance, `define_example :it` defines `it` and `xit`.
#
# Default tags can be provided with *tags* and *metadata*.
# The tags are merged with parent groups.
# Any items with falsey values from *metadata* remove the corresponding tag.
macro define_example(name, *tags, **metadata)
# Defines an example.
#
# If a block is given, it is treated as the code to test.
# The block is provided the current example instance as an argument.
#
# The first argument names the example (test).
# Typically, this specifies what is being tested.
# It has no effect on the test and is purely used for output.
# If omitted, a name is generated from the first assertion in the test.
#
# The example will be marked as pending if the block is omitted.
# A block or name must be provided.
#
# Tags can be specified by adding symbols (keywords) after the first argument.
# Key-value pairs can also be specified.
# Any falsey items will remove a previously defined tag.
macro {{name.id}}(what = nil, *tags, **metadata, &block)
\{% raise "Cannot use '{{name.id}}' inside of a test block" if @def %}
\{% raise "A description or block must be provided. Cannot use '{{name.id}}' alone." unless what || block %}
_spectator_metadata(%metadata, :metadata, {{tags.splat(",")}} {{metadata.double_splat}})
_spectator_metadata(\%metadata, %metadata, \{{tags.splat(",")}} \{{metadata.double_splat}})
\{% if block %}
\{% raise "Block argument count '{{name.id}}' must be 0..1" if block.args.size > 1 %}
private def \%test(\{{block.args.splat}}) : Nil
\{{block.body}}
end end
{% else %}
{% raise "Unrecognized syntax: `it #{description}` at #{_source_file}:#{_source_line}" %}
{% end %}
{% else %}
def %run
{{block.body}}
end
{% end %}
{% if block.is_a?(Nop) %} ::Spectator::DSL::Builder.add_example(
%source = ::Spectator::Source.new({{description.filename}}, line: {{description.line_number}}, end_line: {{description.end_line_number}}) _spectator_example_name(\{{what}}),
{% else %} ::Spectator::Location.new(\{{block.filename}}, \{{block.line_number}}, \{{block.end_line_number}}),
%source = ::Spectator::Source.new({{block.filename}}, line: {{block.line_number}}, end_line: {{block.end_line_number}}) -> { new.as(::Spectator::Context) },
{% end %} \%metadata
::Spectator::SpecBuilder.add_example( ) do |example|
{{description.is_a?(StringLiteral) || description.is_a?(StringInterpolation) || description.is_a?(NilLiteral) ? description : description.stringify}}, example.with_context(\{{@type.name}}) do
%source, \{% if block.args.empty? %}
{{@type.name}} \%test
) { |test| test.as({{@type.name}}).%run } \{% else %}
end \%test(example)
\{% end %}
macro specify(description = nil, &block) end
it({{description}}) {{block}}
end
macro pending(description = nil, &block)
{% if block.is_a?(Nop) %}
{% if description.is_a?(Call) %}
def %run
{{description}}
end end
{% else %}
{% raise "Unrecognized syntax: `pending #{description}` at #{_source_file}:#{_source_line}" %} \{% else %}
{% end %} ::Spectator::DSL::Builder.add_pending_example(
_spectator_example_name(\{{what}}),
::Spectator::Location.new(\{{what.filename}}, \{{what.line_number}}),
\%metadata,
"Not yet implemented"
)
\{% end %}
end
define_pending_example :x{{name.id}}, skip: "Temporarily skipped with x{{name.id}}"
end
# Defines a macro to generate code for a pending example.
# The *name* is the name given to the macro.
#
# The block for the example's content is discarded at compilation time.
# This prevents issues with undefined methods, signature differences, etc.
#
# Default tags can be provided with *tags* and *metadata*.
# The tags are merged with parent groups.
# Any items with falsey values from *metadata* remove the corresponding tag.
macro define_pending_example(name, *tags, **metadata)
# Defines a pending example.
#
# If a block is given, it is treated as the code to test.
# The block is provided the current example instance as an argument.
#
# The first argument names the example (test).
# Typically, this specifies what is being tested.
# It has no effect on the test and is purely used for output.
# If omitted, a name is generated from the first assertion in the test.
#
# Tags can be specified by adding symbols (keywords) after the first argument.
# Key-value pairs can also be specified.
# Any falsey items will remove a previously defined tag.
macro {{name.id}}(what = nil, *tags, **metadata, &block)
\{% raise "Cannot use '{{name.id}}' inside of a test block" if @def %}
\{% raise "A description or block must be provided. Cannot use '{{name.id}}' alone." unless what || block %}
\{% raise "Block argument count '{{name.id}}' must be 0..1" if block && block.args.size > 1 %}
_spectator_metadata(%metadata, :metadata, {{tags.splat(",")}} {{metadata.double_splat}})
_spectator_metadata(\%metadata, %metadata, \{{tags.splat(",")}} \{{metadata.double_splat}})
::Spectator::DSL::Builder.add_pending_example(
_spectator_example_name(\{{what}}),
::Spectator::Location.new(\{{(what || block).filename}}, \{{(what || block).line_number}}, \{{(what || block).end_line_number}}),
\%metadata,
\{% if !block %}"Not yet implemented"\{% end %}
)
end
end
# Inserts the correct representation of a example's name.
# If *what* is a string, then it is dropped in as-is.
# For anything else, it is stringified.
# This is intended to be used to convert a description from the spec DSL to `Node#name`.
private macro _spectator_example_name(what)
{% if what.is_a?(StringLiteral) ||
what.is_a?(StringInterpolation) ||
what.is_a?(NilLiteral) %}
{{what}}
{% else %} {% else %}
def %run {{what.stringify}}
{{block.body}}
end
{% end %} {% end %}
{% if block.is_a?(Nop) %}
%source = ::Spectator::Source.new({{description.filename}}, line: {{description.line_number}}, end_line: {{description.end_line_number}})
{% else %}
%source = ::Spectator::Source.new({{block.filename}}, line: {{block.line_number}}, end_line: {{block.end_line_number}})
{% end %}
::Spectator::SpecBuilder.add_pending_example(
{{description.is_a?(StringLiteral) || description.is_a?(StringInterpolation) || description.is_a?(NilLiteral) ? description : description.stringify}},
%source,
{{@type.name}}
) { |test| test.as({{@type.name}}).%run }
end end
macro skip(description = nil, &block) define_example :example
pending({{description}}) {{block}}
end
macro xit(description = nil, &block) define_example :it
pending({{description}}) {{block}}
end define_example :specify
define_example :fexample, focus: true
define_example :fit, focus: true
define_example :fspecify, focus: true
@[Deprecated("Behavior of pending blocks will change in Spectator v0.11.0. Use `skip` instead.")]
define_pending_example :pending
define_pending_example :skip
end end
end end

View file

@ -0,0 +1,181 @@
require "../block"
require "../example_pending"
require "../expectation"
require "../expectation_failed"
require "../location"
require "../pending_result"
require "../value"
module Spectator::DSL
# Methods and macros for asserting that conditions are met.
module Expectations
# Immediately fail the current test.
# A reason can be specified with *message*.
def fail(message = "Example failed", *, _file = __FILE__, _line = __LINE__)
raise ExampleFailed.new(Location.new(_file, _line), message)
end
# Mark the current test as pending and immediately abort.
# A reason can be specified with *message*.
def pending(message = PendingResult::DEFAULT_REASON, *, _file = __FILE__, _line = __LINE__)
raise ExamplePending.new(Location.new(_file, _line), message)
end
# Mark the current test as skipped and immediately abort.
# A reason can be specified with *message*.
def skip(message = PendingResult::DEFAULT_REASON, *, _file = __FILE__, _line = __LINE__)
raise ExamplePending.new(Location.new(_file, _line), message)
end
# Starts an expectation.
# This should be followed up with `Assertion::Target#to` or `Assertion::Target#to_not`.
# The value passed in will be checked to see if it satisfies the conditions of the specified matcher.
#
# This macro should be used like so:
# ```
# expect(actual).to eq(expected)
# ```
#
# Where the actual value is returned by the system under test,
# and the expected value is what the actual value should be to satisfy the condition.
macro expect(actual)
%actual = begin
{{actual}}
end
%expression = ::Spectator::Value.new(%actual, {{actual.stringify}})
%location = ::Spectator::Location.new({{actual.filename}}, {{actual.line_number}})
::Spectator::Expectation::Target.new(%expression, %location)
end
# Starts an expectation.
# This should be followed up with `Assertion::Target#to` or `Assertion::Target#not_to`.
# The value passed in will be checked to see if it satisfies the conditions of the specified matcher.
#
# This macro should be used like so:
# ```
# expect { raise "foo" }.to raise_error
# ```
#
# The block of code is passed along for validation to the matchers.
#
# The short, one argument syntax used for passing methods to blocks can be used.
# So instead of doing this:
# ```
# expect(subject.size).to eq(5)
# ```
#
# The following syntax can be used instead:
# ```
# expect(&.size).to eq(5)
# ```
#
# The method passed will always be evaluated on the subject.
#
# TECHNICAL NOTE:
# This macro uses an ugly hack to detect the short-hand syntax.
#
# The Crystal compiler will translate:
# ```
# &.foo
# ```
#
# effectively to:
# ```
# { |__arg0| __arg0.foo }
# ```
macro expect(&block)
{% if block.args.size == 1 && block.args[0] =~ /^__arg\d+$/ && block.body.is_a?(Call) && block.body.id =~ /^__arg\d+\./ %}
{% method_name = block.body.id.split('.')[1..-1].join('.') %}
%block = ::Spectator::Block.new({{"#" + method_name}}) do
subject.{{method_name.id}}
end
{% elsif block.args.empty? %}
%block = ::Spectator::Block.new({{"`" + block.body.stringify + "`"}}) {{block}}
{% else %}
{% raise "Unexpected block arguments in 'expect' call" %}
{% end %}
%location = ::Spectator::Location.new({{block.filename}}, {{block.line_number}})
::Spectator::Expectation::Target.new(%block, %location)
end
# Short-hand for expecting something of the subject.
#
# These two are functionally equivalent:
# ```
# expect(subject).to eq("foo")
# is_expected.to eq("foo")
# ```
macro is_expected
expect(subject)
end
# Short-hand form of `#is_expected` that can be used for one-liner syntax.
#
# For instance:
# ```
# it "is 42" do
# expect(subject).to eq(42)
# end
# ```
#
# Can be shortened to:
# ```
# it { is(42) }
# ```
#
# These three are functionally equivalent:
# ```
# expect(subject).to eq("foo")
# is_expected.to eq("foo")
# is("foo")
# ```
#
# See also: `#is_not`
macro is(expected)
expect(subject).to(eq({{expected}}))
end
# Short-hand, negated form of `#is_expected` that can be used for one-liner syntax.
#
# For instance:
# ```
# it "is not 42" do
# expect(subject).to_not eq(42)
# end
# ```
#
# Can be shortened to:
# ```
# it { is_not(42) }
# ```
#
# These three are functionally equivalent:
# ```
# expect(subject).not_to eq("foo")
# is_expected.not_to eq("foo")
# is_not("foo")
# ```
#
# See also: `#is`
macro is_not(expected)
expect(subject).not_to(eq({{expected}}))
end
# Captures multiple possible failures.
# Aborts after the block completes if there were any failed expectations in the block.
#
# ```
# aggregate_failures do
# expect(true).to be_false
# expect(false).to be_true
# end
# ```
def aggregate_failures(label = nil)
::Spectator::Harness.current.aggregate_failures(label) do
yield
end
end
end
end

View file

@ -1,154 +1,235 @@
require "../spec_builder" require "../location"
require "./builder"
require "./memoize"
require "./metadata"
module Spectator module Spectator::DSL
module DSL # DSL methods and macros for creating example groups.
macro context(what, &block) # This module should be included as a mix-in.
class Context%context < {{@type.id}} module Groups
{% include Metadata
description = if what.is_a?(StringLiteral) || what.is_a?(StringInterpolation)
if what.starts_with?("#") || what.starts_with?(".")
what.id.symbolize
else
what
end
else
what.symbolize
end
%}
%source = ::Spectator::Source.new({{block.filename}}, {{block.line_number}}) # Defines a macro to generate code for an example group.
::Spectator::SpecBuilder.start_group({{description}}, %source) # The *name* is the name given to the macro.
#
# Default tags can be provided with *tags* and *metadata*.
# The tags are merged with parent groups.
# Any items with falsey values from *metadata* remove the corresponding tag.
macro define_example_group(name, *tags, **metadata)
# Defines a new example group.
# The *what* argument is a name or description of the group.
#
# The first argument names the example (test).
# Typically, this specifies what is being tested.
# This argument is also used as the subject.
# When it is a type name, it becomes an explicit, which overrides any previous subjects.
# Otherwise it becomes an implicit subject, which doesn't override explicitly defined subjects.
#
# Tags can be specified by adding symbols (keywords) after the first argument.
# Key-value pairs can also be specified.
# Any falsey items will remove a previously defined tag.
#
# TODO: Handle string interpolation in example and group names.
macro {{name.id}}(what, *tags, **metadata, &block)
\{% raise "Cannot use '{{name.id}}' inside of a test block" if @def %}
# Oddly, `#resolve?` can return a constant's value, which isn't a TypeNode. class Group\%group < \{{@type.id}}
# Ensure `described_class` and `subject` are only set for real types (is a `TypeNode`). _spectator_group_subject(\{{what}})
{% if (what.is_a?(Path) || what.is_a?(Generic)) && (described_type = what.resolve?).is_a?(TypeNode) %}
macro described_class
{{what}}
end
subject do _spectator_metadata(:metadata, :super, {{tags.splat(", ")}} {{metadata.double_splat}})
{% if described_type < Reference || described_type < Value %} _spectator_metadata(:metadata, :previous_def, \{{tags.splat(", ")}} \{{metadata.double_splat}})
described_class.new
{% else %}
described_class
{% end %}
end
{% else %}
def _spectator_implicit_subject(*args)
{{what}}
end
{% end %}
{{block.body}} ::Spectator::DSL::Builder.start_group(
_spectator_group_name(\{{what}}),
::Spectator::Location.new(\{{block.filename}}, \{{block.line_number}}, \{{block.end_line_number}}),
metadata
)
::Spectator::SpecBuilder.end_group \{{block.body if block}}
::Spectator::DSL::Builder.end_group
end
end end
end end
macro describe(what, &block) # Defines a macro to generate code for an iterative example group.
context({{what}}) {{block}} # The *name* is the name given to the macro.
#
# Default tags can be provided with *tags* and *metadata*.
# The tags are merged with parent groups.
# Any items with falsey values from *metadata* remove the corresponding tag.
#
# If provided, a block can be used to modify collection that will be iterated.
# It takes a single argument - the original collection from the user.
# The modified collection should be returned.
#
# TODO: Handle string interpolation in example and group names.
macro define_iterative_group(name, *tags, **metadata, &block)
macro {{name.id}}(collection, *tags, count = nil, **metadata, &block)
\{% raise "Cannot use 'sample' inside of a test block" if @def %}
class Group\%group < \{{@type.id}}
_spectator_metadata(:metadata, :super, {{tags.splat(", ")}} {{metadata.double_splat}})
_spectator_metadata(:metadata, :previous_def, \{{tags.splat(", ")}} \{{metadata.double_splat}})
def self.\%collection
\{{collection}}
end
{% if block %}
def self.%mutate({{block.args.splat}})
{{block.body}}
end
def self.\%collection
%mutate(previous_def)
end
{% end %}
\{% if count %}
def self.\%collection
previous_def.first(\{{count}})
end
\{% end %}
::Spectator::DSL::Builder.start_iterative_group(
\%collection,
\{{collection.stringify}},
\{{block.args.empty? ? :nil.id : block.args.first.stringify}},
::Spectator::Location.new(\{{block.filename}}, \{{block.line_number}}, \{{block.end_line_number}}),
metadata
)
\{% if block %}
\{% if block.args.size == 1 %}
let(\{{block.args.first}}) do |example|
example.group.as(::Spectator::ExampleGroupIteration(typeof(Group\%group.\%collection.first))).item
end
\{% elsif block.args.size > 1 %}
\{% raise "Expected 1 argument for 'sample' block, but got #{block.args.size}" %}
\{% end %}
\{{block.body}}
\{% end %}
::Spectator::DSL::Builder.end_group
end
end
end end
macro sample(collection, count = nil, &block) # Inserts the correct representation of a group's name.
{% name = block.args.empty? ? :value.id : block.args.first.id %} # If *what* appears to be a type name, it will be symbolized.
# If it's a string, then it is dropped in as-is.
# For anything else, it is stringified.
# This is intended to be used to convert a description from the spec DSL to `Node#name`.
private macro _spectator_group_name(what)
{% if (what.is_a?(Generic) ||
what.is_a?(Path) ||
what.is_a?(TypeNode) ||
what.is_a?(Union)) &&
what.resolve?.is_a?(TypeNode) %}
{{what.symbolize}}
{% elsif what.is_a?(StringLiteral) ||
what.is_a?(StringInterpolation) ||
what.is_a?(NilLiteral) %}
{{what}}
{% else %}
{{what.stringify}}
{% end %}
end
def %collection # Defines the implicit subject for the test context.
{{collection}} # If *what* is a type, then the `described_class` method will be defined.
end # Additionally, the implicit subject is set to an instance of *what* if it's not a module.
#
def %to_a # There is no common macro type that has the `#resolve?` method.
{% if count %} # Also, `#responds_to?` can't be used in macros.
%collection.first({{count}}) # So the large if statement in this macro is used to look for type signatures.
{% else %} private macro _spectator_group_subject(what)
%collection.to_a {% if (what.is_a?(Generic) ||
{% end %} what.is_a?(Path) ||
end what.is_a?(TypeNode) ||
what.is_a?(Union)) &&
class Context%sample < {{@type.id}} (described_type = what.resolve?).is_a?(TypeNode) %}
%source = ::Spectator::Source.new({{block.filename}}, {{block.line_number}}) private macro described_class
::Spectator::SpecBuilder.start_sample_group({{collection.stringify}}, %source, :%sample, {{name.stringify}}) do |values| {{what}}
sample = {{@type.id}}.new(values)
sample.%to_a
end end
def {{name}} subject do
@spectator_test_values.get_value(:%sample, typeof(%to_a.first)) {% if described_type.class? || described_type.struct? %}
end described_class.new
{{block.body}}
::Spectator::SpecBuilder.end_group
end
end
macro random_sample(collection, count = nil, &block)
{% name = block.args.empty? ? :value.id : block.args.first.id %}
def %collection
{{collection}}
end
def %to_a
{% if count %}
%collection.first({{count}})
{% else %}
%collection.to_a
{% end %}
end
class Context%sample < {{@type.id}}
%source = ::Spectator::Source.new({{block.filename}}, {{block.line_number}})
::Spectator::SpecBuilder.start_sample_group({{collection.stringify}}, %source, :%sample, {{name.stringify}}) do |values|
sample = {{@type.id}}.new(values)
collection = sample.%to_a
{% if count %}
collection.sample({{count}}, ::Spectator.random)
{% else %} {% else %}
collection.shuffle(::Spectator.random) described_class
{% end %} {% end %}
end end
{% else %}
def {{name}} private def _spectator_implicit_subject
@spectator_test_values.get_value(:%sample, typeof(%to_a.first)) {{what}}
end end
{% end %}
{{block.body}}
::Spectator::SpecBuilder.end_group
end
end end
macro given(*assignments, &block) define_example_group :example_group
context({{assignments.splat.stringify}}) do
{% for assignment in assignments %}
let({{assignment.target}}) { {{assignment.value}} }
{% end %}
{% # Trick to get the contents of the block as an array of nodes. define_example_group :describe
# If there are multiple expressions/statements in the block,
# then the body will be a `Expressions` type.
# If there's only one expression, then the body is just that.
body = if block.is_a?(Nop)
raise "Missing block for 'given'"
elsif block.body.is_a?(Expressions)
# Get the expressions, which is already an array.
block.body.expressions
else
# Wrap the expression in an array.
[block.body]
end %}
{% for item in body %} define_example_group :context
# If the item starts with "it", then leave it as-is.
# Otherwise, prefix it with "it" define_example_group :xexample_group, skip: "Temporarily skipped with xexample_group"
# and treat it as the one-liner "it" syntax.
{% if item.is_a?(Call) && item.name == :it.id %} define_example_group :xdescribe, skip: "Temporarily skipped with xdescribe"
{{item}}
{% else %} define_example_group :xcontext, skip: "Temporarily skipped with xcontext"
it {{item}}
{% end %} define_example_group :fexample_group, focus: true
{% end %}
end define_example_group :fdescribe, focus: true
define_example_group :fcontext, focus: true
# Defines a new iterative example group.
# This type of group duplicates its contents for each element in *collection*.
#
# The first argument is the collection of elements to iterate over.
#
# Tags can be specified by adding symbols (keywords) after the first argument.
# Key-value pairs can also be specified.
# Any falsey items will remove a previously defined tag.
#
# The number of items iterated can be restricted by specifying a *count* argument.
# The first *count* items will be used if specified, otherwise all items will be used.
define_iterative_group :sample
# :ditto:
define_iterative_group :xsample, skip: "Temporarily skipped with xsample"
define_iterative_group :fsample, focus: true
# Defines a new iterative example group.
# This type of group duplicates its contents for each element in *collection*.
# This is the same as `#sample` except that the items are shuffled.
# The items are selected with a RNG based on the seed.
#
# The first argument is the collection of elements to iterate over.
#
# Tags can be specified by adding symbols (keywords) after the first argument.
# Key-value pairs can also be specified.
# Any falsey items will remove a previously defined tag.
#
# The number of items iterated can be restricted by specifying a *count* argument.
# The first *count* items will be used if specified, otherwise all items will be used.
define_iterative_group :random_sample do |collection|
collection.to_a.shuffle(::Spectator.random)
end
# :ditto:
define_iterative_group :xrandom_sample, skip: "Temporarily skipped with xrandom_sample" do |collection|
collection.to_a.shuffle(::Spectator.random)
end
# :ditto:
define_iterative_group :frandom_sample, focus: true do |collection|
collection.to_a.shuffle(::Spectator.random)
end end
end end
end end

View file

@ -1,79 +1,142 @@
module Spectator require "../location"
module DSL require "./builder"
macro before_each(&block)
def %hook({{block.args.splat}}) : Nil
{{block.body}}
end
::Spectator::SpecBuilder.add_before_each_hook do |test, example| module Spectator::DSL
cast_test = test.as({{@type.id}}) # DSL methods for adding custom logic to key times of the spec execution.
{% if block.args.empty? %} module Hooks
cast_test.%hook # Defines a macro to create an example group hook.
{% else %} # The *type* indicates when the hook runs and must be a method on `Spectator::DSL::Builder`.
cast_test.%hook(example) # A custom *name* can be used for the hook method.
# If not provided, *type* will be used instead.
# Additionally, a block can be provided.
# The block can perform any operations necessary and yield to invoke the end-user hook.
macro define_example_group_hook(type, name = nil, &block)
macro {{(name ||= type).id}}(&block)
\{% raise "Missing block for '{{name.id}}' hook" unless block %}
\{% raise "Cannot use '{{name.id}}' inside of a test block" if @def %}
private def self.\%hook : Nil
\{{block.body}}
end
{% if block %}
private def self.%wrapper : Nil
{{block.body}}
end
{% end %} {% end %}
::Spectator::DSL::Builder.{{type.id}}(
::Spectator::Location.new(\{{block.filename}}, \{{block.line_number}}, \{{block.end_line_number}})
) do
{% if block %}
%wrapper do |*args|
\{% if block.args.empty? %}
\%hook
\{% else %}
\%hook(*args)
\{% end %}
end
{% else %}
\%hook
{% end %}
end
end end
end end
macro after_each(&block) # Defines a macro to create an example hook.
def %hook({{block.args.splat}}) : Nil # The *type* indicates when the hook runs and must be a method on `Spectator::DSL::Builder`.
{{block.body}} # A custom *name* can be used for the hook method.
end # If not provided, *type* will be used instead.
# Additionally, a block can be provided that takes the current example as an argument.
# The block can perform any operations necessary and yield to invoke the end-user hook.
macro define_example_hook(type, name = nil, &block)
macro {{(name ||= type).id}}(&block)
\{% raise "Missing block for '{{name.id}}' hook" unless block %}
\{% raise "Block argument count '{{name.id}}' hook must be 0..1" if block.args.size > 1 %}
\{% raise "Cannot use '{{name.id}}' inside of a test block" if @def %}
::Spectator::SpecBuilder.add_after_each_hook do |test, example| private def \%hook(\{{block.args.splat}}) : Nil
cast_test = test.as({{@type.id}}) \{{block.body}}
{% if block.args.empty? %} end
cast_test.%hook
{% else %} {% if block %}
cast_test.%hook(example) private def %wrapper({{block.args.splat}}) : Nil
{{block.body}}
end
{% end %} {% end %}
::Spectator::DSL::Builder.{{type.id}}(
::Spectator::Location.new(\{{block.filename}}, \{{block.line_number}})
) do |example|
example.with_context(\{{@type.name}}) do
{% if block %}
{% if block.args.empty? %}
%wrapper do |*args|
\{% if block.args.empty? %}
\%hook
\{% else %}
\%hook(*args)
\{% end %}
end
{% else %}
%wrapper(example) do |*args|
\{% if block.args.empty? %}
\%hook
\{% else %}
\%hook(*args)
\{% end %}
end
{% end %}
{% else %}
\{% if block.args.empty? %}
\%hook
\{% else %}
\%hook(example)
\{% end %}
{% end %}
end
end
end end
end end
macro before_all(&block) # Defines a block of code that will be invoked once before any examples in the suite.
::Spectator::SpecBuilder.add_before_all_hook {{block}} # The block will not run in the context of the current running example.
end # This means that values defined by `let` and `subject` are not available.
define_example_group_hook :before_suite
macro after_all(&block) # Defines a block of code that will be invoked once after all examples in the suite.
::Spectator::SpecBuilder.add_after_all_hook {{block}} # The block will not run in the context of the current running example.
end # This means that values defined by `let` and `subject` are not available.
define_example_group_hook :after_suite
macro around_each(&block) # Defines a block of code that will be invoked once before any examples in the group.
def %hook({{block.args.first || :example.id}}) : Nil # The block will not run in the context of the current running example.
{{block.body}} # This means that values defined by `let` and `subject` are not available.
end define_example_group_hook :before_all
::Spectator::SpecBuilder.add_around_each_hook { |test, proc| test.as({{@type.id}}).%hook(proc) } # Defines a block of code that will be invoked once after all examples in the group.
end # The block will not run in the context of the current running example.
# This means that values defined by `let` and `subject` are not available.
define_example_group_hook :after_all
macro pre_condition(&block) # Defines a block of code that will be invoked before every example in the group.
def %hook({{block.args.splat}}) : Nil # The block will be run in the context of the current running example.
{{block.body}} # This means that values defined by `let` and `subject` are available.
end define_example_hook :before_each
::Spectator::SpecBuilder.add_pre_condition do |test, example| # Defines a block of code that will be invoked after every example in the group.
cast_test = test.as({{@type.id}}) # The block will be run in the context of the current running example.
{% if block.args.empty? %} # This means that values defined by `let` and `subject` are available.
cast_test.%hook define_example_hook :after_each
{% else %}
cast_test.%hook(example)
{% end %}
end
end
macro post_condition(&block) # Defines a block of code that will be invoked around every example in the group.
def %hook({{block.args.splat}}) : Nil # The block will be run in the context of the current running example.
{{block.body}} # This means that values defined by `let` and `subject` are available.
end #
# The block will execute before the example.
::Spectator::SpecBuilder.add_post_condition do |test, example| # An `Example::Procsy` is passed to the block.
cast_test = test.as({{@type.id}}) # The `Example::Procsy#run` method should be called to ensure the example runs.
{% if block.args.empty? %} # More code can run afterwards (in the block).
cast_test.%hook define_example_hook :around_each
{% else %}
cast_test.%hook(example)
{% end %}
end
end
end end
end end

View file

@ -1,9 +1,9 @@
require "../block"
require "../matchers" require "../matchers"
require "../test_block" require "../value"
require "../test_value"
module Spectator module Spectator::DSL
module DSL module Matchers
# Indicates that some value should equal another. # Indicates that some value should equal another.
# The == operator is used for this check. # The == operator is used for this check.
# The value passed to this method is the expected value. # The value passed to this method is the expected value.
@ -13,8 +13,8 @@ module Spectator
# expect(1 + 2).to eq(3) # expect(1 + 2).to eq(3)
# ``` # ```
macro eq(expected) macro eq(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::EqualityMatcher.new(%test_value) ::Spectator::Matchers::EqualityMatcher.new(%value)
end end
# Indicates that some value should not equal another. # Indicates that some value should not equal another.
@ -26,8 +26,8 @@ module Spectator
# expect(1 + 2).to ne(5) # expect(1 + 2).to ne(5)
# ``` # ```
macro ne(expected) macro ne(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::InequalityMatcher.new(%test_value) ::Spectator::Matchers::InequalityMatcher.new(%value)
end end
# Indicates that some value when compared to another satisfies an operator. # Indicates that some value when compared to another satisfies an operator.
@ -61,8 +61,8 @@ module Spectator
# expect(obj.dup).to_not be(obj) # expect(obj.dup).to_not be(obj)
# ``` # ```
macro be(expected) macro be(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::ReferenceMatcher.new(%test_value) ::Spectator::Matchers::ReferenceMatcher.new(%value)
end end
# Indicates that some value should be of a specified type. # Indicates that some value should be of a specified type.
@ -123,7 +123,7 @@ module Spectator
end end
# Indicates that some value should be of a specified type. # Indicates that some value should be of a specified type.
# The value's runtime class is checked. # The value's runtime type is checked.
# A type name or type union should be used for *expected*. # A type name or type union should be used for *expected*.
# #
# Examples: # Examples:
@ -135,7 +135,7 @@ module Spectator
end end
# Indicates that some value should be of a specified type. # Indicates that some value should be of a specified type.
# The value's runtime class is checked. # The value's runtime type is checked.
# A type name or type union should be used for *expected*. # A type name or type union should be used for *expected*.
# This method is identical to `#be_an_instance_of`, # This method is identical to `#be_an_instance_of`,
# and exists just to improve grammar. # and exists just to improve grammar.
@ -148,6 +148,19 @@ module Spectator
be_instance_of({{expected}}) be_instance_of({{expected}})
end end
# Indicates that some value should be of a specified type at compile time.
# The value's compile time type is checked.
# This can test is a variable or value returned by a method is inferred to the expected type.
#
# Examples:
# ```
# value = 42 || "foobar"
# expect(value).to compile_as(Int32 | String)
# ```
macro compile_as(expected)
::Spectator::Matchers::CompiledTypeMatcher({{expected}}).new
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.
# #
@ -173,8 +186,8 @@ module Spectator
# expect(3 - 1).to be_lt(3) # expect(3 - 1).to be_lt(3)
# ``` # ```
macro be_lt(expected) macro be_lt(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::LessThanMatcher.new(%test_value) ::Spectator::Matchers::LessThanMatcher.new(%value)
end end
# Indicates that some value should be less than or equal to another. # Indicates that some value should be less than or equal to another.
@ -186,8 +199,8 @@ module Spectator
# expect(3 - 1).to be_le(3) # expect(3 - 1).to be_le(3)
# ``` # ```
macro be_le(expected) macro be_le(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::LessThanEqualMatcher.new(%test_value) ::Spectator::Matchers::LessThanEqualMatcher.new(%value)
end end
# Indicates that some value should be greater than another. # Indicates that some value should be greater than another.
@ -199,8 +212,8 @@ module Spectator
# expect(3 + 1).to be_gt(3) # expect(3 + 1).to be_gt(3)
# ``` # ```
macro be_gt(expected) macro be_gt(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::GreaterThanMatcher.new(%test_value) ::Spectator::Matchers::GreaterThanMatcher.new(%value)
end end
# Indicates that some value should be greater than or equal to another. # Indicates that some value should be greater than or equal to another.
@ -212,8 +225,8 @@ module Spectator
# expect(3 + 1).to be_ge(3) # expect(3 + 1).to be_ge(3)
# ``` # ```
macro be_ge(expected) macro be_ge(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::GreaterThanEqualMatcher.new(%test_value) ::Spectator::Matchers::GreaterThanEqualMatcher.new(%value)
end end
# Indicates that some value should match another. # Indicates that some value should match another.
@ -230,8 +243,8 @@ module Spectator
# expect({:foo, 5}).to match({Symbol, Int32}) # expect({:foo, 5}).to match({Symbol, Int32})
# ``` # ```
macro match(expected) macro match(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::CaseMatcher.new(%test_value) ::Spectator::Matchers::CaseMatcher.new(%value)
end end
# Indicates that some value should be true. # Indicates that some value should be true.
@ -321,8 +334,8 @@ module Spectator
# NOTE: Do not attempt to mix the two use cases. # NOTE: Do not attempt to mix the two use cases.
# It likely won't work and will result in a compilation error. # It likely won't work and will result in a compilation error.
macro be_within(expected) macro be_within(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::CollectionMatcher.new(%test_value) ::Spectator::Matchers::CollectionMatcher.new(%value)
end end
# Indicates that some value should be between a lower and upper-bound. # Indicates that some value should be between a lower and upper-bound.
@ -344,8 +357,8 @@ module Spectator
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) %value = ::Spectator::Value.new(%range, %label)
::Spectator::Matchers::RangeMatcher.new(%test_value) ::Spectator::Matchers::RangeMatcher.new(%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.
@ -403,8 +416,8 @@ module Spectator
# expect(%w[foo bar]).to start_with(/foo/) # expect(%w[foo bar]).to start_with(/foo/)
# ``` # ```
macro start_with(expected) macro start_with(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::StartWithMatcher.new(%test_value) ::Spectator::Matchers::StartWithMatcher.new(%value)
end end
# Indicates that some value or set should end with another value. # Indicates that some value or set should end with another value.
@ -426,8 +439,8 @@ module Spectator
# expect(%w[foo bar]).to end_with(/bar/) # expect(%w[foo bar]).to end_with(/bar/)
# ``` # ```
macro end_with(expected) macro end_with(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::EndWithMatcher.new(%test_value) ::Spectator::Matchers::EndWithMatcher.new(%value)
end end
# Indicates that some value or set should contain another value. # Indicates that some value or set should contain another value.
@ -451,11 +464,11 @@ module Spectator
# ``` # ```
macro contain(*expected) macro contain(*expected)
{% if expected.id.starts_with?("{*") %} {% if expected.id.starts_with?("{*") %}
%test_value = ::Spectator::TestValue.new({{expected.id[2...-1]}}, {{expected.splat.stringify}}) %value = ::Spectator::Value.new({{expected.id[2...-1]}}, {{expected.splat.stringify}})
::Spectator::Matchers::ContainMatcher.new(%test_value) ::Spectator::Matchers::ContainMatcher.new(%value)
{% else %} {% else %}
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.splat.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.splat.stringify}})
::Spectator::Matchers::ContainMatcher.new(%test_value) ::Spectator::Matchers::ContainMatcher.new(%value)
{% end %} {% end %}
end end
@ -475,8 +488,8 @@ module Spectator
# expect(%i[a b c]).to contain_elements(%i[a b]) # expect(%i[a b c]).to contain_elements(%i[a b])
# ``` # ```
macro contain_elements(expected) macro contain_elements(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::ContainMatcher.new(%test_value) ::Spectator::Matchers::ContainMatcher.new(%value)
end end
# Indicates that some range (or collection) should contain another value. # Indicates that some range (or collection) should contain another value.
@ -497,11 +510,11 @@ module Spectator
# ``` # ```
macro cover(*expected) macro cover(*expected)
{% if expected.id.starts_with?("{*") %} {% if expected.id.starts_with?("{*") %}
%test_value = ::Spectator::TestValue.new({{expected.id[2...-1]}}, {{expected.splat.stringify}}) %value = ::Spectator::Value.new({{expected.id[2...-1]}}, {{expected.splat.stringify}})
::Spectator::Matchers::ContainMatcher.new(%test_value) ::Spectator::Matchers::ContainMatcher.new(%value)
{% else %} {% else %}
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.splat.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.splat.stringify}})
::Spectator::Matchers::ContainMatcher.new(%test_value) ::Spectator::Matchers::ContainMatcher.new(%value)
{% end %} {% end %}
end end
@ -532,11 +545,11 @@ module Spectator
# ``` # ```
macro have(*expected) macro have(*expected)
{% if expected.id.starts_with?("{*") %} {% if expected.id.starts_with?("{*") %}
%test_value = ::Spectator::TestValue.new({{expected.id[2...-1]}}, {{expected.splat.stringify}}) %value = ::Spectator::Value.new({{expected.id[2...-1]}}, {{expected.splat.stringify}})
::Spectator::Matchers::HaveMatcher.new(%test_value) ::Spectator::Matchers::HaveMatcher.new(%value)
{% else %} {% else %}
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.splat.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.splat.stringify}})
::Spectator::Matchers::HaveMatcher.new(%test_value) ::Spectator::Matchers::HaveMatcher.new(%value)
{% end %} {% end %}
end end
@ -559,8 +572,8 @@ module Spectator
# expect([1, 2, 3, :a, :b, :c]).to have_elements([Int32, Symbol]) # expect([1, 2, 3, :a, :b, :c]).to have_elements([Int32, Symbol])
# ``` # ```
macro have_elements(expected) macro have_elements(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::HaveMatcher.new(%test_value) ::Spectator::Matchers::HaveMatcher.new(%value)
end end
# Indicates that some set, such as a `Hash`, has a given key. # Indicates that some set, such as a `Hash`, has a given key.
@ -572,8 +585,8 @@ module Spectator
# expect({"lucky" => 7}).to have_key("lucky") # expect({"lucky" => 7}).to have_key("lucky")
# ``` # ```
macro have_key(expected) macro have_key(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::HaveKeyMatcher.new(%test_value) ::Spectator::Matchers::HaveKeyMatcher.new(%value)
end end
# :ditto: # :ditto:
@ -590,8 +603,8 @@ module Spectator
# expect({"lucky" => 7}).to have_value(7) # expect({"lucky" => 7}).to have_value(7)
# ``` # ```
macro have_value(expected) macro have_value(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::HaveValueMatcher.new(%test_value) ::Spectator::Matchers::HaveValueMatcher.new(%value)
end end
# :ditto: # :ditto:
@ -607,11 +620,11 @@ module Spectator
# ``` # ```
macro contain_exactly(*expected) macro contain_exactly(*expected)
{% if expected.id.starts_with?("{*") %} {% if expected.id.starts_with?("{*") %}
%test_value = ::Spectator::TestValue.new(({{expected.id[2...-1]}}).to_a, {{expected.stringify}}) %value = ::Spectator::Value.new(({{expected.id[2...-1]}}).to_a, {{expected.stringify}})
::Spectator::Matchers::ArrayMatcher.new(%test_value) ::Spectator::Matchers::ArrayMatcher.new(%value)
{% else %} {% else %}
%test_value = ::Spectator::TestValue.new(({{expected}}).to_a, {{expected.stringify}}) %value = ::Spectator::Value.new(({{expected}}).to_a, {{expected.stringify}})
::Spectator::Matchers::ArrayMatcher.new(%test_value) ::Spectator::Matchers::ArrayMatcher.new(%value)
{% end %} {% end %}
end end
@ -623,8 +636,8 @@ module Spectator
# expect([1, 2, 3]).to match_array([3, 2, 1]) # expect([1, 2, 3]).to match_array([3, 2, 1])
# ``` # ```
macro match_array(expected) macro match_array(expected)
%test_value = ::Spectator::TestValue.new(({{expected}}).to_a, {{expected.stringify}}) %value = ::Spectator::Value.new(({{expected}}).to_a, {{expected.stringify}})
::Spectator::Matchers::ArrayMatcher.new(%test_value) ::Spectator::Matchers::ArrayMatcher.new(%value)
end end
# Indicates that some set should have a specified size. # Indicates that some set should have a specified size.
@ -634,8 +647,8 @@ module Spectator
# expect([1, 2, 3]).to have_size(3) # expect([1, 2, 3]).to have_size(3)
# ``` # ```
macro have_size(expected) macro have_size(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::SizeMatcher.new(%test_value) ::Spectator::Matchers::SizeMatcher.new(%value)
end end
# Indicates that some set should have the same size (number of elements) as another set. # Indicates that some set should have the same size (number of elements) as another set.
@ -645,8 +658,8 @@ module Spectator
# expect([1, 2, 3]).to have_size_of(%i[x y z]) # expect([1, 2, 3]).to have_size_of(%i[x y z])
# ``` # ```
macro have_size_of(expected) macro have_size_of(expected)
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
::Spectator::Matchers::SizeOfMatcher.new(%test_value) ::Spectator::Matchers::SizeOfMatcher.new(%value)
end end
# Indicates that some value should have a set of attributes matching some conditions. # Indicates that some value should have a set of attributes matching some conditions.
@ -661,11 +674,11 @@ module Spectator
# ``` # ```
macro have_attributes(**expected) macro have_attributes(**expected)
{% if expected.id.starts_with?("{**") %} {% if expected.id.starts_with?("{**") %}
%test_value = ::Spectator::TestValue.new({{expected.id[3...-1]}}, {{expected.double_splat.stringify}}) %value = ::Spectator::Value.new({{expected.id[3...-1]}}, {{expected.double_splat.stringify}})
::Spectator::Matchers::AttributesMatcher.new(%test_value) ::Spectator::Matchers::AttributesMatcher.new(%value)
{% else %} {% else %}
%test_value = ::Spectator::TestValue.new({{expected}}, {{expected.double_splat.stringify}}) %value = ::Spectator::Value.new({{expected}}, {{expected.double_splat.stringify}})
::Spectator::Matchers::AttributesMatcher.new(%test_value) ::Spectator::Matchers::AttributesMatcher.new(%value)
{% end %} {% end %}
end end
@ -718,37 +731,18 @@ module Spectator
# expect { subject << :foo }.to change(&.size).by(1) # expect { subject << :foo }.to change(&.size).by(1)
# ``` # ```
macro change(&expression) macro change(&expression)
{% if expression.is_a?(Nop) %} {% if expression.args.size == 1 && expression.args[0] =~ /^__arg\d+$/ && expression.body.is_a?(Call) && expression.body.id =~ /^__arg\d+\./ %}
{% raise "Block must be provided to change matcher" %}
{% end %}
# Check if the short-hand method syntax is used.
# This is a hack, since macros don't get this as a "literal" or something similar.
# The Crystal compiler will translate:
# ```
# &.foo
# ```
# to:
# ```
# { |__arg0| __arg0.foo }
# ```
# The hack used here is to check if it looks like a compiler-generated block.
{% if expression.args == ["__arg0".id] && expression.body.is_a?(Call) && expression.body.id =~ /^__arg0\./ %}
# Extract the method name to make it clear to the user what is tested.
# The raw block can't be used because it's not clear to the user.
{% method_name = expression.body.id.split('.')[1..-1].join('.') %} {% method_name = expression.body.id.split('.')[1..-1].join('.') %}
%proc = ->{ subject.{{method_name.id}} } %block = ::Spectator::Block.new({{"#" + method_name}}) do
%test_block = ::Spectator::TestBlock.create(%proc, {{"#" + method_name}}) subject.{{method_name.id}}
end
{% elsif expression.args.empty? %} {% elsif expression.args.empty? %}
# In this case, it looks like the short-hand method syntax wasn't used. %block = ::Spectator::Block.new({{"`" + expression.body.stringify + "`"}}) {{expression}}
# Capture the block as a proc and pass along.
%proc = ->{{expression}}
%test_block = ::Spectator::TestBlock.create(%proc, {{"`" + expression.body.stringify + "`"}})
{% else %} {% else %}
{% raise "Unexpected block arguments in change matcher" %} {% raise "Unexpected block arguments in 'expect' call" %}
{% end %} {% end %}
::Spectator::Matchers::ChangeMatcher.new(%test_block) ::Spectator::Matchers::ChangeMatcher.new(%block)
end end
# Indicates that some block should raise an error. # Indicates that some block should raise an error.
@ -828,8 +822,8 @@ module Spectator
end end
macro have_received(method) macro have_received(method)
%test_value = ::Spectator::TestValue.new(({{method.id.symbolize}}), {{method.id.stringify}}) %value = ::Spectator::Value.new(({{method.id.symbolize}}), {{method.id.stringify}})
::Spectator::Matchers::ReceiveMatcher.new(%test_value) ::Spectator::Matchers::ReceiveMatcher.new(%value)
end end
# Used to create predicate matchers. # Used to create predicate matchers.
@ -872,8 +866,8 @@ module Spectator
{% end %} {% end %}
label << ')' label << ')'
{% end %} {% end %}
test_value = ::Spectator::TestValue.new(descriptor, label.to_s) value = ::Spectator::Value.new(descriptor, label.to_s)
::Spectator::Matchers::{{matcher.id}}.new(test_value) ::Spectator::Matchers::{{matcher.id}}.new(value)
end end
end end
end end

View file

@ -0,0 +1,107 @@
require "../lazy_wrapper"
module Spectator::DSL
# DSL methods for defining test values (subjects).
# These values are stored and reused throughout the test.
module Memoize
# Defines a memoized getter.
# The *name* is the name of the getter method.
# The block is evaluated only on the first time the getter is used
# and the return value is saved for subsequent calls.
macro let(name, &block)
{% raise "Missing block for 'let'" unless block %}
{% raise "Expected zero or one arguments for 'let', but got #{block.args.size}" if block.args.size > 1 %}
{% raise "Cannot use 'let' inside of an example block" if @def %}
{% raise "Cannot use '#{name.id}' for 'let'" if ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.id.symbolize) %}
@%value = ::Spectator::LazyWrapper.new
private def {{name.id}}
{% if block.args.size > 0 %}
{{block.args.first}} = ::Spectator::Example.current
{% end %}
@%value.get do
{{block.body}}
end
end
end
# Defines a memoized getter.
# The *name* is the name of the getter method.
# The block is evaluated once before the example runs
# and the return value is saved.
macro let!(name, &block)
{% raise "Missing block for 'let!'" unless block %}
{% raise "Expected zero or one arguments for 'let!', but got #{block.args.size}" if block.args.size > 1 %}
{% raise "Cannot use 'let!' inside of an example block" if @def %}
{% raise "Cannot use '#{name.id}' for 'let!'" if ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.id.symbolize) %}
let({{name}}) {{block}}
before_each { {{name.id}} }
end
# Explicitly defines the subject of the tests.
# Creates a memoized getter for the subject.
# The block is evaluated only the first time the subject is referenced
# and the return value is saved for subsequent calls.
macro subject(&block)
{% raise "Missing block for 'subject'" unless block %}
{% raise "Expected zero or one arguments for 'let!', but got #{block.args.size}" if block.args.size > 1 %}
{% raise "Cannot use 'subject' inside of an example block" if @def %}
let(subject) {{block}}
end
# Explicitly defines the subject of the tests.
# Creates a memoized getter for the subject.
# The subject can be referenced by using `subject` or *name*.
# The block is evaluated only the first time the subject is referenced
# and the return value is saved for subsequent calls.
macro subject(name, &block)
{% raise "Missing block for 'subject'" unless block %}
{% raise "Expected zero or one arguments for 'subject', but got #{block.args.size}" if block.args.size > 1 %}
{% raise "Cannot use 'subject' inside of an example block" if @def %}
{% raise "Cannot use '#{name.id}' for 'subject'" if ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.id.symbolize) %}
let({{name.id}}) {{block}}
{% if name.id != :subject.id %}
private def subject
{{name.id}}
end
{% end %}
end
# Explicitly defines the subject of the tests.
# Creates a memoized getter for the subject.
# The block is evaluated once before the example runs
# and the return value is saved for subsequent calls.
macro subject!(&block)
{% raise "Missing block for 'subject'" unless block %}
{% raise "Expected zero or one arguments for 'subject!', but got #{block.args.size}" if block.args.size > 1 %}
{% raise "Cannot use 'subject!' inside of an example block" if @def %}
let!(subject) {{block}}
end
# Explicitly defines the subject of the tests.
# Creates a memoized getter for the subject.
# The subject can be referenced by using `subject` or *name*.
# The block is evaluated once before the example runs
# and the return value is saved for subsequent calls.
macro subject!(name, &block)
{% raise "Missing block for 'subject'" unless block %}
{% raise "Expected zero or one arguments for 'subject!', but got #{block.args.size}" if block.args.size > 1 %}
{% raise "Cannot use 'subject!' inside of an example block" if @def %}
{% raise "Cannot use '#{name.id}' for 'subject!'" if ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.id.symbolize) %}
let!({{name.id}}) {{block}}
{% if name.id != :subject.id %}
private def subject
{{name.id}}
end
{% end %}
end
end
end

View file

@ -0,0 +1,26 @@
module Spectator::DSL
module Metadata
# Defines a class method named *name* that combines metadata
# returned by *source* with *tags* and *metadata*.
# Any falsey items from *metadata* are removed.
private macro _spectator_metadata(name, source, *tags, **metadata)
private def self.{{name.id}}
%metadata = {{source.id}}.dup
{% for k in tags %}
%metadata[{{k.id.symbolize}}] = nil
{% end %}
{% for k, v in metadata %}
%cond = begin
{{v}}
end
if %cond
%metadata[{{k.id.symbolize}}] = %cond.to_s
else
%metadata.delete({{k.id.symbolize}})
end
{% end %}
%metadata
end
end
end
end

View file

@ -1,185 +1,183 @@
require "../mocks" require "../mocks"
module Spectator::DSL module Spectator::DSL
macro double(name = "Anonymous", **stubs, &block) module Mocks
{% if name.is_a?(StringLiteral) || name.is_a?(StringInterpolation) %} macro double(name = "Anonymous", **stubs, &block)
anonymous_double({{name}}, {{stubs.double_splat}}) {% if name.is_a?(StringLiteral) || name.is_a?(StringInterpolation) %}
{% else %} anonymous_double({{name}}, {{stubs.double_splat}})
{%
safe_name = name.id.symbolize.gsub(/\W/, "_").id
type_name = "Double#{safe_name}".id
%}
{% if block %}
define_double({{type_name}}, {{name}}, {{stubs.double_splat}}) {{block}}
{% else %} {% else %}
create_double({{type_name}}, {{name}}, {{stubs.double_splat}}) {%
{% end %} safe_name = name.id.symbolize.gsub(/\W/, "_").id
{% end %} type_name = "Double#{safe_name}".id
end %}
macro create_double(type_name, name, **stubs) {% if block %}
{% if type_name.resolve? %} define_double({{type_name}}, {{name}}, {{stubs.double_splat}}) {{block}}
{{type_name}}.new.tap do |%double| {% else %}
{% for name, value in stubs %} create_double({{type_name}}, {{name}}, {{stubs.double_splat}})
allow(%double).to receive({{name.id}}).and_return({{value}})
{% end %} {% end %}
end
{% elsif @def %}
anonymous_double({{name ? name.stringify : "Anonymous"}}, {{stubs.double_splat}})
{% else %}
{% raise "Block required for double definition" %}
{% end %}
end
macro define_double(type_name, name, **stubs, &block)
{% begin %}
{% if (name.is_a?(Path) || name.is_a?(Generic)) && (resolved = name.resolve?) %}
verify_double({{name}})
class {{type_name}} < ::Spectator::Mocks::VerifyingDouble(::{{resolved.id}})
{% else %}
class {{type_name}} < ::Spectator::Mocks::Double
def initialize(null = false)
super({{name.id.stringify}}, null)
end
{% end %} {% end %}
def as_null_object
{{type_name}}.new(true)
end
# TODO: Do something with **stubs?
{{block.body}}
end end
{% end %}
end
def anonymous_double(name = "Anonymous", **stubs) macro create_double(type_name, name, **stubs)
Mocks::AnonymousDouble.new(name, stubs) {% if type_name.resolve? %}
end {{type_name}}.new.tap do |%double|
{% for name, value in stubs %}
macro null_double(name, **stubs, &block) allow(%double).to receive({{name.id}}).and_return({{value}})
{% if name.is_a?(StringLiteral) || name.is_a?(StringInterpolation) %} {% end %}
anonymous_null_double({{name}}, {{stubs.double_splat}}) end
{% else %} {% elsif @def %}
{% anonymous_double({{name ? name.stringify : "Anonymous"}}, {{stubs.double_splat}})
safe_name = name.id.symbolize.gsub(/\W/, "_").id
type_name = "Double#{safe_name}".id
%}
{% if block %}
define_null_double({{type_name}}, {{name}}, {{stubs.double_splat}}) {{block}}
{% else %} {% else %}
create_null_double({{type_name}}, {{name}}, {{stubs.double_splat}}) {% raise "Block required for double definition" %}
{% end %} {% end %}
{% end %} end
end
macro define_double(type_name, name, **stubs, &block)
{% begin %}
{% if (name.is_a?(Path) || name.is_a?(Generic)) && (resolved = name.resolve?) %}
verify_double({{name}})
class {{type_name}} < ::Spectator::Mocks::VerifyingDouble(::{{resolved.id}})
{% else %}
class {{type_name}} < ::Spectator::Mocks::Double
def initialize(null = false)
super({{name.id.stringify}}, null)
end
{% end %}
def as_null_object
{{type_name}}.new(true)
end
# TODO: Do something with **stubs?
{{block.body}}
end
{% end %}
end
def anonymous_double(name = "Anonymous", **stubs)
::Spectator::Mocks::AnonymousDouble.new(name, stubs)
end
macro null_double(name, **stubs, &block)
{% if name.is_a?(StringLiteral) || name.is_a?(StringInterpolation) %}
anonymous_null_double({{name}}, {{stubs.double_splat}})
{% else %}
{%
safe_name = name.id.symbolize.gsub(/\W/, "_").id
type_name = "Double#{safe_name}".id
%}
{% if block.is_a?(Nop) %}
create_null_double({{type_name}}, {{name}}, {{stubs.double_splat}})
{% else %}
define_null_double({{type_name}}, {{name}}, {{stubs.double_splat}}) {{block}}
{% end %}
{% end %}
end
macro create_null_double(type_name, name, **stubs)
{% type_name.resolve? || raise("Could not find a double labeled #{name}") %}
macro create_null_double(type_name, name, **stubs)
{% if type_name.resolve? %}
{{type_name}}.new(true).tap do |%double| {{type_name}}.new(true).tap do |%double|
{% for name, value in stubs %} {% for name, value in stubs %}
allow(%double).to receive({{name.id}}).and_return({{value}}) allow(%double).to receive({{name.id}}).and_return({{value}})
{% end %} {% end %}
end end
{% elsif @def %}
anonymous_null_double({{name ? name.stringify : "Anonymous"}}, {{stubs.double_splat}})
{% else %}
{% raise "Block required for double definition" %}
{% end %}
end
macro define_null_double(type_name, name, **stubs, &block)
class {{type_name}} < ::Spectator::Mocks::Double
def initialize(null = true)
super({{name.id.stringify}}, null)
end
def as_null_object
{{type_name}}.new(true)
end
# TODO: Do something with **stubs?
{{block.body}}
end end
end
def anonymous_null_double(name = "Anonymous", **stubs) macro define_null_double(type_name, name, **stubs, &block)
AnonymousNullDouble.new(name, stubs) class {{type_name}} < ::Spectator::Mocks::Double
end def initialize(null = true)
super({{name.id.stringify}}, null)
end
macro mock(name, &block) def as_null_object
{% resolved = name.resolve {{type_name}}.new(true)
type = if resolved < Reference end
:class
elsif resolved < Value # TODO: Do something with **stubs?
:struct
else
:module
end %}
{% begin %}
{{type.id}} ::{{resolved.id}}
include ::Spectator::Mocks::Stubs
{{block.body}} {{block.body}}
end end
{% end %} end
end
macro verify_double(name, &block) def anonymous_null_double(name = "Anonymous", **stubs)
{% resolved = name.resolve ::Spectator::Mocks::AnonymousNullDouble.new(name, stubs)
type = if resolved < Reference end
:class
elsif resolved < Value
:struct
else
:module
end %}
{% begin %}
{{type.id}} ::{{resolved.id}}
include ::Spectator::Mocks::Reflection
macro finished macro mock(name, &block)
_spectator_reflect {% resolved = name.resolve
type = if resolved < Reference
:class
elsif resolved < Value
:struct
else
:module
end %}
{% begin %}
{{type.id}} ::{{resolved.id}}
include ::Spectator::Mocks::Stubs
{{block.body}}
end end
end {% end %}
{% end %} end
end
def allow(thing) macro verify_double(name, &block)
Mocks::Allow.new(thing) {% resolved = name.resolve
end type = if resolved < Reference
:class
elsif resolved < Value
:struct
else
:module
end %}
{% begin %}
{{type.id}} ::{{resolved.id}}
include ::Spectator::Mocks::Reflection
def allow_any_instance_of(type : T.class) forall T macro finished
Mocks::AllowAnyInstance(T).new _spectator_reflect
end end
end
{% end %}
end
macro expect_any_instance_of(type, _source_file = __FILE__, _source_line = __LINE__) def allow(thing)
%source = ::Spectator::Source.new({{_source_file}}, {{_source_line}}) ::Spectator::Mocks::Allow.new(thing)
::Spectator::Mocks::ExpectAnyInstance({{type}}).new(%source) end
end
macro receive(method_name, _source_file = __FILE__, _source_line = __LINE__, &block) def allow_any_instance_of(type : T.class) forall T
%source = ::Spectator::Source.new({{_source_file}}, {{_source_line}}) ::Spectator::Mocks::AllowAnyInstance(T).new
{% if block %} end
::Spectator::Mocks::ProcMethodStub.create({{method_name.id.symbolize}}, %source) { {{block.body}} }
{% else %}
::Spectator::Mocks::NilMethodStub.new({{method_name.id.symbolize}}, %source)
{% end %}
end
macro receive_messages(_source_file = __FILE__, _source_line = __LINE__, **stubs) macro expect_any_instance_of(type, _source_file = __FILE__, _source_line = __LINE__)
%source = ::Spectator::Source.new({{_source_file}}, {{_source_line}}) %location = ::Spectator::Location.new({{_source_file}}, {{_source_line}})
%stubs = [] of ::Spectator::Mocks::MethodStub ::Spectator::Mocks::ExpectAnyInstance({{type}}).new(%location)
{% for name, value in stubs %} end
%stubs << ::Spectator::Mocks::ValueMethodStub.new({{name.id.symbolize}}, %source, {{value}})
{% end %}
%stubs
end
def no_args macro receive(method_name, _source_file = __FILE__, _source_line = __LINE__, &block)
::Spectator::Mocks::NoArguments.new %location = ::Spectator::Location.new({{_source_file}}, {{_source_line}})
{% if block %}
::Spectator::Mocks::ProcMethodStub.create({{method_name.id.symbolize}}, %location) { {{block.body}} }
{% else %}
::Spectator::Mocks::NilMethodStub.new({{method_name.id.symbolize}}, %location)
{% end %}
end
macro receive_messages(_source_file = __FILE__, _source_line = __LINE__, **stubs)
%location = ::Spectator::Location.new({{_source_file}}, {{_source_line}})
%stubs = [] of ::Spectator::Mocks::MethodStub
{% for name, value in stubs %}
%stubs << ::Spectator::Mocks::ValueMethodStub.new({{name.id.symbolize}}, %location, {{value}})
{% end %}
%stubs
end
def no_args
::Spectator::Mocks::NoArguments.new
end
end end
end end

32
src/spectator/dsl/top.cr Normal file
View file

@ -0,0 +1,32 @@
require "./groups"
module Spectator::DSL
module Top
{% for method in %i[example_group describe context] %}
# Top-level describe method.
# All specs in a file must be wrapped in this call.
# This takes an argument and a block.
# The argument is what your spec is describing.
# It can be any Crystal expression,
# but is typically a class name or feature string.
# The block should contain all of the examples for what is being described.
#
# Example:
# ```
# Spectator.describe Foo do
# # Your examples for `Foo` go here.
# end
# ```
#
# Tags can be specified by adding symbols (keywords) after the first argument.
# Key-value pairs can also be specified.
#
# NOTE: Inside the block, the `Spectator` prefix _should not_ be used.
macro {{method.id}}(description, *tags, **metadata, &block)
class ::SpectatorTestContext
{{method.id}}(\{{description}}, \{{tags.splat(", ")}} \{{metadata.double_splat}}) \{{block}}
end
end
{% end %}
end
end

View file

@ -1,65 +0,0 @@
module Spectator
module DSL
macro let(name, &block)
@%wrapper : ::Spectator::ValueWrapper?
def {{name.id}}
{{block.body}}
end
def {{name.id}}
if (wrapper = @%wrapper)
wrapper.as(::Spectator::TypedValueWrapper(typeof(previous_def))).value
else
previous_def.tap do |value|
@%wrapper = ::Spectator::TypedValueWrapper.new(value)
end
end
end
end
macro let!(name, &block)
@%wrapper : ::Spectator::ValueWrapper?
def %wrapper
{{block.body}}
end
before_each do
@%wrapper = ::Spectator::TypedValueWrapper.new(%wrapper)
end
def {{name.id}}
@%wrapper.as(::Spectator::TypedValueWrapper(typeof(%wrapper))).value
end
end
macro subject(&block)
{% if block.is_a?(Nop) %}
self.subject
{% else %}
let(:subject) {{block}}
{% end %}
end
macro subject(name, &block)
let({{name.id}}) {{block}}
def subject
{{name.id}}
end
end
macro subject!(&block)
let!(:subject) {{block}}
end
macro subject!(name, &block)
let!({{name.id}}) {{block}}
def subject
{{name.id}}
end
end
end
end

View file

@ -0,0 +1,28 @@
require "./fail_result"
module Spectator
# Outcome that indicates running an example generated an error.
# This occurs when an unexpected exception was raised while running an example.
# This is different from a "failed" result in that the error was not from a failed assertion.
class ErrorResult < FailResult
# Calls the `error` method on *visitor*.
def accept(visitor)
visitor.error(self)
end
# Calls the `error` method on *visitor*.
def accept(visitor)
visitor.error(yield self)
end
# One-word description of the result.
def to_s(io)
io << "error"
end
# String used for the JSON status field.
private def json_status
"error"
end
end
end

View file

@ -1,50 +0,0 @@
require "./failed_result"
module Spectator
# Outcome that indicates running an example generated an error.
# This type of result occurs when an exception was raised.
# This is different from a "failed" result
# in that the error was not from a failed expectation.
class ErroredResult < FailedResult
# Calls the `error` method on *interface*.
def call(interface)
interface.error
end
# Calls the `error` method on *interface*
# and passes the yielded value.
def call(interface)
value = yield self
interface.error(value)
end
# One-word descriptor of the result.
def to_s(io)
io << "error"
end
# Adds the common JSON fields for all result types
# and fields specific to errored results.
private def add_json_fields(json : ::JSON::Builder)
super
json.field("exceptions") do
exception = error
json.array do
while exception
error_to_json(exception, json) if exception
exception = error.cause
end
end
end
end
# Adds a single exception to a JSON builder.
private def error_to_json(error : Exception, json : ::JSON::Builder)
json.object do
json.field("type", error.class.to_s)
json.field("message", error.message)
json.field("backtrace", error.backtrace)
end
end
end
end

View file

@ -1,83 +1,259 @@
require "./example_component" require "./example_context_delegate"
require "./test_wrapper" require "./example_group"
require "./harness"
require "./location"
require "./node"
require "./pending_result"
require "./result"
require "./metadata"
module Spectator module Spectator
# Base class for all types of examples. # Standard example that runs a test case.
# Concrete types must implement the `#run_impl` method. class Example < Node
abstract class Example < ExampleComponent # Currently running example.
@finished = false class_getter! current : Example
@description : String? = nil
protected setter description # Group the node belongs to.
getter! group : ExampleGroup
# Indicates whether the example has already been run. # Assigns the node to the specified *group*.
def finished? : Bool # This is an internal method and should only be called from `ExampleGroup`.
@finished # `ExampleGroup` manages the association of nodes to groups.
protected setter group : ExampleGroup?
# Indicates whether the example already ran.
getter? finished : Bool = false
# Result of the last time the example ran.
# Is pending if the example hasn't run.
getter result : Result = PendingResult.new("Example not run")
# Creates the example.
# An instance to run the test code in is given by *context*.
# The *entrypoint* defines the test code (typically inside *context*).
# The *name* describes the purpose of the example.
# It can be a `Symbol` to describe a type.
# The *location* tracks where the example exists in source code.
# The example will be assigned to *group* if it is provided.
# A set of *metadata* can be used for filtering and modifying example behavior.
# Note: The metadata will not be merged with the parent metadata.
def initialize(@context : Context, @entrypoint : self ->,
name : String? = nil, location : Location? = nil,
@group : ExampleGroup? = nil, metadata = Metadata.new)
super(name, location, metadata)
# Ensure group is linked.
group << self if group
end end
# Group that the example belongs to. # Creates a dynamic example.
getter group : ExampleGroup # A block provided to this method will be called as-if it were the test code for the example.
# The block will be given this example instance as an argument.
# The *name* describes the purpose of the example.
# It can be a `Symbol` to describe a type.
# The *location* tracks where the example exists in source code.
# The example will be assigned to *group* if it is provided.
# A set of *metadata* can be used for filtering and modifying example behavior.
# Note: The metadata will not be merged with the parent metadata.
def initialize(name : String? = nil, location : Location? = nil,
@group : ExampleGroup? = nil, metadata = Metadata.new, &block : self ->)
super(name, location, metadata)
# Retrieves the internal wrapped instance. @context = NullContext.new
protected getter test_wrapper : TestWrapper @entrypoint = block
# Source where the example originated from. # Ensure group is linked.
def source : Source group << self if group
@test_wrapper.source
end end
def description : String | Symbol # Creates a pending example.
@description || @test_wrapper.description # The *name* describes the purpose of the example.
# It can be a `Symbol` to describe a type.
# The *location* tracks where the example exists in source code.
# The example will be assigned to *group* if it is provided.
# A set of *metadata* can be used for filtering and modifying example behavior.
# Note: The metadata will not be merged with the parent metadata.
def self.pending(name : String? = nil, location : Location? = nil,
group : ExampleGroup? = nil, metadata = Metadata.new, reason = nil)
# Add pending tag and reason if they don't exist.
metadata = metadata.merge({:pending => nil, :reason => reason}) { |_, v, _| v }
new(name, location, group, metadata) { nil }
end end
def symbolic? : Bool # Executes the test case.
return false unless @test_wrapper.description? # Returns the result of the execution.
# The result will also be stored in `#result`.
description = @test_wrapper.description
description.starts_with?('#') || description.starts_with?('.')
end
abstract def run_impl
# Runs the example code.
# A result is returned, which represents the outcome of the test.
# An example can be run only once.
# An exception is raised if an attempt is made to run it more than once.
def run : Result def run : Result
raise "Attempted to run example more than once (#{self})" if finished? Log.debug { "Running example #{self}" }
run_impl Log.warn { "Example #{self} already ran" } if @finished
ensure
if pending?
Log.debug { "Skipping example #{self} - marked pending" }
@finished = true
return @result = PendingResult.new(pending_reason)
end
previous_example = @@current
@@current = self
begin
@result = Harness.run do
@group.try(&.call_before_all)
if (parent = @group)
parent.call_around_each(procsy).call
else
run_internal
end
if (parent = @group)
parent.call_after_all if parent.finished?
end
end
ensure
@@current = previous_example
@finished = true
end
end
private def run_internal
@group.try(&.call_before_each(self))
@entrypoint.call(self)
@finished = true @finished = true
@group.try(&.call_after_each(self))
end end
# Creates the base of the example. # Executes code within the example's test context.
# The group should be the example group the example belongs to. # This is an advanced method intended for internal usage only.
def initialize(@group, @test_wrapper) #
# The *klass* defines the type of the test context.
# This is typically only known by the code constructing the example.
# An error will be raised if *klass* doesn't match the test context's type.
# The block given to this method will be executed within the test context.
#
# The context casted to an instance of *klass* is provided as a block argument.
#
# TODO: Benchmark compiler performance using this method versus client-side casting in a proc.
protected def with_context(klass)
context = klass.cast(@context)
with context yield
end end
# Indicates there is only one example to run. # Casts the example's test context to a specific type.
def example_count : Int # This is an advanced method intended for internal usage only.
1 #
# The *klass* defines the type of the test context.
# This is typically only known by the code constructing the example.
# An error will be raised if *klass* doesn't match the test context's type.
#
# The context casted to an instance of *klass* is returned.
#
# TODO: Benchmark compiler performance using this method versus client-side casting in a proc.
protected def cast_context(klass)
klass.cast(@context)
end end
# Retrieve the current example. # Yields this example and all parent groups.
def [](index : Int) : Example def ascend
self node = self
while node
yield node
node = node.group?
end
end end
# String representation of the example. # Constructs the full name or description of the example.
# This consists of the groups the example is in and the description. # This prepends names of groups this example is part of.
# The string can be given to end-users to identify the example.
def to_s(io) def to_s(io)
@group.to_s(io) name = @name
io << ' ' unless symbolic? && @group.symbolic?
io << description # Prefix with group's full name if the node belongs to a group.
if (parent = @group)
parent.to_s(io)
# Add padding between the node names
# only if the names don't appear to be symbolic.
# Skip blank group names (like the root group).
io << ' ' unless !parent.name? || # ameba:disable Style/NegatedConditionsInUnless
(parent.name?.is_a?(Symbol) && name.is_a?(String) &&
(name.starts_with?('#') || name.starts_with?('.')))
end
super
end
# Exposes information about the example useful for debugging.
def inspect(io)
super
io << ' ' << result
end end
# Creates the JSON representation of the example, # Creates the JSON representation of the example,
# which is just its name. # which is just its name.
def to_json(json : ::JSON::Builder) def to_json(json : JSON::Builder)
json.string(to_s) json.object do
json.field("description", name? || "<anonymous>")
json.field("full_description", to_s)
if location = location?
json.field("file_path", location.path)
json.field("line_number", location.line)
end
@result.to_json(json) if @finished
end
end
# Creates a procsy from this example that runs the example.
def procsy
Procsy.new(self) { run_internal }
end
# Creates a procsy from this example and the provided block.
def procsy(&block : ->)
Procsy.new(self, &block)
end
# Wraps an example to behave like a `Proc`.
# This is typically used for an *around_each* hook.
# Invoking `#call` or `#run` will run the example.
struct Procsy
# Underlying example that will run.
getter example : Example
# Creates the example proxy.
# The *example* should be run eventually.
# The *proc* defines the block of code to run when `#call` or `#run` is invoked.
def initialize(@example : Example, &@proc : ->)
end
# Invokes the proc.
def call : Nil
@proc.call
end
# Invokes the proc.
def run : Nil
@proc.call
end
# Creates a new procsy for a block and the example from this instance.
def wrap(&block : ->) : self
self.class.new(@example, &block)
end
# Executes code within the example's test context.
# This is an advanced method intended for internal usage only.
#
# The *klass* defines the type of the test context.
# This is typically only known by the code constructing the example.
# An error will be raised if *klass* doesn't match the test context's type.
# The block given to this method will be executed within the test context.
#
# TODO: Benchmark compiler performance using this method versus client-side casting in a proc.
protected def with_context(klass)
context = @example.cast_context(klass)
with context yield
end
# Allow instance to behave like an example.
forward_missing_to @example
end end
end end
end end

View file

@ -0,0 +1,27 @@
require "./context"
require "./example"
require "./location"
require "./metadata"
require "./node_builder"
module Spectator
# Constructs examples.
# Call `#build` to produce an `Example`.
class ExampleBuilder < NodeBuilder
# Creates the builder.
# A proc provided by *context_builder* is used to create a unique `Context` for each example produced by `#build`.
# The *entrypoint* indicates the proc used to invoke the test code in the example.
# The *name*, *location*, and *metadata* will be applied to the `Example` produced by `#build`.
def initialize(@context_builder : -> Context, @entrypoint : Example ->,
@name : String? = nil, @location : Location? = nil, @metadata : Metadata = Metadata.new)
end
# Constructs an example with previously defined attributes and context.
# The *parent* is an already constructed example group to nest the new example under.
# It can be nil if the new example won't have a parent.
def build(parent = nil)
context = @context_builder.call
Example.new(context, @entrypoint, @name, @location, parent, @metadata)
end
end
end

View file

@ -1,26 +0,0 @@
module Spectator
# Abstract base for all examples and collections of examples.
# This is used as the base node type for the composite design pattern.
abstract class ExampleComponent
# Text that describes the context or test.
abstract def description : Symbol | String
def full_description
to_s
end
abstract def source : Source
# Indicates whether the example (or group) has been completely run.
abstract def finished? : Bool
# The number of examples in this instance.
abstract def example_count : Int
# Lookup the example with the specified index.
abstract def [](index : Int) : Example
# Indicates that the component references a type or method.
abstract def symbolic? : Bool
end
end

View file

@ -1,41 +0,0 @@
module Spectator
# Collection of checks that run before and after tests.
# The pre-conditions can be used to verify
# that the SUT is in an expected state prior to testing.
# The post-conditions can be used to verify
# that the SUT is in an expected state after tests have finished.
# Each check is just a `Proc` (code block) that runs when invoked.
class ExampleConditions
# Creates an empty set of conditions.
# This will effectively run nothing extra while running a test.
def self.empty
new(
[] of TestMetaMethod,
[] of TestMetaMethod
)
end
# Creates a new set of conditions.
def initialize(
@pre_conditions : Array(TestMetaMethod),
@post_conditions : Array(TestMetaMethod)
)
end
# Runs all pre-condition checks.
# These should be run before every test.
def run_pre_conditions(wrapper : TestWrapper, example : Example)
@pre_conditions.each do |hook|
wrapper.call(hook, example)
end
end
# Runs all post-condition checks.
# These should be run after every test.
def run_post_conditions(wrapper : TestWrapper, example : Example)
@post_conditions.each do |hook|
wrapper.call(hook, example)
end
end
end
end

View file

@ -0,0 +1,33 @@
require "./context"
require "./example_context_method"
require "./null_context"
module Spectator
# Stores a test context and a method to call within it.
# This is a variant of `ContextDelegate` that accepts the current running example.
struct ExampleContextDelegate
# Retrieves the underlying context.
protected getter context : Context
# Creates the delegate.
# The *context* is the instance of the test context.
# The *method* is proc that downcasts *context* and calls a method on it.
def initialize(@context : Context, @method : ExampleContextMethod)
end
# Creates a delegate with a null context.
# The context will be ignored and the block will be executed in its original scope.
# The example instance will be provided as an argument to the block.
def self.null(&block : Example -> _)
context = NullContext.new
method = ExampleContextMethod.new { |example| block.call(example) }
new(context, method)
end
# Invokes a method in the test context.
# The *example* is the current running example.
def call(example : Example)
@method.call(example, @context)
end
end
end

View file

@ -0,0 +1,10 @@
require "./context"
module Spectator
# Encapsulates a method in a test context.
# This could be used to invoke a test case or hook method.
# The context is passed as an argument.
# The proc should downcast the context instance to the desired type and call a method on that context.
# The current example is also passed as an argument.
alias ExampleContextMethod = Example, Context ->
end

View file

@ -1,5 +1,14 @@
require "./location"
module Spectator module Spectator
# Exception that indicates an example failed and should abort. # Exception that indicates an example failed.
# When raised within a test, the test should abort.
class ExampleFailed < Exception class ExampleFailed < Exception
getter! location : Location
# Creates the exception.
def initialize(@location : Location?, message : String? = nil, cause : Exception? = nil)
super(message, cause)
end
end end
end end

View file

@ -1,9 +0,0 @@
module Spectator
# Base class for all example filters.
# Checks whether an example should be run.
# Sub-classes must implement the `#includes?` method.
abstract class ExampleFilter
# Checks if an example is in the filter, and should be run.
abstract def includes?(example : Example) : Bool
end
end

View file

@ -1,131 +1,133 @@
require "./example_component" require "./example_procsy_hook"
require "./hooks"
require "./node"
module Spectator module Spectator
# Shared base class for groups of examples. # Collection of examples and sub-groups.
# class ExampleGroup < Node
# Represents a collection of examples and other groups. include Hooks
# Use the `#each` methods to iterate through each child. include Indexable(Node)
# However, these methods do not recurse into sub-groups.
# If you need that functionality, see `ExampleIterator`.
# Additionally, the indexer method (`#[]`) will index into sub-groups.
#
# This class also stores hooks to be associated with all examples in the group.
# The hooks can be invoked by running the `#run_before_hooks` and `#run_after_hooks` methods.
abstract class ExampleGroup < ExampleComponent
include Enumerable(ExampleComponent)
include Iterable(ExampleComponent)
@example_count = 0 @nodes = [] of Node
# Retrieves the children in the group. # Parent group this group belongs to.
# This only returns the direct descends (non-recursive). getter! group : ExampleGroup
# The children must be set (with `#children=`) prior to calling this method.
getter! children : Array(ExampleComponent)
# Sets the children of the group. # Assigns this group to the specified *group*.
# This should be called only from a builder in the `DSL` namespace. # This is an internal method and should only be called from `ExampleGroup`.
# The children can be set only once - # `ExampleGroup` manages the association of nodes to groups.
# attempting to set more than once will raise an error. protected setter group : ExampleGroup?
# All sub-groups' children should be set before setting this group's children.
def children=(children : Array(ExampleComponent)) define_hook before_all : ExampleGroupHook do
raise "Attempted to reset example group children" if @children Log.trace { "Processing before_all hooks for #{self}" }
@children = children
# Recursively count the number of examples. @group.try &.call_before_all
# This won't work if a sub-group hasn't had their children set (is still nil). before_all_hooks.each &.call_once
@example_count = children.sum(&.example_count)
end end
def double(id, sample_values) define_hook after_all : ExampleGroupHook, :prepend do
@doubles[id].build(sample_values) Log.trace { "Processing after_all hooks for #{self}" }
end
getter context after_all_hooks.each &.call_once if finished?
if group = @group
def initialize(@context : TestContext) group.call_after_all if group.finished?
end
# Yields each direct descendant.
def each
children.each do |child|
yield child
end end
end end
# Returns an iterator for each direct descendant. define_hook before_each : ExampleHook do |example|
def each : Iterator(ExampleComponent) Log.trace { "Processing before_each hooks for #{self}" }
children.each
@group.try &.call_before_each(example)
before_each_hooks.each &.call(example)
end end
# Number of examples in this group and all sub-groups. define_hook after_each : ExampleHook, :prepend do |example|
def example_count : Int Log.trace { "Processing after_each hooks for #{self}" }
@example_count
after_each_hooks.each &.call(example)
@group.try &.call_after_each(example)
end end
# Retrieves an example by its index. define_hook around_each : ExampleProcsyHook do |procsy|
# This recursively searches for an example. Log.trace { "Processing around_each hooks for #{self}" }
#
# Positive and negative indices can be used. around_each_hooks.reverse_each { |hook| procsy = hook.wrap(procsy) }
# Any value out of range will raise an `IndexError`. if group = @group
# procsy = group.call_around_each(procsy)
# Examples are indexed as if they are in a flattened tree. end
# For instance: procsy
# ```
# examples = [0, 1, [2, 3, 4], [5, [6, 7], 8], 9, [10]].flatten
# ```
# The arrays symbolize groups,
# and the numbers are the index of the example in that slot.
def [](index : Int) : Example
offset = check_bounds(index)
find_nested(offset)
end end
# Checks whether an index is within acceptable bounds. # Creates the example group.
# If the index is negative, # The *name* describes the purpose of the group.
# it will be converted to its positive equivalent. # It can be a `Symbol` to describe a type.
# If the index is out of bounds, an `IndexError` is raised. # The *location* tracks where the group exists in source code.
# If the index is in bounds, # This group will be assigned to the parent *group* if it is provided.
# the positive index is returned. # A set of *metadata* can be used for filtering and modifying example behavior.
private def check_bounds(index) def initialize(@name : Label = nil, @location : Location? = nil,
if index < 0 @group : ExampleGroup? = nil, @metadata : Metadata = Metadata.new)
raise IndexError.new if index < -example_count # Ensure group is linked.
index + example_count group << self if group
else end
raise IndexError.new if index >= example_count
index delegate size, unsafe_fetch, to: @nodes
# Yields this group and all parent groups.
def ascend
group = self
while group
yield group
group = group.group?
end end
end end
# Finds the example with the specified index in the children. # Removes the specified *node* from the group.
# The *index* must be positive and within bounds (use `#check_bounds`). # The node will be unassigned from this group.
private def find_nested(index) def delete(node : Node)
offset = index # Only remove from the group if it is associated with this group.
# Loop through each child return unless node.group == self
# until one is found to contain the index.
found = children.each do |child| node.group = nil
count = child.example_count @nodes.delete(node)
# Example groups consider their range to be [0, example_count).
# Each child is offset by the total example count of the previous children.
# The group exposes them in this way:
# 1. [0, example_count of group 1)
# 2. [example_count of group 1, example_count of group 2)
# 3. [example_count of group n, example_count of group n + 1)
# To iterate through children, the offset is tracked.
# Each iteration removes the previous child's count.
# This way the child receives the expected range.
break child if offset < count
offset -= count
end
# The remaining offset is passed along to the child.
# If it's an `Example`, it returns itself.
# Otherwise, the indexer repeats the process for the next child.
# It should be impossible to get nil here,
# provided the bounds check and example counts are correct.
found.not_nil![offset]
end end
# Checks whether all examples in the group have been run. # Checks if all examples and sub-groups have finished.
def finished? : Bool def finished? : Bool
children.all?(&.finished?) @nodes.all?(&.finished?)
end
# Constructs the full name or description of the example group.
# This prepends names of groups this group is part of.
def to_s(io)
# Prefix with group's full name if the node belongs to a group.
return unless parent = @group
parent.to_s(io)
name = @name
# Add padding between the node names
# only if the names don't appear to be symbolic.
# Skip blank group names (like the root group).
io << ' ' unless !parent.name? || # ameba:disable Style/NegatedConditionsInUnless
(parent.name?.is_a?(Symbol) && name.is_a?(String) &&
(name.starts_with?('#') || name.starts_with?('.')))
super
end
# Adds the specified *node* to the group.
# Assigns the node to this group.
# If the node already belongs to a group,
# it will be removed from the previous group before adding it to this group.
def <<(node : Node)
# Remove from existing group if the node is part of one.
if (previous = node.group?)
previous.delete(node)
end
# Add the node to this group and associate with it.
@nodes << node
node.group = self
end end
end end
end end

View file

@ -0,0 +1,58 @@
require "./example_group"
require "./example_group_hook"
require "./example_hook"
require "./example_procsy_hook"
require "./hooks"
require "./label"
require "./location"
require "./metadata"
require "./node_builder"
module Spectator
# Progressively constructs an example group.
# Hooks and builders for child nodes can be added over time to this builder.
# When done, call `#build` to produce an `ExampleGroup`.
class ExampleGroupBuilder < NodeBuilder
include Hooks
define_hook before_all : ExampleGroupHook
define_hook after_all : ExampleGroupHook, :prepend
define_hook before_each : ExampleHook
define_hook after_each : ExampleHook, :prepend
define_hook around_each : ExampleProcsyHook
@children = [] of NodeBuilder
# Creates the builder.
# Initially, the builder will have no children and no hooks.
# The *name*, *location*, and *metadata* will be applied to the `ExampleGroup` produced by `#build`.
def initialize(@name : Label = nil, @location : Location? = nil, @metadata : Metadata = Metadata.new)
end
# Constructs an example group with previously defined attributes, children, and hooks.
# The *parent* is an already constructed example group to nest the new example group under.
# It can be nil if the new example group won't have a parent.
def build(parent = nil)
ExampleGroup.new(@name, @location, parent, @metadata).tap do |group|
apply_hooks(group)
@children.each(&.build(group))
end
end
# Adds a child builder to the group.
# The *builder* will have `NodeBuilder#build` called on it from within `#build`.
# The new example group will be passed to it.
def <<(builder)
@children << builder
end
# Adds all previously configured hooks to an example group.
private def apply_hooks(group)
before_all_hooks.each { |hook| group.before_all(hook) }
before_each_hooks.each { |hook| group.before_each(hook) }
after_all_hooks.reverse_each { |hook| group.after_all(hook) }
after_each_hooks.reverse_each { |hook| group.after_each(hook) }
around_each_hooks.each { |hook| group.around_each(hook) }
end
end
end

View file

@ -0,0 +1,57 @@
require "./label"
require "./location"
module Spectator
# Information about a hook tied to an example group and a proc to invoke it.
class ExampleGroupHook
# Location of the hook in source code.
getter! location : Location
# User-defined description of the hook.
getter! label : Label
@proc : ->
@called = Atomic::Flag.new
# Creates the hook with a proc.
# The *proc* will be called when the hook is invoked.
# A *location* and *label* can be provided for debugging.
def initialize(@proc : (->), *, @location : Location? = nil, @label : Label = nil)
end
# Creates the hook with a block.
# The block will be executed when the hook is invoked.
# A *location* and *label* can be provided for debugging.
def initialize(*, @location : Location? = nil, @label : Label = nil, &block : -> _)
@proc = block
end
# Invokes the hook.
def call : Nil
@called.test_and_set
@proc.call
end
# Invokes the hook if it hasn't already been invoked.
# Returns true if the hook was invoked (first time being called).
def call_once : Bool
first = @called.test_and_set
@proc.call if first
first
end
# Produces the string representation of the hook.
# Includes the location and label if they're not nil.
def to_s(io)
io << "example group hook"
if (label = @label)
io << ' ' << label
end
if (location = @location)
io << " @ " << location
end
end
end
end

View file

@ -0,0 +1,25 @@
require "./example_group"
require "./label"
require "./location"
require "./metadata"
module Spectator
# Collection of examples and sub-groups for a single iteration of an iterative example group.
class ExampleGroupIteration(T) < ExampleGroup
# Item for this iteration of the example groups.
getter item : T
# Creates the example group iteration.
# The element for the current iteration is provided by *item*.
# The *name* describes the purpose of the group.
# It can be a `Symbol` to describe a type.
# This is typically a stringified form of *item*.
# The *location* tracks where the group exists in source code.
# This group will be assigned to the parent *group* if it is provided.
# A set of *metadata* can be used for filtering and modifying example behavior.
def initialize(@item : T, name : Label = nil, location : Location? = nil,
group : ExampleGroup? = nil, metadata : Metadata = Metadata.new)
super(name, location, group, metadata)
end
end
end

View file

@ -0,0 +1,52 @@
require "./label"
require "./location"
module Spectator
# Information about a hook tied to an example and a proc to invoke it.
class ExampleHook
# Method signature for example hooks.
alias Proc = Example ->
# Location of the hook in source code.
getter! location : Location
# User-defined description of the hook.
getter! label : Label
@proc : Proc
# Creates the hook with a proc.
# The *proc* will be called when the hook is invoked.
# A *location* and *label* can be provided for debugging.
def initialize(@proc : Proc, *, @location : Location? = nil, @label : Label = nil)
end
# Creates the hook with a block.
# The block must take a single argument - the current example.
# The block will be executed when the hook is invoked.
# A *location* and *label* can be provided for debugging.
def initialize(*, @location : Location? = nil, @label : Label = nil, &block : Proc)
@proc = block
end
# Invokes the hook.
# The *example* refers to the current example.
def call(example : Example) : Nil
@proc.call(example)
end
# Produces the string representation of the hook.
# Includes the location and label if they're not nil.
def to_s(io)
io << "example hook"
if (label = @label)
io << ' ' << label
end
if (location = @location)
io << " @ " << location
end
end
end
end

View file

@ -1,75 +0,0 @@
module Spectator
alias TestMetaMethod = ::SpectatorTest, Example ->
# Collection of hooks that run at various times throughout testing.
# A hook is just a `Proc` (code block) that runs at a specified time.
class ExampleHooks
# Creates an empty set of hooks.
# This will effectively run nothing extra while running a test.
def self.empty
new(
[] of ->,
[] of TestMetaMethod,
[] of ->,
[] of TestMetaMethod,
[] of ::SpectatorTest, Proc(Nil) ->
)
end
# Creates a new set of hooks.
def initialize(
@before_all : Array(->),
@before_each : Array(TestMetaMethod),
@after_all : Array(->),
@after_each : Array(TestMetaMethod),
@around_each : Array(::SpectatorTest, Proc(Nil) ->)
)
end
# Runs all "before-all" hooks.
# These hooks should be run once before all examples in the group start.
def run_before_all
@before_all.each &.call
end
# Runs all "before-each" hooks.
# These hooks should be run every time before each example in a group.
def run_before_each(wrapper : TestWrapper, example : Example)
@before_each.each do |hook|
wrapper.call(hook, example)
end
end
# Runs all "after-all" hooks.
# These hooks should be run once after all examples in group finish.
def run_after_all
@after_all.each &.call
end
# Runs all "after-all" hooks.
# These hooks should be run every time after each example in a group.
def run_after_each(wrapper : TestWrapper, example : Example)
@after_each.each do |hook|
wrapper.call(hook, example)
end
end
# Creates a proc that runs the "around-each" hooks
# in addition to a block passed to this method.
# To call the block and all "around-each" hooks,
# just invoke `Proc#call` on the returned proc.
def wrap_around_each(test, block : ->)
wrapper = block
# Must wrap in reverse order,
# otherwise hooks will run in the wrong order.
@around_each.reverse_each do |hook|
wrapper = wrap_foo(test, hook, wrapper)
end
wrapper
end
private def wrap_foo(test, hook, wrapper)
->{ hook.call(test, wrapper) }
end
end
end

View file

@ -1,17 +1,19 @@
require "./example"
require "./node"
module Spectator module Spectator
# Iterates through all examples in a group and its nested groups. # Iterates through all examples in a group and its nested groups.
# Nodes are iterated in pre-order.
class ExampleIterator class ExampleIterator
include Iterator(Example) include Iterator(Example)
# Stack that contains the iterators for each group.
# A stack is used to track where in the tree this iterator is. # A stack is used to track where in the tree this iterator is.
@stack : Array(Iterator(ExampleComponent)) @stack = Deque(Node).new(1)
# Creates a new iterator. # Creates a new iterator.
# The *group* is the example group to iterate through. # The *group* is the example group to iterate through.
def initialize(@group : Iterable(ExampleComponent)) def initialize(@group : Node)
iter = @group.each.as(Iterator(ExampleComponent)) @stack.push(@group)
@stack = [iter]
end end
# Retrieves the next `Example`. # Retrieves the next `Example`.
@ -21,51 +23,30 @@ module Spectator
# a. an example is found. # a. an example is found.
# b. the stack is empty. # b. the stack is empty.
until @stack.empty? until @stack.empty?
# Retrieve the next "thing". # Retrieve the next node.
# This could be an `Example`, # This could be an `Example` or a group.
# or a group. node = @stack.pop
item = advance
# Return the item if it's an example. # If the node is a group, add its direct children to the queue
# in reverse order so that the tree is traversed in pre-order.
if node.is_a?(Indexable(Node))
node.reverse_each { |child| @stack.push(child) }
end
# Return the node if it's an example.
# Otherwise, advance and check the next one. # Otherwise, advance and check the next one.
return item if item.is_a?(Example) return node if node.is_a?(Example)
end end
# Nothing left to iterate. # Nothing left to iterate.
stop stop
end end
# Restart the iterator at the beginning. # Restart the iterator at the beginning.
def rewind def rewind
# Same code as `#initialize`, but return self. @stack.clear
iter = @group.each.as(Iterator(ExampleComponent)) @stack.push(@group)
@stack = [iter]
self self
end end
# Retrieves the top of the stack.
private def top
@stack.last
end
# Retrieves the next "thing" from the tree.
# This method will return an `Example` or "something else."
private def advance
# Get the iterator from the top of the stack.
# Advance the iterator and check what the next item is.
case (item = top.next)
when ExampleGroup
# If the next thing is a group,
# we need to traverse its branch.
# Push its iterator onto the stack and return.
@stack.push(item.each)
when Iterator::Stop
# If a stop instance is encountered,
# then the current group is done.
# Pop its iterator from the stack and return.
@stack.pop
else
# Found an example, return it.
item
end
end
end end
end end

View file

@ -0,0 +1,14 @@
module Spectator
# Exception that indicates an example is pending and should be skipped.
# When raised within a test, the test should abort.
class ExamplePending < Exception
# Location of where the example was aborted.
getter location : Location?
# Creates the exception.
# Specify *location* to where the example was aborted.
def initialize(@location : Location? = nil, message : String? = nil, cause : Exception? = nil)
super(message, cause)
end
end
end

View file

@ -0,0 +1,54 @@
require "./label"
require "./location"
module Spectator
# Information about a hook tied to an example and a proc to invoke it.
class ExampleProcsyHook
# Location of the hook in source code.
getter! location : Location
# User-defined description of the hook.
getter! label : Label
@proc : Example::Procsy ->
# Creates the hook with a proc.
# The *proc* will be called when the hook is invoked.
# A *location* and *label* can be provided for debugging.
def initialize(@proc : (Example::Procsy ->), *, @location : Location? = nil, @label : Label = nil)
end
# Creates the hook with a block.
# The block must take a single argument - the current example wrapped in a procsy.
# The block will be executed when the hook is invoked.
# A *location* and *label* can be provided for debugging.
def initialize(*, @location : Location? = nil, @label : Label = nil, &block : Example::Procsy -> _)
@proc = block
end
# Invokes the hook.
# The *example* refers to the current example.
def call(procsy : Example::Procsy) : Nil
@proc.call(procsy)
end
# Creates an example procsy that invokes this hook.
def wrap(procsy : Example::Procsy) : Example::Procsy
procsy.wrap { call(procsy) }
end
# Produces the string representation of the hook.
# Includes the location and label if they're not nil.
def to_s(io)
io << "example hook"
if (label = @label)
io << ' ' << label
end
if (location = @location)
io << " @ " << location
end
end
end
end

View file

@ -0,0 +1,187 @@
require "json"
require "./expression"
require "./location"
module Spectator
# Result of evaluating a matcher on a target.
# Contains information about the match,
# such as whether it was successful and a description of the operation.
struct Expectation
# Location of the expectation in source code.
# This can be nil if the location isn't capturable,
# for instance using the *should* syntax or dynamically created expectations.
getter! location : Location
# Indicates whether the expectation was met.
def satisfied?
@match_data.matched?
end
# Indicates whether the expectation was not met.
def failed?
!satisfied?
end
# If nil, then the match was successful.
def failure_message?
return unless match_data = @match_data.as?(Matchers::FailedMatchData)
case message = @message
when String then message
when Proc(String) then @message = message.call # Cache result of call.
else match_data.failure_message
end
end
# Description of why the match failed.
def failure_message
failure_message?.not_nil!
end
# Additional information about the match, useful for debug.
# If nil, then the match was successful.
def values?
@match_data.as?(Matchers::FailedMatchData).try(&.values)
end
# Additional information about the match, useful for debug.
def values
values?.not_nil!
end
def description
@match_data.description
end
# Creates the expectation.
# The *match_data* comes from the result of calling `Matcher#match`.
# The *location* is the location of the expectation in source code, if available.
# A custom *message* can be used in case of a failure.
def initialize(@match_data : Matchers::MatchData, @location : Location? = nil,
@message : String? | Proc(String) = nil)
end
# Creates the JSON representation of the expectation.
def to_json(json : JSON::Builder)
json.object do
if location = @location
json.field("file_path", location.path)
json.field("line_number", location.line)
end
json.field("satisfied", satisfied?)
if (failed = @match_data.as?(Matchers::FailedMatchData))
failed_to_json(failed, json)
end
end
end
# Adds failure information to a JSON structure.
private def failed_to_json(failed : Matchers::FailedMatchData, json : JSON::Builder)
json.field("failure", failed.failure_message)
json.field("values") do
json.object do
failed.values.each do |pair|
json.field(pair.first, pair.last)
end
end
end
end
# Stores part of an expectation.
# This covers the actual value (or block) being inspected and its location.
# This is the type returned by an `expect` block in the DSL.
# It is not intended to be used directly, but instead by chaining methods.
# Typically `#to` and `#not_to` are used.
struct Target(T)
# Creates the expectation target.
# The *expression* is the actual value being tested and its label.
# The *location* is the location of where this expectation was defined.
def initialize(@expression : Expression(T), @location : Location)
end
# Asserts that some criteria defined by the matcher is satisfied.
# Allows a custom message to be used.
def to(matcher, message = nil) : Nil
match_data = matcher.match(@expression)
report(match_data, message)
end
def to(stub : Mocks::MethodStub) : Nil
Harness.current.mocks.expect(@expression.value, stub)
value = Value.new(stub.name, stub.to_s)
matcher = Matchers::ReceiveMatcher.new(value, stub.arguments?)
to_eventually(matcher)
end
def to(stubs : Enumerable(Mocks::MethodStub)) : Nil
stubs.each { |stub| to(stub) }
end
# Asserts that some criteria defined by the matcher is not satisfied.
# This is effectively the opposite of `#to`.
# Allows a custom message to be used.
def to_not(matcher, message = nil) : Nil
match_data = matcher.negated_match(@expression)
report(match_data, message)
end
# :ditto:
@[AlwaysInline]
def not_to(matcher, message = nil) : Nil
to_not(matcher, message)
end
def to_not(stub : Mocks::MethodStub) : Nil
value = Value.new(stub.name, stub.to_s)
matcher = Matchers::ReceiveMatcher.new(value, stub.arguments?)
to_never(matcher)
end
def to_not(stubs : Enumerable(Mocks::MethodStub)) : Nil
stubs.each { |stub| to_not(stub) }
end
# Asserts that some criteria defined by the matcher is eventually satisfied.
# The expectation is checked after the example finishes and all hooks have run.
# Allows a custom message to be used.
def to_eventually(matcher, message = nil) : Nil
Harness.current.defer { to(matcher, message) }
end
def to_eventually(stub : Mocks::MethodStub) : Nil
to(stub)
end
def to_eventually(stubs : Enumerable(Mocks::MethodStub)) : Nil
to(stub)
end
# Asserts that some criteria defined by the matcher is never satisfied.
# The expectation is checked after the example finishes and all hooks have run.
# Allows a custom message to be used.
def to_never(matcher, message = nil) : Nil
Harness.current.defer { to_not(matcher, message) }
end
# :ditto:
@[AlwaysInline]
def never_to(matcher, message = nil) : Nil
to_never(matcher, message)
end
def to_never(stub : Mocks::MethodStub) : Nil
to_not(stub)
end
def to_never(stub : Enumerable(Mocks::MethodStub)) : Nil
to_not(stub)
end
# Reports an expectation to the current harness.
private def report(match_data : Matchers::MatchData, message : String? | Proc(String) = nil)
expectation = Expectation.new(match_data, @location, message)
Harness.current.report(expectation)
end
end
end
end

View file

@ -1,15 +1,16 @@
require "./example_failed" require "./example_failed"
require "./expectation"
module Spectator module Spectator
# Exception that indicates a required expectation was not met in an example. # Exception that indicates an expectation from a test failed.
# When raised within a test, the test should abort.
class ExpectationFailed < ExampleFailed class ExpectationFailed < ExampleFailed
# Expectation that failed. # Expectation that failed.
getter expectation : Expectations::Expectation getter expectation : Expectation
# Creates the exception. # Creates the exception.
# The exception string is generated from the expecation message. def initialize(@expectation : Expectation, message : String? = nil, cause : Exception? = nil)
def initialize(@expectation) super(expectation.location?, message, cause)
super(@expectation.failure_message)
end end
end end
end end

View file

@ -1,7 +0,0 @@
require "./expectations/*"
module Spectator
# Namespace that contains all expectations, partials, and handling of them.
module Expectations
end
end

View file

@ -1,62 +0,0 @@
require "./expectation"
module Spectator::Expectations
# Collection of expectations from an example.
class ExampleExpectations
include Enumerable(Expectation)
# Creates the collection.
def initialize(@expectations : Array(Expectation))
end
# Iterates through all expectations.
def each
@expectations.each do |expectation|
yield expectation
end
end
# Returns a collection of only the satisfied expectations.
def satisfied : Enumerable(Expectation)
@expectations.select(&.satisfied?)
end
# Iterates over only the satisfied expectations.
def each_satisfied
@expectations.each do |expectation|
yield expectation if expectation.satisfied?
end
end
# Returns a collection of only the unsatisfied expectations.
def unsatisfied : Enumerable(Expectation)
@expectations.reject(&.satisfied?)
end
# Iterates over only the unsatisfied expectations.
def each_unsatisfied
@expectations.each do |expectation|
yield expectation unless expectation.satisfied?
end
end
# Determines whether the example was successful
# based on if all expectations were satisfied.
def successful?
@expectations.all?(&.satisfied?)
end
# Determines whether the example failed
# based on if any expectations were not satisfied.
def failed?
!successful?
end
# Creates the JSON representation of the expectations.
def to_json(json : ::JSON::Builder)
json.array do
each &.to_json(json)
end
end
end
end

View file

@ -1,74 +0,0 @@
require "../matchers/failed_match_data"
require "../matchers/match_data"
require "../source"
module Spectator::Expectations
# Result of evaluating a matcher on an expectation partial.
struct Expectation
# Location where this expectation was defined.
getter source : Source
# Creates the expectation.
def initialize(@match_data : Matchers::MatchData, @source : Source)
end
# Indicates whether the matcher was satisified.
def satisfied?
@match_data.matched?
end
# Indicates that the expectation was not satisified.
def failure?
!satisfied?
end
# Description of why the match failed.
# If nil, then the match was successful.
def failure_message?
@match_data.as?(Matchers::FailedMatchData).try(&.failure_message)
end
# Description of why the match failed.
def failure_message
failure_message?.not_nil!
end
# Additional information about the match, useful for debug.
# If nil, then the match was successful.
def values?
@match_data.as?(Matchers::FailedMatchData).try(&.values)
end
# Additional information about the match, useful for debug.
def values
values?.not_nil!
end
def description
@match_data.description
end
# Creates the JSON representation of the expectation.
def to_json(json : ::JSON::Builder)
json.object do
json.field("source") { @source.to_json(json) }
json.field("satisfied", satisfied?)
if (failed = @match_data.as?(Matchers::FailedMatchData))
failed_to_json(failed, json)
end
end
end
# Adds failure information to a JSON structure.
private def failed_to_json(failed : Matchers::FailedMatchData, json : ::JSON::Builder)
json.field("failure", failed.failure_message)
json.field("values") do
json.object do
failed.values.each do |pair|
json.field(pair.first, pair.last)
end
end
end
end
end
end

View file

@ -1,101 +0,0 @@
require "../matchers/match_data"
require "../source"
require "../test_expression"
module Spectator::Expectations
# Stores part of an expectation (obviously).
# The part of the expectation this type covers is the actual value and source.
# This can also cover a block's behavior.
struct ExpectationPartial(T)
# The actual value being tested.
# This also contains its label.
getter actual : TestExpression(T)
# Location where this expectation was defined.
getter source : Source
# Creates the partial.
def initialize(@actual : TestExpression(T), @source : Source)
end
# Asserts that some criteria defined by the matcher is satisfied.
def to(matcher) : Nil
match_data = matcher.match(@actual)
report(match_data)
end
def to(stub : Mocks::MethodStub) : Nil
Harness.current.mocks.expect(@actual.value, stub)
value = TestValue.new(stub.name, stub.to_s)
matcher = Matchers::ReceiveMatcher.new(value, stub.arguments?)
to_eventually(matcher)
end
def to(stubs : Enumerable(Mocks::MethodStub)) : Nil
stubs.each { |stub| to(stub) }
end
# Asserts that some criteria defined by the matcher is not satisfied.
# This is effectively the opposite of `#to`.
def to_not(matcher) : Nil
match_data = matcher.negated_match(@actual)
report(match_data)
end
def to_not(stub : Mocks::MethodStub) : Nil
value = TestValue.new(stub.name, stub.to_s)
matcher = Matchers::ReceiveMatcher.new(value, stub.arguments?)
to_never(matcher)
end
def to_not(stubs : Enumerable(Mocks::MethodStub)) : Nil
stubs.each { |stub| to_not(stub) }
end
# :ditto:
@[AlwaysInline]
def not_to(matcher) : Nil
to_not(matcher)
end
# Asserts that some criteria defined by the matcher is eventually satisfied.
# The expectation is checked after the example finishes and all hooks have run.
def to_eventually(matcher) : Nil
Harness.current.defer { to(matcher) }
end
def to_eventually(stub : Mocks::MethodStub) : Nil
to(stub)
end
def to_eventually(stubs : Enumerable(Mocks::MethodStub)) : Nil
to(stub)
end
# Asserts that some criteria defined by the matcher is never satisfied.
# The expectation is checked after the example finishes and all hooks have run.
def to_never(matcher) : Nil
Harness.current.defer { to_not(matcher) }
end
def to_never(stub : Mocks::MethodStub) : Nil
to_not(stub)
end
def to_never(stub : Enumerable(Mocks::MethodStub)) : Nil
to_not(stub)
end
# :ditto:
@[AlwaysInline]
def never_to(matcher) : Nil
to_never(matcher)
end
# Reports an expectation to the current harness.
private def report(match_data : Matchers::MatchData)
expectation = Expectation.new(match_data, @source)
Harness.current.report_expectation(expectation)
end
end
end

View file

@ -1,34 +0,0 @@
module Spectator::Expectations
# Tracks the expectations and their outcomes in an example.
# A single instance of this class should be associated with one example.
class ExpectationReporter
# All expectations are stored in this array.
# The initial capacity is set to one,
# as that is the typical (and recommended)
# number of expectations per example.
@expectations = Array(Expectation).new(1)
# Creates the reporter.
# When the *raise_on_failure* flag is set to true,
# which is the default, an exception will be raised
# on the first failure that is reported.
# To store failures and continue, set the flag to false.
def initialize(@raise_on_failure = true)
end
# Stores the outcome of an expectation.
# If the raise on failure flag is set to true,
# then this method will raise an exception
# when a failing result is given.
def report(expectation : Expectation) : Nil
@expectations << expectation
raise ExpectationFailed.new(expectation) if !expectation.satisfied? && @raise_on_failure
end
# Returns the reported expectations from the example.
# This should be run after the example has finished.
def expectations : ExampleExpectations
ExampleExpectations.new(@expectations)
end
end
end

View file

@ -0,0 +1,18 @@
require "./abstract_expression"
module Spectator
# Represents an expression from a test.
# This is typically captured by an `expect` macro.
# It consists of a label and a typed expression.
# The label should be a string recognizable by the user,
# or nil if one isn't available.
abstract class Expression(T) < AbstractExpression
# Retrieves the underlying value of the expression.
abstract def value : T
# Retrieves the evaluated value of the expression.
def raw_value
value
end
end
end

View file

@ -0,0 +1,81 @@
require "json"
require "./example_failed"
require "./location"
require "./result"
module Spectator
# Outcome that indicates an example failed.
# This typically means an assertion did not pass.
class FailResult < Result
# Error that occurred while running the example.
# This describes the primary reason for the failure.
getter error : Exception
# Creates a failure result.
# The *elapsed* argument is the length of time it took to run the example.
# The *error* is the exception raised that caused the failure.
def initialize(elapsed, @error, expectations = [] of Expectation)
super(elapsed, expectations)
end
# Calls the `failure` method on *visitor*.
def accept(visitor)
visitor.fail(self)
end
# Calls the `failure` method on *visitor*.
def accept(visitor)
visitor.fail(yield self)
end
# Indicates whether the example passed.
def pass? : Bool
false
end
# Indicates whether the example failed.
def fail? : Bool
true
end
# Attempts to retrieve the location where the example failed.
# This only works if the location of the failed expectation was reported.
# If available, returns a `Location`, otherwise `nil`.
def location? : Location?
return unless error = @error.as?(ExampleFailed)
error.location?
end
# Attempts to retrieve the location where the example failed.
# This only works if the location of the failed expectation was reported.
# If available, returns a `Location`, otherwise raises `NilAssertionError`.
def location : Location
location? || raise(NilAssertionError.new("Source location of failure unavailable"))
end
# One-word description of the result.
def to_s(io)
io << "fail"
end
# Creates a JSON object from the result information.
def to_json(json : JSON::Builder)
super
json.field("status", json_status)
json.field("exception") do
json.object do
json.field("class", @error.class.name)
json.field("message", @error.message)
json.field("backtrace", @error.backtrace)
end
end
end
# String used for the JSON status field.
# Necessary for the error result to override the status, but nothing else from `#to_json`.
private def json_status
"failed"
end
end
end

View file

@ -1,42 +0,0 @@
require "./result"
module Spectator
# Outcome that indicates running an example was a failure.
class FailedResult < FinishedResult
# Error that occurred while running the example.
getter error : Exception
# Creates a failed result.
# The *example* should refer to the example that was run
# and that this result is for.
# The *elapsed* argument is the length of time it took to run the example.
# The *expectations* references the expectations that were checked in the example.
# The *error* is the exception that was raised to cause the failure.
def initialize(example, elapsed, expectations, @error)
super(example, elapsed, expectations)
end
# Calls the `failure` method on *interface*.
def call(interface)
interface.failure
end
# Calls the `failure` method on *interface*
# and passes the yielded value.
def call(interface)
value = yield self
interface.failure(value)
end
# One-word descriptor of the result.
def to_s(io)
io << "fail"
end
# Adds all of the JSON fields for finished results and failed results.
private def add_json_fields(json : ::JSON::Builder)
super
json.field("error", error.message)
end
end
end

View file

@ -0,0 +1,85 @@
require "./example"
require "./node"
require "./node_filter"
require "./node_iterator"
module Spectator
# Iterates through selected nodes in a group and its nested groups.
# Nodes are iterated in pre-order.
class FilteredExampleIterator
include Iterator(Example)
# A stack is used to track where in the tree this iterator is.
@stack = Deque(Node).new(1)
# A queue stores forced examples that have been matched by the a parent group.
@queue = Deque(Example).new
# Creates a new iterator.
# The *group* is the example group to iterate through.
# The *filter* selects which examples (and groups) to iterate through.
def initialize(@group : Node, @filter : NodeFilter)
@stack.push(@group)
end
# Retrieves the next selected `Example`.
# If there are none left, then `Iterator::Stop` is returned.
def next
# Return items from the queue first before continuing to the stack.
return @queue.shift unless @queue.empty?
# Keep going until either:
# a. a suitable example is found.
# b. the stack is empty.
until @stack.empty?
# Retrieve the next node.
node = @stack.pop
# If the node is a group, conditionally traverse it.
if node.is_a?(Indexable(Node))
# To traverse, a child node or the group itself must match the filter.
return node if node = next_group_match(node)
elsif node.is_a?(Example) && @filter.includes?(node)
return node
end
end
# Nothing left to iterate.
stop
end
# Restart the iterator at the beginning.
def rewind
@stack.clear
@stack.push(@group)
@queue.clear
self
end
# Attempts to find the next matching example in a group.
# If any child in the group matches, then traversal on the stack (down the tree) continues.
# However, if no children match, but the group itself does, then all examples in the group match.
# In the latter scenario, the examples are added to the queue, and the next item from the queue returned.
# Stack iteration should continue if nil is returned.
private def next_group_match(group : Indexable(Node)) : Example?
# Look for any children that match.
iterator = NodeIterator.new(group)
# Check if any children match.
# Skip first node because its the group being checked.
if iterator.skip(1).any?(@filter)
# Add the group's direct children to the queue
# in reverse order so that the tree is traversed in pre-order.
group.reverse_each { |node| @stack.push(node) }
# Check if the group matches, but no children match.
elsif @filter.includes?(group)
# Add examples from the group to the queue.
# Return the next example from the queue.
iterator.rewind.select(Example).each { |node| @queue.push(node) }
@queue.shift unless @queue.empty?
# If the queue is empty (group has no examples), go to next loop iteration of the stack.
end
end
end
end

View file

@ -1,27 +0,0 @@
module Spectator
# Abstract class for all results by examples
abstract class FinishedResult < Result
# Length of time it took to run the example.
getter elapsed : Time::Span
# The expectations that were run in the example.
getter expectations : Expectations::ExampleExpectations
# Creates a successful result.
# The *example* should refer to the example that was run
# and that this result is for.
# The *elapsed* argument is the length of time it took to run the example.
# The *expectations* references the expectations that were checked in the example.
def initialize(example, @elapsed, @expectations)
super(example)
end
# Adds the common JSON fields for all result types
# and fields specific to finished results.
private def add_json_fields(json : ::JSON::Builder)
super
json.field("time", elapsed.total_seconds)
json.field("expectations", expectations)
end
end
end

View file

@ -0,0 +1,87 @@
require "./formatter"
module Spectator::Formatting
# Reports events to multiple other formatters.
# Events received by this formatter will be sent to others.
class BroadcastFormatter < Formatter
# Creates the broadcast formatter.
# Takes a collection of formatters to pass events along to.
def initialize(@formatters : Enumerable(Formatter))
end
# Forwards the event to other formatters.
def start(notification)
@formatters.each(&.start(notification))
end
# :ditto:
def example_started(notification)
@formatters.each(&.example_started(notification))
end
# :ditto:
def example_finished(notification)
@formatters.each(&.example_finished(notification))
end
# :ditto:
def example_passed(notification)
@formatters.each(&.example_passed(notification))
end
# :ditto:
def example_pending(notification)
@formatters.each(&.example_pending(notification))
end
# :ditto:
def example_failed(notification)
@formatters.each(&.example_failed(notification))
end
# :ditto:
def example_error(notification)
@formatters.each(&.example_error(notification))
end
# :ditto:
def message(notification)
@formatters.each(&.message(notification))
end
# :ditto:
def stop
@formatters.each(&.stop)
end
# :ditto:
def start_dump
@formatters.each(&.start_dump)
end
# :ditto:
def dump_pending(notification)
@formatters.each(&.dump_pending(notification))
end
# :ditto:
def dump_failures(notification)
@formatters.each(&.dump_failures(notification))
end
# :ditto:
def dump_summary(notification)
@formatters.each(&.dump_summary(notification))
end
# :ditto:
def dump_profile(notification)
@formatters.each(&.dump_profile(notification))
end
# :ditto:
def close
@formatters.each(&.close)
end
end
end

View file

@ -1,42 +0,0 @@
require "colorize"
module Spectator::Formatting
# Method for colorizing output.
module Color
extend self
# Symbols in `Colorize` representing result types and formatting types.
private COLORS = {
success: :green,
failure: :red,
error: :magenta,
pending: :yellow,
comment: :cyan,
}
# Colorizes some text with the success color.
def success(text)
text.colorize(COLORS[:success])
end
# Colorizes some text with the failure color.
def failure(text)
text.colorize(COLORS[:failure])
end
# Colorizes some text with the error color.
def error(text)
text.colorize(COLORS[:error])
end
# Colorizes some text with the pending/skipped color.
def pending(text)
text.colorize(COLORS[:pending])
end
# Colorizes some text with the comment color.
def comment(text)
text.colorize(COLORS[:comment])
end
end
end

View file

@ -1,20 +0,0 @@
module Spectator::Formatting
# Produces a stringified comment for output.
private struct Comment(T)
# Creates the comment.
def initialize(@text : T)
end
# Appends the comment to the output.
def to_s(io)
io << '#'
io << ' '
io << @text
end
# Creates a colorized version of the comment.
def self.color(text)
Color.comment(new(text))
end
end
end

View file

@ -0,0 +1,8 @@
require "./components/**"
module Spectator::Formatting
# Namespace for snippets of text displayed in console output.
# These types are typically constructed and have `#to_s` called.
module Components
end
end

View file

@ -0,0 +1,32 @@
module Spectator::Formatting::Components
# Base type for handling indented output.
# Indents are tracked and automatically printed.
# Use `#indent` to increase the indent for the duration of a block.
# Use `#line` to produce a line with an indentation prefixing it.
abstract struct Block
# Default indent amount.
private INDENT = 2
# Creates the block.
# A default *indent* size can be specified.
def initialize(*, @indent : Int32 = INDENT)
end
# Increases the indent by the a specific *amount* for the duration of the block.
private def indent(amount = INDENT)
@indent += amount
yield
@indent -= amount
end
# Produces a line of output with an indent before it.
# The contents of the line should be generated by a block provided to this method.
# Ensure that _only_ one line is produced by the block,
# otherwise the indent will be lost.
private def line(io)
@indent.times { io << ' ' }
yield
io.puts
end
end
end

View file

@ -0,0 +1,23 @@
require "colorize"
module Spectator::Formatting::Components
# Object that can be stringified pre-pended with a comment mark (#).
struct Comment(T)
# Default color for a comment.
private COLOR = :cyan
# Creates a comment with the specified content.
def initialize(@content : T)
end
# Creates a colored comment.
def self.colorize(content)
new(content).colorize(COLOR)
end
# Writes the comment to the output.
def to_s(io)
io << "# " << @content
end
end
end

View file

@ -0,0 +1,81 @@
require "colorize"
require "../../example"
require "../../error_result"
require "./result_block"
module Spectator::Formatting::Components
# Displays information about an error result.
struct ErrorResultBlock < ResultBlock
# Creates the component.
def initialize(example : Example, index : Int32, @result : ErrorResult, subindex = 0)
super(example, index, subindex)
end
# Content displayed on the second line of the block after the label.
private def subtitle
@result.error.message.try(&.each_line.first)
end
# Prefix for the second line of the block.
private def subtitle_label
"Error: ".colorize(:red)
end
# Display error information.
private def content(io)
# Fetch the error and message.
error = @result.error
lines = error.message.try(&.lines)
# Write the error and message if available.
case
when lines.nil? then write_error_class(io, error)
when lines.size == 1 then write_error_message(io, error, lines.first)
when lines.size > 1 then write_multiline_error_message(io, error, lines)
else write_error_class(io, error)
end
# Display the backtrace if it's available.
if backtrace = error.backtrace?
indent { write_backtrace(io, backtrace) }
end
io.puts
end
# Display just the error type.
private def write_error_class(io, error)
line(io) do
io << error.class.colorize(:red)
end
end
# Display the error type and first line of the message.
private def write_error_message(io, error, message)
line(io) do
io << "#{error.class}: ".colorize(:red)
io << message
end
end
# Display the error type and its multi-line message.
private def write_multiline_error_message(io, error, lines)
# Use the normal formatting for the first line.
write_error_message(io, error, lines.first)
# Display additional lines after the first.
lines.skip(1).each do |entry|
line(io) { io << entry }
end
end
# Writes the backtrace entries to the output.
private def write_backtrace(io, backtrace)
backtrace.each do |entry|
# Dim entries that are outside the shard.
entry = entry.colorize.dim unless entry.starts_with?(/(src|spec)\//)
line(io) { io << entry }
end
end
end
end

View file

@ -0,0 +1,26 @@
require "../../example"
require "./comment"
module Spectator::Formatting::Components
# Provides syntax for running a specific example from the command-line.
struct ExampleCommand
# Creates the component with the specified example.
def initialize(@example : Example)
end
# Produces output for running the previously specified example.
def to_s(io)
io << "crystal spec "
# Use location for argument if it's available, since it's simpler.
# Otherwise, use the example name filter argument.
if location = @example.location?
io << location
else
io << "-e " << @example
end
io << ' ' << Comment.colorize(@example.to_s)
end
end
end

View file

@ -0,0 +1,49 @@
require "colorize"
require "../../example"
require "../../expectation"
require "../../fail_result"
require "./result_block"
module Spectator::Formatting::Components
# Displays information about a fail result.
struct FailResultBlock < ResultBlock
@longest_key : Int32
# Creates the component.
def initialize(example : Example, index : Int32, @expectation : Expectation, subindex = 0)
super(example, index, subindex)
@longest_key = expectation.values.max_of { |(key, _value)| key.to_s.size }
end
# Content displayed on the second line of the block after the label.
private def subtitle
@expectation.failure_message
end
# Prefix for the second line of the block.
private def subtitle_label
"Failure: ".colorize(:red)
end
# Display expectation match data.
private def content(io)
indent do
@expectation.values.each do |(key, value)|
value_line(io, key, value)
end
end
io.puts
end
# Display a single line for a match data value.
private def value_line(io, key, value)
key = key.to_s
padding = " " * (@longest_key - key.size)
line(io) do
io << padding << key.colorize(:red) << ": ".colorize(:red) << value
end
end
end
end

View file

@ -0,0 +1,21 @@
require "../../example"
require "./example_command"
module Spectator::Formatting::Components
# Produces a list of commands to run failed examples.
struct FailureCommandList
# Creates the component.
# Requires a set of *failures* to display commands for.
def initialize(@failures : Enumerable(Example))
end
# Produces the list of commands to run failed examples.
def to_s(io)
io.puts "Failed examples:"
io.puts
@failures.each do |failure|
io.puts ExampleCommand.new(failure).colorize(:red)
end
end
end
end

Some files were not shown because too many files have changed in this diff Show more