├── .gitignore ├── .ruby-version ├── Gemfile ├── lib ├── single_cov │ └── version.rb └── single_cov.rb ├── specs ├── fixtures │ ├── minitest │ │ ├── lib │ │ │ └── a.rb │ │ ├── test │ │ │ └── a_test.rb │ │ └── bin │ │ │ └── rails │ └── rspec │ │ ├── lib │ │ └── a.rb │ │ └── spec │ │ └── a_spec.rb ├── spec_helper.rb └── single_cov_spec.rb ├── gemfiles ├── minitest5.gemfile └── minitest5.gemfile.lock ├── Rakefile ├── single_cov.gemspec ├── .github └── workflows │ └── actions.yml ├── MIT-LICENSE ├── .rubocop.yml ├── Gemfile.lock └── Readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.7 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source "https://rubygems.org" 3 | gemspec 4 | -------------------------------------------------------------------------------- /lib/single_cov/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SingleCov 3 | VERSION = "1.11.0" 4 | end 5 | -------------------------------------------------------------------------------- /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/rspec/lib/a.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class A 3 | def a 4 | 1 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /gemfiles/minitest5.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source "https://rubygems.org" 3 | gemspec path: ".." 4 | gem "minitest", "~>5.0" 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | sh "bundle exec rspec specs/single_cov_spec.rb --warnings" 10 | end 11 | 12 | task :rubocop do 13 | sh "bundle exec rubocop" 14 | end 15 | 16 | desc "bundle all gemfiles CMD=install" 17 | task :bundle do 18 | extra = ENV["CMD"] || "install" 19 | Bundler.with_original_env do 20 | Dir["{Gemfile,gemfiles/*.gemfile}"].reverse.each do |gemfile| 21 | sh "BUNDLE_GEMFILE=#{gemfile} bundle #{extra}" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /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 = '>= 3.2.0' # keep in sync with .rubocop.yml, .github/workflows/actions.yml 13 | 14 | s.add_development_dependency "bump" 15 | s.add_development_dependency "minitest" 16 | s.add_development_dependency "rake" 17 | s.add_development_dependency "rspec" 18 | s.add_development_dependency "rubocop" 19 | s.add_development_dependency "simplecov" 20 | end 21 | -------------------------------------------------------------------------------- /.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: [ '3.2', '3.3', '3.4' ] 13 | task: [ 'spec' ] 14 | gemfile: [ 'Gemfile', 'gemfiles/minitest5.gemfile' ] 15 | include: 16 | - ruby: 3.2 # keep in sync with the lowest ruby version, .rubocop.yml, single_cov.gemspec 17 | task: rubocop 18 | gemfile: Gemfile 19 | name: ${{ matrix.ruby }}+${{ matrix.gemfile }} rake ${{ matrix.task }} 20 | env: 21 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 22 | steps: 23 | - uses: actions/checkout@v6 24 | - uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 28 | - run: bundle exec rake ${{ matrix.task }} 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 3.2 # lowest supported version, keep in sync with .github/workflows/actions.yml, single_cov.gemspec 3 | NewCops: enable 4 | SuggestExtensions: false 5 | Exclude: 6 | - vendor/**/* 7 | - gemfiles/vendor/bundle/**/* 8 | 9 | Style/StringLiterals: 10 | Enabled: false 11 | 12 | Style/StringLiteralsInInterpolation: 13 | Enabled: false 14 | 15 | Lint/AmbiguousRegexpLiteral: 16 | Enabled: false 17 | 18 | Bundler/OrderedGems: 19 | Enabled: false 20 | 21 | Metrics: 22 | Enabled: false 23 | 24 | Style/Documentation: 25 | Enabled: false 26 | 27 | Layout/EmptyLineAfterMagicComment: 28 | Enabled: false 29 | 30 | Layout/EndAlignment: 31 | EnforcedStyleAlignWith: variable 32 | 33 | Layout/MultilineOperationIndentation: 34 | Enabled: false 35 | 36 | Layout/MultilineMethodCallIndentation: 37 | EnforcedStyle: indented 38 | 39 | Style/NumericPredicate: 40 | EnforcedStyle: comparison 41 | 42 | Layout/EmptyLineAfterGuardClause: 43 | Enabled: false 44 | 45 | # https://github.com/rubocop-hq/rubocop/issues/5891 46 | Style/SpecialGlobalVars: 47 | Enabled: false 48 | 49 | Style/DoubleNegation: 50 | Enabled: false 51 | 52 | Naming/MethodParameterName: 53 | Enabled: false 54 | 55 | # TODO: fix and change forking-test-runner too 56 | Style/MutableConstant: 57 | Enabled: false 58 | 59 | Style/RegexpLiteral: 60 | Enabled: false 61 | 62 | Layout/LineLength: 63 | Enabled: false 64 | 65 | Style/IfUnlessModifier: 66 | Enabled: false 67 | 68 | Style/GuardClause: 69 | Enabled: false 70 | 71 | Style/InverseMethods: 72 | Enabled: false 73 | 74 | Style/WordArray: 75 | EnforcedStyle: brackets 76 | 77 | Style/SymbolArray: 78 | EnforcedStyle: brackets 79 | 80 | # we use the gemspec as a shared gem list 81 | Gemspec/DevelopmentDependencies: 82 | Enabled: false 83 | -------------------------------------------------------------------------------- /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.3) 10 | bump (0.10.0) 11 | diff-lcs (1.6.2) 12 | docile (1.4.1) 13 | json (2.18.0) 14 | language_server-protocol (3.17.0.5) 15 | lint_roller (1.1.0) 16 | minitest (6.0.0) 17 | prism (~> 1.5) 18 | parallel (1.27.0) 19 | parser (3.3.10.0) 20 | ast (~> 2.4.1) 21 | racc 22 | prism (1.7.0) 23 | racc (1.8.1) 24 | rainbow (3.1.1) 25 | rake (13.3.1) 26 | regexp_parser (2.11.3) 27 | rspec (3.13.2) 28 | rspec-core (~> 3.13.0) 29 | rspec-expectations (~> 3.13.0) 30 | rspec-mocks (~> 3.13.0) 31 | rspec-core (3.13.6) 32 | rspec-support (~> 3.13.0) 33 | rspec-expectations (3.13.5) 34 | diff-lcs (>= 1.2.0, < 2.0) 35 | rspec-support (~> 3.13.0) 36 | rspec-mocks (3.13.7) 37 | diff-lcs (>= 1.2.0, < 2.0) 38 | rspec-support (~> 3.13.0) 39 | rspec-support (3.13.6) 40 | rubocop (1.82.0) 41 | json (~> 2.3) 42 | language_server-protocol (~> 3.17.0.2) 43 | lint_roller (~> 1.1.0) 44 | parallel (~> 1.10) 45 | parser (>= 3.3.0.2) 46 | rainbow (>= 2.2.2, < 4.0) 47 | regexp_parser (>= 2.9.3, < 3.0) 48 | rubocop-ast (>= 1.48.0, < 2.0) 49 | ruby-progressbar (~> 1.7) 50 | unicode-display_width (>= 2.4.0, < 4.0) 51 | rubocop-ast (1.48.0) 52 | parser (>= 3.3.7.2) 53 | prism (~> 1.4) 54 | ruby-progressbar (1.13.0) 55 | simplecov (0.22.0) 56 | docile (~> 1.1) 57 | simplecov-html (~> 0.11) 58 | simplecov_json_formatter (~> 0.1) 59 | simplecov-html (0.13.2) 60 | simplecov_json_formatter (0.1.4) 61 | unicode-display_width (3.2.0) 62 | unicode-emoji (~> 4.1) 63 | unicode-emoji (4.2.0) 64 | 65 | PLATFORMS 66 | ruby 67 | 68 | DEPENDENCIES 69 | bump 70 | minitest 71 | rake 72 | rspec 73 | rubocop 74 | simplecov 75 | single_cov! 76 | 77 | BUNDLED WITH 78 | 4.0.2 79 | -------------------------------------------------------------------------------- /gemfiles/minitest5.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.3) 10 | bump (0.10.0) 11 | diff-lcs (1.6.2) 12 | docile (1.4.1) 13 | json (2.18.0) 14 | language_server-protocol (3.17.0.5) 15 | lint_roller (1.1.0) 16 | minitest (5.27.0) 17 | parallel (1.27.0) 18 | parser (3.3.10.0) 19 | ast (~> 2.4.1) 20 | racc 21 | prism (1.7.0) 22 | racc (1.8.1) 23 | rainbow (3.1.1) 24 | rake (13.3.1) 25 | regexp_parser (2.11.3) 26 | rspec (3.13.2) 27 | rspec-core (~> 3.13.0) 28 | rspec-expectations (~> 3.13.0) 29 | rspec-mocks (~> 3.13.0) 30 | rspec-core (3.13.6) 31 | rspec-support (~> 3.13.0) 32 | rspec-expectations (3.13.5) 33 | diff-lcs (>= 1.2.0, < 2.0) 34 | rspec-support (~> 3.13.0) 35 | rspec-mocks (3.13.7) 36 | diff-lcs (>= 1.2.0, < 2.0) 37 | rspec-support (~> 3.13.0) 38 | rspec-support (3.13.6) 39 | rubocop (1.82.0) 40 | json (~> 2.3) 41 | language_server-protocol (~> 3.17.0.2) 42 | lint_roller (~> 1.1.0) 43 | parallel (~> 1.10) 44 | parser (>= 3.3.0.2) 45 | rainbow (>= 2.2.2, < 4.0) 46 | regexp_parser (>= 2.9.3, < 3.0) 47 | rubocop-ast (>= 1.48.0, < 2.0) 48 | ruby-progressbar (~> 1.7) 49 | unicode-display_width (>= 2.4.0, < 4.0) 50 | rubocop-ast (1.48.0) 51 | parser (>= 3.3.7.2) 52 | prism (~> 1.4) 53 | ruby-progressbar (1.13.0) 54 | simplecov (0.22.0) 55 | docile (~> 1.1) 56 | simplecov-html (~> 0.11) 57 | simplecov_json_formatter (~> 0.1) 58 | simplecov-html (0.13.2) 59 | simplecov_json_formatter (0.1.4) 60 | unicode-display_width (3.2.0) 61 | unicode-emoji (~> 4.1) 62 | unicode-emoji (4.2.0) 63 | 64 | PLATFORMS 65 | arm64-darwin-24 66 | ruby 67 | 68 | DEPENDENCIES 69 | bump 70 | minitest (~> 5.0, >= 0) 71 | rake 72 | rspec 73 | rubocop 74 | simplecov 75 | single_cov! 76 | 77 | CHECKSUMS 78 | ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 79 | bump (0.10.0) sha256=07929cf79031d2863f55e7b111e164566be10c43f8c42d67db7cb3b5745f0975 80 | diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 81 | docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e 82 | json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505 83 | language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc 84 | lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 85 | minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5 86 | parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 87 | parser (3.3.10.0) sha256=ce3587fa5cc55a88c4ba5b2b37621b3329aadf5728f9eafa36bbd121462aabd6 88 | prism (1.7.0) sha256=10062f734bf7985c8424c44fac382ac04a58124ea3d220ec3ba9fe4f2da65103 89 | racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f 90 | rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a 91 | rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c 92 | regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 93 | rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 94 | rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d 95 | rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 96 | rspec-mocks (3.13.7) sha256=0979034e64b1d7a838aaaddf12bf065ea4dc40ef3d4c39f01f93ae2c66c62b1c 97 | rspec-support (3.13.6) sha256=2e8de3702427eab064c9352fe74488cc12a1bfae887ad8b91cba480ec9f8afb2 98 | rubocop (1.82.0) sha256=237b7dc24952d7ec469a9593c7a5283315515e2e7dc24ac91532819c254fc4ec 99 | rubocop-ast (1.48.0) sha256=22df9bbf3f7a6eccde0fad54e68547ae1e2a704bf8719e7c83813a99c05d2e76 100 | ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 101 | simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 102 | simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 103 | simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 104 | single_cov (1.11.0) 105 | unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 106 | unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f 107 | 108 | BUNDLED WITH 109 | 4.0.2 110 | -------------------------------------------------------------------------------- /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 SimpleCov: 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 | return if minitest_running_subset_of_tests? 123 | when :rspec 124 | return if rspec_running_subset_of_tests? 125 | else 126 | raise "Unsupported framework #{framework.inspect}" 127 | end 128 | 129 | start_coverage_recording 130 | 131 | # minitest overrides at_exit, so we need to hack into it to get after it 132 | # if it is not already loaded we could get in front of it, but when using `minitest` executable that is 133 | # not possible 134 | if defined?(Minitest) 135 | (class << Minitest; self; end).prepend(Module.new do 136 | def run(*) 137 | result = super 138 | SingleCov.report_at_exit 139 | result 140 | end 141 | end) 142 | else 143 | override_at_exit do |status, _exception| 144 | report_at_exit if main_process? && status == 0 145 | end 146 | end 147 | end 148 | 149 | def report_at_exit 150 | return unless enabled? 151 | results = coverage_results 152 | generate_report results 153 | exit 1 unless SingleCov.all_covered?(results) 154 | end 155 | 156 | # use this in forks when using rspec to silence duplicated output 157 | def disable 158 | @disabled = true 159 | end 160 | 161 | private 162 | 163 | def uncovered(coverage) 164 | return coverage unless coverage.is_a?(Hash) # just lines 165 | 166 | uncovered_lines = indexes(coverage.fetch(:lines), 0).map! { |i| i + 1 } 167 | uncovered_branches = uncovered_branches(coverage[:branches] || {}) 168 | uncovered_branches.reject! { |br| uncovered_lines.include?(br[0]) } # ignore branch when whole line is uncovered 169 | 170 | # combine lines and branches while keeping them sorted 171 | all = uncovered_lines.concat uncovered_branches 172 | all.sort_by! { |line_start, char_start, _, _, _| [line_start, char_start || 0] } # branches are unsorted 173 | all 174 | end 175 | 176 | def enabled? 177 | !defined?(@disabled) || !@disabled 178 | end 179 | 180 | # assuming that the main process will load all the files, we store it's pid 181 | def main_process! 182 | @main_process_pid = Process.pid 183 | end 184 | 185 | def main_process? 186 | !defined?(@main_process_pid) || @main_process_pid == Process.pid 187 | end 188 | 189 | # {[branch_id] => {[branch_part] => coverage}} --> uncovered location 190 | def uncovered_branches(coverage) 191 | sum = {} 192 | coverage.each_value do |branch| 193 | branch.filter_map do |part, c| 194 | location = [part[2], part[3] + 1, part[4], part[5] + 1] # locations can be duplicated 195 | type = part[0] 196 | info = (sum[location] ||= [0, nil]) 197 | info[0] += c 198 | info[1] = type if type == :else # only else is important to track since it often is not in the code 199 | end 200 | end 201 | 202 | # keep location and type of missing coverage 203 | sum.filter_map { |k, v| k + [v[1]] if v[0] == 0 } 204 | end 205 | 206 | def default_tests 207 | glob("{test,spec}/**/*_{test,spec}.rb") 208 | end 209 | 210 | def glob(pattern) 211 | Dir["#{root}/#{pattern}"].map! { |f| f.sub("#{root}/", '') } 212 | end 213 | 214 | def indexes(list, find) 215 | list.each_with_index.filter_map { |v, i| i if v == find } 216 | end 217 | 218 | # do not ask for coverage when SimpleCov already does or it conflicts 219 | def coverage_results 220 | if defined?(SimpleCov) && (result = SimpleCov.instance_variable_get(:@result)) 221 | result = result.original_result 222 | # singlecov 1.18+ puts string "lines" into the result that we cannot read 223 | if result.each_value.first.is_a?(Hash) 224 | result = result.transform_values { |v| v.transform_keys(&:to_sym) } 225 | end 226 | result 227 | else 228 | Coverage.result 229 | end 230 | end 231 | 232 | # start recording before classes are loaded or nothing can be recorded 233 | # SimpleCov might start coverage again, but that does not hurt ... 234 | def start_coverage_recording 235 | require 'coverage' 236 | if @branches 237 | Coverage.start(lines: true, branches: true) 238 | else 239 | Coverage.start(lines: true) 240 | end 241 | end 242 | 243 | # not running rake or a whole folder 244 | def running_single_file? 245 | COVERAGES.size == 1 246 | end 247 | 248 | # do not record or verify when only running selected tests since it would be missing data 249 | def minitest_running_subset_of_tests? 250 | # via direct option (ruby test.rb -n /foo/) 251 | ARGV.map { |a| a.split('=', 2).first }.intersect?(['-n', '--name', '-l', '--line']) || 252 | 253 | # via testrbl, mtest, rails, or minitest with direct line number (mtest test.rb:123) 254 | (ARGV.first =~ /:\d+\Z/) || 255 | 256 | # via rails test which preloads mintest, removes ARGV and fills options 257 | ( 258 | defined?(Minitest.reporter) && 259 | Minitest.reporter && 260 | (reporter = Minitest.reporter.reporters.first) && 261 | (reporter.options[:filter] || reporter.options[:include]) # MT 5 vs MT 6 262 | ) 263 | end 264 | 265 | def rspec_running_subset_of_tests? 266 | ARGV.intersect?(['-t', '--tag', '-e', '--example']) || ARGV.any? { |a| a =~ /:\d+$|\[[\d:]+\]$/ } 267 | end 268 | 269 | # code stolen from SimpleCov 270 | def override_at_exit 271 | at_exit do 272 | exit_status = if $! # was an exception thrown? 273 | # if it was a SystemExit, use the accompanying status 274 | # otherwise set a non-zero status representing termination by 275 | # some other exception (see github issue 41) 276 | $!.is_a?(SystemExit) ? $!.status : 1 277 | else 278 | # Store the exit status of the test run since it goes away 279 | # after calling the at_exit proc... 280 | 0 281 | end 282 | 283 | yield exit_status, $! 284 | 285 | # Force exit with stored status (see github issue #5) 286 | # unless it's nil or 0 (see github issue #281) 287 | Kernel.exit exit_status if exit_status && exit_status > 0 288 | end 289 | end 290 | 291 | def ensure_covered_file(file) 292 | if file 293 | raise "Use paths relative to project root." if file.start_with?("/") 294 | raise "#{file} does not exist, use paths relative to project root." unless File.exist?("#{root}/#{file}") 295 | else 296 | file = guess_covered_file(caller[1]) 297 | if file.start_with?("/") 298 | raise "Found file #{file} which is not relative to the root #{root}.\nUse `SingleCov.covered! file: 'target_file.rb'` to set covered file location." 299 | elsif !File.exist?("#{root}/#{file}") 300 | 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." 301 | end 302 | end 303 | 304 | file 305 | end 306 | 307 | def bad_coverage_error(file, expected_uncovered, uncovered) 308 | details = "(#{uncovered.size} current vs #{expected_uncovered} configured)" 309 | if expected_uncovered > uncovered.size 310 | if running_single_file? 311 | warning "#{file} has less uncovered lines #{details}, decrement configured uncovered" 312 | end 313 | else 314 | [ 315 | "#{file} new uncovered lines introduced #{details}", 316 | red("Lines missing coverage:"), 317 | *uncovered.map do |line_start, char_start, line_end, char_end, type| 318 | if char_start # branch coverage 319 | if line_start == line_end 320 | "#{file}:#{line_start}:#{char_start}-#{char_end}" 321 | else # possibly unreachable since branches always seem to be on the same line 322 | "#{file}:#{line_start}:#{char_start}-#{line_end}:#{char_end}" 323 | end + (type ? " # #{type}" : "") 324 | else 325 | "#{file}:#{line_start}" 326 | end 327 | end 328 | ] 329 | end 330 | end 331 | 332 | def warning(msg) 333 | "#{msg}?" 334 | end 335 | 336 | def warning?(msg) 337 | msg.end_with?("?") 338 | end 339 | 340 | def red(text) 341 | if $stdin.tty? 342 | "\e[31m#{text}\e[0m" 343 | else 344 | text 345 | end 346 | end 347 | 348 | def no_coverage_error(file) 349 | if $LOADED_FEATURES.include?("#{root}/#{file}") 350 | # we cannot enforce $LOADED_FEATURES during covered! since it would fail when multiple files are loaded 351 | "#{file} was expected to be covered, but was already loaded before coverage started, which makes it uncoverable." 352 | else 353 | "#{file} was expected to be covered, but was never loaded." 354 | end 355 | end 356 | 357 | def guess_covered_file(test) 358 | file = test.dup 359 | 360 | # remove caller junk to get nice error messages when something fails 361 | file.sub!(/\.rb\b.*/, '.rb') 362 | 363 | # resolve all kinds of relativity 364 | file = File.expand_path(file) 365 | 366 | # remove project root 367 | file.sub!("#{root}/", '') 368 | 369 | # preserve subfolders like foobar/test/xxx_test.rb -> foobar/lib/xxx_test.rb 370 | subfolder, file_part = file.split(%r{(?:^|/)(?:test|spec)/}, 2) 371 | unless file_part 372 | raise "#{file} includes neither 'test' nor 'spec' folder ... unable to resolve" 373 | end 374 | 375 | without_ignored_prefixes file_part do 376 | # rails things live in app 377 | file_part[0...0] = 378 | if file_part =~ /^(?:#{RAILS_APP_FOLDERS.map { |f| Regexp.escape(f) }.join('|')})\// 379 | "app/" 380 | elsif file_part.start_with?("lib/") # don't add lib twice 381 | "" 382 | else # everything else lives in lib 383 | "lib/" 384 | end 385 | 386 | # remove test extension 387 | if !file_part.sub!(/_(?:test|spec)\.rb\b.*/, '.rb') && !file_part.sub!('/test_', "/") 388 | raise "Unable to remove test extension from #{file} ... /test_, _test.rb and _spec.rb are supported" 389 | end 390 | end 391 | 392 | # put back the subfolder 393 | file_part[0...0] = "#{subfolder}/" unless subfolder.empty? 394 | 395 | file_part = @rewrite.call(file_part) if defined?(@rewrite) && @rewrite 396 | 397 | file_part 398 | end 399 | 400 | def root 401 | @root ||= (defined?(Bundler) && Bundler.root.to_s.sub(/\/gemfiles$/, '')) || Dir.pwd 402 | end 403 | 404 | def generate_report(results) 405 | return unless (report = coverage_report) 406 | 407 | # not a hard dependency for the whole library 408 | require "json" 409 | require "fileutils" 410 | 411 | used = COVERAGES.map { |f, _| "#{root}/#{f}" } 412 | covered = results.slice(*used) 413 | 414 | if coverage_report_lines 415 | covered = covered.transform_values { |v| v.is_a?(Hash) ? v.fetch(:lines) : v } 416 | end 417 | 418 | # chose "Minitest" because it is what simplecov uses for reports and "Unit Tests" makes sonarqube break 419 | data = JSON.pretty_generate( 420 | "Minitest" => { "coverage" => covered, "timestamp" => Time.now.to_i } 421 | ) 422 | FileUtils.mkdir_p(File.dirname(report)) 423 | File.write report, data 424 | end 425 | 426 | # file_part is modified during yield so we have to make sure to also modify in place 427 | def without_ignored_prefixes(file_part) 428 | folders = file_part.split('/') 429 | return yield unless PREFIXES_TO_IGNORE.include?(folders.first) 430 | 431 | prefix = folders.shift 432 | file_part.replace folders.join('/') 433 | 434 | yield 435 | 436 | file_part[0...0] = "#{prefix}/" 437 | end 438 | end 439 | end 440 | -------------------------------------------------------------------------------- /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 | let(:project_root) do 20 | root = Bundler.root.to_s 21 | root.sub!("/gemfiles", "") || raise if ENV["BUNDLE_GEMFILE"]&.include?("gemfiles/") 22 | root 23 | end 24 | 25 | it "has a VERSION" do 26 | expect(SingleCov::VERSION).to match(/^[.\da-z]+$/) 27 | end 28 | 29 | describe "minitest" do 30 | let(:default_setup) { "SingleCov.setup :minitest, root: root" } 31 | 32 | around { |test| Dir.chdir("specs/fixtures/minitest", &test) } 33 | 34 | it_does_not_complain_when_everything_is_covered 35 | 36 | it "is silent" do 37 | result = sh "ruby test/a_test.rb" 38 | assert_tests_finished_normally(result) 39 | expect(result).to_not include "warning" 40 | end 41 | 42 | it "can redirect output" do 43 | create_file("err", "1") do 44 | result = change_file "test/a_test.rb", ":minitest, ", ":minitest, err: File.open('err', 'w'), " do 45 | change_file "test/a_test.rb", ".covered!", ".covered! uncovered: 3" do 46 | sh "ruby test/a_test.rb" 47 | end 48 | end 49 | assert_tests_finished_normally(result) 50 | expect(result).to_not include "lib/a.rb has less uncovered lines" 51 | expect(File.read("err")).to include "lib/a.rb has less uncovered lines" 52 | end 53 | end 54 | 55 | it "complains about missing implicit else for if" do 56 | change_file("lib/a.rb", "1", "1 if 1.to_s == '1'") do # does not work with `if true` since ruby inlines it 57 | result = sh "ruby test/a_test.rb", fail: true 58 | assert_tests_finished_normally(result) 59 | expect(result).to include "1 current" 60 | expect(result).to include "lib/a.rb:4:5-23" 61 | end 62 | end 63 | 64 | it "complains about missing implicit else for case" do 65 | change_file("lib/a.rb", "1", "case 1\nwhen 1 then 1\nend") do 66 | result = sh "ruby test/a_test.rb", fail: true 67 | assert_tests_finished_normally(result) 68 | expect(result).to include "1 current" 69 | expect(result).to include "lib/a.rb:4:5-6:4" 70 | end 71 | end 72 | 73 | describe "with many uncovered" do 74 | around { |test| change_file("lib/a.rb", "1", "return 1 if 1 == 1\n#{100.times.map { "puts 1" }.join("\n")}", &test) } 75 | 76 | it "truncates when too many lines are uncovered" do 77 | result = sh "ruby test/a_test.rb", fail: true 78 | assert_tests_finished_normally(result) 79 | expect(result).to include "1 current" 80 | expect(result.count("\n")).to equal 50 81 | end 82 | 83 | it "can truncate to custom length for in-depth debugging" do 84 | result = sh "SINGLE_COV_MAX_OUTPUT=60 ruby test/a_test.rb", fail: true 85 | assert_tests_finished_normally(result) 86 | expect(result).to include "1 current" 87 | expect(result.count("\n")).to equal 70 88 | end 89 | end 90 | 91 | describe "running in non-root" do 92 | it_does_not_complain_when_everything_is_covered in_test: true 93 | 94 | it "can report failure" do 95 | add_missing_coverage do 96 | result = sh "cd test && ruby a_test.rb", fail: true 97 | expect(result).to include "uncovered" 98 | end 99 | end 100 | end 101 | 102 | describe "fork" do 103 | it "does not complain in forks" do 104 | change_file("test/a_test.rb", %(it "does a" do), %(it "does a" do\nfork { }\n)) do 105 | result = sh "ruby test/a_test.rb" 106 | assert_tests_finished_normally(result) 107 | expect(result).to_not include("cover") 108 | end 109 | end 110 | 111 | # fork exists with 1 ... so our override ignores it ... 112 | it "does not complain when forking" do 113 | change_file("test/a_test.rb", "assert A.new.a", "assert fork { 1 }\nsleep 0.1\n") do 114 | result = sh "ruby test/a_test.rb", fail: true 115 | assert_tests_finished_normally(result) 116 | expect(result.scan('missing coverage').size).to eq 1 117 | end 118 | end 119 | end 120 | 121 | describe "when coverage has increased" do 122 | around { |t| change_file("test/a_test.rb", "SingleCov.covered!", "SingleCov.covered! uncovered: 1", &t) } 123 | 124 | # we might be running multiple files or have some special argument ... don't blow up 125 | it "warns" do 126 | result = sh "ruby test/a_test.rb" 127 | assert_tests_finished_normally(result) 128 | message = "lib/a.rb has less uncovered lines (0 current vs 1 configured), decrement configured uncovered?" 129 | expect(result).to include message 130 | end 131 | 132 | it "does not warn when running multiple files" do 133 | create_file 'test/b_test.rb', 'SingleCov.covered! file: "lib/a.rb"' do 134 | result = sh "ruby -r bundler/setup -r ./test/a_test.rb -r ./test/b_test.rb -e 1" 135 | assert_tests_finished_normally(result) 136 | expect(result).to_not include "has less uncovered lines" 137 | end 138 | end 139 | end 140 | 141 | describe "when single item is uncovered" do 142 | around { |block| add_missing_coverage(&block) } 143 | 144 | it "complains" do 145 | result = sh "ruby test/a_test.rb", fail: true 146 | assert_tests_finished_normally(result) 147 | expect(result).to 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 = option" do 157 | result = sh "ruby test/a_test.rb -n=/a/" 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 options and rails" do 163 | result = sh "bin/rails test test/a_test.rb -n '/foo/'" 164 | assert_tests_finished_normally(result) 165 | expect(result).to_not include "uncovered" 166 | end 167 | 168 | it "does not complain when only running selected tests via line number" do 169 | # somehow does not work with BUNDLE_GEMFILE set, but works with minitest 5 and 6 when tested manually 170 | next if ENV["BUNDLE_GEMFILE"]&.include?("/gemfiles") 171 | 172 | result = sh "bin/rails test test/a_test.rb:12" 173 | assert_tests_finished_normally(result) 174 | expect(result).to_not include "uncovered" 175 | end 176 | 177 | it "does not complain when tests failed" do 178 | change_file("test/a_test.rb", "assert", "refute") do 179 | result = sh "ruby test/a_test.rb", fail: true 180 | expect(result).to include "1 runs, 1 assertions, 1 failures" 181 | expect(result).to_not include "uncovered" 182 | end 183 | end 184 | 185 | it "does not complain when individually disabled" do 186 | change_file("lib/a.rb", "1", "1 # uncovered") do 187 | sh "ruby test/a_test.rb" 188 | end 189 | end 190 | 191 | it "complains with minitest executable" do 192 | next unless Gem::Specification.find_all_by_name("minitest").any? { |s| s.version >= "6.0.0" } 193 | result = sh "bundle exec minitest test/a_test.rb", fail: true 194 | assert_tests_finished_normally(result) 195 | expect(result).to include "uncovered" 196 | end 197 | 198 | it "complains with minitest loaded before" do 199 | change_file("test/a_test.rb", "require 'single_cov'", "require 'minitest/autorun'; require 'single_cov'") do 200 | result = sh "ruby test/a_test.rb", fail: true 201 | assert_tests_finished_normally(result) 202 | expect(result).to include "uncovered" 203 | end 204 | end 205 | end 206 | 207 | describe "when file cannot be found from caller" do 208 | around { |test| move_file("test/a_test.rb", "test/b_test.rb", &test) } 209 | 210 | it "complains" do 211 | result = sh "ruby test/b_test.rb", fail: true 212 | expect(result).to include "Tried to guess covered file as lib/b.rb, but it does not exist." 213 | expect(result).to include "Use `SingleCov.covered! file: 'target_file.rb'` to set covered file location." 214 | end 215 | 216 | it "works with a rewrite" do 217 | change_file("test/b_test.rb", "SingleCov.covered!", "SingleCov.rewrite { |f| 'lib/a.rb' }\nSingleCov.covered!") do 218 | result = sh "ruby test/b_test.rb" 219 | assert_tests_finished_normally(result) 220 | end 221 | end 222 | 223 | it "works with configured file" do 224 | change_file("test/b_test.rb", "SingleCov.covered!", "SingleCov.covered! file: 'lib/a.rb'") do 225 | result = sh "ruby test/b_test.rb" 226 | assert_tests_finished_normally(result) 227 | end 228 | end 229 | end 230 | 231 | describe "when SimpleCov was loaded after" do 232 | # NOTE: SimpleCov also starts coverage and will break when we activated branches 233 | let(:branchless_setup) { default_setup.sub('root: root', 'root: root, branches: false') } 234 | 235 | around { |t| change_file("test/a_test.rb", default_setup, "#{branchless_setup}\nrequire 'simplecov'\nSimpleCov.start\n", &t) } 236 | 237 | it "works" do 238 | result = sh "ruby test/a_test.rb" 239 | assert_tests_finished_normally(result) 240 | expect(result).to include "Coverage report generated" # SimpleCov 241 | expect(result).to include "Line Coverage: 100.0% (3 / 3)" # SimpleCov 242 | end 243 | 244 | it "complains when coverage is bad" do 245 | change_file 'lib/a.rb', "def a", "def b\n1\nend\ndef a" do 246 | result = sh "ruby test/a_test.rb", fail: true 247 | assert_tests_finished_normally(result) 248 | expect(result).to include "Coverage report generated" # SimpleCov 249 | expect(result).to include "Line Coverage: 80.0% (4 / 5)" # SimpleCov 250 | expect(result).to include "(1 current vs 0 configured)" # SingleCov 251 | end 252 | end 253 | end 254 | 255 | describe "when SimpleCov was defined but did not start" do 256 | around { |t| change_file("test/a_test.rb", default_setup, "#{default_setup}\nrequire 'simplecov'\n", &t) } 257 | 258 | it "falls back to Coverage and complains" do 259 | change_file 'lib/a.rb', "def a", "def b\n1\nend\ndef a" do 260 | result = sh "ruby test/a_test.rb", fail: true 261 | assert_tests_finished_normally(result) 262 | expect(result).to include "(1 current vs 0 configured)" # SingleCov 263 | end 264 | end 265 | end 266 | 267 | describe "branch coverage" do 268 | around { |t| change_file("test/a_test.rb", "root: root", "root: root, branches: true", &t) } 269 | 270 | it_does_not_complain_when_everything_is_covered 271 | 272 | describe "with branches" do 273 | around { |t| change_file("lib/a.rb", "1", "2.times { |i| rand if i == 0 }", &t) } 274 | 275 | it_does_not_complain_when_everything_is_covered 276 | 277 | it "complains when branch coverage is missing" do 278 | change_file("lib/a.rb", "i == 0", "i != i") do 279 | result = sh "ruby test/a_test.rb", fail: true 280 | expect(result).to include ".lib/a.rb new uncovered lines introduced (1 current vs 0 configured)" 281 | expect(result).to include "lib/a.rb:4:19-23" 282 | end 283 | end 284 | 285 | it "complains sorted when line and branch coverage are bad" do 286 | change_file 'lib/a.rb', "def a", "def b\n1\nend\ndef a" do 287 | change_file("lib/a.rb", "i == 0", "i != i") do 288 | result = sh "ruby test/a_test.rb 2>&1", fail: true 289 | expect(result).to include "lib/a.rb new uncovered lines introduced (2 current vs 0 configured)" 290 | expect(result).to include "lib/a.rb:4\nlib/a.rb:7:19-23" 291 | end 292 | end 293 | end 294 | 295 | it "does not complain about branch being missing when line is not covered" do 296 | change_file("lib/a.rb", "end", "end\ndef b\n2.times { |i| rand if i == 0 }\nend\n") do 297 | result = sh "ruby test/a_test.rb", fail: true 298 | expect(result).to include ".lib/a.rb new uncovered lines introduced (1 current vs 0 configured)" 299 | expect(result).to include "lib/a.rb:7" 300 | end 301 | end 302 | 303 | it "does not duplicate coverage" do 304 | change_file("lib/a.rb", "if i == 0", "if i == 0 if i if 0 == 0 if 1 == 0") do 305 | result = sh "ruby test/a_test.rb", fail: true 306 | # different ruby versions sort these differently, but we only care about all being there 307 | expect(result.scan(/^\.?lib\/a.rb.*/).sort).to eq [ 308 | ".lib/a.rb new uncovered lines introduced (4 current vs 0 configured)", 309 | "lib/a.rb:4:19-23", 310 | "lib/a.rb:4:19-33 # else", 311 | "lib/a.rb:4:19-38 # else", 312 | "lib/a.rb:4:19-48 # else" 313 | ] 314 | end 315 | end 316 | 317 | it "complains about missing else coverage" do 318 | change_file("lib/a.rb", "2.times", "1.times") do 319 | result = sh "ruby test/a_test.rb", fail: true 320 | expect(result).to include ".lib/a.rb new uncovered lines introduced (1 current vs 0 configured)" 321 | expect(result).to include "lib/a.rb:4:19-33 # else" 322 | end 323 | end 324 | 325 | it "ignores 0 coverage from duplicate ensure branches" do 326 | change_file("lib/a.rb", "i == 0", "begin; i == 0; ensure; i == 0 if i == 0;end") do 327 | result = sh "ruby test/a_test.rb" 328 | assert_tests_finished_normally(result) 329 | expect(result).to_not include "uncovered" 330 | end 331 | end 332 | end 333 | end 334 | 335 | describe "generate_report" do 336 | around do |t| 337 | replace = "#{default_setup}\nSingleCov.coverage_report = 'coverage/.resultset.json'" 338 | change_file("test/a_test.rb", default_setup, replace, &t) 339 | end 340 | after { FileUtils.rm_rf("coverage") } 341 | 342 | it "generates when requested" do 343 | sh "ruby test/a_test.rb" 344 | result = JSON.parse(File.read("coverage/.resultset.json")) 345 | expect(result["Minitest"]["coverage"]).to eq( 346 | "#{project_root}/specs/fixtures/minitest/lib/a.rb" => { "branches" => {}, "lines" => [nil, 1, 1, 1, nil, nil] } 347 | ) 348 | end 349 | 350 | it "can force line coverage" do 351 | change_file("test/a_test.rb", default_setup, "#{default_setup}\nSingleCov.coverage_report_lines = true") do 352 | sh "ruby test/a_test.rb" 353 | end 354 | result = JSON.parse(File.read("coverage/.resultset.json")) 355 | coverage = [nil, 1, 1, 1, nil, nil] 356 | expect(result["Minitest"]["coverage"]).to eq( 357 | "#{project_root}/specs/fixtures/minitest/lib/a.rb" => coverage 358 | ) 359 | end 360 | 361 | it "does mot fail if file exists" do 362 | FileUtils.mkdir_p "coverage" 363 | File.write("coverage/.resultset.json", "NOT-JSON") 364 | sh "ruby test/a_test.rb" 365 | JSON.parse(File.read("coverage/.resultset.json")) # was updated 366 | end 367 | end 368 | end 369 | 370 | describe "rspec" do 371 | around { |test| Dir.chdir("specs/fixtures/rspec", &test) } 372 | 373 | it "does not complain when everything is covered" do 374 | result = sh "bundle exec rspec spec/a_spec.rb" 375 | assert_specs_finished_normally(result, 3) 376 | expect(result).to_not include "uncovered" 377 | end 378 | 379 | it "does not complain in forks when disabled" do 380 | change_file( 381 | "spec/a_spec.rb", 382 | %(it "does a" do), %{it "does a" do\nfork { SingleCov.remove_instance_variable(:@pid); SingleCov.disable }\n} 383 | ) do 384 | result = sh "bundle exec rspec spec/a_spec.rb" 385 | expect(result).to_not include "uncovered" 386 | assert_specs_finished_normally(result, 3) 387 | end 388 | end 389 | 390 | it "does not complain in forks by default" do 391 | change_file("spec/a_spec.rb", %(it "does a" do), %(it "does a" do\nfork { 11 }\n)) do 392 | result = sh "bundle exec rspec spec/a_spec.rb" 393 | assert_specs_finished_normally(result, 3) 394 | expect(result).to_not include "uncovered" 395 | end 396 | end 397 | 398 | describe "when something is uncovered" do 399 | around { |t| change_file("spec/a_spec.rb", "A.new.a", "1", &t) } 400 | 401 | it "complains when something is uncovered" do 402 | result = sh "bundle exec rspec spec/a_spec.rb", fail: true 403 | assert_specs_finished_normally(result, 3) 404 | expect(result).to include "uncovered" 405 | end 406 | 407 | it "does not complains when running a subset of tests by line" do 408 | result = sh "bundle exec rspec spec/a_spec.rb:15" 409 | assert_specs_finished_normally(result, 1) 410 | expect(result).to_not include "uncovered" 411 | end 412 | 413 | it "does not complains when running a subset of tests sub-line" do 414 | result = sh "bundle exec rspec spec/a_spec.rb[1:1]" 415 | assert_specs_finished_normally(result, 1) 416 | expect(result).to_not include "uncovered" 417 | end 418 | 419 | it "does not complains when running a subset of tests by name" do 420 | result = sh "bundle exec rspec spec/a_spec.rb -e 'does a'" 421 | assert_specs_finished_normally(result, 1) 422 | expect(result).to_not include "uncovered" 423 | end 424 | 425 | it "does not complain when tests failed" do 426 | change_file("spec/a_spec.rb", "eq 1", "eq 2") do 427 | result = sh "bundle exec rspec spec/a_spec.rb", fail: true 428 | expect(result).to include "3 examples, 1 failure" 429 | expect(result).to_not include "uncovered" 430 | end 431 | end 432 | end 433 | end 434 | 435 | describe ".assert_used" do 436 | around { |test| Dir.chdir("specs/fixtures/minitest", &test) } 437 | 438 | it "work when all tests have SingleCov" do 439 | SingleCov.assert_used 440 | end 441 | 442 | it "works when using .not_covered!" do 443 | change_file "test/a_test.rb", "SingleCov.covered!", 'SingleCov.not_covered!' do 444 | SingleCov.assert_used 445 | end 446 | end 447 | 448 | describe "when a test does not have SingleCov" do 449 | around { |t| change_file("test/a_test.rb", "SingleCov.covered", 'Nope', &t) } 450 | 451 | it "raises" do 452 | message = "test/a_test.rb: needs to use SingleCov.covered!" 453 | expect { SingleCov.assert_used }.to raise_error(RuntimeError, message) 454 | end 455 | 456 | it "works with custom files" do 457 | SingleCov.assert_used tests: [] 458 | end 459 | end 460 | end 461 | 462 | describe ".assert_tested" do 463 | around { |test| Dir.chdir("specs/fixtures/minitest", &test) } 464 | 465 | it "work when all files have a test" do 466 | SingleCov.assert_tested 467 | end 468 | 469 | it "complains when untested are now tested" do 470 | message = "Remove [\"lib/b.rb\"] from untested!" 471 | expect { SingleCov.assert_tested untested: ['lib/b.rb'] }.to raise_error(RuntimeError, message) 472 | end 473 | 474 | describe "when a file is missing a test" do 475 | around { |t| move_file('lib/a.rb', 'lib/b.rb', &t) } 476 | 477 | it "complains " do 478 | message = "missing test for lib/b.rb" 479 | expect { SingleCov.assert_tested }.to raise_error(RuntimeError, message) 480 | end 481 | 482 | it "does not complain when it is marked as untested" do 483 | SingleCov.assert_tested untested: ['lib/b.rb'] 484 | end 485 | end 486 | end 487 | 488 | describe ".assert_full_coverage" do 489 | def call 490 | SingleCov.assert_full_coverage currently_complete: complete 491 | end 492 | 493 | let(:complete) { ["test/a_test.rb"] } 494 | 495 | around { |test| Dir.chdir("specs/fixtures/minitest", &test) } 496 | 497 | it "works when correct files are covered" do 498 | call 499 | end 500 | 501 | it "alerts when files are newly covered" do 502 | expect do 503 | complete.pop 504 | call 505 | end.to raise_error(/single_cov_spec\.rb.*test\/a_test.rb/m) 506 | end 507 | 508 | it "alerts when files lost coverage" do 509 | expect do 510 | change_file('test/a_test.rb', 'SingleCov.covered!', 'SingleCov.covered! uncovered: 12') { call } 511 | end.to raise_error(/test\/a_test.rb/) 512 | end 513 | 514 | it "ignores files not_covered" do 515 | complete.pop 516 | change_file('test/a_test.rb', 'SingleCov.covered!', 'SingleCov.not_covered!') { call } 517 | end 518 | 519 | it "ignores files with uncovered commented out" do 520 | change_file('test/a_test.rb', 'SingleCov.covered!', 'SingleCov.covered! # uncovered: 12') { call } 521 | end 522 | 523 | describe 'when file cannot be found from caller' do 524 | let(:complete) { ["test/b_test.rb"] } 525 | 526 | around { |test| move_file('test/a_test.rb', 'test/b_test.rb', &test) } 527 | 528 | it "works when files covered and configured" do 529 | change_file('test/b_test.rb', 'SingleCov.covered!', 'SingleCov.covered! file: lib/a.rb') { call } 530 | end 531 | 532 | it "alerts when files lost coverage and are configured" do 533 | expect do 534 | change_file('test/b_test.rb', 'SingleCov.covered!', 'SingleCov.covered!(uncovered: 12, file: lib/a.rb)') { call } 535 | end.to raise_error(/test\/b_test.rb/) 536 | end 537 | end 538 | end 539 | 540 | describe ".file_under_test" do 541 | def file_under_test(test) 542 | SingleCov.send(:guess_covered_file, "#{SingleCov.send(:root)}/#{test}:34:in `foobar'") 543 | end 544 | 545 | def self.it_maps_path(test, file, ignore) 546 | it "maps #{test} to #{file}#{" when ignoring prefixes" if ignore}" do 547 | stub_const('SingleCov::PREFIXES_TO_IGNORE', ['public']) if ignore 548 | expect(file_under_test(test)).to eq file 549 | end 550 | end 551 | 552 | [false, true].each do |ignore| 553 | { 554 | "test/models/xyz_test.rb" => "app/models/xyz.rb", 555 | "test/lib/xyz_test.rb" => "lib/xyz.rb", 556 | "spec/lib/xyz_spec.rb" => "lib/xyz.rb", 557 | "test/xyz_test.rb" => "lib/xyz.rb", 558 | "test/test_xyz.rb" => "lib/xyz.rb", 559 | "plugins/foo/test/lib/xyz_test.rb" => "plugins/foo/lib/xyz.rb", 560 | "plugins/foo/test/models/xyz_test.rb" => "plugins/foo/app/models/xyz.rb" 561 | }.each { |test, file| it_maps_path test, file, ignore } 562 | end 563 | 564 | it_maps_path "component/foo/test/public/models/xyz_test.rb", "component/foo/lib/public/models/xyz.rb", false 565 | it_maps_path "component/foo/test/public/models/xyz_test.rb", "component/foo/public/app/models/xyz.rb", true 566 | 567 | it "complains about files without test folder" do 568 | message = "oops_test.rb includes neither 'test' nor 'spec' folder ... unable to resolve" 569 | expect { file_under_test("oops_test.rb") }.to raise_error(RuntimeError, message) 570 | end 571 | 572 | it "complains about files without test extension" do 573 | message = "Unable to remove test extension from test/oops.rb ... /test_, _test.rb and _spec.rb are supported" 574 | expect { file_under_test("test/oops.rb") }.to raise_error(RuntimeError, message) 575 | end 576 | end 577 | 578 | # covering a weird edge case where the test folder is not part of the root directory because 579 | # a nested gemfile was used which changed Bundler.root 580 | describe ".guess_and_check_covered_file" do 581 | it "complains nicely when calling file is outside of root" do 582 | expect(SingleCov).to receive(:guess_covered_file).and_return('/oops/foo.rb') 583 | expect do 584 | SingleCov.send(:ensure_covered_file, nil) 585 | end.to raise_error(RuntimeError, /Found file \/oops\/foo.rb which is not relative to the root/) 586 | end 587 | end 588 | 589 | describe ".root" do 590 | it "ignores when bundler root is in a gemfiles folder" do 591 | old = SingleCov.send(:root) 592 | SingleCov.instance_variable_set(:@root, nil) 593 | expect(Bundler).to receive(:root).and_return(Pathname.new("#{old}/gemfiles")) 594 | expect(SingleCov.send(:root)).to eq old 595 | ensure 596 | SingleCov.instance_variable_set(:@root, old) 597 | end 598 | end 599 | 600 | def sh(command, options = {}) 601 | result = `#{command} #{"2>&1" unless options[:keep_output]}` 602 | raise "#{options[:fail] ? "SUCCESS" : "FAIL"} #{command}\n#{result}" if $?.success? == !!options[:fail] 603 | result 604 | end 605 | 606 | def change_file(file, find, replace) 607 | old = File.read(file) 608 | raise "Did not find #{find} in:\n#{old}" unless (new = old.dup.sub!(find, replace)) 609 | File.write(file, new) 610 | yield 611 | ensure 612 | File.write(file, old) 613 | end 614 | 615 | def create_file(file, content) 616 | File.write(file, content) 617 | yield 618 | ensure 619 | File.unlink(file) 620 | end 621 | 622 | def move_file(a, b) 623 | FileUtils.mv(a, b) 624 | yield 625 | ensure 626 | FileUtils.mv(b, a) 627 | end 628 | 629 | def assert_tests_finished_normally(result) 630 | expect(result).to include "1 runs, 1 assertions, 0 failures" 631 | end 632 | 633 | def assert_specs_finished_normally(result, examples) 634 | expect(result).to include "#{examples} example#{'s' if examples != 1}, 0 failures" 635 | end 636 | end 637 | --------------------------------------------------------------------------------