Feature specs で、たくさんの手順があるWebアプリの受け入れテストを書いてみた
ログインして、販売伝票ボタンを押し、商品追加ボタンを押し、表示された商品ページでカテゴリーを選択し、商品の一覧を表示し選択ボタンを押し・・・ ・・・ ・・・ 確定ボタンを押す。
のようなたくさんの手順で一つの作業が完結するようなWebアプリの受け入れテスト(顧客テスト、総合テスト…)を RSpec + Capybara の Feature specs を使って書いてみました。
通常このようなテストには 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 ) 単位で書けるようにしてみました。 この際に問題になったのは
- scenario がランダムに実行される
- DBの値が scenario 単位で初期化される
- セッションが 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