From 096c31d7f5aca6fbfa424cd05cac5fd05a8b8725 Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 9 Jan 2021 19:50:32 -0700 Subject: [PATCH] Initial work on assertions --- src/spectator/assertion.cr | 15 + src/spectator/assertion_failed.cr | 15 + src/spectator/dsl.cr | 1 + src/spectator/dsl/assertions.cr | 437 +++++++++++++++------------- src/spectator/example_failed.cr | 5 - src/spectator/expectation_failed.cr | 15 - src/spectator/test_context.cr | 1 + 7 files changed, 263 insertions(+), 226 deletions(-) create mode 100644 src/spectator/assertion.cr create mode 100644 src/spectator/assertion_failed.cr delete mode 100644 src/spectator/example_failed.cr delete mode 100644 src/spectator/expectation_failed.cr diff --git a/src/spectator/assertion.cr b/src/spectator/assertion.cr new file mode 100644 index 0000000..d2813ce --- /dev/null +++ b/src/spectator/assertion.cr @@ -0,0 +1,15 @@ +require "./block" +require "./expression" + +module Spectator + class Assertion + struct Target(T) + @expression : Expression(T) | Block(T) + @source : Source? + + def initialize(@expression : Expression(T) | Block(T), @source) + puts "TARGET: #{@expression} @ #{@source}" + end + end + end +end diff --git a/src/spectator/assertion_failed.cr b/src/spectator/assertion_failed.cr new file mode 100644 index 0000000..81b28d2 --- /dev/null +++ b/src/spectator/assertion_failed.cr @@ -0,0 +1,15 @@ +require "./source" + +module Spectator + # Exception that indicates an assertion failed. + # When raised within a test, the test should abort. + class AssertionFailed < Exception + # Location where the assertion failed and the exception raised. + getter source : Source + + # Creates the exception. + def initialize(@source : Source, message : String? = nil, cause : Exception? = nil) + super(message, cause) + end + end +end diff --git a/src/spectator/dsl.cr b/src/spectator/dsl.cr index 46c7308..e206d1b 100644 --- a/src/spectator/dsl.cr +++ b/src/spectator/dsl.cr @@ -1,4 +1,5 @@ # require "./dsl/*" +require "./dsl/assertions" require "./dsl/builder" require "./dsl/examples" require "./dsl/groups" diff --git a/src/spectator/dsl/assertions.cr b/src/spectator/dsl/assertions.cr index 11b5ffe..1c2ee93 100644 --- a/src/spectator/dsl/assertions.cr +++ b/src/spectator/dsl/assertions.cr @@ -1,216 +1,241 @@ -require "../expectations/expectation_partial" +require "../assertion" +require "../assertion_failed" +require "../expression" 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) +module Spectator::DSL + # Methods and macros for asserting that conditions are met. + module Assertions + # Checks that the specified condition is true. + # Raises `AssertionFailed` if *condition* is false. + # The *message* is passed to the exception. + def assert(condition, message, *, _file = __FILE__, _line = __LINE__) + raise AssertionFailed.new(Source.new(_file, _line), message) unless condition 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(_source_file = __FILE__, _source_line = __LINE__, &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({{_source_file}}, {{_source_line}}) - ::Spectator::Expectations::ExpectationPartial.new(%test_block, %source) + # Checks that the specified condition is true. + # Raises `AssertionFailed` if *condition* is false. + # The message of the exception is the *condition*. + macro assert(condition) + assert({{condition}}, {{condition.stringify}}, _file: {{condition.filename}}, _line: {{condition.line_number}}) 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 + macro expect(actual) + %actual = begin + {{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") + %expression = ::Spectator::Expression.new(%actual, {{actual.stringify}}) + %source = ::Spectator::Source.new({{actual.filename}}, {{actual.line_number}}) + ::Spectator::Assertion::Target.new(%expression, %source) end 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 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(_source_file = __FILE__, _source_line = __LINE__, &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({{_source_file}}, {{_source_line}}) + ::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 diff --git a/src/spectator/example_failed.cr b/src/spectator/example_failed.cr deleted file mode 100644 index a1b74f7..0000000 --- a/src/spectator/example_failed.cr +++ /dev/null @@ -1,5 +0,0 @@ -module Spectator - # Exception that indicates an example failed and should abort. - class ExampleFailed < Exception - end -end diff --git a/src/spectator/expectation_failed.cr b/src/spectator/expectation_failed.cr deleted file mode 100644 index 05246da..0000000 --- a/src/spectator/expectation_failed.cr +++ /dev/null @@ -1,15 +0,0 @@ -require "./example_failed" - -module Spectator - # Exception that indicates a required expectation was not met in an example. - class ExpectationFailed < ExampleFailed - # Expectation that failed. - getter expectation : Expectations::Expectation - - # Creates the exception. - # The exception string is generated from the expecation message. - def initialize(@expectation) - super(@expectation.failure_message) - end - end -end diff --git a/src/spectator/test_context.cr b/src/spectator/test_context.cr index 8616ba8..c6ca933 100644 --- a/src/spectator/test_context.cr +++ b/src/spectator/test_context.cr @@ -6,6 +6,7 @@ require "./dsl" # 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. class SpectatorTestContext < SpectatorContext + include ::Spectator::DSL::Assertions include ::Spectator::DSL::Examples include ::Spectator::DSL::Groups include ::Spectator::DSL::Hooks