ンンンパ

ふとしです

移転しました

Ruby 外から Web アクセスする何か (Capybara とか、cli とか) を RSpec でテストするときのアクセス先をモックする

mmmpa.mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmpa.net

のテストをこの方法で行いました。

本編

たとえば、Capybara は各種ブラウザを介するアクセスのため、webmock が効かず、別の gem が必要なのは有名です。 https://github.com/oesmith/puffing-billy

Capybara テストする際はこれでいいのですが、Capybara テストする場合には不便かもしれませんね。

また、バッククォートや Open3 を用いて呼びだすような cli、たとえば siege などのターゲットをモックすることはできません。cli におんぶにだっこの gem を開発するときのテストに、かなり不便ですね。

RSpec 開始時にサーバーをたてる

望んだ結果を返すアクセス先があればいいので、テスト中に起動する仮想サーバーをたてます。

public ディレクトリに適当な HTML を用意した上で、

RSpec.configure do |config|
  require 'webrick'
  config.before(:suite) do
    port = ENV['MOCK_PORT'] || 3000
    host = ENV['MOCK_HOST'] || '127.0.0.1'
    started = false

    Thread.start do
      WEBrick::HTTPServer.new(
        DocumentRoot: File.expand_path('./public/', __dir__),
        BindAddress: host,
        Port: port,
        AccessLog: [],
        StartCallback: ->{ started = true }
        Logger: WEBrick::Log::new("/dev/null", 7)
      ).tap { |server|
        Signal.trap(:INT) { server.shutdown }
        server.start
      }
    end

    while !started
      sleep 0.5
    end
  end
end

Thread はあたらしいものを用意しないと、sever.start の時点で RSpec のプロセスが停滞してしまいますので注意しましょう。

また、別 Thread になるので server.start が完了するしないにかかわらずテストに突入、テストが落ちるということがあるので、StartCallback を使って、きちんとサーバーがスタートしたことを確認してからはじめましょう。

単純なアクセス以外

さらに、リダイレクトなど、単純な HTML アクセス以外の処理が必要ならば、インスタンス servermount すると処理を追加できます。

# たとえばリダイレクト
# Rails を模するなら、301(Moved) ではなく 302(Found)
server.mount_proc('/redirect') do |req, res|
  res.set_redirect(WEBrick::HTTPStatus::Found, '/redirected.html')
end

できあがり

これで、以下のようなテストが、自由に行えるようになりました。

expect(`siege -t 10s http://127.0.0.1:3000/foo.html`).to be_truthy

例は siege 自体のテストのようになってしまっていますが、実際は取得した結果をあれこれしたものをテストしました。

雑ではありますが、とても楽に、多くのアクセス先を用意できるようになりました。

siege をラップして多少細かい情報をまとめる gem 書いた。

github.com

Usage

たとえば Rails 内でこうやる。

re = SiegeSiege.run(
      time: 20,
      concurrent: 4,
      user_agent: false,
      urls:
        [
          "http://localhost:3002#{students_path}",
          "http://localhost:3002#{students_path} POST name=abc",
          SiegeSiege::URL.new("http://localhost:3002#{students_path}", :post, {name: 'abc'}),
        ] + Student.ids.shuffle[0..2].map { |id| "http://localhost:3002#{student_path(id)}" }
    )

するとこういうのが得られる。

普通に使ってもコンソールに出てきてたやつ

re.total_result                                                                                                                                                                                                                                                                                                                                                                                  
=> {:command=>"siege -v -c 4 -t 20s -r 1 -R /tmp/20161030-21093-1lo07ca -f /tmp/20161030-21093-13f1kj6",
 :defaulting_to_time_based_testing=>{:value=>20.0, :unit=>"seconds"},
 :transactions=>{:value=>96.0, :unit=>"hits"},
 :availability=>{:value=>75.0, :unit=>"%"},
 :elapsed_time=>{:value=>19.47, :unit=>"secs"},
 :data_transferred=>{:value=>2.03, :unit=>"MB"},
 :response_time=>{:value=>0.8, :unit=>"secs"},
 :transaction_rate=>{:value=>4.93, :unit=>"trans/sec"},
 :throughput=>{:value=>0.1, :unit=>"MB/sec"},
 :concurrency=>{:value=>3.93, :unit=>""},
 :successful_transactions=>{:value=>96.0, :unit=>""},
 :failed_transactions=>{:value=>32.0, :unit=>""},
 :longest_transaction=>{:value=>10.65, :unit=>""},
 :shortest_transaction=>{:value=>0.01, :unit=>""}}

アクセスに使った URL 毎に平均レスポンス時間とか

re.average_log                                                                                                                                                                                                                                                                                                                                                                                   
=> [#<struct SiegeSiege::AverageLog id=6, url="http://localhost:3002/students/10763", count=17, secs=0.691, siege_url=#<SiegeSiege::URL:0x007fbee6efe870 @http_method=:get, @parameter={}, @url="http://localhost:3002/students/10763">>,
 #<struct SiegeSiege::AverageLog id=0, url="http://localhost:3002/students", count=16, secs=1.276, siege_url=#<SiegeSiege::URL:0x007fbee6efeb18 @http_method=:get, @parameter={}, @url="http://localhost:3002/students">>,
 #<struct SiegeSiege::AverageLog id=5, url="http://localhost:3002/students/39284", count=16, secs=0.767, siege_url=#<SiegeSiege::URL:0x007fbee6efe938 @http_method=:get, @parameter={}, @url="http://localhost:3002/students/39284">>,
 #<struct SiegeSiege::AverageLog id=7, url="http://localhost:3002/students/94576", count=17, secs=0.108, siege_url=#<SiegeSiege::URL:0x007fbee6efe7a8 @http_method=:get, @parameter={}, @url="http://localhost:3002/students/94576">>,
 #<struct SiegeSiege::AverageLog id=3, url="http://localhost:3002/students", count=15, secs=1.367, siege_url=#<SiegeSiege::URL:0x007fbee6cd9518 @http_method=:post, @parameter={:name=>"abc"}, @url="http://localhost:3002/students">>,
 #<struct SiegeSiege::AverageLog id=1, url="http://localhost:3002/students", count=15, secs=0.651, siege_url=#<SiegeSiege::URL:0x007fbee6efea50 @http_method=:post, @parameter="name=abc", @url="http://localhost:3002/students">>]

標準出力をまとめたやつ

re.raw_log
=> [#<struct SiegeSiege::LineLog protocol="HTTP/1.1", status="200", secs=10.09, bytes=633, url="http://localhost:3002/students/10763", id=6, date=Sun, 30 Oct 2016 13:10:31 +0000, siege_url=#<SiegeSiege::URL:0x007fbee6efe870 @http_method=:get, @parameter={}, @url="http://localhost:3002/students/10763">>,
 #<struct SiegeSiege::LineLog protocol="HTTP/1.1", status="200", secs=10.29, bytes=45579, url="http://localhost:3002/students", id=0, date=Sun, 30 Oct 2016 13:10:31 +0000, siege_url=#<SiegeSiege::URL:0x007fbee6efeb18 @http_method=:get, @parameter={}, @url="http://localhost:3002/students">>,
 #<struct SiegeSiege::LineLog protocol="HTTP/1.1", status="200", secs=10.24, bytes=633, url="http://localhost:3002/students/39284", id=5, date=Sun, 30 Oct 2016 13:10:31 +0000, siege_url=#<SiegeSiege::URL:0x007fbee6efe938 @http_method=:get, @parameter={}, @url="http://localhost:3002/students/39284">>,
 #<struct SiegeSiege::LineLog protocol="HTTP/1.1", status="200", secs=0.25, bytes=633, url="http://localhost:3002/students/94576", id=7, date=Sun, 30 Oct 2016 13:10:41 +0000, siege_url=#
# 略

所感

カジュアルにテストできて便利ですね、みたいなツールで、詳細な情報を取ろうとするのはやめといた方がいいと思いました。(適切なやつをさがそう)

Capybara で Chromedriver をつかってモバイルモードでテストする時の Capybara.register_driver とか

だいたいのサイトは User Agent で切り替えてると思うので、わざわざモバイルモードでテストが必要なのかしらとか思わないでもない (ダブルタップとか、スワイプをテストする?)、が一応メモ。

Capybara に Driver として登録

# chromedriver configuration

# chrome の起動オプションが使える http://peter.sh/experiments/chromium-command-line-switches/
# デフォルトではサブのディスプレイに表示してしまうので、ずらしている
default_args = %w(
  --window-position=2560,0
)

# ブラウザの外枠 (スクリーンショット撮って計測しよう)
chrome_frame_offset = {
  w: 10,
  h: 86
}

# プリセットの商品のセッティングを使う場合。
# DevTools で選べるモバイルから必要なものをピックアップ。(リストデータくれよ)
[
  {name: 'Apple iPhone 6 Plus', w: 414, h: 736}, # :apple_iphone_6_plus
  {name: 'Google Nexus 7', w: 600, h: 960},      # :google_nexus_7
].map { |configure|
  configure[:w] += chrome_frame_offset[:w]
  configure[:h] += chrome_frame_offset[:h]
  configure
}.each do |configure|
  Capybara.register_driver configure[:name].gsub(' ', '_').downcase.to_sym do |app|
    Capybara::Selenium::Driver.new(
      app,
      browser: :chrome,
      desired_capabilities: Selenium::WebDriver::Remote::Capabilities.chrome(
        'chromeOptions' => {
          args: [
            "--window-size=#{configure[:w]},#{configure[:h]}"
          ] + default_args,
          mobileEmulation: {deviceName: configure[:name]}
        }
      )
    )
  end
end

# 独自のセッティングでモバイルモードを使う場合
# これはイッパツで全画面をスクリーンショットしたかった時の設定
Capybara.register_driver :chrome_dummy_mobile do |app|
  w = 320
  h = 1600

  Capybara::Selenium::Driver.new(
    app,
    browser: :chrome,
    desired_capabilities: Selenium::WebDriver::Remote::Capabilities.chrome(
      'chromeOptions' => {
        args: [
          "--window-size=#{w + chrome_frame_offset[:w]},#{h + chrome_frame_offset[:h]}"
        ] + default_args,
        mobileEmulation: {
          deviceMetrics: {
            width: w,
            height: h,
            pixelRatio: 2.0
          },
          userAgent:
            'Mozilla/5.0 (iPhone; CPU iPhone OS 9_3_2 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13F69 Safari/601.1'
        }
      }
    )
  )
end

選んで使う

# 登録しておいた driver から選ぶ
Capybara.configure do |config|
  config.run_server = false
  config.default_driver = :apple_iphone_6_plus
  config.app_host = 'http://googole.com/'
end

RSpec とか Capybara の Example 内で使いたいメソッドをクラス単位でまとめて使いやすくする gem を書いた。

これ

github.com

先週の gem は 80 行ぐらいだったんですが、今回は 40 行です。

なぜ

入社してからこっち、ずっと Vue.js で SPA 制作という業務に従事していましたが、UI 系の宿命で (一部しか) テストがない。

そんななかで、わりと分岐の多い (難しくはない) 実装をすることになって、これは手作業では (変更の都度に) 確認しきれんなということで Capybara に出張ってもらっていました。テスト維持コスト問題というものがあるので、業務リポジトリには入っていない、ローカルでのみ存在する Capybara です。かわいいよ。

そんななかで findall を直書きしていると、途中での変更抜きにしても、見栄えがとてもつらいことになります。

経路によっては他の Example にも出てくるので、メソッド化をするわけですが、しかし、ページごとにちがう submit_button があったりするので、 registration_page_submit_buttonshopping_cart_submit_button など、なんとかして名前をかえていく必要がありまして、これが読みづらい。

というわけで Registration.submit_buttonShoppingCart.submit_button というように簡単にわけられるようにしました。

これが

def registration_page_submit_button
  find('button', text: 'Register Now!')
end

def shopping_cart_submit_button
  find('button', text: 'Buy Now!')
end

feature 'register then buy' do
  scenario do
    visit '/resistration'
    
    # input data to some form
    
    registration_page_submit_button.click

    visit '/cart'
    
    # input data to some form
    
    shopping_cart_submit_button.click
  end
end

こうなる

class RegistrationPage < RSpecMethodGrouping::Base
  def submit_button
   find('button', text: 'Register Now!')
  end
end
class ShoppingCart < RSpecMethodGrouping::Base
  def submit_button
   find('button', text: 'Buy Now!')
  end
end
feature 'register then buy' do
  scenario do
    visit '/resistration'
    
    # input data to some form
    
    RegistrationPage.submit_button.click
    
    visit '/cart'
    
    # input data to some form
    
    ShoppingCart.submit_button.click
  end
end

よみやすい!

かどうかは人によるかと思いますが、メソッドの管理がしやすくなったのでヨシとします。

この量ではわかりにくいかもしれませんが、他画面遷移 + 要素めちゃ多いとかになると、結構効いてきます。

テスト内の隠蔽どうなの問題

テストに使うデータを過度に隠蔽すると、テストの意図まで隠蔽されて、まるでわからないという問題があります。

しかし、これは、テストの主題ではなく、ページ内の要素を特定しておく (変更時はイッパツで対応できる) のが主目的なので、特に問題ないと思います。

もりもり Capybara でスクリーンショットを撮っていきましょう。

最近 SQL にはまっているので、ついでに Rails というか ActiveRecord で発行された SQL query をカウントする gem を書いた。

Installation

gem 'a_r_q_logger'

github.com

せつめい

ActiveRecord::Base サブクラスのインスタンス生成をできるだけ抑えれば、それだけ処理時間が抑えられるのは自明とされています。

そこで、今は、できるだけ少ない queries に抑えることを目的として SQL の練習をしていますので、すぐわかるようにしました。

その練習を進めていく上で、その 1 query の処理時間が気になっており、SQL のみの処理時間もとれるようにしましたというか、出発点はこっちです。

pry(main)> ARQLogger.log { 3.times { TestModel.has_children(10).map(&:children_count) } } 
=> #<struct ARQLogger::Result count=3, msec=1508.4>

from 句で前もって as しておくのと、CASEWHEN ごとに (複数の) 集約関数を書くのではどっちが速いのか気になって、こういうことをしていたのです。 (おそらくオプティマイザがいい感じにしてくれるおかげで、CASE に同じ式を何回も書くほうが速かった)

テストでも使える

query を抑えようと書いたかっこいい生 SQLが、善意の第三者により書きかえられるということはありがちなことですが、amount of queries を抑えようと書いた生 SQL が、その善意により too many queries になっては目もあてられません。

query 発行量はコントロールできる要素ですから、これはテストで縛っておくことができます。

たとえば以下は N + 1 の解決法でよく出てくる includes の付与テストです。

it do
  expect(ARQLogger.log {
    TestModel.includes(:test_child_models).all.each { |m| m.test_child_models.map(&:name) }
  }.count).to eq(2)
end
it do
  expect(ARQLogger.log {
    TestModel.all.each { |m| m.test_child_models.map(&:name) }
  }.count).to eq(11)
end

久しぶりに gem を書くのが目的だった

どこかにありそうだなと思って軽く調べて、そんな感じの gem もありましたが軽く無視って書きました。

たのしかたです。

npm run scripts で current directory を参照したい。

環境変数を使います。

$PWD ではダメ

npm run foo は常に基準となるディレクトリで実行されるので、たとえば以下の npm run here は、配下のディレクトリに潜っても同じ結果を返します。

"scripts": {
  "here": "echo $PWD"
}

これは current directory を考慮せずに node_modules を一定の条件で使えるので、とても便利です。

なので先に設定しておく

便利ですが、 current directory が欲しい場合もありますので、別個に環境変数を設定し、それを参照します。

"scripts": {
  "here": "echo $C_D"
}
$ C_D=$PWD npm run build

これで実行したディレクトリを得られます。(しかし調べればビルトインで参照できる変数がありそうである)

おわり。

なぜ

watchify

いまは watchify でコンパイル行為を行なっており、こういう build を用意しています。

"scripts": {
  "build": "watchify -t babelify --extension=js ./index.js -o ./built.js -v"
}

特定のディレクトリを指定して watchify

SPA を作成している時分にはこれでよかったのですが、今は自習として、共通のライブラリを使う別々の小さな js ファイルを作成するということをやっています。

いっぱいあるし、個々の build は書きたくない。

このようにしておいて

"scripts": {
  "build": "watchify -t babelify --extension=js $C_D/index.js -o $C_D/built.js -v"
}
$ C_D=$PWD npm run build

余分な js を書く必要がなくてハッピーでした。(脱 Gulp 中かつ、なんらかの他のランナーも使う予定がなかった)

就職できました。

1年間の無職を終えました。

HeartRails | ハートレイルズ | ザ・ウェブサービス・カンパニー

よろしくおねがいします。