Ruby
Ruboty

Ruboty | Testable Ruboty 。 賛否はさておきテスト付きの Ruboty Handler を作成してみた。 #ruboty

More than 3 years have passed since last update.

Ruboty | Testable Ruboty 。 賛否はさておきテスト付きの Ruboty Handler を作成してみた。 #ruboty

概要

Ruboty のテストについて。

Testable Hubot - TDDでテストを書きながらbotを作るの流れを受けてのエントリーです。

前段

2014/10/28、私は1つ目の自作 Ruboty Handler である 「ruboty-qiita_scouter」 を公開しました。
(実際のところ Handler と Action のセットで 1つの機能になるのですが、
現状 Ruboty には 「Handler + Action」 を表す概念・名称がなさそうなので便宜上 「Handler」 として説明します。)

はじめて Handler を実装するにあたって、 Ruboty のテスト手法はどうなっているのだろうと思い、
GitHub を漁って既存の Ruboty Handler を大量に確認しました。

GitHubで「ruboty-」を検索した結果は以下
https://github.com/search?q=ruboty-&type=Repositories&utf8=%E2%9C%93

結果、テスト付きの Ruboty Handler はほとんどありませんでした。
(確認できたのは、 Ruboty の作者さんの Handler の一部。例えば ruboty-github

これは、 Ruboty で作成する Handler には

  • APIの中継
  • 平易なテキスト変換処理

のなど種類のものが多く、

前者の場合は、テストを書いてもモックだらけで手間の割に効果が薄い。
後者の場合は、テストを書くほどでもない。

また、テストを書かない理由には

  • ある程度 Ruboty の内部実装を把握していないとテスト方法すら分からない

  • 現状で、テスト付き Handler のサンプルが少ないことも敷居を上げている

  • 他のプログラムに比べて、機能拡張・修正をしたり継続保守する頻度が低い

  • 日常的に運用するので、エラーがあればすぐ気付くし、
    エラーになったところで致命的ではないものが多く、すぐに直せばいい

のような理由もあると思います。

現状、 Ruboty の Handler を作成しておられる方は、
上記の理由等によりテスト作成のコストがメリットを下回っていると判断しているのかな、
と想像してます。

コスト的なメリットが釣り合うかどうか分かりませんが、テスト付きで Handler を作成したため、
共有のため当記事をまとめることにしました。

テストのメリット

前段で説明した、テストのデメリットも踏まえつつ、メリットがあるとしたら

  • リファクタリングしやすくなる
  • README や Ruboty の description には書ききれない細かな仕様をテストケースで明示できる
  • コードリーディングの助力になる

これらは主メンテナ自身へのメリットだけではなく、利用者にとって

  • プルリク を送るための敷居が下がる
  • 分からないことがあった場合に調べやすくなる

などのメリットがありそうです。

少なくとも、自分は OSS 関連で何か分からないことがあったら、
公式ドキュメントを見て、
それでもわからなければ、GitHub の README を見て、
それでもわからなければ、プロダクトコードとテストコードをセットで見ます。
この時にテストコードがないと、理解に必要な時間が増えます。

前提知識

テストも含めた Ruboty Handler の作成フローの一例

  • 作りたい Handler の機能を紹介する Qiita 記事をはじめに作成
  • ジェネレータでプロダクトコードのテンプレートを生成します
    • @blockgiven さん作の ruboty-gen gem を利用します
      • bundle gem + Handler と Action のコードテンプレートの生成
      • 詳しくは上記 URL の README を参照
  • ジェネレータでテストコードのテンプレートを生成します
    • 自作の rspec_piccolo gem を利用します
      • RSpecを利用したパラメタライズドなテストのひな形を生成します
      • 今回は、 プロダクトコードを ruboty-gen で出力しているので利用していませんが、 プロダクトコードのひな形生成機能もあります
  • Action のテストコードを作成します
  • Action の実装をします
  • Handler のテストコードを作成します
  • Handler の実装をします
  • シェルアダプタで、手動で動作確認をします
  • RuboCop の警告に対応
  • Travis CI の設定を追加
    • どのバージョンの Ruby で動くのか知らせる意味がある
  • Coveralls の設定を追加
  • リリース
  • RubyGems の Badge の設定を追加
    • 利用者の利便性のため。最新版がすぐわかることと、リンク先の RubyGems に簡単に移動できる

具体的なテストコード

テストには RSpec 2.14 を利用しています。
rspec_piccolo という自作ツールで生成したテンプレートをもとにしたテストコードなので、
少し違和感のある内容かもしれません。
自分は、これで書くのが楽なので愛用してます。

Handler

Handler は Action への処理の振り分けが責務のため、
正規表現に対して想定の Action を呼び出していることを観点としてテストしました。
※DRY なテストコードにしているため、嫌いな人は嫌いなスタイルだと思いますが
 そこは記事の論点から外れるのでスルーで。

モック

ポイントとしては、 Action のテストは Action に任せたいので
allow_any_instance_of でモックしています。
本当は Handler の初期化時点で Action を inject 出来るようになっている方が良いのかもしれませんが、
Ruboty 本体の領域なので特に手を出さず。

テストケース

ruboty-qiita_scouter の仕様がシンプルなので、
正規表現に一致するパターン、一致しないパターンをテストしています。
正規表現に一致するパターンが複数あったり、呼び出す Action が分岐する場合には
テストケースが増えることになります。

robot.receive を呼び出してテスト

本当は Handler 単体でテストをできるとよかったのですが、
正規表現と Action の呼び出しを検証するには、 Ruboty 全体を通したテストを実行する
必要があったため、 robot.receive メソッドでのテストをしています。

テストコード

# encoding: utf-8
require 'spec_helper'
require 'ruboty/handlers/qiita_scouter'
require 'ruboty/qiita_scouter/actions/analyze'

# rubocop:disable LineLength, UnusedMethodArgument
describe Ruboty::Handlers::QiitaScouter do
  context :analyze do
    let(:robot) do
      Ruboty::Robot.new
    end

    let(:analyze) do
      Ruboty::QiitaScouter::Actions::Analyze
    end

    cases = [
      {
        case_no: 1,
        case_title: 'exist id case',
        body: 'ruboty qiita scouter tbpgr',
        expected: 'expected message',
        hit: true
      },
      {
        case_no: 2,
        case_title: 'not hit message case',
        body: 'ruboty not_exist message',
        expected: 'expected message',
        hit: false
      }
    ]

    cases.each do |c|
      it "|case_no=#{c[:case_no]}|case_title=#{c[:case_title]}" do
        begin
          case_before c

          # -- given --
          allow_any_instance_of(analyze).to receive(:analyze).and_return(c[:expected])

          # -- then --
          if c[:hit]
            Ruboty.logger.should_receive(:info).with(c[:expected])
          else
            Ruboty.logger.should_not_receive(:info)
          end

          # -- when --
          robot.receive(body: c[:body], from: 'sender', to: 'channel')
        ensure
          case_after c
        end
      end

      def case_before(_c)
        # implement each case before
      end

      def case_after(_c)
        # implement each case after
      end
    end
  end
end
# rubocop:enable LineLength, UnusedMethodArgument

Action

モック

allow_any_instance_of で qiita_scouter gem をモックします。
外部機能を利用する場合は、基本モックでよいかな、と思っています。
品質は外部機能側で確保してもらう。

こちらも Handler 同様 Action の初期化時点で
外部API利用のためのクラスを inject 出来るようになっている方が良いのかもしれませんが、
Ruboty 本体の領域なので特に手を出さず。

テストケース

あまり良くない作法なのですが、 private メソッド analyze に対してテストしています。
本来テスト対象であるべき public の call メソッドに ruboby と連携する処理 ( ruboty.reply(analyze) ) だけを記述しました。

Ruboty::QiitaScouter::Actions::Analyze において、
QiitaScouter gem から取得した戦闘力の数値配列を文字列フォーマットに変換するのが
テストしたい処理です。

しかし ruboty.reply(analyze) の戻りに対してテストをすると
ruboty に干渉する必要が出てきて、テストが無駄に複雑になります。
ruboty.reply が正しく動作するのは、当たり前なのでテストする必要もないと思いました。

結果、analyze メソッドを作成し、ただの文字列に対してテストを行うことにしました。

テストコード

# encoding: utf-8
require 'spec_helper'
require 'qiita_scouter_core'
require 'ruboty/qiita_scouter/actions/analyze'

# rubocop:disable LineLength, UnusedMethodArgument
describe Ruboty::QiitaScouter::Actions::Analyze do
  context :analyze do
    let(:message) do
      # Dummy Message
      class Message < Hash
        def reply(message)
          message
        end
      end
      Message.new
    end

    let(:qiita_scouter) do
      ::QiitaScouter::Core
    end

    cases = [
      {
        case_no: 1,
        case_title: 'exist user case',
        id: 'tbpgr',
        power_levels: [600, 200, 300, 100],
        expected: "ユーザー名: tbpgr 戦闘力: 600 攻撃力: 200 知力: 300 すばやさ: 100"
      },
      {
        case_no: 2,
        case_title: 'not exist user case',
        id: 'tbpgr',
        expected: 'Failed by NoMethodError',
        expect_error: true
      }
    ]

    cases.each do |c|
      it "|case_no=#{c[:case_no]}|case_title=#{c[:case_title]}" do
        begin
          case_before c

          # -- given --
          message[:id] = c[:id]
          if c[:expect_error]
            allow_any_instance_of(qiita_scouter).to receive(:analyze).with(c[:id]).and_raise(NoMethodError)
          else
            allow_any_instance_of(qiita_scouter).to receive(:analyze).with(c[:id]).and_return(c[:power_levels])
          end
          action = Ruboty::QiitaScouter::Actions::Analyze.new(message)

          # -- when --
          actual = action.send(:analyze)

          # -- then --
          expect(actual).to eq(c[:expected])
        ensure
          case_after c
        end
      end

      def case_before(_c)
        # implement each case before
      end

      def case_after(_c)
        # implement each case after
      end
    end
  end
end
# rubocop:enable LineLength, UnusedMethodArgument

ちょっとした提案

Handler + Action 構成のすすめ

大量の Ruboty Handler をざっとコードリーディングしましたが、構成がばらばらでした。

  • Handler に全てを記述するケース。( Fat Handler とでも言うべきか )
  • /lib/ruboty/some_name1/some_name2.rb に Ruboty::SomeName1::SomeName2 などを作成するケース
  • Handler + Actions (ruboty-github のようなスタイル) のケース

そこで、 Handler - Action 構成をおすすめしたいです。

理由は以下になります。

  • 構成の統一によりコードリーディングの負荷が下がる
  • 自動化などエコシステムが発展する際に、構成が統一されていたほうが何かと便利
  • Ruboty の作者さんが Handler と Action という概念を用意しているし、
    Handler にすべてを詰め込むのは名前と処理が一致しない。
    「処理が小さければ Controller に Model の処理を丸ごと詰め込むの?」と問われれば詰め込まない。

ruboty-gen を利用すれば、 Handler + Action 構成のコードの生成は楽です。
また、今回紹介したような責務に応じたテスト内容にすることも楽になります。

Handler + Action の正式名称

私が書いた他の Ruboty の記事にも書いているのですが、 Ruboty の ( Handler + Action ) を作成することに対する名詞が欲しい。
共通の言葉がないとコミュニケーションしにくい。
案は Parts (部品 のメタファ)。良いのがあれば、そちらでも。
Ruboty Plugin とかだと、 Adapter や Brain の 拡張と区別がつかないので NG 。

やりたいと思っていること

  1. README のテンプレート生成ツールの作成
    • bundle gem が生成する README の内容以外にも、テンプレートしたい項目がある。
      例えば ENV とか Dependency とか。
      自分がテンプレート化したいと思っている内容は ruboty-qiita_scouter GitHub README のようなフォーマット
  2. Ruboty Handler 向けの Qiita の記事テンプレート生成ツールの作成
  3. 1, 2 のツールと ruboty-gen, rspec_piccolo を統合して、内部 DSL で設定を行い、一気に全ての自動生成を行うツール
    • 実行するとプロダクトコード・テストコード・README・Markdownのブログエントリのテンプレートが生成される

参照