おもこん

おもこんは「思いつくままにコンピュターの話し」の省略形です

徒然Ruby(30)Rails7 検索と削除

今回はWordBookの検索と削除についてです。

検索

検索ではクライアントがサーバに正規表現を送り、マッチするデータをHTMLで送ってもらいます。 送信方法がGETでもPOSTでもプログラムできますが、今回はGETを使うことにします。

  • GETでデータを送る場合はアドレスに続く?以後にデータを付け足す。 このことから
    • URLの長さの仕様上(理論上)の制限は無いようだが、ブラウザの実装上はあるらしい。 そのためあまりに大きいデータ(目安として約8000、ただし古いブラウザでは約2000ということもあるらしい)を送るのには適していない
    • URLに置かれるために暗号化できず、セキュリティ上問題がある(URLが暗号化されると行き先が分からなくなってしまうので、暗号化できない)
    • URL上に置かれるので、ブックマーク時にデータも保存することができる。 例えばGoogle検索では検索語句はURLに挿入されるので、ブックマークしておけば同一語の検索が簡単にできる
  • GETはそもそもリソースを要求するメソッドであることから、検索結果を送ってもらうメソッドとしては適している
  • POSTではTurboがリダイレクトを期待するのに対して、GETではリダイレクトの必要がない。 これはRailsではアクションの節約になる

検索ワードを秘匿する必要はないので、上記を総合的に考えてGETメソッドを使うことにしました。 特にリダイレクト無しで済むのは大きいような気がします。

Search アクション

ナビゲーションバーのSearchをクリックするとリンク先からsearchアクションにルーティングされます。

get 'words/search'

コントローラのsearchアクションの部分は次のようになっています。

def search
  @search_word = ""
end

@search_wordは検索枠の初期値で、最初は空文字列にしておきます。

ビュー(/app/views/search.html.erb)は次のようになっています。

<h1 class="my-2">単語検索</h1>
<%= form_with url: words_list_path, method: :get do |form| %>
  <div>
    <%= form.label :en, "英単語", class: "form-label" %><br>
    <%= form.text_field :en, value: @search_word, class: "form-control" %>
  </div>
  <div class="my-2">
    <%= form.submit "検索",  class: "btn btn-primary" %>
  </div>
<% end %>
</div>
  • タイトルは「単語検索」。 もうおなじみのmy-2クラスは、m(マージン)y(上下)2(rem)で、上下に隙間を作るBootstrapのクラス
  • form_withメソッドにはモデルはつけない。 createやupdateと異なり、モデルの生成、改変を行わないからだ。 アクセス先はword_list_pathメソッドの返り値/words/listである。 method: :getによって、GETメソッドでのデータ送信となる
  • 「英単語」のラベルをつける
  • text_fieldメソッドによりtype="text"のinputタグを生成する。 初期値は@search_wordで与えられるが、searchメソッドからの流れでは空白になっている。 一度送信してエラーになった後の再入力では前回入力値が@search_wordに残っているので、それが初期値になる
  • サブミットボタンの文字は「検索」になる

検索画面は次のようになります。

検索画面

例えば「cool」を検索枠に入力してサブミット・ボタンを押すと、次のようなURLに送信されます。

http://localhost:3000/words/list?en=cool&commit=%E6%A4%9C%E7%B4%A2

最後の%のついた6バイトデータは「検索」がURL用に変換されたものです。 このように、GETメソッドでのデータ転送は、URLの?以後に「キー=データ」の形で行われます。

List アクション

GETで/words/list?... ... ...にアクセスすると、Wordコントローラのlistアクションにルーティングされます。

get 'words/list'

コントローラのlistアクションの部分は次のようになっています。

class WordsController < ApplicationController
... ... ...
... ... ...
  def list
    @search_word = params[:en]
    if @search_word == ""
      flash.now[:alert] = "検索ワードは入力必須です"
      render :search, status: :unprocessable_entity
    end
    begin
      pattern = Regexp.compile(@search_word)
    rescue RegexpError
      flash.now[:alert] = "正規表現に構文エラーがあります"
      render :search, status: :unprocessable_entity
    else
      @words = Word.all.select{|word| word[:en] =~ pattern}.sort{|w1,w2| w1[:en] <=> w2[:en]}
    end
  end
... ... ...
... ... ...
end
  • 検索パターンはparams[:en]で手に入れることができる。 form_withでフォームタグを作ったときにモデルの指定がなかったことから、params[モデル名][en]の[モデル名]が無くなったことによる。 検索パターンは@search_wordに代入する
  • 検索パターンが空文字列だったときは、入力画面を再表示する。 フラッシュ「検索ワードは入力必須です」を表示する。 なお、検索パターンはRuby正規表現に直されるのだが、仮に空文字列を正規表現にすると//になり、任意の文字列にマッチする。 これを許す考え方もあると思う。 その場合はこのif文は必要ない
  • 検索パターン文字列を指す@search_wodから正規表現オブジェクトpatternを作る。 Regexp.compileメソッドを用いる。
  • コンパイルができない場合は例外が発生する(例えば検索ワードが「*」の場合。繰り返しを表す*の前に文字が必要)。 例外をrescueで捕捉し、「正規表現に構文エラーがあります」をフラッシュにセットし、searchビューで入力画面を再描画する。 HTTPステータスはunprocessable_entity
  • 例外が発生せず無事にコンパイルできた場合は、else節を実行する。 Word.allは、Wordクラスのオブジェクトをデータベースからすべて取り出し、それをActiveRecord::Relationクラスのオブジェクトにして返す。 このクラスはEnumerableモジュールをインクルードしているので、selectなどのメソッドを使うことができる。 selectメソッドは、そのブロック(正規表現patternと一致する)が真となるWordオブジェクトの配列を返す。 最後にその配列にsortメソッドを用いる。 sortメソッドはブロックによる大小評価(<=>メソッドと同じ評価基準)によりソートを行う。 英単語の辞書順にデータがソートされる。 このあと、listビューに@wordsが引き継がれ、マッチした単語の一覧をHTMLにして送信する。 なお、リクエストがGETメソッドだったのでリダイレクトは不要

一度全部データを取り出してから正規表現でマッチするWordオブジェクトの配列を作っています。 データベースから全部取り出さず、正規表現でマッチするものだけを取り出すことはできないのでしょうか? これは可能ですが、いくつか解決しなければならない問題があります。

  • Rails自身はデータ取り出し時の正規表現はサポートしていない(注:LIKE句は使える)
  • SQlite3がデフォルトでは正規表現をサポートしていない。 RailsからSQlite3に発行するSQL正規表現は使えない。 (注:コマンドラインから起動する「sqlite3」というコマンドは正規表現をサポートしている)
  • 正規表現によるデータ取得に関するSQLの構文が統一されていない。 データベースごとにRailsの発行するSQLを変えなければならない
  • 仮にクライアントから送られてきたデータをSQLに単純に埋め込むとしたら、それは非常に危険(SQLインジェクション)。 それを避けるには、データの内容をチェックする必要があり、そのためのプログラムを書かなければならない。 今回はローカルでの使用を考えているので危険性は少ないが、これをネット上で一般公開する場合は特に考慮が必要

この問題はデータベースの大きさとアクセスの頻度によります。 それらがさほど大きくなければ、データを全部読み出す負担は小さく押さえられます。 逆に膨大なデータでは負担が増し、正規表現SQLを発行するメリットが大きくなります。

単語帳のデータ数は通常さほど大きくないので「データ全読み出し」のデメリットはあまり現れないと思われます。 データ数が極めて大きくなるような場合には変更を検討することが望ましいかもしれません。

削除

削除の流れは次のようになります。

  • ナビゲーションバーのDeleteをクリック
  • Wordコントローラのdeleteアクションが実行される=>削除画面表示、その中に削除単語を入力する枠がある
  • 削除単語を入力してサブミットボタンをクリック
  • Wordコントローラのexec_deleteアクションが実行される=>削除実行、次の画面にリダイレクト
Delete アクション

ナビゲーションバーのDeleteをクリックすると/words/deleteにGETメソッドでアクセスします。 これはWordコントローラのdeleteアクションにルーティングされます。

get 'words/delete'

deleteアクションは次の通りです。

def delete
  @delete_word = ""
end

@delete_wordはビューの中で入力枠の初期値として使われます。 最初は空文字列です。

ビュー(/app/views/delete.html.erb)は次のようになります。

<h1 class="my-2">単語削除</h1>
<%= form_with url: words_exec_delete_path, method: :delete do |form| %>
  <div>
    <%= form.label :en, "英単語", class: "form-label" %><br>
    <%= form.text_field :en, value: @delete_word, class: "form-control" %>
  </div>
  <div class="my-2">
    <%= form.submit "削除",  class: "btn btn-primary", data: {turbo_confirm: "Are you sure?"} %>
  </div>
<% end %>

今までのフォーム作成と大きく違うのは次の2点です。

  • リクエストのメソッドがDELETEになっている。
  • サブミットボタンにdata: {turbo_confirm: "Are you sure?"}がついている。 これはボタンをクリックしたときに確認ダイアログを表示するためのものです。 Rails7ではTurboが使われているのでturbo_confirmにしなければなりません。 (以前はconfirmでした)

送信先のアドレスはwords_exec_delete_pathメソッドの返り値である/words/exec_deleteになります。

Exec_delete アクション

DELETEメソッドで/words/exec_deleteに送られたリクエストはWordsクラスのexec_deleteアクションにルーティングされます。

delete 'words/exec_delete'

exec_deleteアクションは次の通りです。

def exec_delete
  @delete_word = params[:en]
  @word = Word.find_by(en: @delete_word)
  if @word == nil
    flash.now[:alert] = "単語#{@en}は未登録のため削除できません"
    render :delete, status: :unprocessable_entity
  else
    begin
      @word.destroy
    rescue
      flash[:alert] = "単語#{@en}を削除できませんでした"
    else
      flash[:success] = "単語を削除しました"
    end
    redirect_to words_index_path, status: :see_other
  end
end
  • クライアントから送られたデータを@delete_wordに代入する
  • データベースから@delete_wordに一致するデータを取り出す
  • データが見つからなければ@wordはnilになる。 このとき「単語・・・は未登録のため削除できません」をフラッシュに入れてdeleteビューを再送信する。 このとき、@delete_wordすなわちクライアントが削除を要求した単語名が入力フォームの初期値になる。 ステータスはunprocessable_entityとする
  • データが見つかったときは@word.destroyメソッドでデータベースから削除する。
  • エラーが発生したときは「単語・・・を削除できませんでした」をフラッシュにセットし、Wordsコントローラのindexアクションにリダイレクト
  • エラー無く削除できた場合は「単語を削除しました」をフラッシュにセットし、Wordsコントローラのindexアクションにリダイレクト

リダイレクトのときのHTTPステータスコードは303(see other)です。 status: :see_otherを省略すると、デフォルトの302(Found)になります。 この2つのステータスコードに対するブラウザの動作は

  • 302(Found) 同じHTTPメソッドで新しいURLにアクセスする。 つまり「はじめのアドレスが(一時的に)移転しているのでそちらにリダイレクトせよ」というのが302の趣旨であるので、同じHTTPメソッドでアクセスする。 (ただし、歴史的理由でGETメソッドでリダイレクト先にアクセスするブラウザもある)
  • 303(see other)GETメソッドで新しいURLにアクセスする。 これは「主にPOSTでデータを送信した後に受信するリソースをリダイレクト先に用意してあるのでそちらを見よ」という趣旨なので(リソース回収用の)GETでリダイレクト先にアクセスする

そのため、302リダイレクトを使うとDELETEメソッドでリダイレクト先にアクセスされ、最悪の場合には何らかのリソースが削除される可能性があるそうです。 ネットではそのような悲しいできごとに遭遇した人からの注意喚起が見られます。 気をつけましょう。

まとめ

以上でとりあえず動くWordbookができあがりました。

Railsは動的なウェブを作ることが特徴です。 「動的」とは、リクエストを受け取ったサーバーがリクエストに応じたHTML文書をその場で作成して送ることです。 それに対して「静的」とは、あらかじめHTML文書をサーバー内の用意し、リクエストのアドレスに対応する文書を送ることです。 「静的」なウェブに対してPOSTでデータを送ることは意味がありません。

Railsのポイントのひとつは、POSTなどで送られるデータの処理になります。 前回と今回の記事でその処理の流れがお分かりいただけたのではないでしょうか。 例えば、appendでは

  • appendアクション:入力画面を送信する
  • createアクション:送られてきたデータの処理。 正常=>303リダイレクト。 エラー:unprocessable_entityステータスで入力画面を再送信

という流れです。

GETでデータを送る場合は、処理が正常だった場合のリダイレクトがありません。 そこだけが違いです。

徒然Ruby(29)Rails7 モデルとデータベース

今回はRailsにおけるデータの作成と保存、そして変更について説明します。 そのベースになるモデルとデータベースの話から始め、appendとchangeの動作について詳しく説明します。

MVC

MVCはモデル、ビュー、コントローラを指すことばで、Railsの構成を指しています。

  • M(Model): モデルは保存対象のデータをオブジェクトにしたもの。 データベースに保存する
  • V(View): クライアントに送るHTML文書を作成する
  • C(Controller): モデルやビューの働きを調整しコントロールする

ビューについては前回の記事で扱ったので、イメージはつかめると思います。 今回はモデルを定義し、さらに全体を調整するコントローラも作成します。

モデルとデータベース

WordBookでは保存するデータは「英単語と日本語訳」のペアです。 ブラウザ画面から単語をRails側に送信すると、そのモデルが作成されデータベースに登録されます。 モデルはデータベースのレコードに1対1に対応するものです。 モデルはデータベースの1レコードに対応するものなので、単数形でクラス定義をします。 「class Word」というわけです。 コントローラが複数形「class Words」だったのと比べてみてください。

Railsのgenerateコマンドでモデルを作成できます。 引数にデータベースのフィールドを付けておきます。 今回は

  • 英単語=>フィールド名en、タイプは文字列(string)
  • 日本語訳=>フィールド名jp、タイプは文字列(string)

とします。

$ bundle exec rails generate model Word en:string jp:string 
      invoke  active_record
      create    db/migrate/20221020235423_create_words.rb
      create    app/models/word.rb
      invoke    test_unit
      create      test/models/word_test.rb
      create      test/fixtures/words.yml

表示されたパスのうち、最初の2つがモデルを扱う上で重要なファイルです。 1つ目のファイルは後ほどdb:migrateのところで説明します。 2つ目のファイルのapp/models/word.rbはモデルの設定をするファイルです。 このファイルにはWordクラスの定義が書かれており、その中身は空になっています。 ですが、このクラスはApplicationRecordクラスのサブクラスとして定義されていますので、スーパークラスの様々なメソッドを使うことができます。

ファイルに以下のようにvalidatesメソッドを追加します。

class Word < ApplicationRecord
  validates :en, presence: true, format: {with: /[a-zA-Z]+/}, uniqueness: true
  validates :jp, presence: true
end

validatesはデータをチェックするメソッドです。 データベースに書き込む直前にこのチェックが働き、チェックを通らない場合は書き込みをしません。 また、エラーの内容がモデルに書き込まれます。

  • enはデータが存在しなければデータベースに保存できない(空欄にはできない)
  • enは英文字の文字列でなければならない(formatのwithには正規表現を使える)
  • enはユニークである(すでに同名のデータが存在するならば、データベースに保存できない)。 英語には同じスペルで異なる単語が存在するのだが、この単語帳ではそれらを別のレコードして登録できない。 そのため1つのレコードに日本語訳を2つ書くことで問題を解決する。 これは誤って同一の単語を二重に登録するのを防ぐための措置
  • jpはデータが存在しなければデータベースに保存できない(空欄にはできない)

db/migrate/20221020235423_create_words.rbは「データベースにアクセスするための設定」をするファイルです。 なお、RailsはデフォルトでSQlite3をデータベースに使います。

class CreateWords < ActiveRecord::Migration[7.0]
  def change
    create_table :words do |t|
      t.string :en
      t.string :jp

      t.timestamps
    end
  end
end

CreateWordsクラスがActiveRecord::Migration[7.0]のサブクラスとして定義されています。 その中でchangeメソッドが定義されています。 create_tableメソッドはwordsという名前のデータベース・テーブルを作るものです。 そのテーブルには、文字列フィールドの「en」と「jp」、そしてタイムスタンプ(作成日時と更新日時)のフィールドが作られます。 このメソッドのブロックには現れませんが、idフィールドも自動的に作られ、1、2、3・・・という整数がレコードに割り振られます。

このファイルを用いてデータベース・テーブルを作ります。 db:migrateコマンドをrailsで実行します。

$ bundle exec rails db:migrate
== 20221020235423 CreateWords: migrating ======================================
-- create_table(:words)
   -> 0.0019s
== 20221020235423 CreateWords: migrated (0.0020s) =============================

コマンド名が「migrate」となっていますが、これは英語で「移住」という意味を表します。 データベースでマイグレート(動詞)、あるいはマイグレーション(名詞)というのは、データを「異なるフィールド構成のデータベース」に移すことをいいます。 同じデータベースで、データを保ったまま「フィールド構成を変更する」ときもマイグレーションといいます。 今回はデータベースをはじめて作成したので、既存のデータはなく、上記の意味でのマイグレーションではありません。

rails db:migrate」の名前から想像できると思いますが、このコマンドはデータベースのマイグレーションにも使えます。 その場合は「create_table」だけでなく「change_table」などのメソッドも使います。

コンソール

ここで、モデルに備わっているいくつかのメソッドを紹介します。 先程定義を書いたWordクラスを例にとります。

  • @word = Word.new => Wordクラスのオブジェクト(モデル)を生成し、インスタンス変数@wordに代入する
  • @word.en= => @wordの指すオブジェクトのインスタンス変数@enに値を代入する
  • @word.en => @wordのインスタンス変数@enの指すオブジェクトの値を取得する
  • @word = Word.find_by(en: "good") => データベースから、enフィールドの値が"good"であるデータを取り出し、@wordに代入する
  • @word.save => @wordの指すオブジェクトをデータベースに保存する
  • @word.delete => @wordの指すオブジェクトをデータベースから削除する

これらのメソッドを試すのにはRailsのコンソールを使うのが良いです。 少々回り道になりますが、コンソールを使ってみましょう。 上記のメソッドを試すと、その式の値が表示されるので何をしているのかがよく分かります。 次の例を丁寧にたどってみてください。

$ bundle exec rails console
Loading development environment (Rails 7.0.4)
irb(main):001:0> @word = Word.new
=> #<Word:0x00007f7759446f88 id: nil, en: nil, jp: nil, created_at: nil, updated_at: nil>
irb(main):002:0> @word.en="good"
=> "good"
irb(main):003:0> @word.jp="良い"
=> "良い"
irb(main):004:0> @word
=> #<Word:0x00007f7759446f88 id: nil, en: "good", jp: "良い", created_at: nil, updated_at: nil>
irb(main):005:0> @word.save
  TRANSACTION (0.7ms)  begin transaction
  Word Create (0.8ms)  INSERT INTO "words" ("en", "jp", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["en", "good"], ["jp", "良い"], ["created_at", "2022-10-21 04:38:51.334385"], ["updated_at", "2022-10-21 04:38:51.334385"]]
  TRANSACTION (14.4ms)  commit transaction
=> true
irb(main):006:0> @word2=Word.new
=> #<Word:0x00007f7758452578 id: nil, en: nil, jp: nil, created_at: nil, updated_at: nil>
irb(main):007:0> @word2=Word.find_by en: "good"
  Word Load (0.4ms)  SELECT "words".* FROM "words" WHERE "words"."en" = ? LIMIT ?  [["en", "good"], ["LIMIT", 1]]
=> #<Word:0x00007f7759505e10 id: 1, en: "good", jp: "良い", created_at: Fri, 21 Oct 2022 04:38:51.334385000 UTC +00:00, updated_at: Fri, 21 Oct 2022 04:38:51.334385000 UTC +00:00>
irb(main):008:0> @word.en == @word2.en
=> true
irb(main):011:0> @word.jp == @word2.jp
=> true
irb(main):012:0> @word.delete
  Word Destroy (43.0ms)  DELETE FROM "words" WHERE "words"."id" = ?  [["id", 1]]
=> #<Word:0x00007f7759446f88 id: 1, en: "good", jp: "良い", created_at: Fri, 21 Oct 2022 04:38:51.334385000 UTC +00:00, updated_at: Fri, 21 Oct 2022 04:38:51.334385000 UTC +00:00>
irb(main):013:0> @word = Word.find_by en: "good"
  Word Load (0.2ms)  SELECT "words".* FROM "words" WHERE "words"."en" = ? LIMIT ?  [["en", "good"], ["LIMIT", 1]]
=> nil
irb(main):014:0> exit
$

データベースとのやりとり(save、find_by、delete)ではSQLコマンドがデータベースに対して発行されているのがわかります。 RailsSQLを発行してくれるので、プログラマーSQLを知らなくてもデータベースを使うことができます。 これはRailsを使うアドバンテージのひとつです。

Railsのコンソールはプロンプトがirb(main)となっています。 irbRubyのgemで単独で使うこともできます。 単にコマンドラインから「irb」と打ち込めばOKです。 Rubyのドキュメントを参照してください。

単語の新規作成

ナビゲーションバーのAppendをクリックしてから単語登録を完了するまでの一連の流れを確認します。 以下では(C)がクライアント(ブラウザ)側、(S)がサーバ(Rails)側とします。

  • (C)ナビゲーションバーのAppendをクリック=>/words/appendへGETメソッドでアクセス
  • (S)Wordsコントローラのappendアクション(Wordsクラスのappendメソッド)へルーティング
  • (S)ビューで英単語と日本語訳のデータ入力枠、送信ボタンのあるHTMLを組み立て、送信
  • (C)単語と日本語訳を入力して送信ボタンをクリック=>/words/createへPOSTメソッドでアクセス
  • (S)Wordsコントローラのcreateアクションへルーティング
  • (S)送られた単語と日本語訳をWordモデル(Wordクラスのインスタンス)にセットしデータベースに保存
  • (S)保存成功の場合=>成功のメッセージと保存した英単語を表示するために/words/show/:idへリダイレクトする。ただし:idは登録したモデルのid
  • (S)保存失敗の場合=>失敗のメッセージと入力枠を再表示(レスポンスのステータスコードを「unprocessable_entity」にする)

(注)「unprocessable_entity」は「クライアントのリクエストが正しかったが、その指示が処理できなかった」ことを表します。 サーバはクライアントのリクエストに対してレスポンスを返すときにステータスコードを常に送りますが、 railsが自動的にコードを作ってくれることが多く、プログラムに明示する必要がありませんでした。 Rails7ではTurboという仕組みが使われていて、Turboではformタグでクライアントがデータを送った後のサーバーの対応が決められています。 詳しいことは「Turbo Handbook」を参照してください。 成功=>リダイレクト、失敗=>「unprocessable_entity」ステータスコードで入力画面を再送信、という流れでほぼ良さそうです。

上記の流れではサーバとクライアントの間を2から3往復します。 一度の操作では完了しないことに注意してください。

なお、ナビゲーションバーのchangeをクリックしたときの流れもこれに似ています。 そこで、今回の記事ではappendとchangeの両方を説明します。

appendとchange共用のパーシャル

append(単語の追加)とchange(単語の変更)は処理が似ています。 両者ともformタグを使ってデータをサーバに送信します。 そこで、フォーム部分を共通に使えるパーシャルにしておきます。 パーシャルはプログラムのサブルーチンのようなもので、他のビューから呼び出し、埋め込むことができます。 パーシャルはファイル名の先頭をアンダースコアにします。 以下に_form.html.erbのリストを示します。

<%= form_with model: @word, url: @path, method: :post do |form| %>
  <div>
    <%= form.label :en, "英単語", class: "form-label" %>
    <%= form.text_field :en, value: @en, class: "form-control" %>
    <% @word && @word.errors.full_messages_for(:en).each do |message| %>
      <div class="text-danger"><%= "DB: 英単語は空欄にできません" if message == "En can't be blank" %></div>
      <div class="text-danger"><%= "DB: 英文字を入力してください" if message == "En is invalid" %></div>
      <div class="text-danger"><%= "DB: すでに同名の単語が登録されています" if message == "En has already been taken" %></div>
    <% end %>
  </div>

  <div>
    <%= form.label :jp, "日本語訳", class: "form-label" %>
    <%= form.text_field :jp, value: @jp, class: "form-control" %>
    <% @word && @word.errors.full_messages_for(:jp).each do |message| %>
      <div class="text-danger"><%= "DB: 日本語訳は空欄にできません" if message == "Jp can't be blank" %></div>
    <% end %>
  </div>

  <div class="my-2">
    <%= form.submit @submit,  class: "btn btn-primary" %>
  </div>
<% end %>
  • form_withがformタグを生成するためのRailsのメソッド。
    • モデルを@wordとする。@wordはあらかじめコントローラで生成しておく
    • 送信先のURLを@pathで与える。@pathはコントローラで生成する
    • 送信メソッドはPOSTにする。 append(作成)ではPOST、change(変更)ではPATCHやPUTを用いるのがRailsの標準だが、ここでは両者ともPOSTを使うことにする
    • ブロックパラメータformにはFormBuilderオブジェクトが代入されており、そのオブジェクトにはlabeltext_fieldメソッドがある。 詳しくはAPIレファランスを参照
  • form.labelメソッドで、inputタグ上部にラベル(文字列)を表示する。 class: "form-label"はBootstrapのクラスclass="form-label"をタグの属性に指定する。
  • form.text_fieldで、type="text"のinputタグが作成される。 タグの名前を"en"に初期状態での枠内の文字列を@enにする。 @enはコントローラで定義する。 class: "form-control"はBootstrapのクラス
  • 次の5行は、@wordがnilでなく、@word.errors.full_messages_for(:en)が空でない場合にメッセージを表示する。 この「空でない」場合とは、@wordをデータベースに保存しようとしてエラーになった場合である。 もちろん、最初のフォーム送信ではエラーはないが、送信後データ保存時にエラーが発生し、フォームが再送信される場合にメッセージが表示される。 したがって、ここのエラーメッセージはモデルのデータベース登録時のバリデーション・メッセージである。
  • jp(日本語訳)の入力フォームも同様
  • form.submitで送信ボタンを表示する。 ボタンに現れる文字列は@submitになるが、これはコントローラで設定する。 class: "btn btn-primary"はBootstrapのボタンデザインのクラス
appendアクション

データ(英語・日本語訳)の新規作成はAppendメニューのクリックによって始まります。 このメニューのリンクは/words/appendに飛びます。 スラッシュから始まるリンクは、完全な形で書くとhttp:.//localhost:3000/words/appendになります。

このリンク先は/config/routes.rbによって、Wordsコントローラのappendアクション(appendメソッド)にルーティングされます。

Rails.application.routes.draw do
  get 'words/index'
  get 'words/show/:id', to: "words#show"
  get 'words/append'
  post 'words/create'
  get 'words/change'
  post 'words/update'
  get 'words/delete'
  delete 'words/exec_delete'
  get 'words/search'
  get 'words/list'
  root "words#index"
end

ブロックの上から3番め、get 'words/append'がそのルーティングです。 HTTPのGETメソッドで/words/appendをアクセスすると、このルーティングにマッチします。 行き先が省略されているときは、そのアドレス通りWordsコントローラのappendアクションに移動します。

Wordsコントローラ(Wordsクラスの定義)の中でappendアクションだけを取り出すと次のようになります。

class WordsController < ApplicationController
... ... ...
... ... ...
  def append
    @en = @jp = ""
    set_append
  end
... ... ...
... ... ...
  private

  def set_append
    @word = Word.new unless @word
    @path = words_create_path
    @submit = "作成"
  end
... ... ...
... ... ...
end

formパーシャルに必要なインスタンス変数はここで設定されます。

  • @wordにはWordモデルから新規作成されたオブジェクトが代入される
  • @pathにはwords_create_pathメソッドの返り値が入る。 このメソッドはroutes.rbからRailsが自動生成するもので、このルーティングでは'/words/create'が代入される。 なお、このようなメソッドを確認するにはbundle exec rails routesコマンドラインから入力する。 これによってルーティングの一覧が表示され、その一番左の列にメソッドが書かれている。 メソッドのないルーティングのルールもある。
  • @enと@jpには空文字列が代入される
  • @submitには"作成"が代入される

コントローラの処理の中で特に出力コマンドがない場合は対応するビューが呼ばれます。 /app/views/append.html.erbの内容は次のようになります。

<h1 class="my-2">単語登録</h1>
<%= render "form" %>
  • タイトルとして「単語登録」を表示。 `class="my-2"はBootstrapのクラスでm(マージン)y(上下)を2(デフォルトで2rem)に設定する。 少し上下に隙間ができる
  • renderメソッドにより、formパーシャルを呼び出してここに埋め込む。 パーシャルのファイル名にはアンダースコアがつくが(/app/views/_form.html.erb)renderの引数ではアンダースコア、拡張子なしで呼び出す

パーシャルのおかげで、非常に短くてすみました。

Append画面

このような画面が表示されます。 サブミットボタンには@submitの内容である「作成」が表示されています。 このボタンを押すと、formタグで定義されたようにPOSTメソッドで/words/createに送信します。

createアクション

ルーティングにより、createアクションが呼ばれます。

... ... ...
post 'words/create'
... ... ...

コントローラのcreateアクションの部分を示します。

class WordsController < ApplicationController
... ... ...
... ... ...
  def create
    @en = params[:word][:en]
    @jp = params[:word][:jp]
    @word = Word.new(en: @en, jp: @jp)
    if @word.save
      flash[:success] = "単語を保存しました"
      redirect_to "/words/show/#{@word.id}", status: :see_other
    else
      set_append
      flash.now[:alert] = "単語を保存できませんでした"
      render :append, status: :unprocessable_entity
    end
  end
... ... ...
... ... ...
end

送信されたデータをparamsメソッドで取り出すことができます。 そのときデータにつけられた名前(例えば「:wordの:en」)はappendビューで作成したフォームタグに書かれたものです。 form_withでモデルを指定した場合は、モデル名が名前の1番めになります。 form.text_fieldの最初の引数が名前の2番めになります。 モデルを指定しない場合は、params[:en]のようにモデル名なしになります。

  • 英単語はparams[:word][:en]で取り出せる。
  • 日本語訳はparams[:word][:jp]で取り出せる。
  • @wordを新規作成する。 そのときenフィールドを@en、jpフィールドを@jpにする。 つまり送信された英単語と日本語訳にする
  • saveメソッドにより@wordをデータベースに登録する。 このとき、モデル定義(/app/models/word.rbファイル)に書いたバリデーションが行われる。 すでに書いたように、バリデーションとはデータのチェックのこと。 改めてどのようなバリデーションだったかを書くと
    • enはデータが存在しなければデータベースに保存できない
    • enは英文字の文字列でなければならない
    • enはユニークである(すでに同名のデータが存在するならば、データベースに保存できない)
    • jpはデータが存在しなければデータベースに保存できない
  • バリデーションががOKならば保存が実行され@word.saveは真になる。 バリデーションを通らなければ保存されず、@wordにエラーメッセージがセットされ、@word.saveは偽になる。
  • 無事保存された場合は、フラッシュに「単語を保存しました」を代入し、showアクションにリダイレクトする。 リダイレクトとは、HTTPステータスコードの300番台とリダイレクト先アドレスをクライアントに送り、クライアントからそのアドレスにアクセスしてもらうことをいう。 なお、Turbo Handbookを見るとTurboではフォームのPOST送信後は303(see other)ステータスコードを期待しているとのことである。 それに沿ってプログラムでは、redirectメソッドの最後にstatus: :see_otherを付け加えた。 仮にステータスコードの指定を省略すると302(Found)がステータスコードになる。 それも試したところ、正しくリダイレクトされたので、現状は302でも良いのかもしれない。 ともあれ、RailsがTurboを使っている以上リダイレクトしなければならない
  • 保存に失敗した場合は、ステータスコードunprocessable_entity(データは受け取ったがサーバ側は想定された作業を遂行できなかった)を返し、リダイレクトせずにデータを送信する。 set_appendメソッドにより、@path送信先アドレス、@submitをセットする。 @wordはnilではないのでそのままになる。 このとき保存に失敗したバリデーションのエラー情報が@wordには含まれている。 renderメソッドでappendビューをクライアントに送る。 appendビューはformパーシャルを呼び出す。 このときformパーシャルの中で@wordのエラーメッセージが取り出され、それに対応した日本語メッセージがHTMLに加えられる。 このあとはクライアントからのPOST送信が繰り返されることになる
フラッシュ

フラッシュはリダイレクト後の画面でメッセージを表示するための仕組みです。 フラッシュはHashに似てキーと値を定義できます。

  • flash[キー]=メッセージ」でキーとメッセージを登録できる
  • flash[キー]」でメッセージを取り出せる

createアクションでデータが無事に保存された場合はflash[:success] = "単語を保存しました"でフラッシュに登録します。 これは次のshowアクションで取り出されて表示されることになります。

フラッシュはどのアクションでも使う可能性があるので、レイアウトの部分に入れておくのが良いです。 そこでレイアウト/app/views/application.html.erbを書き直します。

<!DOCTYPE html>
<html>
... ... ...
... ... ...
  <body>
    <div class="container">
      <div class="col-sm-10 col-lg-8 col-xl-6 mx-auto">
        <%= render "navbar" %>
        <%= render "flash" %>
        <%= yield %>
      </div>
    </div>
  </body>
</html>

コンテナで画面サイズの調整がされているのですが、大きな画面ではまだ幅がありすぎるので<div class="col-sm-10 col-lg-8 col-xl-6 mx-auto">で調整します。 これはすべての画面で適用されます。 その後にナビゲーションバーを表示するパーシャルnavbarを呼び出し、次にフラッシュを表示するパーシャルflashを呼び出します。 navbarはすでにコードを示しましたので、ここではflashのみを示します。

<% flash.each do |name, msg| %>
  <% if name == "success" %>
    <%= content_tag :div, msg, class: "text-success" %>
  <% elsif name == "alert" %>
    <%= content_tag :div, msg, class: "text-danger" %>
  <% end %>
<% end %>

これを/app/views/_flash.html.erbというファイル名で保存します。 フラッシュはHashと同じようにeachコマンドを使うことができます。

  • successキーの場合は、text-successクラス(緑色)でメッセージを表示
  • alertキーの場合はtext-danger(赤色)でメッセージを表示

content_tagメソッドは、ここではdivタグの中にメッセージを入れたHTMLを埋め込みます。

フラッシュはリダイレクトで用いるものですから、次回の送信で反映されます。

  • リダイレクト(303ステータスコード+リダイレクト先のアドレス)をクライアントに送信する(送信1回め)
  • クライアントはそれに基づいてリダイレクト先にGETメソッドでアクセスする
  • サーバがデータを送信する(送信2回め)=>このときフラッシュが有効になる

しかし、リダイレクト無しでフラッシュを使いたいときもあります。 そのときはflash.nowを用います。 flash.nowでは1回めの送信でフラッシュが有効になります。 createアクションの中の次のコードを比べてみてください

if @word.save
  flash[:success] = "単語を保存しました"
  redirect_to "/words/show/#{@word.id}", status: :see_other #<=リダイレクトがあるのでflashを使う
else
  set_append
  flash.now[:alert] = "単語を保存できませんでした"
  render :append, status: :unprocessable_entity #<=リダイレクト無しなのでflash.nowを使う
end

単語の変更

単語の変更はchange、updateアクションで行います。 この2つはappend、createに似ています。

change アクション

appendとの違いは@pathと@submitだけです。

class WordsController < ApplicationController
... ... ...
... ... ...
  def change
    @en = @jp = ""
    set_change
  end
... ... ...
... ... ...
  def set_change
    @word = Word.new unless @word
    @path = words_update_path
    @submit = "変更"
  end
end
  • @pathによるPOSTメソッドの送信先のアドレスにはwotds_update_pathメソッドを使う。 このメソッドは'/words/update'を返す
  • @submitは「変更」にする。 これがボタンの文字になる

対応するビューもほとんど同じです。 /app/views/change.html.erbの内容は次のとおりです。

<h1 class="my-2">単語の変更</h1>
<%= render "form" %>

appendのビューとの違いはタイトルだけです。 画面のスクリーンショットはほぼ同じなので省略します。 ボタンが押されると、POSTメソッドで先程の送信先にデータが送られます。

なお、Railsでは変更、編集、アップデートではPATCHまたはPUTメソッドが使われます。 そのようにプログラムを変更することはもちろん可能で、@pathで送信先をappendとchangeで区別したのと同様に@method変数に"post"、"patch"を入れて区別すればOKです。 その方がベターかもしれません。

update アクション

POSTメソッドで送信されたデータはroutes.rbによってWordsコントローラのupdateアクションにルーティングされます。

post 'words/update'

コントローラからupdateアクションの部分を取り出してみましょう。

class WordsController < ApplicationController
... ... ...
... ... ...
  def update
    @en = params[:word][:en]
    @jp = params[:word][:jp]
    @word = Word.find_by(en: @en)
    if @word == nil
      set_change
      flash.now[:alert] = "単語「#{@en}」は未登録のため変更できません"
      render :change, status: :unprocessable_entity
    elsif @word.update jp: @jp
      flash[:success] = "単語を変更しました"
      redirect_to "/words/show/#{@word.id}", status: :see_other
    else
      set_change
      flash.now[:alert] = "変更した単語を保存できませんでした"
      render :change, status: :unprocessable_entity
    end
  end
... ... ...
... ... ...
  def set_change
    @word = Word.new unless @word
    @path = words_update_path
    @submit = "変更"
  end
end
  • @enと@jpに送られてきた英単語と日本語訳を代入する
  • その英単語をもつWordオブジェクトをデータベースから探し出し@wordに代入する。 そのようなデータがなければ@wordにはnilが代入される
  • @wordがnilならば
    • set_changeメソッドで@wordに新規作成したWordオブジェクトを代入、@pathと@submitを初期化
    • フラッシュに「単語・・・は未登録のため変更できません」をセット。 今回の送信でフラッシュを使うのでflash.nowとnowメソッドをつける
    • 再びchangeビューを作成し、unprocessable_entityのステータスコードとともに送信
  • そうでなければ@wordのjp部分のデータを用いてデータベースを書き換える(update)。 その書き換えが成功した場合は
    • フラッシュに「単語を変更しました」をセット
    • showアクションへリダイレクト
  • データ書き換えに失敗した場合は
    • set_changeメソッドで@pathと@submitを初期化。 @wordはnilではないので変更されない。 この@wordには書き換え時のエラーメッセージが保持されている
    • フラッシュに「変更した単語を保存できませんでした」をセット。
    • 再びchangeビューを作成し、unprocessable_entityのステータスコードとともに送信

やっていることはcreateアクションと似ています。 違いはsaveの代わりにupdateを用いているところです。

  • saveは@wordのデータ全部を作成または更新する
  • updateは@wordの指定されたフィールドのデータのみを更新する

saveは更新にも使えるのでこちらをupdateの代わりに使うこともできます。

show アクション

createとupdateのリダイレクト先のshowアクションについて説明します。 showアクションは特定の1つの単語について表示します。 どの単語かの指定はアドレスの中に書きます。

  • words/show/1=>データベースからid=1の要素を取り出して表示
  • words/show/2=>データベースからid=2の要素を取り出して表示

このような形になります。 createやupdateでは

redirect_to "/words/show/#{@word.id}", status: :see_other

とい形でshowメソッドにリダイレクトしていました。 #{@word.id}の部分が取り出したいデータのidになります。 すなわち、createやupdateで作成または変更したデータのidです。

showメソッドへのルーティングは次のようになります。

get 'words/show/:id', to: "words#show"

GETメソッドでwords/show/:idのアドレスで呼ばれた場合、Wordsコントローラのshowアクションを呼び出します そのとき、:idの部分はパラメータになり、params[:id]でその値を読み出すことができます。 つまり/words/show/10でアクセスするとparams[:id]は10になります。

コントローラにおけるアクションは次のようになります。

def show
  begin
    @word = Word.find(params[:id])
  rescue ActiveRecord::RecordNotFound
    @word = nil
    flash.now[:alert] = "データベースにはid=#{params[:id]}のデータは登録されていません"
  end
end    

findメソッドは引数がidの値になるようなデータを探して取り出します。 データベースからparams[:id]の値がidフィールドに書かれているレコードを取り出し、@wordに代入します。 データが取り出せないときには例外が発生しますので、それを捕捉し、@wordをnilにしてフラッシュを作成します。

ここで、beginとrescueの使い方を説明します。 Rubyは例外が発生するとそこでプログラムを停止し、例外の内容を表示します。 プログラムを停止したくない場合はbeginとrescueを使い「例外の捕捉(つかまえること)」をします。 beginとrescueの間に例外が発生する可能性のあるステートメントを書きます。 rescueの次に想定される例外の名前を書きます。 その例外が発生するとrescue節に実行が移るので、そこで例外に対する処置を行います。

ビューは次のようになります。

<% if @word %>
  <ul class="list-group">
    <li class="list-group-item"><%= @word.en %></li>
    <li class="list-group-item"><%= @word.jp %></li>
  </ul>
<% end %>

@wordがnilのときは何も表示されません。 それでもフラッシュだけは表示されます。

今回はWordbookの単語登録と変更について書きました。 これが全体の3分の1くらいになります。 次回は残りの単語検索と削除を説明します。

徒然Ruby(28)Rails7とBootstrap5

一般に、HTMLは文書の構造を表し、CSSはその体裁(見栄え)を表します。 Railsは最終的にCSSを含むHTML文書を出力するので、この2つについての理解が必須です。 この記事ではとくにCSSの人気ライブラリであるBootstrapを紹介します。 BootstrapはJavascriptも含んでいます。

インストール

RailsとBootstrapの両方とも開発スピードが速いです。 そのため両者を組み合わせてインストールするのは、なかなか難しいものがあります。

  • インターネット情報は古いものが多い
  • 最新のバージョンの組み合わせに対する情報が少ない

今回ここで紹介するのはRails7とBootstrap5の組み合わせで「rails new」コマンドに「-c bootstrap」または「--css=bootstrap」オプションをつける方法です。 自分にとっても新しい方法なので、この記事が正しいと保証することができません。 ひとつの体験談として、また数ある情報のひとつとしてお考えいただきたいと思います。 また、この記事に基づいて行ったことから発生する問題に対して何ら保証するものではありませんので、自己責任でお願いします。

(補足)古いバージョンのrailsではgemを使っていました。 たとえば、bootstrapbootstrap-sassなどです。 Rails7でもそれらを使うことは可能だと思います。 ただし、bootstrap-sassはBootstrap3にしか対応していません。 rails7では、newコマンドでBootstrap5の導入が簡単にできるようになりました。 ここで用いた方法以外にCDNを使う方法も可能だと思います(検証不十分)。 CDNはBootstrapで推奨されている方法で、詳しくはBootstrapのサイトを見てください。

私のPC環境は「Ubuntu 22.04」です。

rails new word_book_rails -c bootstrapを行うと、エラーが出ました。

error @hotwired/turbo@7.2.2: The engine "node" is incompatible with this module. Expected version ">= 14". Got "12.22.9"
error Found incompatible module.

Ubuntu22.04の現在のnodejsのバージョンが12.22.9なので、それが古いというエラーだと思われます。 nodejsの新しいバージョンをインストールする情報がnodesource/distributionsにあります。 エラーメッセージではバージョン14以上が必要となっていますが、最新が18なので、それをインストールしてみます。 nodesource/distributionsのREADME.mdの情報によると、次のコマンドを実行すればよいとのことです。

curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - &&\
sudo apt-get install -y nodejs

実行したところなぜかエラーになり、その後sudo apt autoremovesudo apt remove nodejssudo apt install nodejsしたところ最新版がインストールできました。 エラーが気になりますが、一応次のようになっています。

$ node --version
v18.11.0

word_book_railsフォルダを削除して、再度newコマンドで作成します。

$ rm -rf word_book_rails
$ rails new word_book_rails -c bootstrap

たくさんのメッセージが現れ、数多くの作業が行われていることが感じられます。 途中で

npm WARN set-script set-script is deprecated, use `npm pkg set scripts.scriptname="cmd" instead.

という警告が出ますが、エラーではないので問題ないでしょう。 また、次のようなメッセージも出ます。

File unchanged! The supplied flag value not found!  app/assets/config/manifest.js

これがエラーなのか、警告なのか、あるいは問題のない状況なのかは良く分かりませんでした。 しかし、フォルダ内を見渡したところ、Bootstrapはインストールできているので、このまま次の作業に入りたいと思います。

Bootstrapとは?

BootstrapはCSSJavascriptのライブラリです。 これを使うことによって、ウェブの見栄えを美しくしたり、プルダウンメニューのような動的画面を簡単に作ることができます。 これをBootstrap無しで一から作るとしたら、膨大な時間がかかります。 Bootstrapは本当に神様のようなライブラリです(しかも無料)。 また、CSSJavascriptをよく知らなくても使えるので、学習コストを大幅に下げることができます。

具体的な例はBootstrapのウェブサイトを参照してください。 例えば「Components」の「Buttons」を見ると、サンプルとコードがあります。

Bootstrap=>ドキュメント=>Components=>Buttons

<button type="button" class="btn btn-primary">Primary</button>
<button type="button" class="btn btn-secondary">Secondary</button>
<button type="button" class="btn btn-success">Success</button>
<button type="button" class="btn btn-danger">Danger</button>
<button type="button" class="btn btn-warning">Warning</button>
<button type="button" class="btn btn-info">Info</button>
<button type="button" class="btn btn-light">Light</button>
<button type="button" class="btn btn-dark">Dark</button>

<button type="button" class="btn btn-link">Link</button>

サンプルのような色、形のボタンを作るには、対応するコードを書けば(コピペすれば)良いだけです。

また、「Layout」の「Containers」はレスポンシブデザイン(ディスプレイ画面の大きさに合わせて描画領域を自動的に変更する)のコンテナ(箱)です。 bodyタグの内側を<div class="container"></div>で囲めば、その内容を画面サイズに合うようにサイズ調整してくれます。

この他にも有用な機能が数多くあるので、良く見てください。

Bootstrapの使用例

今回はBootstrapのコンテナ、コラムとナビゲーションバーを使おうと思います。

作成する単語帳のウェブサイトを「Wordbookサイト」または単に「Wordbook」と呼ぶことにします。

コンテナ

Wordbookのすべてのビュー(画面)はレスポンシブデザインになるように、コンテナの中にいれます。 すべてのビューはapplication.html.erbの中に埋め込まれるので、このファイルにコンテナを設定します。

app/views/layouts/application.html.erbをエディタで開き、ボディタグの中身を次のように変更します。

<body>
  <div class="container">
    <%= yield %>
  </div>
</body>

それぞれのビューはyieldのところに埋め込まれるので、すべてのビューがclass="container"のdivタグに囲まれ、レスポンシブになります。 この効果は後ほど見ることにします。

パーシャルとナビゲーションバー

Wordbookのビューの多くはナビゲーションバーを持ちます。 ナビゲーションバーには、append、change、delete、listなどのメニュー(実際にはリンク)を設定します。 すべてのビューではないのでapplication.html.erbに書くことはできないのですが、多くのビューで使うものなのでパーシャルに記述します。 パーシャルはビューから呼ばれ、そこに埋め込まれるファイルです。

<%= render "abc" =>

とビューの中で書かれた部分はapp/views/_abc.html.erbの内容で置きかえられます。 ファイル名の先頭にはアンダースコア(_)がつくことに注意してください。

Bootstrapのウェブの「Components=>Navbar」に例があります。 そのコードをコピペしてから改変します。 以下にパーシャルapp/views/_navbar.html.erbの内容を示します。

<nav class="navbar navbar-expand-lg navbar-light bg-light">
  <div class="container-fluid">
    <a class="navbar-brand" href="#">Navbar</a>
    <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
      <span class="navbar-toggler-icon"></span>
    </button>
    <div class="collapse navbar-collapse" id="navbarSupportedContent">
      <ul class="navbar-nav me-auto mb-2 mb-lg-0">
        <li class="nav-item">
          <a class="nav-link active" aria-current="page" href="/">Home</a>
        </li>
        <li class="nav-item">
          <a class="nav-link" href="/words/append">Append</a>
        </li>
        <li class="nav-item">
          <a class="nav-link" href="/words/change">Change</a>
        </li>
        <li class="nav-item">
          <a class="nav-link" href="/words/delete">Delete</a>
        </li>
        <li class="nav-item">
          <a class="nav-link" href="/words/search">Search</a>
        </li>
    </div>
  </div>
</nav>

10行目から24行目までが改変部分で、メニュー名とリンク先以外はすべてコピペです。 順序なしリスト(<ul>タグで囲まれた部分)の各項目は次の通りです。

  • Home=>初期画面に戻る
  • Append=>単語を追加する画面に遷移する
  • Change=>単語を変更する画面に遷移する
  • Delete=>単語を削除する画面に遷移する
  • Search=>単語を検索しマッチした単語のリスティング画面に遷移する
ビュー

ビューでは「単語帳」という見出しと使い方の順序なしリストを表示することにします。 リストの各項目が短い文字列なので、コンテナの幅のままだとPC画面では横長になりすぎます。 (例えば私のノートPCは幅が1920ピクセルなので、コンテナの幅は1320ピクセルに設定されます)。 他方、スマホのような小さい画面ではコンテナは全画面を使用し、それが最適な表示になります。

そこで、画面サイズに応じた表示幅を設定しなければなちません。 Bootstrapでは画面幅を次のように分けています。

ブレークポイント クラスの接中辞 画面幅の範囲 コンテナの幅
X-Small なし <576px 100%
Small sm ≥576px 540px
Medium md ≥768px 720px
Large lg ≥992px 960px
Extra large xl ≥1200px 1140px
Extra extra large xxl ≥1400px 1320px

例えば、私のPC画面は幅1920pxでxxlになりますから、コンテナの幅は1320pxです。

BootstrapではX-Smallからxxlまでのどの画面でもコンテナの幅を12等分してカラム(列)を作ります。 3カラム分の幅をとりたいときはクラスにcol-3を指定すればよいのです。 この幅は3/12=1/4なので、コンテナの幅の4分の1になります。 (詳しくはBootstrapのカラムのStandalone Column Classesを参照してください)。

また、例えば、画面サイズがMediumのときには6カラム分にしたければcol-md-6とクラスに指定します。 次の例を見てください。

<div class="col-sm-10 col-lg-8 col-xl-6 mx-auto">
... ... ...
... ... ...
</div>
  • sm、lg、xlが使われている。
    • xlは1200px以上なので、xxlも含めて6カラムになる=>xlが1140/12*6=570、xxlが1320/12*6=660
    • lgは8カラム=>960/12*8=640
    • smは576px以上なので、mdも含めて10カラムになる=>smが540/12*10=450、mdが720/12*10=600
    • 上記で指定されていないX-Small(576px未満)は「コンテナのサイズ(12カラム)=画面のサイズ」になる
  • mx-autoは、(m)マージンの(x)左右を(auto)自動にする。 このときコンテンツがセンタリングされる状況になる。 (W3C CSSヒントとトリック参照)

このように画面サイズに応じた幅を取ることができます(レスポンシブ・デザイン)。 のちほど、ビューの中でこのタグが使われます。

動作確認

Bootstrapの効果を確認するために、初期画面のみ実装してみます。 ルーティング、コントローラ、ビューの3つが必要です。

config/routers.rbroot "words#index"の1行を加えます。

Rails.application.routes.draw do
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Defines the root path route ("/")
  # root "articles#index"
  root "words#index"
end

コントローラWordsを作ります。 アクションは初期画面がindexで、AppendからListまではそれぞれ同名小文字にします。 railsのgenerateコマンドで作成します。

$ bundle exec rails generate controller Words index append change delete search
      create  app/controllers/words_controller.rb
       route  get 'words/index'
              get 'words/append'
              get 'words/change'
              get 'words/delete'
              get 'words/search'
      invoke  erb
      create    app/views/words
      create    app/views/words/index.html.erb
      create    app/views/words/append.html.erb
      create    app/views/words/change.html.erb
      create    app/views/words/delete.html.erb
      create    app/views/words/search.html.erb
      invoke  test_unit
      create    test/controllers/words_controller_test.rb
      invoke  helper
      create    app/helpers/words_helper.rb
      invoke    test_unit

コントローラの内容変更は今のところありません。

ビューを作ります。 app/views/words/index.html.erbを編集します。

<div class="col-sm-10 col-lg-8 col-xl-6 mx-auto">
  <%= render "navbar" %>
  <h1 class="text-center">単語帳</h1>
  <h5>単語帳の使い方</h5>
  <ul class="list-group">
    <li class="list-group-item">Wordbook: 初期画面に戻ります</li>
    <li class="list-group-item">Append: 単語を追加します</li>
    <li class="list-group-item">Change: 登録済み単語を変更します</li>
    <li class="list-group-item">Delete: 単語を削除します</li>
    <li class="list-group-item">Search: 単語を検索しマッチした単語を表示します</li>
  </ul>
</div>

以上の準備をしたら、railsでサーバを動作させてブラウザで画面確認します。 今回はbundle exec rails serverを使わずに./bin/devを使います。 これは、前者ではJavascriptが動作しないためです。 Rails Guideの「Working with JavaScript in Rails」の「2 Adding NPM Packages with JavaScript Bundlers」に./bin/devのことが書かれています。

$ ./bin/dev
21:03:34 web.1  | started with pid 50151
... ... ...
... ... ...
21:03:35 web.1  | * Listening on http://127.0.0.1:3000
21:03:35 web.1  | * Listening on http://[::1]:3000
21:03:35 web.1  | Use Ctrl-C to stop
21:03:36 css.1  | Sass is watching for changes. Press Ctrl-C to stop.
21:03:36 css.1  | 

ここでブラウザでhttp://localhost:3000を見てみます。

Word-indexの画面

コンテンツの幅がコンテナよりもかなり小さくなっていることが分かると思います。 また、上にナビゲーションバーも表示されています。

ブラウザがChromeであれば、スマホ画面でどうなるかを確かめることができます。 右上の3つドットが縦に並んでいるアイコンをクリック=>その他のツール=>デベロッパーツール、と選択します。 中央上に画面サイズ設定のメニューがあります。 そこで、IphoneSEのサイズを選んだのが次の図になります。

iPhoneSEサイズのWord-index画面

スマホ画面いっぱいにコンテンツが表示されていて、レスポンシブ・デザインが効いていることがわかります。 また、ナビゲーションバーも変化しています。 ハンバーガーメニューが右上に現れ、それをクリックするとドロップダウンメニューの中にAppendからListまでのメニューが入っています。 このような動的な動きはBootstrapのJavascriptがやっています。

まとめ

Bootstrapを用いたので労せずメニューやレスポンシブデザインを作ることができました。 BootstrapはRailsに限らず、一般のウェブサイト制作に適用することができます。 どんどん使ってみてください。

次回はいよいよ単語データを登録する部分にチャレンジします。

徒然Ruby(27)「Ruby on Rails」インストール

Rubyの最も人気のあるアプリケーションであるRuby on Railsを取り上げようと思い、書き始めました。 予想してはいましたが、相当な分量になってしまいました。 そのため、何回かに分けて記事にすることにします。 また、対象となる読者のレベルをどうしようかと考えましたが、「徒然Ruby」が基礎的な内容から始まったことに鑑み、初心者レベルから始めることにしました。 Railsを仕事で使っているような方には面白くないかもしれませんが、ご容赦ください。

ドキュメント

定番のドキュメントを3つ挙げておきます。

Rails Guide」「API reference」「ソースコード」を理解すれば完璧ですが、初心者には難しいかと思います。 「Railsチュートリアル」(無料でもよい)から始めるか、または「Railsガイド」の第1章から読み始めると良いと思います。

インストール

RailsRubyのgemです。 gemコマンドでインストールできます。

$ gem install rails

RailsではSqlite3データベースが標準です。 あらかじめインストールしておきます。 また、gitを使うので未インストールの場合はここでインストールしておいてください。

$ sudo apt install sqlite3 git

wordbookのRails

本記事でRailsの例として作ろうと考えているのは、minitestのところで作った単語帳アプリwordbookのRails版です。 ただし、ウェブでの一般公開ではなく、ローカル(自分のPC)での使用を考えています。

wordbookはReadlineを使ったCUI(Character User Interface: 文字ベースのユーザ・インターフェース)でした。 これをGUI(Graphical User Interface: グラフィック画面ベースのユーザ・インターフェース)にしたいのですが、RubyGUIはまだ定番がありません。 そこで、ウェブ・ブラウザ上のGUIを利用してwordbookを動かそうというのが狙いです。

newコマンド

Railsのnewコマンドは、ウェブ・アプリ開発に必要な材料を揃えてくれます。

$ rails new word_book_rails
      create  
      create  README.md
      create  Rakefile
... ... ...
... ... ...
         run  git init from "."
Initialized empty Git repository in /ユーザディレクトリ・・・/word_book_rails/.git/
      create  app
      create  app/assets/config/manifest.js
... ... ...
... ... ...
         run  bundle install
Fetching gem metadata from https://rubygems.org/...........
Resolving dependencies.......
Using rake 13.0.6
... ... ...
... ... ...
Bundle complete! 15 Gemfile dependencies, 72 gems now installed.
... ... ...
... ... ...
$ ls
Gemfile  Gemfile.lock  README.md  Rakefile  app  bin  config  config.ru  db  lib  log  public  storage  test  tmp  vendor

newコマンドは沢山の仕事をします。

  • word_book_railsというディレクトリを作成し、様々なファイルをそのディレクトリ以下に作成する
  • git initによってそのディレクトリでgitが使えるよう初期化する
  • Gemfileを作成し、bundlerを起動して必要なgemをインストールする
  • その他必要なことを行う

ディレクトリをリスティングすると様々なファイルとフォルダ(フォルダとディレクトリは同じ。Railsでは「フォルダ」ということが多い)が作成されています。

この段階でテンプレートのウェブを表示することができます。

$ bundle exec rails server
=> Booting Puma
=> Rails 7.0.4 application starting in development 
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 5.6.5 (ruby 3.1.2-p20) ("Birdie's Version")
*  Min threads: 5
*  Max threads: 5
*  Environment: development
*          PID: 5144
* Listening on http://127.0.0.1:3000
* Listening on http://[::1]:3000
Use Ctrl-C to stop

rails server」はローカルでサーバーを立ち上げるコマンドです。 このフォルダではbundlerでgemがインストールされているので、プログラムの起動には常に「bundle exec」をつけます。 「rails server」の短縮形「rails s」でも動作は同じです。 ローカル・ホストのポート3000(http://localhost:3000/)をブラウザで見ると、テンプレートのウェブを見ることができます。

Railsのテンプレート画面

サーバを終了するには、コマンドラインから「CTRL-C」を入力します。

ここまでやったことがどういう意味を持つのかを次のセクションで説明しましょう。

ウェブサーバとクライアント

インターネットでは「サーバ」に対して「クライアント」という言葉を使います。 もしあなたがインターネットでウェブを見ているとしたら、そのあなたのPCがクライアントです。 また、ネット上のコンピュータは「ホスト」とも呼ばれます。

ネット上のサーバとクライアントを詳しく見ると、通信をしているのは、サーバコンピュータ内の「ウェブサーバーアプリ」とクライアントコンピュータ内の「ブラウザ」です。 コンピュータ同士ではなく、コンピュータ内のアプリケーション同士が繋がっていることに注意してください。

インターネットを介した接続

それに対して、前のセクションではRailsとブラウザがひとつのPC内にネットワークを作って接続されていました。

PC内のネットワーク接続

図では「puma」と「rails」がサーバ側でセットになっています。 pumaはサーバ機能を提供するアプリケーションです。 ひとつのコンピュータの中でサーバとクライアントが動いていて、ちょうどインターネット接続のようなことが行われています。

ひとつのPC内にネットワークを作ることに意味はあるのでしょうか?

アプリケーション作成においては多少の意味があります。 一般にアプリケーションにGUIインターフェースを持たせるのはかなり大変です。 特にRubyの場合はこれといったGUIの決定版がありません。 それに対するひとつの答えがブラウザのGUIを利用することです。 もちろんこれがベストだとは思いませんが、選択肢のひとつだとは思います。

もっと重要なのは、ウェブサービスアプリのテストを行えるということです。 これがローカルでサーバとブラウザを使う最大の意義だと思います。

さて、具体例に戻りましょう。 ブラウザが「http://localhost:3000」にアクセスしました。 「localhost」はIPアドレスでは「127.0.0.0」で、そのPC自身を表します。 localhostの「ローカル」は自分(の場所)「ホスト」はPCのことです。 したがって、「ローカルホスト」は自分のPCのことを指しているわけです。

「3000」はポート番号を表します。 ポートとは、サーバ内の様々なサーバーアプリケーションに割り当てられる番号です。 この割当は、ある程度決まったものがあります。 そのごく一部を表にしました。

ポート番号 サービス 意味
80 HTTP ウェブサービス
21 FTP ファイル転送
110 POP3 メールサーバとクライアント通信
443 HTTPS 暗号化されたウェブサービス

これらは、システムポート番号(0から1023まで)に属するポートです。

これに対して1024から49151はユーザポート番号といいます。 この範囲でも公式または非公式に決まった使い方があります。 3000はRuby on Railsの開発用ポート(非公式)ですが、他の用途に用いられることもあります。 49152から65535は決まった割当のない「動的・私的ポート番号」になっています。 (ウィキペディア「ポート(コンピュータ・ネットワーク」より)

http://localhost:3000は自分のPCのrailsの提供するサービスを指しています。 ブラウザがそこをアクセスするとrailsとpumaがウェブ画面のデータを送ってきて、それが表示されたわけです。

ルーティング、コントローラ、ビュー

ルーティング

bundle exec rails serveによってhttp://localhost:3000word_book_rails以下のディレクトリに作られたrailsウェブアプリケーションに割り当てられました。 同様にhttp://localhost:3000/abc.htmlも同じウェブアプリケーションに対するリクエストになります。 このアドレスを2つの部分に分けて

  • http://localhost:3000の部分はシステムによって(pumaと)railsウェブアプリケーションに処理が任される
  • /abc.htmlの部分はrails内部でどのように処理するかを決める

このうち2番めの処理は、ルーティング、コントローラ、ビューに分かれます。

  • ルーティングは「abc.htmlのようなアドレスの下位の部分」に対応する「コントローラ」へ処理を移す
  • コントローラは様々な処理の調整を行う
  • ビューはブラウザに送るHTMLを組み立てる

ルーティングは/config/routes.rbで設定します。 初期状態では設定は空の状態になっています。

Rails.application.routes.draw do
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Defines the root path route ("/")
  # root "articles#index"
end

ルーティングはdoとendの間のブロック部分に書きます。 例えば、root(これは/と同じで、アドレスでhttp://localhost:3000/に相当する)に対してWordsコントローラのindexアクションに対応させるには、

root "words#index"

と書きます。

また、/abc.htmlに対してAbcコントローラのindexアクションに対応させるには、

get "/abc.html", to: "abc#index"

と書きます。

getはHTTPプロトコルのリクエスト・メソッドです。 HTTPはウェブサーバとクライアント(ブラウザ)が通信するためのプロトコル(手続き方法)です。 HTTPではクライアントがリクエストを送り、サーバがそれに応答するというパターンで通信が進みます。 このリクエストでよく使われるのがgetとpostというメソッドです。

  • getはサーバのリソース(HTML文書など)を送れ、というリクエス
  • postはサーバへクライアントからデータを送るリクエスト。 例えば、テキストを書いたりチェックボックスを選択したりしたデータをサブミット(送信)ボタンをクリックして送るような場合

ルーティングの詳細はまた別の記事で説明します。

コントローラ

コントローラはデータの種類ごとにひとつずつ作ります。 例えばブログサービスのrailsアプリでは、ユーザ管理のコントローラUsers、記事管理のコントローラArticlesなどがあります。

今回作る単語帳は単語のコントローラWordsのみで十分です。 コントローラを作るにはrailsのgenerateコマンドが便利です。

$ bundle exec rails generate controller Words
      create  app/controllers/words_controller.rb
      invoke  erb
      create    app/views/words
      invoke  test_unit
      create    test/controllers/words_controller_test.rb
      invoke  helper
      create    app/helpers/words_helper.rb
      invoke    test_unit
$

コントローラは大文字で書きはじめ、通常複数形にします。 複数形にする理由は、単語1個のデータだけでなく、複数の単語を扱うからです。 コントローラ名が2語以上からなる場合はキャメルケース(例えばWordBooks)を使います。

app/controllers/word_controller.rbが、さきほど生成されたコントローラのファイルなので見てみましょう。

class WordsController < ApplicationController
end

コントローラは、Ruby言語のクラス定義に相当します。 この中にインスタンスメソッドを定義して、コントローラの動作を記述します。 Railsではこのインスタンスメソッドを、コントローラの「アクション」と呼びます。

本格的にコントローラを書くのは後の記事になります。 ここでは、indexというアクションを書いてみましょう。

class WordsController < ApplicationController
  def index
  end
end

空のメソッドindexを定義しました。

ルーティング(config/routes.rb)を次のように設定しましょう。

Rails.application.routes.draw do
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Defines the root path route ("/")
  # root "articles#index"
  root "words#index"
end

コメント部分は取り去っても構いません。 これでhttp://localhost:3000またはhttp://localhost:3000/(末尾にスラッシュがある)はWordsコントローラのindexアクションにルーティングされます。 これを"words#index"と書きます。

ビュー

コントローラで特にHTMLを書き出す記述がなければ、対応するビューが呼ばれHTMLを出力します。 "words#index"のアクションに対応するビューはapp/views/words/index.html.erbです。 このファイルはまだ作成されていません。 次のような内容で作成しましょう。

Hello, world.

これでブラウザ画面に「Hello, world.」が表示されるはずです。

読者の中には「HTMLデータにしては、ヘッダもボディタグもなくておかしいな」と思った方がいらっしゃるのではないでしょうか。 実は、このファイルはレイアウトファイルの中に埋め込まれるパーツなのです。 レイアウトファイルはapp/views/layouts/application.html.erbと決まっています。 このファイルを見てみましょう。

<!DOCTYPE html>
<html>
  <head>
    <title>WordBookRails</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

このファイルの<% ... ... %><%= ... ... %>の部分はRubyコードの埋め込みです。 それ以外はHTML文書になっています。

  • <% ... ... %>は単なるRubyコードの記述
  • <%= ... ... %>Rubyコードを実行した値をHTMLに埋め込む。 例えば、<%= 1+2 %>は文字列の3をその部分に埋め込む。 ブラウザで表示すると、その部分に3が表示される

Rubyの埋め込みは全部で5つありますが、そのうちのはじめの4つの内容説明は省略します。 最後の<%= yield %>の部分にapp/views/words/index.html.erbの内容が埋め込まれます。 先程作ったファイルの場合は「Hello, world」が埋め込まれることになります。

動作確認

3つのファイルを変更、作成しました。

  • config/routes.rb
  • app/controllers/word_controller.rb
  • app/views/words/index.html.erb

これをテストしてみましょう。

$ bundle exec rails server

ブラウザでhttp://localhost:3000を見てみましょう。

今度は左上に「Hello, world.」が表示されているはずです。

このように、Railsではリクエストに対して

  • ルーティング
  • コントローラ
  • ビュー

の順に処理がつながっていきます。 このことをまず理解してください。 今回は以上です。

徒然Ruby(26)gemとbundler

[:comtemts]

Rubygems

Rubyではライブラリのことをgem(英語の発音はジェムで意味は「宝石」)といいます。 そして、Rubyのライブラリ管理システムをRubygemsといいます。 gemはhttps://rubyges.orgに保管されています。 このウェブサイトをブラウザで開くと、gemを検索し、gem情報を確認できます。

このウェブサービスRuby on Railsで作られているそうです。

ここからgemをダウンロード・インストールする仕組みもRubygemsと呼ばれ、Rubyに標準で添付されています。 狭い意味ではこれがRubygemsです。

Rubygemsのドキュメントは充実しています。 まずはRubyのドキュメントから見てください

ここにgemコマンドの使い方が書かれています。 Rubygems全般のドキュメント(英語)はRubygemsのウェブサイトにあります。

gemをホームページで探し、ダウンロードするだけならRubyのドキュメントの解説だけで十分です。

gemコマンド

さきほどのリンク先を参照してもらえば十分なので、このセクションではgemコマンドを使い、インストール、実行までの実例を示します。 なお、自前のgemを作ることもできますが、ここでは扱いません(それができるくらいなら「徒然Ruby」を読む必要ないですよね)。

rakというプログラムを例にとります。 このプログラムはカレントディレクトリ以下のファイルからパターンに対応する文字列が含まれるファイルを検索し、該当行を表示するプログラムです。 Unixgrepに似ていますが、あるディレクトリの下にあるファイルすべてを対象にするのが特長です。

gemのインストールには「gemコマンド」に動作の指定「install」をつけ、「gem名」を続けます。 rakのインストールの場合は「gem install rak」です。

$ gem install rak
Fetching rak-1.4.gem
Successfully installed rak-1.4
Parsing documentation for rak-1.4
Installing ri documentation for rak-1.4
Done installing documentation for rak after 0 seconds
1 gem installed
$

これだけでインストールが完了します。 gemには、大別してライブラリと実行プログラムがあります。 rakは実行プログラムです。 rakを実行すると、赤でハイライトされた検索結果が表示されます。

$ rak webrick
Gemfile
6|gem "webrick"
$

この例では、カレントディレクトリのGemfileの6行目に「gem "webrick"」という行があることを示しています。

rakが不要になり、アンインストールしたい場合は次のようにします。

$ gem uninstall rak

他の操作については、Rubyのドキュメントを参考にしてください。

bundlerコマンド

gemに似たコマンドにbundlerがあります。 これもRubyに同梱されています。 bundlerはカレントディレクトリに書かれたGemfileに基づいてgemをインストールします。 Gemfileには複数のgemを指定でき、一度にインストールできるので、gemコマンドよりも便利です。 bundlerは、特に多くのgemを必要とする大規模なプログラムに向いています。 個人が小さいプログラムを作る場合にはgemコマンドで十分です。

gemのバージョンについても指定ができます。 特定の古いバージョンのgemでないと動作しないプログラムの場合は、Gemfileにそのgemのバージョンも記しておきます。 これは特に公開プログラムでは重要で、新しいバージョンのgemに対応するまでの間のつなぎとして用いられます。

多くの人にとってはbundlerを使うのはRuby on Railsでウェブサイトを作るような場合ではないでしょうか。 自作のプログラムでbundlerを使うケースはまだまだ少ないように思います。 そこで、ここではRailsに似ているが、それよりは簡単なアプリケーションであるJekyllを例にとってbundlerを説明したいと思います。 なお、Jekyllは静的なウェブサイトを作成するためのプログラムです。 Jekyllの詳細は「はじめてのJekyll + GitHub Pages」を参照してください。

まず、Jekyllをインストールします。

$ gem install jekyll

ここでは、sampleという名前のサイトを作りましょう。 「jekyll new」でサイトの元になるファイル群を作成します。

$ jekyll new sample
$ cd sample
$ ls
404.html  Gemfile  _config.yml  _posts  about.markdown  index.markdown

新しくディレクトリsampleが作られ、その下にいくつかのファイルが作られています。 その中にはGemfileもあります。 Gemfileは少し長いので、ここではコメント部を省略して掲載します。

source "https://rubygems.org"
gem "jekyll", "~> 4.2.2"
gem "minima", "~> 2.5"
group :jekyll_plugins do
  gem "jekyll-feed", "~> 0.12"
end
platforms :mingw, :x64_mingw, :mswin, :jruby do
  gem "tzinfo", "~> 1.2"
  gem "tzinfo-data"
end
gem "wdm", "~> 0.1.1", :platforms => [:mingw, :x64_mingw, :mswin]
gem "http_parser.rb", "~> 0.6.0", :platforms => [:jruby]

OSがLinuxの場合、platformsのブロックは実行されないので、それを除くと5つのgemが指定されています。 ローカルでJekyllを動かす場合、webrickというgemが必要です。 webrickはRuby3.0から標準ライブラリから削除されましたので、Rubygemsからのインストールが必要です。 Gemfileをエディタで開き、webrickを追加します。

gem "webrick"

Gemfileに書かれたgemをインストールするには、bundlerを用います。 コマンドラインから「bundle install」としてください。 bundlerを起動するためのコマンド名は「bundle」です。

$ bundle install
Fetching gem metadata from https://rubygems.org/............
Resolving dependencies...
Using public_suffix 5.0.0
Using bundler 2.3.23
... ... ...
... ... ...
Using jekyll 4.2.2
Using jekyll-feed 0.16.0
Using jekyll-seo-tag 2.8.0
Using minima 2.5.1
Bundle complete! 8 Gemfile dependencies, 32 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.

途中省略しましたが、全部で32個のgemがインストールされました(下から2行目を見てください)。 gemの数が多くなったのは「Gemfileで指定したgemが依存するgem」も含めてインストールしたからです。

インストール時に新しいファイル「Gemfile.lock」が作られます。 このファイルはインストール時点で「Gemfile指定バージョンの範囲での最新のgem」を確定したものです。 プログラムはこのファイルに基づいてバージョンが選ばれ、実行されます。 実行は「bundle exec プログラム名」という形で行います。 例えばjekyllでサーバーを起動するには「bundle exec jekyll serve」です。 単に「jekyll serve」とすると、Gemfile.lockのバージョンに無関係にgemが起動されます。 通常はその時点での最新版が使われます。 プログラムによっては旧版でないと動かない場合もあるので、Gemfile.lockのバージョンを守ることが大事です。 したがって、「bundle exec」は必ず付けてください。

$ bundle exec jekyll serve
Configuration file: /home/ユーザディレクトリ... .../sample/_config.yml
            Source: /home/ユーザディレクトリ... .../sample
       Destination: /home/ユーザディレクトリ... .../sample/_site
 Incremental build: disabled. Enable with --incremental
      Generating... 
       Jekyll Feed: Generating feed for posts
                    done in 0.417 seconds.
 Auto-regeneration: enabled for '/home/ユーザディレクトリ... .../sample'
    Server address: http://127.0.0.1:4000/
  Server running... press ctrl-c to stop.

下から2行目に「ローカルホストの4000番ポート」にサーバアドレスが設定されていることが記されています。 ブラウザ(Google ChromeFirefoxMicrosoft Edgeなど)を立ち上げ、「http://localhost:4000/」または同じことですが「http://127.0.0.1:4000/」を開くと、次のような画面が現れます。

sampleのローカルホストの画面

  • 「bundle exec jekyll serve」でローカルホスト(あなたが動かしているコンピュータのこと)がウェブサーバとなった
  • ローカルのアドレス(上記のアドレス)にJekyllが作成したページが存在する
  • ブラウザがそれをアクセスすると、上記の画面が表示される

※ ローカルというのは自分の動かしているコンピュータ、リモートというのはそこから離れて動いている別のコンピュータを指します。 また、ホストとはネットワーク上のコンピュータのことです。 ネットワーク上のサーバもコンピュータであり、ホストと呼ばれます。 サーバにアクセスするコンピュータはクライアントと呼ばれますが、これもホストです。

Jekyllはこのようにウェブサイトを作るプログラムです。 表示されたのはJekyllが作ったサンプルページです。 通常は、ユーザが更にカスタマイズしてウェブページを作ります。

  • Jekyllでは静的なウェブサイトを構築できる
  • JekyllはMarkdownをサポートする=>HTMLを書くよりも作業量が少なくて住む
  • JekyllはLiquidをサポートする=>ウェブサイトの内容をLiquidプログラムで構成できる
  • Jekyllのプラグインを使うとより少ない作業量でウェブサイトを構築できる

詳しくは「はじめてのJekyll + GitHub Pages」を参照してください。

サーバを止めるには「CTRL+C」を押します。

「bundle install」と「bundle exec」が最も良く使うコマンドです。 その他には、gemをGemfileの範囲でアップデートする「bundle update」も使うことがあるでしょう。 bundlerについての更に詳しい情報はホームページを参照してください。

Railsを用いたウェブアプリケーションを作成する場合、Gemfileの内容を変更、追加しなければならないこともあると思います。 その時はbundlerのウェブサイトにある「Gemfileの書き方」(Learn More Gemfiles)を参考にしてください。

まとめ

今回はRubyのライブラリをgemと呼ぶこと、そしてgemのインストール方法を紹介しました。 これを読んでお分かりいただけたと思いますが、gemのインストールは簡単です。 あとは、どんなgemがあるのか、という知識が必要なだけです。

直接Rubygemsで調べるのも良いですが、Bestgemsというgemのランキングサイトも参考になると思います。 今日の時点でのダウンロード1位がbundlerですが、これはbundlerがRuby同梱であることを考えると不思議な気がします。 また、人気のRailsですが40位とかなり低いのも不思議です。

ダウンロード上位のgemは有用なことが多いと思われますので、時々眺めてみると良いでしょう。

徒然Ruby(25)minitestのmockの詳細

minitestについて連続して2回書いてきました。 「minitestはドキュメントが少ない」という人がいますが、私も同感です。 例えば、モックとスタブの説明も少ないです。 そこで、今回はmock.rbのソースコードを参考に、モックの私的ドキュメントを書いてみました。 あくまで私個人の考えであり、minitest作成者の意図とは何の関係もありませんので、あらかじめご了解ください。

デリゲータ

デリゲータ(delegator)は「委任者、委任する人」ということなので「モックに処理を委任するオブジェクト」という意味ではないかと思います。 デリゲータはモックを生成するときに、newコマンドの引数として与えます。

require 'minitest/mock'

# delegator
m = Minitest::Mock.new("Hello world")
# m is a mock
p m #=> <Minitest::Mock:0x00007f809dedab50 @delegator="Hello world", @expected_calls={}, @actual_calls={}>
# Because m (mock) uses the delegator's method, m.display is the same as "Hello.world".display
m.display #=> Hello world
print "\n"
print m+"\n" #=> Hello world\n
# Because m has its own to_s method, m.to_s is NOT "Hello world".to_s
print m, "\n" #=> <Minitest::Mock:0x00007f8130db2c08>

m.expect(:size, 1000)
print m.size, "\n" #=> 1000, the size method is defined by m.expect.
print m.length, "\n" #=> 11, which is the real length of "Hello world"
p m #=> <Minitest::Mock:0x00007f264bd96e30 @delegator="Hello world", 
    # @expected_calls={:size=>[{:retval=>1000, :args=>[], :kwargs=>{}}]},
    # @actual_calls={:size=>[{:retval=>1000, :args=>[], :kwargs=>{}}]}>
p m.verify #=> true
# m.size #=> Error: No more expects available for :size

minitest/mockを取り込んでおきます。 mには文字列オブジェクト"Hello world"をデリゲータとするモックを代入します。

  • p mでモックのインスタンス変数@delegatorに"Hello world"がセットされていることがわかる
  • displayメソッドはObjectクラスのメソッドで、自分自身を標準出力に(to_sメソッドで文字列化して)出力する
  • モックはほとんどのメソッドをundef(未定義状態にする)していて、displayメソッドも持っていない
  • そのときはモックはデリゲータのメソッドとして実行する。 したがって、m.display"Hello world".displayを実行し、標準出力にHello worldが出力される
  • モックは+メソッドも持っていないので、m+"\n"はデリゲータの+メソッド("Hello world"+"\n")を実行する
  • モックはto_sメソッドを自身のメソッドとして持っているので、m.to_sはデリゲータを使わず、自身を文字列化する

ここまでで、要するにモックはデリゲータのほとんどのメソッドを引き継いでいることが分かると思います。

モックがexpectメソッドで「みせかけのメソッド」を定義するとき、そのメソッドがデリゲータのメソッドと同一名であれば、expectの定義を優先します。 後半を見ていきましょう

  • モックmにsizeメソッドが1000を返すように、expectメソッドで定義
  • m.sizeは1000を返す。 mはデリゲータのsizeメソッド(こちらは文字数の11になる)は使わず、expectの定義を優先した
  • m.lengthではモック自身はlengthメソッドを持たないので、デリゲータの"Hello world".lengthを実行し、11を返す
  • p mでモックの内容を表示すると、@expected_callsと@actual_callsの配列要素に、expectでの定義とm.sizeの実行それぞれの返り値と引数が記録されている
  • m.verifyでモックにおけるexpectされたメソッドが実行されたのでtrueが返された
  • 最後の一行はコメントされているが、仮にコメントアウトして実行するとエラー(フェイル)になる。 これはexpectが1回で、呼び出し2回目ということで、expectされていないので実行できない、というエラー

以上の機能からするとデリゲータとモックはどういう関係なのでしょうか?

モックはデリゲータをラップする。 デリゲータのメソッドのうち、テストで用いたいメソッドだけexpectでセットし、それ以外はそのまま実行させる

つまり、モックに置き換えたい元のオブジェクトがデリゲータだと考えられます。

メソッド呼び出し時のチェック

引数のチェック

expectでは3番めの引数が、定義するメソッドのパラメータです。

モック.expect(メソッド名, 返り値, パラメータの配列)

expectで定義されたメソッドはそのメソッドの呼び出し時にチェックされます。 チェックするのは

  • expectで定義された回数より多くそのメソッドが呼び出された(No more expects available for メソッド名)
  • expectで定義されたパラメータの数とメソッド呼び出し時の引数の数が一致するか
  • expectで定義されたパラメータとメソッド呼び出し時の引数のタイプが一致するか(===または==が成り立つかどうか)。 例えばexpectでStringのパラメータを定義し、呼び出し時に"abc"が引数であれば、String==="abc"はtrueになる。 ===はClassクラスで定義されていて、引数がそのクラスのインスタンスまたはサブクラスのインスタンスならばtrueになる。 StringクラスはClassクラスのインスタンスなので===が定義されている。 なお、"abc"===Stringはfalseになる。 文字列クラスのインスタンスメソッドとして===が再定義されているためで、文字列インスタンス=====は同じ
# arguments
m.expect(:concat, "Hello, folks.", [String, Integer])
print m.concat("Foo", 100), "\n" #=> Hello, folks.
m.expect(:concat, "Hello, there.", [Integer])
print m.concat("abc"), "\n" #=> Error :concat called with unexpected arguments
m.expect(:concat, "Hello, there.", [String, String])
print m.concat("abc"), "\n" #=> Error :concat expects 2 arguments
  • expectメソッドによって、concatメソッドを返り値"Hello, folks."、引数は2つでタイプはStringとIntegerと定義
  • m.concat("Foo", 100)は引数の数、タイプとも定義に合っているので、返り値"Hello, folks."`が返される
  • expectメソッドによって、concatメソッドを返り値"Hello, there."、引数は1つでタイプはIntegerと定義
  • m.concat("abc")は引数の数は1つで良いが、タイプがIntegerではないのでエラーになる
  • expectメソッドによって、concatメソッドを返り値"Hello, there."、引数は2つでタイプはStringとStringと定義
  • m.concat("abc")は引数の数が2つで定義と異なるのでエラーになる

以上のように、呼び出し時の引数が定義と異なるとエラーになります。

最後の引数のハッシュオブジェクト

一般にメソッド呼び出しの最後の引数のハッシュは{}を省略できることになっています。 expectでも同様に最後のパラメータにハッシュをつけ足すことができます。

モック.expect(メソッド名, 返り値, パラメータの配列, ハッシュ)

呼び出し時にハッシュの部分が同一でなければエラーになります。

m.expect(:concat, "Hello, folks.", [String], a:10,b:20,c:30)
print m.concat("abc", a:10, b:20, c:30), "\n" #=> Hello, folks.
m.expect(:concat, "Hello, folks.", ["efg"], a:10,b:20,c:30)
print m.concat("efg", a:10, b:20, c:30), "\n" #=> Hello, folks.
  • expectメソッドによって、concatメソッドを返り値"Hello, folks."、引数は1つでタイプはString、次にハッシュの引数{a:10,b:20,c:30}が続くよう定義
  • m.concat("abc", a:10, b:20, c:30)では定義通り文字列と(定義と同一の)ハッシュを引数としているので実行され、"Hello, folks."が返される
  • expectメソッドによって、concatメソッドを返り値"Hello, folks."、引数は1つで文字列"efg"、次にハッシュの引数{a:10,b:20,c:30}が続くよう定義
  • m.concat("efg", a:10, b:20, c:30)では定義と同一の文字列、ハッシュを引数としているので実行され、"Hello, folks."が返される

モックは、予定された引数でメソッドが呼ばれるかどうかのチェックが結構厳しいです。 テストですから当然ですが。

expectメソッドにブロックをつけるケース

expectメソッドにブロックを付けることができます。 そのときは第3、4引数(引数とハッシュ)はつけません。

モック.expect(メソッド名, 返り値){|x,y,...| x=10 && y,is_a?(String) && ....}

ブロックのパラメータにはメソッド呼び出し時の引数が代入されます。 ブロックでそのメソッドチェックをします。 メソッド呼び出し時のブロックのチェックもできます。

m.expect(:concat,"Hello, there.") {|x,y| x.is_a?(String) && y.is_a?(Integer)}
print m.concat("a", 1), "\n" #=> Hello, there.
m.expect(:concat,"Hello, there.") {|x,y,&z| x.is_a?(String) && y.is_a?(Integer) && z.call(10)==100}
print m.concat("a", 1){|x| x*x}, "\n" #=> Hello, there.
p m.verify #=> true
  • ブロックにより第1引数が文字列、第2引数が整数であること定義された
  • メソッド実行時に"a"と1が渡されるので、条件を満たしており、実行され"Hello, there."が返される
  • 上記に加えてブロック(&zパラメータ)もチェックする。ブロックは10を与えられると100を返すような動作が期待される
  • m.concat("a", 1){|x| x*x}では、文字列、整数の引数、ブロックはパラメータを2乗(したがって10を100にして返す)なので定義の条件が満たされ"Hello, there."が返される

テストで確認したいことは、対象のプログラムが期待通りにメソッドを呼び出しているかどうかです。 上記の例は極めて簡単なので、引数のタイプの確認の重要性があまり感じられません。 しかし、実際のプログラムでは、引数がいくつかの計算を経て得られることも考えられ、期待通りのオブジェクトかのチェックが重要になるかもしれません。

verify

モックのverifyメソッドは、expectで設定されたメソッドがきちんと呼び出されたかを見ます。

  • expectの設定より多く呼び出したときは、呼び出し時にフェイルになります
  • expectの設定より呼び出しが少ない(0も含め)ときには、verifyメソッドでフェイルになります

以上、モックのソースコードを見て、モックの働きの詳細を紹介しました。

残念ながらminitestの詳しい解説がなかなか見つかりません。 結局ソースコードを読むしかないのか、とちょっと残念な気持ちになります。

ところで、ここまで解説してきましたが、モックがどれくらいテスト上で重要なのでしょうか? そしてどれくらい有効に使えるのでしょうか? プログラムの下位のパーツの代わりを期待されるモックとスタブですが、テスト用のパーツを書くほうが分かりやすいような気もします。 その2つは、やろうとしていることは同じで方法が違うだけです。 こんな考えが浮かぶのは、まだまだテストということの勉強が足りないのでしょうか。

最後に他のテストツールで有名なRspecについてひとこと触れたいと思います。 RSpecは使ったことがあり、本も呼んだことがあります。 RSpecは対象のプログラムの振る舞いを記述することにかなりの重点を置いているように思います。 テストだけではなく、そのプログラムの仕様を記述する感じです。 それがspec(specification 仕様)が名前になっている理由かもしれません。

実はminitestでもspec風の書き方ができるのです。 minitestのドキュメントサイトに少しだけですが、説明があります。 また、RSpecの書き方については書籍などを参考にしてください。

徒然Ruby(24)minitest、mock、stub

今回もminitestの話です。 mockとstubに焦点をあて説明します。

単語帳プログラム

今回は単語帳プログラム「wordbook」を、テストしながら作ることにします。 このプログラムは、テストの例示に使うためのものなので、最小限の機能に絞りました。

端末からの入力に従って、単語帳を編集し、ファイル「に保存/から読み出し」できるというものです。 端末からの入力は「コマンド 英語 日本語訳」という形を原則にしています。

  • a: append(追加)。英語と日本語訳をデータに追加
  • d: delete(削除)。英語(と日本語訳)をデータから削除
  • c: change(変更)。指定された英語の日本語訳を変更
  • p: print(表示)。指定された英語のデータを表示
  • q: quit(終了)。プログラムを終了

コマンドはこの5つだけです。

wb > a add 追加 #=> 英単語「add」と日本語訳「追加」をデータに追加
wb > p add #=> 英単語「add」を表示
add 追加
wb > c add 加える #=> 「add」の訳を「加える」に変更
wb > p add
add 加える
wb > a subtract 減じる
wb > p . #=> 正規表現が可能。任意の文字にマッチ(マッチは単語の一部で良い)
add 加える
subtract 減じる
wb > d add #=> 「add」とその日本語訳を削除
wb > p .
subtract 減じる
wb > q #=> 終了

プログラム・ファイルは4つに分かれます。

  • wordbook.rb: 端末から起動するファイル。コマンドラインの解析をする
  • lib_wordbook.rb: WordBookクラスの定義。実質的なメインプログラム
  • input.rb: Inputクラスの定義。端末から一行入力する
  • db.rb: DBクラスの定義。データのファイルへの保存/読み出しをする

開発は、トップダウンで行うことにします。 トップダウンとは、メインになるプログラムから開発し、メインから呼び出される個々のパーツ・プログラムを後に回す方法です。 逆の手順はボトムアップです。 ボトムアップの利点はひとつひとつ動くパーツから組み立てるので、着実に積み上げることができることです。 ただ、メイン部分で問題が発生すると、また下位のパーツを作り直さなければならなくなるという不利な点があります。

コマンドラインとのインターフェース

コマンドラインとのインターフェースはwordbook.rbに書きます。 このプログラムは、起動時の引数の処理をします。

  • 引数なし=>データファイル名はデフォルト名(db.csv
  • 引数が-hまたは--help=>使い方メッセージを標準エラー出力に出力
  • 引数がひとつ=>引数をデータファイル名とする
  • その他=>使い方表示

プログラムは次のようになります。

#!/bin/sh
exec ruby -x "$0" "$@"
#!ruby

require_relative 'lib_wordbook.rb'

def usage
  $stderr.print "Usage: wordbook [file]\n"
  exit
end

if ARGV.size > 1 || ARGV[0] =~ /--help|-h/
  usage
end
if ARGV.size == 1
  wb = WordBook.new(ARGV[0])
else
  wb = WordBook.new
end
wb.run

$stderrは標準エラー出力のオブジェクトを表す変数でprintメソッドを持っています。 このメソッドは関数形式のprintメソッドと同じで、出力先が違うだけです。 --heloと-hは使い方を表示して終了します。 exitはプログラムを終了するメソッドです。

正しい引数で起動された場合は、WordBookクラスのインスタンスを生成し、そのオブジェクトのrunメソッドを呼び出します。 runメソッドが実質的なメインプログラムになります。

wordbook.rb のテスト

wordbook.rbはコマンドライン引数の解析をするので、テストもコマンドラインから起動して行いたいところです。 そこで、Kernelモジュールのバックティック(`)メソッドを利用して、rubyを実行し、その標準出力を入手してテストに用いることにします。 バックティック・メソッドは「徒然Ruby(11)」を参照してください。 テストプログラムのファイル名は「test_main_wordbook.rb」とします。

require 'minitest/autorun'
require 'fileutils'

# The test will be done under 'temp_test_main_wordbook' directory
class TestMainWorkbook < Minitest::Test
  include FileUtils
  def setup
    @tempd = 'temp_test_main_wordbook' 
    mkdir_p @tempd
    cp 'wordbook.rb', "#{@tempd}/wordbook.rb"
    # Put a stub of "lib_wordbook.rb" under the tepmorary directory.
    # It just prints the argument.
    File.write("#{@tempd}/lib_wordbook.rb", <<~'EOS')
    class WordBook
      def initialize(file="db.csv")
        @file = file
      end
      def run
        print @file, "\n"
      end
    end
    EOS
    cd @tempd
  end
  def teardown
    cd '..'
    remove_entry_secure @tempd
  end
  def test_main_wordbook
    assert_equal("Usage: wordbook [file]\n", `ruby wordbook.rb --help 2>&1`)
    assert_equal("Usage: wordbook [file]\n", `ruby wordbook.rb -h 2>&1`)
    assert_equal("Usage: wordbook [file]\n", `ruby wordbook.rb a.csv b.csv 2>&1`)
    assert_equal("db.csv\n", `ruby wordbook.rb`)
    assert_equal("abc.csv\n", `ruby wordbook.rb abc.csv`)
  end
end

コマンドラインからruby wordbook.rbと入力すると、wordbook.rbはlib_wordbook.rbを読み込み、WordBookクラスのインスタンスを作ろうとします。 まだ、lib_wordbook.rbは書いていませんし、またそれが書けていたとしてもテストには向きません。 ここでは、テスト用のlib_workbook.rbを使ってテストしたいので、新たにテンポラリ・ディレクトリ(一時ディレクトリ)を作り、その中でテストをすることにします。 テンポラリ・ディレクトリ名は「temp_test_main_wordbook」とします。 setupメソッドで上に述べた「下準備」をします。

  • temp_test_main_wordbookを作成する
  • wordbook.rbをそのディレクトリにコピーする
  • 仮のlib_wordbook.rbをそのディレクトリに書き込む。 その内容は、ヒアドキュメントで書いてあるとおりで、runメソッドは単に引数を標準出力に書き出す
  • テンポラリ・ディレクトリにカレントディレクトリを移動する

setupと対になるのがteardownで、これはテスト終了後の後始末をします。 teardownでは次のことを行います。

テストをするメソッドは「test_main_wordbook」です。

  • `ruby wordbook.rb --help 2>&1`でバックティック内のコマンドを実行する。 2>&1標準エラー出力の出力先を標準出力に変更する(bashのリファランス参照)。 バックティックはコマンドの標準出力を捕らえ、メソッドの返り値にする
  • assert_equalで、その返り値が文字列"Usage: wordbook [file]\n"に等しいかをテストする。 2、3番目のテストも同様
  • 4番めは引数なしで起動した場合。 そのときwordbook.rbはrunメソッドを引数"db.csv"をつけて起動する。 テスト用のlib_wordbook.rb内のrunメソッドはその引数を標準出力に書き出すので、db.csvがバックティックメソッドで返されるはずである。 それをassert_equalでチェックする。 5番目も同様

テストしてみます。

$ ruby test_main_wordbook.rb 
Run options: --seed 23981

# Running:

.

Finished in 0.391359s, 2.5552 runs/s, 12.7760 assertions/s.
1 runs, 5 assertions, 0 failures, 0 errors, 0 skips
$

トップレベルのファイルはどうしてもコマンドラインの解析があるので、テスト用のライブラリファイル(このようなものをスタブという)を作り、テスト用のテンポラリディレクトリでテストする形になります。 これは私流のやりかたですが、もし他にもっと良い方法をご存知の方がいれば、コメントで教えていただけるとありがたいです。

スタブ(stub)とモック(mock)

Minitestのstubは「Objectクラスに追加したメソッド」です。 すべてのクラスはObjectの子孫ですから、どのオブジェクトの上でもstubメソッドを呼ぶことができます。 また、「クラスも一種のオブジェクト」ということから、クラスの上でもstubメソッドを呼ぶことができます。

stubはオブジェクトの既存のメソッドの返り値を変更することができます。

オブジェクト.stub(メソッド名, 返り値){ ・・・・・}

このような形で使います。 正確には、引数にさらに付け加えられる情報があるのですが、詳細はMinitestのドキュメントをご覧ください。

stubはどんなオブジェクトに対しても使えるので、とくに入力関係のオブジェクトに使うと効果的です。 例えばFileクラスのクラスメソッドreadに対して、

File.stub(:read, "abcd\n") {・・・・・}

とすると、{}の中、すなわちブロックの中ではFile.read(ファイル名)はいつも"abcd\n"を返します。 stubによるメソッドの変更はブロックの中だけで有効です。

モックは「みせかけのもの」という意味です。 本当のオブジェクトではなく、テストのためにそれらしい振る舞いをするオブジェクトのことをいいます。 minitestのモック・オブジェクトでは、みせかけのインスタンスメソッドとその引数、返り値を定義することができます。

  • モックオブジェクトを生成し、みせかけのインスタンスメソッドを定義
  • そのインスタンスメソッドを実行
  • 期待通りの形で(引数の種類や数、呼ばれた回数)でメソッドが呼ばれたかをチェック

という手順でテストをします。

require 'minitest/autorun'
class TestFoo < Minitest::Test
  def test_foo
    @mock = Minitest::Mock.new
    @mock.expect(:read, "Hello world!")
    assert_equal("Hello world!", @mock.read)
    @mock.verify
  end
end
  • 4行目: モック・オブジェクトを生成しインスタンス変数@mockに代入
  • 5行目: モック・オブジェクトに「みせかけのメソッド」を定義。 定義はモックのexpectメソッドで行う。 1番目の引数=>みせかけのメソッド名、2番目の引数=>そのメソッドの返り値。 この例ではみせかけのメソッドに引数は設定されていないが、もし引数を設定する場合は、3番めの引数に配列で渡す
  • 6行目: @mock.readで、「みせかけのメソッド」readを呼び出す。 定義どおりに、引数なしで呼び出し。 また、その返り値は"Hello world!"になるので、assert_equalのアサーションも通過するはず
  • 7行目: モックのverifyメソッドは、定義されたメソッドが呼ばれたかをチェック。 上記のテストでは6行目で呼び出しているので、テストはパスする。 なお、expectメソッドを複数回行うことができ、そのときは「みせかけのメソッド」の呼び出しも同じ回数だけ行う。 それらの回数が等しいかどうかもverifyはチェックする。 verifyはexpectに対して呼び出しが少ないときにフェイルにするだけで、逆にexpectに対して呼び出しが多いときは呼び出し時にフェイルになる。 また、expectメソッドを複数回使うと、返り値をその回数だけ定義できる。 これらの返り値は呼び出しごとに次々に変わっていく

実際のテストでは、モックオブジェクトを本来のオブジェクトに差し替えてテストをします。 差し替えをどのように行うかは対象となるプログラムによりますが、結構難しくなる場合もあります。 対象プログラムの中身に立ち入らないのがテストの原則ですが、オブジェクトの差し替えはどうしても原則どおりには行かないことが多いと思います。 そのときは、中身に関する事柄をできるだけ少なくします。

スタブとモックを組み合わせて使うこともよくあります。 それは、スタブの2番めの引数(書き換えられたメソッドの返り値)にモックを置くことです。 そのことによって、モックをテスト対象のオブジェクトに送り込むのです。 これは、newメソッドをスタブで書き換え、newで返すオブジェクトをモックに取り替えてしまう、という方法で用いられます。

スタブのより柔軟で高度な使い方としては、2番めの引数(返り値)のところに、callメソッドを持つオブジェクトを置く方法があります。 このときスタブはcallメソッドを実行し、その値を返り値にします。 ここにはProcオブジェクトを入れるのがピッタリですが、モックを入れることも考えられます。 つまり、モックに「みせかけのメソッド」としてcallを定義するのです。 モックは複数回expectを使い、callメソッドの返り値をその回数分セットすることができます。 ということは、スタブで書き換えたメソッドに複数回分の異なる返り値をセットすることが可能になるのです。

require 'minitest/autorun'
# sample class
class A
  def initialize
    @b = B.new
  end
  def show_b
    @b.show
  end
end
class B
  def show
    "class B のオブジェクトです\n"
  end
end
class TestStubAndMock < Minitest::Test
  def test_stub_and_mock
    @a = A.new
    assert_equal("class B のオブジェクトです\n", @a.show_b)
    @mock = Minitest::Mock.new
    B.stub(:new, @mock) do
      @a = A.new
    end
    @mock.expect(:show, "ぼくはモックだよ!\n")
    @mock.expect(:show, "わたしはモックよ!\n")
    assert_equal("ぼくはモックだよ!\n", @a.show_b)
    assert_equal("わたしはモックよ!\n", @a.show_b)
    @mock.verify
  end
end

この例では、クラスAのインスタンス生成時にクラスBのインスタンスを作って@bに代入します。 クラスAのshow_bメソッドでは、@b.showによってクラスBのshowメソッドが呼ばれ"class B のオブジェクトです\n"が返されます。 ちょっと入り組んでいますが、良いでしょうか。

テストプログラムtest_stub_and_mockの最初の2行は今述べたことを実行して、@.show_bによって上述の文字列が返されたことを確認しています。 これは正しく動作し、テストはパスします。

メソッドの3行目から6行目では、モックオブジェクト@mockを生成し、stubメソッドによって、B.newの返り値を@mockにします。 本来B.newはクラスBのオブジェクトを返すのですが、モックを返すようになっているのです。 これによって、クラスAのオブジェクト@a上ではクラスBの振る舞いがモックの振る舞いに置き換わってしまいます。

次の2行はモックのshowメソッドが返す値を設定しています。 @a.show_bの中で、@b.showを実行しますが、@bにはクラスBのオブジェクトではなく、モックが入っているので返り値がモック設定のものになります。 そこで、2つのassert_equalが成功し、最後のverifyも予定通り2回呼ばれていたので成功します。 テストを実行するとすべてパスします。

この方法がクラスAで想定しているのは、initializeメソッドでB.newが呼ばれるだろうということだけです。 それがクラスAのリファクタリングで変更される可能性はごく小さいはずなので、テストはリファクタリング後も使える可能性が高いといえます。

それでは、次のセクションで単語帳プログラムの実例を見てみましょう。

lib_wordbook.rbとそのテスト

lib_wordbook.rbではWordBookクラスを定義します。 このクラスは、InputクラスとDBクラスのインスタンスを生成します(それぞれ@inputと@db)。 WordBookクラスのrunメソッドは、これらのインスタンスを使い、次のような動作をします。

  • ループの中で、@input.inputを呼び出す。 そのメソッドは標準入力(キーボード)からの入力を「コマンド、英語、日本語」の配列に変換して返す
  • コマンドに応じて、@dbのappend、delete、change、list、closeの各メソッドを呼び出す
  • コマンドがqならば、ループを抜け出すとともにrunメソッドを抜け出す

プログラムは次のようになります。

require_relative 'input.rb'
require_relative 'db.rb'

class WordBook
  def initialize(*file)
    @input = Input.new
    if file[0]
      @db = DB.new(file[0])
    else
      @db = DB.new
    end
  end

  def run
    while true
      a = @input.input #=> an array like [command, English, Japanese]
      return unless a
      case a[0]
      when 'a'
        @db.append(a[1], a[2])
      when 'd'
        @db.delete(a[1])
      when 'c'
        @db.change(a[1], a[2])
      when 'p'
        d = @db.list(a[1]).to_a
        d.each do |e,j|
          print "#{e} - #{j}\n"
        end
      when 'q'
        @db.close # save data
        break
      end
    end
  end
end

クラスから生成されるインスタンスの初期化はinitializeメソッドで行います。 このメソッドの引数が*fileとなっているのは、可変長引数を表します。 呼び出し側が、ファイルを引数にする場合と、引数なしの場合があるので、可変長にしました。 引数は配列の形でパラメータfileに代入されます。 実際には引数はあったとしてもひとつで、それはfile[0]に代入されています。 その引数があれば、それを引数にしてDBクラスのインスタンスを生成します。 引数が無ければ(f[0]==nil)、引数なしでDBクラスのインスタンスを生成します。 また、Inputクラスのインスタンスも作ります。

runメソッドはwhile trueの無限ループ内で、入力に応じた@dbのメソッドを呼ぶだけです。 pコマンドの時だけ、@dbから得たデータを標準出力に出力するのが、唯一自分自身の仕事になっています。

さて、このファイルをテストする段階で、まだinput.rbとdb.rbはできていません。 require_relativeでエラーにならないように、空のファイルを置いているだけです。 それらのファイルが定義するInputクラスとDBクラスはテストプログラムの中で定義されます。 また、それらのメソッドはモックの「みせかけのメソッド」になります。 以下はtest_lib_wordbook.rbのプログラムリストです。

require 'minitest/autorun'
require_relative 'lib_wordbook.rb'

# dummy class
class Input
end
class DB
  def initialize(*file)
  end
end

class TestLibWordbook < Minitest::Test
  def test_run
    @mock_input = Minitest::Mock.new
    @mock_db = Minitest::Mock.new
    Input.stub(:new, @mock_input) do
      DB.stub(:new, @mock_db) do
        @wordbook = WordBook.new
      end
    end

    args = []
    args << [['a', 'append', '付け足す'], :append, nil, ['append', '付け足す']]
    args << [['d', 'append'], :delete, nil, ['append']]
    args << [['c', 'append', '付け加える'], :change, nil, ['append', '付け加える']]
    args << [['p', 'app...'], :list, [['append', '付け加える']], ['app...']]

    args.each do |a|
      @mock_input.expect(:input, a[0])
      @mock_db.expect(a[1], a[2], a[3])
      @mock_input.expect(:input, ['q'])
      @mock_db.expect(:close, nil)
      if a[0][0] == 'p'
        assert_output("append - 付け加える\n") {@wordbook.run}
      else
        @wordbook.run
      end
      @mock_input.verify
      @mock_db.verify
    end
  end
end

テストプログラムについて説明します。

InputとDBクラスを定義しておきます。 これらはテスト用のダミーです。 なお、DBクラスのnewメソッド呼び出しには引数がある場合と無い場合があるので、initializeメソッドの引数にはアスタリスクを付けて可変長にします。

WordBookクラスのinitializeメソッドでInput、DBクラスのインスタンスが@inputと@dbに代入されます。 テストではそれらにモックを入れるために、stubメソッドで両クラスのnewメソッドの返り値をモックに変えてWordBook.newを実行します。 これで、runメソッドで使う@inputと@dbがモックオブジェクトを表すようになります。

test_runメソッドがテスト本体です。 まず、argの配列を作ります。 4行あるのが、それぞれ、a、d、c、pのコマンドを入力するときの諸データを配列にしたもので、それが<<メソッドでargに追加されていきます。 最初のデータがeachメソッドのループでどのように使われるかを見ていきましょう。

  • a[0]=['a', 'append', '付け足す'] 'なので、まず@wordbook.input.expect(:input, a[0])で、@inputのモックがinputメソッドに対し'['a', 'append', '付け足す']'を返すように定義をします。 これにより、@input.inputが呼ばれた時に['a', 'append', '付け足す']`が返されます。
  • a[1] = :appenda[2] = nila[3] = ['append', '付け足す']なので、@wordbook.db.expect(a[1], a[2], a[3])のところでは、@dbのモックがappendメソッドに対し、返り値nilで引数が'append', '付け足す'となるように定義をします。 返り値はrunメソッド内では使われていないので、nil以外のものでも構いません。
  • @wordbook.input.expect(:input, ['q'])で次の@input.inputメソッドの返り値を'q'にします。 これはrunメソッドの2回めのループでの呼び出しです。
  • @wordbook.db.expect(:close, nil)で、@db.closeが引数なしで呼び出されるよう定義します。
  • a[0][0]'a'でしたから、else節が実行され、@wordbook.runすなわちrunメソッドが実行されます。 このなかで@input.input、@db.append、@input.input、@db.closeがこの順で呼ばれるはずです。
  • @wordbook.input.verifyで@inputに代入されたモックが、設定されたメソッドを呼んだかをチェックします。
  • @wordbook.db.verifyで@dbに代入されたモックが、設定されたメソッドを呼んだかチェックします。

以上が1セットでこれを内容を変化させて全部で4セット行います。 実行すると、

$ ruby test_lib_wordbook.rb 
Run options: --seed 3358

# Running:

.

Finished in 0.005851s, 170.9253 runs/s, 170.9253 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

無事にテストが通過しました。 内容が複雑でしたが、大丈夫でしょうか。

モックが下位プログラムの代わりをしてくれた、ということが大事な点です。

さて、このテストプログラムではWordBook.newでInputクラスとDBクラスのインスタンス生成が行われていると仮定しました。 これが将来のリファクタリングで変更される可能性は小さいですが、もし変更されればテストプログラムの変更もしなければなりません。 それは、テストプログラムがWordBookクラスの内容に(わずかですが)立ち入っているために起こることです。 すなわち、テストは「対象の振る舞いにフォーカスする」「内部構造に立ち入らない」という原則に触れていることになります。

これを原則に忠実なテストに置き換えるには、モックを諦めなければならないと思います。 なぜならモックはインスタンスの置き換えだからです。

代案としては「クラスのスタブを作る」方法があります。 ここでいうスタブとは、代用品のことで、本物のInputクラス、DBクラスではなく、テスト用に作るものです。 スタブにはテストに必要なすべてのメソッドを持たせ、テストに適するような出力をさせます。 このプログラムは分かりやすく、単純化されます。 モックよりもずっと簡単なので、勧められる方法です。

require 'minitest/autorun'
require_relative 'lib_wordbook.rb'

# dummy class
class Input
  def initialize
    @count = -1
  end
  def input
    @count += 1
    [['a', 'append', '付け足す'], ['d', 'append'], ['c', 'append', '付け加える'], ['p', 'app...'], ['q']][@count]
  end
end
class DB
  def initialize(*file)
  end
  def append(e,j)
    print "append(#{e}, #{j})\n"
  end
  def delete(e)
    print "delete(#{e})\n"
  end
  def change(e,j)
    print "change(#{e}, #{j})\n"
  end
  def list(e)
    print "list(#{e})\n"
  end
  def close
    print "close\n"
  end
end

class TestLibWordbook < Minitest::Test
  def setup
    @wordbook = WordBook.new
  end
  def test_run
    expected_output = "append(append, 付け足す)\ndelete(append)\nchange(append, 付け加える)\nlist(app...)\nclose\n"
    assert_output(expected_output) {@wordbook.run}
  end
end

inputメソッドは、カウンタを使って呼ばれるたびに異なる値を返します。 DBの各メソッドは呼ばれるたびに、メソッド名と引数を標準出力に書き出します。 テスト本体ではrunメソッドの出力結果(上記のDBお各メソッドの出力のトータル)と期待される文字列を比較するだけです。 このテストの良いところは

  • プログラムが分かりやすい
  • テスト対象の内部構造に関わらない

ということです。

このセクションでは、モックを使ったプログラムを書きましたが、それはモックの説明をしたかったからです。 実際にはモックを使わないプログラムの方が適切なテストプログラムだと私は思います。 テストには決まった方法がありません。 いろいろな方法が可能なので、その中で最も良いものをチョイスしてください。

input.rb

入力を担当するInputクラスの書かれたファイルinput.rbは次のようになります。

require 'readline'

class Input
  def input
    while true
      buf = Readline.readline("wb > ", false)
      if buf =~ /^[ac] +[a-zA-Z]+ +\S+$|^d +[a-zA-Z]+$|^p +\S+$|^q$/
        return buf.split(' ')
      else
        $stderr.print "(a|c) 英単語 日本語訳\nd 英単語\np 正規表現\nq\n"
      end
    end
  end
end

readlineライブラリをrequireし、一行入力を可能にします。

このように非常に簡単ですが、Readline.readlineの入力部分はテストする際にはスタブに置き換えて人為的に入力を作り出します。 なお、ここではモックを使うのが難しいのです。 というのは、モックはオブジェクトなのでReadlineに代入したいのですが、Readlineが定数なので再代入できないのです。 それで、モックを直接使うことはできません。 それでは、スタブを使ったテストプログラムを見ていきましょう。

require 'minitest/autorun'
require_relative 'input.rb'

class TestInput < Minitest::Test
  def test_input
    @in = Input.new
    Readline.stub(:readline, "a append 付け足す") { assert_equal(['a', 'append', '付け足す'], @in.input) }
    Readline.stub(:readline, "d append") { assert_equal(['d', 'append'], @in.input) }
    Readline.stub(:readline, "c append 付け足す") { assert_equal(['c', 'append', '付け足す'], @in.input) }
    Readline.stub(:readline, "p a..end") { assert_equal(['p', 'a..end'], @in.input) }
    Readline.stub(:readline, "q") { assert_equal(['q'], @in.input) }
    m = Minitest::Mock.new
    m.expect(:call, "abcd", ["wb > ", false])
    m.expect(:call, "q", ["wb > ", false])
    Readline.stub(:readline, m) {assert_output(nil, "(a|c) 英単語 日本語訳\nd 英単語\np 正規表現\nq\n"){ @result = @in.input }}
    assert_equal(['q'], @result)
  end
end

input.rbをrequire_relativeで取り込んでおきます。 test_inputメソッドがテストプログラムです。

  • まず、Inputクラスのインスタンスを@inに代入しておく
  • Readline.readlineメソッドをスタブで置き換え、"a append 付け足す"を返すようにする
  • ブロック({}の中)で@in.inputでinputメソッドを呼び出す。 Readline.readlineの返した文字列は有効な入力なので、それを配列にして['a', 'append', '付け足す']を返すはずである。 それをassert_equalでテストする。
  • 以下同様にd、c、p、qの各コマンドをテストする
  • 次に有効でない入力があったときのテストをするが、このとき「常に無効な入力=>無限ループ」になってしまうので、最初の入力は無効で2回めの入力を有効にしたい。 スタブだけではそれを実現できないのでモックと組み合わせる

ここで、前の方に出てきたモックとスタブの組み合わせが使われています。 複雑なので、もう一度説明しましょう。

スタブの引数は、メソッド名、返り値になっています。 返り値には、Procオブジェクトなどを入れることができます。 返り値にはProcオブジェクトが返されるのではなく、Procオブジェクトのcallメソッドを実行した値が返されます。 また、このオブジェクトはProcオブジェクトでなくてもcallメソッドを持っていれば、同様にcallメソッドの実行結果を返してくれます。 そこで、モックのexpectメソッドでみせかけのメソッドcallを定義します。 すると、stubはモックのcallメソッドを呼び、expectで設定した返り値が返されます。 モックは複数回expectメソッドを使って、順に異なる返り値を設定できます。

  • モックオブジェクトを生成して変数mに代入する
  • mの最初の返り値を"abcd"(無効な入力)、2回めの返り値を"q"(有効な入力)とする
  • @in.inputを実行すると入力形式の案内が標準エラー出力に出るので、assert_outputでテストする
  • @in.inputの返り値は@resultに代入しておき、次の行でassert_equalでテストする

ここではモックを使って2回の呼び出しに対して異なる返り値を作成しました。 同じことはProcオブジェクトを使ってもできますし、むしろモックよりも複雑なことをできます。 モックで機能が足りないと思ったらProcオブジェクトを考えてみてください。

テストの実行結果は掲載しませんが、きちんとパスします。

スタブを使うのは複雑になりがちです。 それに対して、前のセクションでクラスのスタブを使ったように、Readlineのスタブを作る方法もあります。 これは、Readlineモジュールのreadlineメソッドをテスト用に再定義してしまう方法です。 こんなおそろしいことをして良いのかと思うかもしれませんが、Rubyでは珍しいことではありません。

require 'minitest/autorun'
require 'readline'
require 'stringio'
require_relative 'input.rb'

module Readline
  def self.readline(pronpt="> ", history=false)
    unless @stringio
      @stringio = StringIO.new("a append 付け足す\nd append\nc append 付け足す\np a..end\nq\nabcd\nq\n")
    end
    @stringio.readline.chomp
  end
end

class TestInput < Minitest::Test
  def test_input
    @in = Input.new
    assert_equal(['a', 'append', '付け足す'], @in.input)
    assert_equal(['d', 'append'], @in.input)
    assert_equal(['c', 'append', '付け足す'], @in.input)
    assert_equal(['p', 'a..end'], @in.input)
    assert_equal(['q'], @in.input)
    assert_output(nil, "(a|c) 英単語 日本語訳\nd 英単語\np 正規表現\nq\n"){ @result = @in.input }
    assert_equal(['q'], @result)
  end
end

Readlineモジュールの書き換えのためにrequire 'readline'が必要です。 readlineはReadlineの特異メソッドなので、def self.readlineとして再定義します。

文字列をファイルのように見立てるStringIOというクラスがあります。 このクラスにはreadlineメソッドがあり、文字列から1行ずつ返してくれます。 これがちょうどReadline.readlineの代わりに良いので、再定義の中で使います。 StringIOを使うにはrequire 'stringio'が必要です(ただ、このプログラムではminitestがrequireしているので、書かなくてもrequireされますが)。 はじめて呼ばれるときは@stringioが未定義なので、StringIOのインスタンスを代入します。 StringIO.newの引数が入力の元となる文字列です。 2度目の呼び出しではunlessのところを飛び越します。 @stringio.readlineによって、文字列から1行ずつ(つまり\nで区切られた文字列がひとつずつ)返されます。 Readline.readlineでは行末の改行が切られているので、chompメソッドで改行を落としておきます。

テスト本体ではassert_equalなどで順にInput#inputメソッド(Inputクラスのインスタンスメソッドinputをこのように書くことがあります。 これはドキュメントの中だけで、プログラム中で書くのではありません)をテストするだけです。 このプログラムではstubメソッドを使わずにReadline.readlineを書き換えています。 どちらが良いかは一概に言えませんが、今回のテストプログラムでは後者の方が分かりやすくすっきりとしています。

今回、非常に簡単なプログラムに対して難しいテストプログラムを書きましたが、これは正しい方法なのでしょうか? 私だったら、直接動かしてチェック(人手でチェック)します。 このような簡単で短いプログラムでは、その方が手っ取り早いからです。 今回はテストプログラムを書いたのは、あくまでスタブの説明のためです。

ただ、一般にはテストプログラムは必要で有効なことが多いです。

CSVクラス

CSVクラスはcsv(comma separated values)、コンマ区切りデータ形式を扱うクラスです。 IOクラスのように使え、かつコンマ区切りデータを扱えます。 コンマ区切りデータとはその名の通り、行の中でコンマで区切られたデータです。

pen,ペン
bread,パン

このように、各行には同じ数のコンマ区切りのデータがあります。 上記の例はRubyのデータ構造では次のようになります。

[["pen","ペン"], ["bread","パン"]]
  • CSVファイルの読み出しにはCSV.readを使う。 上記の例のようにCSVファイルの内容が2次元配列として返される。 1行目をヘッダ(タイトル行)とすることもできる。 その場合、引数にハッシュ{header: true}を入れる。 タイトルが無ければ{header: false}を用いる
  • 書き込みにはCSV.openと<<演算子を使う

次のプログラムは、CSVを使った読み書きの典型的な例です。

# 読み込み
array = CSV.read(CSVファイル名, headers: false)
# 書き出し
CSV.open(CSVファイル名) do |csv|
  array.each {|a| csv << a}
end

DBクラスでは単語帳のデータを2次元配列で表し、作業の開始、終了時点でCSVファイルに読み込み、書き出しをします。

db.rbとそのテスト

db.rbの内部ではデータを2次元配列インスタンス変数@dbに格納し、各メソッドで@dbにデータの付加、削除、変更、照会などをします。 プログラムは短く簡単です。

require "csv"

class DB
  def initialize(file='db.csv')
    @file = file
    if File.exist?(@file)
      @db = CSV.read(@file, headers: false)
    else
      @db = []
    end
  end
  def append(e,j)
    @db << [e,j]
  end
  def delete(e)
    i = @db.find_index{|d| e == d[0]}
    @db.delete_at(i) if i # i is nil if the search above didn't find e in @db.
  end
  def change(e,j)
    i = @db.find_index{|d| e == d[0]}
    if i
      @db[i] = [e,j]
    else
      @db <<[e,j]
    end
  end
  def list(e)
    pat = Regexp.compile(e)
    @db.select{|d| pat =~ d[0]}
  end
  def close
    CSV.open(@file, "wb") do |csv|
      @db.each {|x| csv << x}
    end
  end
end
  • initializeでCSVデータを読み込み、closeで書き出しをする
  • append、delete、change、listは2次元配列への追加、削除、変更、照会をする。
  • listメソッドでは引数を正規表現オブジェクトに変えてから(Regexp.compileメソッド)、それに一致するデータの配列を返す

このプログラムのテストは、2つに分かれます。

  • CSVファイルの入出力は、CSVクラスの仕事なので、それをテストの対象から除いた部分のテストをする
  • CSVファイルの入出力が上手くコントロールされているかの部分のテストをする

本来のテストは1番めだけで良いと思いますが、ここでは2番めもテストします。

require 'minitest/autorun'
require_relative 'db.rb'

class TestDB < Minitest::Test
  def test_db
    File.stub(:exist?, true) do
      CSV.stub(:read, [["pen","ペン"],["pencil","鉛筆"]]) do
        @db = DB.new
      end
    end
    assert_equal([["pen","ペン"]], @db.list("^pen$"))
    assert_equal([["pen","ペン"],["pencil","鉛筆"]], @db.list("pen"))
    @db.append("circle","")
    assert_equal([["circle",""]], @db.list("cir"))
    @db.change("circle","円周")
    assert_equal([["circle","円周"]], @db.list("cir"))
    @db.delete("pen")
    assert_equal([["pencil","鉛筆"], ["circle","円周"]], @db.list("."))
  end
  def test_csv
    File.write("test.csv",<<~CSV)
    pen,ペン
    pencil,鉛筆
    CSV
    @db = DB.new("test.csv")
    @db.append("circle","")
    @db.change("circle","円周")
    @db.delete("pen")
    @db.close
    assert_equal("pencil,鉛筆\ncircle,円周\n",File.read("test.csv"))
    File.delete("test.csv")
  end
end
  • test_dbメソッドでは、stubメソッドをネストして使い、ファイル入力の結果が[["pen","ペン"],["pencil","鉛筆"]]になるとしている
  • test_dbメソッドではlistメソッドで内容照会をし、assert_equalでテストする方法をとっている 他のappend、delete、changeについては、その実行後にlistメソッドを使い、正しく実行されているかをテストしている
  • test_csvメソッドではテスト用のCSVファイル「test.csv」を作り、append、delete、change、closeの後に「test.csv」がその作業を反映しているかどうかをテストする

実際にテストを実行してみると、すべてパスします。

wordtest.rbの実行

テストはすべて通ったので、wordtest.rbを実行してみました。 いくつか英単語と日本語訳を入力して、作成されたCSVファイルを見てみると、正しく反映されていました。 小さいプログラムですが、動くと嬉しいものです。 プログラムの今後の発展方向としては

  • 例文、備考などのフィールドを追加する
  • 単語テストのコマンドを作る(英=>日と日=>英の両方向のテスト)
  • CSVでなく、データベースを使う

などが考えられます。 ただ単語帳ソフトが本当に役立つプログラムなのかは疑問が残ります。 どうでしょうか? この問に対する答えは英語教育の専門家でなければ出せないでしょう。 一般に、プログラムが有用かどうかは開発者には分からないことが多いです。 その分野の専門家とソフト開発者の協力はとても大切なことです。

今回は実用には程遠い単語帳プログラムではありますが、開発とテストの実例として見てきました。 実際の開発はもっと規模が大きいですが、同様の手順、すなわちユニットごとに作成とテストを繰り返すことになります。 そのときには、minitestを有効に活用して開発を進めてください。

最後にminitestについて述べます。

minitestは高速です。 大きな開発で使うとそれがよく分かります。 なぜかというと複数のテストをマルチメソッドで並行して行うからです。 逆にこのことはテスト相互が独立していないとコンフリクトを起こす可能性があることを示唆しています。 プログラムの上から下へテストするのではなく、各メソッドは同時並行で非同期に進みます。

minitestはウェブ開発フレームワークRuby on Railsにおける標準のテストシステムになっています。 Railsでは、railsに合うようにminitestの機能を拡張しています。 詳しくはRails Guideを参照してください。 日本語訳もあります。

大きなプログラムのテストでは、Rakeを使ってテストを自動化することができます。 これについては、「はじめてのRake」に説明があります。

今回のテストをするためのRakefile

require "rake/testtask"

FileList['test*.rb'].each do |file|
  Rake::TestTask.new do |t|
    t.test_files = [file]
    t.verbose = false
  end
end

です。 コマンドラインから

$ rake test

とすると、すべてのテストが実行されます。 rakeに引数testが必要なことに注意してください(通常は引数なしでrakeを起動することが多いので)。