module SingleCov
Constants
- COVERAGES
- MAX_OUTPUT
- PREFIXES_TO_IGNORE
- RAILS_APP_FOLDERS
- UNCOVERED_COMMENT_MARKER
- VERSION
Attributes
enable coverage reporting: path to output file, changed by forking-test-runner at runtime to combine many reports
emit only line coverage in coverage report for older coverage systems
Public Class Methods
# File lib/single_cov.rb, line 33 def all_covered?(result) errors = COVERAGES.flat_map do |file, expected_uncovered| next no_coverage_error(file) unless coverage = result["#{root}/#{file}"] uncovered = uncovered(coverage) next if uncovered.size == expected_uncovered # ignore lines that are marked as uncovered via comments # TODO: warn when using uncovered but the section is indeed covered content = File.readlines("#{root}/#{file}") uncovered.reject! do |line_start, _, _, _, _| content[line_start - 1].match?(UNCOVERED_COMMENT_MARKER) end next if uncovered.size == expected_uncovered bad_coverage_error(file, expected_uncovered, uncovered) end.compact return true if errors.empty? if errors.size >= MAX_OUTPUT errors[MAX_OUTPUT..-1] = "... coverage output truncated (use SINGLE_COV_MAX_OUTPUT=999 to expand)" end @error_logger.puts errors errors.all? { |l| warning?(l) } end
# File lib/single_cov.rb, line 82 def assert_full_coverage(tests: default_tests, currently_complete: [], location: nil) location ||= caller(0..1)[1].split(':in').first complete = tests.select { |file| File.read(file) =~ /SingleCov.covered!(?:(?!uncovered).)*(\s*|\s*\#.*)$/ } missing_complete = currently_complete - complete newly_complete = complete - currently_complete errors = [] if missing_complete.any? errors << <<~MSG The following file(s) were previously marked as having 100% SingleCov test coverage (had no `coverage:` option) but are no longer marked as such. #{missing_complete.join("\n")} Please increase test coverage in these files to maintain 100% coverage and remove `coverage:` usage. If this test fails during a file removal, make it pass by removing all references to the removed file's path from the code base. MSG end if newly_complete.any? errors << <<~MSG The following files are newly at 100% SingleCov test coverage. Please add the following to #{location} to ensure 100% coverage is maintained moving forward. #{newly_complete.join("\n")} MSG end raise errors.join("\n") if errors.any? end
# File lib/single_cov.rb, line 70 def assert_tested(files: glob('{app,lib}/**/*.rb'), tests: default_tests, untested: []) missing = files - tests.map { |t| guess_covered_file(t) } fixed = untested - missing missing -= untested if fixed.any? raise "Remove #{fixed.inspect} from untested!" elsif missing.any? raise missing.map { |f| "missing test for #{f}" }.join("\n") end end
# File lib/single_cov.rb, line 61 def assert_used(tests: default_tests) bad = tests.select do |file| File.read(file) !~ /SingleCov.(not_)?covered!/ end unless bad.empty? raise bad.map { |f| "#{f}: needs to use SingleCov.covered!" }.join("\n") end end
mark the file under test as needing coverage
# File lib/single_cov.rb, line 27 def covered!(file: nil, uncovered: 0) file = ensure_covered_file(file) COVERAGES << [file, uncovered] main_process! end
use this in forks when using rspec to silence duplicated output
# File lib/single_cov.rb, line 142 def disable @disabled = true end
mark a test file as not covering anything to make assert_used
pass
# File lib/single_cov.rb, line 22 def not_covered! main_process! end
optionally rewrite the matching path single-cov guessed with a lambda
# File lib/single_cov.rb, line 17 def rewrite(&block) @rewrite = block end
# File lib/single_cov.rb, line 110 def setup(framework, root: nil, branches: true, err: $stderr) @error_logger = err if defined?(SimpleCov) raise "Load SimpleCov after SingleCov" end @branches = branches @root = root case framework when :minitest minitest_should_not_be_running! return if minitest_running_subset_of_tests? when :rspec return if rspec_running_subset_of_tests? else raise "Unsupported framework #{framework.inspect}" end start_coverage_recording override_at_exit do |status, _exception| if enabled? && main_process? && status == 0 results = coverage_results generate_report results exit 1 unless SingleCov.all_covered?(results) end end end
Private Class Methods
# File lib/single_cov.rb, line 323 def bad_coverage_error(file, expected_uncovered, uncovered) details = "(#{uncovered.size} current vs #{expected_uncovered} configured)" if expected_uncovered > uncovered.size if running_single_file? warning "#{file} has less uncovered lines #{details}, decrement configured uncovered" end else [ "#{file} new uncovered lines introduced #{details}", red("Lines missing coverage:"), *uncovered.map do |line_start, char_start, line_end, char_end, type| if char_start # branch coverage if line_start == line_end "#{file}:#{line_start}:#{char_start}-#{char_end}" else # possibly unreachable since branches always seem to be on the same line "#{file}:#{line_start}:#{char_start}-#{line_end}:#{char_end}" end + (type ? " # #{type}" : "") else "#{file}:#{line_start}" end end ] end end
do not ask for coverage when SimpleCov already does or it conflicts
# File lib/single_cov.rb, line 204 def coverage_results if defined?(SimpleCov) && (result = SimpleCov.instance_variable_get(:@result)) result = result.original_result # singlecov 1.18+ puts string "lines" into the result that we cannot read if result.each_value.first.is_a?(Hash) result = result.transform_values { |v| v.transform_keys(&:to_sym) } end result else Coverage.result end end
# File lib/single_cov.rb, line 191 def default_tests glob("{test,spec}/**/*_{test,spec}.rb") end
# File lib/single_cov.rb, line 161 def enabled? (!defined?(@disabled) || !@disabled) end
# File lib/single_cov.rb, line 307 def ensure_covered_file(file) if file raise "Use paths relative to project root." if file.start_with?("/") raise "#{file} does not exist, use paths relative to project root." unless File.exist?("#{root}/#{file}") else file = guess_covered_file(caller[1]) if file.start_with?("/") raise "Found file #{file} which is not relative to the root #{root}.\nUse `SingleCov.covered! file: 'target_file.rb'` to set covered file location." elsif !File.exist?("#{root}/#{file}") raise "Tried to guess covered file as #{file}, but it does not exist.\nUse `SingleCov.covered! file: 'target_file.rb'` to set covered file location." end end file end
ForkingTestRunner fakes an initialized minitest to avoid multiple hooks being installed so hooks still get added in order github.com/grosser/forking_test_runner/pull/4
# File lib/single_cov.rb, line 259 def faked_by_forking_test_runner? defined?(Coverage) && Coverage.respond_to?(:capture_coverage!) end
# File lib/single_cov.rb, line 420 def generate_report(results) return unless report = coverage_report # not a hard dependency for the whole library require "json" require "fileutils" used = COVERAGES.map { |f, _| "#{root}/#{f}" } covered = results.select { |k, _| used.include?(k) } if coverage_report_lines covered = covered.transform_values { |v| v.is_a?(Hash) ? v.fetch(:lines) : v } end # chose "Minitest" because it is what simplecov uses for reports and "Unit Tests" makes sonarqube break data = JSON.pretty_generate( "Minitest" => { "coverage" => covered, "timestamp" => Time.now.to_i } ) FileUtils.mkdir_p(File.dirname(report)) File.write report, data end
# File lib/single_cov.rb, line 195 def glob(pattern) Dir["#{root}/#{pattern}"].map! { |f| f.sub("#{root}/", '') } end
# File lib/single_cov.rb, line 373 def guess_covered_file(test) file = test.dup # remove caller junk to get nice error messages when something fails file.sub!(/\.rb\b.*/, '.rb') # resolve all kinds of relativity file = File.expand_path(file) # remove project root file.sub!("#{root}/", '') # preserve subfolders like foobar/test/xxx_test.rb -> foobar/lib/xxx_test.rb subfolder, file_part = file.split(%r{(?:^|/)(?:test|spec)/}, 2) unless file_part raise "#{file} includes neither 'test' nor 'spec' folder ... unable to resolve" end without_ignored_prefixes file_part do # rails things live in app file_part[0...0] = if file_part =~ /^(?:#{RAILS_APP_FOLDERS.map { |f| Regexp.escape(f) }.join('|')})\// "app/" elsif file_part.start_with?("lib/") # don't add lib twice "" else # everything else lives in lib "lib/" end # remove test extension if !file_part.sub!(/_(?:test|spec)\.rb\b.*/, '.rb') && !file_part.sub!(/\/test_/, "/") raise "Unable to remove test extension from #{file} ... /test_, _test.rb and _spec.rb are supported" end end # put back the subfolder file_part[0...0] = "#{subfolder}/" unless subfolder.empty? file_part = @rewrite.call(file_part) if defined?(@rewrite) && @rewrite file_part end
# File lib/single_cov.rb, line 199 def indexes(list, find) list.each_with_index.filter_map { |v, i| i if v == find } end
assuming that the main process will load all the files, we store it’s pid
# File lib/single_cov.rb, line 166 def main_process! @main_process_pid = Process.pid end
# File lib/single_cov.rb, line 170 def main_process? (!defined?(@main_process_pid) || @main_process_pid == Process.pid) end
do not record or verify when only running selected tests since it would be missing data
# File lib/single_cov.rb, line 264 def minitest_running_subset_of_tests? # via direct option (ruby test.rb -n /foo/) (ARGV.map { |a| a.split('=', 2).first } & ['-n', '--name', '-l', '--line']).any? || # via testrbl or mtest or rails with direct line number (mtest test.rb:123) (ARGV.first =~ /:\d+\Z/) || # via rails test which preloads mintest, removes ARGV and fills options ( defined?(Minitest) && defined?(Minitest.reporter) && Minitest.reporter && (reporter = Minitest.reporter.reporters.first) && reporter.options[:filter] ) end
we cannot insert our hooks when minitest is already running
# File lib/single_cov.rb, line 234 def minitest_should_not_be_running! return unless defined?(Minitest) return unless Minitest.class_variable_defined?(:@@installed_at_exit) return unless Minitest.class_variable_get(:@@installed_at_exit) # untested # https://github.com/rails/rails/pull/26515 rails loads autorun before test # but it works out for some reason return if Minitest.extensions.include?('rails') # untested # forking test runner does some hacky acrobatics to fake minitest status # and the resets it ... works out ok in the end ... return if faked_by_forking_test_runner? # ... but only if it's used with `--merge-coverage` otherwise the coverage reporting is useless if $0.end_with?("/forking-test-runner") raise "forking-test-runner only work with single_cov when using --merge-coverage" end raise "Load minitest after setting up SingleCov" end
# File lib/single_cov.rb, line 364 def no_coverage_error(file) if $LOADED_FEATURES.include?("#{root}/#{file}") # we cannot enforce $LOADED_FEATURES during covered! since it would fail when multiple files are loaded "#{file} was expected to be covered, but was already loaded before coverage started, which makes it uncoverable." else "#{file} was expected to be covered, but was never loaded." end end
code stolen from SimpleCov
# File lib/single_cov.rb, line 286 def override_at_exit at_exit do exit_status = if $! # was an exception thrown? # if it was a SystemExit, use the accompanying status # otherwise set a non-zero status representing termination by # some other exception (see github issue 41) $!.is_a?(SystemExit) ? $!.status : 1 else # Store the exit status of the test run since it goes away # after calling the at_exit proc... 0 end yield exit_status, $! # Force exit with stored status (see github issue #5) # unless it's nil or 0 (see github issue #281) Kernel.exit exit_status if exit_status && exit_status > 0 end end
# File lib/single_cov.rb, line 356 def red(text) if $stdin.tty? "\e[31m#{text}\e[0m" else text end end
# File lib/single_cov.rb, line 416 def root @root ||= (defined?(Bundler) && Bundler.root.to_s.sub(/\/gemfiles$/, '')) || Dir.pwd end
# File lib/single_cov.rb, line 281 def rspec_running_subset_of_tests? (ARGV & ['-t', '--tag', '-e', '--example']).any? || ARGV.any? { |a| a =~ /:\d+$|\[[\d:]+\]$/ } end
not running rake or a whole folder
# File lib/single_cov.rb, line 229 def running_single_file? COVERAGES.size == 1 end
start recording before classes are loaded or nothing can be recorded SimpleCov might start coverage again, but that does not hurt …
# File lib/single_cov.rb, line 219 def start_coverage_recording require 'coverage' if @branches Coverage.start(lines: true, branches: true) else Coverage.start(lines: true) end end
# File lib/single_cov.rb, line 148 def uncovered(coverage) return coverage unless coverage.is_a?(Hash) # just lines uncovered_lines = indexes(coverage.fetch(:lines), 0).map! { |i| i + 1 } uncovered_branches = uncovered_branches(coverage[:branches] || {}) uncovered_branches.reject! { |br| uncovered_lines.include?(br[0]) } # ignore branch when whole line is uncovered # combine lines and branches while keeping them sorted all = uncovered_lines.concat uncovered_branches all.sort_by! { |line_start, char_start, _, _, _| [line_start, char_start || 0] } # branches are unsorted all end
{[branch_id] => {[branch_part] => coverage}} –> uncovered location
# File lib/single_cov.rb, line 175 def uncovered_branches(coverage) sum = {} coverage.each_value do |branch| branch.filter_map do |part, c| location = [part[2], part[3] + 1, part[4], part[5] + 1] # locations can be duplicated type = part[0] info = (sum[location] ||= [0, nil]) info[0] += c info[1] = type if type == :else # only else is important to track since it often is not in the code end end # keep location and type of missing coverage sum.filter_map { |k, v| k + [v[1]] if v[0] == 0 } end
# File lib/single_cov.rb, line 348 def warning(msg) "#{msg}?" end
# File lib/single_cov.rb, line 352 def warning?(msg) msg.end_with?("?") end
file_part is modified during yield so we have to make sure to also modify in place
# File lib/single_cov.rb, line 443 def without_ignored_prefixes(file_part) folders = file_part.split('/') return yield unless PREFIXES_TO_IGNORE.include?(folders.first) prefix = folders.shift file_part.replace folders.join('/') yield file_part[0...0] = "#{prefix}/" end