Turnipのコードリーディング

自分用メモ。

Feature specを書くときに使う[Turnip](https://github.com/jnicklas/turnip)はとてもシンプルで、
Cucumberのようにフォーマッターとか特に提供されていないんだけど、これだけシンプルなら自分で簡単に作れそう。

## RSpecの枠組みに乗せるところ

https://github.com/jnicklas/turnip/blob/master/lib/turnip/rspec.rb

指定されたfeatureファイルをもとに `describe` を作っている。

```ruby
  def run(feature_file)
    feature = Turnip::Builder.build(feature_file)

    return nil if feature.nil?

    instance_eval <<-EOS, feature_file, feature.line
      context = ::RSpec.describe feature.name, feature.metadata_hash
      run_scenario_group(context, feature, feature_file)
    EOS
  end

```

run_scenario_group のなかで、個々のBackground/Scenarioループを回す。

* BackgroundはRSpecのbefore
* Scenarioはdescribe、step群の実行はit
  * Scenarioのメタデータがdescribeの第２引数（メタデータハッシュ）に渡されているのがポイント

```ruby
    context.before do
      background_steps.each do |step|
        run_step(filename, step)
      end
    end

    group.scenarios.each do |scenario|
      step_names = (background_steps + scenario.steps).map(&:to_s)
      description = step_names.join(' -> ')

      context.describe scenario.name, scenario.metadata_hash do
        instance_eval <<-EOS, filename, scenario.line
          it description do
            scenario.steps.each do |step|
              run_step(filename, step)
            end
          end
        EOS
      end
    end
```

## ステップの実行

https://github.com/jnicklas/turnip/blob/master/lib/turnip/execute.rb

動的に定義されたメソッドを呼び出しているだけ。

```ruby
  matches = methods.map do |method|
    next unless method.to_s.start_with?("match: ")
    send(method.to_s, description)
  end.compact

  if matches.length == 0
    raise Turnip::Pending, description
  end

  if matches.length > 1
    msg = ['Ambiguous step definitions'].concat(matches.map(&:trace)).join("\r\n")
    raise Turnip::Ambiguous, msg
  end

  send(matches.first.method_name, *(matches.first.params + extra_args))
```

### stepの定義

「動的に定義」はどこでやっているかというと、

https://github.com/jnicklas/turnip/blob/master/lib/turnip/define.rb

```ruby
def step(method_name=nil, expression, &block)
  if method_name and block
    raise ArgumentError, "can't specify both method name and a block for a step"
  end
  step = Turnip::StepDefinition.new(expression, method_name, caller.first, &block)
  send(:define_method, "match: #{expression}") { |description| step.match(description) }
  send(:define_method, expression, &block) if block
end
```

個々のstepに対して、

* `match: #{expression}` っていう名前のメソッド
  * descriptionが正規表現マッチするかどうかだけを調べる
* `#{expression}` っていう名前のメソッド
  * stepの実行

の２つを生やしている。


expressionとは...？ と若干気になったので、 `Turnip::StepDefinition` にログを仕込んで見てみると、

```ruby
step 'open Top page' do
  visit 'http://awesome.example.com/'
end
```

というstepで試すと、 `method_name = nil` `expression = 'open Top page'` だった。

引数を２つ指定できりょうになっているのは、[method as a step](https://github.com/jnicklas/turnip#methods-as-steps) 用なのだろう。

## そもそもstep一覧はどこで読み込んでいる？

https://github.com/jnicklas/turnip#where-to-place-steps
READMEに書いてあるように、spec_helperで明示的にrequireしたstepしか読まれないのである。

Turnipは上記のstep定義のための `#step` メソッドを提供しているだけ。

## まとめ

FeatureをRSpec変換する部分と、Step定義部分を読んでみた。

Feature一覧は

```ruby
feature_files = Dir.glob('features/**/*.feature')
feature_files.each do |feature_file|
  feature = Turnip::Builder.build(feature_file)

  feature.children.each do |background_or_scenario|
    background_or_scenario.steps.each do |step|
    
    end
  end
end
```

みたいにループを回せる。

Step一覧は

```ruby
# READMEの手順どおりにロード
Dir.glob('steps/**/*_steps.rb') { |f| load f ; true }

# 動的に定義されたメソッドをすべてインクルード
include Turnip::Steps

# https://github.com/jnicklas/turnip/blob/master/lib/turnip/execute.rb を参考にいろいろ...
match_methods = methods.select{|m| m.to_s.start_with?("match:")}
 ...
```

のように、定義されたマッチメソッドを拾えば、特定のstepにマッチするstep定義を取れる。


応用すれば、CIでお行儀の悪いFeature Specを取り締まるとかできそうかも・・・！

#### 追記: すっごい雑だけどstepチェッカー作ってみた

https://gist.github.com/YusukeIwaki/b4fd745f9275ec440423f158480a7d72

CIで `ruby check.rb` をすれば features/ steps/ を走査して

* 未定義のstep
* 複数の定義に該当してしまい、どれを使えばいいのか決められないstep
* 未使用のstep definition

を抽出できます。 

`Calling steps from other steps` は「このプロジェクトでは使わないでください」としてありますｗ　（実装がむずいので諦めたｗ）