テスト駆動開発の基本

テスト駆動開発(Test Driven Development:TDD)とは

テストファーストによって開発を進める開発手法。

TDDのゴール

『動作するきれいなコード』

TDDが指す『テスト』の意味

TDDが指す『テスト』とは、開発者(プログラマ)が自分自身の開発を先に進めるため(開発を促進するため)に、自分自身で書いて行うテスト。

テストが原動力となって開発が進められるため、TDDと呼ばれる。

TDDの目的

TDDは開発手法であり、プログラミングを支援することを目的としている。具体的には次のものがある。

  • 素早いフィードバックの確保

    自身の作業が達成できていることをすぐにテストで確認できる。また、コード追加時にはテストをRedからGreenにすることで、実装が問題なく達成できることを確認しながら作業できる。

    リファクタリング(コード変更)時には、テスト結果がALL Greenを維持するようにすることで、意図しないバグ、副作用の混入を防止に貢献する。

  • 設計改善効果

    TDDでは、フィードバック以外にも、製品コード設計を改善する効果もある。

TDDの基本サイクル

TDDでは、Red, Green, Refactor の3つの作業サイクルを細かく繰り返してプログラミングを進める。

image.png

  1. Red

    失敗するテストを書く

  2. Green

    テストを成功させる最低限のコード書く

  3. Refactor

    コードをリファクタリング(外部から見た振る舞いを変えないままで、コード内部の設計をより良くすること)して綺麗にする

上記の手順1, 2 のサイクル(テストファーストによる追加・変更)を数十秒〜数十分の超短期間で実行していく。

手順3のリファクタリングは、テストファーストでプログラミングする中でソースコードが汚くなってきたら綺麗にする。リファクタリング時は、既存のテストケースが回帰テストとなり、ソフトウェアの機能品質を維持できる。

TDDのスキル

TDDを進めていく際には、次のことを意識する。

  • 問題を小さく分割する
  • 歩幅を状況に応じて調整する
    • テスト ⇨ 仮実装(とりあえず通す実装) ⇨ 三角測量(複数のパターンでテスト)⇨ 実装
    • テスト ⇨ 仮実装 ⇨ 実装
    • テスト ⇨ 明白な実装
  • テストの構造化とリファクタリング

参考

テスト技法

ソフトウェアテストの概要

ソフトウェアテストの目的

ソフトウェアテストの目的は、テスト対象の特徴に合わせてテストケースを組み、いろいろなテストを繰り返して不具合を見つけ出し、ユーザーにとって有用なソフトウェアとなること。

有用なソフトウェアは、要は『システムやサービスを使う人の要求をどれだけ満足させられるか』ということで、『お客様の満足度』と言い換えられる。よって、お客様の立場によって、求められる要求や考え方は多々ある。

そこで、一定の基準として、ソフトウェア品質の評価については ISO/IEC 2510:2011 で8つの品質特性が標準化されている。

品質特性 概要
機能適合性 実装された機能がニーズを満たす度合い
性能効率性 システムの実行時の性能や資源効率の度合い
互換性 他製品やシステムと機能や情報を共有・変換できる度合い
使用性 効果的、効率的に利用できる度合い
信頼性 必要時に実行できる度合い
セキュリティ 不正に悪用されることなく、情報やデータが保護される度合い
保守性 効果的、効率的に保守や修正ができる度合い
移植性 効果的、効率的に他のハードウェアや実行環境に移植できる度合い

ソフトウェアテストの種類

上記のソフトウェアの品質特性を測るテストに次のものがある。

テスト名 概要
機能テスト ソフトウェアの搭載する機能が要求通りかを確認する
性能テスト ユーザが快適に使用できるかを確認する(レスポンスの早さ等)
負荷テスト ソフトウェアに負荷をかけて許容限界値を確認する
ユーザビリティテスト ユーザが使いやすいと感じるかどうかを確認する
セキュリティテスト サイバーリスクに対するセキュリティ強度を測定する

ホワイトボックステスト

ホワイトボックステストは、テスト対象の構造に着目してテストケースを作成する技法。設計や実装の内容から内部構造(処理経路)を網羅するようにテストケースを作成する。

この時の網羅度合についての基準をカバレッジ(網羅率)といい、ホワイトボックステストでは、目標とするカバレッジを満たすようにテストケースを設計していく。

カバレッジには次のような種類がある。

カバレッジ 概要
カバレッジ 全てのコード行のうち、テストで実行された行の割合
ステートメントカバレッジ(C0:命令網羅率) 全ての実行可能命令(ステートメント)のうち、テストで実行された命令の割合
ブロックカバレッジ 全てのブロックのうち、テストで実行されたブロックの割合
パスカバレッジ 全てのパス(処理経路)のうち、テストで実行されたパスの割合
判断文カバレッジ(ブランチカバレッジ、C1:分岐網羅率) 全ての判定条件のうち、テストで実行された判定条件の割合。判断文全体が真と偽の両方の値を取ルようにする。
単純条件カバレッジ(C2:条件網羅率) 全ての条件のうち、テストで実行された条件の割合。C1と違い、個々の条件が真と偽の両方の値を取る必要がある。
Modified Condition/Decision Coverage (MC/DC)カバレッジ 次を満たすようにすると網羅率が100%となる。
a.各「判断文」が、少なくとも1回すべての可能な結果を得ている。
b.1つの「判断文」中の各条件が、少なくとも1回すべての可能な結果を得ている。
c.1つの「判断文」中の個々の条件が、単独で全体の「判断文」の結果を左右する。
関数カバレッジ 全ての関数のうち、テストで最低1回実行された関数の割合
コールカバレッジ 全ての関数呼び出しのうち、テストで呼び出された関数の割合

ブラックボックステスト

ブラックボックステストは、『テスト対象の仕様』に基づいたテストケース作成技法。テスト対象をブラックボックス(中の見えない箱)として捉え、仕様を基にテストケースを作成する。テスト対象の実装には興味がなく、ある入力に対してどのような出力となるかをテストする。

ブラックオックステストでよく使われるのは次の2つ。

法名 概要
同値分割法 データから仕様を基に意味あるグループ同値クラス)に分類し、各グループから代表値を選ぶ技法。これにより、似通った意味ばかりのテストケースになることを防いだり、意味あるデータのテスト漏れを予防できる。
境界値分析 同値クラスごとの境界値をテストデータとして選択する技法。

同値分割

同値分割の作業は大きく下記5つの手順。

  1. 同値クラスを作る

    入力をグループ分けすること。グループ分けは、複数のある基準(観点)によって行う。

  2. 同値クラスを分類する

    グループ分けされた同値クラスを、有効同値クラスと呼ばれる正常系、無効同値クラスと呼ばれる異常系、準正常系に分類する。

  3. 代表値を決定する

    それぞれの観点ごとに、具体的な数値であったり文字列等の代表値を決定する。

  4. テスト条件を決定する

    観点が1つの場合は、決定した代表値をテスト条件とする。観点が複数のテストケースは、無効同値クラスが重複しないようにテスト条件の組み合わせて、テストケースとする。

  5. テスト条件を見直す

    手順1~4で作成したテストケースをセルフレビューやペアレビュー等で見直し、必要なら手順1~4を繰り返す。

境界値分割

同値分割で得られた同値クラスの境界や端に注目してテストケースを考える技法。

デシジョンテーブル

デシジョンテーブルとは論理関係を表形式で整理するためのツールで、行方向に条件と動作、列方向にルールの組合せを示す。

デシジョンテーブルは決定表(JIS X 0125)として規格化されている。表形式で表現することで、プログラムの処理条件やポリシーなどがわかりやすしたり、テスト条件を整理する時に利用する。

デシジョンテーブルは、具体例を見た方がわかりやすいので、参考サイトの例を下記に示す。

例:健康ランドの精算システムにおける、「割引サービス」に関する仕様

[既存の仕様]

  • クーポン持参:10%OFF
  • 平日割引:30%OFF
  • 平日シニア割引(65歳以上):50%OFF
  • 2つ以上の割引サービスが重なった場合は、割引率が高い方が優先される

現在の仕様をデシジョンテーブルで表すと次のようになる。

image.png

[新たに追加される仕様]

  • 土日祝ジュニア割引(15歳以下):20%OFF

仕様が追加されたデシジョンテーブルを表す。条件が成立しない組み合わせについては、N/Aとする。

image.png

第5回 仕様整理に「デシジョンテーブル」を使ってみよう | gihyo.jp

デシジョンテーブルを作成するときの手順は次のもの。

  1. 分析対象・テスト対象の持つ条件・原因を洗い出し、それぞれを行として追加する
  2. 処理・動作・結果を洗い出し、それぞれ行として追加する
  3. 起こりうる条件・原因の組合せを作成し、それぞれ列として追加する
  4. 作成した列のうち、集約可能な列の組を圧縮する
  5. 組合せの作成と圧縮についての検算する

参考

Ruby on Rails:save と save! メソッドの違い

savesave! は、インスタンスを生成した後に使う。どちらもデータのバリデーション(データベースに保存する前に、データ自体の妥当性を検証する仕組み)と、データベースへの保存を行うメソッド。

  def create
  @report = current_user.reports.new(report_params)

  respond_to do |format|
    if @report.save
      format.html { redirect_to report_url(@report) }
    else
      format.html { render :new }
    end
  end
end

違いをまとめると、下記のようになる。

項目 保存成功時の戻り値 保存失敗時の戻り値
save true false
save! true 例外

上記の通り、if/else で処理を分けたい時には save メソッドを用いる。

一方、save! メソッドでは、失敗時に例外が発生するため、保存できない場合の処理は rescue 節で対処する。

Ruby on Rails:ポリモーフィック関連付け

下記のようなモデルを実装するとする。

image.png

上記は、bookモデルとreportモデルはそれぞれcommentモデルと関連があり(本と報告書に対してコメントする機能)、commentは共通モデルで対応することを想定している。

そんな時に使えるのが、ポリモーフィック関連付け。

ポリモーフィック関連づけ

ポリモーフィック関連付けを使うと、次のことができるようになる。

ある1つのモデルが、他の複数のモデルに属していることを1つの関連付けで表現する

ポリモーフィック関連付け

上で検討したER図では、bookモデルとreportモデルは共通のcommentモデルで関連を持ちたいので、ポリモーフィック関連付けの出番ということになる。

commentモデルをbookモデルとreportモデルの両方に従属させたい時には、モデルを次のように宣言する。

class Comment < ApplicationRecord
  belongs_to :imageable, polymorphic: true
end

class Book < ApplicationRecord
  has_many :comments, as: :imageable
end

class Report < ApplicationRecord
  has_many :comments, as: :imageable
end

belongs_toポリモーフィックで宣言すると、任意のモデルから利用可能なインタフェースとして設定したとみなせる。

これのおかげで、@book.comments@report.comments といった形で、各モデルのインスタンスから、コメントを参照できるようになる。

ポリモーフィックなモデルを生成する

上で示したモデルを作ってみる。下記コマンドでマイグレーションファイルのテンプレートを作成する。

% rails g migration CreateComments

次にマイグレーションファイルに、属性、ポリモーフィックと外部キーを追加する。

class CreateComments < ActiveRecord::Migration[6.1]
  def change
    create_table :comments do |t|
      t.text  :content
      t.references :imageable, polymorphic: true
      t.belongs_to :user, foreign_key: true

      t.timestamps
    end
  end
end

マイグレーションファイルを作ったら、モデルを生成する。

% rails db:migrate

次に、コントローラの設定をしていく。

# app/models/comment.rb
class Comment < ApplicationRecord
  ...
  belongs_to :user # 外部キー用
  belongs_to :imageable, polymorphic: true # ポリモーフィック用
  ...
end

# app/models/book.rb
class Book < ApplicationRecord
  ...
  has_many :comments, as: :imageable # ポリモーフィック用
  ...
end

# app/models/report.rb
class Report < ApplicationRecord
  ...
  has_many :comments, as: :imageable # ポリモーフィック用
  ...
end

# app/models/user.rb
class User < ApplicationRecord
  ...
  has_many :comments, dependent: :destroy # ユーザ消えたらコメントも削除
  ...
end

試しに操作してみる。

% rails c
irb(main):001:0> book = Book.first
   (1.0ms)  SELECT sqlite_version(*)
  Book Load (0.1ms)  SELECT "books".* FROM "books" ORDER BY "books"."id" ASC LIMIT ?  [["LIMIT", 1]]      
irb(main):002:0> book.comments.create(id:1,content:"test",user_id: 1)
  TRANSACTION (0.1ms)  begin transaction
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]                              
  Comment Create (1.3ms)  INSERT INTO "comments" ("id", "content", "imageable_type", "imageable_id", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?, ?, ?)  [["id", 1], ["content", "test"], ["imageable_type", "Book"], ["imageable_id", 1], ["user_id", 1], ["created_at", "2022-09-08 14:13:47.757347"], ["updated_at", "2022-09-08 14:13:47.757347"]]
  TRANSACTION (0.5ms)  commit transaction

作ったデータを確認してみる。

% rails db
sqlite> select * from comments;
1|test|Book|1|1|2022-09-08 14:13:47.757347|2022-09-08 14:13:47.757347

参考

Ruby on Rails:belongs_to, has_many で関連付けを行う

ターゲットするER図

image.png

belongs_to, has_many で関連付けを行う

上記のER図において、『日報は投稿者しか更新削除できない』というようにしたい。そのためには日報にユーザ情報が必要なので、日報テーブルにユーザIDの外部キーを導入する。

以下では、日報テーブルにユーザIDの外部キー以外を登録してあったテーブルに対して、外部キーを追加する流れを記述。

と言うわけで、日報テーブルにユーザIDカラムを追加するために、まずはマイグレーションファイルを rails g コマンドで大枠を作る。

rails g migration AddUserIdToReports

生成されたマイグレーションファイルに、ユーザーIDの外部キーを追加するコードを書く。

class AddUserIdToReports < ActiveRecord::Migration[6.1]
  def change
    add_reference :reports, :user, foreign_key: true
  end
end

そして、データベースを移植する。

% rails db:migrate
Running via Spring preloader in process 87420
== 20220906140109 AddUserIdToReports: migrating ===============================
-- add_reference(:reports, :user, {:foreign_key=>true})
   -> 0.0069s
== 20220906140109 AddUserIdToReports: migrated (0.0069s) ======================

ここまでで、ユーザID用を追加した日報テーブルが用意できた。次は、日報とユーザの関連を定義するために、日報モデルに belongs_to で関連付けを行う。 1つの日報は、特定のユーザに紐づくため :user は単数系。

# app/model/report.rb
class Report < ApplicationRecord
  belongs_to :user
end

関連付けを行うことで、ユーザIDがないと日報が作れないようになる。

image.png

次は、ユーザ情報を日報インスタンスに紐づける必要がある。

試しにコンソール上で少し試してみる。下記のように、現状はユーザインスタンスから日報メソッドを操作することができない。

image.png

紐づいているユーザで日報を作成するために、 has_many で複数所有の関連付けを行う。ここで、ユーザが消滅したら日報も同時に消滅させるために、:destroy タグをつけておく。ちなみに複数所有するので、 reports と複数形になっている。

# app/models/user.rb
...
has_many :reports, dependent: :destroy
...

上記の設定で、ユーザインスタンスを通して日報メソッドを操作できるようになった。

image.png

ここまでできたら、最後はユーザインスタンスを通して、日報オブジェクト生成をしてあげられるようにする。

ビューで呼び出される create メソッドを下記のように変更する。

# app/controllers/reports_controller.rb
...
# POST /reports or /reports.json
  def create
    # 変更前
    @report = Report.new(report_params)
    # 変更後
    @report = current_user.reports.build(report_params) 
    # 変更後(new も使えるらしい。が、慣習的にbuildを使うらしい)
    # @report = current_user.reports.new(report_params)
...

試しに入力する。

image.png

無事完了🤗

あとは、投稿したユーザのみ日報を更新・削除できると言う部分について。

日報一覧と日報詳細ビューに if report.user == current_user みたいな感じで、ログインユーザとレポートユーザが一致するとき、更新・削除ボタンを表示するに変更した。

image.png

参考

Ruby on Rails:belongs_to, has_many で関連付けを行う

ターゲットするER図

image.png

belongs_to, has_many で関連付けを行う

上記のER図において、『日報は投稿者しか更新削除できない』というようにしたい。そのためには日報にユーザ情報が必要なので、日報テーブルにユーザIDの外部キーを導入する。

以下では、日報テーブルにユーザIDの外部キー以外を登録してあったテーブルに対して、外部キーを追加する流れを記述。

と言うわけで、日報テーブルにユーザIDカラムを追加するために、まずはマイグレーションファイルを rails g コマンドで大枠を作る。

rails g migration AddUserIdToReports

生成されたマイグレーションファイルに、ユーザーIDの外部キーを追加するコードを書く。

class AddUserIdToReports < ActiveRecord::Migration[6.1]
  def change
    add_reference :reports, :user, foreign_key: true
  end
end

そして、データベースを移植する。

% rails db:migrate
Running via Spring preloader in process 87420
== 20220906140109 AddUserIdToReports: migrating ===============================
-- add_reference(:reports, :user, {:foreign_key=>true})
   -> 0.0069s
== 20220906140109 AddUserIdToReports: migrated (0.0069s) ======================

ここまでで、ユーザID用を追加した日報テーブルが用意できた。次は、日報とユーザの関連を定義するために、日報モデルに belongs_to で関連付けを行う。 1つの日報は、特定のユーザに紐づくため :user は単数系。

# app/model/report.rb
class Report < ApplicationRecord
  belongs_to :user
end

関連付けを行うことで、ユーザIDがないと日報が作れないようになる。

image.png

次は、ユーザ情報を日報インスタンスに紐づける必要がある。

試しにコンソール上で少し試してみる。下記のように、現状はユーザインスタンスから日報メソッドを操作することができない。

image.png

紐づいているユーザで日報を作成するために、 has_many で複数所有の関連付けを行う。ここで、ユーザが消滅したら日報も同時に消滅させるために、:destroy タグをつけておく。ちなみに複数所有するので、 reports と複数形になっている。

# app/models/user.rb
...
has_many :reports, dependent: :destroy
...

上記の設定で、ユーザインスタンスを通して日報メソッドを操作できるようになった。

image.png

ここまでできたら、最後はユーザインスタンスを通して、日報オブジェクト生成をしてあげられるようにする。

ビューで呼び出される create メソッドを下記のように変更する。

# app/controllers/reports_controller.rb
...
# POST /reports or /reports.json
  def create
    # 変更前
    @report = Report.new(report_params)
    # 変更後
    @report = current_user.reports.build(report_params) 
    # 変更後(new も使えるらしい。が、慣習的にbuildを使うらしい)
    # @report = current_user.reports.new(report_params)
...

試しに入力する。

image.png

無事完了🤗

あとは、投稿したユーザのみ日報を更新・削除できると言う部分について。

日報一覧と日報詳細ビューに if report.user == current_user みたいな感じで、ログインユーザとレポートユーザが一致するとき、更新・削除ボタンを表示するに変更した。

image.png

参考

Ruby on Rails:N+1問題とは

N+1問題は、SQLクエリを大量に発行してしまう事象のことを言う。例えば下のようなWebサイトがあるとする。Webサイトのデータは、ユーザ情報のテーブルと、ユーザIDと画像を紐づけた画像テーブルの2つから構成される。

image.png

上記のサイトにアクセスすると、下記のようなSQLクエリでデータベースからデータを取得する。下記に示したように、当該ページに表示するユーザ数の数だけ、SQLクエリが投げられている。

image.png

テーブル数が少ないうちは問題にならないけれど、レコード数が増えてくると深刻なパフォーマンス問題を引き起こすらしい。こうした挙動のことを『N+1問題』という。

原因は、データを都度取得している点にある。下記は上記Webサイトのコードで、ループ文の中で画像テーブルにアクセスする処理を逐次実行してしまっているため、SQLクエリがループの数だけ投げられる。

image.png

対処方法としては、テーブルデータをまとめて取得してしまえば、SQLクエリが大量に発行されることにはならない。よって、N+1問題には、データをまとめて引っ張ってこれるようにすることが対処法となる。方法にはいくつか方法あり、例えば次のものがある。

  • JOIN:テーブルを結合してしまう
  • Eagar Load:ある程度まとめてSELECTする

今回は、Eagar Load で対処する。

コードに includes メソッドを追加して、一度にデータを取得するようにする。

image.png

再度ブラウザにアクセスして、ログを確認する。

image.png

上記の通り、SQLをまとめて実行できるようになる。

また、ActiveStorageには、『N+1問題』に対応する方法として、標準で用意されたメソッドがある。ActiveStorage::Attachment

上のコードの @users.includes(:avatar_attachment).each の部分を @users.all.with_attached_avatar.each にすると、Eagar Load の効果を得られる。