1
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

RailsチュートリアルのテストをRSpecに置き換えてみる【13章~14章+RuboCopの導入】

Posted at

はじめに

Rails初学者の定番教材であるRailsチュートリアルを完走しました。

完走後、Railsチュートリアルの読み物ガイドに目を通してみて、
次のステップとして実践的なテストフレームワークであるRSpecについて学ぶことにしました。

そして、Everyday Railsを購入し、基本的なスペックが一通り書けるようになるまで進めました。

教材を通読・サンプルコードを書いた程度だとまだ理解が浅いので、
実践を通じてRSpecの使い方を理解するためにRailsチュートリアルのテストをMinitestからRSpecに置き換えてみることにしました。

読み物ガイドで紹介されていた下の記事を答え合わせ的に使っていきます。

意識すること

  • 過度にDRYであることよりも可読性を重視する。
    • ただ、ファクトリの扱いには慣れたいのでテストデータの用意はなるべくファクトリで。
  • Railsチュートリアルに縛られすぎない。
    • 例えば「Railsチュートリアルがコントローラテストだったからコントローラスペックを書く」のような考え方は避ける。
  • まず動かし、次に正しくし、それから速くする(Make it work, make it right, make it fast)」を意識する
    • Everyday Railsで紹介されていた格言です。
    • 細かいところや便利なマッチャを使うことにこだわりすぎず、サクッとテストを書いてみる。

環境

Railsチュートリアルの環境をそのまま利用します。

  • Ruby 2.7.4
  • Ruby on Rails 6.0.4

追加でインストールするgem

  • rspec-rails 5.1.1
  • FactoryBot 6.2.0
  • capybara 3.28.0
  • selenium-webdriver 3.142.4
  • webdrivers 4.1.2

リンク

13章 ユーザーのマイクロポスト

13.4

Micropostモデル(ユーザーに紐付けられた投稿)に対する最初のテストを書きます。

spec/models/micropost_spec.rb
RSpec.describe Micropost, type: :model do
  let(:user) { FactoryBot.create(:user) }
  let(:micropost) { Micropost.new(content: "Test post", user_id: user.id) }

  context "with valid attributes" do
    it "is valid" do
      expect(micropost).to be_valid
    end
  end

  context "with invalid attributes" do
    it "is invalid without user_id" do
      micropost.user_id = nil
      expect(micropost).to_not be_valid
    end
  end
end

13.7

Micropostに対するバリデーションのテストを実装します。
contentは長さに対するバリデーション(141文字以内)があるので、境界値テストを実装しました。

spec/models/micropost_spec.rb
require 'rails_helper'

RSpec.describe Micropost, type: :model do
  let(:user) { FactoryBot.create(:user) }
  let(:micropost) { Micropost.new(content: "Test post", user_id: user.id) }

  context "with valid attributes" do
    it "is valid" do
      expect(micropost).to be_valid
    end

    it "is valid with content equal to the boundary value" do
      micropost.content = "a" * 140
      expect(micropost).to be_valid
    end
  end

  context "with invalid attributes" do
    it "is invalid without user_id" do
      micropost.user_id = nil
      expect(micropost).to_not be_valid
    end

    it "is invalid without a content" do
      micropost.content = ""
      expect(micropost).to_not be_valid
    end

    it "is invalid with a too long content" do
      micropost.content = "a" * 141
      expect(micropost).to_not be_valid
    end
  end
end

13.12

has_manyされている投稿の作成を慣習的に正しい方法に変更します。

spec/models/micropost_spec.rb
RSpec.describe Micropost, type: :model do
  let(:user) { FactoryBot.create(:user) }
  let(:micropost) { user.microposts.build(content: "Test post") }
  # 省略...

13.14

Micropostの順序付けをテストします。
実際のFactoryBotの中身は後で実装していきます。

ついでにdescribeでバリデーションのテストを1まとめにします。

spec/models/micropost_spec.rb
RSpec.describe Micropost, type: :model do
  let(:user) { FactoryBot.create(:user) }
  let(:micropost) { FactoryBot.create(:micropost) }

  it "is sorted by newest to oldest" do
    expect(FactoryBot.create(:most_recent_post)).to eq Micropost.first
  end

  describe "validation" do
    context "with valid attributes" do
      it "is valid" do
        expect(micropost).to be_valid
      end
      # 省略...

13.15

順序付けテスト用のデータを作成します。

素直に投稿時間が違うデータを複数作ります。

spec/models/micropost_spec.rb
FactoryBot.define do
  factory :micropost do
    content { "Test post" }
    association :user

    trait :most_recent do
      created_at { Time.zone.now }
    end

    trait :some_time_ago do
      created_at { 30.minutes.ago }
    end

    trait :yesterday do
      created_at { 1.day.ago }
    end

    trait :last_week do
      created_at { 1.week.ago }
    end

    trait :last_month do
      created_at { 1.month.ago }
    end

  end
end

ヘルパーメソッドを作り、そこからFactoryBotを呼び出します。

spec/support/microposts.rb
module PostSupport
  def create_posts_different_posting_time(test_object: :most_recent)
    FactoryBot.create(:micropost, test_object)
    FactoryBot.create(:micropost, :some_time_ago)
    FactoryBot.create(:micropost, :yesterday)
    FactoryBot.create(:micropost, :last_week)
  end
end

RSpec.configure do |config|
  config.include PostSupport
end

定義したヘルパーメソッドをテストで呼び出します。

spec/models/micropost_spec.rb
RSpec.describe Micropost, type: :model do
  let(:user) { FactoryBot.create(:user) }
  let(:micropost) { FactoryBot.create(:micropost) }

  it "is sorted by newest to oldest" do
    create_posts_different_posting_time
    expect(FactoryBot.create(:micropost, :most_recent)).to eq Micropost.first
  end
  # 省略...

ユーザーの重複

テストを実行したところ、次のようなエラーが出ました。

Failure/Error: FactoryBot.create(:micropost, :yesterday) # 2番目に作られるデータ

ActiveRecord::RecordInvalid:
  Validation failed: Email has already been taken

どうやら毎回違うUserを作成しているようです。
そのため、emailのバリデーションによりエラーが発生します。

この問題に対処するため、Userのファクトリを修正します。

spec/factories/users.rb
FactoryBot.define do
  factory :user do
    sequence(:name) { |n| "Example User #{n}" }
    sequence(:email) { |n| "example-#{n}@gmail.com" }
    password { "securePassword" }
    password_confirmation { "securePassword" }
    activated { true }
    activated_at { Time.zone.now }
  # 省略...

nameemailに対して、デフォルトでシーケンスを利用するように変更を加えます。
この時Userファクトリの他の箇所もリファクタリングしました(後述)。

この変更により、無事テストがパスするようになります。

念の為本当にユーザーが変わっているのか見てみます。

spec/models/micropost_spec.rb
it "is sorted by newest to oldest" do
  create_posts_different_posting_time
  Micropost.all.each { |m| puts m.user_id }
  expect(FactoryBot.create(:micropost, :most_recent)).to eq Micropost.first
end

# 出力
1
2
3
4

OKですね。

13.20

dependent: :destroyが正しく動作することをテストします。

spec/models/micropost_spec.rb
RSpec.describe Micropost, type: :model do
  let(:user) { FactoryBot.create(:user) }
  let(:micropost) { FactoryBot.create(:micropost) }

  describe "association" do
    it "destroyed when the associated user is destroyed" do
      user = micropost.user
      expect {
        user.destroy
      }.to change(Micropost, :count).by -1
    end
  end

13.28

マイクロポストが正しく表示されていることをテストします。

まず、複数の投稿を持ったユーザーのトレイトを作成します。

spec/factories/users.rb
FactoryBot.define do
  factory :user do
    trait :with_posts do
      after(:create) { |user| create_list(:micropost, 31, user: user) }
    end

トレイトを呼び出すことで、複数の投稿を持ったユーザーが作成(=複数の投稿が作成)されます。
これにより、複数の投稿が存在する状態でUIをテストすることが出来ます。

残りは通常のシステムスペックです。

spec/system/microposts_spec.rb
RSpec.describe "Microposts", type: :system do
  let(:user) { FactoryBot.create(:user) }

  before do
    driven_by(:rack_test)
  end

  describe "users/id" do
    before do
      @user = FactoryBot.create(:user, :with_posts)
      log_in @user
    end

    it "has a pagination" do
      expect(page).to have_selector "div.pagination"
    end

    it "has 30 posts per page" do
      posts_container = within 'ol.microposts' do
        find_all('li')
      end
      expect(posts_container.length).to eq 30
    end

    it "displays micropost counts" do
      expect(page).to have_content @user.microposts.count.to_s
    end

    it "displays contents of each post" do
      @user.microposts.paginate(page: 1).each do |micropost|
        expect(page).to have_content micropost.content
      end
    end
  end
end

"has 30 posts per page"では、Capybaraのwithinメソッドを使って「特定のセレクタ内」にスコープを特定しています。
このようにすることで、ol.micropostsの子要素として存在する投稿の数をカウントすることが出来ます。

13.2.3 演習

ページネーションの表示が一度のみ(一箇所のみ?)であることをテストするように変更します。

spec/system/microposts_spec.rb
  # 省略...
    it "has a pagination" do
      pagination = find_all("div.pagination")
      expect(pagination.length).to eq 1
    end

13.31

マイクロポストの操作に関するテストを書きます。

spec/requests/microposts_spec.rb
require 'rails_helper'

RSpec.describe "Microposts", type: :request do

  describe "POST /microposts" do
    context "as a logged in user" do
      # 後で書く
    end

    context "as a non-logged in user" do
      it "redirects to login_path" do
        post microposts_path, params: { micropost: { content: "Test post" } }
        expect(response).to redirect_to login_path
      end

      it "doesn't create a post" do
        expect{
          post microposts_path, params: { micropost: { content: "Test post" } }
        }.to_not change(Micropost, :count)
      end
    end
  end

  describe "DELETE /microposts/id" do
    let!(:micropost) { FactoryBot.create(:micropost) }

    context "as a logged in user" do
      # 後で書く
    end

    context "as a non-logged in user" do
      it "redirects to login_path" do
        delete micropost_path(micropost)
        expect(response).to redirect_to login_path
      end
      it "doesn't delete a post" do
        expect{
          delete micropost_path(micropost)
        }.to_not change(Micropost, :count)
      end
    end
  end
end

13.55

自分以外のユーザーのマイクロソフトを削除しようとすると失敗することをテストします。

spec/requests/microposts_spec.rb
  describe "DELETE /microposts/id" do
    let!(:micropost) { FactoryBot.create(:micropost) }

    context "as a logged in user" do
      context "as a wrong user" do
        before do
          wrong_user = FactoryBot.create(:user)
          log_in(wrong_user)
        end

        it "redirects to root_url" do
          delete micropost_path(micropost)
          expect(response).to redirect_to root_url
        end

        it "does't delete a post" do
          expect {
            delete micropost_path(micropost)
          }.to_not change(Micropost, :count)
        end
      end
      context "as a correct user" do
        # 後で書く
      end
    end

13.56

マイクロポストのUIに対するテストを書きます。

spec/system/microposts_spec.rb
RSpec.describe "Microposts", type: :system do
  before do
    driven_by(:rack_test)
  end

  describe "/root" do
    before do
      @user = FactoryBot.create(:user, :with_posts)
      log_in @user
      visit root_path
    end

    describe "POST /microposts" do

      context "with valid attributes" do
        it "creates a post" do
          expect {
            fill_in "micropost_content", with: "Test Post"
            click_button "Post"
          }.to change(Micropost, :count).by 1

          expect(page).to have_content "Test Post"
        end
      end

      context "with invalid attributes" do
        it "doesn't create a post without a content" do
          expect {
            fill_in "micropost_content", with: ""
            click_button "Post"
          }.to_not change(Micropost, :count)

          expect(page).to have_selector 'div#error_explanation'
          expect(page).to have_link '2', href: '/?page=2'
        end
      end
    end

    describe "DELETE /micropost/id" do

      context "as a correct user" do
        it "deletes a post" do
          post = @user.microposts.first
          expect(page).to have_link "delete"

          expect {
            click_link "delete", href: micropost_path(post)
          }.to change(Micropost, :count).by -1

          expect(page).to_not have_content post.content
        end
      end

      context "as a wrong user" do
        let(:wrong_user) { FactoryBot.create(:user) }

        it "doesn't delete a post" do
          visit user_path(wrong_user)
          expect(page).to_not have_link "delete"
        end
      end
    end
  end
end

13.58

サイドバー(マイクロポストの投稿数の表示)のテストを書きます。

投稿の数が0/1の時、単数/複数形が正しく表示されていることをテストします。
投稿を持たない他のユーザーでログインすることも考えましたが、
destroy_allで投稿全削除→visit root_pathでリロードすることで実装しました。

spec/system/microposts_spec.rb
  describe "/root" do
    before do
      @user = FactoryBot.create(:user, :with_posts)
      log_in @user
      visit root_path
    end

    describe "sidebar" do
      it "displays micropost counts" do
        expect(page).to have_content @user.microposts.count.to_s
      end

      it "displays 0 microposts with 0 posts" do
        @user.microposts.destroy_all
        visit root_path
        expect(page).to have_content "0 microposts"
      end

      it "displays 1 micropost with a post" do
        @user.microposts.destroy_all
        fill_in "micropost_content", with: "Test Post"
        click_button "Post"
        expect(page).to have_content "1 micropost"
      end
    end

13.64

画像アップロードのテストを実装します。

まずspec/fixturesにテスト用のデータを置きます。
spec/filesの場合もあるそうです。

curl -o spec/fixtures/kitten.jpg -OL https://cdn.learnenough.com/kitten.jpg

テストを書きます。
attach_fileでファイルをアップロード、be_attachedで添付されたことを確認します。

spec/system/micro_posts_spec.rb
  describe "/root" do
    describe "POST /microposts" do
      context "with valid attributes" do
        it "uploads an image" do
          expect {
            fill_in "micropost_content", with: "Test Post"
            attach_file "micropost_image", "#{Rails.root}/spec/fixtures/kitten.jpg"
            click_button "Post"
          }.to change(Micropost, :count).by 1

          attached_post = Micropost.first
          expect(attached_post.image).to be_attached
        end
        # 省略...

ちなみに、Active Storageではデフォルトで tmp/storage にアップロードしたファイルが保存されるようになっているので、何もしないとテストが実行される度にファイルが増えていってしまいます。

config/storage.yml
test:
  service: Disk
  root: <%= Rails.root.join("tmp/storage") %>

local:
  service: Disk
  root: <%= Rails.root.join("storage") %>

そのため、rails_helper.rbに設定を追記してアップロードされたファイルはテスト終了時に自動的に削除されるようにしておきます。

spec/rails_helper.rb
RSpec.configure do |config|
  # 省略...
  config.after(:suite) do
    FileUtils.rm_rf(ActiveStorage::Blob.service.root)
  end
end

答え合わせ

コード例〜第13章〜|RailsチュートリアルのテストをRSpecで書き換える

複数の投稿のテストデータ

「複数の投稿」のテストデータを用意する方法がかなり違っていました。

複数の投稿の用意に定義したメソッドを使う、という点は同じでしたが、定義する場所と中身が異なっていました。

定義する場所
自分の場合 : ヘルパーメソッドとして別ファイルに定義
例 : ファクトリの中で定義。こちらの方が便利そう。

中身についても、そもそも「最新のmicropostsが最初に来ている」=「作成日時が新しい順になっている」ことをテストしたいだけなら作成日時がばらけたデータは不要だった気がします。

定義

spec/factories/microposts.rb
FactoryBot.define do
  # 省略...
end

def user_with_posts(posts_count: 5)
  FactoryBot.create(:user) do |user|
    FactoryBot.create_list(:orange, posts_count, user: user)
  end
end

このメソッド、FactoryBot.define do~endの外で定義されています。
spec/factories配下のファイル内で定義されたメソッドはFactoryBotで定義されたメソッド扱いになるということ・・・?

呼び出し

FactoryBot.send(:user_with_posts)は、リスト13.15で定義するuser_with_postsメソッドを実行しています。
user_with_postsメソッドはprivateメソッドのためFactoryBot.user_with_postsという風には呼び出せません。

spec/models/micropost_spec.rb
RSpec.describe Micropost, type: :model do
 .
 .
 it '並び順は投稿の新しい順になっていること' do
   FactoryBot.send(:user_with_posts)
   expect(FactoryBot.create(:most_recent)).to eq Micropost.first
 end
 .
 .
end

また、30個以上の投稿を作成する際、例ではファクトリの中で定義したメソッドをそのまま使っていました。
自分は複数の投稿を持つuserのトレイトを定義、使用していました。

spec/factories/users.rb
FactoryBot.define do
  factory :user do
  # 省略...
    trait :with_posts do
      after(:create) { |user| create_list(:micropost, 31, user: user) }
    end

こちらについてはwith_postsトレイトを使う方が好みです。

13.58

現在のURLのままリロードしたい時、current_pathが使えるようです。

# 自分のコード
  it "displays 0 microposts with 0 posts" do
    @user.microposts.destroy_all
    visit root_path
    expect(page).to have_content "0 microposts"
  end

# 例のコード
  it '0件なら"0 microposts"、1件なら"1 micropost"と表示されること' do
    @user.microposts.destroy_all
    visit current_path
    expect(page).to have_content '0 microposts'
    # 省略...

14章 フォロー機能

RSpecで書き換え

relationshipのファクトリ

テストを書く前に、relationshipを生成するためのファクトリを用意します。

relationshipモデルを見ると、Userの関連がfollowerfollowedという名前になっていることがわかります。

app/models/relationship.rb
class Relationship < ApplicationRecord
  belongs_to :follower, class_name: "User"
  belongs_to :followed, class_name: "User"
end

このような場合は、ユーザーのファクトリに対してaliasesを設定する必要があります。

spec/factories/users.rb
FactoryBot.define do
  factory :user , aliases: [:followed, :follower] do
    sequence(:name) { |n| "Example User #{n}" }
    sequence(:email) { |n| "example-#{n}@gmail.com" }
    password { "securePassword" }
    password_confirmation { "securePassword" }
    activated { true }
    activated_at { Time.zone.now }
    # 省略...

aliasesを設定すれば、relationshipのファクトリからその名前で参照することが出来ます。

spec/factories/relationships.rb
FactoryBot.define do
  factory :relationship do
    association :followed
    association :follower
  end
end

relationshipの生成をテストしてみます。

spec/models/relationship_spec.rb
RSpec.describe Relationship, type: :model do
  let(:relationship) { FactoryBot.create(:relationship) }
  it "generates associated data" do
    puts relationship.followed_id
    puts relationship.followed.inspect
    puts relationship.follower_id
    puts relationship.follower.inspect
  end
end
1
#<User id: 1, name: "Example User 1", email: "example-1@gmail.com", created_at: "2022-03-26 23:06:12", updated_at: "2022-03-26 23:06:12", password_digest: [FILTERED], remember_digest: nil, admin: nil, activation_digest: "$2a$04$Ct2iBtdpHwOb/THvd94.Xu2JzFu.Jedghn2xC8pri9U...", activated: true, activated_at: "2022-03-26 23:06:12", reset_digest: nil, reset_sent_at: nil>
2
#<User id: 2, name: "Example User 2", email: "example-2@gmail.com", created_at: "2022-03-26 23:06:12", updated_at: "2022-03-26 23:06:12", password_digest: [FILTERED], remember_digest: nil, admin: nil, activation_digest: "$2a$04$F9nRxyV6hus7ReCquet4m.KquGhE8dRP0tyPzVO0WLC...", activated: true, activated_at: "2022-03-26 23:06:12", reset_digest: nil, reset_sent_at: nil>

問題無さそうです。

14.4

relationshipのバリデーションに対するテストを書きます。

spec/models/relationship_spec.rb
require 'rails_helper'

RSpec.describe Relationship, type: :model do
  describe "validation" do
    let(:relationship) { FactoryBot.create(:relationship) }

    context "with valid attributes" do
      # 後で書く
    end

    context "with invalid attributes" do
      it "is invalid without a follower_id" do
        relationship.follower_id = nil
        expect(relationship).to_not be_valid
      end

      it "is invalid without a followed_id" do
        relationship.followed_id = nil
        expect(relationship).to_not be_valid
      end
    end
  end

end

14.9

following関連のメソッドをテストします。

spec/models/user_spec.rb
  describe "#follow and #unfollow" do
    let(:other_user) { FactoryBot.build(:user) }

    it "can follow the other user" do
      expect(user.following?(other_user)).to_not be_truthy
      user.follow(other_user)
      expect(user.following?(other_user)).to be_truthy
    end

    it "can unfollow the other user" do
      user.follow(other_user)
      expect(user.following?(other_user)).to be_truthy
      user.unfollow(other_user)
      expect(user.following?(other_user)).to_not be_truthy
    end
  end

14.13

followersが正しく機能することをテストします。

spec/models/user_spec.rb
  describe "#follow and #unfollow" do
    let(:user) { FactoryBot.create(:user) }
    let(:other_user) { FactoryBot.create(:user) }

    it "can follow the other user" do
      expect(user.following?(other_user)).to_not be_truthy

      user.follow(other_user)

      expect(user.following?(other_user)).to be_truthy
      expect(other_user.followers.include?(user)).to be_truthy
    end

14.2.2 演習

プロフィールページとHomeページに followingfollowersの統計情報が正しく表示されていることをテストします。

まずはファクトリでテストデータを用意します。

spec/factories/users.rb
FactoryBot.define do
  factory :user , aliases: [:followed, :follower] do
    # 省略...
    trait :with_relationships do
      after(:create) do |user|
        30.times do
          other_user = create(:user)
          user.follow(other_user)
          other_user.follow(user)
        end
      end
    end

コールバックを使い、
他のユーザーを生成 → お互いにフォロー
という流れを繰り返しています。

followingfollowersのどちらかのみでも同じように実装出来ると思います。

プロフィールページのテストを書きます。
"#{正しい数} following / followers"という表示になっていることをテストします。

spec/system/users_spec.rb
  describe "GET /users/id" do
    describe "following and followers" do
      let(:user_with_relationships) { FactoryBot.create(:user, :with_relationships) }
      let(:following) { user_with_relationships.following.count }
      let(:followers) { user_with_relationships.followers.count }

      it "displays statistics for following and followers" do
        log_in user_with_relationships
        expect(page).to have_content("#{following} following")
        expect(page).to have_content("#{followers} followers")
      end
    end
  end

ホームページのテストを書きます。使い回しです。

spec/system/static_pages_spec.rb
  describe "GET /users/id" do
    describe "following and followers" do
      let(:user_with_relationships) { FactoryBot.create(:user, :with_relationships) }
      let(:following) { user_with_relationships.following.count }
      let(:followers) { user_with_relationships.followers.count }

      it "displays statistics for following and followers" do
        log_in user_with_relationships
        expect(page).to have_content("#{following} following")
        expect(page).to have_content("#{followers} followers")
      end
    end
  end

14.24

フォロー/フォロワーページの認可をテストします。

spec/system/users_spec.rb
  describe "GET /users/id/following" do
    let(:user) { FactoryBot.create(:user) }

    context "as a logged in user" do
      # 後で書く
    end

    context "as a non-logged in user" do
      it "redirects to login_path" do
        get following_user_path(user)
        expect(response).to redirect_to login_path
      end
    end
  end

  describe "GET /users/id/followers" do
    let(:user) { FactoryBot.create(:user) }

    context "as a logged in user" do
      # 後で書く
    end

    context "as a non-logged in user" do
      it "redirects to login_path" do
        get followers_user_path(user)
        expect(response).to redirect_to login_path
      end
    end
  end

14.29

following/followerページ(フォローしているユーザー/フォロワーの一覧ページ)に対するテストを書きます。

網羅的に書くのは難しいので、正しい数が表示されていること、正しいリンクが表示されていることのみテストします。

before@following/@followersを変数に入れておくとテストが読みやすくなると思ったので、そのようにしました。
また、@following/@followersが空の場合、後のテストが実行されず通ってしまうので、空でないことを確認しています。

spec/system/users.rb
  describe "GET /users/id/following" do
    let(:user) { FactoryBot.create(:user) }

    context "as a logged in user" do
      before do
        @user_with_relationships = FactoryBot.create(:user, :with_relationships)
        @following = @user_with_relationships.following
        log_in @user_with_relationships
      end

      it "displays the correct number of following user" do
        visit following_user_path(@user_with_relationships)

        # ここが空の場合後のテストが実行されないため
        expect(@following).to_not be_empty

        expect(page).to have_content("#{@following.count} following")
        @following.paginate(page: 1).each do |follow|
          expect(page).to have_link follow.name, href: user_path(follow)
        end
      end
    end

    # 省略...

  describe "GET /users/id/followers" do
    let(:user) { FactoryBot.create(:user) }

    context "as a logged in user" do
      before do
        @user_with_relationships = FactoryBot.create(:user, :with_relationships)
        @followers = @user_with_relationships.followers
        log_in @user_with_relationships
      end

      it "displays the correct number of followers" do
        visit followers_user_path(@user_with_relationships)

        # ここが空の場合後のテストが実行されないため
        expect(@followers).to_not be_empty

        expect(page).to have_content("#{@followers.count} followers")
        @followers.paginate(page: 1).each do |follower|
          expect(page).to have_link follower.name, href: user_path(follower)
        end
      end
    end

14.31

リレーションシップのアクセス制御に対するテストを書きます。

spec/requests/relationships_spec.rb
RSpec.describe "Relationships", type: :request do
  describe "#create" do
    context "as a logged in user" do
      # 後で
    end
    context "as a non-logged in user" do
      it "redirects to login_path" do
        post relationships_path
        expect(response).to redirect_to login_path
      end

      it "doesn't create a relationship" do
        expect{
          post relationships_path
        }.to_not change(Relationship, :count)
      end
    end
  end

  describe "#destroy" do
    let!(:relationship) { FactoryBot.create(:relationship) }
    context "as a logged in user" do
      # 後で
    end
    context "as a non-logged in user" do
      it "redirects to login_path" do
        delete relationship_path(relationship)
        expect(response).to redirect_to login_path
      end

      it "doesn't delete a relationship" do
        expect{
          delete relationship_path(relationship)
        }.to_not change(Relationship, :count)
      end
    end
  end
end

14.40

Follow/Unfollowのテストを書きます。
post/deleteメソッドの引数としてxhr: trueを渡すことで、Ajaxでリクエストを発行するようにします。

spec/requests/relationships_spec.rb
RSpec.describe "Relationships", type: :request do
  describe "#create" do
    context "as a logged in user" do
      before do
        @user = FactoryBot.create(:user)
        @other_user = FactoryBot.create(:user)
        log_in @user
      end

      it "creates a relationship by the standard way" do
        expect{
          post relationships_path, params: { followed_id: @other_user.id }
        }.to change(Relationship, :count).by 1
      end

      it "creates a relationship by the Ajax" do
        expect{
          post relationships_path, params: { followed_id: @other_user.id }, xhr: true
        }.to change(Relationship, :count).by 1
      end
    end
    # 省略...

  describe "#destroy" do
    context "as a logged in user" do
      before do
        @user = FactoryBot.create(:user)
        @other_user = FactoryBot.create(:user)
        log_in @user
      end

      it "deletes a relationship by the standard way" do
        @user.follow(@other_user)
        created_relationship = @user.active_relationships.find_by(followed_id: @other_user.id)
        expect{
          delete relationship_path(created_relationship)
        }.to change(Relationship, :count).by -1
      end

      it "deletes a relationship by the Ajax" do
        @user.follow(@other_user)
        created_relationship = @user.active_relationships.find_by(followed_id: @other_user.id)
        expect{
          delete relationship_path(created_relationship), xhr: true
        }.to change(Relationship, :count).by -1
      end
    end

14.42

ステータスフィードのテストを実装します。

自身/フォローしているユーザーの投稿が表示されていること、フォローしていないユーザーの投稿が表示されないことをテストします。

spec/models_user_spec.rb
  describe "#feed" do
    let(:user) { FactoryBot.create(:user, :with_posts) }
    let(:user_following) { FactoryBot.create(:user, :with_posts) }
    let(:user_unfollowed) { FactoryBot.create(:user, :with_posts) }
    before do
      user.follow(user_following)
    end

    it "displays user's own posts" do
      user.microposts.each do |post_self|
        expect(user.feed).to be_include(post_self)
      end
    end

    it "displays following user's posts" do
      user_following.microposts.each do |post_following|
        expect(user.feed).to be_include(post_following)
      end
    end

    it "doesn't display unfollowed user's posts" do
      user_unfollowed.microposts.each do |post_unfollowed|
        expect(user.feed).to_not be_include(post_unfollowed)
      end
    end
  end

フィードをテストするためには、投稿を持つユーザーを作成する必要があります。
そこで、マイクロポストの表示テストの際に使用した with_postsトレイトを再利用しました。

spec/factories/users.rb
FactoryBot.define do
  factory :user , aliases: [:followed, :follower] do
  # 省略...
    trait :with_posts do
      after(:create) { |user| create_list(:micropost, 31, user: user) }
    end
  end
end

14.49

フィードのHTMLをテストします。

spec/system/static_pages_spec.rb
RSpec.describe "StaticPages", type: :system do
  describe "root" do
    describe "feed" do
      let(:user) { FactoryBot.create(:user, :with_posts) }
      before do
        log_in user
      end

      it "displays correct feeds" do
        visit root_path
        user.feed.paginate(page: 1).each do |micropost|
          expect(page).to have_content(CGI.escapeHTML(micropost.content))
        end
      end
    end

答え合わせ

コード例〜第14章〜|RailsチュートリアルのテストをRSpecで書き換える

フォロー/フォロワーや、投稿を持つユーザーを用意するファクトリの中身がかなり異なっていました。
個人的にはコールバックで用意してしまう方が好みです。

リファクタリング

トレイトによるファクトリの重複解消(13章時点)

下のコードがリファクタリング前の状態です。

必要になる度にとにかく足していったせいで、重複がとても多いです。
そもそも不要なファクトリ(many_usersother_userなど)もあります。

spec/factories/users.rb
FactoryBot.define do
  # 管理者
  factory :user do
    name { "Example User" }
    email { "example@email.com" }
    password { "securePassword" }
    password_confirmation { "securePassword" }
    admin { true }
    activated { true }
    activated_at { Time.zone.now }
  end

  factory :other_user ,class: User do
    name { "Other User" }
    email { "other@gmail.com" }
    password { "securePassword" }
    password_confirmation { "securePassword" }
    activated { true }
    activated_at { Time.zone.now }
  end

  factory :invalid_user, class: User do
    name { "" }
    email { "address@invalid" }
    password { "short" }
    password_confirmation { "rack" }
    activated { true }
    activated_at { Time.zone.now }
  end

  factory :many_users, class: User do
    sequence(:name) { |n| "Example User #{n}" }
    sequence(:email) { |n| "example-#{n}@gmail.com" }
    password { 'password' }
    password_confirmation { 'password' }
    activated { true }
    activated_at { Time.zone.now }
  end

  factory :inactivated_user, class: User do
    name { "inactive" }
    email { "inactivated@email.com" }
    password { "password" }
    password_confirmation { "password" }
    activated { false }
  end
end

トレイトを使って重複を解消していきます。

spec/factories/users.rb
FactoryBot.define do
  factory :user do
    sequence(:name) { |n| "Example User #{n}" }
    sequence(:email) { |n| "example-#{n}@gmail.com" }
    password { "securePassword" }
    password_confirmation { "securePassword" }
    activated { true }
    activated_at { Time.zone.now }

    trait :admin do
      admin { true }
    end

    trait :invalid do
      name { "" }
      email { "address@invalid" }
      password { "short" }
      password_confirmation { "rack" }
    end

    trait :inactivated do
      activated { false }
      activated_at { nil }
    end
  end
end

かなりスッキリしました。

トレイトをスペックから呼び出す際は以下のようにします。

let(:user) { FactoryBot.create(:user) }
let(:other_user) { FactoryBot.create(:user) }
let(:admin) { FactoryBot.create(:user, :admin) }

元となるuserにシーケンスを追加したので、複数回呼び出した場合、勝手にユニークな値になってくれます。
トレイトとして定義したhogeを呼ぶには、createメソッドに(:user, :hoge)を渡します。

ちなみにtraitは「特徴・特性」などの意味を持ちます。
「ある特徴・特性を持ったデータ」だからトレイトなんですね。

スペックの切り出し

フォロー関連のUIテストがstatic_pagesにあるのはおかしいかと思い、別のスペックを作成、切り出しました。

spec/system/following_spec.rb
require "rails_helper"

RSpec.describe "StaticPages", type: :system do
  before do
    driven_by(:rack_test)
  end

  describe "/root" do
    describe "following and followers" do
      let(:user_with_relationships) { FactoryBot.create(:user, :with_relationships) }
      let(:following) { user_with_relationships.following.count }
      let(:followers) { user_with_relationships.followers.count }

      it "displays statistics for following and followers" do
        log_in user_with_relationships
        expect(page).to have_content("#{following} following")
        expect(page).to have_content("#{followers} followers")
      end
    end

    describe "feed" do
      let(:user) { FactoryBot.create(:user, :with_posts) }
      before do
        log_in user
      end

      it "displays correct feeds" do
        visit root_path
        user.feed.paginate(page: 1).each do |micropost|
          expect(page).to have_content(CGI.escapeHTML(micropost.content))
        end
      end
    end
  end
end

RuboCopの導入

本筋からは逸れますが、いつかやろうと思っていたrubocopの導入もしてみます。

RuboCopとは

Rubyの静的コード解析をしてくれるgemです。
導入することで、チームのコーディング規約に準拠した開発が可能になります。

導入

Gemfile に記述、 bundle install を実行します。

group :development do
  gem 'rubocop'
	gem 'rubocop-rails' # rails用
	gem 'rubocop-rspec' # rspec用
	gem 'pre-commit'    # コミット自動実行
end

VSCodeと連携する

settings.json に以下を追記します(拡張機能Rubyのインストールが必要)

// rubocop
"ruby.useLanguageServer": true,
"ruby.lint": {
    "rubocop": true
},
"ruby.format": "rubocop"

コミット時に自動で走らせる

コミットした際に自動で実行、問題が見つかればコミット出来ないようにします。

pre-commit gemを bundle install 後、次のコマンドを実行します。

bundle exec pre-commit install
git config pre-commit.checks rubocop # この変更はプロジェクト内にしか影響しない
bundle exec pre-commit list
  • pre-commit install : .git/hooks/pre-commit というファイルが作成される。
  • git config pre-commit.checks robocop : コミット時に実行されるようになる。
  • pre-commit list : コミット前に実行されるアクションを確認出来る。

なお、設定を解除したい場合は .git/hooks/pre-commit ファイルを削除する。

設定周り

ルールが適用されるのは設定用ファイルよりも下の階層。基本ルートに置く。

.rubocop.yml

設定ファイルです。

デフォルトだと厳しすぎる(行あたり文字数の制限や日本語コメント禁止など)ので、
チーム/個人のコーディングスタイルに合ったルールをこのファイルに書いていきます。

.rubodop_todo.yml

rubocop --auto-gen-config を実行することによって自動生成されます。
あまりに警告が多い時などに、rubocop --auto-gen-configにより警告内容を全てこのファイル内に移動させることが出来ます。
それ以降、このファイルに移動した警告は無視されます。(無視されるような設定がこのファイル内に生成される)

RuboCopを活用した修正の流れ

⓪ $ rubocop --auto-correct を実行して、自動で修正できるものはしてもらう。残りの警告がたくさんある場合> は①へ。警告がそんなに多くない場合(10~20個とか)は③と④を繰り返す。(Railsのコード規則を学ぶのにとても良い教材だと思> うので初めは⓪を飛ばすことをお勧めします。)

① 警告がたくさんあると見ずらいので **$ rubocop --auto-gen-config**を実行して .rubocop_todo.ymlを作> 成、そこに全ての警告をいったん移す。(こうすることで **$ rubocop**を実行しても今の段階では全ての警告は無視され> ます。)

② .rubocop_todo.yml 内の警告の中から一番上の警告をコメントアウトする。(コメントアウトした警告だけが再び> RuboCopに感知されるようになる)

③ $ rubocop を実行して警告を修正する。

④ 警告のデフォルトを変更したり、特定のファイルを今後RuboCopに警告されないたくないという場合は, .rubocop.yml> に設定を書く。

⑤ 修正し終わったら .rubocop_todo.yml に戻り、コメントアウトした警告を削除する。

⑥ .rubocop_todo.yml 内の全ての警告を修正し終わるまで②~⑤を繰り返す。

⑦ テストがある場合はテストを走らせる。

参考 : RuboCop is 何? - Qiita

警告例

RSpecへの置き換えが終了した時点でrubocopを実行した結果出た警告の例です。

Layout::ArgumentAlignment

メソッドの定義や呼び出しが複数行に渡る場合、引数の先頭が揃えてあること。

# good

foo :bar,
    :baz,
    key: value

foo(
  :bar,
  :baz,
  key: value
)

# bad

foo :bar,
  :baz,
  key: value

foo(
  :bar,
    :baz,
    key: value
)

Style::GuardClause

Guard Clauseは、条件分岐のネストを深くしないための技法のひとつで、「ガード節」「ガード条件」「ガード構文」などと訳されることがあります。

参考 : [Ruby/Rails] 例外で深くなったネストをGuard Clauseですっきりさせる|TechRacho by BPS株式会社

条件分岐がネストし過ぎないように、ガード節を使えとのこと。

# bad
def test
  if something
    a = 1
    print a
    work
  end
end

# good
def test
  return unless something
  a = 1
  print a
  work
end

Layout::TrailingEmptyLines

ファイルの最後には空行があること。
なぜファイル末尾に改行を入れるのか - Qiita

Style::MultilineTernaryOperator

三項演算子 condition ? something : else は複数行に分割しない。

下の例の場合は三項演算子の方がわかりやすい気がする。

# bad
def self.digest(string)
  cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                BCrypt::Engine.cost
  BCrypt::Password.create(string, cost: cost)
end

# good
def self.digest(string)
  if ActiveModel::SecurePassword.min_cost
    BCrypt::Password.create(string, cost: BCrypt::Engine::MIN_COST)
  else
    BCrypt::Password.create(string, cost: BCrypt::Engine.cost)
  end
end

Style::RedundantReturn

return省略可能なため。
明示的にreturnを書いたほうがわかりやすいケースもあると思うので、その場合は設定をオフにする。

Lint::AmbiguousOperator

メソッドの引数として渡される、曖昧なオペレーター。

# bad
expect {
    user.destroy
	}.to change(Micropost, :count).by -1

# good
expect {
    user.destroy
	}.to change(Micropost, :count).by(-1)

RSpec::ExampleLength

1exampleの行数。
デフォルトだと厳しすぎるので変更しました。

# exampleの行数
RSpec/ExampleLength:
  Max: 8

Metrics::BlockLength

1ブロックあたりの行数。

RSpecの場合デフォルトの値は現実的ではないので、除外します。

# RSpecのみ行数超過を許可
Metrics/BlockLength:
  Exclude:
    - 'spec/**/*'

RSpec::DescribedClass

described_classdescribe で指定したクラスを指す。

クラスをそのまま指定するよりも明示的でわかりやすい。

# bad
RSpec.describe User do
  subject {User.new}
end

# good
RSpec.describe User do
  subject {described_class.new}
end

RSpec::ContextWording

設定に使いたいPrefixを追加しました。

また、この警告への対処として'session is nil''if session is nil'にするなどの修正をしました。

# デフォルト : Start context description with 'when', 'with', or 'without'.
RSpec/ContextWording:
  Prefixes:
    - when
    - with
    - without
    - if
    - unless
    - for
    - as

RSpec::NotToNot

to_notよりもnot_toを使うべきらしい。

感想

「厳しすぎやろ!」と思うものから「なるほどな〜」となるものまで、様々でした。
自分/チームが気持ちよく開発出来るような.rubocop.ymlを作り上げていきたいです。

参考記事

その他修正点

  • シンプルなミスの修正
  • exampleを具体的に
    • テストの出力を見ただけでは中身がよくわからない箇所を改善しました。
  • exampleに一貫性を持たせる
    • 例えば、「ログインしていない」状態のテストはあっても「ログインしている」状態のテストが無いため追加、など。
  • 英語の修正
    • 「non-logged in user」は「anonymous user」と呼んだ方が適切(参考)なようなので、修正しました。
    • 「gest user」でも良かった?
  • 使用するメソッドに一貫性を持たせる
    • 謎に_path/_urlが混在していたので基本_path(相対パス)で統一しました。

使えていない便利そうな機能達

  • subject
    • テスト対象のオブジェクトを宣言、再利用出来るように。
  • shared_example
    • exampleの宣言、再利用。
  • shared_context
    • contextの宣言、再利用。
  • モックとスタブ
    • モックはDBへアクセスするような処理を減らすために使う。
    • スタブはオブジェクトのメソッドをオーバーライドし、テスト用の結果を返すダミーメソッド。DBやネットワークを使う処理が対象。
    • テストの速度改善や、再現の難しいデータのテストなどに利用すると良いらしい。
  • タグ
    • 特定のテストだけ実行/スキップ。
    • 新機能を追加する時、既存のコードのテストをスキップしたい時など。
  • shoulda-matchersの利用
    • EverydayRailsで推されている便利マッチャ。

まとめ

正直、RSpecに書き換えるだけでここまで大変だとは思っていませんでした。

  • RSpecとMinitestの構文や使い方の違い
  • 各Specの役割
  • どのように検証すれば良いのか
    • テストしたい項目はわかっても、どうテストコードに落とし込めば良いのかわからない
  • describe context の粒度
  • exampleの名前
    • なかなか一貫性を保てない
  • テスト用データの用意

などなど。
挙げるとキリがありませんが、かなり苦戦しました。

ですが、自分なりに試行錯誤しながらそれなりの規模のアプリケーションに対してテストを書いたことで、RSpecに対する理解はもちろん、ソフトウェアテストの片鱗くらいは理解出来た気がします。

RSpecの基礎学習から書き換え時の参照先として、Everyday Railsには非常に助けられました。
RSpecの基礎だけでなく、テストの考え方・原則まで幅広く学ぶことが出来ます。
非常におすすめの教材です。これからRSpecを学ぶ方はぜひ。

また、全体を通して回答例として利用させていただいた記事(RailsチュートリアルのテストをRSpecで書き換える)も非常に参考になりました。

参考記事

答え合わせ用に使用
RailsチュートリアルのテストをRSpecで書き換える

RSpecのREADME.md
https://github.com/rspec/rspec-rails

システムスペック/フィーチャスペック/リクエストスペックの違い
System specs, feature specs, request specs–what’s the difference?

CapybaraのREADME.md
capybara/README.md at master · teamcapybara/capybara

使えるRSpec入門
「Everyday Rails」の翻訳者である@jnchitoさんの書いた記事です。

1
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?