Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Testing mode with RSpec matchers #25

Merged
merged 6 commits into from
Sep 25, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ RSpec/LetSetup:
RSpec/MultipleExpectations:
Enabled: false

RSpec/DescribeClass:
Enabled: false

RSpec/NestedGroups:
Max: 4

Bundler/OrderedGems:
Enabled: false

Expand Down Expand Up @@ -52,3 +58,6 @@ Style/HashTransformKeys:

Style/HashTransformValues:
Enabled: true

Style/Documentation:
Enabled: false
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## Unreleased

### Added

- RSpec matchers `increment_yabeda_counter`, `update_yabeda_gauge`, and `measure_yabeda_histogram` for convenient testing. [#25](https://github.com/yabeda-rb/yabeda/pull/25) by [@Envek][]
- Automatic setup of RSpec on `require "yabeda/rspec"`
- Special test adapter that collects metric changes in memory

## 0.10.1 - 2021-08-30

### Fixed
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,44 @@ Config key | Type | Default | Description |

These are only enabled in debug mode. To enable it either set `debug` config key to `true` (e.g. by specifying `YABEDA_DEBUG=true` in your environment variables or executing `Yabeda.debug!` in your code).

## Testing

### RSpec

Add the following to your `rails_helper.rb` (or `spec_helper.rb`):

```ruby
require "yabeda/rspec"
```

Now you can use `increment_yabeda_counter`, `update_yabeda_gauge`, and `measure_yabeda_histogram` matchers:

```ruby
it "increments counters" do
expect { subject }.to increment_yabeda_counter(Yabeda.myapp.foo_count).by(3)
end
```

You can scope metrics by used tags with `with_tags`:

```ruby
it "updates gauges" do
expect { subject }.to \
update_yabeda_gauge("some_gauge_name").
with_tags(method: "command", command: "subscribe")
end
```

Note that tags you specified doesn't need to be exact, but can be a subset of tags used on metric update. In this example updates with following sets of tags `{ method: "command", command: "subscribe", status: "SUCCESS" }` and `{ method: "command", command: "subscribe", status: "FAILURE" }` will make test example to pass.

And check for values with `by` for counters, `to` for gauges, and `with` for gauges and histograms (and you [can use other matchers here](https://relishapp.com/rspec/rspec-expectations/v/3-10/docs/composing-matchers)):

```ruby
expect { subject }.to \
measure_yabeda_histogram(Yabeda.something.anything_runtime).
with(be_between(0.005, 0.05))
```

## Roadmap (aka TODO or Help wanted)

- Ability to change metric settings for individual adapters
Expand Down
2 changes: 0 additions & 2 deletions lib/yabeda/dsl/class_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
module Yabeda
# DSL for ease of work with Yabeda
module DSL
# rubocop: disable Style/Documentation
module ClassMethods
# Block for grouping and simplifying configuration of related metrics
def configure(&block)
Expand Down Expand Up @@ -110,6 +109,5 @@ def register_group_for(metric)
group.register_metric(metric)
end
end
# rubocop: enable Style/Documentation
end
end
4 changes: 4 additions & 0 deletions lib/yabeda/metric.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,9 @@ def values
def tags
(Yabeda.groups[group].default_tags.keys + Array(super)).uniq
end

def inspect
"#<#{self.class.name}: #{[@group, @name].compact.join('.')}>"
end
end
end
25 changes: 25 additions & 0 deletions lib/yabeda/rspec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

require_relative "./testing"

module Yabeda
# RSpec integration for Yabeda: custom matchers, etc
module RSpec
end
end

require_relative "./rspec/increment_yabeda_counter"
require_relative "./rspec/update_yabeda_gauge"
require_relative "./rspec/measure_yabeda_histogram"

::RSpec.configure do |config|
config.before(:suite) do
Yabeda.configure! unless Yabeda.already_configured?
end

config.after(:each) do
Yabeda::TestAdapter.instance.reset!
end

config.include(Yabeda::RSpec)
end
64 changes: 64 additions & 0 deletions lib/yabeda/rspec/base_matcher.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true

module Yabeda
module RSpec
# Notes:
# +expected+ is always a metric instance
# +actual+ is always a block of code
# Example:
# expect { anything }.to do_whatever_with_yabeda_metric(Yabeda.something)
class BaseMatcher < ::RSpec::Matchers::BuiltIn::BaseMatcher
attr_reader :tags, :metric

# Specify a scope of labels (tags). Subset of tags can be specified.
def with_tags(tags)
@tags = tags
self
end

def initialize(expected)
@expected = @metric = resolve_metric(expected)
rescue KeyError
raise ArgumentError, <<~MSG
Pass metric name or metric instance to matcher (e.g. `increment_yabeda_counter(Yabeda.metric_name)` or \
increment_yabeda_counter('metric_name')). Got #{expected.inspect} instead
MSG
end

# RSpec doesn't define this method, but it is more convenient to rely on +match_when_negated+ method presence
def does_not_match?(actual)
@actual = actual
if respond_to?(:match_when_negated)
match_when_negated(expected, actual)
else
!match(expected, actual)
end
end

def supports_block_expectations?
true
end

# Pretty print metric name (expected is expected to always be a Yabeda metric instance)
def expected_formatted
"Yabeda.#{[metric.group, metric.name].compact.join('.')}"
end

private

def resolve_metric(instance_or_name)
return instance_or_name if instance_or_name.is_a? Yabeda::Metric

Yabeda.metrics.fetch(instance_or_name.to_s)
end

# Filter metric changes by tags.
# If tags specified, treat them as subset of real tags (to avoid bothering with default tags in tests)
def filter_matching_changes(changes)
return changes if tags.nil?

changes.select { |t, _v| t >= tags }
end
end
end
end
81 changes: 81 additions & 0 deletions lib/yabeda/rspec/increment_yabeda_counter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# frozen_string_literal: true

require_relative "./base_matcher"

module Yabeda
module RSpec
# Checks whether Yabeda counter was incremented during test run or not
# @param metric [Yabeda::Counter,String,Symbol] metric instance or name
# @return [Yabeda::RSpec::IncrementYabedaCounter]
def increment_yabeda_counter(metric)
IncrementYabedaCounter.new(metric)
end

# Custom matcher class with implementation for +increment_yabeda_counter+
class IncrementYabedaCounter < BaseMatcher
def by(increment)
@expected_increment = increment
self
end

attr_reader :expected_increment

def initialize(*)
super
return if metric.is_a? Yabeda::Counter

raise ArgumentError, "Pass counter instance/name to `increment_yabeda_counter`. Got #{metric.inspect} instead"
end

def match(metric, block)
block.call

increments = filter_matching_changes(Yabeda::TestAdapter.instance.counters.fetch(metric))

increments.values.any? do |actual_increment|
expected_increment.nil? || values_match?(expected_increment, actual_increment)
end
end

def match_when_negated(metric, block)
unless expected_increment.nil?
raise NotImplementedError, <<~MSG
`expect(Yabeda.metric_name).not_to increment_yabeda_counter` doesn't support specifying increment
with `.by` as it can lead to false positives.
MSG
end

block.call

increments = filter_matching_changes(Yabeda::TestAdapter.instance.counters.fetch(metric))

increments.none?
end

def failure_message
"expected #{expected_formatted} " \
"to be incremented #{"by #{description_of(expected_increment)} " unless expected_increment.nil?}" \
"#{("with tags #{::RSpec::Support::ObjectFormatter.format(tags)} " if tags)}" \
"but #{actual_increments_message}"
end

def failure_message_when_negated
"expected #{expected_formatted} " \
"not to be incremented " \
"#{("with tags #{::RSpec::Support::ObjectFormatter.format(tags)} " if tags)}" \
"but #{actual_increments_message}"
end

def actual_increments_message
counter_increments = Yabeda::TestAdapter.instance.counters.fetch(metric)
if counter_increments.empty?
"no increments of this counter have been made"
elsif tags && counter_increments.key?(tags)
"has been incremented by #{counter_increments.fetch(tags)}"
else
"following increments have been made: #{::RSpec::Support::ObjectFormatter.format(counter_increments)}"
end
end
end
end
end
79 changes: 79 additions & 0 deletions lib/yabeda/rspec/measure_yabeda_histogram.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# frozen_string_literal: true

require_relative "./base_matcher"

module Yabeda
module RSpec
# Checks whether Yabeda histogram was measured during test run or not
# @param metric [Yabeda::Histogram,String,Symbol] metric instance or name
# @return [Yabeda::RSpec::MeasureYabedaHistogram]
def measure_yabeda_histogram(metric)
MeasureYabedaHistogram.new(metric)
end

# Custom matcher class with implementation for +measure_yabeda_histogram+
class MeasureYabedaHistogram < BaseMatcher
def with(value)
@expected_value = value
self
end

attr_reader :expected_value

def initialize(*)
super
return if metric.is_a? Yabeda::Histogram

raise ArgumentError, "Pass histogram instance/name to `measure_yabeda_histogram`. Got #{metric.inspect} instead"
end

def match(metric, block)
block.call

measures = filter_matching_changes(Yabeda::TestAdapter.instance.histograms.fetch(metric))

measures.values.any? { |measure| expected_value.nil? || values_match?(expected_value, measure) }
end

def match_when_negated(metric, block)
unless expected_value.nil?
raise NotImplementedError, <<~MSG
`expect {}.not_to measure_yabeda_histogram` doesn't support specifying values with `.with`
as it can lead to false positives.
MSG
end

block.call

measures = filter_matching_changes(Yabeda::TestAdapter.instance.histograms.fetch(metric))

measures.none?
end

def failure_message
"expected #{expected_formatted} " \
"to be changed #{"to #{expected} " unless expected_value.nil?}" \
"#{("with tags #{::RSpec::Support::ObjectFormatter.format(tags)} " if tags)}" \
"but #{actual_changes_message}"
end

def failure_message_when_negated
"expected #{expected_formatted} " \
"not to be incremented " \
"#{("with tags #{::RSpec::Support::ObjectFormatter.format(tags)} " if tags)}" \
"but #{actual_changes_message}"
end

def actual_changes_message
measures = Yabeda::TestAdapter.instance.histograms.fetch(metric)
if measures.empty?
"no changes of this gauge have been made"
elsif tags && measures.key?(tags)
"has been changed to #{measures.fetch(tags)} with tags #{::RSpec::Support::ObjectFormatter.format(tags)}"
else
"following changes have been made: #{::RSpec::Support::ObjectFormatter.format(measures)}"
end
end
end
end
end
Loading