├── .github └── workflows │ └── actions.yml ├── .gitignore ├── .rubocop.yml ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── Rakefile ├── Readme.md ├── lib ├── single_cov.rb └── single_cov │ └── version.rb ├── single_cov.gemspec └── specs ├── fixtures ├── minitest │ ├── bin │ │ └── rails │ ├── lib │ │ └── a.rb │ └── test │ │ └── a_test.rb └── rspec │ ├── lib │ └── a.rb │ └── spec │ └── a_spec.rb ├── single_cov_spec.rb └── spec_helper.rb /.github/workflows/actions.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | branches: [master] 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | ruby: [ '2.7', '3.0', '3.1', '3.2' ] 13 | task: [ 'spec' ] 14 | include: 15 | - ruby: 2.7 # keep in sync with lowest version 16 | task: rubocop 17 | name: ${{ matrix.ruby }} rake ${{ matrix.task }} 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ matrix.ruby }} 23 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 24 | - run: bundle exec rake ${{ matrix.task }} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.7 # lowest supported version 3 | NewCops: enable 4 | SuggestExtensions: false 5 | 6 | Style/StringLiterals: 7 | Enabled: false 8 | 9 | Style/StringLiteralsInInterpolation: 10 | Enabled: false 11 | 12 | Lint/AmbiguousRegexpLiteral: 13 | Enabled: false 14 | 15 | Bundler/OrderedGems: 16 | Enabled: false 17 | 18 | Metrics: 19 | Enabled: false 20 | 21 | Style/Documentation: 22 | Enabled: false 23 | 24 | Layout/EmptyLineAfterMagicComment: 25 | Enabled: false 26 | 27 | Layout/EndAlignment: 28 | EnforcedStyleAlignWith: variable 29 | 30 | Layout/MultilineOperationIndentation: 31 | Enabled: false 32 | 33 | Layout/MultilineMethodCallIndentation: 34 | EnforcedStyle: indented 35 | 36 | Style/NumericPredicate: 37 | EnforcedStyle: comparison 38 | 39 | Layout/EmptyLineAfterGuardClause: 40 | Enabled: false 41 | 42 | # https://github.com/rubocop-hq/rubocop/issues/5891 43 | Style/SpecialGlobalVars: 44 | Enabled: false 45 | 46 | Style/DoubleNegation: 47 | Enabled: false 48 | 49 | Naming/MethodParameterName: 50 | Enabled: false 51 | 52 | # TODO: fix and change forking-test-runner too 53 | Style/MutableConstant: 54 | Enabled: false 55 | 56 | Style/RegexpLiteral: 57 | Enabled: false 58 | 59 | Layout/LineLength: 60 | Enabled: false 61 | 62 | Style/IfUnlessModifier: 63 | Enabled: false 64 | 65 | Style/GuardClause: 66 | Enabled: false 67 | 68 | Style/InverseMethods: 69 | Enabled: false 70 | 71 | Lint/AssignmentInCondition: 72 | Enabled: false 73 | 74 | Style/WordArray: 75 | EnforcedStyle: brackets 76 | 77 | Style/SymbolArray: 78 | EnforcedStyle: brackets 79 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source "https://rubygems.org" 3 | gemspec 4 | 5 | gem "bump" 6 | gem "rake" 7 | gem "rspec" 8 | gem "minitest", "~> 5.0" 9 | gem "simplecov" 10 | gem "rubocop" 11 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | single_cov (1.11.0) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | ast (2.4.2) 10 | bump (0.10.0) 11 | diff-lcs (1.5.0) 12 | docile (1.4.0) 13 | json (2.6.3) 14 | minitest (5.16.3) 15 | parallel (1.22.1) 16 | parser (3.1.3.0) 17 | ast (~> 2.4.1) 18 | rainbow (3.1.1) 19 | rake (13.0.6) 20 | regexp_parser (2.6.1) 21 | rexml (3.2.5) 22 | rspec (3.12.0) 23 | rspec-core (~> 3.12.0) 24 | rspec-expectations (~> 3.12.0) 25 | rspec-mocks (~> 3.12.0) 26 | rspec-core (3.12.0) 27 | rspec-support (~> 3.12.0) 28 | rspec-expectations (3.12.1) 29 | diff-lcs (>= 1.2.0, < 2.0) 30 | rspec-support (~> 3.12.0) 31 | rspec-mocks (3.12.1) 32 | diff-lcs (>= 1.2.0, < 2.0) 33 | rspec-support (~> 3.12.0) 34 | rspec-support (3.12.0) 35 | rubocop (1.41.1) 36 | json (~> 2.3) 37 | parallel (~> 1.10) 38 | parser (>= 3.1.2.1) 39 | rainbow (>= 2.2.2, < 4.0) 40 | regexp_parser (>= 1.8, < 3.0) 41 | rexml (>= 3.2.5, < 4.0) 42 | rubocop-ast (>= 1.23.0, < 2.0) 43 | ruby-progressbar (~> 1.7) 44 | unicode-display_width (>= 1.4.0, < 3.0) 45 | rubocop-ast (1.24.0) 46 | parser (>= 3.1.1.0) 47 | ruby-progressbar (1.11.0) 48 | simplecov (0.22.0) 49 | docile (~> 1.1) 50 | simplecov-html (~> 0.11) 51 | simplecov_json_formatter (~> 0.1) 52 | simplecov-html (0.12.3) 53 | simplecov_json_formatter (0.1.4) 54 | unicode-display_width (2.3.0) 55 | 56 | PLATFORMS 57 | ruby 58 | 59 | DEPENDENCIES 60 | bump 61 | minitest (~> 5.0) 62 | rake 63 | rspec 64 | rubocop 65 | simplecov 66 | single_cov! 67 | 68 | BUNDLED WITH 69 | 2.4.1 70 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2013 Michael Grosser 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "bundler/setup" 3 | require "bundler/gem_tasks" 4 | require "bump/tasks" 5 | 6 | task default: [:spec, :rubocop] 7 | 8 | task :spec do 9 | m = (Bundler.respond_to?(:with_unbundled_env) ? :with_unbundled_env : :with_clean_env) 10 | Bundler.send(m) do 11 | sh "bundle exec rspec specs/single_cov_spec.rb --warnings" 12 | end 13 | end 14 | 15 | task :rubocop do 16 | sh "bundle exec rubocop" 17 | end 18 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Single Cov [![CI](https://github.com/grosser/single_cov/actions/workflows/actions.yml/badge.svg)](https://github.com/grosser/single_cov/actions?query=branch%3Amaster) 2 | 3 | Actionable code coverage. 4 | 5 | ```Bash 6 | rspec spec/foobar_spec.rb 7 | ...... 8 | 114 example, 0 failures 9 | 10 | lib/foobar.rb new uncovered lines introduced (2 current vs 0 configured) 11 | Uncovered lines: 12 | lib/foobar.rb:22 13 | lib/foobar.rb:23:6-19 14 | ``` 15 | 16 | - Missing coverage on every 💚 test run 17 | - Catch coverage issues before making PRs 18 | - Easily add gradual coverage enforcement for legacy apps 19 | - Up to 10x faster than SingleCov: 2-5% runtime overhead on small files, compared to 20% 20 | - Branch coverage (disable via `branches: false`) 21 | - Use with [forking_test_runner](https://github.com/grosser/forking_test_runner) for exact per test coverage 22 | 23 | ```Ruby 24 | # Gemfile 25 | gem 'single_cov', group: :test 26 | 27 | # spec/spec_helper.rb ... load single_cov before rails, libraries, minitest, or rspec 28 | require 'single_cov' 29 | SingleCov.setup :rspec # or :minitest 30 | 31 | # spec/foobar_spec.rb ... add covered! call to test files 32 | require 'spec_helper' 33 | SingleCov.covered! 34 | 35 | describe "xyz" do ... 36 | ``` 37 | 38 | ### Missing target file 39 | 40 | Each `covered!` call expects to find a matching file, if it does not: 41 | 42 | ```Ruby 43 | # change all guessed paths 44 | SingleCov.rewrite { |f| f.sub('lib/unit/', 'app/models/') } 45 | 46 | # mark directory as being in app and not lib 47 | SingleCov::RAILS_APP_FOLDERS << 'presenters' 48 | 49 | # add 1-off 50 | SingleCov.covered! file: 'scripts/weird_thing.rb' 51 | ``` 52 | 53 | ### Known uncovered 54 | 55 | Add the inline comment `# uncovered` to ignore uncovered code. 56 | 57 | Prevent addition of new uncovered code, without having to cover all existing code by marking how many lines are uncovered: 58 | 59 | ```Ruby 60 | SingleCov.covered! uncovered: 4 61 | ``` 62 | 63 | ### Making a folder not get prefixed with lib/ 64 | 65 | For example packwerk components are hosted in `public` and not `lib/public` 66 | 67 | ```ruby 68 | SingleCov::PREFIXES_TO_IGNORE << "public" 69 | ``` 70 | 71 | ### Missing coverage for implicit `else` in `if` or `case` statements 72 | 73 | When a report shows for example `1:14-16 # else`, that indicates that the implicit else is not covered. 74 | 75 | ```ruby 76 | # needs 2 tests: one for `true` and one for `false` 77 | raise if a == b 78 | 79 | # needs 2 tests: one for `when b` and one for `else` 80 | case a 81 | when b 82 | end 83 | ``` 84 | 85 | ### Verify all code has tests & coverage 86 | 87 | ```Ruby 88 | # spec/coverage_spec.rb 89 | SingleCov.not_covered! # not testing any code in lib/ 90 | 91 | describe "Coverage" do 92 | # recommended 93 | it "does not allow new tests without coverage check" do 94 | # option :tests to pass custom Dir.glob results 95 | SingleCov.assert_used 96 | end 97 | 98 | # recommended 99 | it "does not allow new untested files" do 100 | # option :tests and :files to pass custom Dir.glob results 101 | # :untested to get it passing with known untested files 102 | SingleCov.assert_tested 103 | end 104 | 105 | # optional for full coverage enforcement 106 | it "does not reduce full coverage" do 107 | # make sure that nobody adds `uncovered: 123` to any test that did not have it before 108 | # option :tests to pass custom Dir.glob results 109 | # option :currently_complete for expected list of full covered tests 110 | # option :location for if you store that list in a separate file 111 | SingleCov.assert_full_coverage currently_complete: ["test/a_test.rb"] 112 | end 113 | end 114 | ``` 115 | 116 | ### Automatic bootstrap 117 | 118 | Run this from `irb` to get SingleCov added to all test files. 119 | 120 | ```Ruby 121 | tests = Dir['spec/**/*_spec.rb'] 122 | command = "bundle exec rspec %{file}" 123 | 124 | tests.each do |f| 125 | content = File.read(f) 126 | next if content.include?('SingleCov.') 127 | 128 | # add initial SingleCov call 129 | content = content.split(/\n/, -1) 130 | insert = content.index { |l| l !~ /require/ && l !~ /^#/ } 131 | content[insert...insert] = ["", "SingleCov.covered!"] 132 | File.write(f, content.join("\n")) 133 | 134 | # run the test to check coverage 135 | result = `#{command.sub('%{file}', f)} 2>&1` 136 | if $?.success? 137 | puts "#{f} is good!" 138 | next 139 | end 140 | 141 | if uncovered = result[/\((\d+) current/, 1] 142 | # configure uncovered 143 | puts "Uncovered for #{f} is #{uncovered}" 144 | content[insert+1] = "SingleCov.covered! uncovered: #{uncovered}" 145 | File.write(f, content.join("\n")) 146 | else 147 | # mark bad tests for manual cleanup 148 | content[insert+1] = "# SingleCov.covered! # TODO: manually fix this" 149 | File.write(f, content.join("\n")) 150 | puts "Manually fix: #{f} ... output is:\n#{result}" 151 | end 152 | end 153 | ``` 154 | 155 | ### Cover multiple files from a single test 156 | 157 | When a single integration test covers multiple source files. 158 | 159 | ```ruby 160 | SingleCov.covered! file: 'app/modes/user.rb' 161 | SingleCov.covered! file: 'app/mailers/user_mailer.rb' 162 | SingleCov.covered! file: 'app/controllers/user_controller.rb' 163 | ``` 164 | 165 | ### Generating a coverage report 166 | 167 | ```ruby 168 | SingleCov.coverage_report = "coverage/.resultset.json" 169 | SingleCov.coverage_report_lines = true # only report line coverage for coverage systems that do not support branch coverage 170 | ``` 171 | 172 | Author 173 | ====== 174 | [Michael Grosser](http://grosser.it)
175 | michael@grosser.it
176 | License: MIT 177 | -------------------------------------------------------------------------------- /lib/single_cov.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SingleCov 3 | COVERAGES = [] 4 | MAX_OUTPUT = Integer(ENV["SINGLE_COV_MAX_OUTPUT"] || "40") 5 | RAILS_APP_FOLDERS = ["models", "serializers", "helpers", "controllers", "mailers", "views", "jobs", "channels"] 6 | UNCOVERED_COMMENT_MARKER = /#.*uncovered/ 7 | PREFIXES_TO_IGNORE = [] # things to not prefix with lib/ etc 8 | 9 | class << self 10 | # enable coverage reporting: path to output file, changed by forking-test-runner at runtime to combine many reports 11 | attr_accessor :coverage_report 12 | 13 | # emit only line coverage in coverage report for older coverage systems 14 | attr_accessor :coverage_report_lines 15 | 16 | # optionally rewrite the matching path single-cov guessed with a lambda 17 | def rewrite(&block) 18 | @rewrite = block 19 | end 20 | 21 | # mark a test file as not covering anything to make assert_used pass 22 | def not_covered! 23 | main_process! 24 | end 25 | 26 | # mark the file under test as needing coverage 27 | def covered!(file: nil, uncovered: 0) 28 | file = ensure_covered_file(file) 29 | COVERAGES << [file, uncovered] 30 | main_process! 31 | end 32 | 33 | def all_covered?(result) 34 | errors = COVERAGES.flat_map do |file, expected_uncovered| 35 | next no_coverage_error(file) unless coverage = result["#{root}/#{file}"] 36 | 37 | uncovered = uncovered(coverage) 38 | next if uncovered.size == expected_uncovered 39 | 40 | # ignore lines that are marked as uncovered via comments 41 | # TODO: warn when using uncovered but the section is indeed covered 42 | content = File.readlines("#{root}/#{file}") 43 | uncovered.reject! do |line_start, _, _, _, _| 44 | content[line_start - 1].match?(UNCOVERED_COMMENT_MARKER) 45 | end 46 | next if uncovered.size == expected_uncovered 47 | 48 | bad_coverage_error(file, expected_uncovered, uncovered) 49 | end.compact 50 | 51 | return true if errors.empty? 52 | 53 | if errors.size >= MAX_OUTPUT 54 | errors[MAX_OUTPUT..-1] = "... coverage output truncated (use SINGLE_COV_MAX_OUTPUT=999 to expand)" 55 | end 56 | @error_logger.puts errors 57 | 58 | errors.all? { |l| warning?(l) } 59 | end 60 | 61 | def assert_used(tests: default_tests) 62 | bad = tests.select do |file| 63 | File.read(file) !~ /SingleCov.(not_)?covered!/ 64 | end 65 | unless bad.empty? 66 | raise bad.map { |f| "#{f}: needs to use SingleCov.covered!" }.join("\n") 67 | end 68 | end 69 | 70 | def assert_tested(files: glob('{app,lib}/**/*.rb'), tests: default_tests, untested: []) 71 | missing = files - tests.map { |t| guess_covered_file(t) } 72 | fixed = untested - missing 73 | missing -= untested 74 | 75 | if fixed.any? 76 | raise "Remove #{fixed.inspect} from untested!" 77 | elsif missing.any? 78 | raise missing.map { |f| "missing test for #{f}" }.join("\n") 79 | end 80 | end 81 | 82 | def assert_full_coverage(tests: default_tests, currently_complete: [], location: nil) 83 | location ||= caller(0..1)[1].split(':in').first 84 | complete = tests.select { |file| File.read(file) =~ /SingleCov.covered!(?:(?!uncovered).)*(\s*|\s*\#.*)$/ } 85 | missing_complete = currently_complete - complete 86 | newly_complete = complete - currently_complete 87 | errors = [] 88 | 89 | if missing_complete.any? 90 | errors << <<~MSG 91 | The following file(s) were previously marked as having 100% SingleCov test coverage (had no `coverage:` option) but are no longer marked as such. 92 | #{missing_complete.join("\n")} 93 | Please increase test coverage in these files to maintain 100% coverage and remove `coverage:` usage. 94 | 95 | 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. 96 | MSG 97 | end 98 | 99 | if newly_complete.any? 100 | errors << <<~MSG 101 | The following files are newly at 100% SingleCov test coverage. 102 | Please add the following to #{location} to ensure 100% coverage is maintained moving forward. 103 | #{newly_complete.join("\n")} 104 | MSG 105 | end 106 | 107 | raise errors.join("\n") if errors.any? 108 | end 109 | 110 | def setup(framework, root: nil, branches: true, err: $stderr) 111 | @error_logger = err 112 | 113 | if defined?(SimpleCov) 114 | raise "Load SimpleCov after SingleCov" 115 | end 116 | 117 | @branches = branches 118 | @root = root 119 | 120 | case framework 121 | when :minitest 122 | minitest_should_not_be_running! 123 | return if minitest_running_subset_of_tests? 124 | when :rspec 125 | return if rspec_running_subset_of_tests? 126 | else 127 | raise "Unsupported framework #{framework.inspect}" 128 | end 129 | 130 | start_coverage_recording 131 | 132 | override_at_exit do |status, _exception| 133 | if enabled? && main_process? && status == 0 134 | results = coverage_results 135 | generate_report results 136 | exit 1 unless SingleCov.all_covered?(results) 137 | end 138 | end 139 | end 140 | 141 | # use this in forks when using rspec to silence duplicated output 142 | def disable 143 | @disabled = true 144 | end 145 | 146 | private 147 | 148 | def uncovered(coverage) 149 | return coverage unless coverage.is_a?(Hash) # just lines 150 | 151 | uncovered_lines = indexes(coverage.fetch(:lines), 0).map! { |i| i + 1 } 152 | uncovered_branches = uncovered_branches(coverage[:branches] || {}) 153 | uncovered_branches.reject! { |br| uncovered_lines.include?(br[0]) } # ignore branch when whole line is uncovered 154 | 155 | # combine lines and branches while keeping them sorted 156 | all = uncovered_lines.concat uncovered_branches 157 | all.sort_by! { |line_start, char_start, _, _, _| [line_start, char_start || 0] } # branches are unsorted 158 | all 159 | end 160 | 161 | def enabled? 162 | (!defined?(@disabled) || !@disabled) 163 | end 164 | 165 | # assuming that the main process will load all the files, we store it's pid 166 | def main_process! 167 | @main_process_pid = Process.pid 168 | end 169 | 170 | def main_process? 171 | (!defined?(@main_process_pid) || @main_process_pid == Process.pid) 172 | end 173 | 174 | # {[branch_id] => {[branch_part] => coverage}} --> uncovered location 175 | def uncovered_branches(coverage) 176 | sum = {} 177 | coverage.each_value do |branch| 178 | branch.filter_map do |part, c| 179 | location = [part[2], part[3] + 1, part[4], part[5] + 1] # locations can be duplicated 180 | type = part[0] 181 | info = (sum[location] ||= [0, nil]) 182 | info[0] += c 183 | info[1] = type if type == :else # only else is important to track since it often is not in the code 184 | end 185 | end 186 | 187 | # keep location and type of missing coverage 188 | sum.filter_map { |k, v| k + [v[1]] if v[0] == 0 } 189 | end 190 | 191 | def default_tests 192 | glob("{test,spec}/**/*_{test,spec}.rb") 193 | end 194 | 195 | def glob(pattern) 196 | Dir["#{root}/#{pattern}"].map! { |f| f.sub("#{root}/", '') } 197 | end 198 | 199 | def indexes(list, find) 200 | list.each_with_index.filter_map { |v, i| i if v == find } 201 | end 202 | 203 | # do not ask for coverage when SimpleCov already does or it conflicts 204 | def coverage_results 205 | if defined?(SimpleCov) && (result = SimpleCov.instance_variable_get(:@result)) 206 | result = result.original_result 207 | # singlecov 1.18+ puts string "lines" into the result that we cannot read 208 | if result.each_value.first.is_a?(Hash) 209 | result = result.transform_values { |v| v.transform_keys(&:to_sym) } 210 | end 211 | result 212 | else 213 | Coverage.result 214 | end 215 | end 216 | 217 | # start recording before classes are loaded or nothing can be recorded 218 | # SimpleCov might start coverage again, but that does not hurt ... 219 | def start_coverage_recording 220 | require 'coverage' 221 | if @branches 222 | Coverage.start(lines: true, branches: true) 223 | else 224 | Coverage.start(lines: true) 225 | end 226 | end 227 | 228 | # not running rake or a whole folder 229 | def running_single_file? 230 | COVERAGES.size == 1 231 | end 232 | 233 | # we cannot insert our hooks when minitest is already running 234 | def minitest_should_not_be_running! 235 | return unless defined?(Minitest) 236 | return unless Minitest.class_variable_defined?(:@@installed_at_exit) 237 | return unless Minitest.class_variable_get(:@@installed_at_exit) 238 | 239 | # untested 240 | # https://github.com/rails/rails/pull/26515 rails loads autorun before test 241 | # but it works out for some reason 242 | return if Minitest.extensions.include?('rails') 243 | 244 | # untested 245 | # forking test runner does some hacky acrobatics to fake minitest status 246 | # and the resets it ... works out ok in the end ... 247 | return if faked_by_forking_test_runner? 248 | 249 | # ... but only if it's used with `--merge-coverage` otherwise the coverage reporting is useless 250 | if $0.end_with?("/forking-test-runner") 251 | raise "forking-test-runner only work with single_cov when using --merge-coverage" 252 | end 253 | 254 | raise "Load minitest after setting up SingleCov" 255 | end 256 | 257 | # ForkingTestRunner fakes an initialized minitest to avoid multiple hooks being installed 258 | # so hooks still get added in order https://github.com/grosser/forking_test_runner/pull/4 259 | def faked_by_forking_test_runner? 260 | defined?(Coverage) && Coverage.respond_to?(:capture_coverage!) 261 | end 262 | 263 | # do not record or verify when only running selected tests since it would be missing data 264 | def minitest_running_subset_of_tests? 265 | # via direct option (ruby test.rb -n /foo/) 266 | (ARGV.map { |a| a.split('=', 2).first } & ['-n', '--name', '-l', '--line']).any? || 267 | 268 | # via testrbl or mtest or rails with direct line number (mtest test.rb:123) 269 | (ARGV.first =~ /:\d+\Z/) || 270 | 271 | # via rails test which preloads mintest, removes ARGV and fills options 272 | ( 273 | defined?(Minitest) && 274 | defined?(Minitest.reporter) && 275 | Minitest.reporter && 276 | (reporter = Minitest.reporter.reporters.first) && 277 | reporter.options[:filter] 278 | ) 279 | end 280 | 281 | def rspec_running_subset_of_tests? 282 | (ARGV & ['-t', '--tag', '-e', '--example']).any? || ARGV.any? { |a| a =~ /:\d+$|\[[\d:]+\]$/ } 283 | end 284 | 285 | # code stolen from SimpleCov 286 | def override_at_exit 287 | at_exit do 288 | exit_status = if $! # was an exception thrown? 289 | # if it was a SystemExit, use the accompanying status 290 | # otherwise set a non-zero status representing termination by 291 | # some other exception (see github issue 41) 292 | $!.is_a?(SystemExit) ? $!.status : 1 293 | else 294 | # Store the exit status of the test run since it goes away 295 | # after calling the at_exit proc... 296 | 0 297 | end 298 | 299 | yield exit_status, $! 300 | 301 | # Force exit with stored status (see github issue #5) 302 | # unless it's nil or 0 (see github issue #281) 303 | Kernel.exit exit_status if exit_status && exit_status > 0 304 | end 305 | end 306 | 307 | def ensure_covered_file(file) 308 | if file 309 | raise "Use paths relative to project root." if file.start_with?("/") 310 | raise "#{file} does not exist, use paths relative to project root." unless File.exist?("#{root}/#{file}") 311 | else 312 | file = guess_covered_file(caller[1]) 313 | if file.start_with?("/") 314 | raise "Found file #{file} which is not relative to the root #{root}.\nUse `SingleCov.covered! file: 'target_file.rb'` to set covered file location." 315 | elsif !File.exist?("#{root}/#{file}") 316 | 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." 317 | end 318 | end 319 | 320 | file 321 | end 322 | 323 | def bad_coverage_error(file, expected_uncovered, uncovered) 324 | details = "(#{uncovered.size} current vs #{expected_uncovered} configured)" 325 | if expected_uncovered > uncovered.size 326 | if running_single_file? 327 | warning "#{file} has less uncovered lines #{details}, decrement configured uncovered" 328 | end 329 | else 330 | [ 331 | "#{file} new uncovered lines introduced #{details}", 332 | red("Lines missing coverage:"), 333 | *uncovered.map do |line_start, char_start, line_end, char_end, type| 334 | if char_start # branch coverage 335 | if line_start == line_end 336 | "#{file}:#{line_start}:#{char_start}-#{char_end}" 337 | else # possibly unreachable since branches always seem to be on the same line 338 | "#{file}:#{line_start}:#{char_start}-#{line_end}:#{char_end}" 339 | end + (type ? " # #{type}" : "") 340 | else 341 | "#{file}:#{line_start}" 342 | end 343 | end 344 | ] 345 | end 346 | end 347 | 348 | def warning(msg) 349 | "#{msg}?" 350 | end 351 | 352 | def warning?(msg) 353 | msg.end_with?("?") 354 | end 355 | 356 | def red(text) 357 | if $stdin.tty? 358 | "\e[31m#{text}\e[0m" 359 | else 360 | text 361 | end 362 | end 363 | 364 | def no_coverage_error(file) 365 | if $LOADED_FEATURES.include?("#{root}/#{file}") 366 | # we cannot enforce $LOADED_FEATURES during covered! since it would fail when multiple files are loaded 367 | "#{file} was expected to be covered, but was already loaded before coverage started, which makes it uncoverable." 368 | else 369 | "#{file} was expected to be covered, but was never loaded." 370 | end 371 | end 372 | 373 | def guess_covered_file(test) 374 | file = test.dup 375 | 376 | # remove caller junk to get nice error messages when something fails 377 | file.sub!(/\.rb\b.*/, '.rb') 378 | 379 | # resolve all kinds of relativity 380 | file = File.expand_path(file) 381 | 382 | # remove project root 383 | file.sub!("#{root}/", '') 384 | 385 | # preserve subfolders like foobar/test/xxx_test.rb -> foobar/lib/xxx_test.rb 386 | subfolder, file_part = file.split(%r{(?:^|/)(?:test|spec)/}, 2) 387 | unless file_part 388 | raise "#{file} includes neither 'test' nor 'spec' folder ... unable to resolve" 389 | end 390 | 391 | without_ignored_prefixes file_part do 392 | # rails things live in app 393 | file_part[0...0] = 394 | if file_part =~ /^(?:#{RAILS_APP_FOLDERS.map { |f| Regexp.escape(f) }.join('|')})\// 395 | "app/" 396 | elsif file_part.start_with?("lib/") # don't add lib twice 397 | "" 398 | else # everything else lives in lib 399 | "lib/" 400 | end 401 | 402 | # remove test extension 403 | if !file_part.sub!(/_(?:test|spec)\.rb\b.*/, '.rb') && !file_part.sub!(/\/test_/, "/") 404 | raise "Unable to remove test extension from #{file} ... /test_, _test.rb and _spec.rb are supported" 405 | end 406 | end 407 | 408 | # put back the subfolder 409 | file_part[0...0] = "#{subfolder}/" unless subfolder.empty? 410 | 411 | file_part = @rewrite.call(file_part) if defined?(@rewrite) && @rewrite 412 | 413 | file_part 414 | end 415 | 416 | def root 417 | @root ||= (defined?(Bundler) && Bundler.root.to_s.sub(/\/gemfiles$/, '')) || Dir.pwd 418 | end 419 | 420 | def generate_report(results) 421 | return unless report = coverage_report 422 | 423 | # not a hard dependency for the whole library 424 | require "json" 425 | require "fileutils" 426 | 427 | used = COVERAGES.map { |f, _| "#{root}/#{f}" } 428 | covered = results.select { |k, _| used.include?(k) } 429 | 430 | if coverage_report_lines 431 | covered = covered.transform_values { |v| v.is_a?(Hash) ? v.fetch(:lines) : v } 432 | end 433 | 434 | # chose "Minitest" because it is what simplecov uses for reports and "Unit Tests" makes sonarqube break 435 | data = JSON.pretty_generate( 436 | "Minitest" => { "coverage" => covered, "timestamp" => Time.now.to_i } 437 | ) 438 | FileUtils.mkdir_p(File.dirname(report)) 439 | File.write report, data 440 | end 441 | 442 | # file_part is modified during yield so we have to make sure to also modify in place 443 | def without_ignored_prefixes(file_part) 444 | folders = file_part.split('/') 445 | return yield unless PREFIXES_TO_IGNORE.include?(folders.first) 446 | 447 | prefix = folders.shift 448 | file_part.replace folders.join('/') 449 | 450 | yield 451 | 452 | file_part[0...0] = "#{prefix}/" 453 | end 454 | end 455 | end 456 | -------------------------------------------------------------------------------- /lib/single_cov/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SingleCov 3 | VERSION = "1.11.0" 4 | end 5 | -------------------------------------------------------------------------------- /single_cov.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | name = "single_cov" 3 | require "./lib/#{name.gsub("-", "/")}/version" 4 | 5 | Gem::Specification.new name, SingleCov::VERSION do |s| 6 | s.summary = "Actionable code coverage." 7 | s.authors = ["Michael Grosser"] 8 | s.email = "michael@grosser.it" 9 | s.homepage = "https://github.com/grosser/#{name}" 10 | s.files = `git ls-files lib/ bin/ MIT-LICENSE`.split("\n") 11 | s.license = "MIT" 12 | s.required_ruby_version = '>= 2.7.0' 13 | end 14 | -------------------------------------------------------------------------------- /specs/fixtures/minitest/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | ARGV.shift # ... basically what `rails test` does ... 4 | 5 | require 'bundler/setup' 6 | require 'minitest' 7 | 8 | # calls Minitest.run which sets up reporters with all parsed options 9 | # and removes the options from ARGV 10 | options = {} 11 | options[:filter] = /foo/ if ARGV.delete('-n') && ARGV.delete('/foo/') 12 | Minitest.reporter = Minitest::CompositeReporter.new 13 | Minitest.reporter.reporters << Minitest::SummaryReporter.new(StringIO.new, options) 14 | 15 | require './test/a_test' 16 | -------------------------------------------------------------------------------- /specs/fixtures/minitest/lib/a.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class A 3 | def a 4 | 1 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /specs/fixtures/minitest/test/a_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'bundler/setup' 3 | 4 | $LOAD_PATH << File.expand_path('../lib', __dir__) 5 | $VERBOSE = true 6 | 7 | require 'single_cov' 8 | root = File.expand_path('..', __dir__) 9 | SingleCov.setup :minitest, root: root 10 | 11 | require 'minitest/autorun' 12 | 13 | SingleCov.covered! 14 | 15 | require 'a' 16 | 17 | describe A do 18 | it "does a" do 19 | fork {} # rubocop:disable Lint/EmptyBlock 20 | 21 | assert A.new.a 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /specs/fixtures/rspec/lib/a.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class A 3 | def a 4 | 1 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /specs/fixtures/rspec/spec/a_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'bundler/setup' 3 | 4 | $LOAD_PATH << File.expand_path('../lib', __dir__) 5 | 6 | require 'single_cov' 7 | root = File.expand_path('..', __dir__) 8 | SingleCov.setup :rspec, root: root 9 | 10 | SingleCov.covered! 11 | 12 | require 'a' 13 | 14 | describe A do 15 | it "does a" do 16 | expect(A.new.a).to eq 1 17 | end 18 | 19 | 2.times do 20 | it "does i" do 21 | expect(1).to eq 1 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /specs/single_cov_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "spec_helper" 3 | 4 | SingleCov.instance_variable_set(:@root, File.expand_path('fixtures/minitest', __dir__)) 5 | 6 | describe SingleCov do 7 | def self.it_does_not_complain_when_everything_is_covered(in_test: false) 8 | it "does not complain when everything is covered" do 9 | result = sh(in_test ? "cd test && ruby a_test.rb" : "ruby test/a_test.rb") 10 | assert_tests_finished_normally(result) 11 | expect(result).to_not include "uncovered" 12 | end 13 | end 14 | 15 | def add_missing_coverage(&block) 16 | change_file("test/a_test.rb", "A.new.a", "1 # no test ...", &block) 17 | end 18 | 19 | it "has a VERSION" do 20 | expect(SingleCov::VERSION).to match(/^[.\da-z]+$/) 21 | end 22 | 23 | describe "minitest" do 24 | let(:default_setup) { "SingleCov.setup :minitest, root: root" } 25 | 26 | around { |test| Dir.chdir("specs/fixtures/minitest", &test) } 27 | 28 | it_does_not_complain_when_everything_is_covered 29 | 30 | it "is silent" do 31 | result = sh "ruby test/a_test.rb" 32 | assert_tests_finished_normally(result) 33 | expect(result).to_not include "warning" 34 | end 35 | 36 | it "can redirect output" do 37 | create_file("err", "1") do 38 | result = change_file "test/a_test.rb", ":minitest, ", ":minitest, err: File.open('err', 'w'), " do 39 | change_file "test/a_test.rb", ".covered!", ".covered! uncovered: 3" do 40 | sh "ruby test/a_test.rb" 41 | end 42 | end 43 | assert_tests_finished_normally(result) 44 | expect(result).to_not include "lib/a.rb has less uncovered lines" 45 | expect(File.read("err")).to include "lib/a.rb has less uncovered lines" 46 | end 47 | end 48 | 49 | it "complains about missing implicit else for if" do 50 | change_file("lib/a.rb", "1", "1 if 1.to_s == '1'") do # does not work with `if true` since ruby inlines it 51 | result = sh "ruby test/a_test.rb", fail: true 52 | assert_tests_finished_normally(result) 53 | expect(result).to include "1 current" 54 | expect(result).to include "lib/a.rb:4:5-23" 55 | end 56 | end 57 | 58 | it "complains about missing implicit else for case" do 59 | change_file("lib/a.rb", "1", "case 1\nwhen 1 then 1\nend") do 60 | result = sh "ruby test/a_test.rb", fail: true 61 | assert_tests_finished_normally(result) 62 | expect(result).to include "1 current" 63 | expect(result).to include "lib/a.rb:4:5-6:4" 64 | end 65 | end 66 | 67 | describe "with many uncovered" do 68 | around { |test| change_file("lib/a.rb", "1", "return 1 if 1 == 1\n#{100.times.map { "puts 1" }.join("\n")}", &test) } 69 | 70 | it "truncates when too many lines are uncovered" do 71 | result = sh "ruby test/a_test.rb", fail: true 72 | assert_tests_finished_normally(result) 73 | expect(result).to include "1 current" 74 | expect(result.count("\n")).to equal 50 75 | end 76 | 77 | it "can truncate to custom length for in-depth debugging" do 78 | result = sh "SINGLE_COV_MAX_OUTPUT=60 ruby test/a_test.rb", fail: true 79 | assert_tests_finished_normally(result) 80 | expect(result).to include "1 current" 81 | expect(result.count("\n")).to equal 70 82 | end 83 | end 84 | 85 | describe "running in non-root" do 86 | it_does_not_complain_when_everything_is_covered in_test: true 87 | 88 | it "can report failure" do 89 | add_missing_coverage do 90 | result = sh "cd test && ruby a_test.rb", fail: true 91 | expect(result).to include "uncovered" 92 | end 93 | end 94 | end 95 | 96 | describe "fork" do 97 | it "does not complain in forks" do 98 | change_file("test/a_test.rb", %(it "does a" do), %(it "does a" do\nfork { }\n)) do 99 | result = sh "ruby test/a_test.rb" 100 | assert_tests_finished_normally(result) 101 | expect(result).to_not include("cover") 102 | end 103 | end 104 | 105 | # fork exists with 1 ... so our override ignores it ... 106 | it "does not complain when forking" do 107 | change_file("test/a_test.rb", "assert A.new.a", "assert fork { 1 }\nsleep 0.1\n") do 108 | result = sh "ruby test/a_test.rb", fail: true 109 | assert_tests_finished_normally(result) 110 | expect(result.scan(/missing coverage/).size).to eq 1 111 | end 112 | end 113 | end 114 | 115 | describe "when coverage has increased" do 116 | around { |t| change_file("test/a_test.rb", "SingleCov.covered!", "SingleCov.covered! uncovered: 1", &t) } 117 | 118 | # we might be running multiple files or have some special argument ... don't blow up 119 | it "warns" do 120 | result = sh "ruby test/a_test.rb" 121 | assert_tests_finished_normally(result) 122 | message = "lib/a.rb has less uncovered lines (0 current vs 1 configured), decrement configured uncovered?" 123 | expect(result).to include message 124 | end 125 | 126 | it "does not warn when running multiple files" do 127 | create_file 'test/b_test.rb', 'SingleCov.covered! file: "lib/a.rb"' do 128 | result = sh "ruby -r bundler/setup -r ./test/a_test.rb -r ./test/b_test.rb -e 1" 129 | assert_tests_finished_normally(result) 130 | expect(result).to_not include "has less uncovered lines" 131 | end 132 | end 133 | end 134 | 135 | describe "when single item is uncovered" do 136 | around { |block| add_missing_coverage(&block) } 137 | 138 | it "complains" do 139 | result = sh "ruby test/a_test.rb", fail: true 140 | assert_tests_finished_normally(result) 141 | expect(result).to include "uncovered" 142 | end 143 | 144 | it "does not complain when only running selected tests via option" do 145 | result = sh "ruby test/a_test.rb -n /a/" 146 | assert_tests_finished_normally(result) 147 | expect(result).to_not include "uncovered" 148 | end 149 | 150 | it "does not complain when only running selected tests via = option" do 151 | result = sh "ruby test/a_test.rb -n=/a/" 152 | assert_tests_finished_normally(result) 153 | expect(result).to_not include "uncovered" 154 | end 155 | 156 | it "does not complain when only running selected tests via options and rails" do 157 | result = sh "bin/rails test test/a_test.rb -n '/foo/'" 158 | assert_tests_finished_normally(result) 159 | expect(result).to_not include "uncovered" 160 | end 161 | 162 | it "does not complain when only running selected tests via line number" do 163 | result = sh "bin/rails test test/a_test.rb:12" 164 | assert_tests_finished_normally(result) 165 | expect(result).to_not include "uncovered" 166 | end 167 | 168 | it "does not complain when tests failed" do 169 | change_file("test/a_test.rb", "assert", "refute") do 170 | result = sh "ruby test/a_test.rb", fail: true 171 | expect(result).to include "1 runs, 1 assertions, 1 failures" 172 | expect(result).to_not include "uncovered" 173 | end 174 | end 175 | 176 | it "does not complain when individually disabled" do 177 | change_file("lib/a.rb", "1", "1 # uncovered") do 178 | sh "ruby test/a_test.rb" 179 | end 180 | end 181 | end 182 | 183 | describe "load order" do 184 | it "complains when minitest was started before and setup will not work" do 185 | change_file("test/a_test.rb", "require 'single_cov'", "require 'single_cov'\nrequire 'minitest/autorun'") do 186 | result = sh "ruby test/a_test.rb", fail: true 187 | expect(result).to include "Load minitest after setting up SingleCov" 188 | end 189 | end 190 | 191 | it "does not complain when minitest was loaded before setup" do 192 | change_file("test/a_test.rb", "require 'single_cov'", "require 'single_cov'\nmodule Minitest;end\n") do 193 | result = sh "ruby test/a_test.rb" 194 | assert_tests_finished_normally(result) 195 | end 196 | end 197 | end 198 | 199 | describe "when file cannot be found from caller" do 200 | around { |test| move_file("test/a_test.rb", "test/b_test.rb", &test) } 201 | 202 | it "complains" do 203 | result = sh "ruby test/b_test.rb", fail: true 204 | expect(result).to include "Tried to guess covered file as lib/b.rb, but it does not exist." 205 | expect(result).to include "Use `SingleCov.covered! file: 'target_file.rb'` to set covered file location." 206 | end 207 | 208 | it "works with a rewrite" do 209 | change_file("test/b_test.rb", "SingleCov.covered!", "SingleCov.rewrite { |f| 'lib/a.rb' }\nSingleCov.covered!") do 210 | result = sh "ruby test/b_test.rb" 211 | assert_tests_finished_normally(result) 212 | end 213 | end 214 | 215 | it "works with configured file" do 216 | change_file("test/b_test.rb", "SingleCov.covered!", "SingleCov.covered! file: 'lib/a.rb'") do 217 | result = sh "ruby test/b_test.rb" 218 | assert_tests_finished_normally(result) 219 | end 220 | end 221 | end 222 | 223 | describe "when SimpleCov was loaded after" do 224 | # NOTE: SimpleCov also starts coverage and will break when we activated branches 225 | let(:branchless_setup) { default_setup.sub('root: root', 'root: root, branches: false') } 226 | 227 | around { |t| change_file("test/a_test.rb", default_setup, "#{branchless_setup}\nrequire 'simplecov'\nSimpleCov.start\n", &t) } 228 | 229 | it "works" do 230 | result = sh "ruby test/a_test.rb" 231 | assert_tests_finished_normally(result) 232 | expect(result).to include "3 / 3 LOC (100.0%) covered" # SimpleCov 233 | end 234 | 235 | it "complains when coverage is bad" do 236 | change_file 'lib/a.rb', "def a", "def b\n1\nend\ndef a" do 237 | result = sh "ruby test/a_test.rb", fail: true 238 | assert_tests_finished_normally(result) 239 | expect(result).to include "4 / 5 LOC (80.0%) covered" # SimpleCov 240 | expect(result).to include "(1 current vs 0 configured)" # SingleCov 241 | end 242 | end 243 | end 244 | 245 | describe "when SimpleCov was defined but did not start" do 246 | around { |t| change_file("test/a_test.rb", default_setup, "#{default_setup}\nrequire 'simplecov'\n", &t) } 247 | 248 | it "falls back to Coverage and complains" do 249 | change_file 'lib/a.rb', "def a", "def b\n1\nend\ndef a" do 250 | result = sh "ruby test/a_test.rb", fail: true 251 | assert_tests_finished_normally(result) 252 | expect(result).to include "(1 current vs 0 configured)" # SingleCov 253 | end 254 | end 255 | end 256 | 257 | describe "branch coverage" do 258 | around { |t| change_file("test/a_test.rb", "root: root", "root: root, branches: true", &t) } 259 | 260 | it_does_not_complain_when_everything_is_covered 261 | 262 | describe "with branches" do 263 | around { |t| change_file("lib/a.rb", "1", "2.times { |i| rand if i == 0 }", &t) } 264 | 265 | it_does_not_complain_when_everything_is_covered 266 | 267 | it "complains when branch coverage is missing" do 268 | change_file("lib/a.rb", "i == 0", "i != i") do 269 | result = sh "ruby test/a_test.rb", fail: true 270 | expect(result).to include ".lib/a.rb new uncovered lines introduced (1 current vs 0 configured)" 271 | expect(result).to include "lib/a.rb:4:19-23" 272 | end 273 | end 274 | 275 | it "complains sorted when line and branch coverage are bad" do 276 | change_file 'lib/a.rb', "def a", "def b\n1\nend\ndef a" do 277 | change_file("lib/a.rb", "i == 0", "i != i") do 278 | result = sh "ruby test/a_test.rb 2>&1", fail: true 279 | expect(result).to include "lib/a.rb new uncovered lines introduced (2 current vs 0 configured)" 280 | expect(result).to include "lib/a.rb:4\nlib/a.rb:7:19-23" 281 | end 282 | end 283 | end 284 | 285 | it "does not complain about branch being missing when line is not covered" do 286 | change_file("lib/a.rb", "end", "end\ndef b\n2.times { |i| rand if i == 0 }\nend\n") do 287 | result = sh "ruby test/a_test.rb", fail: true 288 | expect(result).to include ".lib/a.rb new uncovered lines introduced (1 current vs 0 configured)" 289 | expect(result).to include "lib/a.rb:7" 290 | end 291 | end 292 | 293 | it "does not duplicate coverage" do 294 | change_file("lib/a.rb", "i == 0", "i == 0 if i if 0 if false") do 295 | result = sh "ruby test/a_test.rb", fail: true 296 | expect(result).to include ".lib/a.rb new uncovered lines introduced (3 current vs 0 configured)" 297 | expect(result).to include "lib/a.rb:4:19-23\nlib/a.rb:4:19-33 # else\nlib/a.rb:4:19-38 # else" 298 | end 299 | end 300 | 301 | it "complains about missing else coverage" do 302 | change_file("lib/a.rb", "2.times", "1.times") do 303 | result = sh "ruby test/a_test.rb", fail: true 304 | expect(result).to include ".lib/a.rb new uncovered lines introduced (1 current vs 0 configured)" 305 | expect(result).to include "lib/a.rb:4:19-33 # else" 306 | end 307 | end 308 | 309 | it "ignores 0 coverage from duplicate ensure branches" do 310 | change_file("lib/a.rb", "i == 0", "begin; i == 0; ensure; i == 0 if i == 0;end") do 311 | result = sh "ruby test/a_test.rb" 312 | assert_tests_finished_normally(result) 313 | expect(result).to_not include "uncovered" 314 | end 315 | end 316 | end 317 | end 318 | 319 | describe "generate_report" do 320 | around do |t| 321 | replace = "#{default_setup}\nSingleCov.coverage_report = 'coverage/.resultset.json'" 322 | change_file("test/a_test.rb", default_setup, replace, &t) 323 | end 324 | after { FileUtils.rm_rf("coverage") } 325 | 326 | it "generates when requested" do 327 | sh "ruby test/a_test.rb" 328 | result = JSON.parse(File.read("coverage/.resultset.json")) 329 | expect(result["Minitest"]["coverage"]).to eq( 330 | "#{Bundler.root}/specs/fixtures/minitest/lib/a.rb" => { "branches" => {}, "lines" => [nil, 1, 1, 1, nil, nil] } 331 | ) 332 | end 333 | 334 | it "can force line coverage" do 335 | change_file("test/a_test.rb", default_setup, "#{default_setup}\nSingleCov.coverage_report_lines = true") do 336 | sh "ruby test/a_test.rb" 337 | end 338 | result = JSON.parse(File.read("coverage/.resultset.json")) 339 | coverage = [nil, 1, 1, 1, nil, nil] 340 | expect(result["Minitest"]["coverage"]).to eq( 341 | "#{Bundler.root}/specs/fixtures/minitest/lib/a.rb" => coverage 342 | ) 343 | end 344 | 345 | it "does mot fail if file exists" do 346 | FileUtils.mkdir_p "coverage" 347 | File.write("coverage/.resultset.json", "NOT-JSON") 348 | sh "ruby test/a_test.rb" 349 | JSON.parse(File.read("coverage/.resultset.json")) # was updated 350 | end 351 | end 352 | end 353 | 354 | describe "rspec" do 355 | around { |test| Dir.chdir("specs/fixtures/rspec", &test) } 356 | 357 | it "does not complain when everything is covered" do 358 | result = sh "bundle exec rspec spec/a_spec.rb" 359 | assert_specs_finished_normally(result, 3) 360 | expect(result).to_not include "uncovered" 361 | end 362 | 363 | it "does not complain in forks when disabled" do 364 | change_file( 365 | "spec/a_spec.rb", 366 | %(it "does a" do), %{it "does a" do\nfork { SingleCov.remove_instance_variable(:@pid); SingleCov.disable }\n} 367 | ) do 368 | result = sh "bundle exec rspec spec/a_spec.rb" 369 | expect(result).to_not include "uncovered" 370 | assert_specs_finished_normally(result, 3) 371 | end 372 | end 373 | 374 | it "does not complain in forks by default" do 375 | change_file("spec/a_spec.rb", %(it "does a" do), %(it "does a" do\nfork { 11 }\n)) do 376 | result = sh "bundle exec rspec spec/a_spec.rb" 377 | assert_specs_finished_normally(result, 3) 378 | expect(result).to_not include "uncovered" 379 | end 380 | end 381 | 382 | describe "when something is uncovered" do 383 | around { |t| change_file("spec/a_spec.rb", "A.new.a", "1", &t) } 384 | 385 | it "complains when something is uncovered" do 386 | result = sh "bundle exec rspec spec/a_spec.rb", fail: true 387 | assert_specs_finished_normally(result, 3) 388 | expect(result).to include "uncovered" 389 | end 390 | 391 | it "does not complains when running a subset of tests by line" do 392 | result = sh "bundle exec rspec spec/a_spec.rb:15" 393 | assert_specs_finished_normally(result, 1) 394 | expect(result).to_not include "uncovered" 395 | end 396 | 397 | it "does not complains when running a subset of tests sub-line" do 398 | result = sh "bundle exec rspec spec/a_spec.rb[1:1]" 399 | assert_specs_finished_normally(result, 1) 400 | expect(result).to_not include "uncovered" 401 | end 402 | 403 | it "does not complains when running a subset of tests by name" do 404 | result = sh "bundle exec rspec spec/a_spec.rb -e 'does a'" 405 | assert_specs_finished_normally(result, 1) 406 | expect(result).to_not include "uncovered" 407 | end 408 | 409 | it "does not complain when tests failed" do 410 | change_file("spec/a_spec.rb", "eq 1", "eq 2") do 411 | result = sh "bundle exec rspec spec/a_spec.rb", fail: true 412 | expect(result).to include "3 examples, 1 failure" 413 | expect(result).to_not include "uncovered" 414 | end 415 | end 416 | end 417 | end 418 | 419 | describe ".assert_used" do 420 | around { |test| Dir.chdir("specs/fixtures/minitest", &test) } 421 | 422 | it "work when all tests have SingleCov" do 423 | SingleCov.assert_used 424 | end 425 | 426 | it "works when using .not_covered!" do 427 | change_file "test/a_test.rb", "SingleCov.covered!", 'SingleCov.not_covered!' do 428 | SingleCov.assert_used 429 | end 430 | end 431 | 432 | describe "when a test does not have SingleCov" do 433 | around { |t| change_file("test/a_test.rb", "SingleCov.covered", 'Nope', &t) } 434 | 435 | it "raises" do 436 | message = "test/a_test.rb: needs to use SingleCov.covered!" 437 | expect { SingleCov.assert_used }.to raise_error(RuntimeError, message) 438 | end 439 | 440 | it "works with custom files" do 441 | SingleCov.assert_used tests: [] 442 | end 443 | end 444 | end 445 | 446 | describe ".assert_tested" do 447 | around { |test| Dir.chdir("specs/fixtures/minitest", &test) } 448 | 449 | it "work when all files have a test" do 450 | SingleCov.assert_tested 451 | end 452 | 453 | it "complains when untested are now tested" do 454 | message = "Remove [\"lib/b.rb\"] from untested!" 455 | expect { SingleCov.assert_tested untested: ['lib/b.rb'] }.to raise_error(RuntimeError, message) 456 | end 457 | 458 | describe "when a file is missing a test" do 459 | around { |t| move_file('lib/a.rb', 'lib/b.rb', &t) } 460 | 461 | it "complains " do 462 | message = "missing test for lib/b.rb" 463 | expect { SingleCov.assert_tested }.to raise_error(RuntimeError, message) 464 | end 465 | 466 | it "does not complain when it is marked as untested" do 467 | SingleCov.assert_tested untested: ['lib/b.rb'] 468 | end 469 | end 470 | end 471 | 472 | describe ".assert_full_coverage" do 473 | def call 474 | SingleCov.assert_full_coverage currently_complete: complete 475 | end 476 | 477 | let(:complete) { ["test/a_test.rb"] } 478 | 479 | around { |test| Dir.chdir("specs/fixtures/minitest", &test) } 480 | 481 | it "works when correct files are covered" do 482 | call 483 | end 484 | 485 | it "alerts when files are newly covered" do 486 | expect do 487 | complete.pop 488 | call 489 | end.to raise_error(/single_cov_spec\.rb.*test\/a_test.rb/m) 490 | end 491 | 492 | it "alerts when files lost coverage" do 493 | expect do 494 | change_file('test/a_test.rb', 'SingleCov.covered!', 'SingleCov.covered! uncovered: 12') { call } 495 | end.to raise_error(/test\/a_test.rb/) 496 | end 497 | 498 | it "ignores files not_covered" do 499 | complete.pop 500 | change_file('test/a_test.rb', 'SingleCov.covered!', 'SingleCov.not_covered!') { call } 501 | end 502 | 503 | it "ignores files with uncovered commented out" do 504 | change_file('test/a_test.rb', 'SingleCov.covered!', 'SingleCov.covered! # uncovered: 12') { call } 505 | end 506 | 507 | describe 'when file cannot be found from caller' do 508 | let(:complete) { ["test/b_test.rb"] } 509 | 510 | around { |test| move_file('test/a_test.rb', 'test/b_test.rb', &test) } 511 | 512 | it "works when files covered and configured" do 513 | change_file('test/b_test.rb', 'SingleCov.covered!', 'SingleCov.covered! file: lib/a.rb') { call } 514 | end 515 | 516 | it "alerts when files lost coverage and are configured" do 517 | expect do 518 | change_file('test/b_test.rb', 'SingleCov.covered!', 'SingleCov.covered!(uncovered: 12, file: lib/a.rb)') { call } 519 | end.to raise_error(/test\/b_test.rb/) 520 | end 521 | end 522 | end 523 | 524 | describe ".file_under_test" do 525 | def file_under_test(test) 526 | SingleCov.send(:guess_covered_file, "#{SingleCov.send(:root)}/#{test}:34:in `foobar'") 527 | end 528 | 529 | def self.it_maps_path(test, file, ignore) 530 | it "maps #{test} to #{file}#{" when ignoring prefixes" if ignore}" do 531 | stub_const('SingleCov::PREFIXES_TO_IGNORE', ['public']) if ignore 532 | expect(file_under_test(test)).to eq file 533 | end 534 | end 535 | 536 | [false, true].each do |ignore| 537 | { 538 | "test/models/xyz_test.rb" => "app/models/xyz.rb", 539 | "test/lib/xyz_test.rb" => "lib/xyz.rb", 540 | "spec/lib/xyz_spec.rb" => "lib/xyz.rb", 541 | "test/xyz_test.rb" => "lib/xyz.rb", 542 | "test/test_xyz.rb" => "lib/xyz.rb", 543 | "plugins/foo/test/lib/xyz_test.rb" => "plugins/foo/lib/xyz.rb", 544 | "plugins/foo/test/models/xyz_test.rb" => "plugins/foo/app/models/xyz.rb" 545 | }.each { |test, file| it_maps_path test, file, ignore } 546 | end 547 | 548 | it_maps_path "component/foo/test/public/models/xyz_test.rb", "component/foo/lib/public/models/xyz.rb", false 549 | it_maps_path "component/foo/test/public/models/xyz_test.rb", "component/foo/public/app/models/xyz.rb", true 550 | 551 | it "complains about files without test folder" do 552 | message = "oops_test.rb includes neither 'test' nor 'spec' folder ... unable to resolve" 553 | expect { file_under_test("oops_test.rb") }.to raise_error(RuntimeError, message) 554 | end 555 | 556 | it "complains about files without test extension" do 557 | message = "Unable to remove test extension from test/oops.rb ... /test_, _test.rb and _spec.rb are supported" 558 | expect { file_under_test("test/oops.rb") }.to raise_error(RuntimeError, message) 559 | end 560 | end 561 | 562 | # covering a weird edge case where the test folder is not part of the root directory because 563 | # a nested gemfile was used which changed Bundler.root 564 | describe ".guess_and_check_covered_file" do 565 | it "complains nicely when calling file is outside of root" do 566 | expect(SingleCov).to receive(:guess_covered_file).and_return('/oops/foo.rb') 567 | expect do 568 | SingleCov.send(:ensure_covered_file, nil) 569 | end.to raise_error(RuntimeError, /Found file \/oops\/foo.rb which is not relative to the root/) 570 | end 571 | end 572 | 573 | describe ".root" do 574 | it "ignores when bundler root is in a gemfiles folder" do 575 | old = SingleCov.send(:root) 576 | SingleCov.instance_variable_set(:@root, nil) 577 | expect(Bundler).to receive(:root).and_return(Pathname.new("#{old}/gemfiles")) 578 | expect(SingleCov.send(:root)).to eq old 579 | ensure 580 | SingleCov.instance_variable_set(:@root, old) 581 | end 582 | end 583 | 584 | def sh(command, options = {}) 585 | m = (Bundler.respond_to?(:with_unbundled_env) ? :with_unbundled_env : :with_clean_env) 586 | result = Bundler.send(m) { `#{command} #{"2>&1" unless options[:keep_output]}` } 587 | raise "#{options[:fail] ? "SUCCESS" : "FAIL"} #{command}\n#{result}" if $?.success? == !!options[:fail] 588 | result 589 | end 590 | 591 | def change_file(file, find, replace) 592 | old = File.read(file) 593 | raise "Did not find #{find} in:\n#{old}" unless new = old.dup.sub!(find, replace) 594 | File.write(file, new) 595 | yield 596 | ensure 597 | File.write(file, old) 598 | end 599 | 600 | def create_file(file, content) 601 | File.write(file, content) 602 | yield 603 | ensure 604 | File.unlink(file) 605 | end 606 | 607 | def move_file(a, b) 608 | FileUtils.mv(a, b) 609 | yield 610 | ensure 611 | FileUtils.mv(b, a) 612 | end 613 | 614 | def assert_tests_finished_normally(result) 615 | expect(result).to include "1 runs, 1 assertions, 0 failures" 616 | end 617 | 618 | def assert_specs_finished_normally(result, examples) 619 | expect(result).to include "#{examples} example#{'s' if examples != 1}, 0 failures" 620 | end 621 | end 622 | -------------------------------------------------------------------------------- /specs/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "bundler/setup" 3 | require "single_cov/version" 4 | require "single_cov" 5 | require "json" 6 | require "fileutils" 7 | --------------------------------------------------------------------------------