mirror of
https://gitea.invidious.io/iv-org/shard-spectator.git
synced 2024-08-15 00:53:35 +00:00
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:
commit
60085eb726
266 changed files with 7457 additions and 4728 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
52
CHANGELOG.md
52
CHANGELOG.md
|
@ -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.
|
||||||
|
|
24
README.md
24
README.md
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
31
spec/custom_message_spec.cr
Normal file
31
spec/custom_message_spec.cr
Normal 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
71
spec/helpers/example.cr
Normal 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
5
spec/helpers/example.ecr
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
require "<%= spec_helper_path %>"
|
||||||
|
|
||||||
|
Spectator.describe "<%= @example_id %>" do
|
||||||
|
<%= @example_code %>
|
||||||
|
end
|
28
spec/helpers/expectation.cr
Normal file
28
spec/helpers/expectation.cr
Normal 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
67
spec/helpers/result.cr
Normal 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
|
|
@ -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__)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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") }
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
58
spec/runtime_example_spec.cr
Normal file
58
spec/runtime_example_spec.cr
Normal 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
|
|
@ -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
|
||||||
|
|
41
spec/spectator/aggregate_failures_spec.cr
Normal file
41
spec/spectator/aggregate_failures_spec.cr
Normal 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
|
24
spec/spectator/anything_spec.cr
Normal file
24
spec/spectator/anything_spec.cr
Normal 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
|
34
spec/spectator/block_spec.cr
Normal file
34
spec/spectator/block_spec.cr
Normal 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
|
55
spec/spectator/concise_spec.cr
Normal file
55
spec/spectator/concise_spec.cr
Normal 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
|
15
spec/spectator/lazy_spec.cr
Normal file
15
spec/spectator/lazy_spec.cr
Normal 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
|
28
spec/spectator/lazy_wrapper_spec.cr
Normal file
28
spec/spectator/lazy_wrapper_spec.cr
Normal 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
|
36
spec/spectator/value_spec.cr
Normal file
36
spec/spectator/value_spec.cr
Normal 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
|
18
spec/spectator/wrapper_spec.cr
Normal file
18
spec/spectator/wrapper_spec.cr
Normal 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
|
109
src/spectator.cr
109
src/spectator.cr
|
@ -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
|
||||||
|
|
53
src/spectator/abstract_expression.cr
Normal file
53
src/spectator/abstract_expression.cr
Normal 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
|
|
@ -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
34
src/spectator/block.cr
Normal 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
|
|
@ -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
|
|
|
@ -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
|
|
15
src/spectator/composite_node_filter.cr
Normal file
15
src/spectator/composite_node_filter.cr
Normal 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
|
|
@ -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
|
||||||
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
|
||||||
|
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
|
||||||
|
|
317
src/spectator/config/builder.cr
Normal file
317
src/spectator/config/builder.cr
Normal 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
|
240
src/spectator/config/cli_arguments_applicator.cr
Normal file
240
src/spectator/config/cli_arguments_applicator.cr
Normal 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
|
|
@ -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
|
|
|
@ -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
32
src/spectator/context.cr
Normal 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
|
27
src/spectator/context_delegate.cr
Normal file
27
src/spectator/context_delegate.cr
Normal 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
|
10
src/spectator/context_method.cr
Normal file
10
src/spectator/context_method.cr
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
108
src/spectator/dsl/builder.cr
Normal file
108
src/spectator/dsl/builder.cr
Normal 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
|
47
src/spectator/dsl/concise.cr
Normal file
47
src/spectator/dsl/concise.cr
Normal 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
|
|
@ -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
|
||||||
|
|
||||||
|
::Spectator::DSL::Builder.add_example(
|
||||||
|
_spectator_example_name(\{{what}}),
|
||||||
|
::Spectator::Location.new(\{{block.filename}}, \{{block.line_number}}, \{{block.end_line_number}}),
|
||||||
|
-> { new.as(::Spectator::Context) },
|
||||||
|
\%metadata
|
||||||
|
) do |example|
|
||||||
|
example.with_context(\{{@type.name}}) do
|
||||||
|
\{% if block.args.empty? %}
|
||||||
|
\%test
|
||||||
|
\{% else %}
|
||||||
|
\%test(example)
|
||||||
|
\{% end %}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
\{% else %}
|
||||||
|
::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 %}
|
||||||
{% raise "Unrecognized syntax: `it #{description}` at #{_source_file}:#{_source_line}" %}
|
{{what.stringify}}
|
||||||
{% end %}
|
{% end %}
|
||||||
{% else %}
|
|
||||||
def %run
|
|
||||||
{{block.body}}
|
|
||||||
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_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 specify(description = nil, &block)
|
define_example :example
|
||||||
it({{description}}) {{block}}
|
|
||||||
end
|
|
||||||
|
|
||||||
macro pending(description = nil, &block)
|
define_example :it
|
||||||
{% if block.is_a?(Nop) %}
|
|
||||||
{% if description.is_a?(Call) %}
|
|
||||||
def %run
|
|
||||||
{{description}}
|
|
||||||
end
|
|
||||||
{% else %}
|
|
||||||
{% raise "Unrecognized syntax: `pending #{description}` at #{_source_file}:#{_source_line}" %}
|
|
||||||
{% end %}
|
|
||||||
{% else %}
|
|
||||||
def %run
|
|
||||||
{{block.body}}
|
|
||||||
end
|
|
||||||
{% end %}
|
|
||||||
|
|
||||||
{% if block.is_a?(Nop) %}
|
define_example :specify
|
||||||
%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
|
|
||||||
|
|
||||||
macro skip(description = nil, &block)
|
define_example :fexample, focus: true
|
||||||
pending({{description}}) {{block}}
|
|
||||||
end
|
|
||||||
|
|
||||||
macro xit(description = nil, &block)
|
define_example :fit, focus: true
|
||||||
pending({{description}}) {{block}}
|
|
||||||
end
|
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
|
||||||
|
|
181
src/spectator/dsl/expectations.cr
Normal file
181
src/spectator/dsl/expectations.cr
Normal 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
|
|
@ -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?(".")
|
# Defines a macro to generate code for an example group.
|
||||||
what.id.symbolize
|
# The *name* is the name given to the macro.
|
||||||
else
|
#
|
||||||
what
|
# 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 %}
|
||||||
|
|
||||||
|
class Group\%group < \{{@type.id}}
|
||||||
|
_spectator_group_subject(\{{what}})
|
||||||
|
|
||||||
|
_spectator_metadata(:metadata, :super, {{tags.splat(", ")}} {{metadata.double_splat}})
|
||||||
|
_spectator_metadata(:metadata, :previous_def, \{{tags.splat(", ")}} \{{metadata.double_splat}})
|
||||||
|
|
||||||
|
::Spectator::DSL::Builder.start_group(
|
||||||
|
_spectator_group_name(\{{what}}),
|
||||||
|
::Spectator::Location.new(\{{block.filename}}, \{{block.line_number}}, \{{block.end_line_number}}),
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
\{{block.body if block}}
|
||||||
|
|
||||||
|
::Spectator::DSL::Builder.end_group
|
||||||
|
end
|
||||||
end
|
end
|
||||||
else
|
|
||||||
what.symbolize
|
|
||||||
end
|
end
|
||||||
%}
|
|
||||||
|
|
||||||
%source = ::Spectator::Source.new({{block.filename}}, {{block.line_number}})
|
# Defines a macro to generate code for an iterative 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.
|
||||||
|
#
|
||||||
|
# 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 %}
|
||||||
|
|
||||||
# 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_metadata(:metadata, :super, {{tags.splat(", ")}} {{metadata.double_splat}})
|
||||||
{% if (what.is_a?(Path) || what.is_a?(Generic)) && (described_type = what.resolve?).is_a?(TypeNode) %}
|
_spectator_metadata(:metadata, :previous_def, \{{tags.splat(", ")}} \{{metadata.double_splat}})
|
||||||
macro described_class
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Inserts the correct representation of a group's name.
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Defines the implicit subject for the test context.
|
||||||
|
# If *what* is a type, then the `described_class` method will be defined.
|
||||||
|
# Additionally, the implicit subject is set to an instance of *what* if it's not a module.
|
||||||
|
#
|
||||||
|
# There is no common macro type that has the `#resolve?` method.
|
||||||
|
# Also, `#responds_to?` can't be used in macros.
|
||||||
|
# So the large if statement in this macro is used to look for type signatures.
|
||||||
|
private macro _spectator_group_subject(what)
|
||||||
|
{% if (what.is_a?(Generic) ||
|
||||||
|
what.is_a?(Path) ||
|
||||||
|
what.is_a?(TypeNode) ||
|
||||||
|
what.is_a?(Union)) &&
|
||||||
|
(described_type = what.resolve?).is_a?(TypeNode) %}
|
||||||
|
private macro described_class
|
||||||
{{what}}
|
{{what}}
|
||||||
end
|
end
|
||||||
|
|
||||||
subject do
|
subject do
|
||||||
{% if described_type < Reference || described_type < Value %}
|
{% if described_type.class? || described_type.struct? %}
|
||||||
described_class.new
|
described_class.new
|
||||||
{% else %}
|
{% else %}
|
||||||
described_class
|
described_class
|
||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
{% else %}
|
{% else %}
|
||||||
def _spectator_implicit_subject(*args)
|
private def _spectator_implicit_subject
|
||||||
{{what}}
|
{{what}}
|
||||||
end
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
||||||
{{block.body}}
|
|
||||||
|
|
||||||
::Spectator::SpecBuilder.end_group
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
macro describe(what, &block)
|
define_example_group :example_group
|
||||||
context({{what}}) {{block}}
|
|
||||||
|
define_example_group :describe
|
||||||
|
|
||||||
|
define_example_group :context
|
||||||
|
|
||||||
|
define_example_group :xexample_group, skip: "Temporarily skipped with xexample_group"
|
||||||
|
|
||||||
|
define_example_group :xdescribe, skip: "Temporarily skipped with xdescribe"
|
||||||
|
|
||||||
|
define_example_group :xcontext, skip: "Temporarily skipped with xcontext"
|
||||||
|
|
||||||
|
define_example_group :fexample_group, focus: true
|
||||||
|
|
||||||
|
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
|
end
|
||||||
|
|
||||||
macro sample(collection, count = nil, &block)
|
# :ditto:
|
||||||
{% name = block.args.empty? ? :value.id : block.args.first.id %}
|
define_iterative_group :xrandom_sample, skip: "Temporarily skipped with xrandom_sample" do |collection|
|
||||||
|
collection.to_a.shuffle(::Spectator.random)
|
||||||
def %collection
|
|
||||||
{{collection}}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def %to_a
|
# :ditto:
|
||||||
{% if count %}
|
define_iterative_group :frandom_sample, focus: true do |collection|
|
||||||
%collection.first({{count}})
|
collection.to_a.shuffle(::Spectator.random)
|
||||||
{% 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)
|
|
||||||
sample.%to_a
|
|
||||||
end
|
|
||||||
|
|
||||||
def {{name}}
|
|
||||||
@spectator_test_values.get_value(:%sample, typeof(%to_a.first))
|
|
||||||
end
|
|
||||||
|
|
||||||
{{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 %}
|
|
||||||
collection.shuffle(::Spectator.random)
|
|
||||||
{% end %}
|
|
||||||
end
|
|
||||||
|
|
||||||
def {{name}}
|
|
||||||
@spectator_test_values.get_value(:%sample, typeof(%to_a.first))
|
|
||||||
end
|
|
||||||
|
|
||||||
{{block.body}}
|
|
||||||
|
|
||||||
::Spectator::SpecBuilder.end_group
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
macro given(*assignments, &block)
|
|
||||||
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.
|
|
||||||
# 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 %}
|
|
||||||
# If the item starts with "it", then leave it as-is.
|
|
||||||
# Otherwise, prefix it with "it"
|
|
||||||
# and treat it as the one-liner "it" syntax.
|
|
||||||
{% if item.is_a?(Call) && item.name == :it.id %}
|
|
||||||
{{item}}
|
|
||||||
{% else %}
|
|
||||||
it {{item}}
|
|
||||||
{% end %}
|
|
||||||
{% end %}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,79 +1,142 @@
|
||||||
module Spectator
|
require "../location"
|
||||||
module DSL
|
require "./builder"
|
||||||
macro before_each(&block)
|
|
||||||
def %hook({{block.args.splat}}) : Nil
|
module Spectator::DSL
|
||||||
{{block.body}}
|
# DSL methods for adding custom logic to key times of the spec execution.
|
||||||
|
module Hooks
|
||||||
|
# Defines a macro to create an example group hook.
|
||||||
|
# The *type* indicates when the hook runs and must be a method on `Spectator::DSL::Builder`.
|
||||||
|
# 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
|
end
|
||||||
|
|
||||||
::Spectator::SpecBuilder.add_before_each_hook do |test, example|
|
{% if block %}
|
||||||
cast_test = test.as({{@type.id}})
|
private def self.%wrapper : Nil
|
||||||
{% if block.args.empty? %}
|
{{block.body}}
|
||||||
cast_test.%hook
|
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 %}
|
{% else %}
|
||||||
cast_test.%hook(example)
|
\%hook
|
||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
macro after_each(&block)
|
|
||||||
def %hook({{block.args.splat}}) : Nil
|
|
||||||
{{block.body}}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
::Spectator::SpecBuilder.add_after_each_hook do |test, example|
|
# Defines a macro to create an example hook.
|
||||||
cast_test = test.as({{@type.id}})
|
# The *type* indicates when the hook runs and must be a method on `Spectator::DSL::Builder`.
|
||||||
{% if block.args.empty? %}
|
# A custom *name* can be used for the hook method.
|
||||||
cast_test.%hook
|
# If not provided, *type* will be used instead.
|
||||||
{% else %}
|
# Additionally, a block can be provided that takes the current example as an argument.
|
||||||
cast_test.%hook(example)
|
# 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 %}
|
||||||
|
|
||||||
|
private def \%hook(\{{block.args.splat}}) : Nil
|
||||||
|
\{{block.body}}
|
||||||
|
end
|
||||||
|
|
||||||
|
{% if block %}
|
||||||
|
private def %wrapper({{block.args.splat}}) : Nil
|
||||||
|
{{block.body}}
|
||||||
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
macro before_all(&block)
|
::Spectator::DSL::Builder.{{type.id}}(
|
||||||
::Spectator::SpecBuilder.add_before_all_hook {{block}}
|
::Spectator::Location.new(\{{block.filename}}, \{{block.line_number}})
|
||||||
end
|
) do |example|
|
||||||
|
example.with_context(\{{@type.name}}) do
|
||||||
macro after_all(&block)
|
{% if block %}
|
||||||
::Spectator::SpecBuilder.add_after_all_hook {{block}}
|
|
||||||
end
|
|
||||||
|
|
||||||
macro around_each(&block)
|
|
||||||
def %hook({{block.args.first || :example.id}}) : Nil
|
|
||||||
{{block.body}}
|
|
||||||
end
|
|
||||||
|
|
||||||
::Spectator::SpecBuilder.add_around_each_hook { |test, proc| test.as({{@type.id}}).%hook(proc) }
|
|
||||||
end
|
|
||||||
|
|
||||||
macro pre_condition(&block)
|
|
||||||
def %hook({{block.args.splat}}) : Nil
|
|
||||||
{{block.body}}
|
|
||||||
end
|
|
||||||
|
|
||||||
::Spectator::SpecBuilder.add_pre_condition do |test, example|
|
|
||||||
cast_test = test.as({{@type.id}})
|
|
||||||
{% if block.args.empty? %}
|
{% if block.args.empty? %}
|
||||||
cast_test.%hook
|
%wrapper do |*args|
|
||||||
|
\{% if block.args.empty? %}
|
||||||
|
\%hook
|
||||||
|
\{% else %}
|
||||||
|
\%hook(*args)
|
||||||
|
\{% end %}
|
||||||
|
end
|
||||||
{% else %}
|
{% else %}
|
||||||
cast_test.%hook(example)
|
%wrapper(example) do |*args|
|
||||||
|
\{% if block.args.empty? %}
|
||||||
|
\%hook
|
||||||
|
\{% else %}
|
||||||
|
\%hook(*args)
|
||||||
|
\{% end %}
|
||||||
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
macro post_condition(&block)
|
|
||||||
def %hook({{block.args.splat}}) : Nil
|
|
||||||
{{block.body}}
|
|
||||||
end
|
|
||||||
|
|
||||||
::Spectator::SpecBuilder.add_post_condition do |test, example|
|
|
||||||
cast_test = test.as({{@type.id}})
|
|
||||||
{% if block.args.empty? %}
|
|
||||||
cast_test.%hook
|
|
||||||
{% else %}
|
{% else %}
|
||||||
cast_test.%hook(example)
|
\{% if block.args.empty? %}
|
||||||
|
\%hook
|
||||||
|
\{% else %}
|
||||||
|
\%hook(example)
|
||||||
|
\{% end %}
|
||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Defines a block of code that will be invoked once before any examples in the suite.
|
||||||
|
# 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 :before_suite
|
||||||
|
|
||||||
|
# Defines a block of code that will be invoked once after all examples in the suite.
|
||||||
|
# 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_suite
|
||||||
|
|
||||||
|
# Defines a block of code that will be invoked once before any examples in the group.
|
||||||
|
# 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 :before_all
|
||||||
|
|
||||||
|
# Defines a block of code that will be invoked once after all examples in the group.
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Defines a block of code that will be invoked before every example in the group.
|
||||||
|
# The block will be run in the context of the current running example.
|
||||||
|
# This means that values defined by `let` and `subject` are available.
|
||||||
|
define_example_hook :before_each
|
||||||
|
|
||||||
|
# Defines a block of code that will be invoked after every example in the group.
|
||||||
|
# The block will be run in the context of the current running example.
|
||||||
|
# This means that values defined by `let` and `subject` are available.
|
||||||
|
define_example_hook :after_each
|
||||||
|
|
||||||
|
# Defines a block of code that will be invoked around every example in the group.
|
||||||
|
# The block will be run in the context of the current running example.
|
||||||
|
# This means that values defined by `let` and `subject` are available.
|
||||||
|
#
|
||||||
|
# The block will execute before the example.
|
||||||
|
# An `Example::Procsy` is passed to the block.
|
||||||
|
# The `Example::Procsy#run` method should be called to ensure the example runs.
|
||||||
|
# More code can run afterwards (in the block).
|
||||||
|
define_example_hook :around_each
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
|
@ -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
|
||||||
|
|
107
src/spectator/dsl/memoize.cr
Normal file
107
src/spectator/dsl/memoize.cr
Normal 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
|
26
src/spectator/dsl/metadata.cr
Normal file
26
src/spectator/dsl/metadata.cr
Normal 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
|
|
@ -1,6 +1,7 @@
|
||||||
require "../mocks"
|
require "../mocks"
|
||||||
|
|
||||||
module Spectator::DSL
|
module Spectator::DSL
|
||||||
|
module Mocks
|
||||||
macro double(name = "Anonymous", **stubs, &block)
|
macro double(name = "Anonymous", **stubs, &block)
|
||||||
{% if name.is_a?(StringLiteral) || name.is_a?(StringInterpolation) %}
|
{% if name.is_a?(StringLiteral) || name.is_a?(StringInterpolation) %}
|
||||||
anonymous_double({{name}}, {{stubs.double_splat}})
|
anonymous_double({{name}}, {{stubs.double_splat}})
|
||||||
|
@ -56,7 +57,7 @@ module Spectator::DSL
|
||||||
end
|
end
|
||||||
|
|
||||||
def anonymous_double(name = "Anonymous", **stubs)
|
def anonymous_double(name = "Anonymous", **stubs)
|
||||||
Mocks::AnonymousDouble.new(name, stubs)
|
::Spectator::Mocks::AnonymousDouble.new(name, stubs)
|
||||||
end
|
end
|
||||||
|
|
||||||
macro null_double(name, **stubs, &block)
|
macro null_double(name, **stubs, &block)
|
||||||
|
@ -68,26 +69,22 @@ module Spectator::DSL
|
||||||
type_name = "Double#{safe_name}".id
|
type_name = "Double#{safe_name}".id
|
||||||
%}
|
%}
|
||||||
|
|
||||||
{% if block %}
|
{% if block.is_a?(Nop) %}
|
||||||
define_null_double({{type_name}}, {{name}}, {{stubs.double_splat}}) {{block}}
|
|
||||||
{% else %}
|
|
||||||
create_null_double({{type_name}}, {{name}}, {{stubs.double_splat}})
|
create_null_double({{type_name}}, {{name}}, {{stubs.double_splat}})
|
||||||
|
{% else %}
|
||||||
|
define_null_double({{type_name}}, {{name}}, {{stubs.double_splat}}) {{block}}
|
||||||
{% end %}
|
{% end %}
|
||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
macro create_null_double(type_name, name, **stubs)
|
macro create_null_double(type_name, name, **stubs)
|
||||||
{% if type_name.resolve? %}
|
{% type_name.resolve? || raise("Could not find a double labeled #{name}") %}
|
||||||
|
|
||||||
{{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
|
end
|
||||||
|
|
||||||
macro define_null_double(type_name, name, **stubs, &block)
|
macro define_null_double(type_name, name, **stubs, &block)
|
||||||
|
@ -107,7 +104,7 @@ module Spectator::DSL
|
||||||
end
|
end
|
||||||
|
|
||||||
def anonymous_null_double(name = "Anonymous", **stubs)
|
def anonymous_null_double(name = "Anonymous", **stubs)
|
||||||
AnonymousNullDouble.new(name, stubs)
|
::Spectator::Mocks::AnonymousNullDouble.new(name, stubs)
|
||||||
end
|
end
|
||||||
|
|
||||||
macro mock(name, &block)
|
macro mock(name, &block)
|
||||||
|
@ -149,32 +146,32 @@ module Spectator::DSL
|
||||||
end
|
end
|
||||||
|
|
||||||
def allow(thing)
|
def allow(thing)
|
||||||
Mocks::Allow.new(thing)
|
::Spectator::Mocks::Allow.new(thing)
|
||||||
end
|
end
|
||||||
|
|
||||||
def allow_any_instance_of(type : T.class) forall T
|
def allow_any_instance_of(type : T.class) forall T
|
||||||
Mocks::AllowAnyInstance(T).new
|
::Spectator::Mocks::AllowAnyInstance(T).new
|
||||||
end
|
end
|
||||||
|
|
||||||
macro expect_any_instance_of(type, _source_file = __FILE__, _source_line = __LINE__)
|
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}})
|
||||||
::Spectator::Mocks::ExpectAnyInstance({{type}}).new(%source)
|
::Spectator::Mocks::ExpectAnyInstance({{type}}).new(%location)
|
||||||
end
|
end
|
||||||
|
|
||||||
macro receive(method_name, _source_file = __FILE__, _source_line = __LINE__, &block)
|
macro receive(method_name, _source_file = __FILE__, _source_line = __LINE__, &block)
|
||||||
%source = ::Spectator::Source.new({{_source_file}}, {{_source_line}})
|
%location = ::Spectator::Location.new({{_source_file}}, {{_source_line}})
|
||||||
{% if block %}
|
{% if block %}
|
||||||
::Spectator::Mocks::ProcMethodStub.create({{method_name.id.symbolize}}, %source) { {{block.body}} }
|
::Spectator::Mocks::ProcMethodStub.create({{method_name.id.symbolize}}, %location) { {{block.body}} }
|
||||||
{% else %}
|
{% else %}
|
||||||
::Spectator::Mocks::NilMethodStub.new({{method_name.id.symbolize}}, %source)
|
::Spectator::Mocks::NilMethodStub.new({{method_name.id.symbolize}}, %location)
|
||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
macro receive_messages(_source_file = __FILE__, _source_line = __LINE__, **stubs)
|
macro receive_messages(_source_file = __FILE__, _source_line = __LINE__, **stubs)
|
||||||
%source = ::Spectator::Source.new({{_source_file}}, {{_source_line}})
|
%location = ::Spectator::Location.new({{_source_file}}, {{_source_line}})
|
||||||
%stubs = [] of ::Spectator::Mocks::MethodStub
|
%stubs = [] of ::Spectator::Mocks::MethodStub
|
||||||
{% for name, value in stubs %}
|
{% for name, value in stubs %}
|
||||||
%stubs << ::Spectator::Mocks::ValueMethodStub.new({{name.id.symbolize}}, %source, {{value}})
|
%stubs << ::Spectator::Mocks::ValueMethodStub.new({{name.id.symbolize}}, %location, {{value}})
|
||||||
{% end %}
|
{% end %}
|
||||||
%stubs
|
%stubs
|
||||||
end
|
end
|
||||||
|
@ -183,3 +180,4 @@ module Spectator::DSL
|
||||||
::Spectator::Mocks::NoArguments.new
|
::Spectator::Mocks::NoArguments.new
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
32
src/spectator/dsl/top.cr
Normal file
32
src/spectator/dsl/top.cr
Normal 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
|
|
@ -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
|
|
28
src/spectator/error_result.cr
Normal file
28
src/spectator/error_result.cr
Normal 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
|
|
@ -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
|
|
|
@ -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
|
||||||
|
|
||||||
|
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
|
ensure
|
||||||
|
@@current = previous_example
|
||||||
@finished = true
|
@finished = true
|
||||||
end
|
end
|
||||||
|
|
||||||
# Creates the base of the example.
|
|
||||||
# The group should be the example group the example belongs to.
|
|
||||||
def initialize(@group, @test_wrapper)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Indicates there is only one example to run.
|
private def run_internal
|
||||||
def example_count : Int
|
@group.try(&.call_before_each(self))
|
||||||
1
|
@entrypoint.call(self)
|
||||||
|
@finished = true
|
||||||
|
@group.try(&.call_after_each(self))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Retrieve the current example.
|
# Executes code within the example's test context.
|
||||||
def [](index : Int) : Example
|
# This is an advanced method intended for internal usage only.
|
||||||
self
|
#
|
||||||
|
# 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
|
||||||
|
|
||||||
# String representation of the example.
|
# Casts the example's test context to a specific type.
|
||||||
# This consists of the groups the example is in and the description.
|
# This is an advanced method intended for internal usage only.
|
||||||
# The string can be given to end-users to identify the example.
|
#
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Yields this example and all parent groups.
|
||||||
|
def ascend
|
||||||
|
node = self
|
||||||
|
while node
|
||||||
|
yield node
|
||||||
|
node = node.group?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Constructs the full name or description of the example.
|
||||||
|
# This prepends names of groups this example is part of.
|
||||||
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
|
||||||
|
|
27
src/spectator/example_builder.cr
Normal file
27
src/spectator/example_builder.cr
Normal 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
|
|
@ -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
|
|
|
@ -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
|
|
33
src/spectator/example_context_delegate.cr
Normal file
33
src/spectator/example_context_delegate.cr
Normal 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
|
10
src/spectator/example_context_method.cr
Normal file
10
src/spectator/example_context_method.cr
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
|
@ -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
|
||||||
|
|
58
src/spectator/example_group_builder.cr
Normal file
58
src/spectator/example_group_builder.cr
Normal 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
|
57
src/spectator/example_group_hook.cr
Normal file
57
src/spectator/example_group_hook.cr
Normal 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
|
25
src/spectator/example_group_iteration.cr
Normal file
25
src/spectator/example_group_iteration.cr
Normal 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
|
52
src/spectator/example_hook.cr
Normal file
52
src/spectator/example_hook.cr
Normal 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
|
|
@ -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
|
|
|
@ -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
|
||||||
# Otherwise, advance and check the next one.
|
# in reverse order so that the tree is traversed in pre-order.
|
||||||
return item if item.is_a?(Example)
|
if node.is_a?(Indexable(Node))
|
||||||
|
node.reverse_each { |child| @stack.push(child) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Return the node if it's an example.
|
||||||
|
# Otherwise, advance and check the next one.
|
||||||
|
return node if node.is_a?(Example)
|
||||||
|
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
|
||||||
|
|
14
src/spectator/example_pending.cr
Normal file
14
src/spectator/example_pending.cr
Normal 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
|
54
src/spectator/example_procsy_hook.cr
Normal file
54
src/spectator/example_procsy_hook.cr
Normal 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
|
187
src/spectator/expectation.cr
Normal file
187
src/spectator/expectation.cr
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
require "./expectations/*"
|
|
||||||
|
|
||||||
module Spectator
|
|
||||||
# Namespace that contains all expectations, partials, and handling of them.
|
|
||||||
module Expectations
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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
|
|
18
src/spectator/expression.cr
Normal file
18
src/spectator/expression.cr
Normal 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
|
81
src/spectator/fail_result.cr
Normal file
81
src/spectator/fail_result.cr
Normal 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
|
|
@ -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
|
|
85
src/spectator/filtered_example_iterator.cr
Normal file
85
src/spectator/filtered_example_iterator.cr
Normal 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
|
|
@ -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
|
|
87
src/spectator/formatting/broadcast_formatter.cr
Normal file
87
src/spectator/formatting/broadcast_formatter.cr
Normal 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
|
|
@ -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
|
|
|
@ -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
|
|
8
src/spectator/formatting/components.cr
Normal file
8
src/spectator/formatting/components.cr
Normal 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
|
32
src/spectator/formatting/components/block.cr
Normal file
32
src/spectator/formatting/components/block.cr
Normal 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
|
23
src/spectator/formatting/components/comment.cr
Normal file
23
src/spectator/formatting/components/comment.cr
Normal 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
|
81
src/spectator/formatting/components/error_result_block.cr
Normal file
81
src/spectator/formatting/components/error_result_block.cr
Normal 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
|
26
src/spectator/formatting/components/example_command.cr
Normal file
26
src/spectator/formatting/components/example_command.cr
Normal 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
|
49
src/spectator/formatting/components/fail_result_block.cr
Normal file
49
src/spectator/formatting/components/fail_result_block.cr
Normal 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
|
21
src/spectator/formatting/components/failure_command_list.cr
Normal file
21
src/spectator/formatting/components/failure_command_list.cr
Normal 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
Loading…
Add table
Add a link
Reference in a new issue