Ruby
mocha
minitest
bot
Ruboty

MochaとMinitestでRubotyの自動テスト

Minitestの勉強も兼ねてRubotyの自動テストを作ってみました。
MinitestもRubotyの自動テストについても情報が少なく四苦八苦しましたが、
モックライブラリMochaを使ってやりたいことが実現できたので、やったことを記録します。

Rubotyをどうテストするのか、Mochaの使い方がメインの話です。
Ruboty自体やその構成要素のHandler、Actionについての説明は以下が詳しいです。

なおテストコードは個人的な好みでMinitestのSpecスタイルで書いていますが、基本的にはUnitTestスタイルでも同じことができるはずです。

:golf: ゴールと方針

  • Handler
    • Handlerの呼び出しはRuboty::Robot#receive経由で呼び出す。
    • Actionへの振り分けが責務なので、Actionが呼ばれること/呼ばれないことを確認する。
    • Actionが呼ばれる場合
      • 受け取ったメッセージの正規表現マッチを使用することが多いので、正規表現マッチが想定通り取得できていることも確認したい。
      • :arrow_right: 呼び出しの検証にはモックを使う。
      • :arrow_right: 正規表現マッチはAction.newの引数のmessageに格納されているので、Action.newをスタブにして引数を取り出す。
    • Actionが呼ばれない場合
      • :arrow_right: Action#callをモックにして、呼び出されないことを検証する。
  • Action
    • Handlerから受け取ったRuboty::Messageに格納している正規表現マッチに基づいた処理分岐ができることを確認する。
      • :arrow_right: Ruboty::Message#[]をスタブ化して、Actionのインプットとなる正規表現マッチを自由に設定できるようにする。
      • :arrow_right: Ruboty::Message#replyをモック化し、Actionの結果検証する。

:scroll: テスト対象

テスト対象のソースはruboty-genで生成できるソースを元に、テストのバリエーションを増やすために少し手を加えたものを使用します。

# Usage: ruboty-gen gem アプリ名 アクション名
ruboty-gen gem minitest hello

Handler

lib/ruboty/handlers/minitest.rb
require 'ruboty/minitest/actions/hello'

module Ruboty
  module Handlers
    # handler
    class Minitest < Base
      on(/minitest +(?<hello>hello) +(?<target>.+)/, name: 'hello', description: 'helloと言う')

      def hello(message)
        Ruboty::Minitest::Actions::Hello.new(message).call
      end
    end
  end
end
  • 正規表現部分を変更。

Action

lib/ruboty/minitest/actions/hello.rb
module Ruboty
  module Minitest
    module Actions
      # hello
      class Hello < Ruboty::Actions::Base
        def call
          if message[:target] == 'me'
            message.reply(message[:hello])
          else
            message.reply("#{message[:hello]} #{message[:target]}")
          end
        end
      end
    end
  end
end
  • 正規表現マッチで分岐するように変更。

:pencil: テストコード

Handler Test

test/ruboty/handlers/minitest_test.rb
require 'test_helper'

describe Ruboty::Handlers::Minitest do
  let(:robot) { Ruboty::Robot.new }

  before do
  end

  describe '#hello' do
    let(:action) { Ruboty::Minitest::Actions::Hello }

    # Action#callの代わりとなるmockを作る(1回呼ばれるかを検証)
    let(:mock_action_call) { mock().tap { |mock| mock.expects(:call).once } }

    it 'Actionが呼ばれることの検証' do
      # Action.newの引数のmessageを取り出して、正規表現のマッチが正しいかを検証する
      action
        .stubs(:new)
        .with do |message|
          message[:hello].must_equal 'hello'
          message[:target].must_equal 'world'
        end
        .returns(mock_action_call)

      robot.receive(body: 'ruboty minitest hello world', from: 'sender', to: 'channel')
    end

    it 'Actionが呼ばれないことの検証' do
      # Action#callをmock化して、呼ばれないことを検証
      action.any_instance.expects(:call).never
      robot.receive(body: 'ruboty minitest never', from: 'sender', to: 'channel')
    end
  end
end
  • 「Actionが呼ばれることの検証」でやっていること
    • Handlerはメッセージがマッチした場合、 Hello.new(message).call を実行する。
    • 検証したいことは以下の通り。
      • Helloをnewした後にcallが呼ばれること。
      • Hello.newの引数のmessageに格納されている正規表現マッチが想定通りか。
    • Hello.newは引数以外は検証する必要がないためスタブ化してしまい、引数を.withで抜き出すことで、上記2を検証。
    • Hello.newの後のcallが呼ばれることは検証したいため、Hello.newのスタブの戻り値として、モックを指定し、onceで上記1を検証。
    • なお、Mochaのmockはテスト終了時に自動で検証を実行するため、verifyなどを呼ぶ必要はない。
  • 「Actionが呼ばれないことの検証」でやっていること
    • Hello#callが呼ばれないことだけ検証したいので、any_instanceで#callをモック化。neverで一度も呼ばれていないことを検証。

Action Test

test/ruboty/minitest/actions/hello_test.rb
require 'test_helper'

describe Ruboty::Minitest::Actions::Hello do
  subject { Ruboty::Minitest::Actions::Hello.new(mock_message) }

  let(:mock_message) { mock }

  before do
  end

  describe '#call' do
    it '<target>が"me"の場合、helloだけ返す' do
      # スタブで<hello>と<target>を設定
      mock_message.stubs(:[]).with(:hello).returns('hello')
      mock_message.stubs(:[]).with(:target).returns('me')
      # モックでreplyの引数を検証
      mock_message.expects(:reply).with('hello').once

      subject.call
    end

    it '<target>が"me"以外の場合、hello worldを返す' do
      # スタブのreturnsをカンマ区切りにした場合、アクセスした順番に値を返す
      mock_message.stubs(:[]).returns('world', 'hello', 'world')
      # モックでreplyの引数を検証
      mock_message.expects(:reply).with('hello world').once

      subject.call
    end
  end
end
  • 「targetが"me"の場合、helloだけ返す」でやっていること
    • Action#callでは処理のインプットとして、Action.newした際の引数のmessageを使用するため、newに渡すモックを作る。
    • インプットのために、スタブとして[]メソッドを用意する。
      • message[:hello]では'hello'を返す。
      • message[:target]では'me'を返す。
    • Action#callで最終的に実行されるmessage.replyを検証するために、モックとしてreplyメソッドを定義する。
      • with()で引数を検証。
      • onceで呼び出し回数を検証。
  • 「targetが"me"以外の場合、hello worldを返す」でやっていること
    • 基本的には"me"の時と同じ。
    • 備忘のために、スタブでのreturnsの指定の仕方を変えてみた。
      • withで引数の条件を指定するのではなく、カンマ区切りで複数指定することで[]を呼び出した順番に値を返している。

:globe_with_meridians: 参考

:bulb: まとめ

Mochaがとてもとても優秀。
MinitestやRubotyに限ったものでもないので、どんどん使っていきたい。

GitHubにソース一式あげています。
zeero/work-ruboty-minitest