💎

RSpecでパラメタライズドテストを書く

2025/01/09に公開

パラメタライズドテストとは

パラメタライズドテスト(Parameterized Test)は、同じテストケースを異なる入力データセットで繰り返し実行するテスト手法です。

パラメタライズドテストを使わないでテストを書いてみる

以下のモデルのバリデーションに対してテストを書いてみます。

記事を管理するArticleモデルを作成しました。
記事は件名、本文、スラッグ、公開日時を持ちます。

以下のバリデーションを定義しました。

  • title(件名)
    • 必須
    • MAX255文字
  • body(本文)
    • 必須
    • MAX10000文字
  • slug(スラッグ)
    • 必須
    • MAX255文字
    • ユニーク
  • published_at_must_be_in_the_future(カスタムバリデーション)
    • published_atは未来であること

コードにすると以下のようになりました。

app/models/article.rb
class Article < ApplicationRecord
  validates :title, presence: true, length: { maximum: 255 }
  validates :body, presence: true, length: { maximum: 10000 }
  validates :slug, presence: true, length: { maximum: 255 }, uniqueness: true

  validate :published_at_must_be_in_the_future

  private

  def published_at_must_be_in_the_future
    if published_at.present? && published_at <= Time.zone.now
      errors.add(:published_at, "は未来の日時を指定してください")
    end
  end
end

テストは以下のようになりました。

spec/models/article_spec.rb
require "rails_helper"

RSpec.describe Article, type: :model do
  describe "validations" do
    describe "title" do
      context "空でない場合" do
        let(:article) { build(:article, title: "記事タイトル") }

        it "有効であること" do
          expect(article.valid?).to be_truthy
        end
      end

      context "nilの場合" do
        let(:article) { build(:article, title: nil) }

        it "無効であること" do
          expect(article.valid?).to be_falsey
          expect(article.errors[:title]).to include "を入力してください"
        end
      end

      context "空文字の場合" do
        let(:article) { build(:article, title: "") }

        it "無効であること" do
          expect(article.valid?).to be_falsey
          expect(article.errors[:title]).to include "を入力してください"
        end
      end

      context "文字数が254文字の場合" do
        let(:article) { build(:article, title: "a" * 254) }

        it "有効であること" do
          expect(article.valid?).to be_truthy
        end
      end

      context "文字数が255文字の場合" do
        let(:article) { build(:article, title: "a" * 255) }

        it "有効であること" do
          expect(article.valid?).to be_truthy
        end
      end

      context "文字数が256文字の場合" do
        let(:article) { build(:article, title: "a" * 256) }

        it "無効であること" do
          expect(article.valid?).to be_falsey
          expect(article.errors[:title]).to include "は255文字以内で入力してください"
        end
      end
    end

    describe "body" do
      context "空でない場合" do
        let(:article) { build(:article, body: "記事本文") }

        it "有効であること" do
          expect(article.valid?).to be_truthy
        end
      end

      context "nilの場合" do
        let(:article) { build(:article, body: nil) }

        it "無効であること" do
          expect(article.valid?).to be_falsey
          expect(article.errors[:body]).to include "を入力してください"
        end
      end

      context "空文字の場合" do
        let(:article) { build(:article, body: "") }

        it "無効であること" do
          expect(article.valid?).to be_falsey
          expect(article.errors[:body]).to include "を入力してください"
        end
      end

      context "文字数が9999文字以内の場合" do
        let(:article) { build(:article, body: "a" * 9999) }

        it "有効であること" do
          expect(article.valid?).to be_truthy
        end
      end

      context "文字数が10000文字の場合" do
        let(:article) { build(:article, body: "a" * 10000) }

        it "有効であること" do
          expect(article.valid?).to be_truthy
        end
      end

      context "文字数が10001文字の場合" do
        let(:article) { build(:article, body: "a" * 10001) }

        it "無効であること" do
          expect(article.valid?).to be_falsey
          expect(article.errors[:body]).to include "は10000文字以内で入力してください"
        end
      end
    end

    describe "slug" do
      context "空でない場合" do
        let(:article) { build(:article, slug: "article-slug") }

        it "有効であること" do
          expect(article.valid?).to be_truthy
        end
      end

      context "nilの場合" do
        let(:article) { build(:article, slug: nil) }

        it "無効であること" do
          expect(article.valid?).to be_falsey
          expect(article.errors[:slug]).to include "を入力してください"
        end
      end

      context "空文字の場合" do
        let(:article) { build(:article, slug: "") }

        it "無効であること" do
          expect(article.valid?).to be_falsey
          expect(article.errors[:slug]).to include "を入力してください"
        end
      end

      context "文字数が254文字以内の場合" do
        let(:article) { build(:article, slug: "a" * 254) }

        it "有効であること" do
          expect(article.valid?).to be_truthy
        end
      end

      context "文字数が255文字の場合" do
        let(:article) { build(:article, slug: "a" * 255) }

        it "有効であること" do
          expect(article.valid?).to be_truthy
        end
      end

      context "文字数が256文字の場合" do
        let(:article) { build(:article, slug: "a" * 256) }

        it "無効であること" do
          expect(article.valid?).to be_falsey
          expect(article.errors[:slug]).to include "は255文字以内で入力してください"
        end
      end

      context "slugがすでに存在する場合" do
        let!(:already_existing_article) { create(:article, slug: "already-existing-article") }
        let(:article) { build(:article, slug: "already-existing-article") }

        it "無効であること" do
          expect(article.valid?).to be_falsey
          expect(article.errors[:slug]).to include "はすでに存在しています"
        end
      end
    end

    describe '#published_at_must_be_in_the_future' do
      context 'published_atがnilの場合' do
        let(:article) { build(:article, published_at: nil) }

        it '有効であること' do
          expect(article.valid?).to be_truthy
        end
      end

      context 'published_atが未来の日付の場合' do
        let(:article) { build(:article, published_at: Time.zone.now + 1.day) }

        it '有効であること' do
          expect(article.valid?).to be_truthy
        end
      end

      context 'published_atが現在日時の場合' do
        let(:article) { build(:article) }

        it '無効であること' do
          freeze_time do
            article.published_at = Time.zone.now

            article.valid?
            expect(article.errors[:published_at]).to include "は未来の日時を指定してください"
          end
        end
      end

      context 'published_atが過去の日付の場合' do
        let(:article) { build(:article, published_at: Time.zone.now - 1.day) }

        it '無効であること' do
          article.valid?
          expect(article.errors[:published_at]).to include "は未来の日時を指定してください"
        end
      end
    end
  end
end

パラメタライズドテストを使ってテストを書いてみる

RSpecでパラメタライズドテストを実現するために gem rspec-parameterized を導入します。

Gemfile
gem 'rspec-parameterized'

READMEを見るといくつかサンプルが書いてあります。
以下のテストを見てみます。

describe "plus" do
  where(:a, :b, :answer) do
    [
      [1 , 2 , 3],
      [5 , 8 , 13],
      [0 , 0 , 0]
    ]
  end

  with_them do
    it "should do additions" do
      expect(a + b).to eq answer
    end
  end

whereブロックにテストに渡すパラメータを定義しています。
with_themブロックにてパラメータを受け取ってテストを書きます。

先ほど書いたArticleモデルのテストをパラメタライズドテストを使って書き換えると以下のようになりました。

app/models/article.rb
RSpec.describe Article, type: :model do
  describe "validations" do
    describe 'title' do
      let(:article) { build(:article) }

      where(:case_name, :it, :value, :is_valid, :error_message) do
        [
          ['空でない場合', '有効であること', '記事タイトル', true, []],
          ['nilの場合', '無効であること', nil, false, ["を入力してください"]],
          ['空文字の場合', '無効であること', nil, false, ["を入力してください"]],
          ['文字数が254文字の場合', '無効であること', "a" * 254, true, []],
          ['文字数が255文字の場合', '無効であること', "a" * 255, true, []],
          ['文字数が256文字の場合', '無効であること', "a" * 256, false, ["は255文字以内で入力してください"]],
        ]
      end

      with_them do
        it "#{params[:it]}" do
          article.title = value
          expect(article.valid?).to eq is_valid
          expect(article.errors[:title]).to eq error_message
        end
      end
    end

    describe 'body' do
      let(:article) { build(:article) }

      where(:case_name, :it, :value, :is_valid, :error_message) do
        [
          ['空でない場合', '有効であること', '記事タイトル', true, []],
          ['nilの場合', '無効であること', nil, false, ["を入力してください"]],
          ['空文字の場合', '無効であること', nil, false, ["を入力してください"]],
          ['文字数が9999文字の場合', '無効であること', "a" * 9999, true, []],
          ['文字数が10000文字の場合', '無効であること', "a" * 10000, true, []],
          ['文字数が10001文字の場合', '無効であること', "a" * 10001, false, ["は10000文字以内で入力してください"]],
        ]
      end

      with_them do
        it "#{params[:it]}" do
          article.body = value
          expect(article.valid?).to eq is_valid
          expect(article.errors[:body]).to eq error_message
        end
      end
    end

    describe 'slug' do
      let!(:already_existing_article) { create(:article, slug: "already-existing-article") }
      let(:article) { build(:article) }

      where(:case_name, :it, :value, :is_valid, :error_message) do
        [
          ['空でない場合', '有効であること', 'article-slug', true, []],
          ['nilの場合', '無効であること', nil, false, ["を入力してください"]],
          ['空文字の場合', '無効であること', nil, false, ["を入力してください"]],
          ['文字数が254文字の場合', '無効であること', "a" * 254, true, []],
          ['文字数が255文字の場合', '無効であること', "a" * 255, true, []],
          ['文字数が256文字の場合', '無効であること', "a" * 256, false, ["は255文字以内で入力してください"]],
          ['slugがすでに存在する場合', '無効であること', 'already-existing-article', false, ["はすでに存在しています"]],
        ]
      end

      with_them do
        it "#{params[:it]}" do
          article.slug = value
          expect(article.valid?).to eq is_valid
          expect(article.errors[:slug]).to eq error_message
        end
      end
    end

    describe '#published_at_must_be_in_the_future' do
      let(:article) { build(:article) }

      around do |example|
        freeze_time { example.run }
      end

      where(:case_name, :it, :value, :is_valid, :error_message) do
        now = Time.zone.now
        [
          ['published_atがnilの場合', '有効であること', nil, true, []],
          ['published_atが未来の日付の場合', '有効であること', now + 1.day, true, []],
          ['published_atが現在日時の場合', '無効であること', now, false, ["は未来の日時を指定してください"]],
          ['published_atが過去の日付の場合', '無効であること', now - 1.day, false, ['は未来の日時を指定してください']],
        ]
      end

      with_them do
        it "#{params[:it]}" do
          article.published_at = value
          expect(article.valid?).to eq is_valid
          expect(article.errors[:published_at]).to eq error_message
        end
      end
    end
  end
end

テストパターンが網羅できているかひと目でわかり、見やすくなったのではないでしょうか?

まとめ

rspec-parameterizedを使うこと直感的に値が評価されるテストを書くことができるようになりました。

また、可読性が向上し、プルリクエストのレビューの際も認知負荷が減るのではないでしょうか?

Discussion