読者です 読者をやめる 読者になる 読者になる

pockestrap

Web Programmer's memo

RSpecでexitを含むコードをテストする

TL;DR

  • expect{subject}.to raise_error SystemExit
  • exitをテストする状況はそもそも筋が悪い

前置き

こんにちは。私は最近miというRails用のマイグレーションジェネレータを作っています。

github.com

Railsのジェネレータは内部でThorというライブラリを使用しています。 このライブラリを使用していく上で、一つ問題が発生しました。 処理を途中で切り上げるためにexitメソッドを使用しないといけない事態に陥ってしまっていました。

問題点

exitはその時点でプロセスを終了するメソッドです。 このメソッドの問題点の一つに、テストがしづらくなると言うことがあります。

例えば、以下のようなメソッドをテストすることを考えます。

def do_something
  # ...
  if cond
    # ...
    exit 1
  end
  # ...
end

do_somethingテストでcondtrueの場合をテストしたい時、通常の方法だとテストを行うことが出来ません。
何故ならばexitの時点でRubyのプロセスが終了してしまい、テストコード側に処理が戻ってこないためです。

解決策

SystemExit例外を補足することで対処します。

Rubyではexitを実現するために例外機構を使用しています。 exitを呼び出すと、SystemExitが送出されます。 この例外クラスはStandardErrorの子クラスではない為、通常のrescueでは補足されません。

See. module function Kernel.#exit (Ruby 2.3.0)

describe '#do_something' do
  subject{do_something}

  it do
    expect{subject}.to raise_error SystemExit
  end
end

上記コードのようにSystemExitクラスの例外が発生することを期待する/補足することで、対象メソッドのテストが可能となります。

そもそもの設計論

私は、そもそも奥まったメソッドの内部でexitを呼び出すべきではないと考えています。 例えば、先ほどのdo_somethingメソッドは以下のようにリファクタリングすることが出来るでしょう。

def do_something
  # ...
  if cond
    # ...
    return 1
  end
  # ...

  return 0
end

def main
  exit do_something
end

このリファクタリングにより、テストの際はdo_somethingの戻り値を見ることでテストを行うことが可能です。

この手法はRubyに限らず有効でしょう。 例えばGo言語では、os.Exit()を使用した場合deferでの後処理が実行されないため、よりこのテクニックが必要とされます。
Rubyだとexitは例外で実装されているためensure節が実行されるので、Go言語の様な心配は不要ですね。

ところが、Thorを使用していると上記のようなリファクタリングが不可能です。 Thorでは定義された公開メソッドを順に実行していくのですが、この仕様だと途中のメソッドで処理を終了したい際にexitするしかなくなってしまいます。 他にも扱いづらい挙動が多いため、miでは脱Thor(脱Rails Generator)を検討しています。

もし上記のリファクタリングがThorでも可能な方法があれば、教えてくださると助かります。

まとめ

  • expect{subject}.to raise_error SystemExit でテストが可能
  • しかし、exitをテスト対象のコードで行わない様な設計ができればそちらのほうがいい