0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rails APIでBook・VerseモデルのRSpecを書いて学んだこと

0
Last updated at Posted at 2026-02-26

はじめに

Rails APIモードでデータを扱うアプリを開発中です。
今回は、Bookモデル、VerseモデルのRSpecを実装しました。

単に「テストを通す」ことではなく、
・モデルの責務とは何か
・テストは何を保証するのか
・RSpecの構造(let / context / before)
・uniquenessとDB制約の関係
・scopeのテスト設計
を意識して取り組みました。

この記事では、実装しながら理解が深まっていった流れをまとめます。
※今回はFactoryBotは使わず、RSpecの基礎理解を優先しました。

実装には、こちらの記事を参考にさせていただきました。

Bookモデルで学んだこと

Bookモデル

class Book < ApplicationRecord
  has_many :chapters, dependent: :restrict_with_exception

  enum category: {
    type_a: 0,
    type_b: 1
  }

  validates :title, presence: true
  validates :slug, presence: true, uniqueness: true
  validates :category, presence: true
  validates :position,
            presence: true,
            numericality: { only_integer: true },
            uniqueness: true
  validates :chapter_count,
            presence: true,
            numericality: { only_integer: true, greater_than: 0 }
end

BookモデルのRSpecの完成系

require "rails_helper"

RSpec.describe Book, type: :model do
  describe "associations" do
    it "raises an error when destroying a book with verses" do
      book = Book.create!(
        name: "Sample Book 1",
        slug: "sample-book-1",
        testament: :old_testament,
        total_chapters: 50,
        position: 1
      )

      book.verses.create!(
        chapter: 1,
        verse: 1,
        text: "This is a sample sentence."
      )

      expect { book.destroy }
        .to raise_error(ActiveRecord::DeleteRestrictionError)
    end
  end

  describe "validations" do
    it "is valid with all required attributes" do
      book = Book.new(
        name: "Sample Book 1",
        slug: "sample-book-1",
        testament: :old_testament,
        total_chapters: 50,
        position: 1
      )
      expect(book).to be_valid
    end

    describe "name" do
      it "is invalid without a name" do
        book = Book.new(
          name: nil,
          slug: "sample-book-1",
          total_chapters: 50,
          position: 1
        )
        expect(book).not_to be_valid
      end
    end

    describe "slug" do
      it "is invalid without a slug" do
        book = Book.new(
          name: "Sample Book 1",
          slug: nil,
          testament: :old_testament,
          total_chapters: 50,
          position: 1
        )
        expect(book).not_to be_valid
      end

      it "is invalid with a duplicate slug" do
        Book.create!(
          name: "Sample Book 1",
          slug: "sample-book-1",
          testament: :old_testament,
          total_chapters: 50,
          position: 1
        )

        book = Book.new(
          name: "Sample Book 2",
          slug: "sample-book-1",
          testament: :old_testament,
          total_chapters: 40,
          position: 2
        )

        expect(book).not_to be_valid
      end
    end

    describe "testament" do
      it "is invalid without a testament" do
        book = Book.new(
          name: "Sample Book 1",
          slug: "sample-book-1",
          testament: nil,
          total_chapters: 50,
          position: 1
        )
        expect(book).not_to be_valid
      end
    end

    describe "position" do
      it "is invalid without a position" do
        book = Book.new(
          name: "Sample Book 1",
          slug: "sample-book-1",
          testament: :old_testament,
          total_chapters: 50,
          position: nil
        )
        expect(book).not_to be_valid
      end

      it "is invalid with a duplicate position" do
        Book.create!(
          name: "Sample Book 1",
          slug: "sample-book-1",
          testament: :old_testament,
          total_chapters: 50,
          position: 1
        )

        book = Book.new(
          name: "Sample Book 2",
          slug: "sample-book-2",
          testament: :old_testament,
          total_chapters: 40,
          position: 1
        )

        expect(book).not_to be_valid
      end

      it "is invalid with a decimal value" do
        book = Book.new(
          name: "Sample Book 1",
          slug: "sample-book-1",
          testament: :old_testament,
          total_chapters: 50,
          position: 1.5
        )
        expect(book).not_to be_valid
      end

      it "is invalid with a non-integer value" do
        book = Book.new(
          name: "Sample Book 1",
          slug: "sample-book-1",
          testament: :old_testament,
          total_chapters: 50,
          position: "abc"
        )
        expect(book).not_to be_valid
      end
    end

    describe "total_chapters" do
      it "is invalid without total_chapters" do
        book = Book.new(
          name: "Sample Book 1",
          slug: "sample-book-1",
          testament: :old_testament,
          total_chapters: nil,
          position: 1
        )
        expect(book).not_to be_valid
      end

      it "is invalid with a decimal value" do
        book = Book.new(
          name: "Sample Book 1",
          slug: "sample-book-1",
          testament: :old_testament,
          total_chapters: 1.5,
          position: 1
        )
        expect(book).not_to be_valid
      end

      it "is invalid with a non-integer value" do
        book = Book.new(
          name: "Sample Book 1",
          slug: "sample-book-1",
          testament: :old_testament,
          total_chapters: "aaa",
          position: 1
        )
        expect(book).not_to be_valid
      end

      it "is invalid when total_chapters is 0" do
        book = Book.new(
          name: "Sample Book 1",
          slug: "sample-book-1",
          testament: :old_testament,
          total_chapters: 0,
          position: 1
        )
        expect(book).not_to be_valid
      end
    end
  end
end

テスト設計で意識したこと

1. 正常系を一つ定義する

  it "is valid with all required attributes" do
    book = Book.new(
      title: "Sample Book",
      slug: "sample-book",
      category: :type_a,
      chapter_count: 10,
      position: 1
    )
  
    expect(book).to be_valid
  end

これは、正しいBookの形を定義するテストです。
まず正解を作ることが大切だと理解しました。

2. 異常系は責務ごとに分ける

今回のバリデーションである、presence、uniqueness、numericalityについて、
それぞれ分離してっ買うことで、どの責務が壊れたのかわかるテストになります。

3. uniquenessで学んだこと

Book.create!(...)

duplicate = Book.new(...)

expect(duplicate).not_to be_valid

ここで学んだこと:
・uniquenessはDBを参照するため、事前に保存されたデータが必要
・create!を使うと失敗時に即座に気づける

4. depentdentのテストで学んだこと

expect { book.destroy }
  .to raise_error(ActiveRecord::DeleteRestrictionError)

このテストでは、UIやコントローラ、APIの構造が変わっても、削除ルールが壊れないことを保証いています。

ここで学んだこと:

  • dependent: :restrict_with_exceptionをテストする理由は、単に削除時にエラーが出るかを確認したいからではなく、データの整合性を保証するため。
    Railsのモデルの役割は、データ構造の定義、バリデーションの定義、データの整合性の保証。

    例)今回の

    has_many :chapters, dependent: :restrict_with_exception
    

    は、子データ(chapters)が存在する限り、親(book)を削除させない、というルール。
    しかしこの制約がなかったら、Bookだけが削除され、ChapterはDBに残り、結果的にどのBookにも属さないデータが生まれてしまう。これは整合性が壊れた状態。
    つまり、モデルは“データの形”だけでなく“データの振る舞い”も定義する場所だということ。

→このような機能を持つモデルのdependentのテストをするという意味は、
・削除の振る舞いを保証する
・データの一貫性を守る
・モデルの責務を明確にする
という意味を持っています。
モデルは「保存できるか」だけでなく、「削除しても壊れないか」まで設計する場所であると学びました。

Verseモデルで学んだこと

Bookモデルでバリデーションのテストは理解できました。
次に意識したのは、 RSpecの構造についてです。

Verseモデル

class Verse < ApplicationRecord
  belongs_to :book

  validates :chapter,
            presence: true,
            numericality: { only_integer: true, greater_than: 0 }

  validates :verse,
            presence: true,
            numericality: { only_integer: true, greater_than: 0 },
            uniqueness: { scope: [:book_id, :chapter] }

  validates :text, presence: true

  scope :by_book_and_chapter, ->(book_id, chapter) {
    where(book_id: book_id, chapter: chapter).order(:verse)
  }
end

VerseモデルのRSpecの完成形

require "rails_helper"

RSpec.describe Verse, type: :model do
  # データ入力
  let(:book) do
    Book.create!(
      name: "Sample Book 1",
      slug: "sample-book-1",
      testament: :old_testament,
      position: 1,
      total_chapters: 50
    )
  end

  let(:verse) do
    Verse.new(
      book: book,
      chapter: 1,
      verse: 1,
      text: "This is a sample sentence."
    )
  end

  # 関係性
  describe "associations" do
    it "is invalid without a book" do
      verse.book = nil
      expect(verse).not_to be_valid
    end
  end

  # バリデーション
  describe "validations" do
    it "is valid with valid attributes" do
      expect(verse).to be_valid
    end

    # chapter
    describe "chapter" do
      context "when nil" do
        before { verse.chapter = nil }
        it { expect(verse).not_to be_valid }
      end

      context "when 0" do
        before { verse.chapter = 0 }
        it { expect(verse).not_to be_valid }
      end

      context "when not integer" do
        before { verse.chapter = 1.5 }
        it { expect(verse).not_to be_valid }
      end
    end

    # verse
    describe "verse" do
      context "when nil" do
        before { verse.verse = nil }
        it { expect(verse).not_to be_valid }
      end

      context "when 0" do
        before { verse.verse = 0 }
        it { expect(verse).not_to be_valid }
      end

      context "when not integer" do
        before { verse.verse = 1.5 }
        it { expect(verse).not_to be_valid }
      end

      describe "uniqueness" do
        before do
          Verse.create!(
            book: book,
            chapter: 1,
            verse: 1,
            text: "This is a sample sentence."
          )
        end

        context "when same book and same chapter" do
          it "is invalid" do
            duplicate = Verse.new(
              book: book,
              chapter: 1,
              verse: 1,
              text: "Duplicate content."
            )
            expect(duplicate).not_to be_valid
          end
        end

        context "when same book but different chapter" do
          it "is valid" do
            different = Verse.new(
              book: book,
              chapter: 2,
              verse: 1,
              text: "Another sample sentence."
            )
            expect(different).to be_valid
          end
        end

        context "when different book but same chapter" do
          let(:other_book) do
            Book.create!(
              name: "Sample Book 2",
              slug: "sample-book-2",
              testament: :old_testament,
              position: 2,
              total_chapters: 40
            )
          end

          it "is valid" do
            different = Verse.new(
              book: other_book,
              chapter: 1,
              verse: 1,
              text: "Another sample sentence."
            )
            expect(different).to be_valid
          end
        end
      end
    end

    # text
    describe "text" do
      it "is invalid when nil" do
        verse.text = nil
        expect(verse).not_to be_valid
      end
    end
  end

  # スコープ
  describe "scopes" do
    describe ".by_book_and_chapter" do
      let(:other_book) do
        Book.create!(
          name: "Sample Book 2",
          slug: "sample-book-2",
          testament: :old_testament,
          position: 2,
          total_chapters: 40
        )
      end

      before do
        # 対象データ(順番バラバラに入れる)
        Verse.create!(book: book, chapter: 1, verse: 2, text: "Second sentence.")
        Verse.create!(book: book, chapter: 1, verse: 1, text: "First sentence.")

        # ノイズ
        Verse.create!(book: book, chapter: 2, verse: 1, text: "Other chapter.")
        Verse.create!(book: other_book, chapter: 1, verse: 1, text: "Other book.")
      end

      it "returns only verses for the given book and chapter" do
        result = Verse.by_book_and_chapter(book.id, 1)
        expect(result.size).to eq(2)
      end
      
      it "orders verses by verse number in ascending order" do
        result = Verse.by_book_and_chapter(book.id, 1)
        expect(result.map(&:verse)).to eq([1, 2])
      end
    end
  end
end

1. letについて

最初のテストでは毎回Model.new(...)を書いていました。
しかしそれだと冗長になるので、letを導入。

let(:book) { Book.create!(...) }

let(:verse) do
  Verse.new(
    book: book,
    chapter: 1,
    verse: 1,
    text: "text"
  )
end

こうすることで、毎回Model.new(...)を書いてデータを入力する必要もなくなりました。

2. context / beforeの適した使い方

  • context
    contextは、特定の条件や状況を示すもので、条件ごとにテストをグループ化するもの。
    最初はなんとなく使っていましたが、整理すると
    ・条件が1つ → context不要
    ・条件が複数 → contextで整理
    であることがわかりました。

  • before
    beforeは複数のテストで同じ前提条件をまとめてセットアップできるもの。
    ※letとの違い:

    • let
      呼び出された時に実行される。
      値(オブジェクト)を返す。
      →基準となるデータを定義
    • before
      各テストの前に必ず実行される。
      値は返さない。
      →前提条件を作るための

    このbeforeに関しても、複数テストで同じ前提条件を使う時だけ必要であるということを学びました。一つのitなら直接書けばいい。

    it "is invalid when text is nil" do
      verse.text = nil
      expect(verse).not_to be_valid
    end
    

    beforeは整理のためであって、必須ではない。

3. uniqueness(scope付き)のテストについて

uniquenessのテストは、
・同じbook AND 同じchapterの場合NG
・bookが違えば、chapterとverseは同じでもOK
というテストをする必要がある。
【基準となるVerseのデータを入力し、NGパターンとOKパターンをテスト】
まずは基準となるデータを作成。

before do
  Verse.create!(
      book: book,
      chapter: 1,
      verse: 1,
      text: "This is a sample sentence."
  )
end

そこから、NGパターンとOKパターンをテスト。

  • NGパターン
    bookとchapterが同じ場合
    context "when same book and same chapter" do
      it "is invalid" do
        duplicate = Verse.new(...)
        expect(duplicate).not_to be_valid
      end
    
  • OKパターン
    • bookは同じだがchapterが違う場合
      context "when same book but different chapter" do
          it "is valid" do
            different = Verse.new(...)
            expect(different).to be_valid
          end
        end
      
    • bookは違うがchapterが同じ場合
      it "is valid" do
        different = Verse.new(
          book: other_book,
          chapter: 1,
          verse: 1,
          text: "Another sample sentence."
        )
        expect(different).to be_valid
      end
      

まとめ

今回はあえてFactoryBotを使いませんでした。
理由は、RSpecの構造理解、let / context / before の役割などを学ぶためです。

今回の学習を通して、RSpecは書いたコードが正しく動くかを確認するためのものではなく、このモデルは何を責務として持っているのかを自分自身に問い直す作業でもあると感じました。
RSpecを書くことは、コードの品質を上げる行為であると同時に、設計を言語化する行為でもある。

今回の実装を通して、テストは“通すもの”ではなく“保証を設計するもの”だと理解できたことが、一番の収穫でした。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?