RuboCop RSpecParity
A RuboCop plugin that enforces spec parity and best practices in RSpec test suites. This gem helps ensure your Ruby code has proper test coverage and follows RSpec conventions.
Features
This plugin provides these custom cops:
- RSpecParity/FileHasSpec: Ensures every Ruby file in your app directory has a corresponding spec file
- RSpecParity/PublicMethodHasSpec: Ensures every public method has spec test coverage
- RSpecParity/SufficientContexts: Ensures specs have at least as many contexts as the method has branches (if/elsif/else, case/when, &&, ||, ternary operators)
Examples
RSpecParity/FileHasSpec
Ensures every Ruby file in your app directory has a corresponding spec file.
# bad - app/models/user.rb exists but spec/models/user_spec.rb doesn't
# This will trigger an offense
# good - both files exist:
# app/models/user.rb
# spec/models/user_spec.rbRSpecParity/PublicMethodHasSpec
Ensures every public method has spec test coverage.
# bad - app/services/user_creator.rb
class UserCreator
def create(params) # No spec coverage for this method
User.create(params)
end
end
# good - app/services/user_creator.rb with spec/services/user_creator_spec.rb
class UserCreator
def create(params)
User.create(params)
end
end
# spec/services/user_creator_spec.rb
RSpec.describe UserCreator do
describe '#create' do
it 'creates a user' do
# test implementation
end
end
endSkipMethodDescribeFor Configuration
For service objects and similar patterns with a single public method, you can skip the method describe block:
RSpecParity/PublicMethodHasSpec:
SkipMethodDescribeFor:
- 'app/services/**/*'
- 'app/operations/**/*'
RSpecParity/SufficientContexts:
SkipMethodDescribeFor:
- 'app/services/**/*'
- 'app/operations/**/*'With this configuration:
# app/services/user_creator.rb
class UserCreator
def call(params)
User.create(params)
end
private
def validate(params)
# Private helper - doesn't count
end
end
# spec/services/user_creator_spec.rb - Valid!
RSpec.describe UserCreator do
it 'creates a user' do
# test implementation
end
context 'when invalid params' do
it 'raises error' do
end
end
endRequirements:
- Class must have exactly ONE public method
- Spec must contain at least one example (
it,example, orspecify) - Traditional method describes still work if you prefer them
How it works:
-
PublicMethodHasSpec: Instead of requiringdescribe '#call', it just checks that examples exist -
SufficientContexts: Counts top-level contexts/examples instead of looking inside method describes
DescribeAliases Configuration
When an instance method is tested via a class method (e.g., service objects where def call is tested via describe '.call', or jobs where def perform is tested via describe '.perform_later'), you can configure describe aliases:
RSpecParity/PublicMethodHasSpec:
DescribeAliases:
'#call': '.call'
'#perform':
- '.perform_later'
- '.perform_now'
RSpecParity/SufficientContexts:
DescribeAliases:
'#call': '.call'
'#perform':
- '.perform_later'
- '.perform_now'With this configuration:
# app/services/user_creator.rb
class UserCreator
def call(params)
User.create(params)
end
end
# spec/services/user_creator_spec.rb - Valid!
# The alias '#call' => '.call' allows .call to satisfy #call
RSpec.describe UserCreator do
describe '.call' do
it 'creates a user' do
# test implementation
end
end
end# app/jobs/user_job.rb
class UserJob
def perform(params)
process(params)
end
end
# spec/jobs/user_job_spec.rb - Valid!
# The alias '#perform' => ['.perform_later', '.perform_now'] allows either
RSpec.describe UserJob do
describe '.perform_later' do
it 'processes params' do
# test implementation
end
end
endNotes:
- Keys use
#for instance method describes,.for class method describes - Values can be a single string or an array of strings
- Aliases are unidirectional:
'#call': '.call'allows.callto satisfy#call, but not vice versa -
SufficientContextsaggregates contexts from both the original and alias describe blocks
RSpecParity/SufficientContexts
Ensures specs have at least as many contexts as the method has branches.
# bad - app/services/user_creator.rb
def create_user(params)
if params[:admin]
create_admin(params)
elsif params[:moderator]
create_moderator(params)
else
create_regular_user(params)
end
end
# spec/services/user_creator_spec.rb - only 1 context for 3 branches
RSpec.describe UserCreator do
describe '#create_user' do
context 'when creating users' do
# Only one context for 3 branches - triggers offense
end
end
end
# good - 3 contexts for 3 branches
RSpec.describe UserCreator do
describe '#create_user' do
context 'when admin' do
# tests admin branch
end
context 'when moderator' do
# tests moderator branch
end
context 'when regular user' do
# tests regular user branch
end
end
end
# Memoization patterns are ignored by default
def cached_value
@cached_value ||= expensive_operation # Not counted as a branch
end
def cached_value
return @cached_value if defined?(@cached_value) # Not counted as a branch
@cached_value = expensive_operation
endConfiguration options:
-
IgnoreMemoization(default:true) - When enabled, common memoization patterns like@var ||=andreturn @var if defined?(@var)are not counted as branches. Set tofalseif you want to count these as branches.
Assumptions
These cops work based on the following conventions:
-
File organization: Each Ruby file has a corresponding spec file stored in the
spec/directory, mirroring the same directory structure. For example,app/models/user.rbshould have a spec atspec/models/user_spec.rb. -
Spec file naming: Spec files use the
_spec.rbsuffix. -
Instance method specs: Instance methods are tested using the convention
describe '#method_name' do. -
Class method specs: Class methods are tested using the convention
describe '.class_method' do. -
Context blocks for branches: The
SufficientContextscop countscontextblocks within a method'sdescribeblock to ensure each branch path is tested.
If your project uses different conventions, these cops may not work as expected.
Installation
Add this line to your application's Gemfile:
gem 'rubocop-rspec_parity', require: falseAnd then execute:
bundle installOr install it directly:
gem install rubocop-rspec_parityUsage
Add rubocop-rspec_parity to your .rubocop.yml:
require:
- rubocop-rspec_parity
# For RuboCop >= 1.72
plugins:
- rubocop-rspec_parityThe default configuration enables all cops. You can customize them in your .rubocop.yml:
RSpecParity/FileHasSpec:
Enabled: true
Include:
- 'app/**/*.rb'
Exclude:
- 'app/assets/**/*'
- 'app/views/**/*'
RSpecParity/PublicMethodHasSpec:
Enabled: true
SkipMethodDescribeFor: [] # Paths where single-method classes don't need method describe
DescribeAliases: {} # Map describe strings to aliases, e.g. '#call': '.call'
Include:
- 'app/**/*.rb'
RSpecParity/SufficientContexts:
Enabled: true
IgnoreMemoization: true # Set to false to count memoization patterns as branches
SkipMethodDescribeFor: [] # Paths where single-method classes use top-level contexts
DescribeAliases: {} # Map describe strings to aliases, e.g. '#call': '.call'
Include:
- 'app/**/*.rb'
Exclude:
- 'app/assets/**/*'
- 'app/views/**/*'Run RuboCop as usual:
bundle exec rubocopDevelopment
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/povilasjurcys/rubocop-rspec_parity. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the RuboCop RSpecParity project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.