Rails の ActiveRecord モデルテストの書き方ガイドライン

このエントリでは,Ruby on Rails (以下 Rails)の ActiveRecord モデルテストについて,1) どこの何をテストすればよいか,2) どのようにテストを書けばよいか,のガイドラインを示します.このガイドラインRails 公式のものではなく,id:passingloop が使っている私的なものです.疑問・質問・批判・間違いの指摘はページ下部のコメント欄までお願いします.

はじめに

Rails は TDD/BDD サポートが充実した Web アプリケーション開発フレームワークです.Rails で使える Test::Unit や RSpec などといったテスティングフレームワークの使い方に関する解説も豊富にあります.しかし,「どこをどうテストすればよいのか」についての解説は,「使い方」の解説と比較して少ないように思います.もっとも,テスト一般についてどう書くかはアプリケーション次第というところもあるので,これは仕方ありません.とはいえ,ビジネスロジックを含まない「ActiveRecord モデル」なら,ある程度定型化できるのではないでしょうか.

ActiveRecord モデルのテストに関する情報源は多いものの,たくさんあり過ぎて全体像を把握しにくいのも問題です.思いつくだけでも,

があります.そこで,この文書では,私が収集したテストに関する情報をもとに,ActiveRecord モデルに対するテストの書き方ガイドラインを示すことを目標にします.

この文書では,まず,「ActiveRecord モデル」を定義します.この文書で扱う ActiveRecord モデル は MVC の 'M' ではないことに注意してください.次に,ActiveRecord モデルについて,どこの何をテストすればよいのか示します.最後に,Test::Unit を使ってテストをどう書けばよいのかを具体例で説明します.

ソースコードを埋め込むときに Gist を使っている箇所があります.JavaScript を無効にしていたり,https://gist.github.com へのリクエストを拒否していたりすると一部のソースコードが正しく表示されないことがあります.その場合は,直接 Gist を参照してください.

ActiveRecord モデルとは

ActiveRecord モデルとは,狭義には ActiveRecord::Base の派生クラスであり,app/models/model_name.rb ファイルで定義されるモデルクラスのことを指します.しかし,これだけでは,本文書の ActiveRecord モデル定義の半分です.残りの半分を定義するための材料として,Rails のモデルへの批判とそれに対する私の対応策について説明します.

Rails のモデルへの批判

人気 Web アプリケーションフレームワークRails は,その人気ゆえ,批判にさらされることも多くなります.その一例が Rails の モデルへの批判です.たとえば,Ruby on Railsの「えせMVC」の弊害 では,Rails のモデルが ドメインモデル貧血症 に陥りやすい設計であることを批判しています.実は,私もこの批判と同じ意見です.

貧血の治療法

しかし,ビジネスロジックをすべて ActiveRecord モデルに実装する必要があるかというと,必ずしも,そうではありません.たとえば,Dan Chak は著書 "Enterprise Rails" で物理モデルと論理モデルの分離を提案しています.Dan Chak の物理モデルはデータベースとテーブルの抽象化で,ActiveRecord で実現されるものです.物理モデルにビジネスロジックを含めてはいけません.一方,論理モデルはドメインモデルに相当し,ビジネスロジックは全てここに含めます.そして,コントローラなどクライアント向け API は論理モデルにだけ定義します.この方法の利点の一つめは,データベース設計に手を入れてもクライアント向け API が安定すること,二つめは,コントローラが容易にビジネスロジックを利用でき,データの整合性が保証されることです.

本文書の ActiveRecord モデル

私は,Dan Chak の方法とは少し違うやり方でドメインモデル貧血症を予防します.Dan Chak の実装では物理モデルと論理モデルを別々のクラスにしています.この方法では,物理モデルを app/models/physical に,論理モデルを app/models/logical に配置します.この方法は Railsディレクトリ構成規約からやや外れる方法で,物理モデルクラス名が ::Physical で修飾されるため不必要に長くなるという問題があります.そこで,私は,

  • ActiveRecord 派生クラスを物理モデルとして実装する
  • 論理モデルはモジュールとして実装する
  • 物理モデルに論理モデルモジュールを include して MVC の M を実現する

方法を採用しています.これにより,

を実現しています.

本文書の「ActiveRecord モデル」とは,ここで説明した物理モデルのことです.したがって,ActiveRecord モデルに対するテストは物理モデルに対するテストということになります.

どこをテストするのか

それでは,ActiveRecord モデルの,どこの何をテストすればよいのでしょうか.先に答えを言ってしまうと,どこをテストするのかについては

  • Validation
  • データベース

です.

Validation のテストについては,Ruby on Rails Guides が「validation をテストせよ」と書いています.Validation は ActiveRecord モデルクラスに定義されるもので,このクラスでしかテストできないものなので,ActiveRecord モデルでテストします.データベースに関するテストについては ActiveRecord モデルが物理モデルであることから,当然ここでテストします.

と書きましたが,本当に当然なのでしょうか? 次のデータベースに関するテストに関する議論で当然が正しかったかどうか確認します.

データベースに関するテストとは?

データベースに関するテストでは,

  • 制約テスト
    • CHECK
    • NOT NULL
    • UNIQUE
  • 参照整合性テスト
    • FOREIGN KEY

を行ないます.これらはデータベースのスキーマに関するテストなので,SqlUnit とか SchemaUnit とかというのを使ってデータベース側でテストすべきと考えるかもしれません.しかし,制約や参照整合性に違反するデータをデータベースに投入しようとした場合,ActiveRecord モデルは例外を投げることを思い出しましょう.そうなると,最早データベースから離れて ActiveRecord モデルの振舞いの問題なので,例外が期待通りに起こることを振舞いテストとしてテストする必要が出てきます.

つまり,データベースに関するテストとは,制約や参照整合性に違反したデータをデータベースに投入しようとしたときに発生する例外が,期待通りに発生することを確かめるネガティブテストだということになります.

他にもテストすべきものがあるのでは?

ここまで,ActiveRecord モデルでは validation とデータベースをテストすることを説明しましたが,他にもテストするべきものがあるのでは,と疑問に思う人がいるかもしれません.たとえば,

  • has_manybelongs_toのような関連定義
  • スコープ
  • その他のメソッド

です.しかし,これらは ActiveRecord モデルではテストしません.それはどうしてかというと,

has_many や belongs_to, スコープをテストしないのは?

has_many などやスコープといった ActiveRecord が提供するクラスメソッドは,Rails 本体でさんざんテストされているので,テスト無しに使っても大丈夫です.ただし,条件に lambda ブロックを使うなど,複雑なものはテストしてもいいかもしれません.でも,やっぱりしなくてもいいかもしれません.その理由は次を読んで考えてください.

その他のメソッドをテストしないのは?

ActiveRecord モデルクラスに定義されるメソッドは,インクルードされる論理モデルモジュールからのみ呼び出されるべきです.よって,これらのメソッドは基本的にはプライベートメソッドとして定義されます.ユニットテストでプライベートメソッドもテストすべきかどうかは議論が分かれるところですが,私は「プライベートメソッドまで無理してまでテストする必要はない.Ruby だからできるけど」という考えなので,テストしません.

プライベートメソッドをテストしなくても,それを呼び出す論理モデルモジュールのメソッドをテストするのですから,カバレッジ上は問題ありません.

Ruby on Rails Guides には違うことが書いてあるけど?

さんざん参考文献として例に挙げてきた Ruby on Rails Guides ですが,実は,ここで言っているのとは違うことが書いてあります.該当部分を引用して確認してみると,


3.3 What to Include in Your Unit Tests

Ideally, you would like to include a test for everything which could possibly break. It’s a good practice to have at least one test for each of your validations and at least one test for every method in your model.

と,本当は,

  • Validation
  • 全てのメソッド

をテストせよと書いてあります.この Ruby on Rails Guides とこの文書は矛盾しています.それでは,どちらが間違っているかというと,どちらも間違ってはいません.というのも,Ruby on Rails Guides とこの文書とではユニットテストの対象としているモデルの考え方が違うからです.

Ruby on Rails Guides が対象としているモデルがどのようなものかというと,...実は,はっきりと書いていません.ですので,validation とメソッドだけをテストすればいいようなケースも,きっと含んでいるのだと思います.一方,繰り返しですが,この文書のモデルは,より明確に定義された「ActiveRecord モデル」なので,validation とデータベースをテストします.

どのようにテストを書くか

ここまで,ActiveRecord モデルに対するテストは validation に関するテストとデータベースに関するテストの 2 つであることを説明してきました.それでは,これらの 2 つのテストをどのように書けばよいのでしょうか.これらのテストを書く際にはポジティブテスト(成功するテスト)とネガティブテスト(失敗するテスト)の 2 つを書きます.すなわち,

  • 正常値オブジェクト → エラーにならないこと
  • 異常値オブジェクト → エラーになること

をテストします.

ここでは,まず,ActiveRecord モデルの例として Item モデルを用意し,このモデルに対してテストを具体的に書いていきます.

例: Item モデル

何もないところにテストは書けないので,例として Item モデルを用意しました.

Item モデルと Vendor モデルとの関連図

Item モデルは,Vendor モデルに対して belongs_to 関連をもっています.この Item モデルのマイグレーションは次のようになります.

vendor_id, title, price の 3 つのカラムには NOT NULL 制約が設定されています.NOT NULL 制約は add_column メソッドで簡単に設定できます.さらに,11 行目から execute メソッドで SQL を実行しています.データベースには CHECK 制約と FOREIGN KEY 制約を追加したいのですが,Railsマイグレーションにはこの 2 つの制約を追加するメソッドが無いので仕方なく生 SQL に頼ります.余談ですが,この execute メソッドは,「簡単なことは簡単に,そうでないことはそれなりに」実現できる ActiveRecord の懐の深さを感じられるので,私が気に入っているメソッドの一つです.

話を元に戻して,Item モデルの ActiveRecord モデルは次のようになります.

belongs_to 関連と validation が設定されています.

データベースに制約があるのに同じ意味の validation を書くのはなぜか

Item モデルの title, price 2 つのカラムにはデータベース制約と validation の両方が設定されています.データベース制約があるのに同じ意味の validation を書くのは冗長だと思った人がいるかもしれません.しかし,validation が無いと,モデルの異常値でデータベースの例外がアプリケーションに飛んでしまいます.アプリケーションに不必要に例外を飛ばさないためにも,validation を設定しましょう.さらに,データベースの例外からデータの異常を探るのは難しいのですが,validation があるとアプリケーションで捕捉できるというメリットもあります.

実は,データベース制約と validation とでは検出する異常の意味合いが異なります.Validation で検出する異常は入力エラーです.ユーザにデータの修正を促すためのものです.一方,データベースで検出する異常はアプリケーションのバグです.開発者にコードの修正を促すためのものです.というわけで,私はデータベース制約と validation の両方を設定することにしています.

association_id である vendor_id のバリデーションを書かなかったのはなぜか

データベースの vendor_id カラムには NOT NULL 制約と FOREIGN KEY 制約がありました.しかし,ActiveRecord モデルの vendor_id には NOT NULL 制約に相当する validation はありませんでした.これは,vendor_id == nil は入力データの異常ではなく,アプリケーションのバグだと想定しているからです.すなわち,このアプリケーションは vendor_id をユーザが直接指定するような仕様ではなく,さらに,item.vendor = nil と書くようなユースケースも想定していないことを意味しています.そんなこと今までどこにも書いていませんでしたが,今書いたので許してください.

まとめると,NOT NULL/FOREIGN KEY 制約違反はバグというアプリケーションの仕様を想定しているので,validation で検出するよりデータベース例外を投げたほうがよいということです.

準備 1) スキーマRuby ではなく SQL で保存するよう Rails を設定する

テストを書いていく前の準備が 2 つあります.一つめは,スキーマRuby ではなく SQL で保存するよう Rails を設定することです.Rails が test 環境のデータベースを用意するときには,development 環境のデータベーススキーマをコピーします.このときに使うスキーマは,development 環境のデータベースをマイグレーションしたときに自動で保存されるスキーマで,デフォルトでは Ruby で書かれたマイグレーションコードとして保存されます.ここで問題となるのが,execute メソッドなどで追加した Rubyマイグレーションで表現できない SQL 文です.これらの SQL 文はデフォルトの Ruby 形式スキーマには保存されないので,test 環境のデータベースにコピーされません.このままでは制約をテストできません.

そこで,スキーマの保存形式を Ruby から SQL に変更します.config/applicaiton.rb に,

# config/application.rb
module Dummy
class Application < Rails::Application
config.active_record.schema_format = :sql
end
end

と, config.active_record.schema_format = :sql を追加しましょう.これで test 環境のデータベースにも制約がコピーされます.

準備 2) factory_girl を導入する

テストデータの作成に利用する factory_girl を導入します.Rails に factory_girl を導入する際は,factory_girl_rails gem が便利です.factory_girl_rails gem を導入するとジェネレータがフィクスチャリプレースメントとして factory_girl を使うようになります.Gemfile に

group :test do
gem 'factory_girl_rails'
end

を追加して,bundle update します.さらに,test/test_helper.rb で,

# test/test_helper.rb
class ActiveSupport::TestCase
include Factory::Syntax::Methods
end

と,ActiveSupport::TestCase クラスに Factory::Syntax::Methods モジュールをインクルードしておきましょう.このモジュールをインクルードすると,テストメソッド中で FactoryGirl.create(:foo) とする代わりに create(:foo) とスッキリ書けます.

なぜ Fixtures を使わないのか

Fixtures はデータベースの状態を用意します.だから,データベースに制約があるときには,制約に違反する異常値 fixture を作成したくでもできません.このため Fixtures は異常値テストには向きません.ActiveRecord モデルのテストでは,正常値のテストだけでなく異常値のテストも行なうため,フィクスチャリプレースメントとして factory_girl を採用しました.

だからといって,アプリケーションのテスト全体から Fixtures を追放するかというと,そういうわけではなく,論理モデルのテストでは Fixtures のほうが便利で,こちらを使うこともあります.つまり,Fixtures をフィクスチャリプレースメントで完全に置き換えるのではなく,いいとこ取りで併用します.併用する理由,すなわち,完全に置き換えない理由については factory_girl の採用面接 も合わせて読んでみてください.

Validation をテストする

ようやく準備が整いました.まずは validation のテストから書いていきます.

正常値オブジェクトがエラーにならないことを確認する

まず,ポジティブテストを書きます.すなわち,正常値オブジェクトがエラーにならないことを確認するテストです.このときに用意する正常値オブジェクトの属性値には,境界値を設定します.この境界値ファクトリの名前はそのまま,item_with_boundary_value_attributes という何のひねりもない名前にします.ファクトリ定義は,

# test/facytories/items.rb
FactoryGirl.define do
factory :item, aliases: [:item_with_boundary_value_attributes] do
vendor
title "Q"
description "Using this item, you will be taller."
price '0.00'
end
end

となります.このファクトリは Item クラスのオブジェクトを作るものなので,名前を :item として定義しておくとクラス名の設定が不要になり便利です.そして,本来つけたかった名前,:item_with_boundary_value_attributesエイリアスとして設定しています.

そして,いよいよテストメソッドです.テストメソッドは,"{ファクトリ名} is valid" とします.今回のファクトリ名は item_with_boundary_value_attributes だったので,

# test/units/item_test.rb
test 'an item with boundary value attributes is valid' do
assert build(:item_with_boundary_value_attributes).valid?
end

となります.build でデータベースに保存しないオブジェクトを作って valid? するだけです.簡単でしょ.

異常値オブジェクトがエラーになることを確認する

次にネガティブテストを書きます.すなわち,異常値オブジェクトがエラーになることを確認するテストです.異常値を用意する異常値ファクトリには,:{モデル名}_with_{異常な}_{属性名} という命名規則で名前をつけます.今回は,

  • :item_with_empty_title
  • :item_with_blank_title
  • :item_with_empty_price
  • :item_with_negative_price

の 4 つの異常値ファクトリを用意しました.このときに用意する異常値ファクトリは,ポジティブテストで用意した正常値(境界値)を少し修正するだけで用意できます.こんなときには factory_girl の継承機能が便利です.異常値ファクトリを追加して完成した Item モデルのファクトリ定義を次に示します.

次に,テストメソッドを書いていきます.テストメソッドは "{異常値ファクトリ名} is invalid" とします.今回用意したファクトリのそれぞれについてテストを書くと,

# test/units/item_test.rb
test 'an item with empty title is invalid' do
item = build(:item_with_empty_title)
assert item.invalid?
assert item.errors[:title].any?
end

test 'an item with blank title is invalid' do
item = build(:item_with_blank_title)
assert item.invalid?
assert item.errors[:title].any?
end

test 'an item with empty price is invalid' do
item = build(:item_with_empty_price)
assert item.invalid?
assert item.errors[:price].any?
end

test 'an item with negative price is invalid' do
item = build(:item_with_negative_price)
assert item.invalid?
assert item.errors[:price].any?
end

となります.各テストメソッドでは,異常値オブジェクトが invalid? であることと,異常値属性がエラーになっていることを確認します.

これで,validation のテストは完成です.

データベース制約・参照整合性をテストする

Validation のテストが片づいたところで,データベース制約・参照整合性テストに移ります.テストを書く前に,ActiveSupport::TestCase にデータベーステスト用のアサーションを追加しましょう.

準備: データベースエラーのためのアサーションを追加する

データベースに対するネガティブテストではデータベース例外の発生を確認します.具体的には,

  • ActiveRecord::StatementInvalid ... NOT NULL, CHECK 制約違反
  • ActiveRecord::RecordNotUnique ... UNIQUE 制約違反
  • ActiveRecord::InvalidForeignKey ... FOREIGN KEY 制約違反

が発生することを確認します.例外発生のアサーションには assert_raise が使えるのですが,毎回

assert_raise ActiveRecord::StatementInvalid do
...
end

assert_raise ActiveRecord::RecordNotUnique do
...
end

assert_raise ActiveRecord::InvalidForeignKey do
...
end

と長い例外名を書くのは面倒なので,専用のアサーションを用意します.それぞれ名前を,

  • assert_statement_invalid
  • assert_record_not_unique
  • assert_invalid_foreign_key

として,ActiveSupport::TestCase に追加しましょう.追加する場所は test/test_helper.rb が適当です.

# test/test_helper.rb
class ActiveSupport::TestCase
include Factory::Syntax::Methods

def assert_statement_invalid(&block)
assert_raise(ActiveRecord::StatementInvalid, &block)
end

def assert_record_not_unique(&block)
assert_raise(ActiveRecord::RecordNotUnique, &block)
end

def assert_invalid_foreign_key(&block)
assert_raise(ActiveRecord::InvalidForeignKey, &block)
end
end

これで準備が整いました.

正常値オブジェクトを保存できることを確認する

Validation のテストと同様,ポジティブテストから書いていきます.データベースのテストでは validation のテストで用意したファクトリを再利用します.:item_with_boundary_value_attributes ファクトリを使って,"db allows saving an item with boundary value attributes" テストメソッドを書きます.言い忘れましたが,データベースのポジティブテストでテストメソッドを書くときにはメソッド名を "db allows saving {正常値ファクトリ名}" とします.

test 'db allows saving an item with boundary value attributes' do
assert build(:item_with_boundary_value_attributes).save
end

制約をテストする

ここでも,validation のテストで用意した異常値ファクトリを全部そのまま再利用します.ただし,vendor_id カラムに NULL を設定するファクトリだけは用意していないので,build(:item, vendor_id: nil) として異常値オブジェクトを作ります.また,単に save! とするとデータベース制約よりも先に validation がエラーを検出してしまうので,{ :validate => false } として validation を回避します.テストメソッド名は,"db can catch {異常な状態の} {属性}" という命名規則で名前をつけます.そして,例外の捕捉には,準備しておいた制約違反用のアサーション assert_statement_invalid を使います.

test 'db can catch null title' do
assert_statement_invalid do
build(:item_with_empty_title).save!(validate: false)
end
end

test 'db can catch blank title' do
assert_statement_invalid do
build(:item_with_blank_title).save!(validate: false)
end
end

test 'db can catch null price' do
assert_statement_invalid do
build(:item_with_empty_price).save!(validate: false)
end
end

test 'db can catch negative price' do
assert_statement_invalid do
build(:item_with_negative_price).save!(validate: false)
end
end

test 'db can catch null vendor_id' do
assert_statement_invalid do
build(:item, vendor_id: nil).save!(validate: false)
end
end

参照整合性をテストする

参照整合性をテストするためのファクトリも validation のテストで用意していないので,正常値(境界値)オブジェクトの vendor_id を変更して,build(:item, vendor_id: 99999999) のようにして異常値オブジェクトを作成します.テストメソッド名は,"db can catch {モデル名} with invalid reference" とします.

test 'db can catch an item with invalid reference' do
assert_invalid_foreign_key do
build(:item, vendor_id: 99999999).save!(validate: false)
end
end

参照整合性テストで新たにファクトリを定義しなかったのはなぜか

参照整合性テストで異常値オブジェクトを作るときは新たにファクトリを定義せず,既存のファクトリを流用しました.:item_with_invalid_reference というファクトリを新しく作ってもよさそうなのに,と思った方がいたかもしれません.新たに定義しなかったのは理由は簡単で,ファクトリを定義したとしても一度しか呼び出されないからです.Validation のテストで用意したファクトリは,データベースのテストでも使われており,全て二回呼び出されています.制約テストで :item_with_null_vendor_id というファクトリを定義しなかったのも同様の理由です.

テスト完成

これで,テストを全て書き終えました.これまで書いたものを全て繋いで test/units/item_test.rb とします.

(参考)例で登場しなかったパターン: UNIQUE 制約のテスト

データベースの制約には三種類あります.

  • CHECK
  • FOREIGN KEY
  • UNIQUE

このうち,UNIQUE 制約は今回の例では登場しなかったので(参考)として補足します.UNIQUE 制約をもつモデルとして Foo モデルを別に用意しました.テストの説明に使えればいいので名前も中身もかなり適当ですが,必要十分です.まずは,マイグレーションから,

UNIQUE 制約は add_index メソッドで { :unique => true } することで設定できます.次に,ActiveRecord モデルクラスです.

UNIQUE 制約違反はバグではなく入力エラーなので validation を設定しています.

Validation のテスト

それでは UNIQUE 制約について validation のテストから書いていきましょう.テストメソッド名は "{モデル名} with duplicate {UNIQUE 属性} is invalid" とします.

# test/units/foo_test.rb
test 'a foo with a duplicate bar is invalid' do
create(:foo)
foo = build(:foo)
assert foo.invalid?
assert foo.errors[:bar].any?
end

3 行目の create(:foo) で作ったオブジェクトと同じ属性値をもつオブジェクトを 4 行目で作り,そのオブジェクトが invalid? であること(5 行目),bar 属性についてエラーとなること(6 行目)をアサーションしています.

データベースのテスト

次に UNIQUE 制約についてデータベースのテストを書きます.テストメソッド名は,"db can catch duplicate {UNIQUE 属性の属性名}" とします.

# test/units/foo_test.rb
test 'db can catch duplicate bars' do
create(:foo)
assert_record_not_unique do
build(:foo).save!(validate: false)
end
end

準備しておいた assert_record_not_unique アサーションを活用します.

おわりに

この文書では,ActiveRecord モデルテストの書き方についてガイドラインを示し,例を使って具体的に解説しました.最後に,この文書で登場したガイドラインをまとめておきます.

もう一つ,ファクトリ名やテストメソッド名の命名規則ガイドラインとして登場しました.こちらもまとめておきます.

  • 正常値ファクトリ名
    • :{モデル名}_with_boundary_value_attributes:{モデル名}エイリアス
  • 異常値ファクトリ名
    • :{モデル名}_with_{異常な}_{属性名}
  • Validation テストメソッド名
    • "{正常値ファクトリ名} with boundary value attribute is valid"
    • "{異常値ファクトリ名} is invalid"
  • データベーステストメソッド名
    • "db allows saving {正常値ファクトリ名}"
    • "db can catch {異常な} {属性名}"
    • "db can catch duplicate {UNIQUE 制約の属性名}"

大事なことなので二度言いますが,この文書は私的なものです.よって全ての Rails 開発に適用できるものではありません.ActiveRecord モデルの定義からも分かるように,このガイドラインエンタープライズアプリケーション開発を主対象としています.このガイドラインに忠実であるためには,データベース設計は「まとも」でないといけないですし,RDBMSSQL の「制約」をまともに扱えるものを採用する必要があります.データベースを NoSQL 的に使ったり NoSQL を使ったりする開発では何の役にも立ちません.そんな場合でも,どこの何をどのように,そして,それはなぜテストするのか考える上で参考になるかもしれません.もし何かの参考になれば幸いです.

それでは,世の中に ActiveRecord モデルテストが充実した Rails アプリケーションが増えますように.

このガイドラインを書いたときの Rails のバージョン

  • Rails 3.1.3
  • factory_girl 2.3.1
  • factory_girl_rails 1.4.0