はじめに
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 endbeforeは整理のためであって、必須ではない。
- let
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を書くことは、コードの品質を上げる行為であると同時に、設計を言語化する行為でもある。
今回の実装を通して、テストは“通すもの”ではなく“保証を設計するもの”だと理解できたことが、一番の収穫でした。