From 194fc7820290f55fc095623176ff42675c77533d Mon Sep 17 00:00:00 2001 From: Max Melentiev Date: Mon, 7 Nov 2016 20:07:55 +0300 Subject: [PATCH] RSpec matchers --- CHANGELOG.md | 1 + README.md | 17 ++ lib/telegram/bot.rb | 1 + lib/telegram/bot/client_stub.rb | 2 +- lib/telegram/bot/rspec.rb | 7 + lib/telegram/bot/rspec/client_matchers.rb | 151 ++++++++++++++++++ lib/telegram/bot/updates_controller.rb | 2 +- .../bot/updates_controller/rspec_helpers.rb | 10 +- .../bot/rspec/client_matchers_spec.rb | 87 ++++++++++ 9 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 lib/telegram/bot/rspec.rb create mode 100644 lib/telegram/bot/rspec/client_matchers.rb create mode 100644 spec/telegram/bot/rspec/client_matchers_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 2893914..505a1db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - API methods from 2016-10-03 update - Fix typo in module name: CallbackQueyContext -> CallbackQueryContext. - Take `chat` from `message` for callback queries +- RSpec matchers. # 0.8.0 diff --git a/README.md b/README.md index ae6bd38..668ff5b 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,23 @@ There are also some helpers for controller tests. Check out `telegram/bot/updates_controller/rspec_helpers` and `telegram/bot/updates_controller/testing`. +Built-in RSpec matchers will help you to write tests fast: + +```ruby +include Telegram::Bot::RSpec::ClientMatchers # no need if you already use controller herlpers + +expect(&process_update).to send_telegram_message(bot, /msg regexp/, some: :option) +expect(&process_update). + to make_telegram_request(bot, :sendMessage, hash_including(text: 'msg text')) + +# controller specs are even simplier: +describe '#start' do + subject { -> { dispatch_message '/start' } } + it { should respond_with_message(/Hello/) } +end +# See sample app for more examples. +``` + ### Deploying Use `rake telegram:bot:set_webhook` to update webhook url for all configured bots. diff --git a/lib/telegram/bot.rb b/lib/telegram/bot.rb index 72f2400..237f92d 100644 --- a/lib/telegram/bot.rb +++ b/lib/telegram/bot.rb @@ -34,6 +34,7 @@ module Telegram autoload :DebugClient, 'telegram/bot/debug_client' autoload :Initializers, 'telegram/bot/initializers' autoload :Middleware, 'telegram/bot/middleware' + autoload :RSpec, 'telegram/bot/rspec' autoload :UpdatesController, 'telegram/bot/updates_controller' autoload :UpdatesPoller, 'telegram/bot/updates_poller' end diff --git a/lib/telegram/bot/client_stub.rb b/lib/telegram/bot/client_stub.rb index 7269815..f9dc75c 100644 --- a/lib/telegram/bot/client_stub.rb +++ b/lib/telegram/bot/client_stub.rb @@ -43,7 +43,7 @@ module Telegram @requests = Hash.new { |h, k| h[k] = [] } end - def request(action, body) + def request(action, body = {}) requests[action.to_sym] << body end end diff --git a/lib/telegram/bot/rspec.rb b/lib/telegram/bot/rspec.rb new file mode 100644 index 0000000..691eaad --- /dev/null +++ b/lib/telegram/bot/rspec.rb @@ -0,0 +1,7 @@ +module Telegram + module Bot + module RSpec + autoload :ClientMatchers, 'telegram/bot/rspec/client_matchers' + end + end +end diff --git a/lib/telegram/bot/rspec/client_matchers.rb b/lib/telegram/bot/rspec/client_matchers.rb new file mode 100644 index 0000000..e600788 --- /dev/null +++ b/lib/telegram/bot/rspec/client_matchers.rb @@ -0,0 +1,151 @@ +module Telegram + module Bot + module RSpec + # Proxy that uses RSpec::Mocks::ArgListMatcher when it's available. + # Otherwise just performs `#==` match. + # + # Also allows to check argumets with custom block. + class ArgListMatcher + attr_reader :expected, :expected_proc + + def initialize(*args, &block) + @expected_proc = block if block_given? + @expected = + if mocks_matcher? + ::RSpec::Mocks::ArgumentListMatcher.new(*args) + else + args + end + end + + def args_match?(*actual) + if expected_proc + expected_proc[*actual] + true + elsif mocks_matcher? + expected.args_match?(*actual) + else + expected == actual + end + end + + def args + mocks_matcher? ? expected.args : expected + end + + def mocks_matcher? + defined?(::RSpec::Mocks::ArgumentListMatcher) + end + + def to_s + if mocks_matcher? + expected.expected_args.inspect + elsif expected_proc + '(proc matcher)' + else + expected.inspect + end + end + end + + # Matchers to test requests to Telegram API. + # + # Complex matchers requires `rspec-mocks` to be installed. + module ClientMatchers + class MakeTelegramRequest < ::RSpec::Matchers::BuiltIn::BaseMatcher + EXPECTATION_TYPES = { + exactly: :==, + at_most: :>=, + at_least: :<=, + }.freeze + + attr_reader :performed_requests, :description + + def initialize(bot, action, description: nil) + @bot = bot + @action = action + @description = description || "make #{action} telegram request" + exactly(1) + end + + def matches?(proc) # rubocop:disable AbcSize + raise ArgumentError, 'matcher only supports block expectations' unless proc.is_a?(Proc) + original_requests_count = bot.requests[action].count + proc.call + @performed_requests = bot.requests[action].drop(original_requests_count) + @matching_requests_count = performed_requests.count do |request| + !arg_list_matcher || arg_list_matcher.args_match?(request) + end + expectation_method = EXPECTATION_TYPES[expectation_type] + expected_number.public_send(expectation_method, matching_requests_count) + end + + def with(*args, &block) + @arg_list_matcher = ArgListMatcher.new(*args, &block) + self + end + + EXPECTATION_TYPES.each_key do |type| + define_method type do |count| + @expectation_type = type + @expected_number = Integer(count) + self + end + end + + def times + self + end + + def failure_message + "expected to #{base_message}" + end + + def failure_message_when_negated + "expected not to #{base_message}" + end + + def supports_block_expectations? + true + end + + private + + attr_reader :bot, :action, :expectation_type, :expected_number, + :arg_list_matcher, :matching_requests_count + + def base_message + "make #{expectation_type.to_s.tr('_', ' ')} #{expected_number} " \ + "#{bot.inspect}.#{action} requests,".tap do |msg| + msg << " with #{arg_list_matcher}," if arg_list_matcher + msg << " but made #{matching_requests_count}" + if performed_requests + actual_args = performed_requests.map(&:inspect).join(', ') + msg << ", and #{performed_requests.count} with #{actual_args}" + end + end + end + end + + # Check that bot performed request to telegram API: + # + # expect { dispatch_message('Hi!') }. + # to make_telegram_request(bot, :sendMessage). + # with(text: 'Hello!', chat_id: chat_id) + def make_telegram_request(bot, action) + MakeTelegramRequest.new(bot, action) + end + + # Helper for asserting message is sent. Note that options are checked + # with `hash_including`. For strict checks use #make_telegram_request. + def send_telegram_message(bot, text = nil, options = {}) + text = a_string_matching(text) if text.is_a?(Regexp) + options = options.merge(text: text) if text + description = "send telegram message #{text.inspect}" + MakeTelegramRequest.new(bot, :sendMessage, description: description). + with(hash_including(options)) + end + end + end + end +end diff --git a/lib/telegram/bot/updates_controller.rb b/lib/telegram/bot/updates_controller.rb index 1321e6a..8496e5b 100644 --- a/lib/telegram/bot/updates_controller.rb +++ b/lib/telegram/bot/updates_controller.rb @@ -50,7 +50,7 @@ module Telegram # ControllerClass.new(bot, from: telegram_user, chat: telegram_chat). # process(:help, *args) # - class UpdatesController < AbstractController::Base # rubocop:disable ClassLength + class UpdatesController < AbstractController::Base abstract! require 'telegram/bot/updates_controller/session' diff --git a/lib/telegram/bot/updates_controller/rspec_helpers.rb b/lib/telegram/bot/updates_controller/rspec_helpers.rb index 1387119..295c12f 100644 --- a/lib/telegram/bot/updates_controller/rspec_helpers.rb +++ b/lib/telegram/bot/updates_controller/rspec_helpers.rb @@ -14,11 +14,14 @@ RSpec.shared_context 'telegram/bot/updates_controller' do let(:bot_name) { 'bot' } let(:session) { controller.send(:session) } + include Telegram::Bot::RSpec::ClientMatchers + def dispatch(bot = self.bot, update = self.update) controller.dispatch_again(bot, update) end - def dispatch_message(text, options = {}) + def dispatch_message(text, options = nil) + options ||= respond_to?(:default_message_options) ? default_message_options : {} update = build_update :message, options.merge(text: text) dispatch bot, update end @@ -34,4 +37,9 @@ RSpec.shared_context 'telegram/bot/updates_controller' do else input end end + + # Matcher to check response. Make sure to define `let(:chat_id)`. + def respond_with_message(expected) + send_telegram_message(bot, expected, chat_id: chat_id) + end end diff --git a/spec/telegram/bot/rspec/client_matchers_spec.rb b/spec/telegram/bot/rspec/client_matchers_spec.rb new file mode 100644 index 0000000..9292190 --- /dev/null +++ b/spec/telegram/bot/rspec/client_matchers_spec.rb @@ -0,0 +1,87 @@ +RSpec.describe Telegram::Bot::RSpec::ClientMatchers do + let(:bot) { Telegram::Bot::ClientStub.new('TestBot') } + let(:other_bot) { Telegram::Bot::ClientStub.new('OtherTestBot') } + + include described_class + + describe '#make_telegram_request' do + context 'without args' do + it 'works for matching' do + block = ->(*) { bot.send_message(text: 'test') } + expect(&block).to make_telegram_request(bot, :sendMessage) + expect { 3.times(&block) }.to make_telegram_request(bot, :sendMessage).exactly(3).times + expect { 3.times(&block) }.to make_telegram_request(bot, :sendMessage).at_least(2).times + expect { 3.times(&block) }.to make_telegram_request(bot, :sendMessage).at_most(4).times + end + + it 'works for not-matching' do + block = ->(*) { bot.get_me } + expect {}.to_not make_telegram_request(bot, :getMe) + expect { other_bot.get_me }.to_not make_telegram_request(bot, :getMe) + expect { 3.times(&block) }.to_not make_telegram_request(bot, :getMe) + expect { 3.times(&block) }.to_not make_telegram_request(bot, :getMe).exactly(2).times + expect { 3.times(&block) }.to_not make_telegram_request(bot, :getMe).exactly(4).times + expect { 3.times(&block) }.to_not make_telegram_request(bot, :getMe).at_least(4).times + expect { 3.times(&block) }.to_not make_telegram_request(bot, :getMe).at_most(2).times + end + end + + context 'with args' do + it 'works for exact matching' do + args = {text: 'test', parseMode: :Markdown} + block = ->(*) { bot.send_message(args) } + expect(&block).to make_telegram_request(bot, :sendMessage).with(args) + expect(&block).to_not make_telegram_request(bot, :sendMessage).with(args.except(:text)) + expect { 3.times(&block) }.to make_telegram_request(bot, :sendMessage). + with(args).exactly(3).times + end + + it 'works for block matchers' do + args = {text: 'test', parseMode: :Markdown} + block = ->(*) { bot.send_message(args) } + expect(&block).to make_telegram_request(bot, :sendMessage). + with { |actual| expect(actual).to eq(args) } + # It ignores block's result! Custom expectations must be used inside block. + expect(&block).to make_telegram_request(bot, :sendMessage). + with { |actual| actual == {} } + expect do + expect(&block).to_not make_telegram_request(bot, :sendMessage). + with { |actual| expect(actual).to eq({}) } + end.to raise_error RSpec::Expectations::ExpectationNotMetError + n = -1 + expect { 3.times { |i| bot.send_message text: i } }. + to make_telegram_request(bot, :sendMessage).exactly(3).times. + with { |actual| expect(actual).to eq(text: n += 1) } + end + + it 'works for RSpec::Mocks matchers' do + args = {text: 'test', parseMode: :Markdown} + block = ->(*) { bot.send_message(args) } + expect(&block).to make_telegram_request(bot, :sendMessage).with(hash_including(args)) + expect(&block).to make_telegram_request(bot, :sendMessage).with( + text: a_string_matching(/est$/), + parseMode: :Markdown, + ) + end + end + end + + describe '#send_telegram_message' do + let(:args) { {text: 'test', parseMode: :Markdown} } + let(:block) { ->(*) { bot.send_message(args) } } + + it 'works for matching' do + expect(&block).to send_telegram_message(bot, 'test') + expect(&block).to send_telegram_message(bot, /^tes/) + expect(&block).to send_telegram_message(bot, 'test', parseMode: :Markdown) + expect(&block).to send_telegram_message(bot, a_string_including('es')) + end + + it 'works for not-matching' do + expect(&block).to_not send_telegram_message(bot, 'test!') + expect(&block).to_not send_telegram_message(bot, /tes$/) + expect(&block).to_not send_telegram_message(bot, 'test', parseMode: :HTML) + expect(&block).to_not send_telegram_message(bot, a_string_including('smth')) + end + end +end