torutkのブログ

ソフトウェア・エンジニアのブログ

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のキーワードと条件式の丸括弧開きの間に空白がないといったところで慣習的なスタイルと違いがあってそっちが気になってしまいました…。

説明用の変数とメソッドとしての独立

Java読書会でも以前読んだ「リファクタリング」の内容でもあり、違和感なくうなずけます。

値の範囲を制限してプログラムを分かりやすく安全にする

制御系のアプリケーションでは、範囲を定義して範囲外の値を代入できないようにすることが必要でした。必要性はとってもうなずけます。

QuantityやUnitといえば、JavaAPIとしてJSR 363が作られています。JSR 363の簡単な紹介を次に書いています。2018年に入ってJSR 385がAPIのVer.2.0として了承されたようです。
JSR 363 Units of Measurement を調べてみて

範囲を扱うRangeクラスもいくつかのライブラリで使われています。
id:torutk:20110924

値オブジェクトは「不変」にする

この本では言及はありませんが、マルチスレッドプログラミングでスレッド間でオブジェクトを安全に受け渡しする際には「不変」が重要となります。

コレクション型を扱うロジックを専用クラスに閉じ込める

ファーストクラスコレクションというそうですが、これは意識したことがないので有用かどうかは試してみたいところです。

ジェネリックスがJavaに導入される前はコレクションに入れる型を保証するためにコレクションを内部に持ち外部からコレクションへ追加するメソッドで型を規定したことはありました。これの発展形なのかなぁと漠然と思いました。

CHAPTER 2 場合分けのロジックを整理する

判断や処理のロジックをメソッドに独立させる

if文の条件式が複雑になるときは、条件式をメソッド化して整理するのはよく使う手法なので、うなずけます。

else句をなくすと条件分岐が簡単になる

節題がドキッとする内容(elseをなくすとロジックの抜け漏れが発生するんでは?)ですが、読み進めてみるとガード節(早期リターン)の導入でした。これもよく使う手法です。

Javaの列挙型を使えばもっとかんたん

Javaenumいいです。ぜひ活用しましょう。

状態の遷移ルールをわかりやすく記述する

enum導入以前に状態遷移ロジック書いたきりなので、enumで状態遷移ロジックというのは実装したこと、実装しようと思ったことがなかったです。ちょっと目から鱗です。

サンプルコードで、EnumSetを使っていながら、EnumMap使わないでHashMapつかったのは何か意図があるのかなと議論になりました。

CHAPTER 3 業務ロジックを分かりやすく整理する

いろいろな見方があって議論が高まる章となりました。
Java読書会BOFのモットー(自称)は、「脳みそに汗をかこう」*1なので、読書会の本としては成功です。

共通機能ライブラリが失敗する理由

Utilクラス、Commonクラスアプローチではうまくいかないなということは身をもって痛感し、自分ならNGワードとしたい命名です。Commonといいつつ大抵は狭い範囲での共通に過ぎないものばかりでした。

一方で、「データクラスと機能クラスに分ける設計」としては、C++STLライブラリなどがあります。Java 8ではStream APIラムダ式とともに導入されています。

ちょっとしたニーズの違いについては、フラグやオプション引数で対応するのではなく、ラムダ式で違いの部分をコードで利用側から指定するなどの方法もあります。言語仕様、標準APIの進歩でこれまで難しかったことも状況が変わってきています。

このあとまだまだ興味深い内容がつづきますが、日記の方は続き、ということで。

Java読書会BOF主催「現場で役立つシステム設計」を読む会が今月から始まります #javareading

今月8月18日(土)から、Java読書会BOF主催のJava読書会は、次の本となりました。

この本、前回の投票では次点となっていました。この本は、題名に「システム設計」とあるので、プログラミングとは距離を置いたシステム開発方法のようなものと思っていましたが、前回候補に挙がっていたので中身をパラパラ見てみたところ、サンプルコードはJavaで、リファクタリング、メソッド・クラスの分割、命名APIといった内容を扱っていました。ドメイン駆動は直接言及していませんが、ドメインモデルを作ることに言及していました。

Java読書会で、オブジェクト指向を扱うのは、2006年の「デザインパターンとともに学ぶオブジェクト指向のこころ」以来です。

著者の考え方が表われている本なので、賛否両論で議論が盛り上がる期待があります。

WSL(Windows Subsystem for Linux)とHyper-V上のLinuxとの重さの違い

はじめに

Windows OS上でRedmineを動かす環境を用意し、さらにプラグイン開発環境も用意するのはかなり難儀なので、Windows OS上に仮想マシンVMwareVirtualBoxHyper-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のコマンドを実行した場合の時間を比べてみました。

環境

動作確認PCの構成
CPU AMD PhenomⅡ 1050T (2.8GHz)
Memory DDR3 16GB
Disk(Cドライブ) SSD SanDisk UltraⅡ 480GB
Disk(Dドライブ) HDD Western Digial 1002FAEX 1TB
OS Windows 10 Pro 1803 (64bit)
Linux動作環境

RedmineRuby on Railsアプリケーション)のプラグイン開発環境として、Pumaサーバー起動、minitestのテスト実行をするのに、Windows 10マシン上で次の2つの環境を用意しました。

実行時間の計測

この環境で、次の項目の実行時間を測ってみました(ストップウォッチ目測)。

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済みの状態で実行)

bundle exec rails redmine:plugins: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の上でrubyrails)を実行するとクラッシュ(コアダンプ)するようになります。こうなると、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の用語集プラグインを一から再実装する作業をしてきた結果、RubyRuby on RailsRedmineプラグインの作り方についてやっとある程度分かるようになってきました。

まだ当初目標はコンプリートしていませんが(テストの途中で立ち往生気味)、その模様は次の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)

Ruby on Rails 5の上手な使い方 現場のエンジニアが教えるRailsアプリケーション開発の実践手法 (Web Engineer's Books)

2015年の関西Ruby会議06での次のセッション資料では、「両方使ってみて判断する」とまとめられています。
RSpecとMinitest使うならどっち


テストコードの書き方(API)については、Minitestはassertを使ったテスト判定を行うので、CppUnitJUnit(バージョン3まで)などのユニットテストフレームワークで用いられているassertによるテスト判定に慣れていると障壁を感じることなくMinitestのAPIに馴染めます。
RSpecは内部DSLで独特の書き方を覚える必要があります。JUnit 4で導入されたmatcherに概念が近いですがAPIJUnit 4とは違う印象です。

テストデータの準備については、Minitestの特徴であるfixturesを使った、テストデータをYAMLで記述しテスト実行時に読み込みテストコードで利用する方法と、RSpecではFactoryBotでテストデータを生成する方法となり、分かりやすさは圧倒的にfixturesです。しかし、モデルのテストではデータベースの制約・整合性テストをする必要があり、異常値(制約違反データ)をデータベースに入れてわざとエラーを発生させることがfixturesでは出来ないため、RSpec(FactoryBot)がいいという話も見かけました。

モックについてはRSpecに軍配が上がる(Minitestにはモックがない?)ようでうs。

ということで、どちらを選ぶかは、やはりトレードオフとなるようです。
今回はトレードオフの主項目を、学習障壁が低いこと(環境構築の手間と覚えることが少ないこと)とし、テストコードが増えてもよしとし、Minitestを使って進めることとします。

RubyRailsで覚えることだらけの現状では、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')