Feature #20282
openEnhancing Ruby's Coverage with Per-Test Coverage Reports
Description
As Ruby applications grow in complexity, the need for more sophisticated testing and coverage analysis tools becomes paramount. Current coverage tools in Ruby offer a good starting point but fall short in delivering the granularity and flexibility required by modern development practices. Specifically, there is a significant gap in "per-test coverage" reporting, which limits developers' ability to pinpoint exactly which tests exercise which lines of code. This proposal seeks to initiate a discussion around improving Ruby's coverage module to address this gap.
Objectives¶
The primary goal of this initiative is to introduce support for per-test coverage reports within Ruby, focusing on three key areas:
-
Scoped Coverage Data Capture: Implementing the capability to capture coverage data within user-defined scopes, such as global, thread, or fiber scopes. This would allow for more granular control over the coverage analysis process.
-
Efficient Data Capture Controls: Developing mechanisms to efficiently control the capture of coverage data. This includes the ability to exclude specific files, include/ignore/merge eval'd code, to ensure that the coverage data is both relevant and manageable.
-
Compatibility and Consistency: Ensuring that the coverage data is exposed in a manner that is consistent with existing coverage tools and standards. This compatibility is crucial for integrating with a wide array of tooling and for facilitating a seamless developer experience.
Proposed Solutions¶
The heart of this proposal lies in the introduction of a new subclassable component within the Coverage module, tentatively named Coverage::Capture
. This component would allow users to define custom coverage capture behaviors tailored to their specific needs. Below is a hypothetical interface for such a mechanism:
class Coverage::Capture
def self.start
self.new.tap(&:start)
end
# Start receiving coverage callbacks.
def start
end
# Stop receiving coverage callbacks.
def stop
end
# User-overridable statement coverage callback.
def statement(iseq, location)
fetch(iseq)&.statement_coverage.increment(location)
end
# Additional methods for branch/declaration coverage would follow a similar pattern.
end
class MyCoverageCapture < Coverage::Capture
# Provides efficient data capture controls - can return nil if skipping coverage for this iseq, or can store coverage data per-thread, per-fiber, etc.
def fetch(iseq)
@coverage[iseq] ||= Coverage.default_coverage(iseq)
end
end
# Usage example:
my_coverage_capture = MyCoverageCapture.start
# Execute test suite or specific tests
my_coverage_capture.stop
# Access detailed coverage data
puts my_coverage_capture.coverage.statement_coverage
In addition, we'd need a well defined interface for Coverage.default_coverage
, which includes line, branch and declaration coverage statistics. I suggest we take inspiration from the proposed interface defined by the vscode text editor: https://github.com/microsoft/vscode/blob/b44593a612337289c079425a5b2cc7010216eef4/src/vscode-dts/vscode.proposed.testCoverage.d.ts - this interface was designed to be compatible with a wide range of coverage libraries, so represents the intersection of that functionality.
# Hypothetical interface (mostly copied from vscode's proposed interface):
module Coverage
# Contains coverage metadata for a file
class Target
attr_reader :instruction_sequence
attr_accessor :statement_coverage, :branch_coverage, :declaration_coverage, :detailed_coverage
# @param statement_coverage [Hash(Location, StatementCoverage)] A hash table of statement coverage instances keyed on location.
# Similar structures for other coverage data.
def initialize(instruction_sequence, statement_coverage, branch_coverage=nil, declaration_coverage=nil)
@instruction_sequence = instruction_sequence
@statement_coverage = statement_coverage
@branch_coverage = branch_coverage
@declaration_coverage = declaration_coverage
end
end
# Coverage information for a single statement or line.
class StatementCoverage
# The number of times this statement was executed, or a boolean indicating
# whether it was executed if the exact count is unknown. If zero or false,
# the statement will be marked as un-covered.
attr_accessor :executed
# Statement location (line number? or range? or position? AST?)
attr_accessor :location
# Coverage from branches of this line or statement. If it's not a
# conditional, this will be empty.
attr_accessor :branches
# Initializes a new instance of the StatementCoverage class.
#
# @parameter executed [Number, Boolean] The number of times this statement was executed, or a
# boolean indicating whether it was executed if the exact count is unknown. If zero or false,
# the statement will be marked as un-covered.
#
# @parameter location [Position, Range] The statement position.
#
# @parameter branches [Array(BranchCoverage)] Coverage from branches of this line.
# If it's not a conditional, this should be omitted.
def initialize(executed, location, branches=[])
@executed = executed
@location = location
@branches = branches
end
end
# Coverage information for a branch
class BranchCoverage
# The number of times this branch was executed, or a boolean indicating
# whether it was executed if the exact count is unknown. If zero or false,
# the branch will be marked as un-covered.
attr_accessor :executed
# Branch location.
attr_accessor :location
# Label for the branch, used in the context of "the ${label} branch was
# not taken," for example.
attr_accessor :label
# Initializes a new instance of the BranchCoverage class.
#
# @param executed [Number, Boolean] The number of times this branch was executed, or a
# boolean indicating whether it was executed if the exact count is unknown. If zero or false,
# the branch will be marked as un-covered.
#
# @param location [Position, Range] (optional) The branch position.
#
# @param label [String] (optional) Label for the branch, used in the context of
# "the ${label} branch was not taken," for example.
def initialize(executed, location=nil, label=nil)
@executed = executed
@location = location
@label = label
end
end
# Coverage information for a declaration
class DeclarationCoverage
# Name of the declaration. Depending on the reporter and language, this
# may be types such as functions, methods, or namespaces.
attr_accessor :name
# The number of times this declaration was executed, or a boolean
# indicating whether it was executed if the exact count is unknown. If
# zero or false, the declaration will be marked as un-covered.
attr_accessor :executed
# Declaration location.
attr_accessor :location
# Initializes a new instance of the DeclarationCoverage class.
#
# @param name [String] Name of the declaration.
#
# @param executed [Number, Boolean] The number of times this declaration was executed, or a
# boolean indicating whether it was executed if the exact count is unknown. If zero or false,
# the declaration will be marked as un-covered.
#
# @param location [Position, Range] The declaration position.
def initialize(name, executed, location)
@name = name
@executed = executed
@location = location
end
end
end
By following this format, we will be compatible with a wide range of external tools.