ぶていのログでぶログ

思い出したが吉日

CLIツールのテストフレームArubaを使うときのTips

前回の記事でrfというツールを作っていることを書いた。 記事を書いた時点ではテストコードが一切なかった。 さすがに、テストコードがないのは今後開発を続けていくのは不安なのでArubaを使ってテストコードを書いた。

github.com

Arubaはあまり使い方を紹介しているサイトが少なく情報に困ったので、この記事が参考になれば幸いである。

Arubaの始め方

Aurbaはそれ単体で使うわけではなく既存のRubyのテストフレームワークに組み込む形で使用する。 ドキュメントを見ると、RSpec、minitest、Cucumberに対応しているとのこと。 私はRSpecを使ったので、この記事でもRSpecとArubaを使った方法で説明する。

gem install arubaしArubaをインストールしたらarubaコマンドを実行して、初期ファイルを生成する。

$ aruba init --test-framework=rspec
      create  /spec/spec_helper.rb
      create  /spec/support/aruba.rb
      create  /Gemfile

それぞれ、基本的な設定しかされていないので詳細は割愛する。

コマンドの終了ステータスと出力をテストする

一番オーソドックスなテストである、コマンドの終了ステータスと出力を確認するテストコードを作ってみる。 ここでは簡単のために echo hello コマンドが正常に終了することと hello と出力するテストを作成する。 このテストファイルはspec/echo_spec.rbとする。

# spec/echo_spec.rb
require 'spec_helper'

describe 'echo', type: :aruba do 👈①
  before { run_command('echo hello') } 👈②

  it { expect(last_command_started).to be_successfully_executed } 👈③
  it { expect(last_command_started).to have_output 'hello' }      👈③
end

①ではarubaを使うためにtype: :arubaを追加している。 これをつけないと後述するrun_commandなどがnot foundになるので注意すること。 なお、毎度これをつけるのが面倒だということであれば、Aruba::apiを事前にincudeすればよい。

# spec/spec_helper.rb
RSpec.configure do |config|
  config.include Aruba::Api
end

# spec/echo_spec.rb
require 'spec_helper'

describe 'echo' do
  before { run_command('echo hello') }
-- snip --

②ではコマンドを実行している。Arubaが提供しているrun_commandを使うと良い。 run_commandで実行されたコマンドの情報は③にあるlast_command_startedに保持される。 この変数に対してbe_successfully_executedマッチャを使用するとコマンドが正常終了したことの確認ができ、have_outputマッチャを使用するとコマンドの出力(より正確には標準出力への出力)を確認することができる。 echo_spec.rbではコマンドが正常終了することと、helloと出力されることを期待している。 実際にrspecコマンドを実行するとテストOKになるはずだ。

$ rspec
..

Finished in 0.01322 seconds (files took 0.0843 seconds to load)
2 examples, 0 failures

コマンドの異常終了をテストする

echo_spec.rbではコマンドが正常終了することをテストしたが、異常終了することをテストしたい場面がある。 例えば、CLIツールのオプションが不正なときに異常終了するといった場合だ。 その時は以下のようにnot_toマッチャを使うとよい

# spec/false_spec.rb
require 'spec_helper'

describe 'false' do
  before { run_command('false') }

  it { expect(last_command_started).not_to be_successfully_executed }
end

標準エラーへの出力をテストする

前述の異常終了のテストと合わせて、標準エラーに出力されるエラーメッセージをテストしたいといった場合がある。 その時は、have_output_on_stderrマッチャを使うとよい。

# spec/ls_spec.rb
require 'spec_helper'

describe 'ls' do
  before { run_command('ls notfound') }

  it { expect(last_command_started).not_to be_successfully_executed }
  it { expect(last_command_started).to have_output_on_stderr /No such file or directory/ }
end

コマンドの標準入力に値を渡す

CLIツールの中には標準入力から値を取るものもあると思う。 そういった場合は、run_commandを呼び出したあとにtypeメソッドを呼び出せばよい。 ここで注意が必要なのはtypeメソッドは標準入力に値を渡すが、標準入力を閉じることはしない。 つまり、実行されたコマンドは標準入力からの入力待ちのままスタックしてしまうことになる。 これを回避するためにtypeメソッドを実行後にclose_inputメソッドを呼び出す必要がある。 以下はcatコマンドを使ったテストコードの例である。

# spec/cat_spec.rb
require 'spec_helper'

describe 'cat' do
  before do
    run_command('cat')
    type 'hello'
    close_input
  end

  it { expect(last_command_started).to be_successfully_executed }
  it { expect(last_command_started).to have_output 'hello' }
end

run_commandの作業ディレクトリ

run_commandはカレントディレクトリで実行されるわけではなく、arubaによって作られた一時ディレクトリにで実行される。 デフォルトでは $(pwd)/tmp/arubaになる。

$ pwd
/home/buty4649/work/20230530_aruba

$ aruba console
aruba:001:0> run_command('pwd').stdout
# => "/home/buty4649/work/20230530_aruba/tmp/aruba\n"

これを変更するにはAruba.configureブロックでworking_directoryを設定してあげればよい。 例えばcustom_aruba_tempに変更したい場合は以下のような内容をspec/support/aruba.rbあたりにかけばよい。

Aruba.configure do |config|
  config.working_directory = 'custom_aruba_temp'
end

RSpecを並列実行してテストの速度を上げる

Arubaはコマンドをforkして実行する関係でテストの速度が非常に遅くなる。 テスト数が少ないときはあまり問題にならないが、テスト数が増えてくると問題になる。 そこでこの問題を解決するのがparallel_testsである。

github.com

これは簡単に言ってしまえばRSpecを並列に実行することでテストの速度を上げるというものである。 詳しい使い方などはparallel_testsのREADMEを参照してほしい。

これを使えば万事解決!・・・とはならない。 実際に実行してみるとエラーになってしまう。

$ parallel_rspec
4 processes for 4 specs, ~ 1 spec per process
FFFF
..
.

Failures:

  1) echo
     Failure/Error: before { run_command('false') }

     RuntimeError:
       Aruba's working directory does not exist. Maybe you forgot to run `setup_aruba` before using its API.
     # ./spec/false_spec.rb:4:in `block (2 levels) in <top (required)>'
-- snip --

どうもRSpecが並列実行される関係でArubaが使用する作業ディレクトリが存在しないタイミングが発生してしまい、失敗するRSpecプロセスが出てきてしまうようだ。 これを解決するには、並列で実行されるRSpecプロセスごとに違う作業ディレクトリを割り当てれば良い。 幸いparallel_testsでは実行しているRSpecプロセスの起動した順番を$TEST_ENV_NUMBERという環境変数名を割り当てているのでこれを利用すればよい。 ただし、ここにも注意点がありデフォルトでは1番目のプロセスでは$TEST_ENV_NUMBERが空になってしまうのでこれをどうにかしないといけない。 これにも回避策が用意されていてparallel_testsコマンドのオプションに --first-is-1 をつけるだけでよい*1

# spec/support/aruba.rb
Aruba.configure do |config|
  config.working_directory = File.join('tmp', 'aruba', ENV['TEST_ENV_NUMBER'])
end

これでparallel_testsが成功するはず。

$ parallel_rspec --first-is-1
4 processes for 4 specs, ~ 1 spec per process
...

Finished in 0.01492 seconds (files took 0.09467 seconds to load)
2 examples, 0 failures

....

Finished in 0.01333 seconds (files took 0.09905 seconds to load)
1 example, 0 failures

.

Finished in 0.03294 seconds (files took 0.09882 seconds to load)
4 examples, 0 failures

.

Finished in 0.0191 seconds (files took 0.09892 seconds to load)
2 examples, 0 failures


9 examples, 0 failures

Took 0 seconds

Linux環境以外でもArubaを利用する

なんとこのArubaはマルチプラットフォームで動作する。 MacOSでも動作することを確認している*2。 Windowsでも動作するのだが、rubygemsに上がっている2.1.0ではエラーになってしまった。 どうも使用しているライブラリの問題なのか意図しない出力をしてしまうようだった。 以下はその出力の抜粋である。

     ChildProcess::Error:
       Unknown error (Windows says "\x82\xB1\x82\xCC\x91\x80\x8D\xEC\x82\xF0\x90\xB3\x82\xB5\x82\xAD\x8FI\x97\xB9\x82\xB5\x82\xDC\x82\xB5\x82\xBD\x81B", but it did not.)

文字化けしているように見えるがこれはSJISで正常に操作を終了しましたと書かれている。 どうもcmd.exeの呼び出しあたりがおかしくなっているようだった。

この問題はArubaリポジトリのmainブランチではFixされている。 まだrubegemsにはリリースされていないようなので、以下のように直接GitHubのリポジトリから引っ張ってくるように設定する。

# Gemfile
gem 'aruba', github: 'cucumber/aruba', branch: 'main'

まとめ

CLIツールの振る舞いテストにArubaを使った。 ここでは紹介できていない便利な機能もあり、マルチプラットフォームでも動作して私が求めているものでとてもよかった。 今回はmrubyで作成したCLIツールでのでテストにつかったが、mruby以外で作成されたCLIツールでも使えると思う。 Rubyを入れるのは少し手間かもしれないが、選択の余地は十分ありそうに感じた。

参考資料

*1:ENV['TEST_ENV_NUMBER'] || '1'でもいいけど好み

*2:Intel Macでのみ確認した。M1/M2 Macでも動くだろうが確認するための手段が私の手元にない。だれかくれ!