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

Feature specs で、たくさんの手順があるWebアプリの受け入れテストを書いてみた

Ruby

ログインして、販売伝票ボタンを押し、商品追加ボタンを押し、表示された商品ページでカテゴリーを選択し、商品の一覧を表示し選択ボタンを押し・・・ ・・・ ・・・ 確定ボタンを押す。

のようなたくさんの手順で一つの作業が完結するようなWebアプリの受け入れテスト(顧客テスト、総合テスト…)を RSpec + Capybara の Feature specs を使って書いてみました。

http://imagery.pragprog.com/products/140/achbd_xlargecover.jpg

通常このようなテストには Cucumber や Turnip が使われる事が多いですが、どちらも feature(テスト記述) と sptep(コード)に分かれていています。 顧客や開発者以外の人が feature の作成やレビューに関わる場合はメリットがありますが、開発者だけがテストに関わる場合は面倒なだけです。
そこでプログラマーの大好きな RSpec だけで、受け入れテストを書ける Feature specs なのですが、Feature specs には最初に書いたようなたくさんの手順を踏むテストを書くのは苦手です。

RSpec は テストの it "..." do ... end に簡潔なテストを書くのが基本で、通常は DBの値やセッション(Feature specsやController specsなど)は it 単位で初期化されます。rspec:install でセットアップすると it は乱数で毎回違う順に実行されます。
したがって、 受け入れテストの手順を it (Feature specs では scenario) 単位で書いて並べる分けにはいきません。長い手順を一つの it に書かないと行けません。

しかし、長い手順を一つの it に書いてはメンテナンスが極度に下がります・・・・・ そこで、ネットを探していたら RSpec Steps というものを見つけましたが、まだ RSpec 3.0 には対応してないようです。

そこで、設定やパッチ(!?)を駆使して、手順を scenario (= it ) 単位で書けるようにしてみました。 この際に問題になったのは

  1. scenario がランダムに実行される
  2. DBの値が scenario 単位で初期化される
  3. セッションが scenario 単位で初期化される

1. は設定で config.order = "defined" とすれば解決
2. は設定 config.use_transactional_fixtures = false とし before(:all) で FactoryGirlを実行すれば問題ありません。ただしrspec終了後も DBが残るの、 after(:all) でDB をクリアすれば解決しました。
3. は stackoverflow のコメントにあったヒントから Capybara のソースを見て、以下のようなパッチを書き解決しました :-)

def Capybara.reset_sessions!
end

ちなみに、環境は Ruby2.1.1, Ruby on Rails 4.1.0rc2, RSpec 3.0.0.beta2, RSpec Rails 3.0.0.beta2, Capybara 2.1.1, Poltergeist 1.5.0, Factory_girl_rails 4.4.1 です (他の環境では全く試していません)。

specファイル

長いので後半は省略しました

require 'features_helper'

feature "販売伝票作成", js: true  do
  include_context "seed_data"

  scenario "伝票入力ページが表示される" do
    login
    click_menu('販売伝票')
    expect(page.text).to match "販売伝票"
    expect(find_button("商品追加")).to be_truthy
  end

  scenario "商品追加をクリックすると商品選択が表示される" do
    click_on("商品追加")
    expect(find("h4", text: "商品選択").visible?).to be_truthy
  end

  scenario "商品分類を選択すると商品が表示される" do
    within ".modal-content" do
      select "キュロット・女性用", from: "商品分類"
      wait_css("table")

      data, count = parse_data(return_row_count: true)
      expect(count).to eq 5
      expect(data[0][3]).to eq "カバロダービー F マリーン 40"
    end
  end

  scenario "商品を2つ選択でき、商品選択を閉じる" do
    within ".modal-content" do
      click_within_table('選択', row:1)
    end
    wait_css("#slip_line_list tbody tr")
    within ".modal-content" do
      click_within_table('選択', row:2)
     end
    wait_css("#slip_line_list tbody tr:nth-child(2)")
    within ".modal-content" do
      click_within_table('選択', row:3)
     end
    wait_css("#slip_line_list tbody tr:nth-child(3)")
    click_on("閉じる")
    wait_not_css(".modal-content")
  end

  scenario "選択した商品が伝票に表示されている" do
    ・・・・
  end

  scenario "選択した商品の個数を変更できる" do
    ・・・・
  end

  scenario "選択した商品を削除できる" do
    ・・・・
  end

  scenario "お客様、値引、備考を入力し、確認画面へ" do
    ・・・・
  end

  scenario "確認画面に商品一覧と合計金額等が表示されている" do
    ・・・・
  end

  scenario "確定ボタンを押すと、伝票一覧ページが表示される" do
    click_on "確定"

    data = parse_data()
    expect(data.map{|e| [e[1], e[3]]}).to eq [
      ["販売", "山田次郎様 むけ ¥47,000"],
      ["販売", "山田太郎様 むけ ¥57,000"],
      ["発注", "C0016-0000 など 5件"]]
  end

end

設定ファイル features_helper

モデル等の rspec には影響が出ないように Features spec 用の設定ファイルを作って、その中でいろいろとやっています。
データの作成 setup_seed_data は FactoryGirl で初期データを作るメソッドで、cleanup_seed_dataは 全テーブルのデータを削除するメソッドです。

require 'spec_helper'
require 'capybara/poltergeist'
require_relative 'features_seed_data'

Capybara.javascript_driver = :poltergeist

# Force the ActiveRecord to use the same transaction for all threads monkey patch
# https://github.com/jnicklas/capybara/blob/master/README.md#transactions-and-database-setup
#
class ActiveRecord::Base
  mattr_accessor :shared_connection
  @@shared_connection = nil

  def self.connection
    @@shared_connection || retrieve_connection
  end
end
ActiveRecord::Base.shared_connection = ActiveRecord::Base.connection

RSpec.configure do |config|
  config.order = "defined"
  config.use_transactional_fixtures = false
end

def Capybara.reset_sessions!
  # ワイルドだろ〜
end

shared_context "seed_data" do
  before(:all) do
    setup_seed_data

  end
  after(:all) do
    cleanup_seed_data
  end
end

#
# helpers
#

def wait_css(css)
  until has_css?(css)
    sleep 0.5
  end
end

def wait_not_css(css)
  while has_css?(css)
    sleep 0.5
  end
end


def login(email: "xxxx@yyyy.com", password: "ppppp")
  visit root_path
  fill_in "メール", with: email 
  fill_in "パスワード",     with: password
  click_button "ログイン"
  page.text
end

def click_menu(label)
  within("ul.nav") do
    click_link(label)
  end
end

def screenshot(suffix = "")
  save_screenshot("/tmp/PhantomJS#{suffix}.png", :full => true)
end

def parse_data(return_row_count:false)
  row_count = page.all('table tbody tr').count
  col_count = page.all("table thead th").count
  all_td = page.all("table tbody td").map {|e| e.text()}

  table = []
  for row in 0..(row_count - 1)
    table << all_td.shift(col_count)
  end
  return_row_count ? [table, row_count] : table
end

def click_within_table(label, row:0)
  within("table tbody tr:nth-child(#{row})") { click_on(label) }
end