11
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

すっかり時代に乗り遅れてるけど fluentd が必要になったので test も書きながら output プラグインを書いているその記録

Last updated at Posted at 2016-01-27

前置き

Goal

fluentd でメールから redmine を更新するプラグインを TDD で作る

シナリオ

  • gem スケルトン作成
  • テスト書く
  • プラグイン作る
  • gem をビルドする
  • td-agent にインストールする

作る

gem スケルトン作成

以下のコマンドですぐ作れる。gem install bundler は事前に実行しておく。

$ bundle gem --test=minitest fluent-plugin-out_redmine
$ cd fluent-plugin-out_redmine
$ git commit -m  "initial commit"

version を消して、rake (test) が通るようにする。fluentd は version があるとエラーになるので消さないといけない。他に fluentd 特有のポイントとして、プラグインの名前は out_foo などだが、プラグインのクラス名は FooOutput と逆になる。ネームスペースも Fluent::FooOutput になる。Fluent::Plugin::FooOutput とはならない。ここはエラーになるか試してないのでお作法程度のルールなのかどうか不明。

ついでに minitest がカラーで表示できるようにする。
追記:fluentd が gemspec に入ってないので追記が必要。spec.add_development_dependency "fluentd"

diff --git a/fluent-plugin-out_redmine.gemspec b/fluent-plugin-out_redmine.gemspec
index c22284c..219b4a8 100644
--- a/fluent-plugin-out_redmine.gemspec
+++ b/fluent-plugin-out_redmine.gemspec
@@ -1,11 +1,10 @@
 # coding: utf-8
 lib = File.expand_path('../lib', __FILE__)
 $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
-require 'fluent/plugin/out_redmine/version'

 Gem::Specification.new do |spec|
   spec.name          = "fluent-plugin-out_redmine"
-  spec.version       = Fluent::Plugin::OutRedmine::VERSION
+  spec.version       = '0.0.1'
   spec.authors       = ["Tooru Nakai"]

@@ -29,4 +28,5 @@ Gem::Specification.new do |spec|
   spec.add_development_dependency "bundler", "~> 1.11"
   spec.add_development_dependency "rake", "~> 10.0"
   spec.add_development_dependency "minitest", "~> 5.0"
+  spec.add_development_dependency "minitest-reporters"
 end
diff --git a/lib/fluent/plugin/out_redmine.rb b/lib/fluent/plugin/out_redmine.rb
index a50ffd5..e0c8c03 100644
--- a/lib/fluent/plugin/out_redmine.rb
+++ b/lib/fluent/plugin/out_redmine.rb
@@ -1,5 +1,3 @@
-require "fluent/plugin/out_redmine/version"
-
 module Fluent
   module Plugin
     module OutRedmine
diff --git a/lib/fluent/plugin/out_redmine/version.rb b/lib/fluent/plugin/out_redmine/version.rb
deleted file mode 100644
index 0046d6f..b7cd973 100644
--- a/test/fluent/plugin/out_redmine_test.rb
+++ b/test/fluent/plugin/out_redmine_test.rb
@@ -1,11 +1,7 @@
 require 'test_helper'

 class Fluent::Plugin::OutRedmineTest < Minitest::Test
-  def test_that_it_has_a_version_number
-    refute_nil ::Fluent::Plugin::OutRedmine::VERSION
-  end
-
   def test_it_does_something_useful
-    assert false
+    assert true
   end
 end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 04156fa..7f395ee 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -2,3 +2,8 @@ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
 require 'fluent/plugin/out_redmine'

 require 'minitest/autorun'
+
+# http://chriskottom.com/blog/2014/06/dress-up-your-minitest-output/
+require 'minitest/reporters'
+reporter_options = { color: true }
+Minitest::Reporters.use! [Minitest::Reporters::DefaultReporter.new(reporter_options)]

テストを書く

必須

out_redmine_test.rb
  def setup
    Fluent::Test.setup
  end

来週続きを書くであろう自分のためにシナリオを書いておく。

out_redmine_test.rb
# Feature:
#  - user configurable parameter: site="URL of redmine", key="Redmine API Key"
#  - params from input: path="path to raw mail message file"
#  - invoke rdm-mailhandler.rb

Fluent::Test::BufferedOutputTestDriver.new に自作プラグインのクラスを渡してインスタンスを作ると、自作プラグインにアクセスできるようになる。Input とかは InputTestDriver とかになるので、迷ったら本体のテストを読む。どのプラグインも同じことをやっているのですぐわかる。例えば https://github.com/fluent/fluentd/blob/master/test/plugin/test_out_file.rb

まずは config を取るところか始める。config は要するに td-agent.conf で設定されるプラグインに渡すパラメータのこと。プラグインの中ではそのままインスタンス変数となる。ということで全景はこちら。

注:ここでクラスの名前もライブラリ側と合わせるために Fluent::Plugin::OutRedmineTest から Fluent::RedmineOutputTest に書き換える。

out_redmine_test.rb
require 'test_helper'

# Feature:
#  - user configurable parameter: site="URL of redmine", key="Redmine API Key"
#  - params from input: path="path to raw mail message file"
#  - invoke rdm-mailhandler.rb

class Fluent::OutRedmineTest < Minitest::Test
  def setup
    Fluent::Test.setup
  end

  CONFIG = %[
    site http://redmine.rm-example.com
    key a-api-key
  ]

  def create_driver(conf=CONFIG)
    Fluent::Test::BufferedOutputTestDriver.new(Fluent::RedmineOutput).configure(conf)
  end

  def test_configure
    driver = create_driver
    assert_equal 'http://redmine.rm-example.com', driver.instance.site
    assert_equal 'a-api-key', driver.instance.key
  end
end

訂正:tag を config に書いていたが、test 環境では @tag="test" になるので削除。
ということでテスト実行。rake

Error:
Fluent::Plugin::OutRedmineTest#test_configure:
NameError: uninitialized constant Fluent::Test

1 tests, 0 assertions, 0 failures, 1 errors, 0 skips

fluentd を test 側で require していないので追加。require する順番に注意。

diff --git a/test/test_helper.rb b/test/test_helper.rb
index 7f395ee..408eaa6 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,7 +1,11 @@
 $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
-require 'fluent/plugin/out_redmine'

 require 'minitest/autorun'
+require 'fluent/log'
+require 'fluent/test'
+
+# my plugin must be placed after loading 'fluent'
+require 'fluent/plugin/out_redmine'

 # http://chriskottom.com/blog/2014/06/dress-up-your-minitest-output/
 require 'minitest/reporters'

テスト。

fluent-plugin-out_redmine/lib/fluent/plugin/out_redmine.rb:2:in `<module:Fluent>': Plugin is not a module (TypeError)

ようやくライブラリに手をいれることになる。bundler が作ったスケルトンには Plugin があったり クラス名が OutRedmine だったりと名前が想定と違う。名前を書き換えて本家からテンプレをコピー。
http://docs.fluentd.org/articles/plugin-development

Fluent::Plugin.register_output にて名前登録もやっておく。ここで 'out_redmine' ではなく 'redmine' にする。

lib/fluent/plugin/out_redmine.rb
require 'fluentd'

module Fluent
  class RedineOutput < BufferedOutput
    # First, register the plugin. NAME is the name of this plugin
    # and identifies the plugin in the configuration file.
    Fluent::Plugin.register_output('redmine', self)

    # config_param defines a parameter. You can refer a parameter via @path instance variable
    # Without :default, a parameter is required.
    config_param :path, :string

    # This method is called before starting.
    # 'conf' is a Hash that includes configuration parameters.
    # If the configuration is invalid, raise Fluent::ConfigError.
    def configure(conf)
      super

      # You can also refer raw parameter via conf[name].
      @path = conf['path']
    end

    # This method is called when starting.
    # Open sockets or files here.
    def start
      super
    end

    # This method is called when shutting down.
    # Shutdown the thread and close sockets or files here.
    def shutdown
      super
    end

    # This method is called when an event reaches to Fluentd.
    # Convert the event to a raw string.
    def format(tag, time, record)
      [tag, time, record].to_json + "\n"
      ## Alternatively, use msgpack to serialize the object.
      # [tag, time, record].to_msgpack
    end

    # This method is called every flush interval. Write the buffer chunk
    # to files or databases here.
    # 'chunk' is a buffer chunk that includes multiple formatted
    # events. You can use 'data = chunk.read' to get all events and
    # 'chunk.open {|io| ... }' to get IO objects.
    #
    # NOTE! This method is called by internal thread, not Fluentd's main thread. So IO wait doesn't affect other plugins.
    def write(chunk)
      data = chunk.read
      print data
    end

    ## Optionally, you can use chunk.msgpack_each to deserialize objects.
    #def write(chunk)
    #  chunk.msgpack_each {|(tag,time,record)|
    #  }
    #end
  end
end

ではそれっぽくなったところで、rake

Error:
Fluent::RedmineOutputTest#test_configure:
Fluent::ConfigError: 'path' parameter is required

この path はテンプレのコードに書かれていたもの。削除して必要な config パラメータを設定。

lib/fluent/plugin/out_redmine.rb
    # config_param defines a parameter. You can refer a parameter via @path instance variable
    # Without :default, a parameter is required.
    config_param :site, :string
    config_param :key, :string

ここまで書いて気づいたが、minitest ではなく unit test を使わないといけなかったのでは・・・

でもまあ動いているので...

次は本丸。Input からデータを受け取り、それを処理する Output プラグインとしてのコードを書く。Fluentd の動きを簡単にまとめると、Input プラグインでは、router.emit() にハッシュを渡すコードを書くことになっている。emit されたデータが Fluentd にわたって、必要に応じて Output プラグインに渡される。この流れをテストの中に書けば良い。

テストの書き方はおおまかに、driver.emit で Input 側でやる入力をシミュレートし、driver.run することで Output プラグインの write をコールする。driver.run は write の実行結果を返すのでそれを評価すれば Output プラグインをテストできることになる。詳細はこちら。https://github.com/fluent/fluentd/blob/master/lib/fluent/test/output_test.rb

ここでそもそも emit されたデータが Output プラグインに渡っているかが気になるところ。これは driver.expect_format というのが用意されているので、それを使う。

test/fluent/plugin/out_redmine_test.rb
  def test_format
    driver = create_driver

    time = Time.parse("2011-01-02 13:14:15 UTC").to_i
    input_data = {"path"=>"/path/to/mail"}
    # simulate router.emit from Input Plugin
    driver.emit(input_data , time)

    driver.expect_format ["test", time.to_i,{"path":"/path/to/mail"}].to_msgpack

    driver.run
  end

rake

Error:
Fluent::RedmineOutputTest#test_format:
Test::Unit::AssertionFailedError: <"\x93\xA4test\xCEM z'\x81\xA4path\xAD/path/to/mail">("ASCII-8BIT") expected but was
<"[\"test\",1293974055,{\"path\":\"/path/to/mail\"}]\n">("UTF-8").

最初のテンプレでは to_json を使っているので、msgpack に変更する。

    def format(tag, time, record)
      # [tag, time, record].to_json + "\n"
      ## Alternatively, use msgpack to serialize the object.
      [tag, time, record].to_msgpack
    end

これで test_format 通過。続いて rdm-mailhandler.rb を使って redmine を更新する。といっても今のところは system でこのスクリプトをコールするだけの実装。

どうやってテストするか。

out_stdout とか out_file とか標準プラグインは write を実行したらリアルなアウトプット(標準出力とかファイルへの出力)がある。つまり driver.run してから結果をチェックすることができる。でも今回の実装は system("/path/to/rdm-mailhandler.rb") するだけで、結果をエミュレートするのはかなり大変。ということで、こいつをコールする redmine_submit メソッドを作り、そいつに想定したメールファイルのパスが渡されていれば、OKとする。

minitest の stub を使ってみた。こちらを参考に。http://blog.willnet.in/entry/2012/12/05/004010

  def test_redmine_submit
    driver = create_driver
    mail_path = "/path/to/mail"
    input_data = {"path"=>mail_path}

    # MiniTest::Mock.new.expect(method_name, retval, args in array)
    mock_method = MiniTest::Mock.new.expect(:call, true, [mail_path])

    driver.instance.stub(:redmine_submit, mock_method) do
      driver.emit(input_data)
      driver.run
    end

    # Mock.verify returns true if the method was called
    assert_equal true, mock_method.verify
  end

この stub の書き方だと本体にもきちんと redmine_submit が宣言されている必要がある。なのでテストを実行すると

Error:
Fluent::RedmineOutputTest#test_redmine_submit:
NameError: undefined method `redmine_submit' for class `Fluent::RedmineOutput'

となる。stub するので空のメソッドを追加しておく。

lib/fluent/plugin/out_redmine.rb
    def redmine_submit(mail_path)
    end

で、テストするとまだ呼び出してないので・・・

Error:
Fluent::RedmineOutputTest#test_redmine_submit:
MockExpectationError: expected call("/path/to/mail") => true

コールされてないよーとのこと。ここでようやく Fluentd output プラグインに手を入れる。

lib/fluent/plugin/out_redmine.rb
    def write(chunk)
      chunk.msgpack_each {|(tag,time,record)|
        redmine_submit(record["path"])
      }
    end

rake

Finished tests in 1.043659s, 2.8745 tests/s, 2.8745 assertions/s.

redmine_submit の実装はさくっと。

lib/fluent/plugin/out_redmine.rb
    def redmine_submit(mail_path)
      handler="/path/to/rdm-mailhandler.rb"
      system("#{handler} --url #{@site} --key #{@key}")
    end

gem をビルドする

$ rake build

で pkg ディレクトリの下にファイルができる。

td-agent にインストールする

/usr/lib64/fluent/ruby/bin/fluent-gem install /path/to/file.gem
11
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?