Skip to content

Single File Coverage Reporting for SimpleCovΒ #1137

@dmytro-strukov

Description

@dmytro-strukov

Single File Coverage Reporting for SimpleCov

Problem

Currently, SimpleCov doesn't provide a way to check test coverage for individual files. This makes it difficult to:

  • Validate coverage for new files in CI
  • Get quick feedback during TDD
  • Support AI tools that need file-specific coverage metrics

Real-world Example

In our project, we use AI to help write tests. Our .cursorrules require specific coverage checks:

roles:
  - name: "RSpec Testing Guide"
    rules:
      - Check that tests pass and coverage is at least 80% using `bin/rspec_coverage spec/<folder>/<file>.rb`
      # ...other rules

Solution

I've implemented a proof of concept that works with RSpec (ready to implement for Minitest if approved):

$ bin/rspec_coverage spec/models/user_spec.rb
Coverage for:
  spec/models/user_spec.rb

app/models/user.rb:
  Line coverage:   92.5%
  Branch coverage: 87.3%

Implementation

Here are the files needed to implement this feature:

# bin/rspec_coverage
#!/usr/bin/env ruby

if ARGV.empty?
  puts "Usage: bin/rspec_coverage <spec_file1> [spec_file2 ...]"
  puts "Example: bin/rspec_coverage spec/models/user_spec.rb spec/models/post_spec.rb"
  exit 1
end

spec_files = ARGV

invalid_files = spec_files.reject { |file| file.end_with?('_spec.rb') }

unless invalid_files.empty?
  puts "Please provide only spec files (ending with _spec.rb):"
  invalid_files.each { |file| puts "  #{file}" }
  exit 1
end

puts "Coverage for:"
spec_files.each { |file| puts "  #{file}" }
puts

exit_code = 0

spec_files.each do |spec_file|
  puts "\nRunning #{spec_file}..."
  env = {
    'COVERAGE' => 'true',
    'COVERAGE_SPEC_FILE_PATH' => spec_file
  }
  system(env, "bundle exec rspec #{spec_file}")
  exit_code = 1 unless $?.success?
end

exit exit_code 
# lib/simplecov/single_file_reporter.rb
module SimpleCov
  module SingleFileReporter
    module_function

    LOGGER = Logger.new(STDOUT)
    LOGGER.level = Logger::INFO
    LOGGER.formatter = ->(_, _, _, msg) { "#{msg}\n" }

    ReportResult = Struct.new(:success?, :messages, :coverage, keyword_init: true) do
      def self.failure(message)
        new(success?: false, messages: [message], coverage: nil)
      end

      def self.success(coverage_stats)
        new(success?: true, messages: [], coverage: coverage_stats)
      end
    end

    def report_for(spec_file_path)
      example_group = find_example_group(spec_file_path)
      return ReportResult.failure("No example group found for #{spec_file_path}") unless example_group

      described_class = example_group.metadata[:described_class]
      return ReportResult.failure("Cannot determine described_class for #{spec_file_path}") unless described_class

      source_file_path = find_source_file(described_class)
      return ReportResult.failure("Cannot find source file for #{described_class}") unless source_file_path

      target_result = find_coverage_result(source_file_path)

      if target_result
        coverage_stats = log_coverage_stats(target_result)
        ReportResult.success(coverage_stats)
      else
        ReportResult.failure("File #{source_file_path} not found in coverage results")
      end
    end

    def find_example_group(spec_file_path)
      RSpec.world.example_groups.find do |g|
        g.metadata[:absolute_file_path] == File.expand_path(spec_file_path)
      end
    end

    def find_source_file(klass)
      Object.const_source_location(klass.to_s)&.first
    end

    def find_coverage_result(source_file_path)
      SimpleCov.result.files.find { |file| file.filename == source_file_path }
    end

    def log_coverage_stats(target_result, precision = 1)
      coverage_stats = target_result.coverage_statistics

      line_coverage = coverage_stats[:line].percent.round(precision)
      branch_coverage = coverage_stats[:branch].percent.round(precision)

      stats = {
        line_coverage: line_coverage,
        branch_coverage: branch_coverage
      }

      LOGGER.info("#{target_result.filename}:")
      LOGGER.info("  Line coverage:   #{line_coverage}%")
      LOGGER.info("  Branch coverage: #{branch_coverage}%")

      stats
    end
  end
end
# spec/spec_helper.rb

if ENV['COVERAGE']
  require 'simplecov'

  if ENV['COVERAGE_SPEC_FILE_PATH']
    SimpleCov.start 'rails' do
      enable_coverage :branch
      primary_coverage :branch

      coverage_dir 'tmp/coverage'

      target_file = ENV['COVERAGE_SPEC_FILE_PATH']
      next unless target_file

      at_exit do
        SimpleCov::SingleFileReporter.report_for(target_file)
      end
    end
  end
end

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions