RSpec の before(:example) とタグを使って、特定のタグがあるテストケースでだけ前処理を実行する

TL; DR

  • capybara-email を使ってメール送受信をともなう E2E テストを書くときには、テストケースごとに最初に clear_emails を実行してメールキューを空にしないと、意図通りのテスト結果にならないことがあります。
  • テストケースごとに任意の処理を実行するひとつの手段として、テスティングフレームワークbefore フックを使うというものがあります。

おはなし

今手がけているサービスは、(当然のごとく)メール送受信を伴ってユーザ登録をするようなしくみになっているので、E2E テストでも、Turnip で Capybara を使ってメールの送受信をシミュレートしてそれが確実に実現されているかを確認しています。

その確認のテストケースは複数のフィーチャファイルに分かれているのですが、とあるテストケースが、「そのケースが記載されているファイルだけを対象としてテスト実行すると pass するのに、すべてのフィーチャ(ファイル)をまとめて実行すると fail したりしなかったりする」とかいう、いかにも他テストケースとの依存が発生してしまっているような挙動を起こすようになりました。

該当のケースで問題を起こしていたステップはこんな感じ。

step ":email_address に送られた :subject というタイトルの :text リンクをクリックする" do |email_address, subject, text|
  target_email = emails_sent_to(email_address).select { |email| email.subject == subject }.first
  target_email.click_link text
end

調査した結果、 capybara-email の API である emails_sent_to でメールを取得したときの結果に、別のテストケースで受信したメールが混ざっていることに気づかず 、全然関係のないメールの中身をチェックしてしまっていたためにテストが失敗していました。(受信者とタイトルが同じでも、リンクをクリックしたときに送信される GET パラメータがメールごとに違うので、結果的にエラーになるという仕様)

なるほど、では毎テストケースの実行前に受信メールをクリアしてやらないといけないな。ということで、「毎テストケースの実行前」というキーワードから、RSpecbefore(:example) が使えるんじゃないかなと思いつき、それで解決することにしました。

解決方法

特定のテストケースだけを対象として before(:example) で処理を実行する

単純に

step "メールキューをクリアする" do
  clear_emails
end

というステップを作って、メール送受信を伴うテストケースの最初に入れるというのもアリだとは思うのですが、ステップはあくまでも「テストの関心事に関わる内容だけにしたい(プログラム的な都合で行う前処理はステップにすべきでない)」と思っているので、その方法は取らず、メール送受信を伴うテストケースにタグをつけて、そのタグがついているケースのときだけは before フックで前処理を行う という方法を選びました。

RSpec でタグがついているかどうかを条件としてテストケースをフィルタリングすることの説明はこちら。

filters - Hooks - RSpec Core - RSpec - Relish

上記の解説ページでは、

foo というタグがついている場合は invoked_steps 変数の配列に :before_example_foo_bar というシンボルを追加していく」

という処理をもって、

  • foo タグが付いているケースで確かに before フックが呼ばれていること
  • foo タグが指定されていない(あるいは falseyである)場合は before フックは呼ばれていないこと

を明示してくれています。

タグがついている場合のみ実行するフック

では、実際にこの「特定のタグがついている場合のみ実行するフック」のしくみを使ってメールキューをクリアする設定を書きます。

# spec/spec_helper.rb

RSpec.configure do |config|
  (中略)
  config.before(:example, :email) do
    clear_emails

    # NOTE: 念のため確認
    expect(all_emails).to be_empty
  end
end

上記の設定をすることで、「 email タグがついているテストケースの実行前(before example)に、 clear_emails メソッドを呼ぶ」というフックの登録ができます。

実際にこの email タグをテストケースにつけるのは、Turnip なら以下のような感じになりますね。Cucumber も同様です。

# language: :ja

@email
フィーチャ: 登録確認メールの URL をクリックしたら、会員登録が完了する
  背景:
    前提: 以下の会員情報がある:
    # (以下略)
...

RSpec をそのまま使うなら、先ほど貼った relish のリンク filters - Hooks - RSpec Core - RSpec - Relish がいちばんわかりやすいと思います。

おわりに

今回はメール送受信についての前処理を入れたけど、これはなにもメールの操作に限らず、「テスト実行時、こういう場合だけはこういう処理を前(後)にやりたい」といったユースケースはたくさんありそうなので、知っておくとわりと便利なものかも。

しかし、テスト全体で受信メールが同じキューを使われるようだというのは、all_emails で毎回出力してみた結果だけで把握している事実なので、Mail gem などの実装もちゃんと読んで、テストでのメール送受信(のシミュレート)のしくみも理解できたらもっとよかったと思う。

これは別途の課題としておきたい(^^)

CircleCI 2.0 の config.yml の設定値を新しくおぼえた!メモ

CircieCI の設定を変更するにあたって、初めて知った設定値や並列実行のことをメモっておこうとおもいます。

config.yml の設定値について

persist_to_workspace

Special step used to persist a temporary file to be used by another job in the workflow. Note: Workspaces are stored for up to 30 days after being created. All jobs that try to use a Workspace older than 30 days, including partial reruns of a Workflow and SSH reruns of individual jobs, will fail.

(ワークフロー内の別のジョブで使用される一時ファイルを永続化するために使用される特別な手順。

注:ワークスペースは、作成後最大30日間保存されます。 Workflowの部分的な再実行や個々のジョブのSSHの再実行など、30日以上前のWorkspaceを使用しようとするすべてのジョブは失敗します。)

Configuring CircleCI - CircleCI

paths:(共有するパス。指定は絶対パスroot の値からの相対パス表記で) には . と指定すれば、そこがすべて他のジョブと共有されるということみたい。

store_rest_results

Special step used to upload test results so they display in builds’ Test Summary section and can be used for timing analysis. To also see test result as build artifacts, please use the store_artifacts step.

(テスト結果をアップロードしてビルドの Test Summary セクションに表示し、タイミング解析に使用できるようにするための特別な手順。テスト結果をビルド成果物としても表示するには、store_artifactsステップを使用してください。)

Configuring CircleCI - CircleCI

Test Summary セクションというのは CircleCI の実行結果の画面にあるタブのことですね。

少し古いけど、どんな機能かについてはこちらの記事がより具体的にイメージしやすいかも。

テスト分割のトラブルシューティング – CircleCI Japanese Support Center

テストの並列実行について

If your project has a large number of tests, it will need more time to run them on one machine. To reduce this time, you can run tests in parallel by spreading them across multiple machines. This requires specifying a parallelism level. You can use either the CircleCI CLI to split test files, or use environment variables to configure each parallel machine individually.

(プロジェクトに多数のテストがある場合、それらを1台のマシンで実行するにはさらに時間がかかります。 この時間を短縮するために、複数のマシンにテストを分散してテストを並行して実行できます。 これには並列処理レベルを指定する必要があります。 CircleCI CLIを使用してテストファイルを分割することも、環境変数を使用して各並列マシンを個別に構成することもできます。)

Running Tests in Parallel - CircleCI

parallelism キーに 1 より大きい値をセットすることで並列数が指定できるそう。

おわりに

最近関わるプロジェクトでは、CI にはほぼ 100% CircleCI を使っている。(わたしが選定したわけじゃなく、すでにプロジェクトで採用されていた)

ほかの CI サービスについて詳しいわけじゃないけど、CircieCI がこんなに便利でいろんな設定がカンタンにできるなら、自分としても積極的に使っていきたいなと思った!

ふだん CI については「pass したか fail したか」くらいしか気にしていなかったけど、カスタマイズのしがいがあっておもしろそうだ。

Capybara の attach_file で、非表示の input 要素にファイルを添付する

TL; DR

  • Capybara の finder methods は非表示のものを find できません。その対象は dispaly: none になっている要素、だけでなく、 opacity: 0 になっている要素も対象になるので注意しましょう。
  • 非表示になっている <input type="file"> 要素に対して attach_file を使う際には、 make_visible: true を渡すことで、非表示になっている要素も一時的に hidden でない状態にして find してくれます。

ストーリー

非表示という落とし穴

Bootswatch という、Bootstrap をカスタマイズしたテーマ集の中から、 Litera というテーマを採用してデザインをあてているアプリケーションがありまして。

そのアプリケーションのとあるページに、CSV ファイルをアップロードするためのこんな感じ↓の HTML がありました。

<div class="form-group">
  <div class="input-group mb-3 input-user-file">
    <div class="custom-file">
      <input type="file" name="csv" id="inputFile" accept="text/csv" required="required" class="custom-file-input">
      <label class="custom-file-label" for="inputFile">ファイルを選択</label>
    </div>
  </div>
</div>

このファイルアップロード機能を確かめるための E2E テストを書きたくて、Capybara を使ってテストを書いておりました。ちなみにわたし Gherkin が大好きです。Turnip が特に好き。

Capybara で <input type="file"> な要素にファイルを読み込ませるためには attach_file メソッドを使います。ですから

attach_file("inputFile", "/path/to/file")

みたいなコードを書いていたんです...が、なぜかその input 要素が見つからず、以下のエラーが出ました。。

Failure/Error: attach_file("inputFile", Rails.root.join("spec", "fixtures", file_name))

Capybara::ElementNotFound:
  Unable to find visible file field "inputFile"

rdoc とにらめっこしながら、 attach_file に渡す locator が間違っているのかなーとか、もしかしたら別の指定の仕方があるのかなーとかいろいろ試したんだけどどれも結果は同じ。

Method: Capybara::Node::Actions#attach_file — Documentation for jnicklas/capybara (master)

save_and_open_page すると確かに要素は存在しているので、もしやこれは非表示(CSSdisplay: none が指定されている)になっている...?と思って確認してみたけど、そんなようすもなく...。。

...と、調べていると気になるキーワードを発見。

ruby - Capybara Unable To Find Input Field - Stack Overflow

Since the login field is invisible (opacity: 0 to allow the emptyText to show through from below) Capybara won't find it by default.

Opacity!それか!?と思って CSS を見てみたところ...果たして。

.custom-file-input {
    position: relative;
    z-index: 2;
    width: 100%;
    height: calc(2.25rem + 2px);
    margin: 0;
    opacity: 0;
}

BINGO でした。(ちなみにこれは Bootstrap で定義されている CSS です)

なるほどなあ、確かに透明になってちゃ見えないもんね...。

attach_file には make_visible というオプションが用意されている

Capybara のコードとドキュメントを詳しく見ていきます。

Method: Capybara::Node::Actions#attach_file — Documentation for jnicklas/capybara (master)

In the case of the file field being hidden for styling reasons the make_visible option can be used to temporarily change the CSS of the file field, attach the file, and then revert the CSS back to original.

(スタイル上の理由で file フィールドが非表示になっている場合は、 make_visible オプションを使って一時的に file フィールドの CSS を変更し、ファイルを添付してから CSS を元の状態に戻すことができます。)

なんと。まさに今回のわたしのパターンにおあつらえ向きじゃないですか。「そんなこともあろうかと!」という開発者のかたがたの声が聞こえてきそうです。

GitHub のコードを見てみても、

capybara/actions.rb at 10e12bf0645b010bf50bfa99d0f22b3621a7cd1f · teamcapybara/capybara · GitHub

def attach_file(locator = nil, paths, make_visible: nil, **options) # rubocop:disable Style/OptionalArguments
  Array(paths).each do |path|
    raise Capybara::FileNotFound, "cannot attach file, #{path} does not exist" unless File.exist?(path.to_s)
  end
  options[:allow_self] = true if locator.nil?
  # Allow user to update the CSS style of the file input since they are so often hidden on a page
  if make_visible
    ff = find(:file_field, locator, options.merge(visible: :all))
    while_visible(ff, make_visible) { |el| el.set(paths) }
  else
    find(:file_field, locator, options).set(paths)
  end
end

となっていて、 visible: :all (表示・非表示関係なく、すべての要素を find の対象にする)の状態にしていることがわかります。

おわりに

今回はこのふたつのことを学べました。

  • Capybara の finder method は要素の opacity も考慮していること
  • attach_file には非表示状態であることを考慮したオプションがあること

結論にたどり着くまでに、ちゃんと save_and_open_page で要素が表示されているかを確認したことが今回の解決につながるキーだったかな。

成長すると検証のしかたも変わってくるね。

おつかれさまでした!

Devise、password_required? のオーバーライドで。

Devise という、便利さと窮屈さをひきかえにする gem があります。1

今作っているサービスでは、Devise の validatable を使って emailpassword のバリデーションをするような設定にしているんですが、サービスの仕様により、とある条件のレコードだけはパスワードが空でも保存できるようにしたい、という要件がありました。

Devise でこれを実現するには、Devise 本体で以下のように定義してある password_required? というメソッドをアプリケーション側でオーバーライドしますね。 Devise ではパスワードの存在確認、文字数制限についてのバリデーションを実行してくれるようになっているのですが、 password_required? をオーバーライドすることで、それらのバリデーションをどういう条件で実行するかをユーザが任意に決めることができます。

devise/validatable.rb at e72839f4bc18e038e1cb9a0cd24c9aed47cb2183 · plataformatec/devise · GitHub

# app/models/user.rb

class User < ApplicationRecord
  def password_required?
    # NOTE: self.company.name が "株式会社ぴよぴよ" のユーザだけはパスワードが空でも構わないので、
    #             パスワードのバリデーションはしない
    if company.name == "株式会社ぴよぴよ"
      false
    else
      super
    end      
  end
end

これでいっちょあがり〜とひと安心、"株式会社ぴよぴよ" のユーザを使って動作確認をしてみたところ...

ActiveRecord::RecordInvalid (バリデーションに失敗しました: パスワードを入力してください, パスワードを入力してください, パスワードには英小文字、英大文字、数字を全て含めてください。記号は使用できません。):

あれ...バリデーションエラーでてる... つまりバリデーションが実行されているってことだよね...

User モデルのファイルを見てみる

おかしいなあと思いながら、オーバーライドした password_required? メソッドを pry で止めてみたり、プリントデバッグしてみたりいろいろやりながら、User モデルファイルを改めて眺めてみたところ、...

validates :password, presence: true,
                     format: { with: /\A(?=.*?[a-z])(?=.*?[A-Z])(?=.*?\d)[a-zA-Z\d]{8,64}+\z/,
                     message: "には英小文字、英大文字、数字を全て含めてください。記号は使用できません。" },
                     if: -> { new_record? || changes['encrypted_password'] }

おーい!自分で presence: true って書いてるやないかーーーーい!!!\(^o^)/

解決

以下のように修正して解決しました。

validates :password, format: { with: /\A(?=.*?[a-z])(?=.*?[A-Z])(?=.*?\d)[a-zA-Z\d]{8,64}+\z/,
                               message: "には英小文字、英大文字、数字を全て含めてください。記号は使用できません。" },
                     if: -> { new_record? || changes['encrypted_password'] }, allow_blank: true

冒頭にも書いたとおり、Devise で設定されているバリデーションは文字数制限と存在確認のみをしています。

devise/validatable.rb at e72839f4bc18e038e1cb9a0cd24c9aed47cb2183 · plataformatec/devise · GitHub

しかし、わたしが作っているサービスの場合、パスワードに使われる文字種も制限をしたいので、そのバリデーションについてはアプリケーション側で実装する必要があります。 ってなわけで、 format の部分は残したままにしつつ、Devise 側でやっている presence: true は削除。 さらに、「とある条件のレコードだけはパスワードが空でも保存できるようにしたい」ので、 password カラムが空欄になることも許容しなきゃいけない。そのため allow_blank: true も入れました。

Active Record バリデーション | Rails ガイド

おわりに

技術的な記事ってめちゃ久しぶりに書いたのでちょっとドキドキした。

今日書いた内容みたいなポカする人はわたし以外にいると思えないけど(笑)、こんなかんじで "日常系技術ブログ" をモットーにして、書くことのハードルが上がりすぎないようにしつつ続けていけたらいいなー。


  1. 窮屈だなんてちょっとネガティブな表現をしたけど、冗談じゃなく、心中する覚悟があればまじでいい gem だと思ってますよ。ただしその手軽さにだけ目を奪われて「ただラクしたいだけ」という動機で取り入れると早々に壁にぶち当たるので気をつけて。

なにごともまずは Hello World から

はじめまして hinakochang です。

はてなブログ初めてなのでちょっと緊張してます。

はてな ID は rooibos_hinako です。ちなみになんで ID に "rooibos(ルイボス)" ってついてるかというと、名前だけじゃなんとなくツマンナイなと思ったところに、ちょうど飲んでいたルイボスティーが目に留まったからというだけの理由です。これ、後々になって「なんだよルイボスひなこって〜 だっさ〜!」って思うやつなんだろうなあ...(既にちょっと思ってる)

おしごとは Web エンジニアとして主にサーバサイドを担当しています。このブログでは Web 技術に関わるメモとか、感じたこととかを無理のない範囲で残していけたらいいなあなんて思っているので、よかったら今後ともお付き合いください。

よろしくお願いします。