先週はアメリカ西海岸への出張で土曜日夜に帰宅しました。時差なのか夕方眠くなり、夜中近くは目が冴えてとちょっと日本時間に戻りきっていない感じです。
Java読書会BOF主催「現場で役立つシステム設計」を読む会(第1回)の感想 #javareading
Java読書会BOF主催「現場で役立つシステム設計」を読む会が今月から始まります #javareading - torutkのブログで案内したJava読書会「現場で役立つシステム設計」を読む会(第1回)が8月18日(土)に実施されました。
初回は、「はじめに」から、「CHAPTER 4 ドメインモデルの考え方で設計する」の導入部分(p.102上4行目)まで90ページ弱を読み進めました。
http://www.javareading.com/bof/
議事録は近日Java読書会BOFのWebサイトにアップされるとので、自分の感想をずらずらと記載していきます。
書籍の題名について
書籍の題名「現場で役立つシステム設計の原則」は、最初に題名だけ見たときに「システム設計」とあることから、ソフトウェア設計の前段にあるシステム設計(業務の設計、計算機・ネットワーク等のインフラとソフトウェアの配分)を意図したものと誤解していました。
しかしJava読書会BOFの課題図書投票で推薦本に挙がり、内容を見てみたところソフトウェア設計、しかもオブジェクト指向設計な内容だったので、これはと思いました。(昨年投票時は次点でしたが、今回は1位となって読書会をする運びとなりました)
CHAPTER 1 小さくまとめてわかりやすくする
ここはプログラマーとして苦労する問題を共有(共感)し、ドメインモデルへの導入とするためでしょうか、プログラマーに寄り添った内容となっている、いい導入だなと感じました。
変数名
今まで関与したコーディング規約には、命名は原則フルスペルで、略語を使う場合は略語集(用語集)に登録した上で使うとしてきたので、素直に賛同です。
IDEやプログラミング用のエディタであれば、名前の補完があるので長い変数名でも最初の数文字を入れればあとは補完してくれるので入力の手間もそれほどはないと思います。
いまでも変数名等の名前を省略するコードがあるのか参加者に聞いてみましたが、昔の(10年以上前とか)コードで見かけるが、今書かれているものは省略するものは少ないようでした。
一方で、真面目に業務(問題領域)の用語を英語にすると30文字以上になることもあるといった長すぎる名前をどうするか課題もあるとのことでした。
業務の用語については、対応する英語名を一意に決めないとプログラマーによって異なる英語(類語)が使われるので、プロジェクトで用語集(日英対応まで含めて)を定めるのが重要です。ただしプログラミングが始まってから後追いで用語集を策定しても手戻りが増えて大変(反発も大きい)です。
空白行
長いメソッドは段落(意味的に一続きのコード)ごとに空行を入れて読みやすくするという手法は自分でも実践しているので素直にうなずけます。
ただ、この書籍のサンプルコードは、if文の条件式を囲う丸括弧の内側に空白文字があるところ、ifのキーワードと条件式の丸括弧開きの間に空白がないといったところで慣習的なスタイルと違いがあってそっちが気になってしまいました…。
値の範囲を制限してプログラムを分かりやすく安全にする
制御系のアプリケーションでは、範囲を定義して範囲外の値を代入できないようにすることが必要でした。必要性はとってもうなずけます。
QuantityやUnitといえば、JavaのAPIとしてJSR 363が作られています。JSR 363の簡単な紹介を次に書いています。2018年に入ってJSR 385がAPIのVer.2.0として了承されたようです。
JSR 363 Units of Measurement を調べてみて
範囲を扱うRangeクラスもいくつかのライブラリで使われています。
id:torutk:20110924
値オブジェクトは「不変」にする
この本では言及はありませんが、マルチスレッドプログラミングでスレッド間でオブジェクトを安全に受け渡しする際には「不変」が重要となります。
CHAPTER 2 場合分けのロジックを整理する
判断や処理のロジックをメソッドに独立させる
if文の条件式が複雑になるときは、条件式をメソッド化して整理するのはよく使う手法なので、うなずけます。
else句をなくすと条件分岐が簡単になる
節題がドキッとする内容(elseをなくすとロジックの抜け漏れが発生するんでは?)ですが、読み進めてみるとガード節(早期リターン)の導入でした。これもよく使う手法です。
CHAPTER 3 業務ロジックを分かりやすく整理する
いろいろな見方があって議論が高まる章となりました。
Java読書会BOFのモットー(自称)は、「脳みそに汗をかこう」*1なので、読書会の本としては成功です。
共通機能ライブラリが失敗する理由
Utilクラス、Commonクラスアプローチではうまくいかないなということは身をもって痛感し、自分ならNGワードとしたい命名です。Commonといいつつ大抵は狭い範囲での共通に過ぎないものばかりでした。
一方で、「データクラスと機能クラスに分ける設計」としては、C++のSTLライブラリなどがあります。Java 8ではStream APIがラムダ式とともに導入されています。
ちょっとしたニーズの違いについては、フラグやオプション引数で対応するのではなく、ラムダ式で違いの部分をコードで利用側から指定するなどの方法もあります。言語仕様、標準APIの進歩でこれまで難しかったことも状況が変わってきています。
このあとまだまだ興味深い内容がつづきますが、日記の方は続き、ということで。
Java読書会BOF主催「現場で役立つシステム設計」を読む会が今月から始まります #javareading
今月8月18日(土)から、Java読書会BOF主催のJava読書会は、次の本となりました。
現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法
- 作者: 増田亨
- 出版社/メーカー: 技術評論社
- 発売日: 2017/07/05
- メディア: Kindle版
- この商品を含むブログ (4件) を見る
この本、前回の投票では次点となっていました。この本は、題名に「システム設計」とあるので、プログラミングとは距離を置いたシステム開発方法のようなものと思っていましたが、前回候補に挙がっていたので中身をパラパラ見てみたところ、サンプルコードはJavaで、リファクタリング、メソッド・クラスの分割、命名、APIといった内容を扱っていました。ドメイン駆動は直接言及していませんが、ドメインモデルを作ることに言及していました。
Java読書会で、オブジェクト指向を扱うのは、2006年の「デザインパターンとともに学ぶオブジェクト指向のこころ」以来です。
著者の考え方が表われている本なので、賛否両論で議論が盛り上がる期待があります。
WSL(Windows Subsystem for Linux)とHyper-V上のLinuxとの重さの違い
はじめに
Windows OS上でRedmineを動かす環境を用意し、さらにプラグイン開発環境も用意するのはかなり難儀なので、Windows OS上に仮想マシン(VMware、VirtualBox、Hyper-Vなど)を入れてその上にLinux OSを入れてRedmineを動かしていました。
ここで、最近のWindows 10アップデートでは、Windows Subsystem for Linux(以降WSLと呼ぶ)が標準で搭載されるようになったので、このWSL上にopenSUSEを入れて(Windowsストアから無償インストール)Redmineを動かし、プラグイン開発環境にしてみました。
しかし、WSL上でのRailsサーバーの起動やマイグレートコマンド、テストコマンドの実行にかなり待たされます。あまりに遅いので少し調べてみると、/mnt/d/... のように/mnt配下になるファイルのアクセスは、/mnt以外のファイルのアクセスに比べて数段遅いとありました。
そこで、WSL上の/mnt配下、/home配下、そして仮想マシン上でそれぞれRedmineのコマンドを実行した場合の時間を比べてみました。
環境
実行時間の計測
この環境で、次の項目の実行時間を測ってみました(ストップウォッチ目測)。
No. | 項目 | WSL(/mnt/下) | WSL(/home/下) | Hyper-V | |
---|---|---|---|---|---|
1 | PUMA起動 | 37秒 | 14秒 | 3秒 | |
2 | pluginのmigreate | 40秒 | 15秒 | 4秒 | |
3 | minitest unit | 48秒 | 18秒 | 7秒 |
コマンドラインは次の通りです。
1のPUMA起動
bundle exec rails server -b 0.0.0.0
2のplugin migrate(pluginsディレクトリ下には1つだけプラグインを配置、既にmigrate済みの状態で実行)
3のunit test(3ファイル、27 runs, 57 assertions)
bundle exec rails redmine:plugins:test:units NAME=RAILS_ENV=test
結果
Windows上の作業領域にredmineを展開すると、WSLのLinux上では/mnt/以下のパスとなるので圧倒的に遅くなります。
/home以下に展開すると幾分改善されますが、それでもHyper-V上よりも数段遅いという結果でした。
その他
WSLでの問題
Windows 10をスリープしながら使っていると、WSLの上でruby(rails)を実行するとクラッシュ(コアダンプ)するようになります。こうなると、Windows 10を再起動しないと解消しません。すべてのWSLコンソールを落とすと解消しました。
$ bundle exec rails server -b 0.0.0.0 => Booting Puma => Rails 5.2.0 application starting in development => Run `rails server -h` for more startup options /home/foo/redmine/vendor/bundler/ruby/2.4.0/gems/activesupport-5.2.0/lib/active_support/callbacks.rb:676: [BUG] pthread_cond_timedwait: Invalid argument (EINVAL) ruby 2.4.1p111 (2017-03-22 revision 58053) [x86_64-linux-gnu] -- Control frame information ----------------------------------------------- c:0081 p:---- s:0426 e:000425 CFUNC :require :
[Redmine]Redmine 4.0(Rails 5.2)のプラグインをテストするなど(コントローラーのテスト編の続き)
Redmine 4.0(Rails 5.2)のプラグインをテストするなど(コントローラーのテスト編) - torutkのブログ では、コントローラーのテストにRails 5で雛形生成されるActionDispatch::IntegrationTestを少し追求してみましたが頓挫してしまい、Rails 4までのActionController::TestCaseでのテストを進めることにします。
最初のテスト項目
最初に、indexアクションを呼び応答が成功を返すことをテスト項目とします。
まず、雛形として生成されたTermCategoriesControllerクラスのテストクラスは次です
require File.dirname(__FILE__) + '/../test_helper' class TermCategoriesControllerTest < ActionController::TestCase # Replace this with your real tests. def test_truth assert true end end
試みの1
1つだけ定義されるテストメソッドの名前を、test_index_responseとします。テストメソッドの中でindexアクションをgetで呼び出します。呼び出した結果が成功かどうかをassertで判定します。次のコードとなります。
def test_index_response get :index assert_response :success end
実行結果はエラーとなりました。
Error: TermCategoriesControllerTest#test_index_response: ActionController::UrlGenerationError: No route matches {:action=>"index", :controller=>"term_categories"} plugins/redmine_glossary/test/functional/term_categories_controller_test.rb:6:in `test_index_response'
term_categories は、プロジェクトにネストしているので、URLパスは /projects/:project_id/term_categories のようになりますが、ここではget呼び出し時にプロジェクトの指定をしていません。そのため、URLパスは /term_categories のようになり、ルーティング設定に合致しないのでエラーとなります。
試みの2
URLパスがプロジェクトのネストとなるように、プロジェクト識別子を追加します。試みの1との差分は次となります。
def test_index_response - get :index + get :index, params: { project_id: 1 } assert_response :success end
実行結果は次のように故障(failure)となりました。
Failure: TermCategoriesControllerTest#test_index_response [/work/redmine/plugins/redmine_glossary/test/functional/term_categories_controller_test.rb:7]: Expected response to be a <2XX: success>, but was a <404: Not Found>
ルーティング設定に合致し、処理が少し進みましたが、リソースが見つからないので404の応答となっています。プロジェクトID 1に対応するプロジェクトが存在していないので(多分)、このような結果となります。
Redmine本体のtest/fixtures/ディレクトリ下には、テストデータが用意されているので、プロジェクトのテストデータを利用することとします。
試みの3
プロジェクトのデータを、fixtures/projects.ymlから読み込むよう指定します。
class TermCategoriesControllerTest < ActionController::TestCase
+ fixtures :projects
def test_index_response
実行結果は次のように故障(failure)となりました。
Failure: TermCategoriesControllerTest#test_index_response [/work/redmine/plugins/redmine_glossary/test/functional/term_categories_controller_test.rb:9]: Expected response to be a <2XX: success>, but was a <403: Forbidden>
今度はリソースへのアクセスが禁止(アクセス権がない)403の応答となっています。
指定したプロジェクトで用語集プラグインが有効化されていないためと推測します。
そこで、プロジェクトで用語集プラグインを有効化させるメソッドを先に呼びます。
試みの4
fixturesで用意されているプロジェクトデータの中から、今回はID=1のデータを読み込み、そのプロジェクトでモジュール(プラグイン)用語集を有効化します。fixuturesにあるデータを参照するには、ID等から検索するか、fixturesのテストデータを取得するメソッドを利用するかの方法があります。
fixtures/projects.yml のテストデータ(ID=1)は次のようになっています(抜粋)。
projects_001: created_on: 2006-07-19 19:13:59 +02:00 name: eCookbook updated_on: 2006-07-19 22:53:01 +02:00 id: 1 :(略)
このテストデータを参照する方法は、モデルの検索で次のように取得するものと、
project1 = Project.find(1)
fixutres参照専用のメソッド
fixtures :projects : project1 = projects('projects_001')
と、fixturesで指定した名称(YAMLファイル名の拡張子を除いた名前)と同名で用意されるメソッドを使うものがあります。
今回は、後者の方法でfixturesにあるプロジェクトデータへの参照を取得します。
def test_index_response projects('projects_001').enabled_module_names = [:glossary] get :index, params: { project_id: 1 } assert_response :success end
プロジェクトのモジュール(プラグイン)を有効化するメソッドenabled_module_namesで用語集プラグインのモジュール名glossaryを指定しています。
実行結果は次のように故障(failure)となりました。
Failure: TermCategoriesControllerTest#test_index_response [/work/redmine/plugins/redmine_glossary/test/functional/term_categories_controller_test.rb:9]: Expected response to be a <2XX: success>, but was a <302: Found> redirect to <http://test.host/login?back_url=http%3A%2F%2Ftest.host%2Fprojects%2F1%2Fterm_categories> Response body: <html><body>You are being <a href="http://test.host/login?back_url=http%3A%2F%2Ftest.host%2Fprojects%2F1%2Fterm_categories">redirected</a>.</body></html>
用語集プラグインの権限の設定で参照するにはプロジェクトのメンバーでログインしている必要があるので、未ログインでアクセスするとログインページにリダイレクトされています。
そこで、テストの実行にあたりログインを再現しておきます。
試みの5
リクエストパラメーターにユーザーIDを指定します。
fixtures :projects, :users def test_index_response projects('projects_001').enabled_module_names = [:glossary] @request.session[:user_id] = users('users_002').id get :index, params: { project_id: 1 } assert_response :success end
実行結果は次のように故障(failure)となりました。
Failure: TermCategoriesControllerTest#test_index_response [/work/redmine/plugins/redmine_glossary/test/functional/term_categories_controller_test.rb:10]: Expected response to be a <2XX: success>, but was a <403: Forbidden>
ログイン済みでアクションを呼び出しているので、loginアクションへのリダイレクトが発生することはなくなりましたが、ログイン済みのユーザーのロールが用語集プラグインの権限を有していないので403応答となっています。
そこで、ユーザー(ID=2)のロールに、用語集プラグインの権限を付与します。
試みの6
テストデータのプロジェクト(ID=1)にユーザー(ID=2)が所属しているかをmembersテーブルで確認します。テストデータなのでfixtures/members.ymlを調べると、次のとおりproject_id=1にuser_id=2が所属していることが確認できました。
members_001: created_on: 2006-07-19 19:35:33 +02:00 project_id: 1 id: 1 user_id: 2 mail_notification: true
次に、ユーザー(ID=2)がどのロールでプロジェクト(ID=1)に属しているかを、member_rolesテーブルで確認します。テストデータではfixures/member_roles.ymlを調べると、次の通りmember_id=1はrole_id=1のロールで属していることが分かります。
member_roles_001: id: 1 role_id: 1 member_id: 1
roles_id=1は、fixtures/roles.ymlを調べると、
roles_001: name: Manager id: 1 builtin: 0 :(略)
となっています。
そこで、ロール(ID=1)に用語集プラグインの権限を付与するコートを追加します。
fixtures :projects, :users, :roles def test_index_response projects('projects_001').enabled_module_names = [:glossary] roles('roles_001').add_permission! :manage_term_categories @request.session[:user_id] = users('users_002').id get :index, params: { project_id: 1 } assert_response :success end
実行結果は次のとおり故障(failure)となりました。
Failure: TermCategoriesControllerTest#test_index_response [/work/redmine/plugins/redmine_glossary/test/functional/term_categories_controller_test.rb:11]: Expected response to be a <2XX: success>, but was a <403: Forbidden>
ここでは成功を期待していましたが、どうしてでしょうか。
テストデータの参照で、プロジェクトからロールを辿るのに、先ほど、membersテーブル、member_rolesテーブルを順に手繰ってrolesテーブルに至ると記述しました。テストコードの実行において権限の確認で同様にプロジェクトからロールへ辿るために、fixturesの指定でこの手繰る際に参照する各テーブルのデータを指定しておく必要があるようです。
試みの7
ということでfixturesの指定を追加します。
fixtures :projects, :users, :roles, :members, :member_roles def test_index_response projects('projects_001').enabled_module_names = [:glossary] roles('roles_001').add_permission! :manage_term_categories @request.session[:user_id] = users('users_002').id get :index, params: { project_id: 1 } assert_response :success end
実行結果は、次の通り成功となりました。
... Finished in 0.554461s, 5.4107 runs/s, 5.4107 assertions/s. 3 runs, 3 assertions, 0 failures, 0 errors, 0 skips
setupメソッドに準備設定を記述
さて、最初のテスト項目が成功するようになりました。
次にテスト項目を追加していく際に、すべてのテストメソッドに準備設定としてプロジェクトのモジュール(プラグイン)有効化、ロールに権限の付与、を記述するのは冗長です。
そこで、setupメソッドに準備設定を記述します。
require File.dirname(__FILE__) + '/../test_helper' class TermCategoriesControllerTest < ActionController::TestCase fixtures :projects, :users, :roles, :members, :member_roles def setup @project = projects('projects_001') @project.enabled_module_names = [:glossary] roles('roles_001').add_permission! :manage_term_categories end def test_index_response @request.session[:user_id] = users('users_002').id get :index, params: { project_id: 1 } assert_response :success end end
Redmine 4.0(Rails 5.2)のプラグインをテストするなど(コントローラーのテスト編)
Redmine 3.x(Rails 4.2)までは、コントローラーの雛形を生成する際に一緒に生成される機能テスト(functional)のテストコードではActionController::TestCaseを継承していました。
class TermCategoriesControllerTest < ActionController::TestCase
Redmineを離れて、Rails 5ではコントローラーの雛形を生成する際に一緒に生成されるコントローラーのテスト(controller)のテストコードではActionDispatch::IntegrationTestを継承します。
class SomethingsControllerTest < ActionDispatch::IntegrationTest
ただし、Redmine 4.0(開発途上版 trunkブランチ)のコントローラー生成タスクでは従来どおりActionController::TestCaseを継承したテストコードが生成されます。
Redmine 4.xのプラグインにおけるコントローラーのテストで、ActionDispatch::IntegrationTestを使うとどうなるのかを探ってみます。
ActionDispatch::IntegrationTestの場合
コントローラーの一番単純なテストケースは、indexアクションを呼び出しHTTPステータスの成功が返ってくることになります。
ActionController::TestCaseを継承したテストクラスでは、次の様に記述していました。
class TermCategoriesControllerTest < ActionController::TestCase def test_index get :index, params: { project_id: 1 } assert_response :success end end
しかし、ActionDispatch::IntegrationTestを継承したテストクラスでこの記述のままだと実行時にエラーとなります(おかしなURLパスが生成されます)。
Error: TermCategoriesControllerTest#test_index: URI::InvalidURIError: bad URI(is not URI?): http://www.example.com:80index
getメソッドの第1引数 :index が、ホスト(なぜかhttp://www.example.com:80)に直接付いてしまっています。ActionDispatch::IntegrationTestではURLパス(URLからホスト名:ポート番号までを除いた残り)を指定しなくてはならないようです。
URLパスを指定する方法(1)
ルーティング設定でresources/resourceを指定していると、名前付きルーティングが生成されるので、ヘルパーメソッドで指定が可能です。しかし、今回getで生成しているのでデフォルトでは名前付きルーティングが生成されません。
プラグインのconfig/routes.rbで次のように記述していると、
get '/projects/:project_id/term_categories', to: 'term_categories#index'
生成されるルーティングは次のようになり、名前付きルーティングはありません。
GET /projects/:project_id/term_categories(.:format) term_categories#index
プラグインのconfig/routes.rbで次のようにasで名前を指定すると、
get '/projects/:project_id/term_categories', to: 'term_categories#index', as: 'project_term_categories'
生成されるルーティングは次のように、名前付きルーティングが設定されます。
project_term_categories GET /projects/:project_id/term_categories(.:format) term_categories#index
class TermCategoriesControllerTest < ActionDispatch::IntegrationTest def test_index get project_term_categories_url, params: { project_id: 1 } assert_response :success end end
URLパスを指定する方法(2)
名前付きルーティングがない場合、URLパス文字列を自前で構成する方法があります。
get "/projects/#{@project.id}/term_categories", params: { project_id: 1 }
ユーザーの指定方法?
ログインが必要となるプラグイン機能のテストでは、ログイン情報を持たないでテストを実行すると、ログイン画面へのリダイレクトが発生し、テスト結果が成功ではなく302(リダイレクト)となっています。
ActionController::TestCaseでは、@request.session にユーザー情報を入れてログイン状態でのテストを実施できました。
ActionDispatch::IntegrationTestでは、post でログインをしてから対象テストを実施するようです。
ログインのHTTPメソッド(POST)を送ってからコントローラーのアクションに対応するHTTPメソッドを呼ぶのですが、ログイン情報の引継ぎの方法などが分かりません。
このあたりのノウハウが欲しいところです。
Redmine 4.0(Rails 5.2)のプラグインをテストするなど
はじめに
今年の5月〜6月の2か月間で、Redmineの用語集プラグインを一から再実装する作業をしてきた結果、RubyとRuby on RailsとRedmineプラグインの作り方についてやっとある程度分かるようになってきました。
まだ当初目標はコンプリートしていませんが(テストの途中で立ち往生気味)、その模様は次のWikiにあります。
Redmine Glossaryプラグイン再構築 - ソフトウェアエンジニアリング - Torutk
7月に入ったので、本来の目的であった用語集プラグインをRedmine 4.0(Rails 5.2)で動作するよう修正する作業に入りました。こちらは1週間でほぼ作業を終えました。この修正作業のまとめを次のブログに記載しました。
Redmine 4.0(Rails 5.2)に用語集(Glossary)プラグインを対応させる - Qiita
修正作業の過程は次のチケットにメモを残しています。
機能 #81: Redmine 4.0でglossary pluginを動くようにする - ソフトウェアエンジニアリング - Torutk
今後、用語集プラグインのメンテナンスをできるようにするためには、構造を分かりやすくするリファクタリングをしていきたいので、そのためにはテストが不可欠となります。
Railsのテスト
MinitestかRSpecか
Rails標準のテストは、Minitestと呼ばれるテストフレームワークを使ったものです。しかし、Redmineプラグイン開発やRailsの開発関係の記事などでは、別途RSpecを推奨しているものを多く見かけます。Minitest v.s. RSpec を扱うブログも多数見つかります。
Railsを使ったプログラム(Redmienプラグイン)の開発知識を得るために買った次の書籍でも、テストの章ではRSpecを「デファクトスタンダードである」として採用しています。
Ruby on Rails 5の上手な使い方 現場のエンジニアが教えるRailsアプリケーション開発の実践手法 (Web Engineer's Books)
- 作者: 太田智彬,寺下翔太,手塚亮,宗像亜由美,株式会社リクルートテクノロジーズ
- 出版社/メーカー: 翔泳社
- 発売日: 2018/01/24
- メディア: 単行本(ソフトカバー)
- この商品を含むブログ (1件) を見る
2015年の関西Ruby会議06での次のセッション資料では、「両方使ってみて判断する」とまとめられています。
RSpecとMinitest使うならどっち
テストコードの書き方(API)については、Minitestはassertを使ったテスト判定を行うので、CppUnitやJUnit(バージョン3まで)などのユニットテストフレームワークで用いられているassertによるテスト判定に慣れていると障壁を感じることなくMinitestのAPIに馴染めます。
RSpecは内部DSLで独特の書き方を覚える必要があります。JUnit 4で導入されたmatcherに概念が近いですがAPIはJUnit 4とは違う印象です。
テストデータの準備については、Minitestの特徴であるfixturesを使った、テストデータをYAMLで記述しテスト実行時に読み込みテストコードで利用する方法と、RSpecではFactoryBotでテストデータを生成する方法となり、分かりやすさは圧倒的にfixturesです。しかし、モデルのテストではデータベースの制約・整合性テストをする必要があり、異常値(制約違反データ)をデータベースに入れてわざとエラーを発生させることがfixturesでは出来ないため、RSpec(FactoryBot)がいいという話も見かけました。
モックについてはRSpecに軍配が上がる(Minitestにはモックがない?)ようでうs。
ということで、どちらを選ぶかは、やはりトレードオフとなるようです。
今回はトレードオフの主項目を、学習障壁が低いこと(環境構築の手間と覚えることが少ないこと)とし、テストコードが増えてもよしとし、Minitestを使って進めることとします。
RubyやRailsで覚えることだらけの現状では、RSpecの書き方をさらに覚えるのはアップアップ気味で辛さを感じてしまいます。
モデルのテストについて
そもそもモデルクラスをどう構成するか
モデルのテストを探っていたところ、そもそもMVC構造におけるMの役割にぶち当たりました。
Railsのモデルは一般的にはActiveRecordですが、これはO/Rマッパーで、ドメインモデルとしてビジネスロジックを持たせるかどうかについてはRails界隈で議論になっているようです。
Railsに限らず、MVC的な構造を取るとよく陥るのが肥大化したコントローラーです。
Webアプリケーションではなく、GUIアプリケーションや、ヘッドレスな通信処理アプリケーションなどでも、コントローラーでイベントの受理から応答を返すための集計・加工など(いわゆるビジネスロジック)を手掛けてしまいあっという間に数百〜千行超と肥大化することがあります。
その解決の一方法として、O/Rマッパー部分のActiveRecord継承クラス(物理モデル)と、ビジネスロジックを持つ論理モデルに分割し、コントローラーからは論理モデルにアクセスするという設計があります。
コード上では、
- 論理モデルをクラスとして別定義する。コントローラーからは論理モデルにのみアクセス、物理モデルは論理モデルからのみアクセス可能とする。
- 論理モデルをモジュールとして別定義し、ActiveRecord継承クラスでincludeする。コントローラーからはモジュールに定義したメソッドを呼び出し、ActiveRecord継承クラスのメソッド(属性)には触らない。
の2案があります。後者が推奨される模様です。
ActiveRecord継承クラスのテスト
- validationのテスト
- データベースの整合性テスト(制約、外部参照など)
をテストします。
テストデータは、fixtureで用意すると制約違反なデータは用意できないので、まずfixtureでは制約を守ったデータを用意し、テストメソッドでカラムに制約違反となる値を入れてvalidをassertで確認するよウニテスト記述します。
require File.dirname(__FILE__) + '/../test_helper' class TermCategoryTest < ActiveSupport::TestCase plugin_fixtures :term_categories def setup @category = TermCategory.find(1) end def test_valid assert @category.valid? end def test_invalid_without_name @category.name = nil assert_not @category.valid?, 'saved term_category without a name' assert_not_nil @category.errors[:name], 'no validation error for name present' end
ここで、invalidのテストをするにあたり、前提としてモデルクラスでvalidateされている属性となります。モデルクラスでvalidateしていないでデータベースの制約がかかっている場合、valid?はtrueになりますが、モデルをsaveすると例外が発生します(例、ActiveRecodrd::NotNullViolation)。
validationで必須指定をしていないがデータベース上はNOT_NULL制約をかけている属性(カラム)については、次のようなコードでテストできます。
def test_violation_notnull_author_id @term.author_id = nil assert_raises ActiveRecord::NotNullViolation do @term.save end end
テストのデバッグ
ユニットテストを実行して問題が発生したときに、その場所での変数の値を確認したり、ステップ実行させることができると重宝します。というかそれができない開発環境は貧弱です。
Rubyでは、pry-byebugライブラリを使い、ブレークポイントを置きたいコード上にbinding.pryと追記するとそこでブレークしてコマンド待ち(REPL)となります。
その他
assertの書き方メモ
空文字列の判定
assert_empty @term.value('category')
日時の判定(一致判定)
assert_equal '2010-12-13 21:25:16'.to_datetime, @term.value('datetime')