From 53cbf8190e68b45a8856b6255e5300e20fab4323 Mon Sep 17 00:00:00 2001 From: Thomas Sawyer Date: Mon, 28 Sep 2026 17:08:26 -0400 Subject: [PATCH 1/2] Route RubyTest results through recorder --- HISTORY.md | 2 + README.md | 14 +++ demo/04_result_pipeline.md | 151 +++++++++++++++++++++++++++ lib/rubytest.rb | 1 + lib/rubytest/format/abstract_hash.rb | 52 ++++++--- lib/rubytest/format/dotprogress.rb | 137 +++++++++++++----------- lib/rubytest/recorder.rb | 120 ++++++++++++++++----- lib/rubytest/result.rb | 53 ++++++++++ lib/rubytest/runner.rb | 118 ++++++++++++++++----- 9 files changed, 524 insertions(+), 124 deletions(-) create mode 100644 demo/04_result_pipeline.md create mode 100644 lib/rubytest/result.rb diff --git a/HISTORY.md b/HISTORY.md index 4afc7ee..98b49cb 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,6 +2,8 @@ ## Unreleased +* Route structured test results through the recorder to reporters, while + retaining Advice hooks and older reporter callbacks. * Fail empty runs and unmatched test file requests instead of reporting success. * Apply test filters at every suite level and record skipped tests and cases. * Record case errors, finish suite callbacks, and restore reporter output after diff --git a/README.md b/README.md index 5c0415a..6d5b22f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,20 @@ configured to do so. That is the crux of Rubytest specification. Rubytest supports some additional features that can make its usage even more convenient. +### Results and reporting + +The runner turns each completed test into a `Test::Result` with its status, +exception or skip reason, elapsed time, and captured output when requested. +Skipped and broken cases also produce results. `Test::Recorder` keeps these +results, calculates the run summary, and forwards reporting events to the +selected reporter. Advice hooks remain with the runner, where they can affect +test execution. + +Reporters can implement `record(result)` for completed outcomes and +`finish(summary)` for the final totals. Suite and case boundary callbacks are +available for formats that show nesting. Older reporters using `pass`, `fail`, +`error`, `todo`, and skip callbacks continue to work through the recorder. + ## Installation diff --git a/demo/04_result_pipeline.md b/demo/04_result_pipeline.md new file mode 100644 index 0000000..491b2a0 --- /dev/null +++ b/demo/04_result_pipeline.md @@ -0,0 +1,151 @@ +## Result pipeline + +The runner sends completed outcomes to the recorder. The recorder keeps them +and forwards the same result objects, along with case boundaries, to a reporter. + + events = [] + reporter = Object.new + reporter.define_singleton_method(:begin_suite) { |suite| events << :begin_suite } + reporter.define_singleton_method(:begin_case) { |tcase| events << :begin_case } + reporter.define_singleton_method(:begin_test) { |test| events << :begin_test } + reporter.define_singleton_method(:record) { |result| events << result } + reporter.define_singleton_method(:end_test) { |test| events << :end_test } + reporter.define_singleton_method(:end_case) { |tcase| events << :end_case } + reporter.define_singleton_method(:finish) { |summary| events << summary } + + runner_type = Class.new(Test::Runner) do + define_method(:reporter_load) { |_format| reporter } + end + + passing = -> { true } + failing = -> { raise Assertion, 'no' } + broken = -> { raise 'unexpected' } + pending = -> { raise NotImplementedError, 'later' } + skipped = -> { raise 'must not run' } + skipped.define_singleton_method(:skip?) { 'later' } + + skipped_case = [-> { raise 'must not run' }] + skipped_case.define_singleton_method(:skip?) { 'case later' } + broken_case = Class.new(Array) do + def call + raise 'case setup failed' + end + end.new + + runner = runner_type.new(suite: [[passing, failing], broken, pending, skipped, + skipped_case, broken_case], format: 'test') + runner.run.assert == false + + statuses = runner.recorder.results.map(&:status) + statuses.assert == [:pass, :fail, :error, :todo, :skip, :skip, :error] + runner.recorder.results.map(&:kind).assert == + [:test, :test, :test, :test, :test, :case, :case] + runner.recorder[:fail].first.first.assert.equal? failing + runner.recorder[:skip].size.assert == 2 + events.grep(Test::Result).map(&:object_id).assert == + runner.recorder.results.map(&:object_id) + events.count(:begin_case).assert == 2 + events.count(:end_case).assert == 2 + summary = events.last + summary.class.assert == Test::RunSummary + summary.counts[:error].assert == 2 + summary.success?.assert == false + +An after hook can turn an otherwise passing test into a failed result before +that result is recorded. + + hook_reporter = Object.new + hook_reporter.define_singleton_method(:record) { |result| } + hook_reporter.define_singleton_method(:finish) { |summary| } + hook_runner_type = Class.new(Test::Runner) do + define_method(:reporter_load) { |_format| hook_reporter } + end + hook_runner = hook_runner_type.new(suite: [-> { true }], format: 'test') + hook_runner.after(:test) { raise Assertion, 'verification failed' } + hook_runner.run.assert == false + hook_runner.recorder.results.first.status.assert == :fail + hook_runner.recorder.results.first.exception.message.include?('verification failed').assert == true + +A failing before hook is recorded as an error; the test body is not called, +and the after hook still runs. + + calls = 0 + cleanup_ran = false + before_runner = hook_runner_type.new(suite: [-> { calls += 1 }], format: 'test') + before_runner.before(:test) { raise 'setup failed' } + before_runner.after(:test) { cleanup_ran = true } + before_runner.run.assert == false + before_runner.recorder.results.first.status.assert == :error + calls.assert == 0 + cleanup_ran.assert == true + +A case after hook error is recorded after its child test and case reporting +still ends. + + case_end_events = [] + case_end_reporter = Object.new + case_end_reporter.define_singleton_method(:record) { |result| case_end_events << result.status } + case_end_reporter.define_singleton_method(:end_case) { |tcase| case_end_events << :end_case } + case_end_reporter.define_singleton_method(:finish) { |summary| } + case_end_runner_type = Class.new(Test::Runner) do + define_method(:reporter_load) { |_format| case_end_reporter } + end + case_end_runner = case_end_runner_type.new(suite: [[-> { true }]], format: 'test') + case_end_runner.after(:case) { raise 'case teardown failed' } + case_end_runner.run.assert == false + case_end_events.assert == [:pass, :error, :end_case] + + case_end_events.clear + case_body_calls = 0 + case_cleanup_ran = false + case_start_runner = case_end_runner_type.new(suite: [[-> { case_body_calls += 1 }]], format: 'test') + case_start_runner.before(:case) { raise 'case setup failed' } + case_start_runner.after(:case) { case_cleanup_ran = true } + case_start_runner.run.assert == false + case_end_events.assert == [:error, :end_case] + case_body_calls.assert == 0 + case_cleanup_ran.assert == true + +Reporters that still use the old status callbacks work through the Recorder. + + legacy_events = [] + legacy_reporter = Object.new + legacy_reporter.define_singleton_method(:pass) { |test| legacy_events << [:pass, test] } + legacy_reporter.define_singleton_method(:end_suite) { |suite| legacy_events << [:end_suite, suite] } + legacy_recorder = Test::Recorder.new(legacy_reporter) + legacy_suite = [] + legacy_recorder.begin_suite(legacy_suite) + legacy_result = Test::Result.new(test: passing, status: :pass) + legacy_recorder.record(legacy_result) + legacy_recorder.end_suite(legacy_suite) + legacy_events.assert == [[:pass, passing], [:end_suite, legacy_suite]] + legacy_recorder.results.first.assert.equal? legacy_result + +The hash reporter receives captured output in its result, and the runner +restores the process streams after execution. + + require 'rubytest/format/test' + hash_reporter_type = Class.new(Test::Reporters::Test) do + attr_reader :rows + + def initialize(runner) + super + @rows = [] + end + + def record(result) + @rows << super + end + end + hash_runner_type = Class.new(Test::Runner) do + define_method(:reporter_load) { |_format| hash_reporter_type.new(self) } + end + original_stdout, original_stderr = $stdout, $stderr + output_runner = hash_runner_type.new(suite: [-> { print 'hello'; warn 'oops' }], format: 'test') + output_runner.upon(:pass) { print ' from hook' } + output_runner.run.assert == true + row = output_runner.reporter.rows.first + row['stdout'].assert == 'hello from hook' + row['stderr'].assert == "oops\n" + ($stdout.equal?(original_stdout)).assert == true + ($stderr.equal?(original_stderr)).assert == true diff --git a/lib/rubytest.rb b/lib/rubytest.rb index b703fc0..61440c5 100644 --- a/lib/rubytest.rb +++ b/lib/rubytest.rb @@ -12,6 +12,7 @@ module Test require_relative 'rubytest/core_ext' require_relative 'rubytest/code_snippet' require_relative 'rubytest/config' +require_relative 'rubytest/result' require_relative 'rubytest/recorder' require_relative 'rubytest/advice' require_relative 'rubytest/runner' diff --git a/lib/rubytest/format/abstract_hash.rb b/lib/rubytest/format/abstract_hash.rb index e4f4fef..d0ec7f6 100644 --- a/lib/rubytest/format/abstract_hash.rb +++ b/lib/rubytest/format/abstract_hash.rb @@ -7,6 +7,33 @@ module Test::Reporters # class AbstractHash < Abstract + def capture_output? + true + end + + # New reporter entry point. The status callbacks remain available to + # subclasses that format TAP-Y, TAP-J, or other hash-based output. + def record(result = nil) + return super() unless result # historical access to runner.recorder + + @current_result = result + if result.status == :skip + method = result.kind == :case ? :skip_case : :skip_test + public_send(method, result.test, result.reason) + elsif result.status == :pass + pass(result.test) + else + public_send(result.status, result.test, result.exception) + end + ensure + @current_result = nil if result + end + + def finish(summary) + @summary = summary + end_suite(summary.suite) + end + # # @return [Hash] # @@ -51,9 +78,6 @@ def begin_case(test_case) # def begin_test(test) @test_index += 1 - - @stdout, @stderr = $stdout, $stderr - $stdout, $stderr = StringIO.new, StringIO.new end # Ruby Test use the term "skip", where as TAP-Y/J uses "omit". @@ -170,8 +194,6 @@ def todo(test, exception) # def end_test(test) super(test) - ensure - $stdout, $stderr = @stdout, @stderr if @stdout && @stderr end # @@ -185,14 +207,14 @@ def end_case(test_case) def end_suite(suite) h = { 'type' => 'final', - 'time' => Time.now - @start_time, + 'time' => @summary ? @summary.elapsed : Time.now - @start_time, 'counts' => { - 'total' => total, - 'pass' => record[:pass].size, - 'fail' => record[:fail].size, - 'error' => record[:error].size, - 'omit' => record[:omit].size, - 'todo' => record[:todo].size + 'total' => @summary ? @summary.total : total, + 'pass' => @summary ? @summary.counts[:pass] : record[:pass].size, + 'fail' => @summary ? @summary.counts[:fail] : record[:fail].size, + 'error' => @summary ? @summary.counts[:error] : record[:error].size, + 'omit' => @summary ? @summary.counts[:skip] : record[:omit].size, + 'todo' => @summary ? @summary.counts[:todo] : record[:todo].size } } return h @@ -282,13 +304,13 @@ def merge_coverage(hash, test) # def merge_output(hash) - hash['stdout'] = $stdout.respond_to?(:string) ? $stdout.string : '' - hash['stderr'] = $stderr.respond_to?(:string) ? $stderr.string : '' + hash['stdout'] = @current_result ? @current_result.stdout : '' + hash['stderr'] = @current_result ? @current_result.stderr : '' end # def merge_time(hash) - hash['time'] = Time.now - @start_time + hash['time'] = @current_result ? @current_result.elapsed : Time.now - @start_time end end diff --git a/lib/rubytest/format/dotprogress.rb b/lib/rubytest/format/dotprogress.rb index 23af477..ec16142 100644 --- a/lib/rubytest/format/dotprogress.rb +++ b/lib/rubytest/format/dotprogress.rb @@ -5,86 +5,105 @@ module Test::Reporters # Simple Dot-Progress Reporter class Dotprogress < Abstract - def skip_test(unit, reason) - print "S".ansi(:cyan) if runner.verbose? + def initialize(runner, output: $stdout) + super(runner) + @output = output + @verbose = runner.verbose? end - def pass(unit) - print "." - $stdout.flush + def record(result = nil) + return super() unless result # historical access to runner.recorder + + if result.status == :skip + method = result.kind == :case ? :skip_case : :skip_test + public_send(method, result.test, result.reason) + elsif result.status == :pass + pass(result.test) + else + public_send(result.status, result.test, result.exception) + end + end + + def finish(summary) + @summary = summary + end_suite(summary.suite) + end + + # Keep the old callbacks useful to subclasses of this reporter. + def skip_test(test, reason) + emit('S'.ansi(:cyan)) if @verbose + end + + def pass(test) + emit('.') end - def fail(unit, exception) - print "F".ansi(:red) - $stdout.flush + def fail(test, exception) + emit('F'.ansi(:red)) end - def error(unit, exception) - print "E".ansi(:red, :bold) - $stdout.flush + def error(test, exception) + emit('E'.ansi(:red, :bold)) end - def todo(unit, exception) - print "P".ansi(:yellow) - $stdout.flush + def todo(test, exception) + emit('P'.ansi(:yellow)) end def end_suite(suite) - puts; puts - puts timestamp - puts - - if runner.verbose? - unless record[:skip].empty? - puts "SKIPPED\n\n" - record[:skip].each do |test, reason| - puts " #{test}".ansi(:bold) - puts " #{reason}" if String===reason - puts - end - end - end + summary = @summary + @output.puts + @output.puts + rate = summary.elapsed.zero? ? 0.0 : summary.total / summary.elapsed + @output.puts "Finished in %.5fs, %.2f tests/s." % [summary.elapsed, rate] + @output.puts - unless record[:todo].empty? - puts "PENDING\n\n" - record[:todo].each do |test, exception| - puts " #{test}".ansi(:bold) unless test.to_s.empty? - puts " #{exception}" - puts " #{file_and_line(exception)}" - puts code(exception) - puts + if @verbose && summary.counts[:skip].positive? + @output.puts "SKIPPED\n\n" + summary.results_for(:skip).each do |result| + @output.puts " #{result.test}".ansi(:bold) + @output.puts " #{result.reason}" if String === result.reason + @output.puts end end - unless record[:fail].empty? - puts "FAILURES\n\n" - record[:fail].each do |test_unit, exception| - puts " #{test_unit}".ansi(:bold) - puts " #{exception}" - puts " #{file_and_line(exception)}" - puts code(exception) - puts " " + clean_backtrace(exception).join("\n ") - puts + {todo: 'PENDING', fail: 'FAILURES', error: 'ERRORS'}.each do |status, title| + next if summary.counts[status].zero? + @output.puts "#{title}\n\n" + summary.results_for(status).each do |result| + exception = result.exception + @output.puts " #{result.test}".ansi(:bold) unless status == :todo && result.test.to_s.empty? + @output.puts " #{exception}" + @output.puts " #{file_and_line(exception)}" + @output.puts code(exception) + @output.puts " " + clean_backtrace(exception).join("\n ") unless status == :todo + @output.puts end end - unless record[:error].empty? - puts "ERRORS\n\n" - record[:error].each do |test_unit, exception| - puts " #{test_unit}".ansi(:bold) - puts " #{exception}" - puts " #{file_and_line(exception)}" - puts code(exception) - puts " " + clean_backtrace(exception).join("\n ") - puts - end + if summary.total.zero? + @output.puts 'No tests were run.' + else + @output.puts tally_for(summary) end + end - if total.zero? - puts 'No tests were run.' - else - puts tally + private + + def emit(symbol) + @output.print(symbol) + @output.flush + end + + def tally_for(summary) + items = [:pass, :error, :fail, :todo, :skip].filter_map do |status| + count = summary.counts[status] + next if count.zero? + title = TITLES[status].downcase + percentage = @verbose ? " (%.1f%%)" % (count.to_f / summary.total * 100) : '' + "#{count.to_s.ansi(:bold)} #{title}#{percentage}" end + "Executed #{summary.total.to_s.ansi(:bold)} tests with #{items.join(', ')}." end end diff --git a/lib/rubytest/recorder.rb b/lib/rubytest/recorder.rb index 28ec69f..2734efe 100644 --- a/lib/rubytest/recorder.rb +++ b/lib/rubytest/recorder.rb @@ -1,59 +1,127 @@ module Test - - # Recorder class is an observer that tracks all tests - # that are run and categorizes them according to their - # test status. + # Owns run results and controls when they reach the reporter. class Recorder + attr_reader :results, :reporter, :summary - def initialize - @table = Hash.new{ |h,k| h[k] = [] } + def initialize(reporter = nil) + @reporter = reporter + @results = [] + @table = Hash.new { |hash, key| hash[key] = [] } end + # Keep the historical status-indexed view for callers and older reporters. def [](key) @table[key.to_sym] end - # - def skip_test(test, reason) - self[:skip] << [test, reason] + def begin_suite(suite) + @suite = suite + @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + reporter.begin_suite(suite) if reporter&.respond_to?(:begin_suite) end - def skip_case(test_case, reason) - self[:skip] << [test_case, reason] + def begin_case(test_case) + reporter.begin_case(test_case) if reporter&.respond_to?(:begin_case) + end + + def begin_test(test) + reporter.begin_test(test) if reporter&.respond_to?(:begin_test) + end + + def capture_output? + !!(reporter && reporter.respond_to?(:capture_output?) && reporter.capture_output?) + end + + def record(result) + @results << result + case result.status + when :pass + self[:pass] << result.test + when :skip + self[:skip] << [result.test, result.reason] + else + self[result.status] << [result.test, result.exception] + end + + return result unless reporter + + if native_reporter? + reporter.record(result) + else + report_legacy_result(result) + end + result + end + + def end_test(test) + reporter.end_test(test) if reporter&.respond_to?(:end_test) + end + + def end_case(test_case) + reporter.end_case(test_case) if reporter&.respond_to?(:end_case) + end + + def end_suite(suite) + @summary = RunSummary.new(suite: suite, results: results, elapsed: elapsed) + if reporter + if native_reporter? + reporter.finish(@summary) + else + reporter.end_suite(suite) + end + end + @summary end - # Add `test` to pass set. + def success? + (@summary || RunSummary.new(suite: @suite, results: results, elapsed: elapsed)).success? + end + + # Compatibility for code that sent status callbacks to the recorder. def pass(test) - self[:pass] << test + record(Result.new(test: test, status: :pass)) end def fail(test, exception) - self[:fail] << [test, exception] + record(Result.new(test: test, status: :fail, exception: exception)) end def error(test, exception) - self[:error] << [test, exception] + record(Result.new(test: test, status: :error, exception: exception)) end def todo(test, exception) - self[:todo] << [test, exception] + record(Result.new(test: test, status: :todo, exception: exception)) + end + + def skip_test(test, reason) + record(Result.new(test: test, status: :skip, reason: reason)) end - #def omit(test, exception) - # self[:omit] << [test, exception] - #end + def skip_case(test_case, reason) + record(Result.new(test: test_case, kind: :case, status: :skip, reason: reason)) + end - # Returns true if tests were recorded without errors or failures. - def success? - return false unless self[:error].empty? && self[:fail].empty? + private - [:pass, :todo, :skip].any?{ |status| !self[status].empty? } + def native_reporter? + reporter.respond_to?(:record) && reporter.respond_to?(:finish) end - # Ignore any other signals. - def method_missing(*a) + def elapsed + return 0.0 unless @started_at + Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at end + def report_legacy_result(result) + if result.status == :skip + method = result.kind == :case ? :skip_case : :skip_test + reporter.public_send(method, result.test, result.reason) + elsif result.status == :pass + reporter.pass(result.test) + else + reporter.public_send(result.status, result.test, result.exception) + end + end end - end diff --git a/lib/rubytest/result.rb b/lib/rubytest/result.rb new file mode 100644 index 0000000..1469640 --- /dev/null +++ b/lib/rubytest/result.rb @@ -0,0 +1,53 @@ +module Test + # The outcome of one test, or of a case that was skipped or raised an error. + class Result + STATUSES = [:pass, :fail, :error, :todo, :skip].freeze + KINDS = [:test, :case].freeze + + attr_reader :test, :kind, :status, :exception, :reason, :elapsed, :stdout, :stderr + + def initialize(test:, status:, kind: :test, exception: nil, reason: nil, + elapsed: 0.0, stdout: '', stderr: '') + raise ArgumentError, "unknown result status: #{status.inspect}" unless STATUSES.include?(status) + raise ArgumentError, "unknown result kind: #{kind.inspect}" unless KINDS.include?(kind) + + @test = test + @kind = kind + @status = status + @exception = exception + @reason = reason + @elapsed = elapsed + @stdout = stdout.to_s.dup.freeze + @stderr = stderr.to_s.dup.freeze + freeze + end + end + + # A snapshot of everything recorded during one run. + class RunSummary + attr_reader :suite, :results, :counts, :elapsed + + def initialize(suite:, results:, elapsed:) + @suite = suite + @results = results.dup.freeze + @elapsed = elapsed + @counts = Result::STATUSES.each_with_object({}) do |status, counts| + counts[status] = @results.count { |result| result.status == status } + end.freeze + freeze + end + + def total + results.size + end + + def results_for(status) + results.select { |result| result.status == status } + end + + def success? + counts[:fail].zero? && counts[:error].zero? && + (counts[:pass] + counts[:todo] + counts[:skip]).positive? + end + end +end diff --git a/lib/rubytest/runner.rb b/lib/rubytest/runner.rb index a94d47c..2582765 100644 --- a/lib/rubytest/runner.rb +++ b/lib/rubytest/runner.rb @@ -1,3 +1,5 @@ +require 'stringio' + module Test # Alias for `Test.configure`. @@ -117,8 +119,7 @@ def upon(type, &block) # Record pass, fail, error and pending tests. attr :recorder - # Array of observers, typically this just contains the recorder and - # reporter instances. + # Execution hooks and the reporting pipeline entry point. attr :observers # Run test suite. @@ -145,17 +146,24 @@ def run end @reporter = reporter_load(format) - @recorder = Recorder.new + @recorder = Recorder.new(@reporter) - @observers = [advice, @recorder, @reporter] + @observers = [advice, @recorder] started = false begin - observers.each{ |o| o.begin_suite(suite) } + advice.begin_suite(suite) + recorder.begin_suite(suite) started = true run_thru(suite) ensure - observers.each{ |o| o.end_suite(suite) } if started + if started + begin + advice.end_suite(suite) + ensure + recorder.end_suite(suite) + end + end end ensure config.after.call if config.after @@ -196,13 +204,26 @@ def run_thru(list) # def run_case(tcase) if tcase.respond_to?(:skip?) && (reason = tcase.skip?) - return observers.each{ |o| o.skip_case(tcase, reason) } + advice.skip_case(tcase, reason) + return recorder.record(Result.new(test: tcase, kind: :case, + status: :skip, reason: reason)) end - observers.each{ |o| o.begin_case(tcase) } + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + before_error = nil + begin + advice.begin_case(tcase) + rescue *OPEN_ERRORS + raise + rescue Exception => hook_error + before_error = hook_error + end + recorder.begin_case(tcase) begin - if tcase.respond_to?(:call) + if before_error + raise before_error + elsif tcase.respond_to?(:call) tcase.call do run_thru(tcase) end @@ -212,9 +233,21 @@ def run_case(tcase) rescue *OPEN_ERRORS raise rescue Exception => exception - observers.each{ |o| o.error(tcase, exception) } + advice.error(tcase, exception) + recorder.record(Result.new(test: tcase, kind: :case, status: :error, + exception: exception, elapsed: elapsed_since(started))) ensure - observers.each{ |o| o.end_case(tcase) } + begin + advice.end_case(tcase) + rescue *OPEN_ERRORS + raise + rescue Exception => exception + advice.error(tcase, exception) + recorder.record(Result.new(test: tcase, kind: :case, status: :error, + exception: exception, elapsed: elapsed_since(started))) + ensure + recorder.end_case(tcase) + end end end @@ -225,31 +258,68 @@ def run_case(tcase) # def run_test(test) if test.respond_to?(:skip?) && (reason = test.skip?) - return observers.each{ |o| o.skip_test(test, reason) } + advice.skip_test(test, reason) + return recorder.record(Result.new(test: test, status: :skip, reason: reason)) end - observers.each{ |o| o.begin_test(test) } + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + exception = nil begin - exception = nil + advice.begin_test(test) + rescue *OPEN_ERRORS + raise + rescue Exception => hook_error + exception = hook_error + end + + recorder.begin_test(test) + report_started = true + capture = recorder.capture_output? + original_stdout, original_stderr = $stdout, $stderr if capture + captured_stdout, captured_stderr = StringIO.new, StringIO.new if capture + begin + $stdout, $stderr = captured_stdout, captured_stderr if capture begin - success = test.call - raise Assertion, "failure of #{test}" if config.hard? && !success + unless exception + success = test.call + raise Assertion, "failure of #{test}" if config.hard? && !success + end rescue *OPEN_ERRORS raise - rescue NotImplementedError => exception - result = :todo rescue Exception => exception - result = exception.assertion? ? :fail : :error - else - result = :pass + ensure + begin + advice.end_test(test) + rescue *OPEN_ERRORS + raise + rescue Exception => hook_error + exception ||= hook_error + end end - observers.each do |o| - exception ? o.public_send(result, test, exception) : o.pass(test) + status = if exception.nil? + :pass + elsif NotImplementedError === exception + :todo + else + exception.assertion? ? :fail : :error end + exception ? advice.public_send(status, test, exception) : advice.pass(test) + result = Result.new(test: test, status: status, exception: exception, + elapsed: elapsed_since(started), + stdout: captured_stdout&.string, + stderr: captured_stderr&.string) ensure - observers.each{ |o| o.end_test(test) } + $stdout, $stderr = original_stdout, original_stderr if capture end + + recorder.record(result) + ensure + recorder.end_test(test) if report_started + end + + def elapsed_since(started) + Process.clock_gettime(Process::CLOCK_MONOTONIC) - started end # TODO: Make sure this filtering code is correct for the complex From 82784c07e473bdf919c18a9df789aeb9142ce7e6 Mon Sep 17 00:00:00 2001 From: Thomas Sawyer Date: Mon, 28 Sep 2026 22:19:04 -0400 Subject: [PATCH 2/2] Bring core reporters back into Rubytest --- HISTORY.md | 6 ++ README.md | 11 ++- demo/03_runner_reliability.md | 14 ++-- demo/04_result_pipeline.md | 43 +++++++----- demo/05_builtin_reporters.md | 75 ++++++++++++++++++++ lib/rubytest/cli.rb | 6 -- lib/rubytest/format/abstract.rb | 100 --------------------------- lib/rubytest/format/abstract_hash.rb | 40 ++++++----- lib/rubytest/format/dotprogress.rb | 21 +++--- lib/rubytest/format/outline.rb | 44 ++++++++++++ lib/rubytest/format/progress.rb | 35 ++++++++++ lib/rubytest/format/summary.rb | 31 +++++++++ lib/rubytest/format/tap.rb | 54 +++++++++++++++ lib/rubytest/recorder.rb | 71 +------------------ lib/rubytest/result.rb | 5 +- lib/rubytest/runner.rb | 67 +++++++++++++----- 16 files changed, 374 insertions(+), 249 deletions(-) create mode 100644 demo/05_builtin_reporters.md create mode 100644 lib/rubytest/format/outline.rb create mode 100644 lib/rubytest/format/progress.rb create mode 100644 lib/rubytest/format/summary.rb create mode 100644 lib/rubytest/format/tap.rb diff --git a/HISTORY.md b/HISTORY.md index 98b49cb..21a1239 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,6 +2,12 @@ ## Unreleased +* Bring summary, outline, and progress reporters into Rubytest and add a TAP 13 + reporter that counts completed results, including skipped and broken cases. +* Remove CLI shortcuts for the unbundled TAP-Y and TAP-J formats. +* Remove the old Recorder status callbacks and status-indexed result table. +* Preserve test and teardown exceptions together in a result, and turn outcome + hook failures into recorded errors. * Route structured test results through the recorder to reporters, while retaining Advice hooks and older reporter callbacks. * Fail empty runs and unmatched test file requests instead of reporting success. diff --git a/README.md b/README.md index 6d5b22f..7d1927e 100644 --- a/README.md +++ b/README.md @@ -43,15 +43,20 @@ additional features that can make its usage even more convenient. The runner turns each completed test into a `Test::Result` with its status, exception or skip reason, elapsed time, and captured output when requested. +If both the test and a hook raise, the result retains both exceptions. Skipped and broken cases also produce results. `Test::Recorder` keeps these results, calculates the run summary, and forwards reporting events to the selected reporter. Advice hooks remain with the runner, where they can affect test execution. -Reporters can implement `record(result)` for completed outcomes and +Reporters implement `record(result)` for completed outcomes and `finish(summary)` for the final totals. Suite and case boundary callbacks are -available for formats that show nesting. Older reporters using `pass`, `fail`, -`error`, `todo`, and skip callbacks continue to work through the recorder. +available for formats that show nesting. + +The built-in formats are `dotprogress` (default), `summary` (one status line per +test), `outline` (nested cases), `progress` (running count and timing), and +`tap` (TAP version 13). Select one with `rubytest -f FORMAT`. Formats load only +when selected; TAP captures test output and emits it as diagnostics. ## Installation diff --git a/demo/03_runner_reliability.md b/demo/03_runner_reliability.md index 1f90f4d..70c4f6a 100644 --- a/demo/03_runner_reliability.md +++ b/demo/03_runner_reliability.md @@ -55,8 +55,8 @@ test that matches the selection is still reported. selected = Test::Runner.new(suite: [top, other, [nested, skipped]], match: ['wanted'], format: 'test') selected.run.assert == true - selected.recorder[:pass].size.assert == 2 - selected.recorder[:skip].size.assert == 1 + selected.recorder.summary.counts[:pass].assert == 2 + selected.recorder.summary.counts[:skip].assert == 1 Skipping an entire case is recorded too. @@ -67,7 +67,7 @@ Skipping an entire case is recorded too. end.new case_skip_runner = Test::Runner.new(suite: [skipped_case], format: 'test') case_skip_runner.run.assert == true - case_skip_runner.recorder[:skip].size.assert == 1 + case_skip_runner.recorder.summary.counts[:skip].assert == 1 ### Case errors and cleanup @@ -84,8 +84,8 @@ A case setup error is recorded, and the next test and suite cleanup still run. case_runner.after(:suite) { suite_ended = true } case_runner.run.assert == false - case_runner.recorder[:error].size.assert == 1 - case_runner.recorder[:pass].size.assert == 1 + case_runner.recorder.summary.counts[:error].assert == 1 + case_runner.recorder.summary.counts[:pass].assert == 1 suite_ended.assert == true An assertion failure in a hash reporter records the failure and restores @@ -96,7 +96,7 @@ standard output. failure_runner = Test::Runner.new(suite: [failing_test], format: 'test') failure_runner.run.assert == false - failure_runner.recorder[:fail].size.assert == 1 + failure_runner.recorder.summary.counts[:fail].assert == 1 ($stdout.equal?(original_stdout)).assert == true Global assertionless mode treats a false return as a failure. @@ -107,7 +107,7 @@ Global assertionless mode treats a false return as a failure. false_test = RunnerProbe.new('false result') { false } hard_runner = Test::Runner.new(suite: [false_test], format: 'test') hard_runner.run.assert == false - hard_runner.recorder[:fail].size.assert == 1 + hard_runner.recorder.summary.counts[:fail].assert == 1 ensure Test::Config.assertionless = previous_assertionless end diff --git a/demo/04_result_pipeline.md b/demo/04_result_pipeline.md index 491b2a0..c82df39 100644 --- a/demo/04_result_pipeline.md +++ b/demo/04_result_pipeline.md @@ -40,8 +40,8 @@ and forwards the same result objects, along with case boundaries, to a reporter. statuses.assert == [:pass, :fail, :error, :todo, :skip, :skip, :error] runner.recorder.results.map(&:kind).assert == [:test, :test, :test, :test, :test, :case, :case] - runner.recorder[:fail].first.first.assert.equal? failing - runner.recorder[:skip].size.assert == 2 + runner.recorder.results.find { |result| result.status == :fail }.test.assert.equal? failing + runner.recorder.summary.counts[:skip].assert == 2 events.grep(Test::Result).map(&:object_id).assert == runner.recorder.results.map(&:object_id) events.count(:begin_case).assert == 2 @@ -66,6 +66,25 @@ that result is recorded. hook_runner.recorder.results.first.status.assert == :fail hook_runner.recorder.results.first.exception.message.include?('verification failed').assert == true +If both a test and teardown fail, the error takes precedence while the result +keeps both exceptions. An outcome hook that raises also becomes a result. + + double_runner = hook_runner_type.new(suite: [-> { raise Assertion, 'body failed' }], + format: 'test') + double_runner.after(:test) { raise 'cleanup failed' } + double_runner.run.assert == false + double_result = double_runner.recorder.results.first + double_result.status.assert == :error + double_result.exception.message.assert == 'cleanup failed' + double_result.exceptions.first.message.include?('body failed').assert == true + double_result.exceptions.last.message.assert == 'cleanup failed' + + outcome_runner = hook_runner_type.new(suite: [-> { true }], format: 'test') + outcome_runner.upon(:pass) { raise 'outcome hook failed' } + outcome_runner.run.assert == false + outcome_runner.recorder.results.first.status.assert == :error + outcome_runner.recorder.results.first.exception.message.assert == 'outcome hook failed' + A failing before hook is recorded as an error; the test body is not called, and the after hook still runs. @@ -106,21 +125,6 @@ still ends. case_body_calls.assert == 0 case_cleanup_ran.assert == true -Reporters that still use the old status callbacks work through the Recorder. - - legacy_events = [] - legacy_reporter = Object.new - legacy_reporter.define_singleton_method(:pass) { |test| legacy_events << [:pass, test] } - legacy_reporter.define_singleton_method(:end_suite) { |suite| legacy_events << [:end_suite, suite] } - legacy_recorder = Test::Recorder.new(legacy_reporter) - legacy_suite = [] - legacy_recorder.begin_suite(legacy_suite) - legacy_result = Test::Result.new(test: passing, status: :pass) - legacy_recorder.record(legacy_result) - legacy_recorder.end_suite(legacy_suite) - legacy_events.assert == [[:pass, passing], [:end_suite, legacy_suite]] - legacy_recorder.results.first.assert.equal? legacy_result - The hash reporter receives captured output in its result, and the runner restores the process streams after execution. @@ -149,3 +153,8 @@ restores the process streams after execution. row['stderr'].assert == "oops\n" ($stdout.equal?(original_stdout)).assert == true ($stderr.equal?(original_stderr)).assert == true + + case_output_runner = hash_runner_type.new(suite: [skipped_case], format: 'test') + case_output_runner.run.assert == true + case_output_runner.reporter.rows.first['type'].assert == 'case' + case_output_runner.reporter.rows.first['status'].assert == 'omit' diff --git a/demo/05_builtin_reporters.md b/demo/05_builtin_reporters.md new file mode 100644 index 0000000..eb5c105 --- /dev/null +++ b/demo/05_builtin_reporters.md @@ -0,0 +1,75 @@ +## Built-in reporters + +The human-readable formats consume the same results, including pending tests +and skipped cases. They can be selected without installing another gem. + + require 'stringio' + + probe_type = Class.new do + def initialize(label, reason = nil, &action) + @label, @reason, @action = label, reason, action + end + + def call + @action.call + end + + def skip? + @reason + end + + def to_s + @label + end + end + + passing = probe_type.new('passing') { true } + failing = probe_type.new('failing') { raise Assertion, 'wrong value' } + pending = probe_type.new('pending') { raise NotImplementedError, 'later' } + skipped = probe_type.new('skipped', 'not available') { raise 'must not run' } + skipped_case = Class.new(Array) do + def skip?; 'case unavailable'; end + def to_s; 'skipped case'; end + end.new([passing]) + broken_case = Class.new(Array) do + def call; raise 'case setup failed'; end + def to_s; 'broken case'; end + end.new([passing]) + suite = [[passing, failing], pending, skipped, skipped_case, broken_case] + + render = lambda do |format, tests = suite| + require "rubytest/format/#{format}" + output = StringIO.new + reporter_type = Test::Reporters.const_get(format.capitalize) + runner_type = Class.new(Test::Runner) do + define_method(:reporter_load) { |_name| reporter_type.new(self, output: output) } + end + runner = runner_type.new(suite: tests, format: format) + [runner.run, output.string, runner.recorder.summary] + end + + %w[summary outline progress].each do |format| + success, output, report = render.call(format) + success.assert == false + report.total.assert == 6 + output.include?('failing').assert == true + output.include?('skipped case').assert == true + output.include?('6').assert == true + end + +TAP emits one test point for every result. Test output is captured and appears +as diagnostics, so it cannot corrupt the TAP stream. The final plan uses the +number actually recorded, including the broken and skipped cases. + + output_test = probe_type.new('output') { print "ok 999 - injected\n"; true } + tap_suite = [[output_test, failing], pending, skipped, skipped_case, broken_case] + success, output, report = render.call('tap', tap_suite) + success.assert == false + lines = output.lines.map(&:chomp) + lines.first.assert == 'TAP version 13' + lines.last.assert == '1..6' + lines.grep(/\A(?:ok|not ok) \d+ - /).size.assert == report.total + lines.grep(/# SKIP /).size.assert == 2 + lines.grep(/# TODO /).size.assert == 1 + lines.grep(/\A# stdout: ok 999 - injected/).size.assert == 1 + lines.grep(/\Anot ok \d+ - broken case/).size.assert == 1 diff --git a/lib/rubytest/cli.rb b/lib/rubytest/cli.rb index 4b4147b..5d75156 100644 --- a/lib/rubytest/cli.rb +++ b/lib/rubytest/cli.rb @@ -62,12 +62,6 @@ def options opt.on '-f', '--format NAME', 'report format' do |name| conf.format = name end - opt.on '-y', '--tapy', 'shortcut for -f tapy' do - conf.format = 'tapy' - end - opt.on '-j', '--tapj', 'shortcut for -f tapj' do - conf.format = 'tapj' - end opt.on '-t', '--tag TAG', 'select tests by tag' do |tag| conf.tags.concat makelist(tag) diff --git a/lib/rubytest/format/abstract.rb b/lib/rubytest/format/abstract.rb index 8963b2d..8cf7705 100644 --- a/lib/rubytest/format/abstract.rb +++ b/lib/rubytest/format/abstract.rb @@ -43,34 +43,6 @@ def begin_case(test_case) def begin_test(test) end - # - def skip_case(test_case, reason=nil) - end - - # - def skip_test(test, reason=nil) - end - - # - #def test(test) - #end - - # - def pass(test) - end - - # - def fail(test, exception) - end - - # Report a test error. - def error(test, exception) - end - - # Report a pending test. - def todo(test, exception) - end - # def end_test(test) end @@ -79,21 +51,8 @@ def end_test(test) def end_case(test_case) end - # - def end_suite(test_suite) - end - protected - def record - runner.recorder - end - - # Is coverage information requested? - #def cover? - # runner.cover? - #end - # Count up the total number of tests. def total_count(suite) c = 0 @@ -107,73 +66,14 @@ def total_count(suite) return c end - # Common timestamp any reporter can use. - def timestamp - seconds = Time.now - @start_time - - "Finished in %.5fs, %.2f tests/s." % [seconds, total/seconds] - end - - # - def total - @total ||= subtotal - end - - # - def subtotal - [:todo, :pass, :fail, :error, :omit, :skip].inject(0) do |s,r| - s += record[r.to_sym].size; s - end - end - - # TODO: lump skipped and omitted into one group ? - TITLES = { :pass => 'passing', :fail => 'failures', :error => 'errors', :todo => 'pending', - :omit => 'omissions', :skip => 'skipped' } - # TODO: Add assertion counts (if reasonably possible). - - # Common tally stamp any reporter can use. - # - # @return [String] tally stamp - def tally - sizes = {} - names = %w{pass error fail todo omit skip}.map{ |n| n.to_sym } - names.each do |r| - sizes[r] = record[r].size - end - - #names.unshift(:tests) - #sizes[:tests] = total - - s = [] - names.each do |n| - next unless sizes[n] > 0 - s << tally_item(n, sizes) - end - - 'Executed ' + "#{total}".ansi(:bold) + ' tests with ' + s.join(', ') + '.' - end - - # - def tally_item(name, sizes) - x = [] - x << "%s" % sizes[name].to_s.ansi(:bold) - x << " %s" % TITLES[name].downcase - x << " (%.1f%%)" % ((sizes[name].to_f/total*100)) if runner.verbose? - x.join('') - end - - #-- - # TODO: Matching `bin/ruby-test` is not robust. - #++ - # Remove reference to lemon library from backtrace. # # @param [Exception] exception diff --git a/lib/rubytest/format/abstract_hash.rb b/lib/rubytest/format/abstract_hash.rb index d0ec7f6..0c2b973 100644 --- a/lib/rubytest/format/abstract_hash.rb +++ b/lib/rubytest/format/abstract_hash.rb @@ -2,8 +2,7 @@ module Test::Reporters - # Hash Abstract is a base class for the TAP-Y - # and TAP-J reporters. + # Structured records used by the internal test reporter. # class AbstractHash < Abstract @@ -11,11 +10,8 @@ def capture_output? true end - # New reporter entry point. The status callbacks remain available to - # subclasses that format TAP-Y, TAP-J, or other hash-based output. - def record(result = nil) - return super() unless result # historical access to runner.recorder - + # Return a structured record of the completed result. + def record(result) @current_result = result if result.status == :skip method = result.kind == :case ? :skip_case : :skip_test @@ -105,6 +101,10 @@ def skip_test(test, reason=nil) return h end + def skip_case(test_case, reason=nil) + skip_test(test_case, reason).merge('type' => 'case') + end + # # @return [Hash] # @@ -207,14 +207,14 @@ def end_case(test_case) def end_suite(suite) h = { 'type' => 'final', - 'time' => @summary ? @summary.elapsed : Time.now - @start_time, + 'time' => @summary.elapsed, 'counts' => { - 'total' => @summary ? @summary.total : total, - 'pass' => @summary ? @summary.counts[:pass] : record[:pass].size, - 'fail' => @summary ? @summary.counts[:fail] : record[:fail].size, - 'error' => @summary ? @summary.counts[:error] : record[:error].size, - 'omit' => @summary ? @summary.counts[:skip] : record[:omit].size, - 'todo' => @summary ? @summary.counts[:todo] : record[:todo].size + 'total' => @summary.total, + 'pass' => @summary.counts[:pass], + 'fail' => @summary.counts[:fail], + 'error' => @summary.counts[:error], + 'omit' => @summary.counts[:skip], + 'todo' => @summary.counts[:todo] } } return h @@ -275,6 +275,12 @@ def merge_exception(hash, test, exception, bt=false) hash['exception']['snippet' ] = code(exception).to_omap hash['exception']['message' ] = exception.message hash['exception']['backtrace'] = clean_backtrace(exception) if bt + if @current_result && @current_result.exceptions.size > 1 + hash['exceptions'] = @current_result.exceptions.map do |error| + {'class' => error.class.to_s, 'message' => error.message, + 'backtrace' => clean_backtrace(error)} + end + end end # TODO: This is still an "idea in progress" for both RybyTest and Tap-Y/J. @@ -304,13 +310,13 @@ def merge_coverage(hash, test) # def merge_output(hash) - hash['stdout'] = @current_result ? @current_result.stdout : '' - hash['stderr'] = @current_result ? @current_result.stderr : '' + hash['stdout'] = @current_result.stdout + hash['stderr'] = @current_result.stderr end # def merge_time(hash) - hash['time'] = @current_result ? @current_result.elapsed : Time.now - @start_time + hash['time'] = @current_result.elapsed end end diff --git a/lib/rubytest/format/dotprogress.rb b/lib/rubytest/format/dotprogress.rb index ec16142..fcf99e5 100644 --- a/lib/rubytest/format/dotprogress.rb +++ b/lib/rubytest/format/dotprogress.rb @@ -11,9 +11,7 @@ def initialize(runner, output: $stdout) @verbose = runner.verbose? end - def record(result = nil) - return super() unless result # historical access to runner.recorder - + def record(result) if result.status == :skip method = result.kind == :case ? :skip_case : :skip_test public_send(method, result.test, result.reason) @@ -29,11 +27,14 @@ def finish(summary) end_suite(summary.suite) end - # Keep the old callbacks useful to subclasses of this reporter. def skip_test(test, reason) emit('S'.ansi(:cyan)) if @verbose end + def skip_case(test_case, reason) + emit('S'.ansi(:cyan)) if @verbose + end + def pass(test) emit('.') end @@ -71,12 +72,14 @@ def end_suite(suite) next if summary.counts[status].zero? @output.puts "#{title}\n\n" summary.results_for(status).each do |result| - exception = result.exception @output.puts " #{result.test}".ansi(:bold) unless status == :todo && result.test.to_s.empty? - @output.puts " #{exception}" - @output.puts " #{file_and_line(exception)}" - @output.puts code(exception) - @output.puts " " + clean_backtrace(exception).join("\n ") unless status == :todo + result.exceptions.each_with_index do |exception, index| + prefix = result.exceptions.size > 1 ? "#{index + 1}. " : '' + @output.puts " #{prefix}#{exception.class}: #{exception.message}" + @output.puts " #{file_and_line(exception)}" + @output.puts code(exception) + @output.puts " " + clean_backtrace(exception).join("\n ") unless status == :todo + end @output.puts end end diff --git a/lib/rubytest/format/outline.rb b/lib/rubytest/format/outline.rb new file mode 100644 index 0000000..998d205 --- /dev/null +++ b/lib/rubytest/format/outline.rb @@ -0,0 +1,44 @@ +require_relative 'dotprogress' + +module Test::Reporters + # Nest case headings and show each result at its place in the suite. + class Outline < Dotprogress + SYMBOLS = { + pass: '.', fail: 'F', error: 'E', todo: 'P', skip: 'S' + }.freeze + + def begin_suite(suite) + @depth = 0 + end + + def begin_case(test_case) + @output.puts "#{' ' * @depth}#{test_case.to_s.lines.first.to_s.strip}" + @depth += 1 + end + + def end_case(test_case) + @depth -= 1 + end + + def record(result) + depth = result.kind == :case && @depth.positive? ? @depth - 1 : @depth + indent = ' ' * depth + @output.puts "#{indent}#{SYMBOLS.fetch(result.status)} #{result.test.to_s.lines.first.to_s.strip}" + if [:fail, :error].include?(result.status) + result.exceptions.each do |exception| + @output.puts "#{indent} #{exception.class}: #{exception.message}" + location = file_and_line(exception) + @output.puts "#{indent} #{location}" unless location.empty? + end + end + end + + def end_suite(suite) + summary = @summary + @output.puts + rate = summary.elapsed.zero? ? 0.0 : summary.total / summary.elapsed + @output.puts "Finished in %.5fs, %.2f tests/s." % [summary.elapsed, rate] + @output.puts summary.total.zero? ? 'No tests were run.' : tally_for(summary) + end + end +end diff --git a/lib/rubytest/format/progress.rb b/lib/rubytest/format/progress.rb new file mode 100644 index 0000000..a654d7f --- /dev/null +++ b/lib/rubytest/format/progress.rb @@ -0,0 +1,35 @@ +require_relative 'dotprogress' + +module Test::Reporters + # Show completed results and timing without enumerating the suite in advance. + class Progress < Dotprogress + SYMBOLS = { + pass: '.', fail: 'F', error: 'E', todo: 'P', skip: 'S' + }.freeze + + def begin_suite(suite) + @completed = 0 + @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @depth = 0 + end + + def begin_case(test_case) + @output.puts "#{' ' * @depth}#{test_case.to_s.lines.first.to_s.strip}" + @depth += 1 + end + + def end_case(test_case) + @depth -= 1 + end + + def record(result) + @completed += 1 + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at + depth = result.kind == :case && @depth.positive? ? @depth - 1 : @depth + label = "#{' ' * depth}#{result.test.to_s.lines.first.to_s.strip}" + @output.puts "%4d %8.3fs %8.5fs %s %s" % [ + @completed, elapsed, result.elapsed, SYMBOLS.fetch(result.status), label + ] + end + end +end diff --git a/lib/rubytest/format/summary.rb b/lib/rubytest/format/summary.rb new file mode 100644 index 0000000..da9ab01 --- /dev/null +++ b/lib/rubytest/format/summary.rb @@ -0,0 +1,31 @@ +require_relative 'dotprogress' + +module Test::Reporters + # One status line per result, followed by failure details and totals. + class Summary < Dotprogress + LABELS = { + pass: 'PASS', fail: 'FAIL', error: 'ERROR', + todo: 'TODO', skip: 'SKIP' + }.freeze + + def begin_suite(suite) + @case_path = [] + end + + def begin_case(test_case) + @case_path << test_case.to_s.lines.first.to_s.strip + end + + def end_case(test_case) + @case_path.pop + end + + def record(result) + label = result.test.to_s.lines.first.to_s.strip + path = @case_path.dup + path.pop if result.kind == :case && path.last == label + path << label unless label.empty? + @output.puts "#{LABELS.fetch(result.status).ljust(5)} #{path.join(' ')}" + end + end +end diff --git a/lib/rubytest/format/tap.rb b/lib/rubytest/format/tap.rb new file mode 100644 index 0000000..668b4c6 --- /dev/null +++ b/lib/rubytest/format/tap.rb @@ -0,0 +1,54 @@ +module Test::Reporters + # TAP version 13 stream. The plan follows execution so case outcomes count. + class Tap < Abstract + def initialize(runner, output: $stdout) + super(runner) + @output = output + end + + def capture_output? + true + end + + def begin_suite(suite) + @index = 0 + @output.puts 'TAP version 13' + end + + def record(result) + @index += 1 + label = single_line(result.test.to_s) + line = "#{[:pass, :skip].include?(result.status) ? 'ok' : 'not ok'} #{@index} - #{label}" + case result.status + when :skip + line << " # SKIP #{single_line(result.reason)}" + when :todo + line << " # TODO #{single_line(result.exception&.message)}" + end + @output.puts line.rstrip + + if [:fail, :error].include?(result.status) + result.exceptions.each do |exception| + diagnostic("#{exception.class}: #{exception.message}") + diagnostic(exception.backtrace&.first) if exception.backtrace&.first + end + end + result.stdout.each_line { |line| diagnostic("stdout: #{line}") } + result.stderr.each_line { |line| diagnostic("stderr: #{line}") } + end + + def finish(summary) + @output.puts summary.total.zero? ? '1..0 # SKIP No tests were run' : "1..#{summary.total}" + end + + private + + def single_line(value) + value.to_s.gsub(/\s+/, ' ').strip.gsub('#', '#') + end + + def diagnostic(message) + message.to_s.each_line { |line| @output.puts "# #{line.chomp}" } + end + end +end diff --git a/lib/rubytest/recorder.rb b/lib/rubytest/recorder.rb index 2734efe..67d3e89 100644 --- a/lib/rubytest/recorder.rb +++ b/lib/rubytest/recorder.rb @@ -6,12 +6,6 @@ class Recorder def initialize(reporter = nil) @reporter = reporter @results = [] - @table = Hash.new { |hash, key| hash[key] = [] } - end - - # Keep the historical status-indexed view for callers and older reporters. - def [](key) - @table[key.to_sym] end def begin_suite(suite) @@ -34,22 +28,7 @@ def capture_output? def record(result) @results << result - case result.status - when :pass - self[:pass] << result.test - when :skip - self[:skip] << [result.test, result.reason] - else - self[result.status] << [result.test, result.exception] - end - - return result unless reporter - - if native_reporter? - reporter.record(result) - else - report_legacy_result(result) - end + reporter.record(result) if reporter result end @@ -63,13 +42,7 @@ def end_case(test_case) def end_suite(suite) @summary = RunSummary.new(suite: suite, results: results, elapsed: elapsed) - if reporter - if native_reporter? - reporter.finish(@summary) - else - reporter.end_suite(suite) - end - end + reporter.finish(@summary) if reporter @summary end @@ -77,51 +50,11 @@ def success? (@summary || RunSummary.new(suite: @suite, results: results, elapsed: elapsed)).success? end - # Compatibility for code that sent status callbacks to the recorder. - def pass(test) - record(Result.new(test: test, status: :pass)) - end - - def fail(test, exception) - record(Result.new(test: test, status: :fail, exception: exception)) - end - - def error(test, exception) - record(Result.new(test: test, status: :error, exception: exception)) - end - - def todo(test, exception) - record(Result.new(test: test, status: :todo, exception: exception)) - end - - def skip_test(test, reason) - record(Result.new(test: test, status: :skip, reason: reason)) - end - - def skip_case(test_case, reason) - record(Result.new(test: test_case, kind: :case, status: :skip, reason: reason)) - end - private - def native_reporter? - reporter.respond_to?(:record) && reporter.respond_to?(:finish) - end - def elapsed return 0.0 unless @started_at Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at end - - def report_legacy_result(result) - if result.status == :skip - method = result.kind == :case ? :skip_case : :skip_test - reporter.public_send(method, result.test, result.reason) - elsif result.status == :pass - reporter.pass(result.test) - else - reporter.public_send(result.status, result.test, result.exception) - end - end end end diff --git a/lib/rubytest/result.rb b/lib/rubytest/result.rb index 1469640..5a3c9c2 100644 --- a/lib/rubytest/result.rb +++ b/lib/rubytest/result.rb @@ -4,10 +4,10 @@ class Result STATUSES = [:pass, :fail, :error, :todo, :skip].freeze KINDS = [:test, :case].freeze - attr_reader :test, :kind, :status, :exception, :reason, :elapsed, :stdout, :stderr + attr_reader :test, :kind, :status, :exception, :exceptions, :reason, :elapsed, :stdout, :stderr def initialize(test:, status:, kind: :test, exception: nil, reason: nil, - elapsed: 0.0, stdout: '', stderr: '') + elapsed: 0.0, stdout: '', stderr: '', exceptions: nil) raise ArgumentError, "unknown result status: #{status.inspect}" unless STATUSES.include?(status) raise ArgumentError, "unknown result kind: #{kind.inspect}" unless KINDS.include?(kind) @@ -15,6 +15,7 @@ def initialize(test:, status:, kind: :test, exception: nil, reason: nil, @kind = kind @status = status @exception = exception + @exceptions = (exceptions || (exception ? [exception] : [])).dup.freeze @reason = reason @elapsed = elapsed @stdout = stdout.to_s.dup.freeze diff --git a/lib/rubytest/runner.rb b/lib/rubytest/runner.rb index 2582765..4812ccc 100644 --- a/lib/rubytest/runner.rb +++ b/lib/rubytest/runner.rb @@ -263,13 +263,13 @@ def run_test(test) end started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - exception = nil + exceptions = [] begin advice.begin_test(test) rescue *OPEN_ERRORS raise rescue Exception => hook_error - exception = hook_error + exceptions << hook_error end recorder.begin_test(test) @@ -280,32 +280,35 @@ def run_test(test) begin $stdout, $stderr = captured_stdout, captured_stderr if capture begin - unless exception + if exceptions.empty? success = test.call raise Assertion, "failure of #{test}" if config.hard? && !success end rescue *OPEN_ERRORS raise - rescue Exception => exception + rescue Exception => error + exceptions << error ensure begin advice.end_test(test) rescue *OPEN_ERRORS raise rescue Exception => hook_error - exception ||= hook_error + exceptions << hook_error end end - status = if exception.nil? - :pass - elsif NotImplementedError === exception - :todo - else - exception.assertion? ? :fail : :error + status, exception = outcome_for(exceptions) + begin + exception ? advice.public_send(status, test, exception) : advice.pass(test) + rescue *OPEN_ERRORS + raise + rescue Exception => hook_error + exceptions << hook_error + status, exception = outcome_for(exceptions) end - exception ? advice.public_send(status, test, exception) : advice.pass(test) result = Result.new(test: test, status: status, exception: exception, + exceptions: exceptions, elapsed: elapsed_since(started), stdout: captured_stdout&.string, stderr: captured_stderr&.string) @@ -322,6 +325,23 @@ def elapsed_since(started) Process.clock_gettime(Process::CLOCK_MONOTONIC) - started end + def outcome_for(exceptions) + return [:pass, nil] if exceptions.empty? + + exception = exceptions.find do |error| + !(NotImplementedError === error) && !error.assertion? + end + exception ||= exceptions.find(&:assertion?) + exception ||= exceptions.first + + status = if NotImplementedError === exception + :todo + else + exception.assertion? ? :fail : :error + end + [status, exception] + end + # TODO: Make sure this filtering code is correct for the complex # condition that that ordered testcases can't have their tests # filtered individually (since they may depend on one another). @@ -371,16 +391,25 @@ def select(cases) def reporter_load(format) format = DEFAULT_REPORT_FORMAT unless format format = format.to_s.downcase - name = reporter_list.find{ |r| /^#{format}/ =~ r } || format + name = reporter_list.find{ |r| r.start_with?(format) } || format - begin - require "rubytest/format/#{name}" - rescue LoadError - raise "mistyped or uninstalled report format" unless format + if KNOWN_FORMATS.include?(name) + require_relative "format/#{name}" + else + begin + require "rubytest/format/#{name}" + rescue LoadError => error + raise ArgumentError, "unknown report format #{name.inspect}" if error.path == "rubytest/format/#{name}" + raise + end end reporter = Test::Reporters.const_get(name.capitalize) - reporter.new(self) + reporter = reporter.new(self) + unless reporter.respond_to?(:record) && reporter.respond_to?(:finish) + raise ArgumentError, "report format #{name} does not support result reporting" + end + reporter end # List of known report formats. @@ -388,7 +417,7 @@ def reporter_load(format) # TODO: Could use finder gem to look these up, but that's yet another dependency. # KNOWN_FORMATS = %w{ - dotprogress html progress outline summary tap tapy tapj test + dotprogress progress outline summary tap test } # Returns a list of available report types.