shoulda-matchersの使い方【8.0対応】|設定・主要マッチャー・バージョン選定
shoulda-matchersは、Railsモデルのバリデーションやアソシエーションが宣言どおり定義されているかを1行で検証できるgemです。thoughtbot社が開発し、2026年6月12日に最新の8.0.1がリリースされました。8.0系はRuby 3.3以上・Rails 7.2以上が必須で、それより古い環境では別のバージョンを指定する必要があります。この記事では、Gemfileへの追加からrails_helper.rbの設定、バリデーションとアソシエーションの実際の書き方、バージョンの選び分け、そして初見では原因がわかりにくいExistingRecordInvalidエラーの対処までを、公式リポジトリのソースとリリースノートに基づいて整理します。
まとめ
- 最新は8.0.1(2026年6月12日)です。Gemfileの
testグループにgem 'shoulda-matchers', '~> 8.0'を書き、bundle installを実行します。 - 設定はrails_helper.rb末尾に
Shoulda::Matchers.configureブロックを1つ置くだけです。追加のrequireは要りません。 - 8.0系の必須要件はRuby 3.3以上・Rails 7.2以上です。Ruby 3.2やRails 7.1が残っているなら7.0.1、Rails 6.1〜7.0なら6.5.0を指定します。
- 主戦場はモデルテストです。
validate_presence_ofやbelong_toのような宣言の検証に絞って使います。 validate_uniqueness_ofだけは内部で既存レコードを作るため、NOT NULL制約のある列があるとExistingRecordInvalidで落ちます。subjectに有効なレコードを渡して回避します。- 業務ロジックのテストには向きません。公式READMEも、フレームワーク側の設定漏れを防ぐ道具として位置づけています。
ここから先は、実際のコードとバージョン情報で1つずつ確認していきます。
shoulda-matchersが肩代わりする記述と素のRSpecとの差
バリデーション1件あたり5行から1行への短縮
Railsのモデルにvalidates :name, presence: trueと書いたとき、その宣言が効いているかを素のRSpecで確認しようとすると、インスタンスを作って属性をnilにしてvalid?を呼び、エラーメッセージを検証する、という手順になります。
# 素のRSpecで書いた場合
RSpec.describe User, type: :model do
it 'name が必須であること' do
user = User.new(name: nil)
user.valid?
expect(user.errors[:name]).to include("can't be blank")
end
end
# shoulda-matchers を使った場合
RSpec.describe User, type: :model do
it { should validate_presence_of(:name) }
end
5行が1行になります。ただし検証している内容は同じではありません。前者はエラーメッセージの文言まで確認しているのに対し、後者は「presenceバリデーションが宣言されていること」だけを確認します。属性が10個・20個と増えるモデルほど、後者の記述量削減が効いてきます。RSpec自体の考え方から確認したい場合はRSpecとは何か?テスト駆動開発におけるRSpecの役割と概要を先に読むと、以降のコードが追いやすくなるはずです。
RSpecとMinitestの両対応、マッチャーが効く範囲
gem名にshouldが入っているためRSpec専用と誤解されがちですが、Minitestでも同じマッチャーが使えます。書き方はit { should ... }がshould ...に変わる程度です。
一方、マッチャーが提供される範囲はActiveModel・ActiveRecord・ActionController・ルーティング、そしてdelegate_methodのような単独マッチャーに限られます。ブラウザ操作を伴うE2Eテストや、HTTPリクエストを通した結合テストは対象外です。前者はRSpecでCapybaraとPlaywrightを導入するための基本手順、後者はリクエストスペックとは?基本概念と目的についての説明で扱う領域になります。
インストールとRSpec設定:Gemfile追加からrails_helper.rbの記述まで
Gemfileのtestグループへの追加
公式READMEが示す記述は、testグループ限定での追加です。開発環境や本番環境に読み込ませる必要はありません。
# Gemfile
group :test do
gem 'shoulda-matchers', '~> 8.0'
end
記述後にbundle installを実行します。制約を'~> 8.0'と書くと8.0以上9.0未満が対象になり、8.0.1のようなパッチ更新は自動で取り込まれます。注意したいのはバージョン指定を省略した場合です。BundlerはRubyのバージョン要件に合わせて解決するため、Ruby 3.2の環境では8.0系が候補から外れ、7.0系が入ります。エラーにはならず古いマッチャーで動いてしまうので、バージョンは必ず明示してください。
Railsアプリでの設定(rails_helper.rb末尾)
gemを入れただけではマッチャーは使えません。どのライブラリのマッチャーを、どのテストフレームワークに組み込むかを設定ブロックで宣言します。Railsアプリの場合はspec/rails_helper.rbの末尾に次を置きます。
# spec/rails_helper.rb の末尾
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
end
with.library :railsは、ActiveModel・ActiveRecord・ActionController・ルーティングの4つをまとめて有効化する指定です。ソース上もSUB_LIBRARIESとしてこの4つが列挙されています。この設定ブロックを書き忘れるとundefined method 'validate_presence_of'のようなNoMethodErrorになります。導入直後にマッチャーが認識されない場合は、まずこのブロックの有無と配置場所を確認してください。
Rails以外のプロジェクトでの設定
Railsを使わずActiveRecordやActiveModelだけを利用しているプロジェクトでも導入できます。この場合は:railsではなく、必要なライブラリを個別に指定してspec/spec_helper.rbの末尾に置きます。
# spec/spec_helper.rb の末尾(Rails を使わない場合)
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
# 使う分だけ残す
with.library :active_record
with.library :active_model
end
end
非Rails環境ではもう一点、注意が要ります。Railsプロジェクトではexample groupのタグに応じてマッチャーが自動で読み込まれますが、非Rails環境ではその仕組みが働きません。include Shoulda::Matchers::ActiveRecordのようにモジュールを明示的にincludeするか、RSpec.configureでconfig.include(Shoulda::Matchers::ActiveModel, type: :model)と指定します。
Minitestで使う場合のtest_helper.rb
Minitest環境でも設定の形は同じで、test_helper.rbの末尾に置きます。
# Gemfile
group :test do
gem 'shoulda-matchers', '~> 8.0'
gem 'shoulda-context', '~> 2.0' # context, should 記法が必要なら
end
# test/test_helper.rb の末尾
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :minitest
with.library :rails
end
end
公式READMEはumbrella gemのshoulda経由を案内していますが、2026年8月時点のshouldaは4.0.0(2020年6月13日)が最新で、依存がshoulda-matchers ~> 4.0に固定されています。shoulda経由では8.0系は入りません。8.0系のマッチャーを使いたいMinitestプロジェクトでは、上のようにshoulda-matchersを直接指定し、contextとshouldの記法が必要ならshoulda-contextを単体で追加してください。
バージョン選定:Ruby・Railsの対応要件と8.0系の破壊的変更
8.0.1時点の対応バージョン早見表
shoulda-matchersはメジャーバージョンごとに旧Rails・旧Rubyのサポートを切っていく方針です。各バージョンのREADMEが明示している対応関係は次のとおりです。
| バージョン | リリース日 | 対応Ruby | 対応Rails |
|---|---|---|---|
| 8.0.1 | 2026-06-12 | 3.3以上 | 7.2以上 |
| 7.0.1 | 2025-10-31 | 3.2以上 | 7.1以上 |
| 6.5.0 | 2025-04-25 | 3.0以上 | 6.1以上 |
| 4.5.1 | – | 2.4以上 | 4.2以上 |
Rails 7.2以上かつRuby 3.3以上なら8.0系、Rails 7.1が残っているかRuby 3.2で止まっているなら7.0.1、Rails 6.1〜7.0なら6.5.0を選びます。これより古い環境には、Ruby 2.4未満・Rails 4.1未満向けにv3.1.3という案内先も残っています。
8.0.0で削除されたRails 7.1・Ruby 3.2サポート
8.0.0のリリースノートは、後方互換性のない変更としてRails 7.1とRuby 3.2のサポート終了を明記しています。8.0系へ上げる際の最大の障壁がこれです。
記法の面でも1つ落とし穴があります。define_enum_forにwith_optionsをチェーンしていたコードは、8.0.0からNotImplementedErrorで落ちるようになりました。ソースには「with_options is not a valid qualifier for the define_enum_for matcher. Did you mean to use with_values instead?」という例外メッセージが実装されています。7.x系ではActiveSupportのObject#with_optionsに吸われて素通りしていた記述なので、8.0系へ上げる前にwith_valuesへ置き換えてください。逆にいえば、この1点と実行環境の要件さえ満たせば、既存のテストコードはほぼそのまま動きます。
8.0系で追加された複数属性の一括検証
8.0.0の新機能で実務に効くのが、1つのマッチャーへ複数の属性をまとめて渡せるようになった点です。属性ごとにitを並べる必要がなくなりました。
class Person
include ActiveModel::Model
attr_accessor :first_name, :last_name
validates_presence_of :first_name, :last_name
validates_length_of :first_name, :last_name, maximum: 50
end
RSpec.describe Person, type: :model do
# 8.0 以降は複数属性をまとめて渡せる
it { expect(subject).to validate_presence_of(:first_name, :last_name) }
# チェーンした修飾子は渡した全属性に一律で適用される
it { expect(subject).to validate_length_of(:first_name, :last_name).is_at_most(50) }
end
対応しているのはActiveModelのvalidate_*系9個に、have_readonly_attributeとhave_one_attachedを加えた11個です。修飾子は渡した全属性に同じ条件として適用されるため、属性ごとに条件が違う場合はまとめられません。いずれかの属性で失敗した場合、失敗メッセージには落ちた属性とその理由が属性ごとに列挙されます。
モデルテストの書き方:バリデーションとアソシエーションの記述例
バリデーション系マッチャーの基本形
モデルテストで最も使うのがActiveModelのバリデーション系マッチャーです。validatesで宣言したものに対応するマッチャーが用意されています。
RSpec.describe User, type: :model do
describe 'validations' do
it { should validate_presence_of(:email) }
it { should validate_length_of(:name).is_at_most(50) }
it { should validate_numericality_of(:age).is_greater_than_or_equal_to(0) }
it { should validate_inclusion_of(:role).in_array(%w[admin member guest]) }
it { should validate_confirmation_of(:password) }
it { should have_secure_password }
end
end
has_secure_passwordを宣言したモデルでパスワードのpresenceを検証する場合は注意が要ります。subjectにパスワードが設定済みのレコードを渡すと、「passwordが設定済みのuserに対してvalidate_presence_ofが呼ばれた。空のpasswordを持つuserを使ってほしい」という趣旨のエラーになります。has_secure_passwordがpasswordをnilにできない形にモデルを上書きするためで、マッチャー側の不具合ではありません。
アソシエーション系マッチャーとオプション指定の検証
ActiveRecordのアソシエーションは、関連の有無だけでなくオプションまで検証できます。修飾子をチェーンする形です。
RSpec.describe Post, type: :model do
describe 'associations' do
it { should belong_to(:user) }
it { should belong_to(:category).class_name('PostCategory').optional }
it { should belong_to(:organization).counter_cache(true) }
it { should have_many(:comments).dependent(:destroy) }
it { should have_many(:tags).through(:taggings).source(:tag) }
it { should have_one(:profile).inverse_of(:post) }
it { should have_and_belong_to_many(:groups) }
it { should have_one_attached(:thumbnail) }
end
end
マッチャー名がhave_and_belong_to_manyと単数形である点に注意してください。モデル側の宣言はhas_and_belongs_to_manyで複数形です。写し間違えるとNoMethodErrorになります。
チェーンできる修飾子はclass_name、with_foreign_key、dependent、through、source、counter_cache、inverse_of、optional、required、validate、autosave、touch、order、index_errors、strict_loadingなどです。counter_cache(true)のようにオプション値まで固定できるため、キャッシュカラムの設定漏れを検知できます。counter_cache自体の実装とカウントずれの対処はcounter_cacheとは?Railsでの実装手順とカウントずれ・後付けの対処、関連の種類そのものの整理はRailsのAssociationとは?にまとめています。
subjectの上書きとFactoryBotでの有効レコード用意
バリデーションのテストでは、まっさらなModel.newではなく有効なレコードをsubjectに渡すのが定石です。公式READMEも「迷ったらテスト対象クラスのインスタンスを渡す」と明記しています。
RSpec.describe Post, type: :model do
describe 'validations' do
# FactoryBot で必須属性が埋まったレコードを用意する
subject { build(:post) }
it { should validate_presence_of(:title) }
end
end
この形にしておくと、次章で扱うExistingRecordInvalidも同時に避けられます。subjectを上書きしない場合、RSpecはdescribeに渡したクラスのnewを暗黙のsubjectとして使います。必須属性が多いモデルほど、この暗黙のsubjectが原因で落ちるケースが増えるわけです。
validate_uniqueness_ofでExistingRecordInvalidが出る原因と回避手順
validate_uniqueness_ofは、他のマッチャーと動作原理が違います。一意性は「既に同じ値のレコードが存在するとき」にしか検証できないため、このマッチャーはまずDBに既存レコードがあるかを調べ、無ければ自分でINSERTします。そのINSERTが、テスト対象と無関係な列のNOT NULL制約に引っかかって失敗する、というのが典型的なつまずきです。
class CreatePosts < ActiveRecord::Migration[7.2]
def change
create_table :posts do |t|
t.string :title
t.text :content, null: false # ← これが原因になる
end
end
end
class Post < ActiveRecord::Base
validates :title, uniqueness: true
end
# この書き方は例外で落ちる
RSpec.describe Post, type: :model do
it { should validate_uniqueness_of(:title) }
end
このとき出るのは通常のテスト失敗ではなく例外です。PostgreSQLなら次のようなメッセージになります。
Shoulda::Matchers::ActiveRecord::ValidateUniquenessOfMatcher::ExistingRecordInvalid:
validate_uniqueness_of works by matching a new record against an
existing record. If there is no existing record, it will create one
using the record you provide.
While doing this, the following error was raised:
PG::NotNullViolation: ERROR: null value in column "content" violates
not-null constraint
DETAIL: Failing row contains (1, null, null).
The best way to fix this is to provide the matcher with a record where
any required attributes are filled in with valid values beforehand.
末尾の1文がそのまま対処法です。必須属性を埋めたレコードを事前に用意し、subjectとして渡します。前章のsubject { build(:post) }のようにFactoryBot経由でも構いませんし、ファクトリが無ければ直接組み立てても同じです。
RSpec.describe Post, type: :model do
describe 'validations' do
subject { Post.new(content: 'Here is the content') }
it { should validate_uniqueness_of(:title) }
end
end
複合ユニーク制約を張っている場合はscoped_toで表現します。マッチャー側だけ付けても通らないので、モデルの宣言と対になっているかを確認してください。
# モデル側が scope 付きで宣言されているときだけ有効
class Post < ActiveRecord::Base
belongs_to :category
validates :title, uniqueness: { scope: :category_id }
end
RSpec.describe Post, type: :model do
subject { Post.new(content: 'Here is the content', category: Category.new) }
it { should validate_uniqueness_of(:title).scoped_to(:category_id) }
end
MySQLやSQLiteでもドライバ由来のメッセージが変わるだけで、原因と対処は同じです。DBスキーマ側にNOT NULLを増やしたタイミングで、一見無関係な一意性テストが突然落ち始めるのはこの挙動によります。制約を追加するPRでは、一意性テストのsubjectがファクトリ経由になっているかを併せて確認しておくと再発を防げます。
コントローラ・ルーティングのマッチャーとsubjectで壊れる書き方
permit・filter_param・ルーティングの検証
ActionController向けにも、Strong Parametersやコールバック、ルーティングの宣言を検証するマッチャーがあります。
RSpec.describe PostsController, type: :controller do
it { should permit(:title, :body).for(:create, params: { post: { title: 'a' } }) }
it { should use_before_action(:authenticate_user!) }
it { should filter_param(:password) }
end
RSpec.describe 'routes for Posts', type: :routing do
# routing グループではコントローラを明示する
it { should route(:get, '/posts').to(controller: :posts, action: :index) }
end
ただし、このうちコントローラスペックを積極的に使う場面は限られます(ルーティングスペックはこの指摘の対象外です)。rspec-railsのREADMEが「RSpec 3.5以降、RailsチームとRSpecチームのいずれも、リクエストスペックのような機能テストを優先し、コントローラを直接テストすることを推奨していない」と明記しているためです。コントローラ系マッチャーは、permitやfilter_paramのようにリクエストスペックでは確認しづらいコントローラ固有の宣言に絞るのが現実的な使い方になります。
subjectではなくbeforeを使うべきケース
コントローラスペックでsubjectにリクエストを入れる書き方は、一部のマッチャーを壊します。公式READMEが挙げている例が分かりやすいでしょう。
RSpec.describe PostsController, type: :controller do
describe 'GET #index' do
subject { get :index }
it { should have_http_status(:success) } # 動く
it { should permit(:title, :body).for(:post) } # 動かない
end
end
subjectがレスポンスになってしまい、permitが期待するコントローラのインスタンスが渡らないためです。リクエストを先に実行したいならbeforeを使います。コントローラスペックではsubjectを上書きしない、とルールで決めてしまうのが安全です。
RSpec.describe PostsController, type: :controller do
describe 'GET #index' do
before { get :index }
it { expect(response).to have_http_status(:success) }
it { should render_template('index') }
end
end
example groupごとに自動で読み込まれる範囲
Railsプロジェクトでは、マッチャーはexample groupの種類に応じて自動的に読み込まれます。ActiveRecord・ActiveModelのマッチャーはtype: :modelのグループかspec/models配下のファイル、ActionControllerのマッチャーはtype: :controllerのグループかspec/controllers配下、routeマッチャーはtype: :routingのグループかspec/routing配下でのみ有効です。delegate_methodのような単独マッチャーだけは全グループで使えます。
モデルのマッチャーがリクエストスペックで使えないのは、この仕組みによるものです。
種類別マッチャー早見表(8.0.1時点)
8.0.1のREADMEが列挙するマッチャーを、検証対象の宣言と対応づけて一覧にしました。前章までで扱わなかったものも含みます。
| 分類 | マッチャー | 検証する宣言 |
|---|---|---|
| ActiveModel | validate_presence_of | validates presence |
| ActiveModel | validate_length_of | validates length |
| ActiveModel | validate_numericality_of | validates numericality |
| ActiveModel | validate_comparison_of | validates comparison |
| ActiveModel | validate_inclusion_of | validates inclusion |
| ActiveModel | validate_exclusion_of | validates exclusion |
| ActiveModel | validate_confirmation_of | validates confirmation |
| ActiveModel | validate_acceptance_of | validates acceptance |
| ActiveModel | validate_absence_of | validates absence |
| ActiveModel | allow_value | 値ごとの妥当性 |
| ActiveModel | have_secure_password | has_secure_password |
| ActiveRecord | validate_uniqueness_of | validates uniqueness |
| ActiveRecord | belong_to | belongs_to |
| ActiveRecord | have_many | has_many |
| ActiveRecord | have_one | has_one |
| ActiveRecord | have_and_belong_to_many | has_and_belongs_to_many |
| ActiveRecord | have_delegated_type | delegated_type |
| ActiveRecord | accept_nested_attributes_for | accepts_nested_attributes_for |
| ActiveRecord | define_enum_for | enum |
| ActiveRecord | serialize | serialize |
| ActiveRecord | normalize | normalizes |
| ActiveRecord | encrypt | encrypts |
| ActiveRecord | have_rich_text | has_rich_text |
| ActiveRecord | have_one_attached | has_one_attached |
| ActiveRecord | have_many_attached | has_many_attached |
| ActiveRecord | have_db_column | テーブルの列 |
| ActiveRecord | have_db_index | 列のインデックス |
| ActiveRecord | have_readonly_attribute | attr_readonly |
| ActiveRecord | have_implicit_order_column | implicit_order_column |
| ActionController | permit | params の許可属性 |
| ActionController | filter_param | ログ出力の除外 |
| ActionController | redirect_to | リダイレクト先 |
| ActionController | render_template | 描画テンプレート |
| ActionController | render_with_layout | レイアウト |
| ActionController | rescue_from | rescue_from |
| ActionController | respond_with | ステータスコード |
| ActionController | set_session | session の中身 |
| ActionController | set_flash | flash の中身 |
| ActionController | use_before_action | before_action |
| ActionController | use_after_action | after_action |
| ActionController | use_around_action | around_action |
| ルーティング | route | ルーティング定義 |
| 単独 | delegate_method | delegate |
Rails 7.1で追加されたnormalizesに対応するnormalizeマッチャーは、入力と期待値をチェーンで書きます。should normalize(:email).from(" [email protected]").to("[email protected]")のように、正規化の前後を1行で表現できる形です。ActiveRecord Encryptionを使っているならencryptでencrypts宣言の有無も検証できます。
shoulda-matchersを使うべきでない場面
このgemを業務ロジックのテストに広げるのは誤用です。公式READMEの「A Note on Testing Style」が、shoulda-matchersはリフレクションとイントロスペクションで実装されている、つまり実装の内部構造を見に行くツールだと明言しています。振る舞いではなく宣言を検証しているため、テストが実装の形に強く結びつきます。
条件分岐を含むカスタムバリデーションには使えません。validate :custom_checkのようにメソッドで書いた検証ロジックは、どのマッチャーでも表現できないからです。入力と結果の組み合わせを列挙する通常のテストを書きます。
計算処理や状態遷移などのドメインロジックも対象外です。「宣言があること」を確認しても、その処理が正しい答えを返すかは一切保証されません。
モデルのすべての属性に機械的にマッチャーを並べる運用も避けるべきです。validatesの行をテストに書き写しているだけの状態になり、リファクタリングのたびに両方を直す二重メンテナンスが発生します。
逆に、このgemが最も価値を出すのは、テストが手薄になりがちなフレームワークの設定部分です。アソシエーションのdependent: :destroyが消えていないか、Strong Parametersの許可属性が意図せず増えていないか。この種の設定漏れは通常のテストでは見落としやすく、発生したときの影響が大きい領域です。ここに絞って使うのが正しい距離感になります。
よくある質問
gem ‘shoulda’ と gem ‘shoulda-matchers’ は何が違いますか?
shouldaは、shoulda-matchers(マッチャー本体)とshoulda-context(contextやshouldといったMinitest向けの記法)をまとめたumbrella gemです。ただし2026年8月時点のshouldaは4.0.0(2020年6月13日)が最新で、依存がshoulda-matchers ~> 4.0に固定されています。8.0系の機能を使いたい場合は、shoulda経由ではなくshoulda-matchersを直接指定してください。
Rails 7.1のプロジェクトでは使えませんか?
8.0系は使えません。8.0.0のリリースノートが後方互換性のない変更としてRails 7.1とRuby 3.2のサポート終了を明記しています。Rails 7.1環境ではgem 'shoulda-matchers', '~> 7.0'と指定して7.0.1を使ってください。7.0系はRails 8・8.1にも対応済みなので、Rails側を上げるまでのつなぎとして問題なく機能します。
マッチャーがNoMethodErrorになるのはなぜですか?
原因は主に2つです。1つはrails_helper.rbのShoulda::Matchers.configureブロックの書き忘れで、gemを入れただけではマッチャーは有効になりません。もう1つはファイルの置き場所です。モデル系マッチャーはspec/models配下かtype: :modelを付けたグループでしか読み込まれないため、spec/servicesなどに置いたファイルでは未定義になります。type:タグを明示すれば解決します。
should と is_expected.to はどちらを使うべきですか?
どちらでも動作は同じです。公式ドキュメントは短さを理由にshouldを採用していますが、it { is_expected.to validate_presence_of(:name) }という書き方も正式にサポートされています。プロジェクトでRuboCopのRSpec系ルールを有効にしている場合は、警告が出ないほうに寄せてください。混在させると読み手が両方の記法を追うことになります。
宣言の検証以外は、どうテストを書けばいいですか?
入力と期待する結果の組み合わせを直接書く、通常のRSpecのテストで対応します。たとえばvalidate :end_date_after_start_dateのようなカスタムバリデーションなら、開始日より前の終了日を持つレコードを組み立ててexpect(record).not_to be_validとexpect(record.errors[:end_date]).to be_presentを確認します。宣言の有無はshoulda-matchers、処理の結果は通常のテスト、という分担です。