diff --git a/CHANGELOG.md b/CHANGELOG.md index 6541b7b..3acacc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) ### Changed - `around_each` hooks wrap `before_all` and `after_all` hooks. [#12](https://github.com/icy-arctic-fox/spectator/issues/12) diff --git a/spec/spectator/aggregate_failures_spec.cr b/spec/spectator/aggregate_failures_spec.cr new file mode 100644 index 0000000..5fa5074 --- /dev/null +++ b/spec/spectator/aggregate_failures_spec.cr @@ -0,0 +1,32 @@ +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 + end +end diff --git a/src/spectator/dsl/expectations.cr b/src/spectator/dsl/expectations.cr index c42d272..7a228a1 100644 --- a/src/spectator/dsl/expectations.cr +++ b/src/spectator/dsl/expectations.cr @@ -162,5 +162,20 @@ module Spectator::DSL 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 + ::Spectator::Harness.current.aggregate_failures do + yield + end + end end end diff --git a/src/spectator/harness.cr b/src/spectator/harness.cr index 8034262..100b4ac 100644 --- a/src/spectator/harness.cr +++ b/src/spectator/harness.cr @@ -2,7 +2,9 @@ require "./error_result" require "./example_failed" require "./example_pending" require "./expectation" +require "./expectation_failed" require "./mocks" +require "./multiple_expectations_failed" require "./pass_result" require "./result" @@ -66,6 +68,7 @@ module Spectator @deferred = Deque(->).new @expectations = [] of Expectation + @aggregate : Array(Expectation)? = nil # Runs test code and produces a result based on the outcome. # The test code should be called from within the block given to this method. @@ -75,14 +78,20 @@ module Spectator translate(elapsed + elapsed2, error || error2) end - def report(expectation : Expectation) : Nil + def report(expectation : Expectation) : Bool Log.debug { "Reporting expectation #{expectation}" } @expectations << expectation # TODO: Move this out of harness, maybe to `Example`. Example.current.name = expectation.description unless Example.current.name? - raise ExpectationFailed.new(expectation, expectation.failure_message) if expectation.failed? + if expectation.failed? + raise ExpectationFailed.new(expectation, expectation.failure_message) unless (aggregate = @aggregate) + aggregate << expectation + false + else + true + end end # Stores a block of code to be executed later. @@ -91,6 +100,31 @@ module Spectator @deferred << block end + def aggregate_failures + previous = @aggregate + @aggregate = aggregate = [] of Expectation + begin + yield.tap do + # If there's an nested aggregate (for some reason), allow the top-level one to handle things. + check_aggregate(aggregate) unless previous + end + ensure + @aggregate = previous + end + end + + private def check_aggregate(aggregate) + failures = aggregate.select(&.failed?) + case failures.size + when 0 then return + when 1 + expectation = failures.first + raise ExpectationFailed.new(expectation, expectation.failure_message) + else + raise MultipleExpectationsFailed.new(failures, "Got #{failures.size} failures from failure aggregation block") + end + end + # Yields to run the test code and returns information about the outcome. # Returns a tuple with the elapsed time and an error if one occurred (otherwise nil). private def capture : Tuple(Time::Span, Exception?) diff --git a/src/spectator/multiple_expectations_failed.cr b/src/spectator/multiple_expectations_failed.cr new file mode 100644 index 0000000..9a911fa --- /dev/null +++ b/src/spectator/multiple_expectations_failed.cr @@ -0,0 +1,16 @@ +require "./example_failed" +require "./expectation" + +module Spectator + # Exception that indicates more than one expectation from a test failed. + # When raised within a test, the test should abort. + class MultipleExpectationsFailed < ExampleFailed + # Expectations that failed. + getter expectations : Array(Expectation) + + # Creates the exception. + def initialize(@expectations : Array(Expectation), message : String? = nil, cause : Exception? = nil) + super(nil, message, cause) + end + end +end